From 404ff8ae547211afee627173956409aa0756f201 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 15:16:39 +0700 Subject: [PATCH 01/10] build: fail when local ARIEC checkout does not match lock --- scripts/validate-ariec61850-lock.ps1 | 79 ++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 scripts/validate-ariec61850-lock.ps1 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" From f72f2551450782a04adaf600275c53e7f6163fab Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 15:16:58 +0700 Subject: [PATCH 02/10] build: enforce pinned ARIEC revision locally --- ArIED61850Tester.csproj | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/ArIED61850Tester.csproj b/ArIED61850Tester.csproj index e8afcea4c..c74539116 100644 --- a/ArIED61850Tester.csproj +++ b/ArIED61850Tester.csproj @@ -28,6 +28,8 @@ ..\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 @@ -68,5 +70,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." /> + + + + + + From 25b72c04cbf6889d171d07187f45a2131a371bd1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 15:17:11 +0700 Subject: [PATCH 03/10] test: verify ARSAS engine lock is enforced --- .../ArIec61850PinRegressionTests.cs | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 tests/ARSAS.Tests/ArIec61850PinRegressionTests.cs 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}'."); + } +} From 5672b22395c6f5968d6e0d1629649cb695d55a62 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 15:17:54 +0700 Subject: [PATCH 04/10] test: cover 58-member Siemens static DataSet selection end to end --- ...nsStaticDataSetSelectionRegressionTests.cs | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 tests/ARSAS.Tests/SiemensStaticDataSetSelectionRegressionTests.cs 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")))))); + } +} From d6e7a8b33b5b165b3c1cc95933ee951e435a0b12 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 15:19:48 +0700 Subject: [PATCH 05/10] fix: make first-run launcher ignore decorative borders --- MainWindow.IoTesting.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/MainWindow.IoTesting.cs b/MainWindow.IoTesting.cs index 7ca7eb2cc..a59521839 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; @@ -328,7 +335,7 @@ private async void OpenIoListPackage_Click(object sender, RoutedEventArgs e) } catch (OperationCanceledException) { - SetStatus("ARSAS IO FAT project import cancelled."); + SetStatus("IO List import cancelled."); } catch (Exception ex) when (ex is IOException or JsonException or InvalidDataException or UnauthorizedAccessException or ArgumentException or InvalidOperationException) { From 607735031701fb6ef6a8f4639d569355e9bc17a0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 15:20:07 +0700 Subject: [PATCH 06/10] test: lock first-run launcher to operational border contract --- .../FirstRunLauncherRegressionTests.cs | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) 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] From d669eb975c5c306db7de4244ae4873f0f0d4524d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 15:21:11 +0700 Subject: [PATCH 07/10] fix: preserve authoritative static DataSet display identity --- SignalSelectionWizardWindow.xaml.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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; } From 257f6c74e9475d4996cde1d9577a2ea9737be41b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 15:21:32 +0700 Subject: [PATCH 08/10] test: prevent Signal Selection constructor from rewriting FCDA identity --- ...neDataSetSignalSelectionRegressionTests.cs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) 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() { From 58efbf422286c577f9eecb7c1c864b6de47907b2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 15:22:31 +0700 Subject: [PATCH 09/10] build: select PowerShell host per platform --- ArIED61850Tester.csproj | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ArIED61850Tester.csproj b/ArIED61850Tester.csproj index c74539116..a5b22a41b 100644 --- a/ArIED61850Tester.csproj +++ b/ArIED61850Tester.csproj @@ -30,6 +30,8 @@ ..\ARIEC61850\src\AR.Iec61850.Transports.Npcap\AR.Iec61850.Transports.Npcap.csproj $(MSBuildProjectDirectory)\engines\ARIEC61850.lock.json $(MSBuildProjectDirectory)\scripts\validate-ariec61850-lock.ps1 + powershell + pwsh @@ -79,6 +81,6 @@ - + From fb1063fb91325a7e6a47a70d7253c313e788e2aa Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 15:24:52 +0700 Subject: [PATCH 10/10] chore: restore IO FAT cancellation status text --- MainWindow.IoTesting.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MainWindow.IoTesting.cs b/MainWindow.IoTesting.cs index a59521839..db523151f 100644 --- a/MainWindow.IoTesting.cs +++ b/MainWindow.IoTesting.cs @@ -335,7 +335,7 @@ private async void OpenIoListPackage_Click(object sender, RoutedEventArgs e) } catch (OperationCanceledException) { - SetStatus("IO List import cancelled."); + SetStatus("ARSAS IO FAT project import cancelled."); } catch (Exception ex) when (ex is IOException or JsonException or InvalidDataException or UnauthorizedAccessException or ArgumentException or InvalidOperationException) {