Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 168 additions & 3 deletions Services/IoTesting/IoFatEngineeringSelectionBridge.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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.
Expand All @@ -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)
Expand Down Expand Up @@ -170,6 +271,70 @@ public static bool ApplyEngineeringSignalSelection(
return runtimeMatches.Length == 1 ? runtimeMatches[0] : null;
}

internal static IReadOnlyList<IoTestPointPlan> FindStaticDataSetRuntimeCoverage(
IoTestIedPlan ied,
string? runtimeReference,
string? functionalConstraint = null)
{
ArgumentNullException.ThrowIfNull(ied);
var runtime = IoTestLiveBindingService.NormalizeReference(runtimeReference);
if (runtime.Length == 0)
return Array.Empty<IoTestPointPlan>();

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<IoTestPointPlan> 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
153 changes: 153 additions & 0 deletions tests/ARSAS.Tests/IoFatSharedWorkspaceRuntimeDedupRegressionTests.cs
Original file line number Diff line number Diff line change
@@ -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()
};
}
Loading