From a19fa0ecc976600932318ad218175d86d545ad80 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 3 Sep 2026 13:40:55 +0700 Subject: [PATCH 1/3] Prevent redundant manual FAT rows for static runtime coverage --- .../IoFatEngineeringSelectionBridge.cs | 171 +++++++++++++++++- 1 file changed, 168 insertions(+), 3 deletions(-) diff --git a/Services/IoTesting/IoFatEngineeringSelectionBridge.cs b/Services/IoTesting/IoFatEngineeringSelectionBridge.cs index 381c9cce9..49d830dcd 100644 --- a/Services/IoTesting/IoFatEngineeringSelectionBridge.cs +++ b/Services/IoTesting/IoFatEngineeringSelectionBridge.cs @@ -21,8 +21,16 @@ public static int Initialize( ArgumentNullException.ThrowIfNull(ied); ArgumentNullException.ThrowIfNull(device); - var changed = 0; - foreach (var point in ied.TestPoints.Where(IoTestSignalSelectionService.IsDirectSclAuthority)) + // A previous shared-workspace build could materialize a generic scalar as an + // scl-manual-* row even though an authoritative static DataSet membership already + // owned the same runtime leaf. Retire only that redundant shared-selection overlay; + // keep TEST, FAT disposition, and any captured evidence untouched for audit history. + var redundantManualPoints = FindRedundantManualWorkspacePoints(ied); + var changed = RetireRedundantManualWorkspaceRows(ied); + + foreach (var point in ied.TestPoints + .Where(IoTestSignalSelectionService.IsDirectSclAuthority) + .Where(point => !redundantManualPoints.Contains(point))) { var signal = FindSignal(point, device); if (signal is null) @@ -55,6 +63,30 @@ public static int Initialize( } } + // If Engineering had explicitly selected the redundant generic scalar, preserve + // that operator intent by projecting it onto the authoritative static FAT row(s), + // never by re-enabling the stale manual duplicate. + if (preserveExistingEngineeringSelection) + { + foreach (var manualPoint in redundantManualPoints) + { + var signal = FindSignal(manualPoint, device); + if (signal?.IsSelected != true) + continue; + + foreach (var staticPoint in FindStaticDataSetRuntimeCoverage( + ied, + manualPoint.ObjectReference, + manualPoint.FunctionalConstraint)) + { + if (staticPoint.WorkspaceSelected) + continue; + staticPoint.WorkspaceSelected = true; + changed++; + } + } + } + device.RecountSelectedSignals(); return changed; } @@ -93,6 +125,11 @@ public static bool ApplyEngineeringSignalSelection( ArgumentNullException.ThrowIfNull(ied); ArgumentNullException.ThrowIfNull(device); + // Always retire stale manual overlays before applying the current Engineering + // decision. This makes persisted projects self-heal on reopen without deleting + // historical evidence or weakening the duplicate-reference FAT preflight guard. + var changed = RetireRedundantManualWorkspaceRows(ied) > 0; + // Engineering owns shared workspace membership only. A FAT row explicitly removed // by the operator stays removed, and its FAT TEST preference/evidence is preserved // across Engineering deselect/reselect operations. @@ -101,7 +138,71 @@ public static bool ApplyEngineeringSignalSelection( .Where(point => ReferenceEquals(FindSignal(point, device), signal)) .ToArray(); - var changed = false; + // An exact static membership signal remains one-to-one even when multiple distinct + // static memberships share the same engine-proven runtime leaf. Never fan a direct + // static checkbox action across sibling memberships merely because ObjectReference + // is shared. + var staticMatching = matching + .Where(IoTestSignalSelectionService.IsSclDataSetAuthority) + .ToArray(); + if (staticMatching.Length > 0) + { + foreach (var point in staticMatching) + { + if (point.WorkspaceSelected == selected) + continue; + point.WorkspaceSelected = selected; + changed = true; + } + + foreach (var manualPoint in matching.Where(IoTestSignalSelectionService.IsSclWorkspaceAuthority)) + { + if (!manualPoint.WorkspaceSelected) + continue; + manualPoint.WorkspaceSelected = false; + changed = true; + } + + return changed; + } + + var staticCoverage = FindStaticDataSetRuntimeCoverage( + ied, + signal.ObjectReference, + signal.FunctionalConstraint); + if (staticCoverage.Count > 0) + { + // A generic Engineering scalar already represented by static DataSet authority + // must not create a second scl-manual-* FAT row. Selecting the scalar projects + // onto the existing static row(s); deselecting it only retires redundant manual + // overlays because each exact static membership owns its own checkbox decision. + if (selected) + { + foreach (var staticPoint in staticCoverage) + { + if (staticPoint.WorkspaceSelected) + continue; + staticPoint.WorkspaceSelected = true; + changed = true; + } + } + + foreach (var manualPoint in ied.TestPoints + .Where(IoTestSignalSelectionService.IsSclWorkspaceAuthority) + .Where(point => HasSameRuntimeIdentity( + point, + signal.ObjectReference, + signal.FunctionalConstraint))) + { + if (!manualPoint.WorkspaceSelected) + continue; + manualPoint.WorkspaceSelected = false; + changed = true; + } + + return changed; + } + foreach (var point in matching) { if (point.WorkspaceSelected == selected) @@ -170,6 +271,70 @@ public static bool ApplyEngineeringSignalSelection( return runtimeMatches.Length == 1 ? runtimeMatches[0] : null; } + internal static IReadOnlyList FindStaticDataSetRuntimeCoverage( + IoTestIedPlan ied, + string? runtimeReference, + string? functionalConstraint = null) + { + ArgumentNullException.ThrowIfNull(ied); + var runtime = IoTestLiveBindingService.NormalizeReference(runtimeReference); + if (runtime.Length == 0) + return Array.Empty(); + + var requiredFc = functionalConstraint?.Trim() ?? string.Empty; + return ied.TestPoints + .Where(IoTestSignalSelectionService.IsSclDataSetAuthority) + .Where(point => IoTestLiveBindingService.NormalizeReference(point.ObjectReference) + .Equals(runtime, StringComparison.OrdinalIgnoreCase)) + .Where(point => requiredFc.Length == 0 || + string.IsNullOrWhiteSpace(point.FunctionalConstraint) || + point.FunctionalConstraint.Equals(requiredFc, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + } + + internal static int RetireRedundantManualWorkspaceRows(IoTestIedPlan ied) + { + ArgumentNullException.ThrowIfNull(ied); + var changed = 0; + foreach (var point in FindRedundantManualWorkspacePoints(ied)) + { + if (!point.WorkspaceSelected) + continue; + point.WorkspaceSelected = false; + changed++; + } + return changed; + } + + private static HashSet FindRedundantManualWorkspacePoints(IoTestIedPlan ied) + => ied.TestPoints + .Where(IoTestSignalSelectionService.IsSclWorkspaceAuthority) + .Where(point => FindStaticDataSetRuntimeCoverage( + ied, + point.ObjectReference, + point.FunctionalConstraint) + .Count > 0) + .ToHashSet(); + + private static bool HasSameRuntimeIdentity( + IoTestPointPlan point, + string? runtimeReference, + string? functionalConstraint) + { + var runtime = IoTestLiveBindingService.NormalizeReference(runtimeReference); + if (runtime.Length == 0 || + !IoTestLiveBindingService.NormalizeReference(point.ObjectReference) + .Equals(runtime, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var requiredFc = functionalConstraint?.Trim() ?? string.Empty; + return requiredFc.Length == 0 || + string.IsNullOrWhiteSpace(point.FunctionalConstraint) || + point.FunctionalConstraint.Equals(requiredFc, StringComparison.OrdinalIgnoreCase); + } + private static bool TryCreateManualWorkspacePoint( SignalDefinition signal, IoTestIedPlan ied, From 26a0f7417984d8ab469ccbb9ebc666302e724599 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 3 Sep 2026 13:41:25 +0700 Subject: [PATCH 2/3] Regress static authority over redundant manual FAT rows --- ...redWorkspaceRuntimeDedupRegressionTests.cs | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 tests/ARSAS.Tests/IoFatSharedWorkspaceRuntimeDedupRegressionTests.cs diff --git a/tests/ARSAS.Tests/IoFatSharedWorkspaceRuntimeDedupRegressionTests.cs b/tests/ARSAS.Tests/IoFatSharedWorkspaceRuntimeDedupRegressionTests.cs new file mode 100644 index 000000000..bcc01336d --- /dev/null +++ b/tests/ARSAS.Tests/IoFatSharedWorkspaceRuntimeDedupRegressionTests.cs @@ -0,0 +1,153 @@ +using ArIED61850Tester.Models.IoTesting; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class IoFatSharedWorkspaceRuntimeDedupRegressionTests +{ + [Fact] + public void RedundantManualRuntimeOverlay_IsRetiredBeforeFatPreflight() + { + const string runtime = "AA1E1F00R1ADD/GGIO2.TimeSynchrnz.stVal"; + var staticA = StaticPoint( + "scl-static-a", + runtime, + "AA1E1F00R1ADD/GGIO2.TimeSynchrnz", + "AA1E1F00R1LD0/LLN0.EventsA", + 1); + var staticB = StaticPoint( + "scl-static-b", + runtime, + "AA1E1F00R1ADD/GGIO2.TimeSynchrnz.stVal", + "AA1E1F00R1LD0/LLN0.EventsB", + 2); + var manual = ManualPoint(runtime); + var ied = Ied(staticA, staticB, manual); + + var blocked = IoTestSessionPreflight.Validate(ied); + Assert.False(blocked.Succeeded); + Assert.Contains("multiple enabled test points", blocked.Message, StringComparison.OrdinalIgnoreCase); + + var retired = IoFatEngineeringSelectionBridge.RetireRedundantManualWorkspaceRows(ied); + + Assert.Equal(1, retired); + Assert.False(manual.WorkspaceSelected); + Assert.True(manual.TestEnabled); + Assert.True(manual.IsIncludedInFat); + Assert.True(staticA.WorkspaceSelected); + Assert.True(staticB.WorkspaceSelected); + + var ready = IoTestSessionPreflight.Validate(ied); + Assert.True(ready.Succeeded, ready.Message); + } + + [Fact] + public void StaticRuntimeCoverage_PreservesDistinctMembershipFanOut() + { + const string runtime = "AA1E1F00R1ADD/GGIO2.TimeSynchrnz.stVal"; + var staticA = StaticPoint( + "scl-static-a", + runtime, + "AA1E1F00R1ADD/GGIO2.TimeSynchrnz", + "AA1E1F00R1LD0/LLN0.EventsA", + 1); + var staticB = StaticPoint( + "scl-static-b", + runtime, + "AA1E1F00R1ADD/GGIO2.TimeSynchrnz.stVal", + "AA1E1F00R1LD0/LLN0.EventsB", + 2); + var manual = ManualPoint(runtime); + var ied = Ied(staticA, staticB, manual); + + var coverage = IoFatEngineeringSelectionBridge.FindStaticDataSetRuntimeCoverage( + ied, + runtime, + "ST"); + + Assert.Equal(2, coverage.Count); + Assert.Contains(staticA, coverage); + Assert.Contains(staticB, coverage); + Assert.DoesNotContain(manual, coverage); + } + + [Fact] + public void ManualOnlyRuntime_RemainsSharedWorkspaceScope() + { + const string runtime = "AA1E1F00R1ADD/GGIO2.PrSetChgd.stVal"; + var manual = ManualPoint(runtime); + var ied = Ied(manual); + + var retired = IoFatEngineeringSelectionBridge.RetireRedundantManualWorkspaceRows(ied); + + Assert.Equal(0, retired); + Assert.True(manual.WorkspaceSelected); + Assert.True(manual.TestEnabled); + Assert.True(manual.IsIncludedInFat); + } + + private static IoTestPointPlan StaticPoint( + string id, + string runtimeReference, + string staticReference, + string dataSet, + int memberIndex) + => new() + { + TestPointId = id, + IedName = "AA1E1F00R1", + IpAddress = "192.168.81.83", + SignalName = staticReference, + ObjectReference = runtimeReference, + FunctionalConstraint = "ST", + ExpectedOnText = "TRUE", + ExpectedOffText = "FALSE", + DataType = "BOOLEAN", + SignalAddress = "source-sha", + DataSetName = dataSet, + SourceIecReference = staticReference, + ReportDisplayReference = staticReference, + EventLogSearchReference = runtimeReference, + SourceRow = memberIndex, + SignalKind = FatSignalKind.Discrete, + CaptureMode = FatCaptureMode.AutomaticTransition, + WorkspaceSelected = true, + TestEnabled = true, + ImportReady = true, + BindingStatus = IoTestSignalSelectionService.SclDataSetAuthorityBindingStatus, + BindingEvidence = "Static SCL DataSet authority" + }; + + private static IoTestPointPlan ManualPoint(string runtimeReference) + => new() + { + TestPointId = "scl-manual-8498597f6ee9a39943c0", + IedName = "AA1E1F00R1", + IpAddress = "192.168.81.83", + SignalName = "GGIO2.TimeSynchrnz.stVal", + ObjectReference = runtimeReference, + FunctionalConstraint = "ST", + ExpectedOnText = "TRUE", + ExpectedOffText = "FALSE", + DataType = "BOOLEAN", + SignalAddress = "source-sha", + SourceIecReference = runtimeReference, + ReportDisplayReference = runtimeReference, + EventLogSearchReference = runtimeReference, + SignalKind = FatSignalKind.Discrete, + CaptureMode = FatCaptureMode.AutomaticTransition, + WorkspaceSelected = true, + TestEnabled = true, + ImportReady = true, + BindingStatus = IoTestSignalSelectionService.SclWorkspaceAuthorityBindingStatus, + BindingEvidence = "Shared SCL workspace authority" + }; + + private static IoTestIedPlan Ied(params IoTestPointPlan[] points) + => new() + { + IedName = "AA1E1F00R1", + IpAddress = "192.168.81.83", + TestPoints = points.ToList() + }; +} From 20499f29f337482fa788054b2f9a41555503935b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 3 Sep 2026 13:41:49 +0700 Subject: [PATCH 3/3] Lock shared workspace runtime dedupe wiring --- ...reshSclSelectionAuthorityRegressionTests.cs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/ARSAS.Tests/IoFatFreshSclSelectionAuthorityRegressionTests.cs b/tests/ARSAS.Tests/IoFatFreshSclSelectionAuthorityRegressionTests.cs index 1a329e05e..3dcbef6f4 100644 --- a/tests/ARSAS.Tests/IoFatFreshSclSelectionAuthorityRegressionTests.cs +++ b/tests/ARSAS.Tests/IoFatFreshSclSelectionAuthorityRegressionTests.cs @@ -36,6 +36,24 @@ public void FreshSclBridge_DoesNotMergeSharedSelectionWithFatDispositionOrTestSc Assert.DoesNotContain("point.TestEnabled = signal.IsSelected;", source, StringComparison.Ordinal); } + [Fact] + public void GenericEngineeringScalar_IsGuardedByStaticRuntimeCoverageBeforeManualRowCreation() + { + var source = Read("Services/IoTesting/IoFatEngineeringSelectionBridge.cs"); + + Assert.Contains("RetireRedundantManualWorkspaceRows(ied)", source, StringComparison.Ordinal); + Assert.Contains("var staticCoverage = FindStaticDataSetRuntimeCoverage(", source, StringComparison.Ordinal); + Assert.Contains("if (staticCoverage.Count > 0)", source, StringComparison.Ordinal); + Assert.Contains("must not create a second scl-manual-* FAT row", source, StringComparison.Ordinal); + + var coverageGuard = source.IndexOf("if (staticCoverage.Count > 0)", StringComparison.Ordinal); + var manualCreate = source.IndexOf( + "if (matching.Length == 0 && selected && TryCreateManualWorkspacePoint", + StringComparison.Ordinal); + Assert.True(coverageGuard >= 0, "Static runtime coverage guard is missing."); + Assert.True(manualCreate > coverageGuard, "Manual row creation must remain behind the static runtime coverage guard."); + } + private static string Read(string relativePath) => File.ReadAllText(FindRepoFile(relativePath)).Replace("\r\n", "\n", StringComparison.Ordinal);