diff --git a/ArIED61850Tester.csproj b/ArIED61850Tester.csproj index e8afcea4c..a5b22a41b 100644 --- a/ArIED61850Tester.csproj +++ b/ArIED61850Tester.csproj @@ -28,6 +28,10 @@ ..\ARIEC61850\src\AR.Iec61850\AR.Iec61850.csproj $(ARIEC61850_NPCAP_PROJECT) ..\ARIEC61850\src\AR.Iec61850.Transports.Npcap\AR.Iec61850.Transports.Npcap.csproj + $(MSBuildProjectDirectory)\engines\ARIEC61850.lock.json + $(MSBuildProjectDirectory)\scripts\validate-ariec61850-lock.ps1 + powershell + pwsh @@ -68,5 +72,15 @@ Text="ARIEC61850 engine project was not found at '$(ArIec61850Project)'. Place this folder beside the ARIEC61850 repository, or build with -p:ArIec61850Project=FULL_PATH_TO_AR.Iec61850.csproj." /> + + + + + + diff --git a/MainWindow.IoTesting.cs b/MainWindow.IoTesting.cs index 7ca7eb2cc..db523151f 100644 --- a/MainWindow.IoTesting.cs +++ b/MainWindow.IoTesting.cs @@ -47,7 +47,14 @@ private void InstallFirstRunTestingChoices() if (emptyState?.Child is not Grid heroGrid) return; - var generalTestingCard = heroGrid.Children.OfType().SingleOrDefault(); + // The hero can contain visual-only Borders (for example P2IndustrialHeroTint). + // Launcher discovery must identify the operational card by content contract rather + // than asserting that the visual tree contains exactly one Border. + var generalTestingCard = heroGrid.Children + .OfType() + .FirstOrDefault(border => + !Equals(border.Tag, "P2IndustrialHeroTint") && + border.Child is StackPanel); if (generalTestingCard?.Child is not StackPanel generalContent) return; diff --git a/SignalSelectionWizardWindow.xaml.cs b/SignalSelectionWizardWindow.xaml.cs index f82e3c754..b82f64ba5 100644 --- a/SignalSelectionWizardWindow.xaml.cs +++ b/SignalSelectionWizardWindow.xaml.cs @@ -69,7 +69,13 @@ public SignalSelectionWizardWindow(Iec61850MonitorDevice device, int restoredSel foreach (var signal in device.Signals) { - signal.DisplayReference = Iec61850MonitorPoint.StripIedNamePrefix(signal.ObjectReference, device.Name); + // Static DataSet inventory owns DisplayReference. Only legacy/non-DataSet rows + // that have no display identity may fall back to a shortened runtime reference. + if (string.IsNullOrWhiteSpace(signal.DisplayReference)) + { + signal.DisplayReference = + Iec61850MonitorPoint.StripIedNamePrefix(signal.ObjectReference, device.Name); + } signal.PropertyChanged += Signal_PropertyChanged; } diff --git a/scripts/validate-ariec61850-lock.ps1 b/scripts/validate-ariec61850-lock.ps1 new file mode 100644 index 000000000..42fac2e5a --- /dev/null +++ b/scripts/validate-ariec61850-lock.ps1 @@ -0,0 +1,79 @@ +param( + [Parameter(Mandatory = $true)] + [string]$LockPath, + + [Parameter(Mandatory = $true)] + [string]$EngineProject, + + [Parameter(Mandatory = $true)] + [string]$ProjectRoot +) + +$ErrorActionPreference = 'Stop' + +function Resolve-FullPath { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$BasePath + ) + + if ([System.IO.Path]::IsPathRooted($Path)) { + return [System.IO.Path]::GetFullPath($Path) + } + + return [System.IO.Path]::GetFullPath((Join-Path $BasePath $Path)) +} + +$projectRootFull = Resolve-FullPath -Path $ProjectRoot -BasePath (Get-Location).Path +$lockFull = Resolve-FullPath -Path $LockPath -BasePath $projectRootFull +$engineProjectFull = Resolve-FullPath -Path $EngineProject -BasePath $projectRootFull + +if (-not (Test-Path -LiteralPath $lockFull -PathType Leaf)) { + throw "ARIEC61850 integration lock was not found at '$lockFull'." +} + +if (-not (Test-Path -LiteralPath $engineProjectFull -PathType Leaf)) { + throw "ARIEC61850 engine project was not found at '$engineProjectFull'." +} + +$lock = Get-Content -LiteralPath $lockFull -Raw | ConvertFrom-Json +$expected = ([string]$lock.commit).Trim().ToLowerInvariant() +if ([string]::IsNullOrWhiteSpace($expected) -or $expected.Length -lt 7) { + throw "ARIEC61850 integration lock '$lockFull' does not contain a valid commit SHA." +} + +$engineProjectDirectory = Split-Path -Parent $engineProjectFull +$repoRoot = (& git -C $engineProjectDirectory rev-parse --show-toplevel 2>$null) +if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($repoRoot)) { + throw "ARIEC61850 project '$engineProjectFull' is not inside a readable Git checkout; ARSAS cannot prove which engine source is being compiled." +} +$repoRoot = $repoRoot.Trim() + +$actual = (& git -C $repoRoot rev-parse HEAD 2>$null) +if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($actual)) { + throw "ARSAS could not read the ARIEC61850 Git revision from '$repoRoot'." +} +$actual = $actual.Trim().ToLowerInvariant() + +if ($actual -ne $expected) { + $message = @" +ARSAS refused to compile against an unpinned ARIEC61850 engine. +Expected by: $lockFull +Expected SHA: $expected +Actual SHA : $actual +Engine repo : $repoRoot + +Synchronize the engine checkout first, for example: + git -C "$repoRoot" fetch origin + git -C "$repoRoot" switch main + git -C "$repoRoot" pull --ff-only + git -C "$repoRoot" rev-parse HEAD + +If main intentionally moved beyond the ARSAS lock, update engines/ARIEC61850.lock.json through a reviewed ARSAS change instead of compiling an arbitrary engine revision. +"@ + throw $message +} + +Write-Host "ARIEC61850 lock verified: $actual" diff --git a/tests/ARSAS.Tests/ArIec61850PinRegressionTests.cs b/tests/ARSAS.Tests/ArIec61850PinRegressionTests.cs new file mode 100644 index 000000000..09772ab6c --- /dev/null +++ b/tests/ARSAS.Tests/ArIec61850PinRegressionTests.cs @@ -0,0 +1,54 @@ +using System.Reflection; +using System.Text.Json; +using AR.Iec61850.Discovery; + +namespace ARSAS.Tests; + +public sealed class ArIec61850PinRegressionTests +{ + [Fact] + public void LoadedEngineAssembly_MatchesArsasIntegrationLock() + { + var lockPath = FindRepoFile("engines/ARIEC61850.lock.json"); + using var document = JsonDocument.Parse(File.ReadAllText(lockPath)); + var expected = document.RootElement.GetProperty("commit").GetString(); + + Assert.False(string.IsNullOrWhiteSpace(expected)); + + var informationalVersion = typeof(Iec61850DataSetSignalInventoryProjection) + .Assembly + .GetCustomAttribute()? + .InformationalVersion ?? string.Empty; + + Assert.Contains(expected!, informationalVersion, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ApplicationBuild_ValidatesSiblingEngineGitHeadBeforeProjectReferences() + { + var project = File.ReadAllText(FindRepoFile("ArIED61850Tester.csproj")); + var validator = File.ReadAllText(FindRepoFile("scripts/validate-ariec61850-lock.ps1")); + + Assert.Contains("ValidateArIec61850Revision", project, StringComparison.Ordinal); + Assert.Contains("BeforeTargets=\"ResolveProjectReferences\"", project, StringComparison.Ordinal); + Assert.Contains("validate-ariec61850-lock.ps1", project, StringComparison.OrdinalIgnoreCase); + Assert.Contains("rev-parse HEAD", validator, StringComparison.Ordinal); + Assert.Contains("$actual -ne $expected", validator, StringComparison.Ordinal); + Assert.Contains("refused to compile against an unpinned ARIEC61850 engine", validator, StringComparison.Ordinal); + } + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + + throw new FileNotFoundException( + $"Could not locate repository file '{relativePath}' from '{AppContext.BaseDirectory}'."); + } +} diff --git a/tests/ARSAS.Tests/FirstRunLauncherRegressionTests.cs b/tests/ARSAS.Tests/FirstRunLauncherRegressionTests.cs index 5e16ace45..aa32e8f5c 100644 --- a/tests/ARSAS.Tests/FirstRunLauncherRegressionTests.cs +++ b/tests/ARSAS.Tests/FirstRunLauncherRegressionTests.cs @@ -3,18 +3,14 @@ namespace ARSAS.Tests; public sealed class FirstRunLauncherRegressionTests { [Fact] - public void FirstRunLauncher_RemovesDecorativeBorderSynchronouslyBeforeQueuedDiscovery() + public void FirstRunLauncher_SelectsOperationalCardWithoutAssumingSingleBorder() { - var source = File.ReadAllText(FindRepoFile("MainWindow.FirstRunLauncherRepair.cs")); - - var loadedHandler = source.IndexOf("private static void FirstRunLauncherRepair_Loaded", StringComparison.Ordinal); - var removeBeforeQueue = source.IndexOf("window.RemoveFirstRunHeroTintBeforeLauncherDiscovery();", loadedHandler, StringComparison.Ordinal); - var queueContextIdle = source.IndexOf("window.Dispatcher.BeginInvoke", loadedHandler, StringComparison.Ordinal); + var source = File.ReadAllText(FindRepoFile("MainWindow.IoTesting.cs")); - Assert.True(loadedHandler >= 0); - Assert.True(removeBeforeQueue > loadedHandler); - Assert.True(queueContextIdle > removeBeforeQueue); - Assert.Contains("before that queued callback can execute SingleOrDefault", source, StringComparison.Ordinal); + Assert.DoesNotContain("heroGrid.Children.OfType().SingleOrDefault()", source, StringComparison.Ordinal); + Assert.Contains("P2IndustrialHeroTint", source, StringComparison.Ordinal); + Assert.Contains("border.Child is StackPanel", source, StringComparison.Ordinal); + Assert.Contains("FirstOrDefault(border =>", source, StringComparison.Ordinal); } [Fact] diff --git a/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs b/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs index fe6a15ba1..1b18e004c 100644 --- a/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs +++ b/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs @@ -19,6 +19,25 @@ public void DeviceInventoryMerge_FallsBackToOfflineSclDesignModel() StringComparison.Ordinal); } + [Fact] + public void SignalSelectionConstructor_PreservesExistingStaticFcdaDisplayIdentity() + { + var source = File.ReadAllText(FindRepoFile("SignalSelectionWizardWindow.xaml.cs")); + + Assert.Contains( + "if (string.IsNullOrWhiteSpace(signal.DisplayReference))", + source, + StringComparison.Ordinal); + Assert.DoesNotContain( + "signal.DisplayReference = Iec61850MonitorPoint.StripIedNamePrefix(signal.ObjectReference, device.Name);\n signal.PropertyChanged", + source, + StringComparison.Ordinal); + Assert.Contains( + "Static DataSet inventory owns DisplayReference", + source, + StringComparison.Ordinal); + } + [Fact] public void SignalSelectionRecovery_DoesNotOverwriteStaticFcdaDisplayIdentity() { diff --git a/tests/ARSAS.Tests/SiemensStaticDataSetSelectionRegressionTests.cs b/tests/ARSAS.Tests/SiemensStaticDataSetSelectionRegressionTests.cs new file mode 100644 index 000000000..538b4c028 --- /dev/null +++ b/tests/ARSAS.Tests/SiemensStaticDataSetSelectionRegressionTests.cs @@ -0,0 +1,146 @@ +using System.Xml.Linq; +using AR.Iec61850.Scl.Engineering; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class SiemensStaticDataSetSelectionRegressionTests +{ + [Fact] + public void SiemensLike_58Member_CrossLd_StaticDataSets_AreRepresented58Of58() + { + var model = SclLiveModelProjectionBuilder.Build(BuildFixture(), "Siprotec_58_member.cid"); + var signals = new List(); + + var merge = Iec61850DataSetSignalInventoryService.EnsureMandatorySignals(signals, model); + var snapshot = Iec61850DataSetCompletenessDiagnostic.Evaluate(model, signals); + + Assert.Equal(2, model.DataSets.Count); + Assert.Equal(58, model.DataSets.Sum(dataSet => dataSet.Members.Count)); + Assert.Equal(58, merge.MandatoryCatalogCount); + Assert.Equal(58, signals.Count); + Assert.Equal(58, snapshot.StaticMemberCount); + Assert.Equal(58, snapshot.MandatoryInventoryCount); + Assert.Equal(58, snapshot.RepresentedCount); + Assert.Equal(0, snapshot.MissingCount); + Assert.True(snapshot.IsComplete); + + Assert.Contains(signals, signal => signal.DisplayReference == "AA1C1F13R4ADD/GGIO1.Dig01"); + Assert.Contains(signals, signal => signal.DisplayReference == "AA1C1F13R4MEAS/MMXU1.Ana22"); + Assert.All(signals, signal => + { + Assert.DoesNotContain("[ST]", signal.DisplayReference, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("[MX]", signal.DisplayReference, StringComparison.OrdinalIgnoreCase); + }); + } + + private static XDocument BuildFixture() + { + XNamespace ns = "http://www.iec.ch/61850/2003/SCL"; + + var digitalDataSet = new XElement(ns + "DataSet", new XAttribute("name", "Digital")); + foreach (var index in Enumerable.Range(1, 36)) + { + digitalDataSet.Add(new XElement(ns + "FCDA", + new XAttribute("ldInst", "ADD"), + new XAttribute("lnClass", "GGIO"), + new XAttribute("lnInst", "1"), + new XAttribute("doName", $"Dig{index:00}"), + new XAttribute("fc", "ST"))); + } + + var analogDataSet = new XElement(ns + "DataSet", new XAttribute("name", "Analog")); + foreach (var index in Enumerable.Range(1, 22)) + { + analogDataSet.Add(new XElement(ns + "FCDA", + new XAttribute("ldInst", "MEAS"), + new XAttribute("lnClass", "MMXU"), + new XAttribute("lnInst", "1"), + new XAttribute("doName", $"Ana{index:00}"), + new XAttribute("fc", "MX"))); + } + + var ggioType = new XElement(ns + "LNodeType", + new XAttribute("id", "GGIOType"), + new XAttribute("lnClass", "GGIO")); + foreach (var index in Enumerable.Range(1, 36)) + { + ggioType.Add(new XElement(ns + "DO", + new XAttribute("name", $"Dig{index:00}"), + new XAttribute("type", "SpsType"))); + } + + var mmxuType = new XElement(ns + "LNodeType", + new XAttribute("id", "MMXUType"), + new XAttribute("lnClass", "MMXU")); + foreach (var index in Enumerable.Range(1, 22)) + { + mmxuType.Add(new XElement(ns + "DO", + new XAttribute("name", $"Ana{index:00}"), + new XAttribute("type", "MvType"))); + } + + return new XDocument( + new XElement(ns + "SCL", + new XAttribute("version", "2007"), + new XAttribute("revision", "B"), + new XElement(ns + "Header", new XAttribute("id", "SIEMENS_58")), + new XElement(ns + "IED", + new XAttribute("name", "AA1C1F13R4"), + new XElement(ns + "AccessPoint", + new XAttribute("name", "E"), + new XElement(ns + "Server", + new XElement(ns + "LDevice", + new XAttribute("inst", "Application"), + new XElement(ns + "LN0", + new XAttribute("lnClass", "LLN0"), + new XAttribute("lnType", "LLN0Type"), + analogDataSet, + digitalDataSet)), + new XElement(ns + "LDevice", + new XAttribute("inst", "ADD"), + new XElement(ns + "LN0", + new XAttribute("lnClass", "LLN0"), + new XAttribute("lnType", "LLN0Type")), + new XElement(ns + "LN", + new XAttribute("lnClass", "GGIO"), + new XAttribute("inst", "1"), + new XAttribute("lnType", "GGIOType"))), + new XElement(ns + "LDevice", + new XAttribute("inst", "MEAS"), + new XElement(ns + "LN0", + new XAttribute("lnClass", "LLN0"), + new XAttribute("lnType", "LLN0Type")), + new XElement(ns + "LN", + new XAttribute("lnClass", "MMXU"), + new XAttribute("inst", "1"), + new XAttribute("lnType", "MMXUType")))))), + new XElement(ns + "DataTypeTemplates", + new XElement(ns + "LNodeType", + new XAttribute("id", "LLN0Type"), + new XAttribute("lnClass", "LLN0")), + ggioType, + mmxuType, + new XElement(ns + "DOType", + new XAttribute("id", "SpsType"), + new XAttribute("cdc", "SPS"), + new XElement(ns + "DA", + new XAttribute("name", "stVal"), + new XAttribute("bType", "BOOLEAN"), + new XAttribute("fc", "ST"))), + new XElement(ns + "DOType", + new XAttribute("id", "MvType"), + new XAttribute("cdc", "MV"), + new XElement(ns + "DA", + new XAttribute("name", "mag"), + new XAttribute("bType", "Struct"), + new XAttribute("type", "AnalogueValue"), + new XAttribute("fc", "MX"))), + new XElement(ns + "DAType", + new XAttribute("id", "AnalogueValue"), + new XElement(ns + "BDA", + new XAttribute("name", "f"), + new XAttribute("bType", "FLOAT32")))))); + } +}