| ").Append(index).Append(" | ").Append(Html(point.SignalName)).Append(" | ").Append(Html(point.ReportIecReference)).Append(" | ")
@@ -918,9 +1023,21 @@ private sealed record IoTestPointData(
string BindingEvidence,
IoTestRuntimeData Runtime)
{
+ public bool WorkspaceSelected { get; init; } = true;
public FatSignalKind SignalKind { get; init; } = FatSignalKind.Discrete;
public FatCaptureMode CaptureMode { get; init; } = FatCaptureMode.AutomaticTransition;
public FatSignalDisposition FatDisposition { get; init; } = FatSignalDisposition.Included;
+ public string Cdc { get; init; } = string.Empty;
+ public string SourceIecReference { get; init; } = string.Empty;
+ public string ReportDisplayReference { get; init; } = string.Empty;
+ public string EventLogSearchReference { get; init; } = string.Empty;
+ public string EvidenceExpected { get; init; } = string.Empty;
+ public string MappingQuality { get; init; } = string.Empty;
+ public string ReviewStatus { get; init; } = string.Empty;
+ public string ReviewReason { get; init; } = string.Empty;
+ public string EventLogMatch { get; init; } = string.Empty;
+ public string EvidenceReference { get; init; } = string.Empty;
+ public string ReviewerComment { get; init; } = string.Empty;
}
private sealed record IoTestRuntimeData(
@@ -962,4 +1079,4 @@ private sealed record IoTestPackageEvidence(
string Sha256,
long RecordCount,
string LastHash);
-}
\ No newline at end of file
+}
diff --git a/Services/IoTesting/IoTestReconciliationCache.cs b/Services/IoTesting/IoTestReconciliationCache.cs
index 7a2724013..c22fe04fc 100644
--- a/Services/IoTesting/IoTestReconciliationCache.cs
+++ b/Services/IoTesting/IoTestReconciliationCache.cs
@@ -22,6 +22,14 @@ public static class IoTestReconciliationCache
/// already-active association and let ARIEC61850 own exact reads, alternate strategies,
/// probe budgets, and failure verdicts. A workspace that has never created a session may
/// still build a model-only document; that path can only remain DesignOnly, never Absent.
+ ///
+ /// Direct SCL FAT is intentionally different. Its complete static DataSet scope is already
+ /// authoritative before association and local signal selection does not require design/live
+ /// reconciliation. Even model-only reconciliation can be CPU-heavy enough on a large saved
+ /// model to hold the operator before StartMonitoringAsync. Therefore direct SCL FAT has no
+ /// reconciliation gate at all: invalidate any stale cache and return immediately. Live
+ /// acquisition starts from the SCL authority; Engineering/diagnostic reconciliation remains
+ /// an explicit, non-critical workflow and is never allowed to delay physical FAT readiness.
///
public static Task RefreshAsync(
Iec61850MonitorDevice device,
@@ -29,6 +37,12 @@ public static Task RefreshAsync(
{
ArgumentNullException.ThrowIfNull(device);
+ if (UsesDirectSclFatAuthority(device))
+ {
+ Entries.TryRemove(device, out _);
+ return Task.CompletedTask;
+ }
+
if (!NativeIec61850Client.HasReconciliationOwner(device.IpAddress, device.Port))
return RefreshModelOnlyAsync(device, cancellationToken);
@@ -193,6 +207,10 @@ public static void Invalidate(Iec61850MonitorDevice device)
Entries.TryRemove(device, out _);
}
+ private static bool UsesDirectSclFatAuthority(Iec61850MonitorDevice device)
+ => device.SclWorkspace?.DesignModel is not null &&
+ device.IdentitySource.Contains("FAT SCL", StringComparison.OrdinalIgnoreCase);
+
private sealed record CacheEntry(
object DesignModel,
object LiveModel,
diff --git a/Services/IoTesting/IoTestSessionController.cs b/Services/IoTesting/IoTestSessionController.cs
index 53eb21330..a3e47d35d 100644
--- a/Services/IoTesting/IoTestSessionController.cs
+++ b/Services/IoTesting/IoTestSessionController.cs
@@ -22,10 +22,21 @@ public sealed class IoTestSessionController : ObservableObject, IDisposable
private readonly Func _journalFactory;
private readonly IoTestTransitionEvaluator _evaluator;
private readonly IoTestRollingCaptureCoordinator _captureCoordinator;
- private readonly ConcurrentQueue _pendingSnapshots = new();
+ private readonly FatAutoCaptureCoordinator _autoCaptureCoordinator = new();
+ // Only the newest observation per live point is needed by the UI transition
+ // evaluator. Coalescing prevents a fast report/poll stream from building an
+ // unbounded dispatcher backlog while the operator scrolls the FAT grid.
+ private readonly ConcurrentDictionary _pendingSnapshots = new(StringComparer.OrdinalIgnoreCase);
+ // Actual transitions are never coalesced: FAT evidence must retain a fast
+ // OFF→ON→OFF sequence even when all three samples arrive in one UI frame.
+ private readonly ConcurrentQueue _pendingEdgeSnapshots = new();
private readonly Dictionary> _activePointIndex = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary _activeLivePoints = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet _sessionPointIds = new(StringComparer.OrdinalIgnoreCase);
+ // Value 1 & Value 2 bulk Recapture is deliberately two-stage. The staged Value 1
+ // remains audit history only until Value 2 is captured successfully; current pointers
+ // therefore keep the old complete pair authoritative during the condition change.
+ private readonly Dictionary _pendingPairValue1 = new(StringComparer.OrdinalIgnoreCase);
private IoTestSessionState _state = IoTestSessionState.Idle;
private IoTestIedPlan? _activeIed;
@@ -128,20 +139,20 @@ public IoTestSessionActionResult Start(
if (captureScope == null)
{
expectedPoints = ied.TestPoints
- .Where(point => point.IsIncludedInFat && point.TestEnabled && point.ImportReady)
+ .Where(point => point.WorkspaceSelected && point.IsIncludedInFat && point.TestEnabled && point.ImportReady)
.ToList();
}
else
{
var knownPoints = new HashSet(ied.TestPoints);
var invalidScope = captureScope
- .Where(point => !knownPoints.Contains(point) || !point.IsIncludedInFat || !point.TestEnabled || !point.ImportReady)
+ .Where(point => !knownPoints.Contains(point) || !point.WorkspaceSelected || !point.IsIncludedInFat || !point.TestEnabled || !point.ImportReady)
.Distinct()
.ToList();
if (invalidScope.Count > 0)
{
return IoTestSessionActionResult.Failure(
- "The requested FAT capture scope contains a row that is not part of this IED, was removed by the operator, is not selected, or is not import-ready.");
+ "The requested FAT capture scope contains a row that is not part of this IED/shared workspace, was removed by the operator, has TEST disabled, or is not import-ready.");
}
expectedPoints = captureScope.Distinct().ToList();
@@ -160,6 +171,8 @@ public IoTestSessionActionResult Start(
CleanupSessionResources(disposeJournal: true);
_captureCoordinator.Clear();
+ _autoCaptureCoordinator.Clear();
+ _pendingPairValue1.Clear();
SessionId = Guid.NewGuid();
StartedAtUtc = DateTimeOffset.UtcNow;
CompletedAtUtc = null;
@@ -170,7 +183,7 @@ public IoTestSessionActionResult Start(
_activePointIndex.Clear();
_activeLivePoints.Clear();
_sessionPointIds.Clear();
- while (_pendingSnapshots.TryDequeue(out _)) { }
+ ClearPendingSnapshots();
foreach (var binding in bindings)
{
@@ -190,29 +203,41 @@ public IoTestSessionActionResult Start(
LastJournalHash = string.Empty;
JournalIntegrityText = "Evidence journal open · hash chain initialized";
- var startupEntries = new List(bindings.Count + 1)
+ var startupEntries = new List((bindings.Count * 2) + 1)
{
SessionEvent("session_started", $"FAT session started with an explicit capture scope of {bindings.Count} operator-selected point(s).")
};
+ var autoDecisions = new List<(IoTestPointPlan Point, FatAutoCaptureDecision Decision)>();
foreach (var binding in bindings)
{
var observation = CreateObservation(binding.Point, binding.LivePoint, _connectionGeneration, DateTimeOffset.UtcNow);
if (binding.Point.CaptureMode == FatCaptureMode.OperatorSnapshot)
{
- binding.Point.Runtime.ApplyObservation(observation with { NormalizedState = null });
+ var genericObservation = observation with { NormalizedState = null };
+ binding.Point.Runtime.ApplyObservation(genericObservation);
startupEntries.Add(PointEvent(
"operator_snapshot_baseline",
binding.Point,
- observation with { NormalizedState = null },
+ genericObservation,
null,
- "Live value established for operator Value 1 / Value 2 snapshot capture."));
+ "Live value established for automatic/manual Value 1 / Value 2 capture."));
+ var decision = _autoCaptureCoordinator.Observe(binding.Point, genericObservation);
+ autoDecisions.Add((binding.Point, decision));
+ if (decision.Evidence != null)
+ startupEntries.Add(FatValueEvent(binding.Point, decision.Evidence));
continue;
}
var evaluation = _captureCoordinator.Start(binding.Point, observation);
startupEntries.Add(PointEvent("baseline", binding.Point, observation, evaluation.Evidence, evaluation.Reason));
+ var automaticDecision = _autoCaptureCoordinator.Observe(binding.Point, observation);
+ autoDecisions.Add((binding.Point, automaticDecision));
+ if (automaticDecision.Evidence != null)
+ startupEntries.Add(FatValueEvent(binding.Point, automaticDecision.Evidence));
}
AppendBatchRequired(startupEntries);
+ foreach (var (point, decision) in autoDecisions)
+ PromoteAutoCaptureDecision(point, decision);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException)
{
@@ -224,7 +249,7 @@ public IoTestSessionActionResult Start(
}
State = IoTestSessionState.Running;
- StatusText = $"FAT capture running for {ied.IedName}. Automatic rows remain armed; operator-snapshot rows expose live values for Value 1 / Value 2 capture until Stop.";
+ StatusText = $"FAT capture running for {ied.IedName}. Value 1 / Value 2 auto-capture is armed; manual Capture and multi-row Recapture remain operator overrides until Stop.";
RaiseProgress();
UpdateRunningCompletionStatus();
return IoTestSessionActionResult.Success(StatusText);
@@ -237,7 +262,7 @@ public IoTestSessionActionResult Pause(string reason = "Paused by operator")
return IoTestSessionActionResult.Failure("No running FAT session is available to pause.");
State = IoTestSessionState.Paused;
- while (_pendingSnapshots.TryDequeue(out _)) { }
+ ClearPendingSnapshots();
StatusText = reason;
if (!AppendOrFault(SessionEvent("session_paused", reason)))
return IoTestSessionActionResult.Failure(StatusText);
@@ -270,8 +295,10 @@ public IoTestSessionActionResult Resume()
}
_connectionGeneration++;
- while (_pendingSnapshots.TryDequeue(out _)) { }
- if (!AppendOrFault(SessionEvent("session_resumed", "Session resumed after rebinding current live points; current values are treated as a new baseline image.")))
+ _pendingPairValue1.Clear();
+ _autoCaptureCoordinator.Clear();
+ ClearPendingSnapshots();
+ if (!AppendOrFault(SessionEvent("session_resumed", "Session resumed after rebinding current live points; current values are treated as a new baseline image. Any staged pair Recapture was cancelled.")))
return IoTestSessionActionResult.Failure(StatusText);
foreach (var binding in refreshedBindings)
@@ -288,16 +315,20 @@ public IoTestSessionActionResult Resume()
null,
"Live value refreshed after reconnect; existing Value 1 / Value 2 evidence is preserved.")))
return IoTestSessionActionResult.Failure(StatusText);
+ if (!ApplyAutoCapture(binding.Point, genericObservation))
+ return IoTestSessionActionResult.Failure(StatusText);
continue;
}
var evaluation = _captureCoordinator.Observe(binding.Point, observation);
if (!AppendOrFault(PointEvent("resume_baseline", binding.Point, observation, evaluation.Evidence, evaluation.Reason)))
return IoTestSessionActionResult.Failure(StatusText);
+ if (!ApplyAutoCapture(binding.Point, observation))
+ return IoTestSessionActionResult.Failure(StatusText);
}
State = IoTestSessionState.Running;
- StatusText = $"FAT capture resumed for {ActiveIed.IedName}; current evidence is preserved until newer evidence is explicitly completed.";
+ StatusText = $"FAT capture resumed for {ActiveIed.IedName}; current evidence is preserved and auto-capture continues only for incomplete slots.";
RaiseProgress();
UpdateRunningCompletionStatus();
return IoTestSessionActionResult.Success(StatusText);
@@ -350,23 +381,16 @@ public IoTestSessionActionResult CaptureOperatorSnapshot(IoTestPointPlan? point,
return IoTestSessionActionResult.Failure("The selected FAT row is not part of the active IED session.");
if (!_sessionPointIds.Contains(point.TestPointId))
return IoTestSessionActionResult.Failure("The selected FAT row is not part of the active capture scope.");
- if (!point.IsIncludedInFat || !point.TestEnabled || !point.ImportReady)
- return IoTestSessionActionResult.Failure("The FAT row must remain included, operator-selected, and import-ready to capture evidence.");
+ if (!point.WorkspaceSelected || !point.IsIncludedInFat || !point.TestEnabled || !point.ImportReady)
+ return IoTestSessionActionResult.Failure("The FAT row must remain in the shared workspace, included, TEST-enabled, and import-ready to capture evidence.");
if (point.CaptureMode != FatCaptureMode.OperatorSnapshot)
- return IoTestSessionActionResult.Failure("This FAT row uses automatic transition capture; Value 1 / Value 2 are captured by live transitions.");
+ return IoTestSessionActionResult.Failure("Use right-click Recapture for automatic-transition rows.");
if (!_activeLivePoints.TryGetValue(point.TestPointId, out var livePoint))
return IoTestSessionActionResult.Failure("The FAT row does not have one active live point to snapshot.");
try
{
- var observation = new FatLiveValueObservation(
- livePoint.Value,
- DateTimeOffset.UtcNow,
- IoTestValueNormalizer.ParseIedTimestamp(livePoint.DeviceTimestamp),
- livePoint.Quality,
- livePoint.SourceMode,
- livePoint.Sequence,
- _connectionGeneration);
+ var observation = CreateLiveValueObservation(livePoint);
var evidence = FatOperatorSnapshotCaptureService.CreateEvidence(slot, observation);
// Audit history is authoritative and append-only. Only after the journal write
@@ -375,6 +399,10 @@ public IoTestSessionActionResult CaptureOperatorSnapshot(IoTestPointPlan? point,
return IoTestSessionActionResult.Failure(StatusText);
point.Runtime.SetFatValueEvidence(evidence);
+ point.Runtime.AutoCaptureStage = point.IsFatEvidenceComplete
+ ? FatAutoCaptureStage.Complete
+ : point.HasValue1Evidence ? FatAutoCaptureStage.WaitingChange : FatAutoCaptureStage.WaitingValue1;
+ _autoCaptureCoordinator.Clear(point);
point.Runtime.StatusReason = point.IsFatEvidenceComplete
? "Value 1 and Value 2 captured. Current evidence is complete; either value may be recaptured while the session remains running."
: $"{(slot == FatValueSlot.Value1 ? "Value 1" : "Value 2")} captured; capture the remaining value to complete current evidence.";
@@ -389,6 +417,140 @@ public IoTestSessionActionResult CaptureOperatorSnapshot(IoTestPointPlan? point,
}
}
+ public IoTestSessionActionResult RecaptureValues(
+ IReadOnlyCollection points,
+ FatValueSlot slot)
+ {
+ ThrowIfDisposed();
+ if (!TryPrepareRecapture(points, out var bindings, out var failure))
+ return IoTestSessionActionResult.Failure(failure);
+
+ if (slot == FatValueSlot.Value1 && bindings.Any(binding => _pendingPairValue1.ContainsKey(binding.Point.TestPointId)))
+ {
+ return IoTestSessionActionResult.Failure(
+ "A Value 1 & Value 2 pair Recapture is already staged for one or more selected rows. Complete it with Recapture Value 2 or cancel the staged pair first.");
+ }
+
+ var captures = new List<(IoTestPointPlan Point, FatValueEvidence Evidence, FatValueEvidence? StagedValue1)>();
+ try
+ {
+ foreach (var binding in bindings)
+ {
+ var evidence = FatOperatorSnapshotCaptureService.CreateEvidence(
+ slot,
+ CreateLiveValueObservation(binding.LivePoint),
+ FatEvidenceCaptureKind.OperatorRecapture);
+ _pendingPairValue1.TryGetValue(binding.Point.TestPointId, out var stagedValue1);
+ if (slot == FatValueSlot.Value2 && stagedValue1 != null && stagedValue1.ConnectionGeneration != _connectionGeneration)
+ {
+ return IoTestSessionActionResult.Failure(
+ "A staged Value 1 belongs to an earlier IED connection generation. Cancel it and start Value 1 & Value 2 Recapture again.");
+ }
+ captures.Add((binding.Point, evidence, slot == FatValueSlot.Value2 ? stagedValue1 : null));
+ }
+
+ AppendBatchRequired(captures.Select(item => FatValueEvent(
+ item.Point,
+ item.Evidence,
+ item.StagedValue1 != null ? "fat_value_recapture_pair_commit" : "fat_value_recapture")).ToList());
+ }
+ catch (Exception ex) when (ex is InvalidOperationException or IOException or UnauthorizedAccessException)
+ {
+ return IoTestSessionActionResult.Failure(ex.Message);
+ }
+
+ foreach (var capture in captures)
+ {
+ if (capture.StagedValue1 != null)
+ {
+ capture.Point.Runtime.SetFatValueEvidence(capture.StagedValue1);
+ _pendingPairValue1.Remove(capture.Point.TestPointId);
+ }
+ capture.Point.Runtime.SetFatValueEvidence(capture.Evidence);
+ capture.Point.Runtime.AutoCaptureStage = capture.Point.IsFatEvidenceComplete
+ ? FatAutoCaptureStage.Complete
+ : capture.Point.HasValue1Evidence ? FatAutoCaptureStage.WaitingChange : FatAutoCaptureStage.WaitingValue1;
+ capture.Point.Runtime.StatusReason = capture.StagedValue1 != null
+ ? "Operator pair Recapture completed; staged Value 1 and current Value 2 are now the authoritative pair."
+ : $"Operator recaptured {slot}; the other current slot was preserved.";
+ _autoCaptureCoordinator.Clear(capture.Point);
+ }
+
+ StatusText = captures.Any(item => item.StagedValue1 != null)
+ ? $"{captures.Count} signal(s) · Value 1 & Value 2 recaptured · {ActiveIed!.IedName}"
+ : $"{captures.Count} signal(s) · {slot} recaptured · {ActiveIed!.IedName}";
+ RaiseProgress();
+ UpdateRunningCompletionStatus();
+ return IoTestSessionActionResult.Success(StatusText);
+ }
+
+ public IoTestSessionActionResult BeginPairRecapture(IReadOnlyCollection points)
+ {
+ ThrowIfDisposed();
+ if (!TryPrepareRecapture(points, out var bindings, out var failure))
+ return IoTestSessionActionResult.Failure(failure);
+ if (bindings.Any(binding => _pendingPairValue1.ContainsKey(binding.Point.TestPointId)))
+ return IoTestSessionActionResult.Failure("A Value 1 & Value 2 pair Recapture is already staged for one or more selected rows.");
+
+ var staged = new List<(IoTestPointPlan Point, FatValueEvidence Evidence)>();
+ try
+ {
+ foreach (var binding in bindings)
+ {
+ staged.Add((
+ binding.Point,
+ FatOperatorSnapshotCaptureService.CreateEvidence(
+ FatValueSlot.Value1,
+ CreateLiveValueObservation(binding.LivePoint),
+ FatEvidenceCaptureKind.OperatorRecapture)));
+ }
+
+ AppendBatchRequired(staged.Select(item => FatValueEvent(
+ item.Point,
+ item.Evidence,
+ "fat_value_recapture_pair_staged",
+ "Operator staged a new Value 1 for transactional Value 1 & Value 2 Recapture; current pointers remain unchanged until Value 2 is captured.")).ToList());
+ }
+ catch (Exception ex) when (ex is InvalidOperationException or IOException or UnauthorizedAccessException)
+ {
+ return IoTestSessionActionResult.Failure(ex.Message);
+ }
+
+ foreach (var item in staged)
+ {
+ _pendingPairValue1[item.Point.TestPointId] = item.Evidence;
+ item.Point.Runtime.StatusReason = "New Value 1 staged for pair Recapture; change the test condition, then Recapture Value 2. Existing current pair remains authoritative.";
+ }
+
+ StatusText = $"{staged.Count} signal(s) · Value 1 staged · change condition, then Recapture Value 2 · {ActiveIed!.IedName}";
+ RaiseProgress();
+ return IoTestSessionActionResult.Success(StatusText);
+ }
+
+ public IoTestSessionActionResult CancelPairRecapture(IReadOnlyCollection points)
+ {
+ ThrowIfDisposed();
+ var requested = points?.Where(point => point != null).Distinct().ToList() ?? new List();
+ var staged = requested.Where(point => _pendingPairValue1.ContainsKey(point.TestPointId)).ToList();
+ if (staged.Count == 0)
+ return IoTestSessionActionResult.Failure("No staged Value 1 & Value 2 Recapture exists for the selected rows.");
+ if (State != IoTestSessionState.Running || ActiveIed == null)
+ return IoTestSessionActionResult.Failure("The owning FAT session is not running.");
+
+ if (!AppendOrFault(SessionEvent(
+ "fat_value_recapture_pair_cancelled",
+ $"Operator cancelled staged pair Recapture for {staged.Count} row(s): {string.Join(", ", staged.Select(point => point.TestPointId))}.")))
+ return IoTestSessionActionResult.Failure(StatusText);
+
+ foreach (var point in staged)
+ {
+ _pendingPairValue1.Remove(point.TestPointId);
+ point.Runtime.StatusReason = "Staged pair Recapture cancelled; previous current evidence remains unchanged.";
+ }
+ StatusText = $"{staged.Count} signal(s) · staged pair Recapture cancelled · {ActiveIed.IedName}";
+ return IoTestSessionActionResult.Success(StatusText);
+ }
+
public void Enqueue(Iec61850EventEntry entry)
{
if (entry == null || _disposed || State != IoTestSessionState.Running)
@@ -399,7 +561,7 @@ public void Enqueue(Iec61850EventEntry entry)
!entry.DeviceId.Equals(activeDevice.DeviceId, StringComparison.OrdinalIgnoreCase))
return;
- _pendingSnapshots.Enqueue(new QueuedSnapshot(
+ var queued = new QueuedSnapshot(
entry.DeviceId,
entry.IecReference,
entry.OldValue,
@@ -409,7 +571,24 @@ public void Enqueue(Iec61850EventEntry entry)
entry.SourceMode,
entry.Reason,
entry.Sequence,
- DateTimeOffset.UtcNow));
+ DateTimeOffset.UtcNow);
+ var pendingKey = PointKey(entry.DeviceId, entry.IecReference);
+ var preservesTransitionEvidence =
+ _activePointIndex.TryGetValue(pendingKey, out var mappedPoints) &&
+ mappedPoints.Any(point => point.CaptureMode == FatCaptureMode.AutomaticTransition);
+ if (preservesTransitionEvidence &&
+ !Iec61850MonitorPoint.AreSemanticallyEquivalent(entry.OldValue, entry.NewValue))
+ {
+ _pendingSnapshots.TryRemove(pendingKey, out _);
+ _pendingEdgeSnapshots.Enqueue(queued);
+ }
+ else
+ {
+ _pendingSnapshots.AddOrUpdate(
+ pendingKey,
+ queued,
+ (_, current) => queued.Sequence >= current.Sequence ? queued : current);
+ }
ScheduleDrain();
}
@@ -421,7 +600,7 @@ public void Dispose()
Stop("Workspace closed; FAT session stopped.");
_disposed = true;
CleanupSessionResources(disposeJournal: true);
- while (_pendingSnapshots.TryDequeue(out _)) { }
+ ClearPendingSnapshots();
}
private void ScheduleDrain()
@@ -446,9 +625,15 @@ private void DrainPendingSnapshots()
try
{
while (processed < MaxSnapshotsPerDrain &&
- stopwatch.ElapsedMilliseconds < DrainBudgetMilliseconds &&
- _pendingSnapshots.TryDequeue(out var snapshot))
+ stopwatch.ElapsedMilliseconds < DrainBudgetMilliseconds)
{
+ QueuedSnapshot? snapshot;
+ if (!_pendingEdgeSnapshots.TryDequeue(out snapshot))
+ {
+ var pendingKey = _pendingSnapshots.Keys.FirstOrDefault();
+ if (pendingKey == null || !_pendingSnapshots.TryRemove(pendingKey, out snapshot))
+ break;
+ }
ProcessSnapshot(snapshot);
processed++;
if (State != IoTestSessionState.Running)
@@ -458,11 +643,17 @@ private void DrainPendingSnapshots()
finally
{
Interlocked.Exchange(ref _drainScheduled, 0);
- if (!_pendingSnapshots.IsEmpty && State == IoTestSessionState.Running)
+ if ((!_pendingEdgeSnapshots.IsEmpty || !_pendingSnapshots.IsEmpty) && State == IoTestSessionState.Running)
ScheduleDrain();
}
}
+ private void ClearPendingSnapshots()
+ {
+ _pendingSnapshots.Clear();
+ while (_pendingEdgeSnapshots.TryDequeue(out _)) { }
+ }
+
private void ProcessSnapshot(QueuedSnapshot snapshot)
{
if (State != IoTestSessionState.Running)
@@ -472,23 +663,12 @@ private void ProcessSnapshot(QueuedSnapshot snapshot)
if (!_activePointIndex.TryGetValue(key, out var points))
return;
+ var progressChanged = false;
foreach (var point in points.Distinct())
{
- if (point.CaptureMode == FatCaptureMode.OperatorSnapshot)
- {
- point.Runtime.ApplyObservation(new IoTestObservation(
- null,
- snapshot.Value,
- snapshot.CapturedAtUtc,
- IoTestValueNormalizer.ParseIedTimestamp(snapshot.DeviceTimestamp),
- snapshot.Quality,
- snapshot.SourceMode,
- snapshot.Sequence,
- _connectionGeneration));
- continue;
- }
-
- var normalized = IoTestValueNormalizer.Normalize(point, snapshot.Value);
+ var normalized = point.CaptureMode == FatCaptureMode.AutomaticTransition
+ ? IoTestValueNormalizer.Normalize(point, snapshot.Value)
+ : null;
var observation = new IoTestObservation(
normalized,
snapshot.Value,
@@ -498,46 +678,82 @@ private void ProcessSnapshot(QueuedSnapshot snapshot)
snapshot.SourceMode,
snapshot.Sequence,
_connectionGeneration);
- var evaluation = _captureCoordinator.Observe(point, observation);
- if (evaluation.Evidence != null)
- {
- var eventType = evaluation.Evidence.Verdict == IoEvidenceVerdict.Rejected
- ? "transition_rejected"
- : evaluation.Evidence.Transition == IoEvidenceTransition.On
- ? "on_evidence"
- : "off_evidence";
- if (!AppendOrFault(PointEvent(eventType, point, observation, evaluation.Evidence, evaluation.Reason, snapshot.Reason, snapshot.PreviousValue)))
- return;
- }
- else if (evaluation.StateChanged)
+ if (point.CaptureMode == FatCaptureMode.OperatorSnapshot)
{
- if (!AppendOrFault(PointEvent(
- "baseline_state",
- point,
- observation,
- null,
- evaluation.Reason,
- snapshot.Reason,
- snapshot.PreviousValue)))
- return;
+ point.Runtime.ApplyObservation(observation);
}
- else if (normalized == null)
+ else
{
- if (!AppendOrFault(PointEvent(
- "unresolved_edge",
- point,
- observation,
- null,
- "A live edge was observed but its digital state could not be normalized.",
- snapshot.Reason,
- snapshot.PreviousValue)))
- return;
+ var evaluation = _captureCoordinator.Observe(point, observation);
+ progressChanged |= evaluation.StateChanged || evaluation.Evidence != null;
+
+ if (evaluation.Evidence != null)
+ {
+ var eventType = evaluation.Evidence.Verdict == IoEvidenceVerdict.Rejected
+ ? "transition_rejected"
+ : evaluation.Evidence.Transition == IoEvidenceTransition.On
+ ? "on_evidence"
+ : "off_evidence";
+ if (!AppendOrFault(PointEvent(eventType, point, observation, evaluation.Evidence, evaluation.Reason, snapshot.Reason, snapshot.PreviousValue)))
+ return;
+ }
+ else if (evaluation.StateChanged)
+ {
+ if (!AppendOrFault(PointEvent(
+ "baseline_state",
+ point,
+ observation,
+ null,
+ evaluation.Reason,
+ snapshot.Reason,
+ snapshot.PreviousValue)))
+ return;
+ }
+ else if (normalized == null)
+ {
+ if (!AppendOrFault(PointEvent(
+ "unresolved_edge",
+ point,
+ observation,
+ null,
+ "A live edge was observed but its digital state could not be normalized.",
+ snapshot.Reason,
+ snapshot.PreviousValue)))
+ return;
+ }
}
+
+ var previousStage = point.Runtime.AutoCaptureStage;
+ var previousComplete = point.IsFatEvidenceComplete;
+ if (!ApplyAutoCapture(point, observation))
+ return;
+ progressChanged |= previousStage != point.Runtime.AutoCaptureStage || previousComplete != point.IsFatEvidenceComplete;
}
- RaiseProgress();
- UpdateRunningCompletionStatus();
+ if (progressChanged)
+ {
+ RaiseProgress();
+ UpdateRunningCompletionStatus();
+ }
+ }
+
+ private bool ApplyAutoCapture(IoTestPointPlan point, IoTestObservation observation)
+ {
+ var decision = _autoCaptureCoordinator.Observe(point, observation);
+ if (decision.Evidence != null && !AppendOrFault(FatValueEvent(point, decision.Evidence)))
+ return false;
+ PromoteAutoCaptureDecision(point, decision);
+ return true;
+ }
+
+ private static void PromoteAutoCaptureDecision(IoTestPointPlan point, FatAutoCaptureDecision decision)
+ {
+ point.Runtime.AutoCaptureStage = decision.Stage;
+ if (decision.Evidence != null)
+ point.Runtime.SetFatValueEvidence(decision.Evidence);
+ if (point.CaptureMode == FatCaptureMode.OperatorSnapshot || decision.Evidence != null)
+ point.Runtime.StatusReason = decision.Message;
}
private void UpdateRunningCompletionStatus()
@@ -558,8 +774,8 @@ private void UpdateRunningCompletionStatus()
var failed = automatic.Count(point => point.Runtime.State == IoTestPointState.Failed);
var snapshots = scoped.Count - automatic.Count;
StatusText =
- $"{ActiveIed.IedName} current evidence complete: {passed} PASS, {review} review, {failed} fail, {snapshots} operator-snapshot row(s) complete. " +
- "Capture remains running; current evidence may be replaced only by newer complete/operator-confirmed captures until Stop.";
+ $"{ActiveIed.IedName} current evidence complete: {passed} PASS, {review} review, {failed} fail, {snapshots} snapshot row(s) complete. " +
+ "Auto-capture is locked at the current pair; explicit Recapture is required to replace Value 1 or Value 2 until Stop.";
}
private void ActiveDevice_PropertyChanged(object? sender, PropertyChangedEventArgs e)
@@ -578,18 +794,64 @@ private void InterruptForConnectionLoss(string capturedStatus)
if (State != IoTestSessionState.Running || ActiveIed == null)
return;
+ var stagedCount = _pendingPairValue1.Count;
+ _pendingPairValue1.Clear();
+ _autoCaptureCoordinator.Clear();
State = IoTestSessionState.Interrupted;
- while (_pendingSnapshots.TryDequeue(out _)) { }
- StatusText = $"{ActiveIed.IedName} monitoring was interrupted ({capturedStatus}). Reconnect the IED, then Resume to establish a new baseline.";
+ ClearPendingSnapshots();
+ StatusText = $"{ActiveIed.IedName} monitoring was interrupted ({capturedStatus}). Reconnect the IED, then Resume to establish a new baseline." +
+ (stagedCount == 0 ? string.Empty : $" {stagedCount} staged pair Recapture item(s) were cancelled; current pointers were unchanged.");
AppendOrFault(SessionEvent("session_interrupted", StatusText));
}
+ private bool TryPrepareRecapture(
+ IReadOnlyCollection? points,
+ out List bindings,
+ out string failure)
+ {
+ bindings = new List();
+ failure = string.Empty;
+ if (State != IoTestSessionState.Running || ActiveIed == null)
+ {
+ failure = "Start this IED FAT session before using Recapture.";
+ return false;
+ }
+
+ var requested = points?.Where(point => point != null).Distinct().ToList() ?? new List();
+ if (requested.Count == 0)
+ {
+ failure = "Select one or more FAT rows before using Recapture.";
+ return false;
+ }
+
+ foreach (var point in requested)
+ {
+ if (!ActiveIed.TestPoints.Contains(point) || !_sessionPointIds.Contains(point.TestPointId))
+ {
+ failure = $"{point.SignalName} is not part of the active IED capture scope. No evidence changed.";
+ return false;
+ }
+ if (!point.WorkspaceSelected || !point.IsIncludedInFat || !point.TestEnabled || !point.ImportReady)
+ {
+ failure = $"{point.SignalName} is not eligible for Recapture because it left the shared/included/TEST/import-ready FAT scope. No evidence changed.";
+ return false;
+ }
+ if (!_activeLivePoints.TryGetValue(point.TestPointId, out var livePoint))
+ {
+ failure = $"{point.SignalName} does not have one active live point. No evidence changed.";
+ return false;
+ }
+ bindings.Add(new SessionBinding(point, livePoint));
+ }
+ return true;
+ }
+
private static List BuildSessionBindings(
IReadOnlyCollection captureScope,
Iec61850MonitorDevice device)
{
var result = new List();
- foreach (var point in captureScope.Where(point => point.IsIncludedInFat && point.IsLiveBound))
+ foreach (var point in captureScope.Where(point => point.WorkspaceSelected && point.IsIncludedInFat && point.IsLiveBound))
{
var expected = IoTestLiveBindingService.NormalizeReference(
string.IsNullOrWhiteSpace(point.LiveSignalReference) ? point.ObjectReference : point.LiveSignalReference);
@@ -631,6 +893,16 @@ private static IoTestObservation CreateObservation(
livePoint.Sequence,
connectionGeneration);
+ private FatLiveValueObservation CreateLiveValueObservation(Iec61850MonitorPoint livePoint)
+ => new(
+ livePoint.Value,
+ DateTimeOffset.UtcNow,
+ IoTestValueNormalizer.ParseIedTimestamp(livePoint.DeviceTimestamp),
+ livePoint.Quality,
+ livePoint.SourceMode,
+ livePoint.Sequence,
+ _connectionGeneration);
+
private void AddPointIndex(string deviceId, string reference, IoTestPointPlan point)
{
var key = PointKey(deviceId, reference);
@@ -703,34 +975,47 @@ private IoTestJournalEntry PointEvent(
Reason = evidence?.VerdictReason ?? reason
};
- private IoTestJournalEntry FatValueEvent(IoTestPointPlan point, FatValueEvidence evidence) => new()
+ private IoTestJournalEntry FatValueEvent(
+ IoTestPointPlan point,
+ FatValueEvidence evidence,
+ string? eventType = null,
+ string? reason = null)
{
- EventType = "fat_value_snapshot",
- RecordedAtUtc = evidence.CapturedAt,
- ProjectId = _project.ProjectId,
- SessionId = SessionId,
- IedName = point.IedName,
- IpAddress = point.IpAddress,
- SourceWorkbookName = _project.SourceWorkbookName,
- SourceWorkbookSha256 = _project.SourceWorkbookSha256,
- ApplicationVersion = CurrentApplicationVersion,
- Operator = Environment.UserName,
- Workstation = Environment.MachineName,
- TestPointId = point.TestPointId,
- SignalName = point.SignalName,
- ObjectReference = string.IsNullOrWhiteSpace(point.LiveSignalReference) ? point.ObjectReference : point.LiveSignalReference,
- Attempt = point.Runtime.Attempt,
- Transition = evidence.Slot.ToString(),
- ObservedValue = evidence.RawValue,
- IedTimestamp = evidence.IedTimestamp,
- Quality = evidence.Quality,
- AcquisitionSource = evidence.AcquisitionSource,
- PointSequence = evidence.Sequence,
- ConnectionGeneration = evidence.ConnectionGeneration,
- Verdict = "Accepted",
- Reason = $"Operator captured {evidence.Slot} from the current live IEC 61850 value.",
- EvidenceKind = "fat-value-snapshot"
- };
+ var automatic = evidence.CaptureKind == FatEvidenceCaptureKind.AutomaticValue;
+ var recapture = evidence.CaptureKind == FatEvidenceCaptureKind.OperatorRecapture;
+ return new IoTestJournalEntry
+ {
+ EventType = eventType ?? (automatic ? "fat_value_auto" : recapture ? "fat_value_recapture" : "fat_value_snapshot"),
+ RecordedAtUtc = evidence.CapturedAt,
+ ProjectId = _project.ProjectId,
+ SessionId = SessionId,
+ IedName = point.IedName,
+ IpAddress = point.IpAddress,
+ SourceWorkbookName = _project.SourceWorkbookName,
+ SourceWorkbookSha256 = _project.SourceWorkbookSha256,
+ ApplicationVersion = CurrentApplicationVersion,
+ Operator = Environment.UserName,
+ Workstation = Environment.MachineName,
+ TestPointId = point.TestPointId,
+ SignalName = point.SignalName,
+ ObjectReference = string.IsNullOrWhiteSpace(point.LiveSignalReference) ? point.ObjectReference : point.LiveSignalReference,
+ Attempt = point.Runtime.Attempt,
+ Transition = evidence.Slot.ToString(),
+ ObservedValue = evidence.RawValue,
+ IedTimestamp = evidence.IedTimestamp,
+ Quality = evidence.Quality,
+ AcquisitionSource = evidence.AcquisitionSource,
+ PointSequence = evidence.Sequence,
+ ConnectionGeneration = evidence.ConnectionGeneration,
+ Verdict = "Accepted",
+ Reason = reason ?? (automatic
+ ? $"Auto Capture accepted {evidence.Slot} from the stable live IEC 61850 value."
+ : recapture
+ ? $"Operator recaptured {evidence.Slot} from the current live IEC 61850 value."
+ : $"Operator captured {evidence.Slot} from the current live IEC 61850 value."),
+ EvidenceKind = automatic ? "fat-value-auto" : recapture ? "fat-value-recapture" : "fat-value-snapshot"
+ };
+ }
private void AppendRequired(IoTestJournalEntry entry)
{
@@ -745,7 +1030,7 @@ private void AppendBatchRequired(IReadOnlyCollection entries
var journal = _journal ?? throw new InvalidOperationException("No evidence journal is open for the active FAT session.");
var envelopes = journal.AppendBatch(entries);
if (envelopes.Count == 0)
- throw new InvalidOperationException("Evidence journal startup batch produced no records.");
+ throw new InvalidOperationException("Evidence journal batch produced no records.");
var envelope = envelopes[^1];
EvidenceRecordCount = envelope.JournalSequence;
LastJournalHash = envelope.Hash;
@@ -808,7 +1093,9 @@ private void CleanupSessionResources(bool disposeJournal, bool keepActiveIed = f
_activePointIndex.Clear();
_activeLivePoints.Clear();
_captureCoordinator.Clear();
- while (_pendingSnapshots.TryDequeue(out _)) { }
+ _autoCaptureCoordinator.Clear();
+ _pendingPairValue1.Clear();
+ ClearPendingSnapshots();
if (disposeJournal)
{
_journal?.Dispose();
@@ -858,4 +1145,4 @@ private sealed record QueuedSnapshot(
string Reason,
long Sequence,
DateTimeOffset CapturedAtUtc);
-}
\ No newline at end of file
+}
diff --git a/Services/IoTesting/IoTestSessionPreflight.cs b/Services/IoTesting/IoTestSessionPreflight.cs
index 7122629c9..508045120 100644
--- a/Services/IoTesting/IoTestSessionPreflight.cs
+++ b/Services/IoTesting/IoTestSessionPreflight.cs
@@ -10,19 +10,19 @@ public static IoTestSessionActionResult Validate(IoTestIedPlan? ied)
return IoTestSessionActionResult.Failure("Select an imported IED first.");
var unsafeEnabled = ied.TestPoints
- .Where(point => point.IsIncludedInFat && point.TestEnabled && !point.ImportReady)
+ .Where(point => point.WorkspaceSelected && point.IsIncludedInFat && point.TestEnabled && !point.ImportReady)
.ToList();
if (unsafeEnabled.Count > 0)
{
return IoTestSessionActionResult.Failure(
- $"{unsafeEnabled.Count} included, enabled signal(s) still require import/binding review. Disable them or repair their mapping before starting FAT.");
+ $"{unsafeEnabled.Count} shared-workspace-selected FAT signal(s) still require import/binding review. Disable their TEST scope or repair their mapping before starting FAT.");
}
var enabledReady = ied.TestPoints
- .Where(point => point.IsIncludedInFat && point.TestEnabled && point.ImportReady)
+ .Where(point => point.WorkspaceSelected && point.IsIncludedInFat && point.TestEnabled && point.ImportReady)
.ToList();
if (enabledReady.Count == 0)
- return IoTestSessionActionResult.Failure("No included, import-ready FAT signal is enabled for this IED.");
+ return IoTestSessionActionResult.Failure("No shared-workspace-selected, included, import-ready FAT signal has TEST enabled for this IED.");
var duplicateReferences = enabledReady
.GroupBy(
@@ -40,11 +40,11 @@ public static IoTestSessionActionResult Validate(IoTestIedPlan? ied)
var first = duplicateReferences[0];
var ids = string.Join(", ", first.Select(point => point.TestPointId));
return IoTestSessionActionResult.Failure(
- $"One live IEC 61850 reference is assigned to multiple enabled test points in the included FAT scope ({ids}). Resolve the duplicate mapping before FAT so one edge cannot produce multiple results.");
+ $"One live IEC 61850 reference is assigned to multiple enabled test points in the shared included FAT scope ({ids}). Resolve the duplicate mapping before FAT so one edge cannot produce multiple results.");
}
return IoTestSessionActionResult.Success(
- $"{enabledReady.Count} included signal(s) passed FAT session preflight.");
+ $"{enabledReady.Count} shared-workspace-selected FAT signal(s) passed session preflight.");
}
private static bool IsDistinctSclDataSetMembershipFanOut(IEnumerable points)
diff --git a/Services/IoTesting/IoTestSignalSelectionService.cs b/Services/IoTesting/IoTestSignalSelectionService.cs
index 8e6d24bc8..067a08d7a 100644
--- a/Services/IoTesting/IoTestSignalSelectionService.cs
+++ b/Services/IoTesting/IoTestSignalSelectionService.cs
@@ -21,15 +21,18 @@ public sealed record IoTestSignalSelectionResult(
}
///
-/// Resolves the enabled IO-list scope against one discovered IED model without
+/// Resolves an IO-list/FAT acquisition scope against one discovered IED model without
/// guessing. Exact references remain highest priority. Canonical IEC 61850 forms
/// accept vendor-safe spelling differences only when the best candidate is unique.
-/// Weak source rows are resolved only after stronger references have claimed their
-/// signals, allowing deterministic sibling evidence without fuzzy text matching.
+/// Weak legacy source rows remain gated by TEST selection. Direct-SCL workspace rows
+/// are acquisition candidates whenever shared workspace selection keeps them visible;
+/// their TEST checkbox is evidence authority only.
///
public sealed class IoTestSignalSelectionService
{
internal const string SclDataSetAuthorityBindingStatus = "SCL_DATASET_AUTHORITY";
+ internal const string SclWorkspaceAuthorityBindingStatus = "SCL_WORKSPACE_AUTHORITY";
+ private const int SclStaticMembershipIdentityBonus = 1000;
private static readonly Regex ProtectionCodeRegex = new(
@"\((?\d{2,3}[A-Z]{0,3})(?:\s*-\s*[^)]*)?\)",
@@ -49,8 +52,16 @@ public IoTestSignalSelectionResult Resolve(
// semantic inference in ARSAS; the engine remains the sole membership authority.
Iec61850DataSetSignalInventoryService.EnsureMandatorySignals(device);
+ // P1 + shared workspace authority: direct-SCL connection/live acquisition follows
+ // the Engineering/FAT workspace selection, not TEST. Legacy workbook rows retain
+ // their historical TestEnabled gate because they do not have an independent
+ // SCL-owned workspace identity.
var requested = ied.TestPoints
- .Where(point => point.TestEnabled && point.ImportReady)
+ .Where(point =>
+ point.WorkspaceSelected &&
+ point.IsIncludedInFat &&
+ point.ImportReady &&
+ (point.TestEnabled || IsDirectSclAuthority(point)))
.ToList();
var matches = new List(requested.Count);
var missing = new List();
@@ -88,11 +99,12 @@ public IoTestSignalSelectionResult Resolve(
// assignments as elimination evidence. No candidate is ever selected by text
// similarity: it must still be the one unique best IEC object left.
//
- // SCL FAT rows are different from legacy workbook rows: their identity is the
- // engine-authoritative static DataSet membership, not a unique runtime leaf. Two
- // distinct memberships may legally resolve to the same primary DataAttribute, so
- // those authority rows are allowed to fan out from one proven live signal. The
- // signal is still recorded as used so a legacy workbook row cannot silently share it.
+ // Static DataSet FAT rows are different from legacy workbook rows: their identity
+ // is an engine-authoritative membership, not a unique runtime leaf. Two distinct
+ // memberships may legally resolve to the same primary DataAttribute, so those
+ // static authority rows are allowed to fan out from one proven live signal. Manual
+ // workspace rows own a normal unique SCL object identity and therefore do not use
+ // this static-membership sharing exception.
var madeProgress = true;
while (madeProgress && unresolved.Count > 0)
{
@@ -151,7 +163,7 @@ public IoTestSignalSelectionResult Resolve(
matches,
missing,
ambiguous,
- $"Resolved {matches.Count} enabled FAT signal(s) to discovered model points.{smartText}");
+ $"Resolved {matches.Count} FAT acquisition signal(s) to discovered model points.{smartText}");
}
internal static bool IsSclDataSetAuthority(IoTestPointPlan point)
@@ -160,6 +172,15 @@ internal static bool IsSclDataSetAuthority(IoTestPointPlan point)
SclDataSetAuthorityBindingStatus,
StringComparison.OrdinalIgnoreCase);
+ internal static bool IsSclWorkspaceAuthority(IoTestPointPlan point)
+ => string.Equals(
+ point.BindingStatus,
+ SclWorkspaceAuthorityBindingStatus,
+ StringComparison.OrdinalIgnoreCase);
+
+ internal static bool IsDirectSclAuthority(IoTestPointPlan point)
+ => IsSclDataSetAuthority(point) || IsSclWorkspaceAuthority(point);
+
private static int BestSignalScore(
IoTestPointPlan point,
SignalDefinition signal,
@@ -170,11 +191,20 @@ private static int BestSignalScore(
if (importedReferences.Count == 0)
return 0;
+ // Direct static SCL FAT owns stronger identity than a runtime leaf: one row
+ // corresponds to one exact static DataSet membership. A connected Engineering
+ // Workspace can legitimately contain both a generic scalar signal and a dedicated
+ // static-member projection with the same ObjectReference. Prefer exact
+ // (DataSetReference + DisplayReference) membership identity, then use the resolved
+ // ObjectReference only as the acquisition target.
+ if (HasExactSclStaticMembershipIdentity(point, signal))
+ return IoTestReferenceMatcher.ExactScore + SclStaticMembershipIdentityBonus;
+
// ARIEC deliberately keeps static FCDA/FCD membership identity in
// DisplayReference while ObjectReference may remain the resolved runtime leaf.
- // SCL-backed FAT rows therefore compare against both engine-owned identities.
+ // Manual SCL workspace rows also preserve DisplayReference as exact source identity.
// Legacy workbook rows keep the old ObjectReference-only contract.
- var observedReferences = IsSclDataSetAuthority(point)
+ var observedReferences = IsDirectSclAuthority(point)
? new[] { signal.ObjectReference, signal.DisplayReference }
.Where(value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.OrdinalIgnoreCase)
@@ -193,6 +223,38 @@ private static int BestSignalScore(
.Max();
}
+ private static bool HasExactSclStaticMembershipIdentity(
+ IoTestPointPlan point,
+ SignalDefinition signal)
+ {
+ if (!IsSclDataSetAuthority(point) || string.IsNullOrWhiteSpace(signal.DisplayReference))
+ return false;
+
+ if (!string.IsNullOrWhiteSpace(point.DataSetName) &&
+ !string.IsNullOrWhiteSpace(signal.DataSetReference) &&
+ !IoTestLiveBindingService.NormalizeReference(point.DataSetName)
+ .Equals(
+ IoTestLiveBindingService.NormalizeReference(signal.DataSetReference),
+ StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ var display = IoTestLiveBindingService.NormalizeReference(signal.DisplayReference);
+ if (display.Length == 0)
+ return false;
+
+ return new[]
+ {
+ point.SourceIecReference,
+ point.ReportDisplayReference,
+ point.EventLogSearchReference
+ }
+ .Where(reference => !string.IsNullOrWhiteSpace(reference))
+ .Select(IoTestLiveBindingService.NormalizeReference)
+ .Any(reference => reference.Equals(display, StringComparison.OrdinalIgnoreCase));
+ }
+
private static List NarrowByProtectionIdentity(
IoTestPointPlan point,
IReadOnlyCollection candidates)
diff --git a/Services/IoTesting/IoTestWorkspaceBootstrapService.cs b/Services/IoTesting/IoTestWorkspaceBootstrapService.cs
index 16c2d9476..1fb83b1a4 100644
--- a/Services/IoTesting/IoTestWorkspaceBootstrapService.cs
+++ b/Services/IoTesting/IoTestWorkspaceBootstrapService.cs
@@ -150,6 +150,7 @@ private static void ApplySnapshotProgress(IoTestProject project, string snapshot
var savedIeds = RequiredArray(savedProject, "ieds").EnumerateArray().ToArray();
RestoreIedLevelEvidence(project, savedIeds);
+ RestoreMissingManualWorkspaceRows(project, savedIeds);
var savedPoints = savedIeds
.SelectMany(ied => RequiredArray(ied, "testPoints").EnumerateArray())
@@ -160,8 +161,14 @@ private static void ApplySnapshotProgress(IoTestProject project, string snapshot
if (!savedPoints.TryGetValue(point.TestPointId, out var saved))
continue;
- // TestEnabled and FAT disposition are both operator-authored and are restored
- // independently. Restoring/removing a FAT row must never rewrite its checkbox.
+ // Shared Engineering/FAT membership, FAT TEST scope, and FAT disposition are
+ // three independent operator authorities. Same-source continuation restores all
+ // three without allowing Remove/Restore to rewrite Engineering selection.
+ if (saved.TryGetProperty("workspaceSelected", out var workspaceSelected) &&
+ workspaceSelected.ValueKind is JsonValueKind.True or JsonValueKind.False)
+ {
+ point.WorkspaceSelected = workspaceSelected.GetBoolean();
+ }
if (saved.TryGetProperty("testEnabled", out var enabled) && enabled.ValueKind is JsonValueKind.True or JsonValueKind.False)
point.TestEnabled = enabled.GetBoolean();
point.RestoreFatDisposition(OptionalEnum(saved, "fatDisposition", FatSignalDisposition.Included));
@@ -224,6 +231,116 @@ private static bool SnapshotSourceMatches(JsonElement savedProject, IoTestProjec
legacyWorkbookSha.Equals(project.SourceWorkbookSha256, StringComparison.OrdinalIgnoreCase);
}
+ private static void RestoreMissingManualWorkspaceRows(
+ IoTestProject project,
+ IReadOnlyList savedIeds)
+ {
+ if (!project.SchemaVersion.StartsWith("ARSAS-FAT-SCL-", StringComparison.OrdinalIgnoreCase))
+ return;
+
+ var trustedSclHashes = project.Sources
+ .Where(source => source.Kind.Equals(IoFatSourceKinds.Scl, StringComparison.OrdinalIgnoreCase))
+ .Select(source => source.Sha256)
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+ if (trustedSclHashes.Count == 0)
+ return;
+
+ foreach (var ied in project.Ieds)
+ {
+ var savedIed = savedIeds.FirstOrDefault(candidate =>
+ OptionalString(candidate, "iedName", string.Empty).Equals(ied.IedName, StringComparison.OrdinalIgnoreCase) &&
+ OptionalString(candidate, "ipAddress", string.Empty).Equals(ied.IpAddress, StringComparison.OrdinalIgnoreCase));
+ if (savedIed.ValueKind != JsonValueKind.Object)
+ continue;
+
+ var existingIds = ied.TestPoints
+ .Select(point => point.TestPointId)
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var savedPoint in RequiredArray(savedIed, "testPoints").EnumerateArray())
+ {
+ var testPointId = RequiredString(savedPoint, "testPointId");
+ if (existingIds.Contains(testPointId))
+ continue;
+
+ var bindingStatus = OptionalString(savedPoint, "bindingStatus", string.Empty);
+ if (!bindingStatus.Equals(
+ IoTestSignalSelectionService.SclWorkspaceAuthorityBindingStatus,
+ StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ if (!testPointId.StartsWith("scl-manual-", StringComparison.OrdinalIgnoreCase))
+ throw new InvalidDataException($"Saved manual SCL row '{testPointId}' has an invalid workspace identity.");
+
+ var savedIedName = RequiredString(savedPoint, "iedName");
+ var savedIpAddress = RequiredString(savedPoint, "ipAddress");
+ if (!savedIedName.Equals(ied.IedName, StringComparison.OrdinalIgnoreCase) ||
+ !savedIpAddress.Equals(ied.IpAddress, StringComparison.OrdinalIgnoreCase))
+ {
+ throw new InvalidDataException($"Saved manual SCL row '{testPointId}' does not belong to its persisted IED endpoint.");
+ }
+
+ var sourceSha = OptionalString(savedPoint, "signalAddress", string.Empty).Trim();
+ if (sourceSha.Length == 0 || !trustedSclHashes.Contains(sourceSha))
+ {
+ throw new InvalidDataException(
+ $"Saved manual SCL row '{testPointId}' does not belong to the exact reopened SCL source set.");
+ }
+
+ var objectReference = RequiredString(savedPoint, "objectReference");
+ var sourceReference = OptionalString(savedPoint, "sourceIecReference", string.Empty);
+ if (string.IsNullOrWhiteSpace(objectReference) || string.IsNullOrWhiteSpace(sourceReference))
+ throw new InvalidDataException($"Saved manual SCL row '{testPointId}' has incomplete IEC 61850 identity.");
+
+ var point = new IoTestPointPlan
+ {
+ TestPointId = testPointId,
+ IedName = savedIedName,
+ IpAddress = savedIpAddress,
+ SignalName = RequiredString(savedPoint, "signalName"),
+ ObjectReference = objectReference,
+ FunctionalConstraint = OptionalString(savedPoint, "functionalConstraint", string.Empty),
+ ExpectedOnText = RequiredString(savedPoint, "expectedOnText"),
+ ExpectedOffText = RequiredString(savedPoint, "expectedOffText"),
+ ExpectedOnRaw = OptionalInt(savedPoint, "expectedOnRaw", 1),
+ ExpectedOffRaw = OptionalInt(savedPoint, "expectedOffRaw", 0),
+ DataType = OptionalString(savedPoint, "dataType", "SDI"),
+ SignalAddress = sourceSha,
+ DataSetName = OptionalString(savedPoint, "dataSetName", string.Empty),
+ LogicalDevice = OptionalString(savedPoint, "logicalDevice", string.Empty),
+ LogicalNode = OptionalString(savedPoint, "logicalNode", string.Empty),
+ DataObject = OptionalString(savedPoint, "dataObject", string.Empty),
+ DataAttribute = OptionalString(savedPoint, "dataAttribute", string.Empty),
+ Cdc = OptionalString(savedPoint, "cdc", string.Empty),
+ SourceIecReference = sourceReference,
+ ReportDisplayReference = OptionalString(savedPoint, "reportDisplayReference", sourceReference),
+ EventLogSearchReference = OptionalString(savedPoint, "eventLogSearchReference", objectReference),
+ EvidenceExpected = OptionalString(savedPoint, "evidenceExpected", string.Empty),
+ MappingQuality = OptionalString(savedPoint, "mappingQuality", string.Empty),
+ ReviewStatus = OptionalString(savedPoint, "reviewStatus", string.Empty),
+ ReviewReason = OptionalString(savedPoint, "reviewReason", string.Empty),
+ EventLogMatch = OptionalString(savedPoint, "eventLogMatch", string.Empty),
+ EvidenceReference = OptionalString(savedPoint, "evidenceReference", string.Empty),
+ ReviewerComment = OptionalString(savedPoint, "reviewerComment", string.Empty),
+ SourceSheet = OptionalString(savedPoint, "sourceSheet", string.Empty),
+ SourceRow = OptionalInt(savedPoint, "sourceRow", 0),
+ SignalKind = OptionalEnum(savedPoint, "signalKind", FatSignalKind.Other),
+ CaptureMode = OptionalEnum(savedPoint, "captureMode", FatCaptureMode.OperatorSnapshot),
+ WorkspaceSelected = OptionalBool(savedPoint, "workspaceSelected", true),
+ TestEnabled = OptionalBool(savedPoint, "testEnabled", true),
+ ImportReady = OptionalBool(savedPoint, "importReady", true),
+ BindingStatus = bindingStatus,
+ BindingEvidence = OptionalString(savedPoint, "bindingEvidence", string.Empty)
+ };
+
+ if (!ied.AddTestPoint(point))
+ throw new InvalidDataException($"Saved manual SCL row '{testPointId}' collides with the reopened workspace.");
+ existingIds.Add(testPointId);
+ }
+ }
+ }
+
private static void RestoreIedLevelEvidence(IoTestProject project, IReadOnlyList savedIeds)
{
foreach (var ied in project.Ieds)
@@ -303,6 +420,11 @@ private static string OptionalString(JsonElement parent, string property, string
? value.GetString() ?? fallback
: fallback;
+ private static bool OptionalBool(JsonElement parent, string property, bool fallback)
+ => parent.TryGetProperty(property, out var value) && value.ValueKind is JsonValueKind.True or JsonValueKind.False
+ ? value.GetBoolean()
+ : fallback;
+
private static int OptionalInt(JsonElement parent, string property, int fallback)
=> parent.TryGetProperty(property, out var value) && value.TryGetInt32(out var number)
? number
diff --git a/Services/NativeIec61850Client.AggregateProjection.cs b/Services/NativeIec61850Client.AggregateProjection.cs
new file mode 100644
index 000000000..b0f13209a
--- /dev/null
+++ b/Services/NativeIec61850Client.AggregateProjection.cs
@@ -0,0 +1,24 @@
+using AR.Iec61850.Discovery;
+
+namespace ArIED61850Tester.Services;
+
+public sealed partial class NativeIec61850Client
+{
+ ///
+ /// Returns the same per-IED model authority used by semantic report projection.
+ /// Direct-SCL FAT therefore uses the opened SCL design model; fully discovered
+ /// Engineering sessions can use the live model. No ambient/global model is consulted.
+ ///
+ internal LiveIedModelDiscoveryDocument? AggregateProjectionAuthorityModel
+ => _semanticReportProjectionAuthorityModel ?? _liveModel;
+
+ internal bool TryBuildSchemaSafeAggregateReadPlan(
+ string requestedReference,
+ out SchemaSafeAggregateProjectionService.ReadPlan plan,
+ out string status)
+ => SchemaSafeAggregateProjectionService.TryBuildReadPlan(
+ AggregateProjectionAuthorityModel,
+ requestedReference,
+ out plan,
+ out status);
+}
diff --git a/Services/NativeIec61850Client.HybridReporting.cs b/Services/NativeIec61850Client.HybridReporting.cs
index 99b3b0ba6..bcf9a5de5 100644
--- a/Services/NativeIec61850Client.HybridReporting.cs
+++ b/Services/NativeIec61850Client.HybridReporting.cs
@@ -46,6 +46,7 @@ public async Task BuildHybridReportPlansAsync(
cancellationToken.ThrowIfCancellationRequested();
_authoritativeHybridSubscriptions.Clear();
+ ResetSemanticReportProjectionContext();
var planningModel = ResolveHybridPlanningModel(device);
if (planningModel is null)
@@ -78,6 +79,8 @@ public async Task BuildHybridReportPlansAsync(
};
}
+ SetSemanticReportProjectionAuthority(planningModel);
+
// A fast SCL connect deliberately skips full live-model discovery. The opened SCL
// design model is still an ARIEC-typed model and is therefore valid as the catalog
// authority for literal signal mapping. It is NOT treated as live RCB evidence:
diff --git a/Services/NativeIec61850Client.SemanticReporting.cs b/Services/NativeIec61850Client.SemanticReporting.cs
new file mode 100644
index 000000000..15712ab93
--- /dev/null
+++ b/Services/NativeIec61850Client.SemanticReporting.cs
@@ -0,0 +1,109 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using AR.Iec61850.Discovery;
+using ArMms = AR.Iec61850.Mms;
+
+namespace ArIED61850Tester.Services;
+
+///
+/// P5.4 report-value projection bridge. Report protocol/member identity stays engine-owned;
+/// this layer only supplies the already-authoritative per-IED model so structured static
+/// DataSet members can fan out to proven scalar descendants before ARSAS updates live points.
+///
+public sealed partial class NativeIec61850Client
+{
+ private LiveIedModelDiscoveryDocument? _semanticReportProjectionAuthorityModel;
+ private LiveIedModelDiscoveryDocument? _semanticReportProjectionContextModel;
+ private ArMms.MmsReportSemanticProjectionContext? _semanticReportProjectionContext;
+ private readonly HashSet _semanticReportProjectionWarningsSeen = new(StringComparer.OrdinalIgnoreCase);
+
+ private void SetSemanticReportProjectionAuthority(LiveIedModelDiscoveryDocument model)
+ {
+ ArgumentNullException.ThrowIfNull(model);
+ if (ReferenceEquals(_semanticReportProjectionAuthorityModel, model))
+ return;
+
+ _semanticReportProjectionAuthorityModel = model;
+ _semanticReportProjectionContextModel = null;
+ _semanticReportProjectionContext = null;
+ _semanticReportProjectionWarningsSeen.Clear();
+ }
+
+ private void ResetSemanticReportProjectionContext()
+ {
+ _semanticReportProjectionAuthorityModel = null;
+ _semanticReportProjectionContextModel = null;
+ _semanticReportProjectionContext = null;
+ _semanticReportProjectionWarningsSeen.Clear();
+ }
+
+ private ArMms.MmsReportValueProjection ProjectReportValue(ArMms.MmsReportFrame report)
+ {
+ ArgumentNullException.ThrowIfNull(report);
+
+ // Hybrid FAT uses the exact planning authority (live model or opened SCL design).
+ // Generic report callers use the fresh live model built by reporting discovery.
+ // No global/ambient semantic state is used, so model evidence cannot cross IEDs.
+ var model = _semanticReportProjectionAuthorityModel ?? _liveModel;
+ if (model is null)
+ return ArMms.MmsReportValueProjector.Project(report);
+
+ try
+ {
+ if (_semanticReportProjectionContext is null ||
+ !ReferenceEquals(_semanticReportProjectionContextModel, model))
+ {
+ _semanticReportProjectionContext = ArMms.MmsReportSemanticProjectionContext.Create(model);
+ _semanticReportProjectionContextModel = model;
+ _semanticReportProjectionWarningsSeen.Clear();
+ }
+
+ var projection = ArMms.MmsSemanticReportValueProjector.Project(
+ report,
+ _semanticReportProjectionContext);
+ return SuppressRepeatedSemanticWarnings(projection);
+ }
+ catch (Exception ex)
+ {
+ // Projection must never invent evidence or tear down a healthy MMS association.
+ // Preserve the established raw update and expose an explicit fail-closed warning.
+ var baseline = ArMms.MmsReportValueProjector.Project(report);
+ var fallback = new ArMms.MmsReportValueProjection
+ {
+ Updates = baseline.Updates,
+ Warnings = baseline.Warnings
+ .Concat(new[]
+ {
+ $"REPORT_SEMANTIC_FALLBACK: model-backed projection failed closed: {ex.GetType().Name}: {ex.Message}"
+ })
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToArray()
+ };
+ return SuppressRepeatedSemanticWarnings(fallback);
+ }
+ }
+
+ private ArMms.MmsReportValueProjection SuppressRepeatedSemanticWarnings(
+ ArMms.MmsReportValueProjection projection)
+ {
+ if (projection.Warnings.Count == 0)
+ return projection;
+
+ // Structured report projection runs for every report frame. The same structural
+ // expansion warning can therefore repeat indefinitely even though it describes a
+ // stable model fact. Keep processing every value update, but surface each identical
+ // semantic warning once per projection model/session to avoid log/UI churn.
+ var freshWarnings = projection.Warnings
+ .Where(warning => _semanticReportProjectionWarningsSeen.Add(warning))
+ .ToArray();
+ if (freshWarnings.Length == projection.Warnings.Count)
+ return projection;
+
+ return new ArMms.MmsReportValueProjection
+ {
+ Updates = projection.Updates,
+ Warnings = freshWarnings
+ };
+ }
+}
diff --git a/Services/NativeIec61850Client.cs b/Services/NativeIec61850Client.cs
index 507f2d34e..f71c41a34 100644
--- a/Services/NativeIec61850Client.cs
+++ b/Services/NativeIec61850Client.cs
@@ -64,6 +64,7 @@ public async Task ConnectAsync(string ipAddress, int port, CancellationToken can
_liveModel = null;
_reportMonitorSessions.Clear();
_reportMonitorCoverage.Clear();
+ ResetSemanticReportProjectionContext();
Interlocked.Exchange(ref _engineCompatibilityWarningIssued, 0);
DetectedIdentity = new Iec61850DeviceIdentity();
_host = ipAddress?.Trim() ?? string.Empty;
@@ -490,7 +491,7 @@ public async Task ReceiveReportMonitorSliceAsync
ReceivedAt = report.ReceivedAt
});
- var projection = ArMms.MmsReportValueProjector.Project(report);
+ var projection = ProjectReportValue(report);
warnings.AddRange(projection.Warnings);
updates.AddRange(projection.Updates.Select(update => new NativeReportValueUpdate
{
@@ -4058,6 +4059,23 @@ private static string NormalizeFunctionalConstraint(string functionalConstraint,
private Iec61850ReadValue? ProjectReadValue(ArMms.MmsDataValue? value, string dataType, string readReference, string requestedReference)
{
+ // Explicit object-level DataSet members must be projected before generic
+ // first-scalar semantic binding. Otherwise a three-phase THD object collapses
+ // to phase A and loses the B/C values carried by the same MMS structure.
+ if (value != null &&
+ SignalDefinition.IsThreePhaseMeasurementAggregate(requestedReference) &&
+ TryProjectThreePhaseAggregate(value, requestedReference, readReference, out var aggregate))
+ {
+ return aggregate;
+ }
+
+ if (value != null &&
+ SignalDefinition.IsDemandEnergyAggregate(requestedReference) &&
+ TryProjectDemandEnergyAggregate(value, requestedReference, readReference, dataType, out var demand))
+ {
+ return demand;
+ }
+
if (TryProjectStructuredLeafBySemantic(value, dataType, readReference, requestedReference, out var semanticProjection, out var semanticStatus))
return semanticProjection;
@@ -4094,6 +4112,72 @@ private static string NormalizeFunctionalConstraint(string functionalConstraint,
};
}
+ private static bool TryProjectThreePhaseAggregate(
+ ArMms.MmsDataValue value,
+ string requestedReference,
+ string readReference,
+ out Iec61850ReadValue projected)
+ {
+ projected = new Iec61850ReadValue();
+ if (value.Kind is not (ArMms.MmsDataKind.Structure or ArMms.MmsDataKind.Array) ||
+ value.Children.Count < 3)
+ {
+ return false;
+ }
+
+ var phaseValues = new List(3);
+ foreach (var phase in value.Children.Take(3))
+ {
+ var scalar = FindFirstFloating(phase) ?? FindFirstInteger(phase);
+ if (scalar == null)
+ return false;
+
+ var converted = ConvertProjectedValue(scalar, "Float32", requestedReference);
+ phaseValues.Add(Convert.ToDouble(converted, CultureInfo.InvariantCulture)
+ .ToString("0.######", CultureInfo.InvariantCulture));
+ }
+
+ var display = string.Join(", ", phaseValues);
+ projected = new Iec61850ReadValue
+ {
+ Value = display,
+ DisplayValue = display,
+ Quality = DecodeQuality(value),
+ DeviceTimestamp = DecodeTimestamp(value),
+ SourceReference = requestedReference,
+ ReadReference = readReference,
+ Projection = "projected-three-phase-aggregate"
+ };
+ return true;
+ }
+
+ private static bool TryProjectDemandEnergyAggregate(
+ ArMms.MmsDataValue value,
+ string requestedReference,
+ string readReference,
+ string dataType,
+ out Iec61850ReadValue projected)
+ {
+ projected = new Iec61850ReadValue();
+ var scalar = FindFirstFloating(value) ?? FindFirstInteger(value);
+ if (scalar == null)
+ return false;
+
+ var raw = ConvertProjectedValue(scalar, dataType, requestedReference);
+ var display = FormatProjectedDisplay(raw, dataType);
+ projected = new Iec61850ReadValue
+ {
+ Value = raw,
+ DisplayValue = display,
+ Quality = DecodeQuality(value),
+ DeviceTimestamp = DecodeTimestamp(value),
+ SourceReference = requestedReference,
+ ReadReference = readReference,
+ Projection = "projected-demand-energy-aggregate"
+ };
+ return true;
+ }
+
private bool TryProjectWithArIecBinding(
ArMms.MmsDataValue? value,
string dataType,
diff --git a/Services/SchemaSafeAggregateProjectionService.cs b/Services/SchemaSafeAggregateProjectionService.cs
new file mode 100644
index 000000000..fcc8817f6
--- /dev/null
+++ b/Services/SchemaSafeAggregateProjectionService.cs
@@ -0,0 +1,557 @@
+using System.Globalization;
+using AR.Iec61850.Binding;
+using AR.Iec61850.Discovery;
+using ArMms = AR.Iec61850.Mms;
+
+namespace ArIED61850Tester.Services;
+
+///
+/// Projects explicitly structured FAT/Engineering measurement parents only after the
+/// authoritative IEC 61850 schema has named every value that is published. This service
+/// deliberately never chooses a child by raw MMS position or by "first numeric" shape.
+///
+public static class SchemaSafeAggregateProjectionService
+{
+ public sealed record Projection(
+ object? Value,
+ string DisplayValue,
+ string Quality,
+ string DeviceTimestamp,
+ string ProjectionStatus);
+
+ public sealed record ReadLeaf(
+ string Reference,
+ string Label,
+ string DataType);
+
+ public sealed record ReadPlan(
+ string ParentReference,
+ string Kind,
+ IReadOnlyList Leaves);
+
+ private sealed record BoundLeaf(
+ Iec61850BoundValueRow Row,
+ IReadOnlyList Ancestors,
+ double NumericValue);
+
+ ///
+ /// Builds the exact scalar-read plan used by the live FAT/Engineering runtime.
+ /// The plan is derived only from named attributes in the authoritative SCL/live model.
+ /// It never derives phase identity from MMS child position or numeric value ordering.
+ ///
+ public static bool TryBuildReadPlan(
+ LiveIedModelDiscoveryDocument? authorityModel,
+ string requestedReference,
+ out ReadPlan plan,
+ out string status)
+ {
+ plan = new ReadPlan(requestedReference ?? string.Empty, string.Empty, Array.Empty());
+ status = string.Empty;
+
+ if (authorityModel is null)
+ {
+ status = "Schema-safe aggregate read plan blocked: no authoritative SCL/live IEC 61850 model is attached.";
+ return false;
+ }
+
+ if (!TryFindDataObject(authorityModel, requestedReference, out var dataObject))
+ {
+ status = $"Schema-safe aggregate read plan blocked: DataObject schema was not found uniquely for {requestedReference}.";
+ return false;
+ }
+
+ if (IsThreePhaseThd(requestedReference, out var phases))
+ {
+ var leaves = new List(phases.Count);
+ foreach (var phase in phases)
+ {
+ var prefix = NormalizeReference(requestedReference) + "." + phase.Path.ToLowerInvariant();
+ var tiers = new[]
+ {
+ new[] { prefix + ".cval.mag.f" },
+ new[] { prefix + ".mag.f" },
+ new[] { prefix + ".instcval.mag.f", prefix + ".instmag.f" }
+ };
+
+ if (!TryResolvePreferredAttributeReference(dataObject, tiers, out var reference, out var failure))
+ {
+ status = $"Schema-safe THD read plan blocked for {requestedReference}: phase {phase.Label} has no unique named magnitude leaf. {failure}";
+ return false;
+ }
+
+ leaves.Add(new ReadLeaf(reference, phase.Label, "Float32"));
+ }
+
+ if (leaves.Select(leaf => NormalizeReference(leaf.Reference))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .Count() != phases.Count)
+ {
+ status = $"Schema-safe THD read plan blocked for {requestedReference}: phase leaves are not unique.";
+ return false;
+ }
+
+ plan = new ReadPlan(requestedReference, "ThreePhaseThd", leaves);
+ status = $"Schema-safe THD read plan resolved exact leaves: {string.Join(", ", leaves.Select(leaf => leaf.Reference))}.";
+ return true;
+ }
+
+ if (IsDemandEnergy(requestedReference))
+ {
+ var prefix = NormalizeReference(requestedReference);
+ var tiers = new[]
+ {
+ new[] { prefix + ".mag.f" },
+ new[] { prefix + ".cval.mag.f" },
+ new[] { prefix + ".instmag.f", prefix + ".instcval.mag.f" }
+ };
+
+ if (!TryResolvePreferredAttributeReference(dataObject, tiers, out var reference, out var failure))
+ {
+ status = $"Schema-safe DmdWh read plan blocked for {requestedReference}: no unique named energy magnitude leaf. {failure}";
+ return false;
+ }
+
+ plan = new ReadPlan(
+ requestedReference,
+ "DemandEnergy",
+ new[] { new ReadLeaf(reference, "Value", "Float32") });
+ status = $"Schema-safe DmdWh read plan resolved exact leaf {reference}.";
+ return true;
+ }
+
+ status = $"Schema-safe aggregate read planning does not own {requestedReference}.";
+ return false;
+ }
+
+ public static bool TryProject(
+ LiveIedModelDiscoveryDocument? authorityModel,
+ ArMms.MmsDataValue? value,
+ string requestedReference,
+ string readReference,
+ string dataType,
+ out Projection projection,
+ out string status)
+ {
+ projection = new Projection(null, "-", string.Empty, string.Empty, string.Empty);
+ status = string.Empty;
+
+ if (authorityModel is null)
+ {
+ status = "Schema-safe aggregate projection blocked: no authoritative SCL/live IEC 61850 model is attached.";
+ return false;
+ }
+
+ if (value is null || value.Kind is not (ArMms.MmsDataKind.Structure or ArMms.MmsDataKind.Array))
+ {
+ status = "Schema-safe aggregate projection requires a structured MMS value.";
+ return false;
+ }
+
+ if (!TryFindDataObject(authorityModel, requestedReference, out var dataObject))
+ {
+ status = $"Schema-safe aggregate projection blocked: DataObject schema was not found for {requestedReference}.";
+ return false;
+ }
+
+ var rootSchema = Iec61850DataObjectSchemaBuilder.FromLiveDataObject(dataObject).ToRootNode();
+ var readSchema = TryFindSchemaNode(rootSchema, readReference, out var exactReadSchema)
+ ? exactReadSchema
+ : ReferencesEqual(readReference, dataObject.Reference)
+ ? rootSchema
+ : null;
+ if (readSchema is null)
+ {
+ status = $"Schema-safe aggregate projection blocked: read reference {readReference} is outside schema {dataObject.Reference}.";
+ return false;
+ }
+
+ var binding = Iec61850ValueBindingEngine.Bind(readSchema, value);
+ if (binding.HasMismatch)
+ {
+ status = $"Schema-safe aggregate projection blocked by schema/value mismatch: {FormatDiagnostics(binding.Diagnostics)}";
+ return false;
+ }
+
+ if (IsThreePhaseThd(requestedReference, out var phases))
+ return TryProjectThreePhase(binding.Root, requestedReference, readReference, phases, out projection, out status);
+
+ if (IsDemandEnergy(requestedReference))
+ return TryProjectDemandEnergy(binding.Root, requestedReference, readReference, dataType, out projection, out status);
+
+ status = $"Schema-safe aggregate projection does not own {requestedReference}.";
+ return false;
+ }
+
+ private static bool TryProjectThreePhase(
+ Iec61850BoundValueRow root,
+ string requestedReference,
+ string readReference,
+ IReadOnlyList<(string Path, string Label)> phases,
+ out Projection projection,
+ out string status)
+ {
+ projection = new Projection(null, "-", string.Empty, string.Empty, string.Empty);
+ var values = new List(phases.Count);
+ var leaves = new List(phases.Count);
+
+ foreach (var phase in phases)
+ {
+ var prefix = NormalizeReference(requestedReference) + "." + phase.Path.ToLowerInvariant();
+ var candidateTiers = new[]
+ {
+ new[] { prefix + ".cval.mag.f" },
+ new[] { prefix + ".mag.f" },
+ new[] { prefix + ".instcval.mag.f", prefix + ".instmag.f" }
+ };
+
+ if (!TryResolvePreferredNumericLeaf(root, candidateTiers, out var leaf, out var failure))
+ {
+ status = $"Schema-safe THD projection blocked for {requestedReference}: phase {phase.Label} has no unique named magnitude leaf. {failure}";
+ return false;
+ }
+
+ leaves.Add(leaf);
+ values.Add($"{phase.Label}={leaf.NumericValue.ToString("0.######", CultureInfo.InvariantCulture)}");
+ }
+
+ // Every phase is resolved independently by exact IEC reference. A repeated row
+ // would mean the schema did not actually prove three distinct phase identities.
+ if (leaves.Select(leaf => NormalizeReference(leaf.Row.Reference)).Distinct(StringComparer.OrdinalIgnoreCase).Count() != phases.Count)
+ {
+ status = $"Schema-safe THD projection blocked for {requestedReference}: phase references are not unique.";
+ return false;
+ }
+
+ var display = string.Join(", ", values);
+ projection = new Projection(
+ display,
+ display,
+ FirstUsefulAcrossLeaves(leaves, root, row => row.Quality),
+ FirstUsefulAcrossLeaves(leaves, root, row => row.Timestamp),
+ "schema-safe-three-phase-aggregate");
+ status = $"Schema-safe THD aggregate projected from exact named leaves: {string.Join(", ", leaves.Select(leaf => leaf.Row.Reference))}.";
+ return true;
+ }
+
+ private static bool TryProjectDemandEnergy(
+ Iec61850BoundValueRow root,
+ string requestedReference,
+ string readReference,
+ string dataType,
+ out Projection projection,
+ out string status)
+ {
+ projection = new Projection(null, "-", string.Empty, string.Empty, string.Empty);
+ var prefix = NormalizeReference(requestedReference);
+
+ // DmdWhMV has existed in both canonical mag.f and vendor instantaneous instMag.f
+ // forms. Canonical engineering value wins when present. Instantaneous forms are
+ // accepted only when exactly one named fallback exists. No arbitrary numeric child
+ // is ever used.
+ var candidateTiers = new[]
+ {
+ new[] { prefix + ".mag.f" },
+ new[] { prefix + ".cval.mag.f" },
+ new[] { prefix + ".instmag.f", prefix + ".instcval.mag.f" }
+ };
+
+ if (!TryResolvePreferredNumericLeaf(root, candidateTiers, out var leaf, out var failure))
+ {
+ status = $"Schema-safe DmdWh projection blocked for {requestedReference}: no unique named energy magnitude leaf. {failure}";
+ return false;
+ }
+
+ var numeric = leaf.NumericValue;
+ var display = numeric.ToString("0.######", CultureInfo.InvariantCulture);
+ projection = new Projection(
+ numeric,
+ display,
+ FirstUseful(leaf.Row.Quality, leaf.Ancestors.Reverse().Select(row => row.Quality), root.Quality),
+ FirstUseful(leaf.Row.Timestamp, leaf.Ancestors.Reverse().Select(row => row.Timestamp), root.Timestamp),
+ "schema-safe-demand-energy-aggregate");
+ status = $"Schema-safe DmdWh aggregate projected from exact named leaf {leaf.Row.Reference}.";
+ return true;
+ }
+
+ private static bool TryResolvePreferredAttributeReference(
+ LiveIedDataObjectModel dataObject,
+ IReadOnlyList candidateTiers,
+ out string reference,
+ out string failure)
+ {
+ reference = string.Empty;
+ failure = string.Empty;
+
+ foreach (var tier in candidateTiers)
+ {
+ var expected = tier.ToHashSet(StringComparer.OrdinalIgnoreCase);
+ var matches = dataObject.Attributes
+ .Select(attribute => new
+ {
+ Attribute = attribute,
+ EffectiveReference = EffectiveAttributeReference(dataObject, attribute)
+ })
+ .Where(item => expected.Contains(NormalizeReference(item.EffectiveReference)))
+ .Where(item => string.IsNullOrWhiteSpace(item.Attribute.FunctionalConstraint) ||
+ item.Attribute.FunctionalConstraint.Equals("MX", StringComparison.OrdinalIgnoreCase))
+ .GroupBy(item => NormalizeReference(item.EffectiveReference), StringComparer.OrdinalIgnoreCase)
+ .Select(group => group.First().EffectiveReference)
+ .ToArray();
+
+ if (matches.Length == 0)
+ continue;
+ if (matches.Length != 1)
+ {
+ failure = $"Approved named fallback tier is ambiguous: {string.Join(", ", matches)}.";
+ return false;
+ }
+
+ reference = matches[0];
+ return true;
+ }
+
+ failure = "The authoritative schema contains none of the approved exact magnitude references.";
+ return false;
+ }
+
+ private static string EffectiveAttributeReference(
+ LiveIedDataObjectModel dataObject,
+ LiveIedDataAttributeModel attribute)
+ {
+ if (!string.IsNullOrWhiteSpace(attribute.ObjectReference))
+ return attribute.ObjectReference.Trim().Replace('$', '.');
+
+ var path = (attribute.AttributePath ?? string.Empty).Trim().Replace('$', '.').Trim('.');
+ return string.IsNullOrWhiteSpace(path)
+ ? string.Empty
+ : dataObject.Reference.Trim().Replace('$', '.').TrimEnd('.') + "." + path;
+ }
+
+ private static bool TryResolvePreferredNumericLeaf(
+ Iec61850BoundValueRow root,
+ IReadOnlyList candidateTiers,
+ out BoundLeaf leaf,
+ out string failure)
+ {
+ leaf = new BoundLeaf(new Iec61850BoundValueRow(), Array.Empty(), 0d);
+ failure = string.Empty;
+
+ foreach (var tier in candidateTiers)
+ {
+ var matches = new List();
+ foreach (var reference in tier)
+ {
+ if (!TryFindBoundRow(root, reference, out var row, out var ancestors))
+ continue;
+ if (!TryParseNumeric(row.Value, out var numeric))
+ {
+ failure = $"Named leaf {row.Reference} was bound but its value '{row.Value}' is not numeric.";
+ return false;
+ }
+ matches.Add(new BoundLeaf(row, ancestors, numeric));
+ }
+
+ if (matches.Count == 0)
+ continue;
+ if (matches.Count != 1)
+ {
+ failure = $"Named fallback tier is ambiguous: {string.Join(", ", matches.Select(match => match.Row.Reference))}.";
+ return false;
+ }
+
+ leaf = matches[0];
+ return true;
+ }
+
+ failure = "The authoritative schema contains none of the approved exact magnitude references.";
+ return false;
+ }
+
+ private static bool TryFindDataObject(
+ LiveIedModelDiscoveryDocument model,
+ string reference,
+ out LiveIedDataObjectModel dataObject)
+ {
+ dataObject = new LiveIedDataObjectModel();
+ if (!TryGetDataObjectReference(reference, out var dataObjectReference))
+ return false;
+
+ var matches = model.LogicalDevices
+ .SelectMany(device => device.LogicalNodes)
+ .SelectMany(node => node.DataObjects)
+ .Where(candidate => ReferencesEqual(candidate.Reference, dataObjectReference))
+ .ToArray();
+ if (matches.Length != 1)
+ return false;
+
+ dataObject = matches[0];
+ return true;
+ }
+
+ private static bool TryGetDataObjectReference(string reference, out string dataObjectReference)
+ {
+ dataObjectReference = string.Empty;
+ var text = (reference ?? string.Empty).Trim().Replace('$', '.');
+ var slash = text.IndexOf('/');
+ if (slash <= 0 || slash >= text.Length - 1)
+ return false;
+
+ var domain = text[..slash];
+ var segments = text[(slash + 1)..]
+ .Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ if (segments.Length < 2)
+ return false;
+
+ dataObjectReference = $"{domain}/{segments[0]}.{segments[1]}";
+ return true;
+ }
+
+ private static bool TryFindSchemaNode(
+ Iec61850ValueSchemaNode node,
+ string reference,
+ out Iec61850ValueSchemaNode result)
+ {
+ if (ReferencesEqual(node.Reference, reference))
+ {
+ result = node;
+ return true;
+ }
+
+ foreach (var child in node.Children)
+ {
+ if (TryFindSchemaNode(child, reference, out result))
+ return true;
+ }
+
+ result = new Iec61850ValueSchemaNode();
+ return false;
+ }
+
+ private static bool TryFindBoundRow(
+ Iec61850BoundValueRow root,
+ string reference,
+ out Iec61850BoundValueRow row,
+ out IReadOnlyList ancestors)
+ {
+ var path = new List();
+ return TryFindBoundRow(root, reference, path, out row, out ancestors);
+ }
+
+ private static bool TryFindBoundRow(
+ Iec61850BoundValueRow current,
+ string reference,
+ List path,
+ out Iec61850BoundValueRow row,
+ out IReadOnlyList ancestors)
+ {
+ path.Add(current);
+ if (ReferencesEqual(current.Reference, reference))
+ {
+ row = current;
+ ancestors = path.Take(path.Count - 1).ToArray();
+ path.RemoveAt(path.Count - 1);
+ return true;
+ }
+
+ foreach (var child in current.Children)
+ {
+ if (TryFindBoundRow(child, reference, path, out row, out ancestors))
+ {
+ path.RemoveAt(path.Count - 1);
+ return true;
+ }
+ }
+
+ path.RemoveAt(path.Count - 1);
+ row = new Iec61850BoundValueRow();
+ ancestors = Array.Empty();
+ return false;
+ }
+
+ private static bool IsThreePhaseThd(
+ string reference,
+ out IReadOnlyList<(string Path, string Label)> phases)
+ {
+ var normalized = NormalizeReference(reference).TrimEnd('.');
+ if (normalized.EndsWith(".thda", StringComparison.OrdinalIgnoreCase))
+ {
+ phases = new[]
+ {
+ ("phsA", "A"),
+ ("phsB", "B"),
+ ("phsC", "C")
+ };
+ return true;
+ }
+
+ if (normalized.EndsWith(".thdppv", StringComparison.OrdinalIgnoreCase))
+ {
+ phases = new[]
+ {
+ ("phsAB", "AB"),
+ ("phsBC", "BC"),
+ ("phsCA", "CA")
+ };
+ return true;
+ }
+
+ phases = Array.Empty<(string, string)>();
+ return false;
+ }
+
+ private static bool IsDemandEnergy(string reference)
+ => NormalizeReference(reference).TrimEnd('.').EndsWith(".dmdwhmv", StringComparison.OrdinalIgnoreCase);
+
+ private static bool TryParseNumeric(string? value, out double numeric)
+ => double.TryParse(
+ (value ?? string.Empty).Trim(),
+ NumberStyles.Float | NumberStyles.AllowThousands,
+ CultureInfo.InvariantCulture,
+ out numeric);
+
+ private static string FirstUsefulAcrossLeaves(
+ IReadOnlyList leaves,
+ Iec61850BoundValueRow root,
+ Func selector)
+ {
+ // Parent composite quality/timestamp is only advertised when the selected phase
+ // leaves agree. Mixed evidence must remain blank rather than implying one phase's
+ // metadata applies to the whole aggregate.
+ var useful = leaves
+ .Select(leaf => FirstUseful(selector(leaf.Row), leaf.Ancestors.Reverse().Select(selector), selector(root)))
+ .Where(IsUseful)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+ return useful.Length == 1 ? useful[0] : string.Empty;
+ }
+
+ private static string FirstUseful(string primary, IEnumerable inherited, string fallback)
+ {
+ if (IsUseful(primary))
+ return primary;
+ foreach (var candidate in inherited)
+ {
+ if (IsUseful(candidate))
+ return candidate;
+ }
+ return IsUseful(fallback) ? fallback : string.Empty;
+ }
+
+ private static bool IsUseful(string? value)
+ => !string.IsNullOrWhiteSpace(value) && value != "-";
+
+ private static string FormatDiagnostics(IReadOnlyList diagnostics)
+ => diagnostics.Count == 0
+ ? "no binding diagnostics"
+ : string.Join("; ", diagnostics.Take(4));
+
+ private static bool ReferencesEqual(string? left, string? right)
+ => NormalizeReference(left).Equals(NormalizeReference(right), StringComparison.OrdinalIgnoreCase);
+
+ private static string NormalizeReference(string? reference)
+ => (reference ?? string.Empty)
+ .Trim()
+ .Replace('$', '.')
+ .Replace("..", ".")
+ .ToLowerInvariant();
+}
diff --git a/VERSION b/VERSION
index e0380928f..7337a939f 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.6.32
+1.6.33
diff --git a/design-qa.md b/design-qa.md
index 547ac2406..54d9b019d 100644
--- a/design-qa.md
+++ b/design-qa.md
@@ -1,4 +1,41 @@
-# IED protection relay fascia design QA
+# SCL signal selection modal design QA
+
+## Visual sources
+
+- User reference / previous implementation: attached visual `codex-clipboard-090813c8-4eb5-4d1a-a394-dd81fe44b6f0.png`
+- Runtime capture of the new WPF modal: `output/scl-signal-selection-modern.png`
+- Side-by-side comparison: `output/scl-signal-selection-comparison.png`
+
+## Current scope
+
+The native `MessageBox` used after SCL import was replaced by `SclSignalSelectionModeWindow`, a modal WPF card that uses the existing ARSAS application icon, typography, color tokens, button styles, Lucide resources, elevation, and rounded-card language.
+
+## Comparison findings
+
+- The previous paragraph-heavy system dialog now has a clear title, shared-workspace subtitle, IED count badge, two explicit selection cards, and a supporting note.
+- `Use Static DataSet` is selected by default and visibly marked as recommended; `Choose Signals Manually` is an equally accessible radio-card choice.
+- Generic Yes/No labels are replaced by Cancel and Continue, with default and cancel keyboard behavior retained.
+- Spacing, 17–24 px corner radii, subdued borders, blue selection state, Aptos/Segoe typography, and application button styles match existing ARSAS pop windows such as Save SCL.
+- The runtime screenshot shows no clipped copy, overlapping controls, broken padding, or truncated actions at the captured Windows scaling.
+- Both cards are fully clickable, mouse/pressed/focus states are defined, Enter continues, Escape cancels, and closing the window leaves the imported Engineering model unchanged.
+
+## Functional companion checks
+
+- Engineering Open SCL now permits multiple file selection and adds every distinct file into the same Engineering workspace.
+- A single signal-authority decision applies to all newly imported IED workspaces and is bridged into an already-loaded FAT workspace without a second prompt.
+- Duplicate FAT sources are filtered by SHA-256 before append; existing connected or monitoring IEDs are retained.
+
+## Current validation
+
+- Release build: passed with 0 errors using the ARIEC61850 commit pinned by `engines/ARIEC61850.lock.json`.
+- Test suite: 616 passed, 0 failed, 0 skipped.
+- Runtime WPF capture and side-by-side visual inspection: passed.
+
+Final result for the SCL signal selection modal: **passed**.
+
+---
+
+# Previous IED protection relay fascia design QA
## Scope
diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json
index 6fc0f53c2..2e101141b 100644
--- a/engines/ARIEC61850.lock.json
+++ b/engines/ARIEC61850.lock.json
@@ -2,7 +2,7 @@
"schemaVersion": 1,
"repository": "masarray/ARIEC61850",
"ref": "main",
- "commit": "bd959bc68719c52853cede272c1f562aec9900e2",
- "sourcePullRequest": 103,
- "purpose": "Pins the exact ARIEC61850 engine used by ARSAS while preserving the reviewed reporting/control ancestry. PR #76 preserves unresolved static DataSet members; PR #77 canonicalizes cross-logical-device SCL references; PR #78 keeps one descriptor per static DataSet member while separating the resolved runtime primary leaf from original FCDA/FCD identity; PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp; PR #80 normalizes validated DataRef-enabled InformationReport ordering; PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata; PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling; PR #85 evaluates association capabilities before automatic dynamic mutation; PR #86 records dynamic-attempt failure/skip evidence and best-effort rollback. PR #87 restores baseline-safe static precedence. PR #88 adds a fail-closed single-member DefineNamedVariableList -> GetNamedVariableListAttributes -> DeleteNamedVariableList probation with exact invoke/request/response/routing/member/association/cleanup evidence. PR #89 quarantines automatic full dynamic DataSet activation because a successful one-member NVL probation does not guarantee association survival; it also preserves safe instMag/mag and instCVal/cVal projection while ambiguous structures remain raw. PR #90 / field-proven engine a18e550d07f7bbe4ff7753c180b02615075f6292 preserves G1/G1.1 Smart Control: signed primitive constraints, ordered SBO/SBOw-to-Operate wire evidence, StationControl origin compatibility, and explicit MMS Write DataAccessError including object-access-denied. G2 PR #91 adds qualification-only bounded multi-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList evidence with exact ordered read-back, encoded request/PDU evidence and fail-closed cleanup; PR #92 adds the 1/4/8/16/32 qualification ladder, deterministic bisection and explicit EnvelopeQualified acceptance; PR #93 adds a default-disabled ExplicitCommissioning coordinator with hard attempt budget, exact-set failure localization and fresh-association stop semantics; PR #94 adds identity-bound qualification profiles and prevents ProductionEligible unless RCB activation, an actual correctly mapped InformationReport, and all G2.6 physical regression gates are proven. G2.4 engine PR #95 retains the commissioning-only transactional URCB TrgOps/OptFlds lease. P0 physically proved the corrected IEC 61850 MMS TrgOps reserved-bit mapping: bit 0 reserved, bits 1..5 dchg/qchg/dupd/integrity/GI, so dchg+GI encodes canonically as 0244; P0 also separates raw BER equality from IEC significant-bit equality and provides a one-URCB TrgOps-only micro-probe that never writes OptFlds, DatSet, Resv, RptEna, GI or any DataSet service. P1 adds a dedicated one-URCB OptFlds-only capture/write/readback/finally-restore micro-probe for reason-for-inclusion + data-set-name, canonical target 061800, using ten-bit significant-value comparison while never writing TrgOps, DatSet, Resv, RptEna, GI, Define/Delete DataSet, starting a report monitor, or changing profile state. The G2.4 Owner correction exposes the exact local TCP address of the active MMS association and fail-closed decodes a server RCB Owner as a 4-byte IPv4 or 16-byte IPv6 address; physical SIPROTEC Owner C0A851F0 decodes to 192.168.81.240 and may prove caller ownership only when it exactly matches the active local TCP endpoint. Owner mismatch or unsupported encoding remains a hard failure. Original RCB values remain captured for restore, raw BER evidence is retained, and Production automatic dynamic BRCB/URCB activation remains quarantined until a compatible ProductionEligible profile is consumed by a later G2 phase. FAT P5.3 engine PR #103 resolves intermediate structured static DataSet members such as MMXU A.phsA and PPV.phsAB only to typed descendants below the exact FCDA boundary, selects a unique semantic primary runtime leaf such as cVal.mag.f without crossing sibling phases, preserves original static membership identity, and leaves genuinely ambiguous structures unresolved rather than guessing."
+ "commit": "b9ee5fc9650b72e69bc81287a1e1b047ad19b054",
+ "sourcePullRequest": 106,
+ "purpose": "Pins the exact ARIEC61850 engine used by ARSAS while preserving the reviewed reporting/control ancestry. PR #76 preserves unresolved static DataSet members; PR #77 canonicalizes cross-logical-device SCL references; PR #78 keeps one descriptor per static DataSet member while separating the resolved runtime primary leaf from original FCDA/FCD identity; PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp; PR #80 normalizes validated DataRef-enabled InformationReport ordering; PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata; PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling; PR #85 evaluates association capabilities before automatic dynamic mutation; PR #86 records dynamic-attempt failure/skip evidence and best-effort rollback. PR #87 restores baseline-safe static precedence. PR #88 adds a fail-closed single-member DefineNamedVariableList -> GetNamedVariableListAttributes -> DeleteNamedVariableList probation with exact invoke/request/response/routing/member/association/cleanup evidence. PR #89 quarantines automatic full dynamic DataSet activation because a successful one-member NVL probation does not guarantee association survival; it also preserves safe instMag/mag and instCVal/cVal projection while ambiguous structures remain raw. PR #90 / field-proven engine a18e550d07f7bbe4ff7753c180b02615075f6292 preserves G1/G1.1 Smart Control: signed primitive constraints, ordered SBO/SBOw-to-Operate wire evidence, StationControl origin compatibility, and explicit MMS Write DataAccessError including object-access-denied. G2 PR #91 adds qualification-only bounded multi-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList evidence with exact ordered read-back, encoded request/PDU evidence and fail-closed cleanup; PR #92 adds the 1/4/8/16/32 qualification ladder, deterministic bisection and explicit EnvelopeQualified acceptance; PR #93 adds a default-disabled ExplicitCommissioning coordinator with hard attempt budget, exact-set failure localization and fresh-association stop semantics; PR #94 adds identity-bound qualification profiles and prevents ProductionEligible unless RCB activation, an actual correctly mapped InformationReport, and all G2.6 physical regression gates are proven. G2.4 engine PR #95 retains the commissioning-only transactional URCB TrgOps/OptFlds lease. P0 physically proved the corrected IEC 61850 MMS TrgOps reserved-bit mapping: bit 0 reserved, bits 1..5 dchg/qchg/dupd/integrity/GI, so dchg+GI encodes canonically as 0244; P0 also separates raw BER equality from IEC significant-bit equality and provides a one-URCB TrgOps-only micro-probe that never writes OptFlds, DatSet, Resv, RptEna, GI or any DataSet service. P1 adds a dedicated one-URCB OptFlds-only capture/write/readback/finally-restore micro-probe for reason-for-inclusion + data-set-name, canonical target 061800, using ten-bit significant-value comparison while never writing TrgOps, DatSet, Resv, RptEna, GI, Define/Delete DataSet, starting a report monitor, or changing profile state. The G2.4 Owner correction exposes the exact local TCP address of the active MMS association and fail-closed decodes a server RCB Owner as a 4-byte IPv4 or 16-byte IPv6 address; physical SIPROTEC Owner C0A851F0 decodes to 192.168.81.240 and may prove caller ownership only when it exactly matches the active local TCP endpoint. Owner mismatch or unsupported encoding remains a hard failure. Original RCB values remain captured for restore, raw BER evidence is retained, and Production automatic dynamic BRCB/URCB activation remains quarantined until a compatible ProductionEligible profile is consumed by a later G2 phase. FAT P5.3 engine PR #103 resolves intermediate structured static DataSet members such as MMXU A.phsA and PPV.phsAB only to typed descendants below the exact FCDA boundary, selects a unique semantic primary runtime leaf such as cVal.mag.f without crossing sibling phases, preserves original static membership identity, and leaves genuinely ambiguous structures unresolved rather than guessing. FAT P5.4 engine PR #106 adds fail-closed model-backed InformationReport projection for structured static DataSet members: an exact report member reference now resolves independently of sparse decoder-side report value position, while DataSet scope still prevents duplicate static memberships from collapsing; when a report omits the member reference, static DataSet index remains the unique fail-closed fallback. All schema-proven scalar descendants are fanned out without selecting a sibling phase, and schema mismatch preserves raw projection instead of guessing. ARSAS supplies the per-IED LiveDiscovery/SCL planning model at the report receive seam."
}
diff --git a/landing/release-notes.json b/landing/release-notes.json
index 353ac0041..c4447bef5 100644
--- a/landing/release-notes.json
+++ b/landing/release-notes.json
@@ -1,56 +1,52 @@
{
"schemaVersion": 1,
"product": "ARSAS",
- "version": "1.6.31",
- "title": "Reconnect-safe live DataSet discovery and hybrid RCB acquisition",
- "titleId": "Discovery DataSet live dan acquisition RCB hybrid yang aman saat reconnect",
- "summary": "ARSAS 1.6.31 completes the repaired live reporting pipeline from discovery through reconnect: DataSet members are retained, static and dynamic BRCB/URCB coverage is planned by ARIEC61850, only uncovered signals fall back to MMS polling, and a new MMS association cannot inherit report or command-correlation evidence from the previous connection.",
- "summaryId": "ARSAS 1.6.31 melengkapi perbaikan pipeline live reporting dari discovery sampai reconnect: member DataSet dipertahankan, coverage static dan dynamic BRCB/URCB direncanakan oleh ARIEC61850, hanya signal yang tidak ter-cover yang fallback ke MMS polling, dan association MMS baru tidak dapat mewarisi evidence report atau korelasi command dari koneksi sebelumnya.",
+ "version": "1.6.33",
+ "title": "Fast, unified and responsive IEC 61850 FAT workspace",
+ "titleId": "Workspace IEC 61850 FAT yang cepat, terpadu, dan responsif",
+ "summary": "ARSAS 1.6.33 unifies Engineering and FAT around the imported SCL model, shortens direct-SCL connection readiness, projects structured THD and demand-energy values correctly, and keeps operator input responsive during continuous live acquisition.",
+ "summaryId": "ARSAS 1.6.33 menyatukan Engineering dan FAT pada model SCL yang diimpor, mempercepat kesiapan koneksi direct-SCL, memproyeksikan THD terstruktur dan demand-energy dengan benar, serta menjaga input operator tetap responsif saat akuisisi live terus berjalan.",
"highlights": [
- "Live discovery requests bounded DataSet directories and retains their members, preventing report-capable signals from disappearing between MMS discovery and the application catalog.",
- "ARSAS executes the ARIEC61850 hybrid plan across static BRCB, static URCB, verified-free dynamic RCB slots, and per-signal MMS polling residuals.",
- "Dynamic RCB planning supports temporary DataSets whose members reside in logical devices different from the RCB owner, as permitted by the live server model.",
- "Report catalog matching selects the owning primary-value descriptor even when related quality and timestamp descriptors share the same primary reference.",
- "Instantaneous magnitude values such as Hz.instMag.f resolve their quality and timestamp companions at Hz.q and Hz.t, while preserving clear operator-facing names.",
- "Smart reconnect runs the same authoritative hybrid planner as initial monitoring, with fresh RCB availability checks and clean per-association report evidence."
+ "Direct-SCL FAT connection reuses the imported model and reaches monitoring after the MMS association and bounded point setup without a discovery-from-zero or legacy settle delay.",
+ "Engineering and FAT share SCL signal identity and operator selection; switching workspace no longer creates an isolated signal universe.",
+ "Additional CID, SCD, ICD or IID sources can be added to an existing FAT project, including while another IED evidence session remains active.",
+ "Structured ThdA and ThdPPV parent values preserve their phase A/B/C or AB/BC/CA members, while DmdWhMV uses the exact MMS value authority exposed by the relay.",
+ "Windows taskbar grouping uses the embedded ARSAS icon and a stable explicit application identity."
],
"highlightsId": [
- "Live discovery meminta directory DataSet secara terbatas dan mempertahankan member-nya, sehingga signal report-capable tidak lagi hilang di antara MMS discovery dan catalog aplikasi.",
- "ARSAS mengeksekusi hybrid plan ARIEC61850 melalui static BRCB, static URCB, slot dynamic RCB yang terverifikasi free, dan residual MMS polling per signal.",
- "Perencanaan dynamic RCB mendukung DataSet sementara dengan member pada logical device yang berbeda dari owner RCB, sesuai model live server.",
- "Pemetaan report catalog memilih descriptor primary value pemiliknya walaupun descriptor quality dan timestamp terkait menggunakan primary reference yang sama.",
- "Instantaneous magnitude seperti Hz.instMag.f menemukan companion quality dan timestamp pada Hz.q dan Hz.t, sambil mempertahankan nama yang jelas bagi operator.",
- "Smart reconnect menjalankan authoritative hybrid planner yang sama dengan initial monitoring, termasuk pemeriksaan availability RCB terbaru dan evidence report per-association yang bersih."
+ "Koneksi FAT direct-SCL menggunakan kembali model hasil import dan masuk ke monitoring setelah association MMS serta bounded point setup tanpa discovery dari nol atau legacy settle delay.",
+ "Engineering dan FAT berbagi identitas serta pilihan signal SCL; perpindahan workspace tidak lagi membentuk signal universe yang terpisah.",
+ "Source CID, SCD, ICD, atau IID tambahan dapat dimasukkan ke project FAT yang sedang digunakan, termasuk ketika evidence session IED lain masih aktif.",
+ "Parent value ThdA dan ThdPPV terstruktur mempertahankan member phase A/B/C atau AB/BC/CA, sedangkan DmdWhMV menggunakan exact MMS value authority yang diberikan relay.",
+ "Grouping taskbar Windows menggunakan icon ARSAS yang tertanam dan explicit application identity yang stabil."
],
"improvements": [
- "Fresh RCB availability is revalidated before any setup write, and static or dynamic control blocks are reused only when the live evidence still permits it.",
- "Physical validation evidence distinguishes successful RCB setup from actual report frames and verified process-value changes, avoiding false claims of report delivery.",
- "Physical-validation mutation and snapshot capture are synchronized so operator diagnostics cannot observe torn counters or collection changes while report frames arrive.",
- "The release source is gated by the standard ARSAS solution build and regression suite plus portable and Windows installer smoke validation before publication.",
- "Stable Windows publication produces installer and portable binaries together with SHA-256 checksums, SPDX SBOM, application/engine provenance and GitHub artifact attestations."
+ "Live FAT observations are coalesced in bounded dispatcher batches below mouse and keyboard input priority, while the dedicated digital-edge queue remains lossless.",
+ "Steady telemetry updates only visible value cells; it no longer re-filters the entire DataGrid or resets autosave for every poll.",
+ "Capture progress and workspace summaries update only when evidence or test state actually changes.",
+ "Adding an IED synchronizes only the new endpoints, leaving the active IED's capture map and evidence scope immutable.",
+ "The Windows release remains gated by the full ARSAS regression suite, portable smoke validation, checksums, SPDX SBOM, provenance and GitHub attestations."
],
"improvementsId": [
- "Availability RCB terbaru divalidasi ulang sebelum setup write, dan control block static atau dynamic hanya digunakan ketika evidence live masih mengizinkannya.",
- "Evidence validasi fisik membedakan setup RCB yang berhasil dari report frame aktual dan perubahan process value yang terverifikasi, sehingga tidak ada klaim palsu mengenai report delivery.",
- "Mutasi dan snapshot physical validation disinkronisasi agar diagnostic operator tidak melihat counter yang torn atau perubahan collection ketika report frame masuk.",
- "Source release digate oleh build solution dan regression suite ARSAS standard serta portable dan Windows installer smoke validation sebelum publikasi.",
- "Publikasi Windows stable menghasilkan binary installer dan portable bersama SHA-256 checksum, SPDX SBOM, provenance aplikasi/engine dan GitHub artifact attestation."
+ "Observasi live FAT di-coalesce dalam bounded dispatcher batch di bawah prioritas input mouse dan keyboard, sementara queue digital-edge khusus tetap lossless.",
+ "Telemetry steady hanya mengubah cell value yang terlihat; seluruh DataGrid tidak lagi di-filter ulang dan timer autosave tidak lagi di-reset pada setiap poll.",
+ "Progress capture dan ringkasan workspace hanya diperbarui ketika evidence atau test state benar-benar berubah.",
+ "Penambahan IED hanya menyinkronkan endpoint baru sehingga capture map dan evidence scope IED aktif tetap immutable.",
+ "Release Windows tetap digate oleh regression suite ARSAS lengkap, portable smoke validation, checksum, SPDX SBOM, provenance, dan GitHub attestation."
],
"knownLimitations": [
- "Physical relay validation remains necessary for device-specific engineering behavior that cannot be reproduced by CI, including vendor-specific model exposure and field network conditions.",
- "A successful RCB setup is intentionally not treated as proof that report frames were received; field validation must confirm frames and value changes for the target IED and network.",
- "The three-digit FAT timestamp display intentionally rounds the decoded timestamp to the nearest millisecond; full decoded precision remains available in evidence views.",
+ "Physical relay validation remains necessary for vendor-specific values, report behavior and field network conditions that cannot be reproduced by CI.",
+ "Adding an SCL source does not automatically start or switch the active FAT evidence session; the operator selects and starts each IED explicitly.",
"Windows x64 is the only packaged desktop platform in this stable release.",
"The public binaries are not Authenticode code-signed; Windows SmartScreen may show an unrecognized-publisher warning.",
"Raw-Ethernet GOOSE and Sampled Values workflows require an administrator-installed and approved Npcap driver, suitable capture permission and visibility of the relevant multicast traffic."
],
"knownLimitationsId": [
- "Validasi relay fisik tetap diperlukan untuk perilaku engineering spesifik perangkat yang tidak dapat direproduksi oleh CI, termasuk exposure model vendor-specific dan kondisi network di lapangan.",
- "Setup RCB yang berhasil sengaja tidak dianggap sebagai bukti bahwa report frame telah diterima; validasi lapangan harus mengonfirmasi frame dan perubahan value pada IED dan network target.",
- "Display timestamp FAT tiga digit secara sengaja membulatkan decoded timestamp ke millisecond terdekat; full decoded precision tetap tersedia pada evidence view.",
+ "Validasi relay fisik tetap diperlukan untuk value vendor-specific, perilaku report, dan kondisi network lapangan yang tidak dapat direproduksi oleh CI.",
+ "Penambahan source SCL tidak otomatis memulai atau memindahkan evidence session FAT aktif; operator memilih dan memulai setiap IED secara eksplisit.",
"Windows x64 adalah satu-satunya platform desktop yang dipaketkan pada stable release ini.",
"Binary publik belum ditandatangani dengan Authenticode; Windows SmartScreen dapat menampilkan peringatan unrecognized publisher.",
- "Workflow raw-Ethernet GOOSE dan Sampled Values memerlukan driver Npcap yang telah dipasang dan disetujui administrator, capture permission yang sesuai dan visibility traffic multicast terkait."
+ "Workflow raw-Ethernet GOOSE dan Sampled Values memerlukan driver Npcap yang telah dipasang dan disetujui administrator, capture permission yang sesuai, serta visibility traffic multicast terkait."
],
"codeSigning": {
"status": "unsigned",
@@ -63,11 +59,11 @@
"src": "assets/screenshots/arsas-live-values.webp",
"width": 1507,
"height": 893,
- "alt": "Representative ARSAS 1.6.31 live values and reconnect-safe IEC 61850 acquisition workspace",
- "altId": "Representasi workspace live values dan acquisition IEC 61850 ARSAS 1.6.31 yang aman saat reconnect",
- "caption": "Representative ARSAS live-value workspace for stable release 1.6.31. Acquisition labels identify report delivery only after report frames are received; reconnect resets association-scoped evidence and uncovered signals remain explicit MMS polling residuals.",
- "captionId": "Representasi workspace live value ARSAS untuk stable release 1.6.31. Label acquisition mengidentifikasi report delivery hanya setelah report frame diterima; reconnect mereset evidence per-association dan signal yang tidak ter-cover tetap ditampilkan sebagai residual MMS polling."
+ "alt": "ARSAS 1.6.33 Engineering and FAT live IEC 61850 workspace",
+ "altId": "Workspace live IEC 61850 Engineering dan FAT ARSAS 1.6.33",
+ "caption": "ARSAS 1.6.33 shares imported SCL signal identity between Engineering and FAT while bounded background refresh keeps operator interaction responsive.",
+ "captionId": "ARSAS 1.6.33 berbagi identitas signal SCL antara Engineering dan FAT sementara bounded background refresh menjaga interaksi operator tetap responsif."
},
"issuesUrl": "https://github.com/masarray/arsas/issues/new/choose",
- "releaseUrl": "https://github.com/masarray/arsas/releases/tag/v1.6.31"
+ "releaseUrl": "https://github.com/masarray/arsas/releases/tag/v1.6.33"
}
diff --git a/output/scl-signal-selection-comparison.png b/output/scl-signal-selection-comparison.png
new file mode 100644
index 000000000..c3a6ff981
Binary files /dev/null and b/output/scl-signal-selection-comparison.png differ
diff --git a/output/scl-signal-selection-modern.png b/output/scl-signal-selection-modern.png
new file mode 100644
index 000000000..4f0acfe08
Binary files /dev/null and b/output/scl-signal-selection-modern.png differ
diff --git a/tests/ARSAS.Tests/IoFatContinuationScopeRegressionTests.cs b/tests/ARSAS.Tests/IoFatContinuationScopeRegressionTests.cs
index ee2d9d8f9..6bff70f74 100644
--- a/tests/ARSAS.Tests/IoFatContinuationScopeRegressionTests.cs
+++ b/tests/ARSAS.Tests/IoFatContinuationScopeRegressionTests.cs
@@ -3,7 +3,7 @@ namespace ARSAS.Tests;
public sealed class IoFatContinuationScopeRegressionTests
{
[Fact]
- public void SelectedRows_ArePreparedExplicitlyAndOnlyLiveSubsetIsArmedWithoutMutatingOperatorSelection()
+ public void SelectedRows_DefineEvidenceScopeWhileFullIedScopeOwnsLivePreparation()
{
var source = File.ReadAllText(FindRepoFile("IoListTestingWindow.ContextUx.cs"))
.Replace("\r\n", "\n", StringComparison.Ordinal);
@@ -15,7 +15,7 @@ public void SelectedRows_ArePreparedExplicitlyAndOnlyLiveSubsetIsArmedWithoutMut
"var captureScope = selectedIed.TestPoints",
StringComparison.Ordinal);
var preparationIndex = source.IndexOf(
- "progress,\n captureScope);",
+ "var preparation = await engineeringWindow.PrepareIoTestIedForFatAsync(\n Project,\n selectedIed,\n progress);",
StringComparison.Ordinal);
var liveScopeIndex = source.IndexOf(
"var liveCaptureScope = captureScope",
@@ -27,12 +27,13 @@ public void SelectedRows_ArePreparedExplicitlyAndOnlyLiveSubsetIsArmedWithoutMut
"Session.Start(selectedIed, liveCaptureScope)",
StringComparison.Ordinal);
- Assert.True(preflightIndex >= 0, "The real operator selection must be validated before capture scope creation.");
+ Assert.True(preflightIndex >= 0, "The operator-selected evidence scope must be validated before capture scope creation.");
Assert.True(scopeIndex > preflightIndex, "Capture scope must be created only after preflight succeeds.");
- Assert.True(preparationIndex > scopeIndex, "The complete operator-selected scope must be used for live preparation.");
- Assert.True(liveScopeIndex > preparationIndex, "Live evidence scope must be derived only after preparation has produced binding evidence.");
+ Assert.True(preparationIndex > scopeIndex, "P1 live preparation must use the full included IED acquisition scope, not the TEST subset.");
+ Assert.True(liveScopeIndex > preparationIndex, "Evidence scope must be filtered to proven live rows only after full IED preparation.");
Assert.True(liveReadyIndex >= liveScopeIndex, "Active evidence scope must require LivePointReady rows.");
- Assert.True(sessionStartIndex > liveScopeIndex, "Only the proven live subset may reach Session.Start.");
+ Assert.True(sessionStartIndex > liveScopeIndex, "Only the proven selected live subset may reach Session.Start.");
+ Assert.DoesNotContain("progress,\n captureScope);", source, StringComparison.Ordinal);
Assert.DoesNotContain("point.TestEnabled = false", source, StringComparison.Ordinal);
Assert.DoesNotContain("point.TestEnabled = true", source, StringComparison.Ordinal);
Assert.DoesNotContain("protectedPoints", source, StringComparison.Ordinal);
diff --git a/tests/ARSAS.Tests/IoFatDirectSclLatencyAndIdentityRegressionTests.cs b/tests/ARSAS.Tests/IoFatDirectSclLatencyAndIdentityRegressionTests.cs
new file mode 100644
index 000000000..e3a505ee1
--- /dev/null
+++ b/tests/ARSAS.Tests/IoFatDirectSclLatencyAndIdentityRegressionTests.cs
@@ -0,0 +1,219 @@
+using AR.Iec61850.Discovery;
+using ArIED61850Tester.Models;
+using ArIED61850Tester.Models.IoTesting;
+using ArIED61850Tester.Services;
+using ArIED61850Tester.Services.IoTesting;
+
+namespace ARSAS.Tests;
+
+public sealed class IoFatDirectSclLatencyAndIdentityRegressionTests
+{
+ [Fact]
+ public void StructuredStaticMember_KeepsDedicatedIdentityAndPublishesResolvedThdScalarToRuntime()
+ {
+ const string dataObject = "IEDLD0/I_MHAI1.ThdA";
+ const string staticMember = dataObject + ".phsA";
+ const string runtimeLeaf = staticMember + ".cVal.mag.f";
+ const string dataSet = "IEDLD0/LLN0.Analog";
+
+ var model = new LiveIedModelDiscoveryDocument
+ {
+ Source = "SclWorkspace",
+ IedName = "IED",
+ LogicalDevices = new[]
+ {
+ new LiveIedLogicalDeviceModel
+ {
+ MmsDomain = "IEDLD0",
+ Inst = "LD0",
+ LogicalNodes = new[]
+ {
+ new LiveIedLogicalNodeModel
+ {
+ Name = "I_MHAI1",
+ LnClass = "MHAI",
+ LnInst = "1",
+ DataObjects = new[]
+ {
+ new LiveIedDataObjectModel
+ {
+ Reference = dataObject,
+ Name = "ThdA",
+ InferredCdc = "WYE",
+ Attributes = new[]
+ {
+ new LiveIedDataAttributeModel
+ {
+ ObjectReference = runtimeLeaf,
+ AttributePath = "phsA.cVal.mag.f",
+ FunctionalConstraint = "MX",
+ MmsReference = "IEDLD0/I_MHAI1$MX$ThdA$phsA$cVal$mag$f",
+ MmsItemName = "I_MHAI1$MX$ThdA$phsA$cVal$mag$f",
+ SclBType = "FLOAT32",
+ MmsType = "floating-point",
+ Source = "SCL.DataTypeTemplates",
+ TypeSource = "SCL.DataTypeTemplates",
+ TypeConfidence = LiveIedDiscoveryConfidenceLevel.Exact
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ DataSets = new[]
+ {
+ new LiveIedDataSetModel
+ {
+ Reference = dataSet,
+ Domain = "IEDLD0",
+ LogicalNode = "LLN0",
+ Name = "Analog",
+ MemberCount = 1,
+ Members = new[]
+ {
+ new LiveIedDataSetMemberModel
+ {
+ Index = 0,
+ Reference = staticMember,
+ FunctionalConstraint = "MX",
+ MmsReference = "IEDLD0/I_MHAI1$MX$ThdA$phsA",
+ Confidence = LiveIedDiscoveryConfidenceLevel.Exact
+ }
+ }
+ }
+ }
+ };
+
+ var genericScalar = new SignalDefinition
+ {
+ Name = "ThdA phsA mag",
+ ObjectReference = runtimeLeaf,
+ DisplayReference = runtimeLeaf,
+ FunctionalConstraint = "MX",
+ DataType = "FLOAT32",
+ Category = "Measurement",
+ Source = "Cached live model"
+ };
+ var signals = new List { genericScalar };
+
+ var merge = Iec61850DataSetSignalInventoryService.EnsureMandatorySignals(signals, model);
+
+ Assert.Equal(1, merge.MandatoryCatalogCount);
+ Assert.Equal(1, merge.AddedCount);
+ Assert.Equal(2, signals.Count);
+ Assert.Equal(runtimeLeaf, genericScalar.DisplayReference);
+
+ var membershipSignal = Assert.Single(signals.Where(signal =>
+ signal.DisplayReference.Equals(staticMember, StringComparison.OrdinalIgnoreCase)));
+ Assert.NotSame(genericScalar, membershipSignal);
+ Assert.Equal(runtimeLeaf, membershipSignal.ObjectReference);
+ Assert.Equal(dataSet, membershipSignal.DataSetReference);
+
+ // A checked Engineering THD phase magnitude and the explicit static DataSet
+ // membership row must both cross the SignalDefinition -> monitor-point runtime
+ // gate. Harmonic spectra/configuration stay filtered; this exact FLOAT32 process
+ // value is the operator-visible live measurement.
+ Assert.True(genericScalar.CanPublishToRuntime);
+ Assert.True(membershipSignal.IsExplicitDataSetRuntimeValue);
+ Assert.True(membershipSignal.CanPublishAsSignal);
+ Assert.True(membershipSignal.CanPublishToRuntime);
+
+ var structuredParent = new SignalDefinition
+ {
+ Name = "ThdA",
+ ObjectReference = dataObject,
+ DisplayReference = dataObject,
+ FunctionalConstraint = "MX",
+ DataType = "structure",
+ Category = "DataSet",
+ DataSetReference = dataSet,
+ Source = "ARIEC static DataSet parent"
+ };
+ Assert.False(structuredParent.IsExplicitDataSetRuntimeValue);
+ // The object-level DataSet row is also a live composite value (phsA/B/C).
+ // It remains distinct from a scalar member but must not stay Unknown in FAT.
+ Assert.True(structuredParent.CanPublishToRuntime);
+
+ var point = new IoTestPointPlan
+ {
+ TestPointId = "THD-A",
+ IedName = "IED",
+ IpAddress = "192.0.2.10",
+ SignalName = "ThdA.phsA",
+ ObjectReference = runtimeLeaf,
+ FunctionalConstraint = "MX",
+ ExpectedOnText = "Value 1",
+ ExpectedOffText = "Value 2",
+ SourceIecReference = staticMember,
+ ReportDisplayReference = staticMember,
+ EventLogSearchReference = staticMember,
+ DataSetName = dataSet,
+ BindingStatus = IoTestSignalSelectionService.SclDataSetAuthorityBindingStatus,
+ ImportReady = true,
+ TestEnabled = true,
+ SignalKind = FatSignalKind.Analog,
+ CaptureMode = FatCaptureMode.OperatorSnapshot
+ };
+ var ied = new IoTestIedPlan
+ {
+ IedName = "IED",
+ IpAddress = "192.0.2.10",
+ TestPoints = new List { point }
+ };
+ var device = new Iec61850MonitorDevice
+ {
+ Name = "IED",
+ SclIedName = "IED",
+ IpAddress = "192.0.2.10",
+ Port = 102
+ };
+ device.Signals.AddRange(signals);
+
+ var selection = new IoTestSignalSelectionService().Resolve(ied, device);
+
+ Assert.True(selection.Succeeded, selection.Message);
+ var match = Assert.Single(selection.Matches);
+ Assert.Same(membershipSignal, match.Signal);
+ Assert.True(match.Signal.CanPublishToRuntime);
+ Assert.Empty(selection.MissingPoints);
+ Assert.Empty(selection.AmbiguousPoints);
+ }
+
+ [Fact]
+ public void DirectSclFat_ReconciliationCannotGateAcquisitionAtAll()
+ {
+ var source = File.ReadAllText(FindRepoFile("Services/IoTesting/IoTestReconciliationCache.cs"));
+ var directSclGate = source.IndexOf("if (UsesDirectSclFatAuthority(device))", StringComparison.Ordinal);
+ var connectedOwnerGate = source.IndexOf("if (!NativeIec61850Client.HasReconciliationOwner", StringComparison.Ordinal);
+
+ Assert.True(directSclGate >= 0, "Direct SCL FAT must have an explicit nonblocking reconciliation path.");
+ Assert.True(connectedOwnerGate > directSclGate,
+ "Direct SCL FAT must return before native reconciliation ownership is considered.");
+ Assert.Contains("Entries.TryRemove(device, out _);", source, StringComparison.Ordinal);
+ Assert.Contains("return Task.CompletedTask;", source, StringComparison.Ordinal);
+ Assert.Contains("UsesDirectSclFatAuthority", source, StringComparison.Ordinal);
+ Assert.Contains("FAT SCL", source, StringComparison.Ordinal);
+
+ var gateBodyStart = source.IndexOf('{', directSclGate);
+ var gateBodyEnd = source.IndexOf('}', gateBodyStart + 1);
+ var gateBody = source.Substring(gateBodyStart, gateBodyEnd - gateBodyStart + 1);
+ Assert.DoesNotContain("RefreshModelOnlyAsync", gateBody, StringComparison.Ordinal);
+ Assert.DoesNotContain("ReconcileConnectedAsync", gateBody, 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/IoFatFreshSclSelectionAuthorityRegressionTests.cs b/tests/ARSAS.Tests/IoFatFreshSclSelectionAuthorityRegressionTests.cs
new file mode 100644
index 000000000..1a329e05e
--- /dev/null
+++ b/tests/ARSAS.Tests/IoFatFreshSclSelectionAuthorityRegressionTests.cs
@@ -0,0 +1,56 @@
+namespace ARSAS.Tests;
+
+public sealed class IoFatFreshSclSelectionAuthorityRegressionTests
+{
+ [Fact]
+ public void DirectSclImporter_DefaultsStaticDatasetRowsToChecked()
+ {
+ var source = Read("Services/IoTesting/IoFatSclProjectImportService.cs");
+
+ Assert.Contains("TestEnabled = true,", source, StringComparison.Ordinal);
+ Assert.Contains("ImportReady = true,", source, StringComparison.Ordinal);
+ Assert.Contains("BindingStatus = \"SCL_DATASET_AUTHORITY\"", source, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void SclBridge_UsesExistingEngineeringSelectionAsSharedWorkspaceAuthority()
+ {
+ var source = Read("Services/IoTesting/IoFatEngineeringSelectionBridge.cs");
+
+ Assert.Contains("if (preserveExistingEngineeringSelection)", source, StringComparison.Ordinal);
+ Assert.Contains("point.WorkspaceSelected != signal.IsSelected", source, StringComparison.Ordinal);
+ Assert.Contains("point.WorkspaceSelected = signal.IsSelected;", source, StringComparison.Ordinal);
+ Assert.Contains("point.WorkspaceSelected = true;", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("point.TestEnabled = signal.IsSelected;", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("_ = preserveExistingEngineeringSelection;", source, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void FreshSclBridge_DoesNotMergeSharedSelectionWithFatDispositionOrTestScope()
+ {
+ var source = Read("Services/IoTesting/IoFatEngineeringSelectionBridge.cs");
+
+ Assert.Contains("FatDisposition remains a FAT-only Remove/Restore authority", source, StringComparison.Ordinal);
+ Assert.Contains("TEST never mutates Engineering", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("point.RestoreToFat();", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("point.TestEnabled = signal.IsSelected;", source, StringComparison.Ordinal);
+ }
+
+ private static string Read(string relativePath)
+ => File.ReadAllText(FindRepoFile(relativePath)).Replace("\r\n", "\n", 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/IoFatMultiIedConnectionRegressionTests.cs b/tests/ARSAS.Tests/IoFatMultiIedConnectionRegressionTests.cs
index 8931220da..9ceea6b39 100644
--- a/tests/ARSAS.Tests/IoFatMultiIedConnectionRegressionTests.cs
+++ b/tests/ARSAS.Tests/IoFatMultiIedConnectionRegressionTests.cs
@@ -67,18 +67,121 @@ public void IedConnectionAction_IsOwnedByEachIed()
}
[Fact]
- public void P1_SourceContract_UsesPerIedPreparationWithoutWeakeningEvidenceIsolation()
+ public void MultipleIeds_CanRetainIndependentConnectedMonitoringState()
+ {
+ var a = Ied("IED_A", "192.168.81.10", Point("P-A", "IED_A", "192.168.81.10", "IED_ALD0/GGIO1.Ind1.stVal"));
+ var b = Ied("IED_B", "192.168.81.11", Point("P-B", "IED_B", "192.168.81.11", "IED_BLD0/GGIO1.Ind2.stVal"));
+
+ a.ApplyLiveDeviceBinding("device-a", "Monitoring · fast MMS", true, true);
+ b.ApplyLiveDeviceBinding("device-b", "Monitoring · report", true, true);
+
+ Assert.True(a.IsLiveConnected);
+ Assert.True(a.IsLiveMonitoring);
+ Assert.Equal("device-a", a.LiveDeviceId);
+ Assert.True(b.IsLiveConnected);
+ Assert.True(b.IsLiveMonitoring);
+ Assert.Equal("device-b", b.LiveDeviceId);
+ }
+
+ [Fact]
+ public void DirectSclAcquisition_ResolvesUncheckedIncludedMember_ButNotRemovedMember()
+ {
+ const string reference = "IED_ALD0/GGIO1.Ind1.stVal";
+ var point = Point(
+ "P-A",
+ "IED_A",
+ "192.168.81.10",
+ reference,
+ bindingStatus: "SCL_DATASET_AUTHORITY");
+ point.TestEnabled = false;
+
+ var signal = new SignalDefinition
+ {
+ Name = "Ind1",
+ ObjectReference = reference,
+ DisplayReference = reference,
+ FunctionalConstraint = "ST",
+ DataType = "BOOLEAN",
+ Category = "DataSet"
+ };
+ var device = new Iec61850MonitorDevice
+ {
+ Name = "IED_A",
+ SclIedName = "IED_A",
+ IpAddress = "192.168.81.10",
+ Port = 102
+ };
+ device.Signals.Add(signal);
+
+ var service = new IoTestSignalSelectionService();
+ var included = service.Resolve(Ied("IED_A", "192.168.81.10", point), device);
+
+ Assert.Single(included.Matches);
+ Assert.Same(point, included.Matches[0].TestPoint);
+ Assert.Same(signal, included.Matches[0].Signal);
+ Assert.False(point.TestEnabled);
+
+ point.RemoveFromFat();
+ var removed = service.Resolve(Ied("IED_A", "192.168.81.10", point), device);
+ Assert.Empty(removed.Matches);
+ Assert.Empty(removed.MissingPoints);
+ Assert.Empty(removed.AmbiguousPoints);
+ Assert.False(point.IsIncludedInFat);
+ Assert.False(point.TestEnabled);
+ }
+
+ [Fact]
+ public void P1_ConnectAndAcquisitionDoNotUseTestCheckboxAsAuthority()
+ {
+ var autoConnect = ReadRepoFile("MainWindow.IoTesting.AutoConnect.cs");
+ var contextUx = ReadRepoFile("IoListTestingWindow.ContextUx.cs");
+ var monitor = ReadRepoFile("MainWindow.IoTesting.MultiIedMonitor.cs");
+ var resolver = ReadRepoFile("Services/IoTesting/IoTestSignalSelectionService.cs");
+
+ Assert.Contains("requestedPointsOverride is null || point.TestEnabled", autoConnect, StringComparison.Ordinal);
+ Assert.Contains("point.WorkspaceSelected", autoConnect, StringComparison.Ordinal);
+ Assert.Contains("point.IsIncludedInFat", autoConnect, StringComparison.Ordinal);
+ Assert.Contains("point.ImportReady", autoConnect, StringComparison.Ordinal);
+ Assert.Contains("var acquisitionSignals = selection.Matches", autoConnect, StringComparison.Ordinal);
+ Assert.Contains("StartIoFatDeviceMonitorAsync(device, acquisitionSignals)", autoConnect, StringComparison.Ordinal);
+ Assert.Contains("point.TestEnabled || IsDirectSclAuthority(point)", resolver, StringComparison.Ordinal);
+ Assert.Contains("point.WorkspaceSelected", resolver, StringComparison.Ordinal);
+ Assert.Contains("point.IsIncludedInFat", resolver, StringComparison.Ordinal);
+
+ Assert.DoesNotContain("var enabledReady = targetIed.TestPoints", contextUx, StringComparison.Ordinal);
+ Assert.Contains("private Task PrepareIndependentIedConnectionAsync", contextUx, StringComparison.Ordinal);
+ Assert.Contains("Project,\n targetIed,\n progress);", contextUx, StringComparison.Ordinal);
+ Assert.Contains("var captureScope = selectedIed.TestPoints", contextUx, StringComparison.Ordinal);
+ Assert.Contains("point.WorkspaceSelected && point.IsIncludedInFat && point.TestEnabled && point.ImportReady", contextUx, StringComparison.Ordinal);
+ Assert.Contains("var result = Session.Start(selectedIed, liveCaptureScope);", contextUx, StringComparison.Ordinal);
+
+ Assert.Contains("device.BeginBulkSignalSelection();", monitor, StringComparison.Ordinal);
+ Assert.Contains("signal.IsSelected = acquisitionSet.Contains(signal);", monitor, StringComparison.Ordinal);
+ Assert.Contains("signal.IsSelected = wasSelected;", monitor, StringComparison.Ordinal);
+ Assert.Contains("device.EndBulkSignalSelection();", monitor, StringComparison.Ordinal);
+ Assert.Contains("_runtime.StartMonitoringAsync", monitor, StringComparison.Ordinal);
+ Assert.DoesNotContain("SaveSignalSelectionMemory(device)", monitor, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void P2_SourceContract_UsesPerIedEvidenceLeavesWithoutWeakeningDeviceIsolation()
{
var autoConnect = ReadRepoFile("MainWindow.IoTesting.AutoConnect.cs");
var contextUx = ReadRepoFile("IoListTestingWindow.ContextUx.cs");
var progressUx = ReadRepoFile("IoListTestingWindow.RealPreparationProgress.cs");
var connectUx = ReadRepoFile("IoListTestingWindow.MultiIedConnectionUx.cs");
var printPreview = ReadRepoFile("IoListTestingWindow.PrintPreview.cs");
- var session = ReadRepoFile("Services/IoTesting/IoTestSessionController.cs");
+ var leaf = ReadRepoFile("Services/IoTesting/IoTestSessionController.cs");
+ var coordinator = ReadRepoFile("Services/IoTesting/IoTestMultiSessionCoordinator.cs");
+ var runtimeWiring = ReadRepoFile("MainWindow.IoTesting.MultiSessionEvidence.cs");
+ var window = ReadRepoFile("IoListTestingWindow.xaml.cs");
Assert.Contains("_ioTestLiveBindingService.BindIed(ied, Devices)", autoConnect, StringComparison.Ordinal);
Assert.DoesNotContain("_ioTestLiveBindingService.Bind(project, Devices)", autoConnect, StringComparison.Ordinal);
Assert.Contains("requestedPointsOverride", autoConnect, StringComparison.Ordinal);
+ Assert.Contains("selectDevice: false", autoConnect, StringComparison.Ordinal);
+ Assert.Contains("other IED monitors remain active", autoConnect, StringComparison.Ordinal);
+ Assert.DoesNotContain("Session.IsSessionActive", autoConnect, StringComparison.Ordinal);
Assert.Contains("private async void ConnectIed_Click", contextUx, StringComparison.Ordinal);
Assert.Contains("targetIed.IsPreparing", contextUx, StringComparison.Ordinal);
Assert.Contains("SelectedIed.IsPreparing", contextUx, StringComparison.Ordinal);
@@ -89,13 +192,28 @@ public void P1_SourceContract_UsesPerIedPreparationWithoutWeakeningEvidenceIsola
Assert.Contains("Other IED connection workflows keep running", connectUx, StringComparison.Ordinal);
Assert.Contains("new Binding(nameof(SelectedIed))", connectUx, StringComparison.Ordinal);
- // P1 parallelizes connection/monitoring only. The evidence controller remains
- // intentionally single-active so relay transitions cannot enter the wrong journal.
- Assert.Contains("Stop the active FAT session before starting another IED.", session, StringComparison.Ordinal);
- Assert.Contains("entry.DeviceId.Equals(activeDevice.DeviceId", session, StringComparison.Ordinal);
+ // The leaf still refuses a second IED because it is intentionally one-Ied-only.
+ // P2 parallelism comes from one leaf/journal per IED, never by weakening the leaf.
+ Assert.Contains("Stop the active FAT session before starting another IED.", leaf, StringComparison.Ordinal);
+ Assert.Contains("entry.DeviceId.Equals(activeDevice.DeviceId", leaf, StringComparison.Ordinal);
+ Assert.Contains("class IoTestMultiSessionCoordinator", coordinator, StringComparison.Ordinal);
+ Assert.Contains("ActiveSessionCount", coordinator, StringComparison.Ordinal);
+ Assert.Contains("GetOrCreateController", coordinator, StringComparison.Ordinal);
+ Assert.Contains("EnqueueAdditional", coordinator, StringComparison.Ordinal);
+ Assert.Contains("!ReferenceEquals(controller, _primaryController)", coordinator, StringComparison.Ordinal);
+ Assert.Contains("AttachIoTestParallelEvidenceSessions", runtimeWiring, StringComparison.Ordinal);
+ Assert.Contains("_runtime.PointUpdated += Runtime_IoTestAdditionalPointUpdated", runtimeWiring, StringComparison.Ordinal);
+ Assert.Contains("coordinator.EnqueueAdditional(new Iec61850EventEntry", runtimeWiring, StringComparison.Ordinal);
+ Assert.Contains("IoTestMultiSessionCoordinator Session", window, StringComparison.Ordinal);
+ Assert.Contains("Session.StopAll", window, StringComparison.Ordinal);
}
- private static IoTestPointPlan Point(string id, string iedName, string ipAddress, string reference) => new()
+ private static IoTestPointPlan Point(
+ string id,
+ string iedName,
+ string ipAddress,
+ string reference,
+ string bindingStatus = "") => new()
{
TestPointId = id,
IedName = iedName,
@@ -112,6 +230,8 @@ public void P1_SourceContract_UsesPerIedPreparationWithoutWeakeningEvidenceIsola
SourceIecReference = reference,
EventLogSearchReference = reference,
ReportDisplayReference = reference + " [ST]",
+ BindingStatus = bindingStatus,
+ WorkspaceSelected = true,
TestEnabled = true,
ImportReady = true
};
diff --git a/tests/ARSAS.Tests/IoFatP3CaptureRegressionTests.cs b/tests/ARSAS.Tests/IoFatP3CaptureRegressionTests.cs
new file mode 100644
index 000000000..3e28f9271
--- /dev/null
+++ b/tests/ARSAS.Tests/IoFatP3CaptureRegressionTests.cs
@@ -0,0 +1,432 @@
+using ArIED61850Tester.Models;
+using ArIED61850Tester.Models.IoTesting;
+using ArIED61850Tester.Services.IoTesting;
+
+namespace ARSAS.Tests;
+
+public sealed class IoFatP3CaptureRegressionTests
+{
+ [Fact]
+ public void AnalogAutoCapture_LatchesStableBaselineAndFinalSettledValue_NotTransients()
+ {
+ var point = NewPoint("AN-1", FatSignalKind.Analog, FatCaptureMode.OperatorSnapshot);
+ var coordinator = new FatAutoCaptureCoordinator();
+ long sequence = 0;
+
+ FatAutoCaptureDecision decision = default!;
+ foreach (var raw in new[] { "0", "0", "0" })
+ {
+ decision = coordinator.Observe(point, Observation(raw, ++sequence));
+ if (decision.Evidence != null)
+ point.Runtime.SetFatValueEvidence(decision.Evidence);
+ }
+
+ Assert.NotNull(point.Runtime.Value1Evidence);
+ Assert.Equal("0", point.Runtime.Value1Evidence!.RawValue);
+ Assert.Equal(FatEvidenceCaptureKind.AutomaticValue, point.Runtime.Value1Evidence.CaptureKind);
+
+ foreach (var raw in new[] { "18.412", "43.920", "61.850", "65.702", "65.746", "65.748", "65.748", "65.748" })
+ {
+ decision = coordinator.Observe(point, Observation(raw, ++sequence));
+ if (decision.Evidence != null)
+ point.Runtime.SetFatValueEvidence(decision.Evidence);
+ }
+
+ Assert.NotNull(point.Runtime.Value2Evidence);
+ Assert.Equal("65.748", point.Runtime.Value2Evidence!.RawValue);
+ Assert.Equal(FatEvidenceCaptureKind.AutomaticValue, point.Runtime.Value2Evidence.CaptureKind);
+ Assert.Equal(FatAutoCaptureStage.Complete, decision.Stage);
+ }
+
+ [Fact]
+ public void AutoCapture_BadQualityDoesNotCreateEvidence_AndCompletePairDoesNotAutoOverwrite()
+ {
+ var point = NewPoint("AN-2", FatSignalKind.Analog, FatCaptureMode.OperatorSnapshot);
+ var coordinator = new FatAutoCaptureCoordinator();
+
+ var rejected = coordinator.Observe(point, Observation("12.3", 1, quality: "Invalid"));
+ Assert.Null(rejected.Evidence);
+ Assert.False(point.HasValue1Evidence);
+
+ var value1 = Evidence(FatValueSlot.Value1, "10", 2, FatEvidenceCaptureKind.OperatorRecapture);
+ var value2 = Evidence(FatValueSlot.Value2, "20", 3, FatEvidenceCaptureKind.OperatorRecapture);
+ point.Runtime.SetFatValueEvidence(value1);
+ point.Runtime.SetFatValueEvidence(value2);
+
+ var locked = coordinator.Observe(point, Observation("999", 4));
+
+ Assert.Null(locked.Evidence);
+ Assert.Equal(FatAutoCaptureStage.Complete, locked.Stage);
+ Assert.Equal(value1.EvidenceId, point.Runtime.Value1Evidence!.EvidenceId);
+ Assert.Equal(value2.EvidenceId, point.Runtime.Value2Evidence!.EvidenceId);
+ }
+
+ [Fact]
+ public void GenericValueEvidence_OverridesLegacyAutomaticOnOffProjection()
+ {
+ var point = NewPoint("DI-1", FatSignalKind.Discrete, FatCaptureMode.AutomaticTransition);
+ var evaluator = new IoTestTransitionEvaluator();
+ evaluator.StartAttempt(point, DigitalObservation(false, 1));
+ evaluator.Observe(point, DigitalObservation(true, 2));
+ evaluator.Observe(point, DigitalObservation(false, 3));
+
+ Assert.Equal("True", point.Value1Text);
+ Assert.Equal("False", point.Value2Text);
+
+ var manualValue1 = Evidence(FatValueSlot.Value1, "MANUAL-V1", 4, FatEvidenceCaptureKind.OperatorRecapture);
+ var manualValue2 = Evidence(FatValueSlot.Value2, "MANUAL-V2", 5, FatEvidenceCaptureKind.OperatorRecapture);
+ point.Runtime.SetFatValueEvidence(manualValue1);
+ point.Runtime.SetFatValueEvidence(manualValue2);
+
+ Assert.Equal("MANUAL-V1", point.Value1Text);
+ Assert.Equal("MANUAL-V2", point.Value2Text);
+ Assert.True(point.IsFatEvidenceComplete);
+ }
+
+ [Fact]
+ public void RecaptureSingleSlot_PreservesOtherSlot_AndUsesOperatorRecaptureProvenance()
+ {
+ var fixture = CreateSnapshotFixture();
+ using var controller = fixture.Controller;
+ Assert.True(controller.Start(fixture.Ied).Succeeded);
+
+ fixture.LivePoint.Value = "10";
+ fixture.LivePoint.Sequence = 1;
+ Assert.True(controller.CaptureOperatorSnapshot(fixture.Point, FatValueSlot.Value1).Succeeded);
+ fixture.LivePoint.Value = "20";
+ fixture.LivePoint.Sequence = 2;
+ Assert.True(controller.CaptureOperatorSnapshot(fixture.Point, FatValueSlot.Value2).Succeeded);
+ var oldValue2 = fixture.Point.Runtime.Value2Evidence!.EvidenceId;
+
+ fixture.LivePoint.Value = "15";
+ fixture.LivePoint.Sequence = 3;
+ var recaptured = controller.RecaptureValues(new[] { fixture.Point }, FatValueSlot.Value1);
+
+ Assert.True(recaptured.Succeeded, recaptured.Message);
+ Assert.Equal("15", fixture.Point.Value1Text);
+ Assert.Equal("20", fixture.Point.Value2Text);
+ Assert.Equal(oldValue2, fixture.Point.Runtime.Value2Evidence!.EvidenceId);
+ Assert.Equal(FatEvidenceCaptureKind.OperatorRecapture, fixture.Point.Runtime.Value1Evidence!.CaptureKind);
+ }
+
+ [Fact]
+ public void PairRecapture_StagesValue1WithoutChangingCurrentPair_ThenPromotesBothOnValue2()
+ {
+ var fixture = CreateSnapshotFixture();
+ using var controller = fixture.Controller;
+ Assert.True(controller.Start(fixture.Ied).Succeeded);
+ CaptureInitialPair(controller, fixture, "10", "20");
+ var oldV1 = fixture.Point.Runtime.Value1Evidence!.EvidenceId;
+ var oldV2 = fixture.Point.Runtime.Value2Evidence!.EvidenceId;
+
+ fixture.LivePoint.Value = "30";
+ fixture.LivePoint.Sequence = 3;
+ var staged = controller.BeginPairRecapture(new[] { fixture.Point });
+
+ Assert.True(staged.Succeeded, staged.Message);
+ Assert.Equal(oldV1, fixture.Point.Runtime.Value1Evidence!.EvidenceId);
+ Assert.Equal(oldV2, fixture.Point.Runtime.Value2Evidence!.EvidenceId);
+ Assert.Equal("10", fixture.Point.Value1Text);
+ Assert.Equal("20", fixture.Point.Value2Text);
+
+ fixture.LivePoint.Value = "40";
+ fixture.LivePoint.Sequence = 4;
+ var committed = controller.RecaptureValues(new[] { fixture.Point }, FatValueSlot.Value2);
+
+ Assert.True(committed.Succeeded, committed.Message);
+ Assert.Equal("30", fixture.Point.Value1Text);
+ Assert.Equal("40", fixture.Point.Value2Text);
+ Assert.Equal(FatEvidenceCaptureKind.OperatorRecapture, fixture.Point.Runtime.Value1Evidence!.CaptureKind);
+ Assert.Equal(FatEvidenceCaptureKind.OperatorRecapture, fixture.Point.Runtime.Value2Evidence!.CaptureKind);
+ Assert.True(controller.Stop().Succeeded);
+ var journal = File.ReadAllText(controller.JournalPath);
+ Assert.Contains("fat_value_recapture_pair_staged", journal, StringComparison.Ordinal);
+ Assert.Contains("fat_value_recapture_pair_commit", journal, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void PairRecapture_CancelLeavesPreviousCurrentPairUntouched()
+ {
+ var fixture = CreateSnapshotFixture();
+ using var controller = fixture.Controller;
+ Assert.True(controller.Start(fixture.Ied).Succeeded);
+ CaptureInitialPair(controller, fixture, "10", "20");
+ var oldV1 = fixture.Point.Runtime.Value1Evidence!.EvidenceId;
+ var oldV2 = fixture.Point.Runtime.Value2Evidence!.EvidenceId;
+
+ fixture.LivePoint.Value = "50";
+ fixture.LivePoint.Sequence = 3;
+ Assert.True(controller.BeginPairRecapture(new[] { fixture.Point }).Succeeded);
+ var cancelled = controller.CancelPairRecapture(new[] { fixture.Point });
+
+ Assert.True(cancelled.Succeeded, cancelled.Message);
+ Assert.Equal(oldV1, fixture.Point.Runtime.Value1Evidence!.EvidenceId);
+ Assert.Equal(oldV2, fixture.Point.Runtime.Value2Evidence!.EvidenceId);
+ Assert.Equal("10", fixture.Point.Value1Text);
+ Assert.Equal("20", fixture.Point.Value2Text);
+ }
+
+ [Fact]
+ public void BatchJournalFailure_DoesNotPromoteAnyRecapturePointer()
+ {
+ var fixture = CreateSnapshotFixture(useFailingJournal: true);
+ using var controller = fixture.Controller;
+ Assert.True(controller.Start(fixture.Ied).Succeeded);
+ CaptureInitialPair(controller, fixture, "10", "20");
+ var oldV1 = fixture.Point.Runtime.Value1Evidence!.EvidenceId;
+ var oldV2 = fixture.Point.Runtime.Value2Evidence!.EvidenceId;
+ fixture.FailingJournal!.FailNextBatch = true;
+
+ fixture.LivePoint.Value = "99";
+ fixture.LivePoint.Sequence = 4;
+ var result = controller.RecaptureValues(new[] { fixture.Point }, FatValueSlot.Value1);
+
+ Assert.False(result.Succeeded);
+ Assert.Equal(oldV1, fixture.Point.Runtime.Value1Evidence!.EvidenceId);
+ Assert.Equal(oldV2, fixture.Point.Runtime.Value2Evidence!.EvidenceId);
+ Assert.Equal(IoTestSessionState.Faulted, controller.State);
+ }
+
+ [Fact]
+ public void Recapture_PreflightRejectsRemovedRowWithoutChangingEvidence()
+ {
+ var fixture = CreateSnapshotFixture();
+ using var controller = fixture.Controller;
+ Assert.True(controller.Start(fixture.Ied).Succeeded);
+ CaptureInitialPair(controller, fixture, "10", "20");
+ var oldV1 = fixture.Point.Runtime.Value1Evidence!.EvidenceId;
+ fixture.Point.RemoveFromFat();
+ fixture.LivePoint.Value = "100";
+
+ var result = controller.RecaptureValues(new[] { fixture.Point }, FatValueSlot.Value1);
+
+ Assert.False(result.Succeeded);
+ Assert.Equal(oldV1, fixture.Point.Runtime.Value1Evidence!.EvidenceId);
+ }
+
+ [Fact]
+ public void P3GridSource_UsesExtendedSelection_PreservesSelectedRightClick_AndOffersRecaptureMenu()
+ {
+ var source = File.ReadAllText(FindRepositoryFile("IoListTestingWindow.FatV2Ux.cs"));
+
+ Assert.Contains("SelectionMode = DataGridSelectionMode.Extended", source, StringComparison.Ordinal);
+ Assert.Contains("if (row.IsSelected)", source, StringComparison.Ordinal);
+ Assert.Contains("SelectedItems", source, StringComparison.Ordinal);
+ Assert.Contains("Header = \"Recapture\"", source, StringComparison.Ordinal);
+ Assert.Contains("Header = \"Value 1\"", source, StringComparison.Ordinal);
+ Assert.Contains("Header = \"Value 2\"", source, StringComparison.Ordinal);
+ Assert.Contains("Header = \"Value 1 & Value 2\"", source, StringComparison.Ordinal);
+ Assert.Contains("BeginPairRecapture", source, StringComparison.Ordinal);
+ }
+
+ private static void CaptureInitialPair(
+ IoTestSessionController controller,
+ SnapshotFixture fixture,
+ string value1,
+ string value2)
+ {
+ fixture.LivePoint.Value = value1;
+ fixture.LivePoint.Sequence = 1;
+ Assert.True(controller.CaptureOperatorSnapshot(fixture.Point, FatValueSlot.Value1).Succeeded);
+ fixture.LivePoint.Value = value2;
+ fixture.LivePoint.Sequence = 2;
+ Assert.True(controller.CaptureOperatorSnapshot(fixture.Point, FatValueSlot.Value2).Succeeded);
+ }
+
+ private static SnapshotFixture CreateSnapshotFixture(bool useFailingJournal = false)
+ {
+ var point = NewPoint("AN-SNAPSHOT", FatSignalKind.Analog, FatCaptureMode.OperatorSnapshot);
+ var ied = new IoTestIedPlan
+ {
+ IedName = point.IedName,
+ IpAddress = point.IpAddress,
+ TestPoints = { point }
+ };
+ var project = new IoTestProject
+ {
+ ProjectId = "P3-FAT",
+ SchemaVersion = "ARSAS-FAT-SCL-1.0",
+ ProjectName = "P3 FAT",
+ Ieds = { ied }
+ };
+ project.InitializeRuntimeNotifications();
+
+ var device = new Iec61850MonitorDevice
+ {
+ DeviceId = "p3-device",
+ Name = ied.IedName,
+ SclIedName = ied.IedName,
+ IpAddress = ied.IpAddress,
+ Port = 102,
+ IsConnected = true,
+ IsMonitoring = true,
+ Status = "Monitoring"
+ };
+ var livePoint = new Iec61850MonitorPoint
+ {
+ DeviceId = device.DeviceId,
+ DeviceName = device.Name,
+ IpAddress = device.IpAddress,
+ SignalName = point.SignalName,
+ IecReference = point.ObjectReference,
+ FunctionalConstraint = "MX",
+ Value = "0",
+ Quality = "Good",
+ DeviceTimestamp = "2026-09-03T06:00:00.000Z",
+ SourceMode = "MMS-POLL",
+ Sequence = 0,
+ Status = "Live"
+ };
+ device.Points.Add(livePoint);
+ new IoTestLiveBindingService().Bind(project, new[] { device });
+
+ var root = Path.Combine(Path.GetTempPath(), "ARSAS.Tests", "P3", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(root);
+ FailingBatchJournal? failing = null;
+ IoTestSessionController controller;
+ if (useFailingJournal)
+ {
+ controller = new IoTestSessionController(
+ project,
+ key => Resolves(key, device) ? device : null,
+ action => action(),
+ root,
+ journalFactory: (p, plan, sessionId, startedAt) =>
+ {
+ failing = new FailingBatchJournal(IoTestEvidenceJournal.Create(root, p, plan, sessionId, startedAt));
+ return failing;
+ });
+ }
+ else
+ {
+ controller = new IoTestSessionController(
+ project,
+ key => Resolves(key, device) ? device : null,
+ action => action(),
+ root);
+ }
+
+ return new SnapshotFixture(project, ied, point, device, livePoint, root, controller, () => failing);
+ }
+
+ private static bool Resolves(string key, Iec61850MonitorDevice device)
+ => key.Equals(device.DeviceId, StringComparison.OrdinalIgnoreCase) ||
+ key.Equals(device.Name, StringComparison.OrdinalIgnoreCase) ||
+ key.Equals(device.IpAddress, StringComparison.OrdinalIgnoreCase);
+
+ private static IoTestPointPlan NewPoint(string id, FatSignalKind kind, FatCaptureMode mode)
+ => new()
+ {
+ TestPointId = id,
+ IedName = "IED1",
+ IpAddress = "192.0.2.10",
+ SignalName = id,
+ ObjectReference = $"IED1LD0/GGIO1.{id}.stVal",
+ FunctionalConstraint = kind == FatSignalKind.Analog ? "MX" : "ST",
+ ExpectedOnText = "Value 1",
+ ExpectedOffText = "Value 2",
+ SignalKind = kind,
+ CaptureMode = mode,
+ WorkspaceSelected = true,
+ TestEnabled = true,
+ ImportReady = true,
+ BindingStatus = IoTestSignalSelectionService.SclWorkspaceAuthorityBindingStatus
+ };
+
+ private static IoTestObservation Observation(string raw, long sequence, string quality = "Good")
+ {
+ var timestamp = new DateTimeOffset(2026, 9, 3, 6, 0, 0, TimeSpan.Zero).AddMilliseconds(sequence * 100);
+ return new IoTestObservation(
+ null,
+ raw,
+ timestamp,
+ timestamp.AddMilliseconds(-2),
+ quality,
+ "MMS-POLL",
+ sequence,
+ 1);
+ }
+
+ private static IoTestObservation DigitalObservation(bool state, long sequence)
+ {
+ var timestamp = new DateTimeOffset(2026, 9, 3, 6, 0, 0, TimeSpan.Zero).AddMilliseconds(sequence * 100);
+ return new IoTestObservation(
+ state,
+ state ? "True" : "False",
+ timestamp,
+ timestamp.AddMilliseconds(-2),
+ "Good",
+ "BRCB",
+ sequence,
+ 1);
+ }
+
+ private static FatValueEvidence Evidence(
+ FatValueSlot slot,
+ string raw,
+ long sequence,
+ FatEvidenceCaptureKind kind)
+ => new(
+ Guid.NewGuid(),
+ slot,
+ kind,
+ raw,
+ DateTimeOffset.UtcNow,
+ DateTimeOffset.UtcNow.AddMilliseconds(-2),
+ "Good",
+ "MMS-POLL",
+ sequence,
+ 1);
+
+ private static string FindRepositoryFile(string relativePath)
+ {
+ var directory = new DirectoryInfo(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}'.");
+ }
+
+ private sealed record SnapshotFixture(
+ IoTestProject Project,
+ IoTestIedPlan Ied,
+ IoTestPointPlan Point,
+ Iec61850MonitorDevice Device,
+ Iec61850MonitorPoint LivePoint,
+ string Root,
+ IoTestSessionController Controller,
+ Func FailingJournalAccessor)
+ {
+ public FailingBatchJournal? FailingJournal => FailingJournalAccessor();
+ }
+
+ private sealed class FailingBatchJournal : IIoTestEvidenceJournal
+ {
+ private readonly IIoTestEvidenceJournal _inner;
+
+ public FailingBatchJournal(IIoTestEvidenceJournal inner) => _inner = inner;
+
+ public bool FailNextBatch { get; set; }
+ public string FilePath => _inner.FilePath;
+ public long RecordCount => _inner.RecordCount;
+ public string LastHash => _inner.LastHash;
+
+ public IoTestJournalEnvelope Append(IoTestJournalEntry entry) => _inner.Append(entry);
+
+ public IReadOnlyList AppendBatch(IEnumerable entries)
+ {
+ if (FailNextBatch)
+ {
+ FailNextBatch = false;
+ throw new IOException("Synthetic P3 batch journal failure.");
+ }
+ return _inner.AppendBatch(entries);
+ }
+
+ public void Dispose() => _inner.Dispose();
+ }
+}
diff --git a/tests/ARSAS.Tests/IoFatP53PartialLiveRegressionTests.cs b/tests/ARSAS.Tests/IoFatP53PartialLiveRegressionTests.cs
index a9248a27b..6399a9938 100644
--- a/tests/ARSAS.Tests/IoFatP53PartialLiveRegressionTests.cs
+++ b/tests/ARSAS.Tests/IoFatP53PartialLiveRegressionTests.cs
@@ -11,7 +11,7 @@ public void Preparation_AllowsUsablePartialSclLiveScopeWithoutMutatingOperatorSe
Assert.Contains("mayProceedWithPartialSclSelection", source, StringComparison.Ordinal);
Assert.Contains("selection.Matches.Count > 0", source, StringComparison.Ordinal);
- Assert.Contains("unresolvedSelectionPoints.All(IoTestSignalSelectionService.IsSclDataSetAuthority)", source, StringComparison.Ordinal);
+ Assert.Contains("unresolvedSelectionPoints.All(IoTestSignalSelectionService.IsDirectSclAuthority)", source, StringComparison.Ordinal);
Assert.Contains("if (liveCount == 0)", source, StringComparison.Ordinal);
Assert.DoesNotContain("point.TestEnabled = false", source, StringComparison.Ordinal);
Assert.DoesNotContain("RemoveFromFat()", source, StringComparison.Ordinal);
@@ -44,6 +44,7 @@ public void OperatorSelectedWaitingRow_RemainsIncludedAndSelectedByModelContract
FunctionalConstraint = "MX",
ExpectedOnText = "Value 1",
ExpectedOffText = "Value 2",
+ WorkspaceSelected = true,
TestEnabled = true,
ImportReady = true
};
@@ -53,6 +54,7 @@ public void OperatorSelectedWaitingRow_RemainsIncludedAndSelectedByModelContract
"Waiting for safe structured primary binding.",
"device-1");
+ Assert.True(point.WorkspaceSelected);
Assert.True(point.TestEnabled);
Assert.True(point.IsIncludedInFat);
Assert.Equal(IoTestLiveBindingState.NotEvaluated, point.LiveBindingState);
diff --git a/tests/ARSAS.Tests/IoFatP53StructuredAnalogBindingRegressionTests.cs b/tests/ARSAS.Tests/IoFatP53StructuredAnalogBindingRegressionTests.cs
index 744126ad2..f4ad51fec 100644
--- a/tests/ARSAS.Tests/IoFatP53StructuredAnalogBindingRegressionTests.cs
+++ b/tests/ARSAS.Tests/IoFatP53StructuredAnalogBindingRegressionTests.cs
@@ -1,6 +1,8 @@
using AR.Iec61850.Discovery;
using ArIED61850Tester.Models;
+using ArIED61850Tester.Models.IoTesting;
using ArIED61850Tester.Services;
+using ArIED61850Tester.Services.IoTesting;
namespace ARSAS.Tests;
@@ -30,6 +32,7 @@ public void MandatoryInventory_PreservesStaticPhaseIdentityAndUsesResolvedMagnit
Assert.Equal(staticMember, signal.DisplayReference);
Assert.Equal(runtimeLeaf, signal.ObjectReference);
Assert.Equal("MX", signal.FunctionalConstraint);
+ Assert.True(signal.CanPublishAsSignal);
Assert.Contains("mandatory static DataSet member", signal.Source, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("primary leaf unresolved", signal.Source, StringComparison.OrdinalIgnoreCase);
}
@@ -55,10 +58,99 @@ public void MandatoryInventory_DoesNotCrossStructuredPhaseBoundary()
Assert.DoesNotContain("phsBC", signal.ObjectReference, StringComparison.OrdinalIgnoreCase);
}
+ [Theory]
+ [InlineData("A", "phsA", "phsB", "phsC")]
+ [InlineData("PPV", "phsAB", "phsBC", "phsCA")]
+ public void MandatoryInventory_DoesNotCollapseStructuredPhaseSiblingsThroughSharedParentAlias(
+ string dataObjectName,
+ string phase1,
+ string phase2,
+ string phase3)
+ {
+ var dataObject = "IEDLD0/MMXU1." + dataObjectName;
+ var phases = new[] { phase1, phase2, phase3 };
+ var members = phases.Select(phase => dataObject + "." + phase).ToArray();
+ var attributes = phases
+ .SelectMany(phase => new[]
+ {
+ Attribute(dataObject + "." + phase + ".cVal.mag.f", phase + ".cVal.mag.f"),
+ Attribute(dataObject + "." + phase + ".q", phase + ".q", "Quality"),
+ Attribute(dataObject + "." + phase + ".t", phase + ".t", "Timestamp")
+ })
+ .ToArray();
+ var design = BuildDesign(dataObject, members, attributes);
+ var signals = new List();
+
+ var result = Iec61850DataSetSignalInventoryService.EnsureMandatorySignals(signals, design);
+
+ Assert.Equal(3, result.MandatoryCatalogCount);
+ Assert.Equal(3, result.AddedCount);
+ Assert.Equal(3, signals.Count);
+ Assert.Equal(3, signals.Select(signal => signal.DisplayReference).Distinct(StringComparer.OrdinalIgnoreCase).Count());
+ Assert.Equal(3, signals.Select(signal => signal.ObjectReference).Distinct(StringComparer.OrdinalIgnoreCase).Count());
+
+ foreach (var phase in phases)
+ {
+ var staticMember = dataObject + "." + phase;
+ var runtimeLeaf = staticMember + ".cVal.mag.f";
+ var signal = Assert.Single(signals.Where(candidate =>
+ candidate.DisplayReference.Equals(staticMember, StringComparison.OrdinalIgnoreCase)));
+ Assert.Equal(runtimeLeaf, signal.ObjectReference);
+ }
+
+ var ied = new IoTestIedPlan
+ {
+ IedName = "IED",
+ IpAddress = "192.0.2.10",
+ IedRole = "Regression",
+ TestPoints = phases.Select(phase =>
+ {
+ var staticMember = dataObject + "." + phase;
+ return new IoTestPointPlan
+ {
+ TestPointId = "THD-" + phase,
+ IedName = "IED",
+ IpAddress = "192.0.2.10",
+ SignalName = phase,
+ ObjectReference = staticMember + ".cVal.mag.f",
+ SourceIecReference = staticMember,
+ ReportDisplayReference = staticMember,
+ FunctionalConstraint = "MX",
+ ExpectedOnText = "Value 1",
+ ExpectedOffText = "Value 2",
+ ImportReady = true,
+ TestEnabled = true
+ };
+ }).ToList()
+ };
+ var device = new Iec61850MonitorDevice
+ {
+ Name = "IED",
+ SclIedName = "IED",
+ IpAddress = "192.0.2.10",
+ Port = 102
+ };
+ device.Signals.AddRange(signals);
+
+ var selection = new IoTestSignalSelectionService().Resolve(ied, device);
+
+ Assert.True(selection.Succeeded, selection.Message);
+ Assert.Equal(3, selection.Matches.Count);
+ Assert.Equal(3, selection.Matches.Select(match => match.Signal).Distinct().Count());
+ Assert.Empty(selection.MissingPoints);
+ Assert.Empty(selection.AmbiguousPoints);
+ }
+
private static LiveIedModelDiscoveryDocument BuildDesign(
string dataObjectReference,
string memberReference,
params LiveIedDataAttributeModel[] attributes)
+ => BuildDesign(dataObjectReference, new[] { memberReference }, attributes);
+
+ private static LiveIedModelDiscoveryDocument BuildDesign(
+ string dataObjectReference,
+ IReadOnlyList memberReferences,
+ params LiveIedDataAttributeModel[] attributes)
{
var slash = dataObjectReference.IndexOf('/');
var domain = dataObjectReference[..slash];
@@ -105,18 +197,16 @@ private static LiveIedModelDiscoveryDocument BuildDesign(
Domain = domain,
LogicalNode = "LLN0",
Name = "Analog",
- MemberCount = 1,
- Members = new[]
- {
+ MemberCount = memberReferences.Count,
+ Members = memberReferences.Select((memberReference, index) =>
new LiveIedDataSetMemberModel
{
- Index = 0,
+ Index = index,
Reference = memberReference,
FunctionalConstraint = "MX",
MmsReference = BuildMmsReference(memberReference),
Confidence = LiveIedDiscoveryConfidenceLevel.Exact
- }
- }
+ }).ToArray()
}
}
};
diff --git a/tests/ARSAS.Tests/IoFatParallelEvidenceSessionRegressionTests.cs b/tests/ARSAS.Tests/IoFatParallelEvidenceSessionRegressionTests.cs
new file mode 100644
index 000000000..00c51cda2
--- /dev/null
+++ b/tests/ARSAS.Tests/IoFatParallelEvidenceSessionRegressionTests.cs
@@ -0,0 +1,257 @@
+using ArIED61850Tester.Models;
+using ArIED61850Tester.Models.IoTesting;
+using ArIED61850Tester.Services.IoTesting;
+
+namespace ARSAS.Tests;
+
+public sealed class IoFatParallelEvidenceSessionRegressionTests
+{
+ [Fact]
+ public void TwoIeds_CanCaptureEvidenceConcurrently_WithStrictDeviceIsolation()
+ {
+ var fixture = CreateFixture();
+ using var primary = NewController(fixture.Project, fixture.Devices, fixture.Root);
+ using var sessions = new IoTestMultiSessionCoordinator(fixture.Project, primary);
+ sessions.ConfigureSiblingFactory(() => NewController(fixture.Project, fixture.Devices, fixture.Root));
+
+ sessions.SelectContext(fixture.IedA);
+ var startA = sessions.Start(fixture.IedA, new[] { fixture.PointA });
+ sessions.SelectContext(fixture.IedB);
+ var startB = sessions.Start(fixture.IedB, new[] { fixture.PointB });
+
+ Assert.True(startA.Succeeded, startA.Message);
+ Assert.True(startB.Succeeded, startB.Message);
+ Assert.Equal(2, sessions.ActiveSessionCount);
+ Assert.True(sessions.IsIedSessionActive(fixture.IedA));
+ Assert.True(sessions.IsIedSessionActive(fixture.IedB));
+ Assert.Same(fixture.IedB, sessions.ActiveIed);
+
+ // Reproduce the production split route: MainWindow sends every event to the
+ // primary leaf, while the P2 coordinator sends it only to additional leaves.
+ RouteEvent(primary, sessions, Event(fixture.DeviceA, fixture.PointA, false, true, 1));
+ RouteEvent(primary, sessions, Event(fixture.DeviceB, fixture.PointB, false, true, 1));
+ RouteEvent(primary, sessions, Event(fixture.DeviceA, fixture.PointA, true, false, 2));
+ RouteEvent(primary, sessions, Event(fixture.DeviceB, fixture.PointB, true, false, 2));
+
+ Assert.Equal(IoTestPointState.Passed, fixture.PointA.Runtime.State);
+ Assert.Equal(IoTestPointState.Passed, fixture.PointB.Runtime.State);
+ Assert.Equal(1, fixture.PointA.Runtime.OnEvidence!.Sequence);
+ Assert.Equal(2, fixture.PointA.Runtime.OffEvidence!.Sequence);
+ Assert.Equal(1, fixture.PointB.Runtime.OnEvidence!.Sequence);
+ Assert.Equal(2, fixture.PointB.Runtime.OffEvidence!.Sequence);
+
+ // Stopping B must not seal or interrupt A's independent session/journal.
+ var stopB = sessions.Stop("IED B complete");
+ Assert.True(stopB.Succeeded, stopB.Message);
+ Assert.Equal(1, sessions.ActiveSessionCount);
+ Assert.True(sessions.IsIedSessionActive(fixture.IedA));
+ Assert.False(sessions.IsIedSessionActive(fixture.IedB));
+
+ sessions.SelectContext(fixture.IedA);
+ Assert.Same(fixture.IedA, sessions.ActiveIed);
+ Assert.True(sessions.CanStop);
+ var stopA = sessions.Stop("IED A complete");
+ Assert.True(stopA.Succeeded, stopA.Message);
+ Assert.False(sessions.IsSessionActive);
+ Assert.False(sessions.HasActiveSessions);
+ Assert.Equal(0, sessions.ActiveSessionCount);
+
+ var evidenceFiles = Directory.GetFiles(fixture.Root, "*.evidence.jsonl", SearchOption.AllDirectories);
+ Assert.Equal(2, evidenceFiles.Length);
+ Assert.All(evidenceFiles, path =>
+ {
+ var verification = IoTestEvidenceJournal.Verify(path);
+ Assert.True(verification.IsValid, verification.Error);
+ });
+ }
+
+ [Fact]
+ public void SelectedContext_EditAndControlState_IsIndependentFromSiblingSession()
+ {
+ var fixture = CreateFixture();
+ using var primary = NewController(fixture.Project, fixture.Devices, fixture.Root);
+ using var sessions = new IoTestMultiSessionCoordinator(fixture.Project, primary);
+ sessions.ConfigureSiblingFactory(() => NewController(fixture.Project, fixture.Devices, fixture.Root));
+
+ sessions.SelectContext(fixture.IedA);
+ Assert.True(sessions.Start(fixture.IedA, new[] { fixture.PointA }).Succeeded);
+ Assert.True(sessions.IsSessionActive);
+ Assert.True(sessions.HasActiveSessions);
+ Assert.True(sessions.IsSelectedSessionActive);
+ Assert.False(sessions.CanEditPlan);
+ Assert.False(sessions.CanStart);
+
+ sessions.SelectContext(fixture.IedB);
+
+ // IsSessionActive is selected-context state for existing UI bindings. The global
+ // close/export safety boundary remains HasActiveSessions/ActiveSessionCount.
+ Assert.False(sessions.IsSessionActive);
+ Assert.True(sessions.HasActiveSessions); // A is still running globally.
+ Assert.False(sessions.IsSelectedSessionActive);
+ Assert.True(sessions.CanEditPlan);
+ Assert.True(sessions.CanStart);
+ Assert.Null(sessions.ActiveIed);
+
+ Assert.True(sessions.Start(fixture.IedB, new[] { fixture.PointB }).Succeeded);
+ Assert.True(sessions.IsSessionActive);
+ Assert.True(sessions.HasActiveSessions);
+ Assert.True(sessions.IsSelectedSessionActive);
+ Assert.False(sessions.CanEditPlan);
+ Assert.Equal(2, sessions.ActiveSessionCount);
+
+ Assert.True(sessions.StopAll("test cleanup").Succeeded);
+ Assert.False(sessions.IsSessionActive);
+ Assert.False(sessions.HasActiveSessions);
+ }
+
+ private static void RouteEvent(
+ IoTestSessionController primary,
+ IoTestMultiSessionCoordinator sessions,
+ Iec61850EventEntry entry)
+ {
+ primary.Enqueue(entry);
+ sessions.EnqueueAdditional(entry);
+ }
+
+ private static Fixture CreateFixture()
+ {
+ var pointA = Point("A-1", "IED_A", "192.168.81.10", "IED_ALD0/GGIO1.Ind1.stVal");
+ var pointB = Point("B-1", "IED_B", "192.168.81.11", "IED_BLD0/GGIO1.Ind2.stVal");
+ var iedA = Ied("IED_A", "192.168.81.10", pointA);
+ var iedB = Ied("IED_B", "192.168.81.11", pointB);
+ var project = new IoTestProject
+ {
+ ProjectId = "P2-PARALLEL",
+ SchemaVersion = "ARSAS-FAT-IO-1.0",
+ ProjectName = "P2 Parallel Evidence",
+ SourceWorkbookName = "P2.xlsx",
+ SourceWorkbookSha256 = new string('a', 64),
+ Ieds = { iedA, iedB }
+ };
+ project.InitializeRuntimeNotifications();
+
+ var deviceA = Device("device-a", iedA, pointA);
+ var deviceB = Device("device-b", iedB, pointB);
+ var devices = new[] { deviceA, deviceB };
+ new IoTestLiveBindingService().Bind(project, devices);
+
+ var root = Path.Combine(Path.GetTempPath(), "ARSAS.Tests", "P2", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(root);
+ return new Fixture(project, iedA, iedB, pointA, pointB, deviceA, deviceB, devices, root);
+ }
+
+ private static IoTestPointPlan Point(
+ string id,
+ string iedName,
+ string ipAddress,
+ string reference) => new()
+ {
+ TestPointId = id,
+ IedName = iedName,
+ IpAddress = ipAddress,
+ SignalName = id,
+ ObjectReference = reference,
+ FunctionalConstraint = "ST",
+ ExpectedOnText = "ON",
+ ExpectedOffText = "OFF",
+ LogicalDevice = iedName + "LD0",
+ LogicalNode = "GGIO1",
+ DataObject = "Ind",
+ DataAttribute = "stVal",
+ SourceIecReference = reference,
+ EventLogSearchReference = reference,
+ ReportDisplayReference = reference + " [ST]",
+ BindingStatus = "CID_DATASET_EXACT",
+ TestEnabled = true,
+ ImportReady = true
+ };
+
+ private static IoTestIedPlan Ied(string name, string ipAddress, IoTestPointPlan point) => new()
+ {
+ IedName = name,
+ IpAddress = ipAddress,
+ IedRole = "Protection IED",
+ TestPoints = { point }
+ };
+
+ private static Iec61850MonitorDevice Device(
+ string deviceId,
+ IoTestIedPlan ied,
+ IoTestPointPlan point)
+ {
+ var device = new Iec61850MonitorDevice
+ {
+ DeviceId = deviceId,
+ Name = ied.IedName,
+ SclIedName = ied.IedName,
+ IpAddress = ied.IpAddress,
+ Port = 102,
+ IsConnected = true,
+ IsMonitoring = true,
+ Status = "Monitoring"
+ };
+ device.Points.Add(new Iec61850MonitorPoint
+ {
+ DeviceId = deviceId,
+ DeviceName = ied.IedName,
+ IpAddress = ied.IpAddress,
+ SignalName = point.SignalName,
+ IecReference = point.ObjectReference,
+ FunctionalConstraint = "ST",
+ Value = "False",
+ Quality = "Good",
+ DeviceTimestamp = "2026-09-02T09:00:00.000Z",
+ SourceMode = "BRCB",
+ Sequence = 0,
+ Status = "Live"
+ });
+ return device;
+ }
+
+ private static IoTestSessionController NewController(
+ IoTestProject project,
+ IReadOnlyCollection devices,
+ string root) => new(
+ project,
+ key => devices.FirstOrDefault(device =>
+ key.Equals(device.DeviceId, StringComparison.OrdinalIgnoreCase) ||
+ key.Equals(device.Name, StringComparison.OrdinalIgnoreCase) ||
+ key.Equals(device.IpAddress, StringComparison.OrdinalIgnoreCase)),
+ action => action(),
+ root);
+
+ private static Iec61850EventEntry Event(
+ Iec61850MonitorDevice device,
+ IoTestPointPlan point,
+ bool oldValue,
+ bool newValue,
+ long sequence) => new()
+ {
+ Sequence = sequence,
+ DeviceId = device.DeviceId,
+ PointKey = device.Points[0].PointKey,
+ DeviceTimestamp = new DateTimeOffset(2026, 9, 2, 9, 0, 0, TimeSpan.Zero)
+ .AddMilliseconds(sequence * 100)
+ .ToString("O"),
+ DeviceName = device.Name,
+ IpAddress = device.IpAddress,
+ SignalName = point.SignalName,
+ IecReference = point.ObjectReference,
+ OldValue = oldValue ? "True" : "False",
+ NewValue = newValue ? "True" : "False",
+ Quality = "Good",
+ SourceMode = "BRCB",
+ Reason = "data-change"
+ };
+
+ private sealed record Fixture(
+ IoTestProject Project,
+ IoTestIedPlan IedA,
+ IoTestIedPlan IedB,
+ IoTestPointPlan PointA,
+ IoTestPointPlan PointB,
+ Iec61850MonitorDevice DeviceA,
+ Iec61850MonitorDevice DeviceB,
+ IReadOnlyCollection Devices,
+ string Root);
+}
diff --git a/tests/ARSAS.Tests/IoFatRemoveReportScopeRegressionTests.cs b/tests/ARSAS.Tests/IoFatRemoveReportScopeRegressionTests.cs
new file mode 100644
index 000000000..45d3e0aa9
--- /dev/null
+++ b/tests/ARSAS.Tests/IoFatRemoveReportScopeRegressionTests.cs
@@ -0,0 +1,164 @@
+using System.Text;
+using ArIED61850Tester.Models.IoTesting;
+using ArIED61850Tester.Services.IoTesting;
+
+namespace ARSAS.Tests;
+
+public sealed class IoFatRemoveReportScopeRegressionTests
+{
+ [Theory]
+ [InlineData("ARSAS-FAT-SCL-2.0")]
+ [InlineData("ARSAS-FAT-IO-1.0")]
+ public void RemoveFromFat_IsHardExclusionForNativeReport_WithoutMutatingSharedOrTestSelection(string schemaVersion)
+ {
+ var removed = BuildPoint("REMOVE_ME", "IED1LD/GGIO1.Ind1.stVal");
+ var kept = BuildPoint("KEEP_ME", "IED1LD/GGIO1.Ind2.stVal");
+ var ied = new IoTestIedPlan
+ {
+ IedName = "IED1",
+ IpAddress = "192.0.2.10",
+ TestPoints = new List { removed, kept }
+ };
+ var project = new IoTestProject
+ {
+ ProjectId = "REMOVE-REPORT-REGRESSION",
+ SchemaVersion = schemaVersion,
+ ProjectName = "Remove report regression",
+ Ieds = new List { ied }
+ };
+
+ removed.RemoveFromFat();
+
+ Assert.True(removed.WorkspaceSelected);
+ Assert.True(removed.TestEnabled);
+ Assert.False(removed.IsIncludedInFat);
+
+ var pdf = IoFatPdfReportService.Generate(
+ project,
+ new DateTimeOffset(2026, 9, 3, 4, 30, 0, TimeSpan.Zero));
+ var reportText = Encoding.ASCII.GetString(pdf);
+
+ Assert.DoesNotContain("REMOVE_ME", reportText, StringComparison.Ordinal);
+ Assert.Contains("KEEP_ME", reportText, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void RemoveFromFat_ExcludesRowFromTestPreflight_EvenWhenTestRemainsEnabled()
+ {
+ var removed = BuildPoint("REMOVE_FROM_TEST", "IED1LD/GGIO1.Ind1.stVal");
+ var kept = BuildPoint("KEEP_IN_TEST", "IED1LD/GGIO1.Ind2.stVal");
+ // Make the removed row deliberately unsafe if it were still part of the test scope.
+ removed = CloneWithImportReady(removed, importReady: false);
+ removed.RemoveFromFat();
+
+ var ied = new IoTestIedPlan
+ {
+ IedName = "IED1",
+ IpAddress = "192.0.2.10",
+ TestPoints = new List { removed, kept }
+ };
+
+ var result = IoTestSessionPreflight.Validate(ied);
+
+ Assert.True(removed.TestEnabled);
+ Assert.False(removed.IsIncludedInFat);
+ Assert.True(result.Succeeded);
+ Assert.Contains("1 shared-workspace-selected FAT signal", result.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void RestoreToFat_ReturnsSameSharedSignalToReport_WithTestPreferencePreserved()
+ {
+ var point = BuildPoint("RESTORE_ME", "IED1LD/GGIO1.Ind1.stVal");
+ var ied = new IoTestIedPlan
+ {
+ IedName = "IED1",
+ IpAddress = "192.0.2.10",
+ TestPoints = new List { point }
+ };
+ var project = new IoTestProject
+ {
+ ProjectId = "RESTORE-REPORT-REGRESSION",
+ SchemaVersion = "ARSAS-FAT-SCL-2.0",
+ ProjectName = "Restore report regression",
+ Ieds = new List { ied }
+ };
+
+ point.TestEnabled = false;
+ point.RemoveFromFat();
+ point.RestoreToFat();
+
+ Assert.True(point.WorkspaceSelected);
+ Assert.False(point.TestEnabled);
+ Assert.True(point.IsIncludedInFat);
+
+ var pdf = IoFatPdfReportService.Generate(
+ project,
+ new DateTimeOffset(2026, 9, 3, 4, 31, 0, TimeSpan.Zero));
+ var reportText = Encoding.ASCII.GetString(pdf);
+ Assert.Contains("RESTORE_ME", reportText, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void PrintPreview_UsesSameScopedLayoutContractAsNativePdf()
+ {
+ var source = File.ReadAllText(FindRepoFile("Services/IoTesting/IoFatReportPreviewDocumentBuilder.cs"));
+ var reportService = File.ReadAllText(FindRepoFile("Services/IoTesting/IoFatPdfReportService.cs"));
+ var scope = File.ReadAllText(FindRepoFile("Services/IoTesting/IoFatReportScope.cs"));
+ var autoConnect = File.ReadAllText(FindRepoFile("MainWindow.IoTesting.AutoConnect.cs"));
+ var contextUx = File.ReadAllText(FindRepoFile("IoListTestingWindow.ContextUx.cs"));
+
+ Assert.Contains("IoFatPdfReportService.BuildLayout", source, StringComparison.Ordinal);
+ Assert.Contains("IoFatReportScope.Create(project)", reportService, StringComparison.Ordinal);
+ Assert.Contains("point.WorkspaceSelected && point.IsIncludedInFat", scope, StringComparison.Ordinal);
+ Assert.Contains("point.WorkspaceSelected &&", autoConnect, StringComparison.Ordinal);
+ Assert.Contains("point.IsIncludedInFat &&", autoConnect, StringComparison.Ordinal);
+ Assert.Contains("point.WorkspaceSelected && point.IsIncludedInFat && point.TestEnabled", contextUx, StringComparison.Ordinal);
+ }
+
+ private static IoTestPointPlan BuildPoint(string signalName, string reference)
+ => new()
+ {
+ TestPointId = signalName,
+ IedName = "IED1",
+ IpAddress = "192.0.2.10",
+ SignalName = signalName,
+ ObjectReference = reference,
+ FunctionalConstraint = "ST",
+ ExpectedOnText = "TRUE",
+ ExpectedOffText = "FALSE",
+ WorkspaceSelected = true,
+ TestEnabled = true,
+ ImportReady = true
+ };
+
+ private static IoTestPointPlan CloneWithImportReady(IoTestPointPlan source, bool importReady)
+ => new()
+ {
+ TestPointId = source.TestPointId,
+ IedName = source.IedName,
+ IpAddress = source.IpAddress,
+ SignalName = source.SignalName,
+ ObjectReference = source.ObjectReference,
+ FunctionalConstraint = source.FunctionalConstraint,
+ ExpectedOnText = source.ExpectedOnText,
+ ExpectedOffText = source.ExpectedOffText,
+ WorkspaceSelected = source.WorkspaceSelected,
+ TestEnabled = source.TestEnabled,
+ ImportReady = importReady
+ };
+
+ 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/IoFatReportPreviewServiceTests.cs b/tests/ARSAS.Tests/IoFatReportPreviewServiceTests.cs
index d50239453..047813963 100644
--- a/tests/ARSAS.Tests/IoFatReportPreviewServiceTests.cs
+++ b/tests/ARSAS.Tests/IoFatReportPreviewServiceTests.cs
@@ -160,15 +160,15 @@ public void PreviewKeepsProgressOnIedCardOnly()
}
[Fact]
- public void IoFatPreparation_UsesCachedReconnect_AndReturnsOnFirstUsableLiveImage()
+ public void IoFatPreparation_UsesCachedReconnect_AndReturnsImmediatelyAfterMonitorStart()
{
var source = File.ReadAllText(FindRepoFile("MainWindow.IoTesting.AutoConnect.cs"));
Assert.Contains("Fast reconnect", source, StringComparison.Ordinal);
Assert.Contains("ConnectUsingSavedModelAsync", source, StringComparison.Ordinal);
- Assert.Contains("SettleIoFatReportPriorityAsync", source, StringComparison.Ordinal);
- Assert.Contains("Waiting for first live FAT image", source, StringComparison.Ordinal);
- Assert.Contains("TimeSpan.FromMilliseconds(2500)", source, StringComparison.Ordinal);
+ Assert.Contains("Return control to FAT immediately", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("SettleIoFatReportPriorityAsync", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("TimeSpan.FromMilliseconds(2500)", source, StringComparison.Ordinal);
Assert.Contains("IsReportSource", source, StringComparison.Ordinal);
Assert.DoesNotContain("rebuilding the report plan once", source, StringComparison.Ordinal);
Assert.DoesNotContain("TimeSpan.FromSeconds(8)", source, StringComparison.Ordinal);
diff --git a/tests/ARSAS.Tests/IoFatSclAppendWorkflowRegressionTests.cs b/tests/ARSAS.Tests/IoFatSclAppendWorkflowRegressionTests.cs
new file mode 100644
index 000000000..9c2cb74d7
--- /dev/null
+++ b/tests/ARSAS.Tests/IoFatSclAppendWorkflowRegressionTests.cs
@@ -0,0 +1,68 @@
+namespace ARSAS.Tests;
+
+public sealed class IoFatSclAppendWorkflowRegressionTests
+{
+ [Fact]
+ public void P04_LoadedFatRoutesSclToAppendInsteadOfWorkspaceReplacement()
+ {
+ var modeSwitch = ReadRepoFile("MainWindow.WorkspaceModeSwitch.cs");
+
+ Assert.Contains("Add SCL / CID to loaded FAT workspace", modeSwitch, StringComparison.Ordinal);
+ Assert.Contains("OpenSclForLoadedFatAppendAsync(loaded)", modeSwitch, StringComparison.Ordinal);
+ Assert.DoesNotContain(
+ "QueueIoFatWorkspaceReplacement(\n () => OpenSclFatTesting_Click",
+ modeSwitch,
+ StringComparison.Ordinal);
+
+ // Workbook and portable project opens intentionally retain replacement semantics.
+ Assert.Contains("() => OpenIoListTesting_Click(this, new RoutedEventArgs())", modeSwitch, StringComparison.Ordinal);
+ Assert.Contains("() => OpenIoListPackage_Click(this, new RoutedEventArgs())", modeSwitch, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void P04_AddSclAvailabilityIsIndependentFromIedPreparationAndEvidenceSession()
+ {
+ var window = ReadRepoFile("IoListTestingWindow.xaml.cs");
+ var addIed = ReadRepoFile("IoListTestingWindow.AddIed.cs");
+
+ Assert.Contains("public bool CanAddFatIed => !_isAddingFatIeds;", window, StringComparison.Ordinal);
+ Assert.DoesNotContain("public bool CanAddFatIed => !IsPreparingIed", window, StringComparison.Ordinal);
+ Assert.Contains("ImportAdditionalSclSourcesAsync", addIed, StringComparison.Ordinal);
+ Assert.Contains("AppendSclIedsToLoadedFatAsync(this, sclPaths)", addIed, StringComparison.Ordinal);
+ Assert.DoesNotContain("AddSclIedsToLoadedFatAsync(this, dialog.FileNames)", addIed, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void P04_ParsesBeforeAppendGateAndMutatesOnlyNewIeds()
+ {
+ var append = ReadRepoFile("MainWindow.IoTesting.SclAppend.cs");
+
+ Assert.Contains("SemaphoreSlim _ioFatSclAppendGate", append, StringComparison.Ordinal);
+ var parseIndex = append.IndexOf("ImportAdditionalAsync(", StringComparison.Ordinal);
+ var gateIndex = append.IndexOf("_ioFatSclAppendGate.WaitAsync", StringComparison.Ordinal);
+ Assert.True(parseIndex >= 0 && gateIndex > parseIndex, "SCL parsing must complete before the append mutation gate is acquired.");
+
+ Assert.Contains("ReferenceEquals(window, _loadedIoFatWindow)", append, StringComparison.Ordinal);
+ Assert.Contains("existingIedKeys.Add", append, StringComparison.Ordinal);
+ Assert.Contains("window.Project.Ieds.Add(ied)", append, StringComparison.Ordinal);
+ Assert.Contains("SynchronizeImportedSclFatWithEngineering(window.Project, addedIeds)", append, StringComparison.Ordinal);
+ Assert.Contains("window.RegisterAddedIeds(addedIeds)", append, StringComparison.Ordinal);
+ Assert.DoesNotContain("IsPreparingIed", append, StringComparison.Ordinal);
+ Assert.DoesNotContain("Session.Stop", append, StringComparison.Ordinal);
+ }
+
+ private static string ReadRepoFile(string relativePath)
+ {
+ DirectoryInfo? directory = new(AppContext.BaseDirectory);
+ while (directory != null)
+ {
+ var candidate = Path.Combine(directory.FullName, relativePath);
+ if (File.Exists(candidate))
+ return File.ReadAllText(candidate).Replace("\r\n", "\n", StringComparison.Ordinal);
+ directory = directory.Parent;
+ }
+
+ throw new FileNotFoundException(
+ $"Could not locate repository file '{relativePath}' from '{AppContext.BaseDirectory}'.");
+ }
+}
diff --git a/tests/ARSAS.Tests/IoFatSclProjectImportTests.cs b/tests/ARSAS.Tests/IoFatSclProjectImportTests.cs
index d29aeebeb..caacf3059 100644
--- a/tests/ARSAS.Tests/IoFatSclProjectImportTests.cs
+++ b/tests/ARSAS.Tests/IoFatSclProjectImportTests.cs
@@ -148,6 +148,26 @@ public async Task MultiScl_ImportOrderDoesNotChangeIdentity_AndP3PersistenceKeep
}
}
+ [Fact]
+ public async Task IncrementalImport_PreservesExistingRuntimeWorkspaceAndAddsNextIed()
+ {
+ var root = TempDirectory();
+ var first = Path.Combine(root, "relay-first.cid");
+ var second = Path.Combine(root, "relay-next.cid");
+ BuildSinglePointFixture("IED-FIRST", "S1", "ADD", "GGIO", "1", "Dig01", "ST", "BOOLEAN").Save(first);
+ BuildSinglePointFixture("IED-NEXT", "S1", "MEAS", "MMXU", "1", "Ana01", "MX", "FLOAT32").Save(second);
+ var importer = new IoFatSclProjectImportService();
+
+ await importer.ImportAsync(new[] { first });
+ var additional = await importer.ImportAdditionalAsync(new[] { second });
+
+ Assert.True(importer.TryGetRuntimeWorkspace("IED-FIRST", string.Empty, out var firstWorkspace));
+ Assert.True(importer.TryGetRuntimeWorkspace("IED-NEXT", string.Empty, out var nextWorkspace));
+ Assert.NotNull(firstWorkspace);
+ Assert.NotNull(nextWorkspace);
+ Assert.Equal("IED-NEXT", Assert.Single(additional.Project.Ieds).IedName);
+ }
+
[Fact]
public async Task ProductionLoader_NoStaticDataSet_ReturnsZeroRowsAndExplicitFinding()
{
diff --git a/tests/ARSAS.Tests/IoFatSelectionAuthorityRegressionTests.cs b/tests/ARSAS.Tests/IoFatSelectionAuthorityRegressionTests.cs
index 6ee6f9fd4..cbfb57880 100644
--- a/tests/ARSAS.Tests/IoFatSelectionAuthorityRegressionTests.cs
+++ b/tests/ARSAS.Tests/IoFatSelectionAuthorityRegressionTests.cs
@@ -10,7 +10,8 @@ public void SnapshotAndPackageRestore_DoNotAutoDisableCompletedRows()
Assert.DoesNotContain("ExcludeCompletedFromNextSession", source, StringComparison.Ordinal);
Assert.DoesNotContain("point.TestEnabled = false", source, StringComparison.Ordinal);
Assert.Contains("point.TestEnabled = enabled.GetBoolean();", source, StringComparison.Ordinal);
- Assert.Contains("TestEnabled and FAT disposition are both operator-authored", source, StringComparison.Ordinal);
+ Assert.Contains("WorkspaceSelected", source, StringComparison.Ordinal);
+ Assert.Contains("FAT TEST scope, and FAT disposition", source, StringComparison.Ordinal);
}
[Fact]
@@ -19,6 +20,7 @@ public void CleanRetest_ClearsEvidenceButNeverChangesOperatorSelection()
var source = Read("Services/IoTesting/IoFatCleanSessionService.cs");
Assert.DoesNotContain("point.TestEnabled =", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("point.WorkspaceSelected =", source, StringComparison.Ordinal);
Assert.Contains("runtime.OnEvidence = null;", source, StringComparison.Ordinal);
Assert.Contains("runtime.OffEvidence = null;", source, StringComparison.Ordinal);
Assert.Contains("Selection belongs to the operator", source, StringComparison.Ordinal);
@@ -31,7 +33,7 @@ public void StartAndContinuation_NeverRewriteCheckboxStateAndArmOnlyLiveRows()
Assert.DoesNotContain("point.TestEnabled = false", source, StringComparison.Ordinal);
Assert.DoesNotContain("point.TestEnabled = true", source, StringComparison.Ordinal);
- Assert.Contains("point.IsIncludedInFat && point.TestEnabled && point.ImportReady", source, StringComparison.Ordinal);
+ Assert.Contains("point.WorkspaceSelected && point.IsIncludedInFat && point.TestEnabled && point.ImportReady", source, StringComparison.Ordinal);
Assert.Contains("point.LiveBindingState == IoTestLiveBindingState.LivePointReady", source, StringComparison.Ordinal);
Assert.Contains("Session.Start(selectedIed, liveCaptureScope)", source, StringComparison.Ordinal);
Assert.Contains("checkbox/disposition unchanged", source, StringComparison.Ordinal);
diff --git a/tests/ARSAS.Tests/IoFatSharedWorkspacePersistenceRegressionTests.cs b/tests/ARSAS.Tests/IoFatSharedWorkspacePersistenceRegressionTests.cs
new file mode 100644
index 000000000..753443a2e
--- /dev/null
+++ b/tests/ARSAS.Tests/IoFatSharedWorkspacePersistenceRegressionTests.cs
@@ -0,0 +1,191 @@
+using System.Security.Cryptography;
+using ArIED61850Tester.Models.IoTesting;
+using ArIED61850Tester.Services.IoTesting;
+
+namespace ARSAS.Tests;
+
+public sealed class IoFatSharedWorkspacePersistenceRegressionTests
+{
+ [Fact]
+ public async Task SameSclReopen_RestoresManualWorkspaceRow_SelectionTestScopeAndFatDispositionIndependently()
+ {
+ var root = TempDirectory();
+ var source = Path.Combine(root, "manual.cid");
+ await File.WriteAllBytesAsync(source, new byte[] { 0x53, 0x43, 0x4C, 0x2D, 0x31 });
+ var sourceSha = Hash(source);
+ var sourceInputs = new[] { new IoFatSourceInput(source, IoFatSourceKinds.Scl) };
+ var projectsRoot = Path.Combine(root, "projects");
+ var evidenceRoot = Path.Combine(root, "evidence");
+
+ var manual = ManualPoint(sourceSha);
+ manual.WorkspaceSelected = false;
+ manual.TestEnabled = false;
+ manual.RemoveFromFat();
+
+ var original = SclProject(manual);
+ var originalSession = Session(original, evidenceRoot);
+ var opened = await IoTestWorkspacePersistence.OpenSourcesAsync(
+ original,
+ originalSession,
+ sourceInputs,
+ projectsRoot,
+ evidenceRoot);
+ using (originalSession)
+ using (opened.Workspace)
+ opened.Workspace.SaveNow();
+
+ // A fresh direct-SCL importer does not fabricate manual non-DataSet rows. The
+ // same-source workspace continuation must re-materialize the persisted manual row
+ // before restoring its three independent operator authorities.
+ var fresh = SclProject();
+ var restored = await IoTestWorkspaceBootstrapService.OpenSourcesAsync(
+ fresh,
+ sourceInputs,
+ projectsRoot,
+ evidenceRoot,
+ Session);
+
+ using (restored.Session)
+ using (restored.Workspace)
+ {
+ Assert.True(restored.RestoredProgress);
+ var point = Assert.Single(restored.Project.Ieds[0].TestPoints);
+ Assert.Equal(manual.TestPointId, point.TestPointId);
+ Assert.Equal(IoTestSignalSelectionService.SclWorkspaceAuthorityBindingStatus, point.BindingStatus);
+ Assert.Equal(sourceSha, point.SignalAddress);
+ Assert.False(point.WorkspaceSelected);
+ Assert.False(point.TestEnabled);
+ Assert.False(point.IsIncludedInFat);
+ }
+ }
+
+ [Fact]
+ public async Task SameSclReopen_RestoresStaticWorkspaceSelection_WithoutChangingFatTestOrDisposition()
+ {
+ var root = TempDirectory();
+ var source = Path.Combine(root, "static.cid");
+ await File.WriteAllBytesAsync(source, new byte[] { 0x53, 0x43, 0x4C, 0x2D, 0x32 });
+ var sourceSha = Hash(source);
+ var sourceInputs = new[] { new IoFatSourceInput(source, IoFatSourceKinds.Scl) };
+ var projectsRoot = Path.Combine(root, "projects");
+ var evidenceRoot = Path.Combine(root, "evidence");
+
+ var originalPoint = StaticPoint(sourceSha);
+ originalPoint.WorkspaceSelected = false;
+ originalPoint.TestEnabled = true;
+ var original = SclProject(originalPoint);
+ var originalSession = Session(original, evidenceRoot);
+ var opened = await IoTestWorkspacePersistence.OpenSourcesAsync(
+ original,
+ originalSession,
+ sourceInputs,
+ projectsRoot,
+ evidenceRoot);
+ using (originalSession)
+ using (opened.Workspace)
+ opened.Workspace.SaveNow();
+
+ // Fresh import defaults the same static DataSet member selected. Continuation must
+ // restore the Engineering/shared selection without coupling it to FAT TEST state.
+ var freshPoint = StaticPoint(sourceSha);
+ freshPoint.WorkspaceSelected = true;
+ freshPoint.TestEnabled = true;
+ var restored = await IoTestWorkspaceBootstrapService.OpenSourcesAsync(
+ SclProject(freshPoint),
+ sourceInputs,
+ projectsRoot,
+ evidenceRoot,
+ Session);
+
+ using (restored.Session)
+ using (restored.Workspace)
+ {
+ var point = Assert.Single(restored.Project.Ieds[0].TestPoints);
+ Assert.False(point.WorkspaceSelected);
+ Assert.True(point.TestEnabled);
+ Assert.True(point.IsIncludedInFat);
+ }
+ }
+
+ private static IoTestPointPlan ManualPoint(string sourceSha)
+ => new()
+ {
+ TestPointId = "scl-manual-0123456789abcdef0123",
+ IedName = "IED1",
+ IpAddress = "192.0.2.10",
+ SignalName = "Manual GGIO indication",
+ ObjectReference = "IED1LD/GGIO1.Ind1.stVal",
+ FunctionalConstraint = "ST",
+ ExpectedOnText = "TRUE",
+ ExpectedOffText = "FALSE",
+ DataType = "BOOLEAN",
+ SignalAddress = sourceSha,
+ SourceIecReference = "IED1LD/GGIO1.Ind1.stVal",
+ ReportDisplayReference = "IED1LD/GGIO1.Ind1.stVal",
+ EventLogSearchReference = "IED1LD/GGIO1.Ind1.stVal",
+ SignalKind = FatSignalKind.Discrete,
+ CaptureMode = FatCaptureMode.AutomaticTransition,
+ WorkspaceSelected = true,
+ TestEnabled = true,
+ ImportReady = true,
+ BindingStatus = IoTestSignalSelectionService.SclWorkspaceAuthorityBindingStatus,
+ BindingEvidence = "Shared SCL workspace authority"
+ };
+
+ private static IoTestPointPlan StaticPoint(string sourceSha)
+ => new()
+ {
+ TestPointId = "scl-static-0001",
+ IedName = "IED1",
+ IpAddress = "192.0.2.10",
+ SignalName = "Static GGIO indication",
+ ObjectReference = "IED1LD/GGIO1.Ind2.stVal",
+ FunctionalConstraint = "ST",
+ ExpectedOnText = "TRUE",
+ ExpectedOffText = "FALSE",
+ DataType = "BOOLEAN",
+ SignalAddress = sourceSha,
+ DataSetName = "IED1LD/LLN0.Events",
+ SourceIecReference = "IED1LD/GGIO1.Ind2.stVal",
+ ReportDisplayReference = "IED1LD/GGIO1.Ind2.stVal",
+ EventLogSearchReference = "IED1LD/GGIO1.Ind2.stVal",
+ SourceRow = 1,
+ SignalKind = FatSignalKind.Discrete,
+ CaptureMode = FatCaptureMode.AutomaticTransition,
+ WorkspaceSelected = true,
+ TestEnabled = true,
+ ImportReady = true,
+ BindingStatus = IoTestSignalSelectionService.SclDataSetAuthorityBindingStatus,
+ BindingEvidence = "Static SCL DataSet authority"
+ };
+
+ private static IoTestProject SclProject(params IoTestPointPlan[] points)
+ => new()
+ {
+ ProjectId = "SHARED-SCL-WORKSPACE",
+ SchemaVersion = "ARSAS-FAT-SCL-1.0",
+ ProjectName = "Shared SCL workspace regression",
+ Ieds = new List
+ {
+ new()
+ {
+ IedName = "IED1",
+ IpAddress = "192.0.2.10",
+ TestPoints = points.ToList()
+ }
+ }
+ };
+
+ private static IoTestSessionController Session(IoTestProject project, string evidenceRoot)
+ => new(project, _ => null, action => action(), evidenceRoot);
+
+ private static string Hash(string path)
+ => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path))).ToLowerInvariant();
+
+ private static string TempDirectory()
+ {
+ var path = Path.Combine(Path.GetTempPath(), "ARSAS.Tests", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(path);
+ return path;
+ }
+}
diff --git a/tests/ARSAS.Tests/IoFatStructuredMmsAliasRegressionTests.cs b/tests/ARSAS.Tests/IoFatStructuredMmsAliasRegressionTests.cs
new file mode 100644
index 000000000..b7e5d8b9e
--- /dev/null
+++ b/tests/ARSAS.Tests/IoFatStructuredMmsAliasRegressionTests.cs
@@ -0,0 +1,116 @@
+using AR.Iec61850.Discovery;
+using ArIED61850Tester.Models;
+using ArIED61850Tester.Services;
+
+namespace ARSAS.Tests;
+
+public sealed class IoFatStructuredMmsAliasRegressionTests
+{
+ [Fact]
+ public void CachedStructuredMmsContainerAlias_CannotAbsorbDistinctPhaseMembers()
+ {
+ const string dataObject = "IEDLD0/I_MHAI1.ThdA";
+ const string sharedStructuredMms = "IEDLD0/I_MHAI1$MX$ThdA";
+ var phases = new[] { "phsA", "phsB", "phsC" };
+ var members = phases.Select(phase => dataObject + "." + phase).ToArray();
+ var attributes = phases.Select(phase => new LiveIedDataAttributeModel
+ {
+ ObjectReference = dataObject + "." + phase + ".cVal.mag.f",
+ AttributePath = phase + ".cVal.mag.f",
+ FunctionalConstraint = "MX",
+ // Reproduce the dangerous cache/report shape: several scalar semantic
+ // descendants can be observed through one structured MMS container alias.
+ MmsReference = sharedStructuredMms,
+ MmsItemName = "I_MHAI1$MX$ThdA",
+ SclBType = "FLOAT32",
+ MmsType = "floating-point",
+ Source = "Regression structured MMS container",
+ TypeSource = "Regression",
+ TypeConfidence = LiveIedDiscoveryConfidenceLevel.Exact
+ }).ToArray();
+
+ var model = new LiveIedModelDiscoveryDocument
+ {
+ Source = "SclWorkspace",
+ IedName = "IED",
+ LogicalDevices = new[]
+ {
+ new LiveIedLogicalDeviceModel
+ {
+ MmsDomain = "IEDLD0",
+ Inst = "LD0",
+ LogicalNodes = new[]
+ {
+ new LiveIedLogicalNodeModel
+ {
+ Name = "I_MHAI1",
+ LnClass = "MHAI",
+ LnInst = "1",
+ DataObjects = new[]
+ {
+ new LiveIedDataObjectModel
+ {
+ Reference = dataObject,
+ Name = "ThdA",
+ InferredCdc = "WYE",
+ Attributes = attributes
+ }
+ }
+ }
+ }
+ }
+ },
+ DataSets = new[]
+ {
+ new LiveIedDataSetModel
+ {
+ Reference = "IEDLD0/LLN0.Analog",
+ Domain = "IEDLD0",
+ LogicalNode = "LLN0",
+ Name = "Analog",
+ MemberCount = members.Length,
+ Members = members.Select((member, index) => new LiveIedDataSetMemberModel
+ {
+ Index = index,
+ Reference = member,
+ FunctionalConstraint = "MX",
+ MmsReference = "IEDLD0/I_MHAI1$MX$ThdA$" + phases[index],
+ Confidence = LiveIedDiscoveryConfidenceLevel.Exact
+ }).ToArray()
+ }
+ }
+ };
+
+ // Fast reconnect can already contain a cached structured parent/container row.
+ // It must remain separate from the static phase members imported for FAT.
+ var signals = new List
+ {
+ new()
+ {
+ Name = "ThdA",
+ ObjectReference = sharedStructuredMms,
+ DisplayReference = sharedStructuredMms,
+ FunctionalConstraint = "MX",
+ DataType = "structure",
+ Source = "Saved model structured container"
+ }
+ };
+
+ var result = Iec61850DataSetSignalInventoryService.EnsureMandatorySignals(signals, model);
+
+ Assert.Equal(3, result.MandatoryCatalogCount);
+ Assert.Equal(3, result.AddedCount);
+ Assert.Equal(4, signals.Count);
+ Assert.Contains(signals, signal => signal.ObjectReference == sharedStructuredMms);
+
+ foreach (var phase in phases)
+ {
+ var staticMember = dataObject + "." + phase;
+ var runtimeLeaf = staticMember + ".cVal.mag.f";
+ var signal = Assert.Single(signals.Where(candidate =>
+ candidate.DisplayReference.Equals(staticMember, StringComparison.OrdinalIgnoreCase)));
+ Assert.Equal(runtimeLeaf, signal.ObjectReference);
+ Assert.NotEqual(sharedStructuredMms, signal.ObjectReference);
+ }
+ }
+}
diff --git a/tests/ARSAS.Tests/IoFatTimestampTooltipTests.cs b/tests/ARSAS.Tests/IoFatTimestampTooltipTests.cs
index 4811af14d..bd6f42efc 100644
--- a/tests/ARSAS.Tests/IoFatTimestampTooltipTests.cs
+++ b/tests/ARSAS.Tests/IoFatTimestampTooltipTests.cs
@@ -42,4 +42,40 @@ public void TimestampHover_ShowsRoundedDisplayAndDecodedFullPrecision()
Assert.Contains("Quality: Good", tooltip, StringComparison.Ordinal);
Assert.Contains("Source: BRCB", tooltip, StringComparison.Ordinal);
}
+
+ [Fact]
+ public void SnapshotTimestampHover_ShowsRoundedDisplayAndDecodedFullPrecision()
+ {
+ var timestamp = new DateTimeOffset(2026, 9, 3, 12, 43, 16, TimeSpan.FromHours(7))
+ .AddTicks(9_215_108);
+ var evidence = new FatValueEvidence(
+ Guid.NewGuid(),
+ FatValueSlot.Value1,
+ FatEvidenceCaptureKind.OperatorSnapshot,
+ "12.345",
+ timestamp.AddMilliseconds(2),
+ timestamp,
+ "good",
+ "ARIEC Hybrid: StaticBrcb",
+ 7,
+ 3);
+
+ var method = typeof(IoTestPointRuntime).GetMethod(
+ "BuildFatEvidenceToolTip",
+ BindingFlags.NonPublic | BindingFlags.Static)
+ ?? throw new MissingMethodException(nameof(IoTestPointRuntime), "BuildFatEvidenceToolTip");
+ var tooltip = (string)method.Invoke(null, [evidence, "Value 1"])!;
+
+ Assert.Contains("Value 1: 12.345", tooltip, StringComparison.Ordinal);
+ Assert.Contains("Displayed (rounded to nearest ms): 2026-09-03 12:43:16.922 +07:00", tooltip, StringComparison.Ordinal);
+ Assert.Contains(
+ "Decoded IED timestamp (full precision): " + timestamp.ToString("O", CultureInfo.InvariantCulture),
+ tooltip,
+ StringComparison.Ordinal);
+ Assert.DoesNotContain("16.9220000", tooltip, StringComparison.Ordinal);
+ Assert.Contains("ARSAS capture (full precision):", tooltip, StringComparison.Ordinal);
+ Assert.Contains("Quality: good", tooltip, StringComparison.Ordinal);
+ Assert.Contains("Source: ARIEC Hybrid: StaticBrcb", tooltip, StringComparison.Ordinal);
+ Assert.Contains("Capture: OperatorSnapshot", tooltip, StringComparison.Ordinal);
+ }
}
diff --git a/tests/ARSAS.Tests/IoFatV2UiLockRegressionTests.cs b/tests/ARSAS.Tests/IoFatV2UiLockRegressionTests.cs
index 5763bc463..668dd4c3a 100644
--- a/tests/ARSAS.Tests/IoFatV2UiLockRegressionTests.cs
+++ b/tests/ARSAS.Tests/IoFatV2UiLockRegressionTests.cs
@@ -1,19 +1,75 @@
+using System.Globalization;
+using ArIED61850Tester.Models.IoTesting;
+
namespace ARSAS.Tests;
public sealed class IoFatV2UiLockRegressionTests
{
[Fact]
- public void RuntimeGrid_LocksPlanMutationButKeepsOperatorCaptureAvailable()
+ public void RuntimeGrid_LocksOnlyTheIedThatOwnsPreparationOrEvidenceScope()
{
var source = File.ReadAllText(FindRepoFile("IoListTestingWindow.FatV2Ux.cs"));
- Assert.Contains("DataContext.CanEditPlan", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("DataContext.CanEditPlan", source, StringComparison.Ordinal);
+ Assert.Contains("public bool SelectedCanEditPlan => SelectedIed is not null && CanEditIedPlan(SelectedIed);", source, StringComparison.Ordinal);
+ Assert.Contains("!ied.IsPreparing", source, StringComparison.Ordinal);
+ Assert.Contains("!Session.IsIedSessionActive(ied)", source, StringComparison.Ordinal);
+
+ // The TEST checkbox binds directly to the selected IED and selected-session
+ // projection so the owning IED locks synchronously; another IED remains editable
+ // without waiting for a dispatcher-level window-property refresh.
+ Assert.Contains("var editability = new MultiBinding", source, StringComparison.Ordinal);
+ Assert.Contains("DataContext.SelectedIed", source, StringComparison.Ordinal);
+ Assert.Contains("DataContext.Session.ActiveIed", source, StringComparison.Ordinal);
+ Assert.Contains("DataContext.Session.IsSessionActive", source, StringComparison.Ordinal);
+ Assert.Contains("DataContext.SelectedIed.IsPreparing", source, StringComparison.Ordinal);
+ Assert.Contains("IoFatSelectedIedPlanEditabilityConverter", source, StringComparison.Ordinal);
+ Assert.Contains("!ReferenceEquals(activeIed, selectedIed)", source, StringComparison.Ordinal);
+
+ Assert.Contains("if (!CanEditPointPlan(point))", source, StringComparison.Ordinal);
Assert.Contains("point.RemoveFromFat();", source, StringComparison.Ordinal);
- Assert.Contains("if (!CanEditPlan)", source, StringComparison.Ordinal);
+
+ // Operator snapshot capture remains the intentional evidence action and is not
+ // disabled by the TEST-checkbox edit lock.
Assert.Contains("CanCaptureOperatorSnapshot", source, StringComparison.Ordinal);
Assert.Contains("Session.CaptureOperatorSnapshot(point, slot)", source, StringComparison.Ordinal);
}
+ [Fact]
+ public void EditabilityConverter_AllowsOtherIedWhileActiveOwnerRemainsLocked()
+ {
+ var activeIed = new IoTestIedPlan { IedName = "IED-A", IpAddress = "192.0.2.10" };
+ var otherIed = new IoTestIedPlan { IedName = "IED-B", IpAddress = "192.0.2.11" };
+ var converter = new ArIED61850Tester.IoFatSelectedIedPlanEditabilityConverter();
+
+ bool Evaluate(IoTestIedPlan selected, IoTestIedPlan? active, bool sessionActive, bool preparing)
+ => Assert.IsType(converter.Convert(
+ new object[] { selected, active!, sessionActive, preparing },
+ typeof(bool),
+ null!,
+ CultureInfo.InvariantCulture));
+
+ Assert.False(Evaluate(activeIed, activeIed, sessionActive: true, preparing: false));
+ Assert.True(Evaluate(otherIed, activeIed, sessionActive: true, preparing: false));
+ Assert.False(Evaluate(otherIed, activeIed, sessionActive: true, preparing: true));
+ Assert.True(Evaluate(activeIed, active: null, sessionActive: false, preparing: false));
+ }
+
+ [Fact]
+ public void RemovedSignals_RestoreLockIsEvaluatedPerOwningIed()
+ {
+ var workspace = File.ReadAllText(FindRepoFile("IoListTestingWindow.FatV2Ux.cs"));
+ var removed = File.ReadAllText(FindRepoFile("RemovedFatSignalsWindow.cs"));
+
+ Assert.Contains("!Session.IsIedSessionActive(ied)", workspace, StringComparison.Ordinal);
+ Assert.Contains("new RemovedFatSignalsWindow(Project, CanEditPointPlan)", workspace, StringComparison.Ordinal);
+ Assert.Contains("Func? canEditPoint", removed, StringComparison.Ordinal);
+ Assert.Contains("canEditPoint?.Invoke(point) ?? true", removed, StringComparison.Ordinal);
+ Assert.Contains("new Binding(nameof(RemovedSignalRow.CanEdit))", removed, StringComparison.Ordinal);
+ Assert.Contains("_view.Cast().Where(row => row.CanEdit)", removed, StringComparison.Ordinal);
+ Assert.Contains("_rows.Where(row => row.CanEdit && row.IsSelected)", removed, StringComparison.Ordinal);
+ }
+
private static string FindRepoFile(string relativePath)
{
DirectoryInfo? directory = new(AppContext.BaseDirectory);
@@ -28,4 +84,4 @@ private static string FindRepoFile(string relativePath)
throw new FileNotFoundException(
$"Could not locate repository file '{relativePath}' from '{AppContext.BaseDirectory}'.");
}
-}
\ No newline at end of file
+}
diff --git a/tests/ARSAS.Tests/IoFatV2WorkspaceRegressionTests.cs b/tests/ARSAS.Tests/IoFatV2WorkspaceRegressionTests.cs
index 0f11d5619..3042e168a 100644
--- a/tests/ARSAS.Tests/IoFatV2WorkspaceRegressionTests.cs
+++ b/tests/ARSAS.Tests/IoFatV2WorkspaceRegressionTests.cs
@@ -62,6 +62,36 @@ public void OperatorSnapshot_JournalFailure_DoesNotPromoteCurrentEvidencePointer
Assert.Equal(IoTestSessionState.Faulted, controller.State);
}
+ [Fact]
+ public void OperatorSnapshot_LiveRefreshDoesNotRaiseSessionProgressForEverySample()
+ {
+ var fixture = ManualFixture();
+ using var controller = fixture.Controller;
+ Assert.True(controller.Start(fixture.Ied).Succeeded);
+
+ var sessionNotifications = 0;
+ controller.PropertyChanged += (_, _) => sessionNotifications++;
+ controller.Enqueue(new Iec61850EventEntry
+ {
+ Sequence = 2,
+ DeviceId = fixture.Device.DeviceId,
+ PointKey = fixture.LivePoint.PointKey,
+ DeviceTimestamp = "2026-09-01T03:00:00.250Z",
+ DeviceName = fixture.Device.Name,
+ IpAddress = fixture.Device.IpAddress,
+ SignalName = fixture.LivePoint.SignalName,
+ IecReference = fixture.LivePoint.IecReference,
+ OldValue = "12.34",
+ NewValue = "12.35",
+ Quality = "Good",
+ SourceMode = "BRCB",
+ Reason = "periodic-refresh"
+ });
+
+ Assert.Equal("12.35", fixture.Point.Runtime.CurrentValue);
+ Assert.Equal(0, sessionNotifications);
+ }
+
[Fact]
public void RemoveRestore_PreservesCheckboxAndCurrentEvidence()
{
@@ -307,4 +337,4 @@ public IReadOnlyList AppendBatch(IEnumerable entries)", journal, StringComparison.Ordinal);
Assert.Contains("FlushDurable();", journal, StringComparison.Ordinal);
- // Preserve #150's immediate TRUE-edge scheduling while retaining P0's bounded
- // drain/yield behavior for bursts. The first dispatch is DataBind; a reschedule
- // requested while that drain is active is intentionally demoted to Background.
+ // Every bounded drain yields to operator input. Edge samples remain in the
+ // dedicated lossless queue, so responsiveness does not weaken FAT evidence.
Assert.Contains("DispatchIoFatEvidence", main, StringComparison.Ordinal);
- Assert.Contains("Volatile.Read(ref _ioFatEvidenceDrainDispatchActive) == 0", main, StringComparison.Ordinal);
- Assert.Contains("DispatcherPriority.DataBind", main, StringComparison.Ordinal);
Assert.Contains("DispatcherPriority.Background", main, StringComparison.Ordinal);
- Assert.Contains("Interlocked.Increment(ref _ioFatEvidenceDrainDispatchActive)", main, StringComparison.Ordinal);
- Assert.Contains("Interlocked.Decrement(ref _ioFatEvidenceDrainDispatchActive)", main, StringComparison.Ordinal);
+ Assert.DoesNotContain("DispatcherPriority.DataBind", main, StringComparison.Ordinal);
+ Assert.Contains("_pendingEdgeSnapshots", controller, StringComparison.Ordinal);
}
[Fact]
@@ -78,6 +75,19 @@ public void NewCleanFatSession_ArchivesOldJournalsAndClearsEveryActiveTimestampS
Assert.Contains("Session.ResetForCleanRetest()", ui, StringComparison.Ordinal);
}
+ [Fact]
+ public void FatUiRefresh_DoesNotRefilterOrAutosaveForEveryLiveSample()
+ {
+ var ux = Read("IoListTestingWindow.FatV2Ux.cs");
+ var persistence = Read("Services/IoTesting/IoTestProjectPersistenceService.cs");
+ var controller = Read("Services/IoTesting/IoTestSessionController.cs");
+
+ Assert.Contains("RefreshFatV2WorkspaceUx(bool refreshRows = false)", ux, StringComparison.Ordinal);
+ Assert.Contains("if (refreshRows)", ux, StringComparison.Ordinal);
+ Assert.Contains("nameof(IoTestPointRuntime.CurrentValue)", persistence, StringComparison.Ordinal);
+ Assert.Contains("if (progressChanged)", controller, StringComparison.Ordinal);
+ }
+
private static string Read(string relativePath)
=> File.ReadAllText(FindRepoFile(relativePath)).Replace("\r\n", "\n", StringComparison.Ordinal);
diff --git a/tests/ARSAS.Tests/IoTestSessionControllerTests.cs b/tests/ARSAS.Tests/IoTestSessionControllerTests.cs
index 62c04f015..83738b455 100644
--- a/tests/ARSAS.Tests/IoTestSessionControllerTests.cs
+++ b/tests/ARSAS.Tests/IoTestSessionControllerTests.cs
@@ -21,7 +21,7 @@ public void LiveOffOnOffSequence_KeepsCaptureRunningUntilOperatorStopAndPersists
Assert.Equal(IoTestSessionState.Running, controller.State);
Assert.NotNull(fixture.Point.Runtime.OnEvidence);
Assert.NotNull(fixture.Point.Runtime.OffEvidence);
- Assert.Contains("Capture remains running", controller.StatusText, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("explicit Recapture", controller.StatusText, StringComparison.OrdinalIgnoreCase);
var stopped = controller.Stop();
@@ -178,7 +178,7 @@ public void ExplicitCaptureScope_RejectsUncheckedPointRatherThanOverridingOperat
Assert.False(result.Succeeded);
Assert.False(fixture.Point.TestEnabled);
Assert.Equal(IoTestSessionState.Idle, controller.State);
- Assert.Contains("not selected", result.Message, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("TEST disabled", result.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
diff --git a/tests/ARSAS.Tests/IoTestSessionPointUpdateTests.cs b/tests/ARSAS.Tests/IoTestSessionPointUpdateTests.cs
index 37014a077..24dbb498a 100644
--- a/tests/ARSAS.Tests/IoTestSessionPointUpdateTests.cs
+++ b/tests/ARSAS.Tests/IoTestSessionPointUpdateTests.cs
@@ -79,7 +79,7 @@ public void GoodQualityPointUpdate_RecoversRejectedInitialBaselineAndKeepsCaptur
Assert.True(started.Succeeded, started.Message);
Assert.Equal(IoTestPointState.Passed, point.Runtime.State);
Assert.Equal(IoTestSessionState.Running, controller.State);
- Assert.Contains("Capture remains running", controller.StatusText, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("explicit Recapture", controller.StatusText, StringComparison.OrdinalIgnoreCase);
Assert.True(controller.Stop().Succeeded);
var journal = File.ReadAllText(controller.JournalPath);
Assert.Contains("waiting for good-quality baseline", journal, StringComparison.OrdinalIgnoreCase);
diff --git a/tests/ARSAS.Tests/IoTestingUiContractTests.cs b/tests/ARSAS.Tests/IoTestingUiContractTests.cs
index c6d9a7a5c..1ad83cc40 100644
--- a/tests/ARSAS.Tests/IoTestingUiContractTests.cs
+++ b/tests/ARSAS.Tests/IoTestingUiContractTests.cs
@@ -65,6 +65,40 @@ public void IoTestingWindow_ExposesCalmExportActionsWithoutPathNoise()
.First(value => value == "{Binding Storage.SnapshotPath}"));
}
+ [Fact]
+ public void IoTestingWindow_AllowsAddingAnIedDuringIndependentIedWorkflows()
+ {
+ var document = XDocument.Load(FindRepoFile("IoListTestingWindow.xaml"));
+ XNamespace presentation = "http://schemas.microsoft.com/winfx/2006/xaml/presentation";
+ var addButton = document
+ .Descendants(presentation + "Button")
+ .Single(button => (string?)button.Attribute("Click") == "AddFatIedFromScl_Click");
+
+ Assert.Equal("{Binding CanAddFatIed}", (string?)addButton.Attribute("IsEnabled"));
+
+ var window = File.ReadAllText(FindRepoFile("IoListTestingWindow.xaml.cs"));
+ var addFlow = File.ReadAllText(FindRepoFile("IoListTestingWindow.AddIed.cs"));
+ Assert.Contains("public bool CanAddFatIed => !_isAddingFatIeds", window, StringComparison.Ordinal);
+ Assert.DoesNotContain("CanAddFatIed => !IsPreparingIed", window, StringComparison.Ordinal);
+ Assert.Contains("if (!CanAddFatIed", addFlow, StringComparison.Ordinal);
+ Assert.Contains("SetAddingFatIeds(true)", addFlow, StringComparison.Ordinal);
+ Assert.Contains("SetAddingFatIeds(false)", addFlow, StringComparison.Ordinal);
+ Assert.Contains("AppendSclIedsToLoadedFatAsync(this, sclPaths)", addFlow, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void WindowsExecutableAndProcessDeclareTheArsasTaskbarIconIdentity()
+ {
+ var project = File.ReadAllText(FindRepoFile("ArIED61850Tester.csproj"));
+ var app = File.ReadAllText(FindRepoFile("App.xaml.cs"));
+ var identity = File.ReadAllText(FindRepoFile("App.WindowsIdentity.cs"));
+
+ Assert.Contains("Assets\\app-icon.ico", project, StringComparison.Ordinal);
+ Assert.Contains("WindowsApplicationIdentity.Apply();", app, StringComparison.Ordinal);
+ Assert.Contains("SetCurrentProcessExplicitAppUserModelID", identity, StringComparison.Ordinal);
+ Assert.True(File.Exists(FindRepoFile("Assets/app-icon.ico")));
+ }
+
[Fact]
public void IoTestingWindow_ConnectsWithoutLockingExplorerNavigationAndPreservesOperatorSelection()
{
@@ -136,9 +170,9 @@ public void IoFatAutomaticPreparation_UsesFastCommissioningAcquisitionWithoutPro
Assert.Contains("ConnectAndConfigureDeviceAsync", source, StringComparison.Ordinal);
Assert.Contains("StartDeviceMonitorAsync", source, StringComparison.Ordinal);
Assert.Contains("deterministic fast MMS", source, StringComparison.OrdinalIgnoreCase);
- Assert.Contains("first live FAT image", source, StringComparison.OrdinalIgnoreCase);
- Assert.Contains("SettleIoFatReportPriorityAsync", source, StringComparison.Ordinal);
- Assert.Contains("TimeSpan.FromMilliseconds(2500)", source, StringComparison.Ordinal);
+ Assert.Contains("Return control to FAT immediately", source, StringComparison.OrdinalIgnoreCase);
+ Assert.DoesNotContain("SettleIoFatReportPriorityAsync", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("TimeSpan.FromMilliseconds(2500)", source, StringComparison.Ordinal);
Assert.DoesNotContain("rebuilding the report plan once", source, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("TimeSpan.FromSeconds(8)", source, StringComparison.Ordinal);
Assert.DoesNotContain("TimeSpan.FromSeconds(10)", source, StringComparison.Ordinal);
@@ -301,4 +335,4 @@ private static string FindRepoFile(string relativePath)
throw new FileNotFoundException(
$"Could not locate repository file '{relativePath}' from '{AppContext.BaseDirectory}'.");
}
-}
\ No newline at end of file
+}
diff --git a/tests/ARSAS.Tests/SchemaSafeAggregateProjectionRegressionTests.cs b/tests/ARSAS.Tests/SchemaSafeAggregateProjectionRegressionTests.cs
new file mode 100644
index 000000000..5dda24381
--- /dev/null
+++ b/tests/ARSAS.Tests/SchemaSafeAggregateProjectionRegressionTests.cs
@@ -0,0 +1,255 @@
+using AR.Iec61850.Discovery;
+using ArIED61850Tester.Services;
+
+namespace ARSAS.Tests;
+
+public sealed class SchemaSafeAggregateProjectionRegressionTests
+{
+ [Fact]
+ public void ThdA_ReadPlan_UsesExactNamedPhases_IndependentOfAttributeOrder()
+ {
+ const string parent = "IEDLD0/I_MHAI1.ThdA";
+ var model = Model(DataObject(
+ parent,
+ "ThdA",
+ // Deliberately scrambled with unrelated numeric leaves first.
+ Attribute(parent + ".noise.f", "noise.f"),
+ Attribute(parent + ".phsC.cVal.mag.f", "phsC.cVal.mag.f"),
+ Attribute(parent + ".phsA.instCVal.mag.f", "phsA.instCVal.mag.f"),
+ Attribute(parent + ".phsB.cVal.mag.f", "phsB.cVal.mag.f"),
+ Attribute(parent + ".phsA.cVal.mag.f", "phsA.cVal.mag.f"),
+ Attribute(parent + ".phsC.instCVal.mag.f", "phsC.instCVal.mag.f")));
+
+ var ok = SchemaSafeAggregateProjectionService.TryBuildReadPlan(
+ model,
+ parent,
+ out var plan,
+ out var status);
+
+ Assert.True(ok, status);
+ Assert.Equal("ThreePhaseThd", plan.Kind);
+ Assert.Equal(new[] { "A", "B", "C" }, plan.Leaves.Select(leaf => leaf.Label));
+ Assert.Equal(
+ new[]
+ {
+ parent + ".phsA.cVal.mag.f",
+ parent + ".phsB.cVal.mag.f",
+ parent + ".phsC.cVal.mag.f"
+ },
+ plan.Leaves.Select(leaf => leaf.Reference),
+ StringComparer.OrdinalIgnoreCase);
+ Assert.DoesNotContain(plan.Leaves, leaf => leaf.Reference.Contains("noise", StringComparison.OrdinalIgnoreCase));
+ Assert.DoesNotContain(plan.Leaves, leaf => leaf.Reference.Contains("instCVal", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public void ThdPPV_ReadPlan_UsesExactAB_BC_CA_Identities()
+ {
+ const string parent = "IEDLD0/V_MHAI1.ThdPPV";
+ var model = Model(DataObject(
+ parent,
+ "ThdPPV",
+ Attribute(parent + ".phsCA.cVal.mag.f", "phsCA.cVal.mag.f"),
+ Attribute(parent + ".phsAB.cVal.mag.f", "phsAB.cVal.mag.f"),
+ Attribute(parent + ".phsBC.cVal.mag.f", "phsBC.cVal.mag.f")));
+
+ Assert.True(SchemaSafeAggregateProjectionService.TryBuildReadPlan(
+ model,
+ parent,
+ out var plan,
+ out var status), status);
+
+ Assert.Equal(new[] { "AB", "BC", "CA" }, plan.Leaves.Select(leaf => leaf.Label));
+ Assert.Equal(
+ new[]
+ {
+ parent + ".phsAB.cVal.mag.f",
+ parent + ".phsBC.cVal.mag.f",
+ parent + ".phsCA.cVal.mag.f"
+ },
+ plan.Leaves.Select(leaf => leaf.Reference),
+ StringComparer.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Thd_ReadPlan_MissingOneNamedPhase_FailsClosed()
+ {
+ const string parent = "IEDLD0/I_MHAI1.ThdA";
+ var model = Model(DataObject(
+ parent,
+ "ThdA",
+ Attribute(parent + ".phsA.cVal.mag.f", "phsA.cVal.mag.f"),
+ Attribute(parent + ".phsC.cVal.mag.f", "phsC.cVal.mag.f"),
+ Attribute(parent + ".someOtherFloat.f", "someOtherFloat.f")));
+
+ var ok = SchemaSafeAggregateProjectionService.TryBuildReadPlan(
+ model,
+ parent,
+ out var plan,
+ out var status);
+
+ Assert.False(ok);
+ Assert.Empty(plan.Leaves);
+ Assert.Contains("phase B", status, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("none of the approved exact magnitude references", status, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void DemandEnergy_ReadPlan_PrefersCanonicalMag_AndIgnoresOtherNumericLeaves()
+ {
+ const string parent = "IEDLD0/XPRE_MMTR1.DmdWhMV";
+ var model = Model(DataObject(
+ parent,
+ "DmdWhMV",
+ Attribute(parent + ".noise.f", "noise.f"),
+ Attribute(parent + ".instMag.f", "instMag.f"),
+ Attribute(parent + ".mag.f", "mag.f")));
+
+ Assert.True(SchemaSafeAggregateProjectionService.TryBuildReadPlan(
+ model,
+ parent,
+ out var plan,
+ out var status), status);
+
+ var leaf = Assert.Single(plan.Leaves);
+ Assert.Equal("DemandEnergy", plan.Kind);
+ Assert.Equal(parent + ".mag.f", leaf.Reference, ignoreCase: true);
+ }
+
+ [Fact]
+ public void DemandEnergy_ReadPlan_UsesOneExactInstantFallback_WhenCanonicalIsAbsent()
+ {
+ const string parent = "IEDLD0/XPRE_MMTR1.DmdWhMV";
+ var model = Model(DataObject(
+ parent,
+ "DmdWhMV",
+ Attribute(parent + ".noise.f", "noise.f"),
+ Attribute(parent + ".instMag.f", "instMag.f")));
+
+ Assert.True(SchemaSafeAggregateProjectionService.TryBuildReadPlan(
+ model,
+ parent,
+ out var plan,
+ out var status), status);
+
+ Assert.Equal(parent + ".instMag.f", Assert.Single(plan.Leaves).Reference, ignoreCase: true);
+ }
+
+ [Fact]
+ public void DemandEnergy_ReadPlan_AmbiguousInstantFallback_FailsClosed()
+ {
+ const string parent = "IEDLD0/XPRE_MMTR1.DmdWhMV";
+ var model = Model(DataObject(
+ parent,
+ "DmdWhMV",
+ Attribute(parent + ".instMag.f", "instMag.f"),
+ Attribute(parent + ".instCVal.mag.f", "instCVal.mag.f")));
+
+ var ok = SchemaSafeAggregateProjectionService.TryBuildReadPlan(
+ model,
+ parent,
+ out _,
+ out var status);
+
+ Assert.False(ok);
+ Assert.Contains("ambiguous", status, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void RuntimeResolver_InterceptsAggregateBeforeGenericParentRead()
+ {
+ var source = File.ReadAllText(FindRepoFile("Services/IecSignalReadResolver.cs"))
+ .Replace("\r\n", "\n", StringComparison.Ordinal);
+ var intercept = source.IndexOf("if (client is NativeIec61850Client native && IsSchemaSafeAggregate(signal.ObjectReference))", StringComparison.Ordinal);
+ var generic = source.IndexOf("var references = BuildReadCandidates(signal.ObjectReference).ToList();", StringComparison.Ordinal);
+
+ Assert.True(intercept >= 0, "Aggregate values must have an explicit schema-safe runtime interception path.");
+ Assert.True(generic > intercept, "Aggregate interception must occur before generic parent/alternate read candidates are built.");
+ Assert.Contains("TryBuildSchemaSafeAggregateReadPlan", source, StringComparison.Ordinal);
+ Assert.Contains("schema-safe-three-phase-exact-leaf-reads", source, StringComparison.Ordinal);
+ Assert.Contains("schema-safe-demand-energy-exact-leaf-read", source, StringComparison.Ordinal);
+
+ var helperStart = source.IndexOf("private static async Task ReadSchemaSafeAggregateAsync", StringComparison.Ordinal);
+ var helperEnd = source.IndexOf("private static bool IsSchemaSafeAggregate", helperStart, StringComparison.Ordinal);
+ Assert.True(helperStart >= 0 && helperEnd > helperStart);
+ var helper = source[helperStart..helperEnd];
+ Assert.DoesNotContain("ReadValueAsync(signal.ObjectReference", helper, StringComparison.Ordinal);
+ Assert.Contains("ReadValueAsync(\n leaf.Reference", helper, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void SchemaSafeService_HasNoPositionalOrFirstNumericAggregateSelection()
+ {
+ var source = File.ReadAllText(FindRepoFile("Services/SchemaSafeAggregateProjectionService.cs"));
+
+ Assert.DoesNotContain("Children.Take(3)", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("FindFirstFloating", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("FindFirstInteger", source, StringComparison.Ordinal);
+ Assert.Contains("TryResolvePreferredAttributeReference", source, StringComparison.Ordinal);
+ }
+
+ private static LiveIedModelDiscoveryDocument Model(params LiveIedDataObjectModel[] objects)
+ => new()
+ {
+ Source = "SclWorkspace regression",
+ IedName = "IED",
+ LogicalDevices =
+ [
+ new LiveIedLogicalDeviceModel
+ {
+ MmsDomain = "IEDLD0",
+ Inst = "LD0",
+ LogicalNodes =
+ [
+ new LiveIedLogicalNodeModel
+ {
+ Name = "MHAI1",
+ LnClass = "MHAI",
+ LnInst = "1",
+ DataObjects = objects
+ }
+ ]
+ }
+ ]
+ };
+
+ private static LiveIedDataObjectModel DataObject(
+ string reference,
+ string name,
+ params LiveIedDataAttributeModel[] attributes)
+ => new()
+ {
+ Reference = reference,
+ Name = name,
+ InferredCdc = "MV",
+ Attributes = attributes
+ };
+
+ private static LiveIedDataAttributeModel Attribute(string reference, string path)
+ => new()
+ {
+ ObjectReference = reference,
+ AttributePath = path,
+ FunctionalConstraint = "MX",
+ MmsReference = reference.Replace('.', '$'),
+ SclBType = "FLOAT32",
+ MmsType = "floating-point",
+ Source = "SCL.DataTypeTemplates",
+ TypeSource = "SCL.DataTypeTemplates",
+ TypeConfidence = LiveIedDiscoveryConfidenceLevel.Exact
+ };
+
+ 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(relativePath);
+ }
+}
diff --git a/tests/ARSAS.Tests/SemanticReportProductionWiringRegressionTests.cs b/tests/ARSAS.Tests/SemanticReportProductionWiringRegressionTests.cs
new file mode 100644
index 000000000..abebabcbb
--- /dev/null
+++ b/tests/ARSAS.Tests/SemanticReportProductionWiringRegressionTests.cs
@@ -0,0 +1,185 @@
+using AR.Iec61850.Discovery;
+using AR.Iec61850.Mms;
+
+namespace ARSAS.Tests;
+
+public sealed class SemanticReportProductionWiringRegressionTests
+{
+ [Fact]
+ public void ProductionReceivePath_UsesModelBackedSemanticOverlay()
+ {
+ var native = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.cs"))
+ .Replace("\r\n", "\n", StringComparison.Ordinal);
+ var helper = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.SemanticReporting.cs"))
+ .Replace("\r\n", "\n", StringComparison.Ordinal);
+ var hybrid = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.HybridReporting.cs"))
+ .Replace("\r\n", "\n", StringComparison.Ordinal);
+
+ var receiveStart = native.IndexOf(
+ "public async Task ReceiveReportMonitorSliceAsync",
+ StringComparison.Ordinal);
+ var receiveEnd = native.IndexOf(
+ "public async Task> StopReportMonitorsAsync",
+ receiveStart,
+ StringComparison.Ordinal);
+ Assert.True(receiveStart >= 0 && receiveEnd > receiveStart);
+ var receive = native[receiveStart..receiveEnd];
+
+ Assert.Contains("var projection = ProjectReportValue(report);", receive, StringComparison.Ordinal);
+ Assert.DoesNotContain("MmsReportValueProjector.Project(report)", receive, StringComparison.Ordinal);
+ Assert.Contains("MmsSemanticReportValueProjector.Project", helper, StringComparison.Ordinal);
+ Assert.Contains("MmsReportSemanticProjectionContext.Create", helper, StringComparison.Ordinal);
+ Assert.Contains("_semanticReportProjectionAuthorityModel ?? _liveModel", helper, StringComparison.Ordinal);
+ Assert.Contains("REPORT_SEMANTIC_FALLBACK", helper, StringComparison.Ordinal);
+ Assert.Contains("SetSemanticReportProjectionAuthority(planningModel);", hybrid, StringComparison.Ordinal);
+ Assert.Contains("ResetSemanticReportProjectionContext();", native, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void PinnedEngine_FansOutWholeThreePhaseStructuredMember_WithoutChoosingPrimaryPhase()
+ {
+ const string objectReference = "IEDLD0/MHAI1.ThdA";
+ const string dataSetReference = "IEDLD0/LLN0.Analog";
+ var attributes = new[]
+ {
+ Attribute(objectReference + ".phsA.cVal.mag.f", "phsA.cVal.mag.f"),
+ Attribute(objectReference + ".phsB.cVal.mag.f", "phsB.cVal.mag.f"),
+ Attribute(objectReference + ".phsC.cVal.mag.f", "phsC.cVal.mag.f")
+ };
+ var model = new LiveIedModelDiscoveryDocument
+ {
+ Source = "ARSAS regression",
+ IedName = "IED",
+ LogicalDevices =
+ [
+ new LiveIedLogicalDeviceModel
+ {
+ MmsDomain = "IEDLD0",
+ LogicalNodes =
+ [
+ new LiveIedLogicalNodeModel
+ {
+ Name = "MHAI1",
+ LnClass = "MHAI",
+ LnInst = "1",
+ DataObjects =
+ [
+ new LiveIedDataObjectModel
+ {
+ Reference = objectReference,
+ Name = "ThdA",
+ InferredCdc = "WYE",
+ Attributes = attributes
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ DataSets =
+ [
+ new LiveIedDataSetModel
+ {
+ Reference = dataSetReference,
+ Domain = "IEDLD0",
+ LogicalNode = "LLN0",
+ Name = "Analog",
+ MemberCount = 1,
+ Members =
+ [
+ new LiveIedDataSetMemberModel
+ {
+ Index = 0,
+ Reference = objectReference,
+ FunctionalConstraint = "MX",
+ MmsReference = "IEDLD0/MHAI1$MX$ThdA",
+ Confidence = LiveIedDiscoveryConfidenceLevel.Exact
+ }
+ ]
+ }
+ ]
+ };
+
+ var binding = Assert.Single(Iec61850DataSetSemanticBindingResolver.Resolve(model).Members);
+ Assert.Equal(LiveIedDataSetMemberResolutionStatus.Ambiguous, binding.ResolutionStatus);
+ Assert.Null(binding.PrimaryValue);
+
+ var frame = new MmsReportFrame
+ {
+ ReceivedAt = new DateTimeOffset(2026, 9, 1, 9, 0, 0, TimeSpan.Zero),
+ Header = new MmsReportHeader { DataSetReference = dataSetReference },
+ Values =
+ [
+ new MmsReportValue
+ {
+ // Deliberately differs from the authoritative static DataSet member index (0).
+ // A sparse InformationReport value position must not defeat an exact IEC member reference.
+ Index = 17,
+ Member = new MmsDataSetDirectoryMember
+ {
+ UserReference = objectReference,
+ FunctionalConstraint = "MX"
+ },
+ Value = MmsDataValue.Structure([Phase(1.25), Phase(2.5), Phase(3.75)]),
+ ReasonForInclusion = ["data-change"]
+ }
+ ]
+ };
+
+ var projection = MmsSemanticReportValueProjector.Project(
+ frame,
+ MmsReportSemanticProjectionContext.Create(model));
+
+ Assert.Contains(projection.Updates, update =>
+ update.Reference.Equals(objectReference + ".phsA.cVal.mag.f", StringComparison.OrdinalIgnoreCase));
+ Assert.Contains(projection.Updates, update =>
+ update.Reference.Equals(objectReference + ".phsB.cVal.mag.f", StringComparison.OrdinalIgnoreCase));
+ Assert.Contains(projection.Updates, update =>
+ update.Reference.Equals(objectReference + ".phsC.cVal.mag.f", StringComparison.OrdinalIgnoreCase));
+ Assert.All(projection.Updates, update =>
+ Assert.Equal("semantic-structured-leaf", update.ProjectionStatus));
+ Assert.DoesNotContain(projection.Updates, update =>
+ update.Reference.Equals(objectReference, StringComparison.OrdinalIgnoreCase));
+ Assert.DoesNotContain(projection.Warnings, warning =>
+ warning.StartsWith("REPORT_RAW_STRUCT:", StringComparison.OrdinalIgnoreCase));
+ Assert.DoesNotContain(projection.Warnings, warning =>
+ warning.StartsWith("REPORT_SEMANTIC_FALLBACK:", StringComparison.OrdinalIgnoreCase));
+ }
+
+ private static LiveIedDataAttributeModel Attribute(string reference, string path)
+ => new()
+ {
+ ObjectReference = reference,
+ AttributePath = path,
+ FunctionalConstraint = "MX",
+ MmsReference = reference.Replace('.', '$'),
+ SclBType = "FLOAT32",
+ MmsType = "floating-point",
+ Source = "ARSAS regression",
+ TypeSource = "ARSAS regression",
+ TypeConfidence = LiveIedDiscoveryConfidenceLevel.Exact
+ };
+
+ private static MmsDataValue Phase(double value)
+ => MmsDataValue.Structure(
+ [
+ MmsDataValue.Structure(
+ [
+ MmsDataValue.Structure([MmsDataValue.FloatingPoint(value)])
+ ])
+ ]);
+
+ 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(relativePath);
+ }
+}
diff --git a/tests/ARSAS.Tests/ThdMeasurementAndWorkspaceBridgeRegressionTests.cs b/tests/ARSAS.Tests/ThdMeasurementAndWorkspaceBridgeRegressionTests.cs
new file mode 100644
index 000000000..81ac9238d
--- /dev/null
+++ b/tests/ARSAS.Tests/ThdMeasurementAndWorkspaceBridgeRegressionTests.cs
@@ -0,0 +1,274 @@
+using ArIED61850Tester.Models;
+using ArIED61850Tester.Models.IoTesting;
+using ArIED61850Tester.Services.IoTesting;
+
+namespace ARSAS.Tests;
+
+public sealed class ThdMeasurementAndWorkspaceBridgeRegressionTests
+{
+ [Theory]
+ [InlineData("IEDTHD/I_MHAI1.ThdA.phsA.cVal.mag.f")]
+ [InlineData("IEDTHD/I_MHAI1.ThdA.phsB.cVal.mag.f")]
+ [InlineData("IEDTHD/I_MHAI1.ThdA.phsC.cVal.mag.f")]
+ [InlineData("IEDTHD/V_MHAI1.ThdPPV.phsAB.cVal.mag.f")]
+ [InlineData("IEDTHD/V_MHAI1.ThdPPV.phsBC.cVal.mag.f")]
+ [InlineData("IEDTHD/V_MHAI1.ThdPPV.phsCA.cVal.mag.f")]
+ public void NumericThdPhaseMagnitude_IsPublishableToLiveRuntime(string reference)
+ {
+ var signal = Signal(reference, reference, "IEDTHD/LLN0.Analog");
+
+ Assert.False(signal.IsRawAttribute);
+ Assert.True(signal.IsValueSignal);
+ Assert.True(signal.CanPublishAsSignal);
+ Assert.True(signal.CanPublishToRuntime);
+ }
+
+ [Theory]
+ [InlineData("IEDTHD/I_MHAI1.ThdA")]
+ [InlineData("IEDTHD/V_MHAI1.ThdPPV")]
+ public void ThreePhaseThdParent_IsPublishableAsCompositeLiveValue(string reference)
+ {
+ var signal = Signal(reference, reference, "IEDTHD/LLN0.Analog");
+
+ Assert.True(SignalDefinition.IsThreePhaseMeasurementAggregate(reference));
+ Assert.False(signal.IsRawAttribute);
+ Assert.True(signal.CanPublishToRuntime);
+ }
+
+ [Theory]
+ [InlineData("IEDTHD/I_MHAI1.ThdA.phsA.q")]
+ [InlineData("IEDTHD/V_MHAI1.ThdPPV.phsAB.t")]
+ public void ThdCompanion_RemainsNonPublishable(string reference)
+ {
+ var signal = Signal(reference, reference, "IEDTHD/LLN0.Analog");
+
+ Assert.False(signal.CanPublishAsSignal);
+ }
+
+ [Theory]
+ [InlineData("IEDENERGY/XPRE_MMTR1.DmdWhMV.mag.f")]
+ [InlineData("IEDENERGY/XPRE_MMTR1.DmdWhMV.instMag.f")]
+ public void DemandEnergyMagnitude_IsNotDiscardedAsStatisticNoise(string reference)
+ {
+ var signal = Signal(reference, reference, "IEDENERGY/LLN0.Analog");
+
+ Assert.False(signal.IsRawAttribute);
+ Assert.True(signal.CanPublishToRuntime);
+ }
+
+ [Fact]
+ public void ObjectLevelDemandEnergyDataSetMember_IsPublishableForScalarProjection()
+ {
+ const string reference = "IEDENERGY/XPRE_MMTR1.DmdWhMV";
+ var signal = Signal(reference, reference, "IEDENERGY/LLN0.Analog");
+
+ Assert.True(SignalDefinition.IsDemandEnergyAggregate(reference));
+ Assert.False(signal.IsRawAttribute);
+ Assert.True(signal.CanPublishToRuntime);
+ }
+
+ [Fact]
+ public void FatFastPath_SkipsConnectedReconciliation_AndUsesSchemaSafeParentReads()
+ {
+ var root = FindRepoRoot();
+ var autoConnect = File.ReadAllText(Path.Combine(root, "MainWindow.IoTesting.AutoConnect.cs"));
+ var runtime = File.ReadAllText(Path.Combine(root, "Services", "Iec61850MonitorRuntime.cs"));
+ var resolver = File.ReadAllText(Path.Combine(root, "Services", "IecSignalReadResolver.cs"));
+ var aggregate = File.ReadAllText(Path.Combine(root, "Services", "SchemaSafeAggregateProjectionService.cs"));
+
+ Assert.Contains("if (hasSclRuntimeAuthority)", autoConnect, StringComparison.Ordinal);
+ Assert.Contains("IoTestReconciliationCache.Invalidate(device)", autoConnect, StringComparison.Ordinal);
+ Assert.Contains("RequiresExactMmsValueAuthority(point.IecReference)", runtime, StringComparison.Ordinal);
+ Assert.Contains("ReadSchemaSafeAggregateAsync", resolver, StringComparison.Ordinal);
+ Assert.Contains("TryBuildSchemaSafeAggregateReadPlan", resolver, StringComparison.Ordinal);
+ Assert.Contains("schema-safe-three-phase-exact-leaf-reads", resolver, StringComparison.Ordinal);
+ Assert.Contains("schema-safe-demand-energy-exact-leaf-read", resolver, StringComparison.Ordinal);
+ Assert.Contains("TryResolvePreferredAttributeReference", aggregate, StringComparison.Ordinal);
+ Assert.DoesNotContain("Children.Take(3)", aggregate, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void FatWorkspace_ExposesIncrementalSclIedImport()
+ {
+ var root = FindRepoRoot();
+ var xaml = File.ReadAllText(Path.Combine(root, "IoListTestingWindow.xaml"));
+ var importer = File.ReadAllText(Path.Combine(root, "Services", "IoTesting", "IoFatSclProjectImportService.cs"));
+
+ Assert.Contains("AddFatIedFromScl_Click", xaml, StringComparison.Ordinal);
+ Assert.Contains("ImportAdditionalAsync", importer, StringComparison.Ordinal);
+ Assert.Contains("AddRuntimeWorkspaces", importer, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void ExistingEngineeringSelection_InitializesFat_ThenSynchronizesBothWays()
+ {
+ const string dataSet = "IEDTHD/LLN0.Analog";
+ const string member = "IEDTHD/I_MHAI1.ThdA.phsA";
+ const string runtime = member + ".cVal.mag.f";
+ var signal = Signal(runtime, member, dataSet);
+ var device = Device(signal);
+ var point = Point(member, runtime, dataSet);
+ var ied = Ied(point);
+
+ // Engineering owns shared workspace membership, while TEST remains a FAT-only
+ // evidence-scope preference. An intentionally unchecked Engineering signal must
+ // hide from FAT without mutating the operator's TEST choice.
+ point.TestEnabled = true;
+ IoFatEngineeringSelectionBridge.Initialize(
+ ied,
+ device,
+ preserveExistingEngineeringSelection: true);
+ Assert.False(point.WorkspaceSelected);
+ Assert.True(point.TestEnabled);
+ Assert.False(signal.IsSelected);
+
+ // Engineering reselects the shared row without touching FAT-only TEST state.
+ signal.IsSelected = true;
+ Assert.True(IoFatEngineeringSelectionBridge.ApplyEngineeringSignalSelection(
+ signal,
+ selected: true,
+ ied,
+ device));
+ Assert.True(point.WorkspaceSelected);
+ Assert.True(point.TestEnabled);
+ Assert.True(point.IsIncludedInFat);
+
+ // FAT can change the shared workspace membership independently from TEST.
+ point.WorkspaceSelected = false;
+ Assert.True(IoFatEngineeringSelectionBridge.ApplyFatPointSelection(point, device));
+ Assert.False(signal.IsSelected);
+ Assert.True(point.TestEnabled);
+ }
+
+ [Fact]
+ public void RemovedSclFatRow_EngineeringSelectionChangesWorkspaceOnly_AndNeverRestoresDisposition()
+ {
+ const string dataSet = "IEDTHD/LLN0.Analog";
+ const string member = "IEDTHD/I_MHAI1.ThdA.phsB";
+ const string runtime = member + ".cVal.mag.f";
+ var signal = Signal(runtime, member, dataSet);
+ signal.IsSelected = true;
+ var device = Device(signal);
+ var point = Point(member, runtime, dataSet);
+ var ied = Ied(point);
+
+ point.RemoveFromFat();
+ Assert.False(point.IsIncludedInFat);
+ Assert.True(point.WorkspaceSelected);
+ Assert.True(point.TestEnabled);
+ Assert.False(IoFatEngineeringSelectionBridge.ApplyFatPointSelection(point, device));
+ Assert.True(signal.IsSelected);
+
+ signal.IsSelected = false;
+ Assert.True(IoFatEngineeringSelectionBridge.ApplyEngineeringSignalSelection(
+ signal,
+ selected: false,
+ ied,
+ device));
+ Assert.False(point.IsIncludedInFat);
+ Assert.False(point.WorkspaceSelected);
+ Assert.True(point.TestEnabled);
+
+ signal.IsSelected = true;
+ Assert.True(IoFatEngineeringSelectionBridge.ApplyEngineeringSignalSelection(
+ signal,
+ selected: true,
+ ied,
+ device));
+ Assert.False(point.IsIncludedInFat);
+ Assert.True(point.WorkspaceSelected);
+ Assert.True(point.TestEnabled);
+
+ point.RestoreToFat();
+ Assert.True(point.IsIncludedInFat);
+ Assert.True(point.WorkspaceSelected);
+ Assert.True(point.TestEnabled);
+ Assert.False(IoFatEngineeringSelectionBridge.ApplyFatPointSelection(point, device));
+ Assert.True(signal.IsSelected);
+ }
+
+ [Fact]
+ public void NewDirectSclFatWorkspace_SelectsTheSameCanonicalEngineeringRows()
+ {
+ const string dataSet = "IEDTHD/LLN0.Analog";
+ const string member = "IEDTHD/V_MHAI1.ThdPPV.phsAB";
+ const string runtime = member + ".cVal.mag.f";
+ var canonical = Signal(runtime, member, dataSet);
+ var duplicatePresentationLeaf = Signal(runtime, runtime, dataSet);
+ var device = Device(canonical, duplicatePresentationLeaf);
+ var point = Point(member, runtime, dataSet);
+
+ IoFatEngineeringSelectionBridge.Initialize(
+ Ied(point),
+ device,
+ preserveExistingEngineeringSelection: false);
+
+ Assert.True(canonical.IsSelected);
+ Assert.False(duplicatePresentationLeaf.IsSelected);
+ Assert.Same(canonical, IoFatEngineeringSelectionBridge.FindSignal(point, device));
+ }
+
+ private static SignalDefinition Signal(string runtime, string display, string dataSet) => new()
+ {
+ Name = "THD",
+ ObjectReference = runtime,
+ DisplayReference = display,
+ FunctionalConstraint = "MX",
+ DataType = "FLOAT32",
+ Category = "DataSet",
+ DataSetReference = dataSet,
+ ProbeStatus = "Not probed",
+ Value = "-",
+ Quality = "Unknown"
+ };
+
+ private static Iec61850MonitorDevice Device(params SignalDefinition[] signals)
+ {
+ var device = new Iec61850MonitorDevice
+ {
+ Name = "IEDTHD",
+ SclIedName = "IEDTHD",
+ IpAddress = "192.0.2.20",
+ Port = 102
+ };
+ device.Signals.AddRange(signals);
+ return device;
+ }
+
+ private static IoTestIedPlan Ied(IoTestPointPlan point) => new()
+ {
+ IedName = "IEDTHD",
+ IpAddress = "192.0.2.20",
+ TestPoints = [point]
+ };
+
+ private static IoTestPointPlan Point(string member, string runtime, string dataSet) => new()
+ {
+ TestPointId = "thd-phase",
+ IedName = "IEDTHD",
+ IpAddress = "192.0.2.20",
+ SignalName = "THD",
+ ObjectReference = runtime,
+ FunctionalConstraint = "MX",
+ ExpectedOnText = "Value 1",
+ ExpectedOffText = "Value 2",
+ DataSetName = dataSet,
+ SourceIecReference = member,
+ ReportDisplayReference = member,
+ EventLogSearchReference = member,
+ BindingStatus = IoTestSignalSelectionService.SclDataSetAuthorityBindingStatus,
+ ImportReady = true,
+ WorkspaceSelected = true,
+ TestEnabled = true,
+ SignalKind = FatSignalKind.Analog,
+ CaptureMode = FatCaptureMode.OperatorSnapshot
+ };
+
+ private static string FindRepoRoot()
+ {
+ var directory = new DirectoryInfo(AppContext.BaseDirectory);
+ while (directory != null && !File.Exists(Path.Combine(directory.FullName, "ArIED61850Tester.csproj")))
+ directory = directory.Parent;
+ return directory?.FullName ?? throw new DirectoryNotFoundException("Repository root not found.");
+ }
+}
diff --git a/tests/ARSAS.Tests/WorkspaceModeSwitchTests.cs b/tests/ARSAS.Tests/WorkspaceModeSwitchTests.cs
index f8cb1dc60..ade9126ba 100644
--- a/tests/ARSAS.Tests/WorkspaceModeSwitchTests.cs
+++ b/tests/ARSAS.Tests/WorkspaceModeSwitchTests.cs
@@ -12,13 +12,59 @@ public void MainWindow_AlwaysExposesEngineeringAndPersistentIoFatWorkspaceModes(
Assert.Contains("IO LIST FAT · LOADED", source, StringComparison.Ordinal);
Assert.Contains("_loadedIoFatWindow", source, StringComparison.Ordinal);
Assert.Contains("ShowLoadedIoFatWorkspace", source, StringComparison.Ordinal);
+ Assert.Contains("CurrentEngineeringSclSourcePaths", source, StringComparison.Ordinal);
+ Assert.Contains("OpenSclFatSourcesAsync(sharedSources, selectionMode: null)", source, StringComparison.Ordinal);
Assert.Contains("Continue loaded FAT project", source, StringComparison.Ordinal);
+ Assert.Contains("Import SCL / CID files", source, StringComparison.Ordinal);
+ Assert.Contains("Add SCL / CID to loaded FAT workspace", source, StringComparison.Ordinal);
+ Assert.Contains("OpenSclForLoadedFatAppendAsync(loaded)", source, StringComparison.Ordinal);
+ Assert.Contains("OpenSclFatTesting_Click", source, StringComparison.Ordinal);
Assert.Contains("Import another IO List Excel workbook", source, StringComparison.Ordinal);
Assert.Contains("Open another portable .arsas project", source, StringComparison.Ordinal);
Assert.Contains("QueueIoFatWorkspaceReplacement", source, StringComparison.Ordinal);
Assert.Contains("FrameworkElement.LoadedEvent", source, StringComparison.Ordinal);
}
+ [Fact]
+ public void SclImport_UsesOneSelectionAuthorityAcrossEngineeringAndFat()
+ {
+ var workflow = File.ReadAllText(FindRepoFile("MainWindow.SharedSclWorkspace.cs"));
+ var selectionWindow = File.ReadAllText(FindRepoFile("SclSignalSelectionModeWindow.xaml"));
+ var engineering = File.ReadAllText(FindRepoFile("MainWindow.xaml.cs"));
+ var fat = File.ReadAllText(FindRepoFile("MainWindow.IoTesting.cs"));
+ var fatProjection = File.ReadAllText(FindRepoFile("IoListTestingWindow.FatV2Ux.cs"));
+
+ Assert.Contains("new SclSignalSelectionModeWindow", workflow, StringComparison.Ordinal);
+ Assert.DoesNotContain("MessageBox.Show", workflow, StringComparison.Ordinal);
+ Assert.Contains("Use Static DataSet", selectionWindow, StringComparison.Ordinal);
+ Assert.Contains("Choose Signals Manually", selectionWindow, StringComparison.Ordinal);
+ Assert.Contains("SelectionCard", selectionWindow, StringComparison.Ordinal);
+ Assert.Contains("CardShadow", selectionWindow, StringComparison.Ordinal);
+ Assert.Contains("PrimaryButton", selectionWindow, StringComparison.Ordinal);
+ Assert.Contains("SoftButton", selectionWindow, StringComparison.Ordinal);
+ Assert.Contains("_sharedSclSelectionAuthorityDeviceIds", workflow, StringComparison.Ordinal);
+ Assert.Contains("ApplyStaticDataSetSelection", engineering, StringComparison.Ordinal);
+ Assert.Contains("selectionAlreadyApplied: true", engineering, StringComparison.Ordinal);
+ Assert.Contains("ApplyManualSelectionToFatProjectAsync", fat, StringComparison.Ordinal);
+ Assert.Contains("promptForSelection: true", fat, StringComparison.Ordinal);
+ Assert.Contains("PromptSclSignalSelectionMode(this, import.Project.Ieds.Count)", fat, StringComparison.Ordinal);
+ Assert.Contains("point.WorkspaceSelected", fatProjection, StringComparison.Ordinal);
+ Assert.Contains("point.IsIncludedInFat", fatProjection, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void EngineeringSclImport_IsAdditiveAndSupportsMultipleFiles()
+ {
+ var engineering = File.ReadAllText(FindRepoFile("MainWindow.xaml.cs"));
+
+ Assert.Contains("Title = \"Open one or more IEC 61850 SCL files\"", engineering, StringComparison.Ordinal);
+ Assert.Contains("Multiselect = true", engineering, StringComparison.Ordinal);
+ Assert.Contains("foreach (var sourcePath in dialog.FileNames.Distinct", engineering, StringComparison.Ordinal);
+ Assert.Contains("var importedSources = new List<(string Path, string Sha256)>()", engineering, StringComparison.Ordinal);
+ Assert.Contains("newFatSourcePaths", engineering, StringComparison.Ordinal);
+ Assert.Contains("AppendSclIedsToLoadedFatAsync", engineering, StringComparison.Ordinal);
+ }
+
[Fact]
public void FatWorkspace_ReturnsToEngineeringByHideAndShowWithoutUnloadingProject()
{
@@ -41,7 +87,7 @@ public void FatWorkspace_ReturnsToEngineeringByHideAndShowWithoutUnloadingProjec
}
[Fact]
- public void LoadingAnotherFatProject_IsExplicitAndClosesTheLoadedWorkspaceFirst()
+ public void LoadingAnotherFatProject_IsExplicitWhileLoadedSclImportIsAdditive()
{
var source = File.ReadAllText(FindRepoFile("MainWindow.WorkspaceModeSwitch.cs"));
var hostSource = File.ReadAllText(FindRepoFile("MainWindow.IoTesting.cs"));
@@ -49,6 +95,13 @@ public void LoadingAnotherFatProject_IsExplicitAndClosesTheLoadedWorkspaceFirst(
Assert.Contains("Save and close that workspace before loading another project?", source, StringComparison.Ordinal);
Assert.Contains("loaded.Close();", source, StringComparison.Ordinal);
Assert.Contains("Dispatcher.BeginInvoke(openReplacement", source, StringComparison.Ordinal);
+
+ // P0.4 keeps SCL additive while workbook/project opens remain explicit replacement.
+ Assert.Contains("OpenSclForLoadedFatAppendAsync(loaded)", source, StringComparison.Ordinal);
+ Assert.DoesNotContain(
+ "QueueIoFatWorkspaceReplacement(\n () => OpenSclFatTesting_Click",
+ source,
+ StringComparison.Ordinal);
Assert.Contains("QueueIoFatWorkspaceReplacement(() => OpenIoListTesting_Click", hostSource, StringComparison.Ordinal);
Assert.Contains("QueueIoFatWorkspaceReplacement(() => OpenIoListPackage_Click", hostSource, StringComparison.Ordinal);
}
|