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"))))));
+ }
+}