From 88398f58ae2ab878af2cdf1dd2c1d7bbcf21c4c8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 6 Sep 2026 05:29:04 +0700 Subject: [PATCH 01/16] Preserve shared Static DataSet authority across FAT and Engineering --- MainWindow.SharedSclWorkspace.cs | 52 +++++++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/MainWindow.SharedSclWorkspace.cs b/MainWindow.SharedSclWorkspace.cs index 2531c48f5..163f20023 100644 --- a/MainWindow.SharedSclWorkspace.cs +++ b/MainWindow.SharedSclWorkspace.cs @@ -27,6 +27,13 @@ private enum SclSignalSelectionMode private readonly HashSet _sharedSclStaticDataSetAuthorityDeviceIds = new(StringComparer.OrdinalIgnoreCase); + // The same selection dialog is used by Engineering import and FAT import. Remember its + // result only long enough for every IED from that one import to consume it. This closes + // the FAT-first gap where Static DataSet was selected in the dialog but the subsequent + // generic shared-selection marker silently changed the device back to Hybrid. + private SclSignalSelectionMode? _pendingSharedSclSelectionMode; + private int _pendingSharedSclSelectionAssignments; + private bool IsSharedStaticDataSetAuthority(Iec61850MonitorDevice device) => _sharedSclStaticDataSetAuthorityDeviceIds.Contains(device.DeviceId) || Iec61850MonitoringModeRegistry.IsStaticDataSetReportOnly(device); @@ -40,9 +47,22 @@ private bool IsSharedStaticDataSetAuthority(Iec61850MonitorDevice device) if (dialog.ShowDialog() != true) return null; - return dialog.UseStaticDataSet + var mode = dialog.UseStaticDataSet ? SclSignalSelectionMode.StaticDataSet : SclSignalSelectionMode.Manual; + _pendingSharedSclSelectionMode = mode; + _pendingSharedSclSelectionAssignments = Math.Max(1, iedCount); + return mode; + } + + private void CompletePendingSharedSclSelectionAssignment() + { + if (_pendingSharedSclSelectionAssignments <= 0) + return; + + _pendingSharedSclSelectionAssignments--; + if (_pendingSharedSclSelectionAssignments == 0) + _pendingSharedSclSelectionMode = null; } private void ApplyStaticDataSetSelection(Iec61850MonitorDevice device) @@ -72,6 +92,7 @@ private void ApplyStaticDataSetSelection(Iec61850MonitorDevice device) _sharedSclStaticDataSetAuthorityDeviceIds.Add(device.DeviceId); SaveSignalSelectionMemory(device); device.RefreshComputed(); + CompletePendingSharedSclSelectionAssignment(); AddLog( "INFO", @@ -101,11 +122,35 @@ private void ClearSharedSignalSelection(Iec61850MonitorDevice device) private void MarkSharedSelectionAuthority(Iec61850MonitorDevice device) { + // FAT-first Static DataSet selection must survive the generic shared-workspace + // hand-off. If the immediately preceding import dialog chose Static DataSet, + // establish the same authority Engineering uses instead of demoting to Hybrid. + if (_pendingSharedSclSelectionMode == SclSignalSelectionMode.StaticDataSet) + { + ApplyStaticDataSetSelection(device); + return; + } + + // Switching Engineering -> FAT with no new selection dialog must preserve the + // acquisition contract already owned by the Engineering device. + if (IsSharedStaticDataSetAuthority(device)) + { + Iec61850MonitoringModeRegistry.UseStaticDataSetReportOnly(device); + _sharedSclSelectionAuthorityDeviceIds.Add(device.DeviceId); + _sharedSclStaticDataSetAuthorityDeviceIds.Add(device.DeviceId); + SaveSignalSelectionMemory(device); + device.RefreshComputed(); + CompletePendingSharedSclSelectionAssignment(); + return; + } + // Manual selection restores the normal Smart/Hybrid acquisition contract. _sharedSclStaticDataSetAuthorityDeviceIds.Remove(device.DeviceId); Iec61850MonitoringModeRegistry.UseHybrid(device); _sharedSclSelectionAuthorityDeviceIds.Add(device.DeviceId); SaveSignalSelectionMemory(device); + device.RefreshComputed(); + CompletePendingSharedSclSelectionAssignment(); } private string[] CurrentEngineeringSclSourcePaths() @@ -208,6 +253,11 @@ await OpenSignalSelectionWizardAsync( device); } + // Manual is an explicit acquisition choice. Establish Hybrid before the shared + // authority marker so a previous Static DataSet session cannot leak into this + // newly selected manual workspace. + _sharedSclStaticDataSetAuthorityDeviceIds.Remove(device.DeviceId); + Iec61850MonitoringModeRegistry.UseHybrid(device); MarkSharedSelectionAuthority(device); } } From 28ba25ea99060df203940b1fdcf61cc39c0ffd99 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 6 Sep 2026 05:29:23 +0700 Subject: [PATCH 02/16] Keep appended Static DataSet workspaces report-only --- MainWindow.IoTesting.SclAppend.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/MainWindow.IoTesting.SclAppend.cs b/MainWindow.IoTesting.SclAppend.cs index a10852114..947b60d45 100644 --- a/MainWindow.IoTesting.SclAppend.cs +++ b/MainWindow.IoTesting.SclAppend.cs @@ -138,6 +138,21 @@ await ApplyManualSelectionToFatProjectAsync( window, resetSelection: true); } + else if (selectionMode == SclSignalSelectionMode.StaticDataSet && !selectionAlreadyApplied) + { + // A programmatic Static DataSet append does not pass through the selection + // dialog, so establish the report-only authority explicitly here. The same + // device, selected signals and acquisition mode are then visible in both + // Engineering and FAT without any live discovery pass. + foreach (var ied in addedIeds) + { + var device = ResolveIoTestDevice(ied.LiveDeviceId) + ?? ResolveIoTestDevice(ied.IpAddress) + ?? ResolveIoTestDevice(ied.IedName); + if (device is not null) + ApplyStaticDataSetSelection(device); + } + } else { foreach (var ied in addedIeds) From 34446f67ffc03235abc3cf791fb143247d4445de Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 6 Sep 2026 05:29:46 +0700 Subject: [PATCH 03/16] Auto-capture report-backed analog FAT values --- .../IoTesting/FatAutoCaptureCoordinator.cs | 36 ++++++++++++++++--- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/Services/IoTesting/FatAutoCaptureCoordinator.cs b/Services/IoTesting/FatAutoCaptureCoordinator.cs index 3bb3093de..e17dcdbc1 100644 --- a/Services/IoTesting/FatAutoCaptureCoordinator.cs +++ b/Services/IoTesting/FatAutoCaptureCoordinator.cs @@ -19,10 +19,11 @@ public static FatAutoCaptureDecision None(FatAutoCaptureStage stage, string mess /// evidence pointers: callers must durably append the returned evidence first, then /// promote it. That keeps the evidence journal authoritative when storage fails. /// -/// Analog values use an intentionally small, deterministic settling window rather than -/// capturing every transient MMS/report update. Three consecutive samples must remain -/// inside an adaptive 0.05% band before the slot is accepted. Discrete/Other values use -/// semantic change and are latched immediately after good-quality observation. +/// Poll-backed analog values use a small deterministic settling window rather than +/// capturing every transient sample. Authoritative report/Static DataSet observations do +/// not necessarily repeat the same analog value, so one good report sample is accepted as +/// the stable condition. Discrete/Other values use semantic change and are latched +/// immediately after good-quality observation. /// public sealed class FatAutoCaptureCoordinator { @@ -98,7 +99,22 @@ public FatAutoCaptureDecision Observe(IoTestPointPlan point, IoTestObservation o Clear(point); return FatAutoCaptureDecision.None( slot == FatValueSlot.Value1 ? FatAutoCaptureStage.WaitingValue1 : FatAutoCaptureStage.WaitingChange, - "Analog value is not numerically stable enough for automatic capture; manual Recapture remains available."); + "Analog value is not numeric enough for automatic capture; operator Recapture remains available after a valid live value is established."); + } + + // Static DataSet / InformationReport acquisition is change-driven. Requiring three + // identical reports would leave a perfectly steady analog point uncaptured forever. + // One good report observation is already the authoritative relay image, so accept it + // immediately while retaining the three-sample settling rule for polling sources. + if (IsAuthoritativeReportObservation(observation)) + { + Clear(point); + return new FatAutoCaptureDecision( + CreateEvidence(slot, observation), + slot == FatValueSlot.Value1 ? FatAutoCaptureStage.WaitingChange : FatAutoCaptureStage.Complete, + slot == FatValueSlot.Value1 + ? "Report-backed analog Value 1 captured automatically; waiting for a meaningful new condition." + : "Report-backed analog Value 2 captured automatically; current evidence is complete."); } var key = point.TestPointId; @@ -140,6 +156,16 @@ public void Clear(IoTestPointPlan point) public void Clear() => _analogCandidates.Clear(); + private static bool IsAuthoritativeReportObservation(IoTestObservation observation) + { + var source = observation.AcquisitionSource?.Trim() ?? string.Empty; + return source.Contains("Static DataSet", StringComparison.OrdinalIgnoreCase) || + source.Contains("InformationReport", StringComparison.OrdinalIgnoreCase) || + source.Contains("Report", StringComparison.OrdinalIgnoreCase) || + source.Contains("URCB", StringComparison.OrdinalIgnoreCase) || + source.Contains("BRCB", StringComparison.OrdinalIgnoreCase); + } + private static FatValueEvidence CreateEvidence(FatValueSlot slot, IoTestObservation observation) => new( Guid.NewGuid(), From 261d3023ccc9d77c0059cca0b049f72f289858c5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 6 Sep 2026 05:30:15 +0700 Subject: [PATCH 04/16] Canonicalize FAT live value presentation --- Services/IoTesting/IoFatValuePresentation.cs | 32 ++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 Services/IoTesting/IoFatValuePresentation.cs diff --git a/Services/IoTesting/IoFatValuePresentation.cs b/Services/IoTesting/IoFatValuePresentation.cs new file mode 100644 index 000000000..b4cd5a85f --- /dev/null +++ b/Services/IoTesting/IoFatValuePresentation.cs @@ -0,0 +1,32 @@ +namespace ArIED61850Tester.Services.IoTesting; + +internal static class IoFatValuePresentation +{ + /// + /// FAT uses one stable Boolean presentation from the first frame onward. Formatting-only + /// changes such as false -> False or true -> True must never look like process changes. + /// Non-Boolean IEC values (Open/Closed, DbPos, enum text, analog values) are preserved. + /// + internal static string Canonicalize(string? value) + { + var text = (value ?? string.Empty).Trim(); + if (text.Length == 0) + return "-"; + + if (bool.TryParse(text, out var boolean)) + return boolean ? "True" : "False"; + + return text; + } + + internal static bool IsFormattingOnlyBooleanChange(string? left, string? right) + { + if (!bool.TryParse((left ?? string.Empty).Trim(), out var leftBoolean) || + !bool.TryParse((right ?? string.Empty).Trim(), out var rightBoolean)) + { + return false; + } + + return leftBoolean == rightBoolean; + } +} From ca34d329f2044cf1dc1fb95a349ed3ed723332e0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 6 Sep 2026 05:30:41 +0700 Subject: [PATCH 05/16] Project authoritative runtime updates into FAT --- MainWindow.IoFatRuntimeAuthority.cs | 196 ++++++++++++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 MainWindow.IoFatRuntimeAuthority.cs diff --git a/MainWindow.IoFatRuntimeAuthority.cs b/MainWindow.IoFatRuntimeAuthority.cs new file mode 100644 index 000000000..67c5fb746 --- /dev/null +++ b/MainWindow.IoFatRuntimeAuthority.cs @@ -0,0 +1,196 @@ +using System.Collections.Concurrent; +using System.Windows; +using System.Windows.Threading; +using ArIED61850Tester.Models; +using ArIED61850Tester.Models.IoTesting; +using ArIED61850Tester.Services.IoTesting; + +namespace ArIED61850Tester; + +public partial class MainWindow +{ + private const int IoFatRuntimeProjectionDrainLimit = 128; + private static readonly bool IoFatRuntimeAuthorityRegistered = RegisterIoFatRuntimeAuthority(); + + private readonly ConcurrentDictionary _ioFatPendingRuntimeSnapshots = + new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary> _ioFatRuntimePointIndex = + new(StringComparer.OrdinalIgnoreCase); + private IoTestProject? _ioFatRuntimeIndexedProject; + private int _ioFatRuntimeIndexedPointCount = -1; + private int _ioFatRuntimeProjectionScheduled; + private bool _ioFatRuntimeAuthorityAttached; + + private static bool RegisterIoFatRuntimeAuthority() + { + EventManager.RegisterClassHandler( + typeof(MainWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(IoFatRuntimeAuthority_Loaded)); + return true; + } + + private static void IoFatRuntimeAuthority_Loaded(object sender, RoutedEventArgs e) + { + if (sender is MainWindow window) + window.AttachIoFatRuntimeAuthority(); + } + + private void AttachIoFatRuntimeAuthority() + { + if (_ioFatRuntimeAuthorityAttached) + return; + + _ioFatRuntimeAuthorityAttached = true; + _runtime.PointUpdated += Runtime_IoFatRuntimeAuthorityPointUpdated; + Closed += IoFatRuntimeAuthorityWindow_Closed; + } + + private void IoFatRuntimeAuthorityWindow_Closed(object? sender, EventArgs e) + { + Closed -= IoFatRuntimeAuthorityWindow_Closed; + _runtime.PointUpdated -= Runtime_IoFatRuntimeAuthorityPointUpdated; + _ioFatPendingRuntimeSnapshots.Clear(); + _ioFatRuntimePointIndex.Clear(); + _ioFatRuntimeIndexedProject = null; + _ioFatRuntimeIndexedPointCount = -1; + _ioFatRuntimeAuthorityAttached = false; + } + + private void Runtime_IoFatRuntimeAuthorityPointUpdated(Iec61850PointSnapshot snapshot) + { + if (snapshot?.Point == null || _ioFatSelectionBridgeProject == null) + return; + + var key = IoFatRuntimeKey(snapshot.Point.DeviceId, snapshot.Point.IecReference); + if (key.Length == 0) + return; + + _ioFatPendingRuntimeSnapshots.AddOrUpdate( + key, + snapshot, + (_, current) => snapshot.Sequence >= current.Sequence ? snapshot : current); + ScheduleIoFatRuntimeProjection(); + } + + private void ScheduleIoFatRuntimeProjection() + { + if (Interlocked.Exchange(ref _ioFatRuntimeProjectionScheduled, 1) != 0) + return; + + Dispatcher.BeginInvoke( + new Action(DrainIoFatRuntimeProjection), + DispatcherPriority.Background); + } + + private void DrainIoFatRuntimeProjection() + { + try + { + EnsureIoFatRuntimePointIndex(); + var processed = 0; + while (processed < IoFatRuntimeProjectionDrainLimit) + { + var key = _ioFatPendingRuntimeSnapshots.Keys.FirstOrDefault(); + if (key == null || !_ioFatPendingRuntimeSnapshots.TryRemove(key, out var snapshot)) + break; + + ApplyIoFatRuntimeSnapshot(key, snapshot); + processed++; + } + } + finally + { + Interlocked.Exchange(ref _ioFatRuntimeProjectionScheduled, 0); + if (!_ioFatPendingRuntimeSnapshots.IsEmpty && _ioFatSelectionBridgeProject != null) + ScheduleIoFatRuntimeProjection(); + } + } + + private void EnsureIoFatRuntimePointIndex() + { + var project = _ioFatSelectionBridgeProject; + if (project == null) + { + _ioFatRuntimePointIndex.Clear(); + _ioFatRuntimeIndexedProject = null; + _ioFatRuntimeIndexedPointCount = -1; + return; + } + + var pointCount = project.Ieds.Sum(ied => ied.TestPoints.Count); + if (ReferenceEquals(project, _ioFatRuntimeIndexedProject) && + pointCount == _ioFatRuntimeIndexedPointCount) + { + return; + } + + _ioFatRuntimePointIndex.Clear(); + foreach (var ied in project.Ieds) + { + var device = ResolveIoTestDevice(ied.LiveDeviceId) + ?? ResolveIoTestDevice(ied.IpAddress) + ?? ResolveIoTestDevice(ied.IedName); + if (device == null) + continue; + + foreach (var point in ied.TestPoints) + { + foreach (var reference in IoTestLiveBindingService.ImportedReferences(point)) + AddIoFatRuntimePointIndex(device.DeviceId, reference, point); + + if (!string.IsNullOrWhiteSpace(point.LiveSignalReference)) + AddIoFatRuntimePointIndex(device.DeviceId, point.LiveSignalReference, point); + } + } + + _ioFatRuntimeIndexedProject = project; + _ioFatRuntimeIndexedPointCount = pointCount; + } + + private void AddIoFatRuntimePointIndex(string? deviceId, string? reference, IoTestPointPlan point) + { + var key = IoFatRuntimeKey(deviceId, reference); + if (key.Length == 0) + return; + + if (!_ioFatRuntimePointIndex.TryGetValue(key, out var points)) + { + points = new List(); + _ioFatRuntimePointIndex[key] = points; + } + + if (!points.Contains(point)) + points.Add(point); + } + + private void ApplyIoFatRuntimeSnapshot(string key, Iec61850PointSnapshot snapshot) + { + if (!_ioFatRuntimePointIndex.TryGetValue(key, out var points)) + return; + + var value = IoFatValuePresentation.Canonicalize(snapshot.Value); + var quality = string.IsNullOrWhiteSpace(snapshot.Quality) ? "Unknown" : snapshot.Quality.Trim(); + var source = string.IsNullOrWhiteSpace(snapshot.SourceMode) ? "Unknown" : snapshot.SourceMode.Trim(); + var iedTimestamp = string.IsNullOrWhiteSpace(snapshot.DeviceTimestamp) || snapshot.DeviceTimestamp == "-" + ? "—" + : snapshot.DeviceTimestamp.Trim(); + + foreach (var point in points) + { + point.Runtime.CurrentValue = value; + point.Runtime.CurrentQuality = quality; + point.Runtime.CurrentSource = source; + point.Runtime.CurrentIedTimestamp = iedTimestamp; + } + } + + private static string IoFatRuntimeKey(string? deviceId, string? reference) + { + var id = (deviceId ?? string.Empty).Trim(); + var normalizedReference = IoTestLiveBindingService.NormalizeReference(reference); + return id.Length == 0 || normalizedReference.Length == 0 + ? string.Empty + : id + "|" + normalizedReference; + } +} From edf003a99502854cce86fea75c5ae76c5ea748e9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 6 Sep 2026 05:32:35 +0700 Subject: [PATCH 06/16] Stabilize FAT value presentation without virtualization changes --- IoListTestingWindow.P0Presentation.cs | 55 +++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 IoListTestingWindow.P0Presentation.cs diff --git a/IoListTestingWindow.P0Presentation.cs b/IoListTestingWindow.P0Presentation.cs new file mode 100644 index 000000000..854266352 --- /dev/null +++ b/IoListTestingWindow.P0Presentation.cs @@ -0,0 +1,55 @@ +using System.Windows; +using System.Windows.Controls; +using ArIED61850Tester.Services.IoTesting; + +namespace ArIED61850Tester; + +public partial class IoListTestingWindow +{ + private static readonly bool P0FatPresentationRegistered = RegisterP0FatPresentation(); + private bool _p0FatPresentationInstalled; + + private static bool RegisterP0FatPresentation() + { + EventManager.RegisterClassHandler( + typeof(IoListTestingWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(P0FatPresentation_Loaded)); + EventManager.RegisterClassHandler( + typeof(Button), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(P0FatCaptureButton_Loaded)); + return true; + } + + private static void P0FatPresentation_Loaded(object sender, RoutedEventArgs e) + { + if (sender is not IoListTestingWindow window || window._p0FatPresentationInstalled) + return; + + window._p0FatPresentationInstalled = true; + + // The bind-time image may come directly from ARIEC as lowercase Boolean text. + // Canonicalize it before the operator starts interacting with FAT. Subsequent live + // snapshots pass through the same formatter in MainWindow.IoFatRuntimeAuthority. + foreach (var point in window.Project.Ieds.SelectMany(ied => ied.TestPoints)) + point.Runtime.CurrentValue = IoFatValuePresentation.Canonicalize(point.Runtime.CurrentValue); + } + + private static void P0FatCaptureButton_Loaded(object sender, RoutedEventArgs e) + { + if (sender is not Button button || + !Equals(button.Content, "✓ Capture") || + Window.GetWindow(button) is not IoListTestingWindow) + { + return; + } + + // Analog Value 1 / Value 2 is automatic in the normal FAT workflow. Keep the + // explicit Recapture context-menu path as an audit/operator override, but never + // expose a per-cell manual Capture button. + button.Visibility = Visibility.Collapsed; + button.IsTabStop = false; + button.IsHitTestVisible = false; + } +} From c0f57383acd613b9be31a4a5a8a387885f70f428 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 6 Sep 2026 05:32:50 +0700 Subject: [PATCH 07/16] Initialize Engineering command checks once without UI polling --- MainWindow.P0CommandDefaults.cs | 115 ++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 MainWindow.P0CommandDefaults.cs diff --git a/MainWindow.P0CommandDefaults.cs b/MainWindow.P0CommandDefaults.cs new file mode 100644 index 000000000..80647136b --- /dev/null +++ b/MainWindow.P0CommandDefaults.cs @@ -0,0 +1,115 @@ +using System.Collections.Specialized; +using System.Windows; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester; + +public partial class MainWindow +{ + private static readonly bool P0CommandDefaultsRegistered = RegisterP0CommandDefaults(); + private readonly HashSet _p0CommandDefaultDevices = new(); + private readonly HashSet _p0CommandDefaultsInitialized = new(); + private bool _p0CommandDefaultsAttached; + + private static bool RegisterP0CommandDefaults() + { + EventManager.RegisterClassHandler( + typeof(MainWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(P0CommandDefaults_Loaded)); + return true; + } + + private static void P0CommandDefaults_Loaded(object sender, RoutedEventArgs e) + { + if (sender is MainWindow window) + window.AttachP0CommandDefaults(); + } + + private void AttachP0CommandDefaults() + { + if (_p0CommandDefaultsAttached) + return; + + _p0CommandDefaultsAttached = true; + Devices.CollectionChanged += P0CommandDefaults_DevicesChanged; + foreach (var device in Devices) + TrackP0CommandDefaultsDevice(device); + Closed += P0CommandDefaults_WindowClosed; + } + + private void P0CommandDefaults_WindowClosed(object? sender, EventArgs e) + { + Closed -= P0CommandDefaults_WindowClosed; + Devices.CollectionChanged -= P0CommandDefaults_DevicesChanged; + foreach (var device in _p0CommandDefaultDevices.ToArray()) + UntrackP0CommandDefaultsDevice(device); + _p0CommandDefaultsInitialized.Clear(); + _p0CommandDefaultsAttached = false; + } + + private void P0CommandDefaults_DevicesChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + if (e.OldItems != null) + { + foreach (var device in e.OldItems.OfType()) + UntrackP0CommandDefaultsDevice(device); + } + + if (e.NewItems != null) + { + foreach (var device in e.NewItems.OfType()) + TrackP0CommandDefaultsDevice(device); + } + + if (e.Action == NotifyCollectionChangedAction.Reset) + { + foreach (var device in _p0CommandDefaultDevices.ToArray()) + { + if (!Devices.Contains(device)) + UntrackP0CommandDefaultsDevice(device); + } + foreach (var device in Devices) + TrackP0CommandDefaultsDevice(device); + } + } + + private void TrackP0CommandDefaultsDevice(Iec61850MonitorDevice device) + { + if (!_p0CommandDefaultDevices.Add(device)) + { + ApplyP0CommandDefaults(device.CommandSignals); + return; + } + + device.CommandSignals.CollectionChanged += P0CommandDefaults_CommandSignalsChanged; + ApplyP0CommandDefaults(device.CommandSignals); + } + + private void UntrackP0CommandDefaultsDevice(Iec61850MonitorDevice device) + { + if (!_p0CommandDefaultDevices.Remove(device)) + return; + device.CommandSignals.CollectionChanged -= P0CommandDefaults_CommandSignalsChanged; + } + + private void P0CommandDefaults_CommandSignalsChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + if (sender is IEnumerable signals) + ApplyP0CommandDefaults(signals); + } + + private void ApplyP0CommandDefaults(IEnumerable signals) + { + foreach (var signal in signals) + { + // Initialize once per SignalDefinition object. If the operator later disables + // Interlock or Synchro, projection refreshes must respect that explicit choice. + if (!_p0CommandDefaultsInitialized.Add(signal)) + continue; + + signal.ControlInterlockCheck = true; + signal.ControlSynchroCheck = true; + } + } +} From 924c48ff39ac8d09268201b24d23e4b07ee49e21 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 6 Sep 2026 05:33:19 +0700 Subject: [PATCH 08/16] Reindex FAT runtime bindings when live device attachment changes --- MainWindow.IoFatRuntimeAuthority.cs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/MainWindow.IoFatRuntimeAuthority.cs b/MainWindow.IoFatRuntimeAuthority.cs index 67c5fb746..9f0b34318 100644 --- a/MainWindow.IoFatRuntimeAuthority.cs +++ b/MainWindow.IoFatRuntimeAuthority.cs @@ -107,7 +107,7 @@ private void DrainIoFatRuntimeProjection() } } - private void EnsureIoFatRuntimePointIndex() + private void EnsureIoFatRuntimePointIndex(bool force = false) { var project = _ioFatSelectionBridgeProject; if (project == null) @@ -119,7 +119,8 @@ private void EnsureIoFatRuntimePointIndex() } var pointCount = project.Ieds.Sum(ied => ied.TestPoints.Count); - if (ReferenceEquals(project, _ioFatRuntimeIndexedProject) && + if (!force && + ReferenceEquals(project, _ioFatRuntimeIndexedProject) && pointCount == _ioFatRuntimeIndexedPointCount) { return; @@ -167,7 +168,15 @@ private void AddIoFatRuntimePointIndex(string? deviceId, string? reference, IoTe private void ApplyIoFatRuntimeSnapshot(string key, Iec61850PointSnapshot snapshot) { if (!_ioFatRuntimePointIndex.TryGetValue(key, out var points)) - return; + { + // A FAT project can be attached before the corresponding Engineering device + // or live binding has finished. Rebuild once on the first unmatched runtime + // sample so newly attached device/reference identities become visible without + // polling, per-cell subscriptions, or collection scans on every update. + EnsureIoFatRuntimePointIndex(force: true); + if (!_ioFatRuntimePointIndex.TryGetValue(key, out points)) + return; + } var value = IoFatValuePresentation.Canonicalize(snapshot.Value); var quality = string.IsNullOrWhiteSpace(snapshot.Quality) ? "Unknown" : snapshot.Quality.Trim(); From 9cff6e5a7886e5e4bfcbbb5cc6b1b77e8f7b8c7c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 6 Sep 2026 05:33:34 +0700 Subject: [PATCH 09/16] Lock P0 analog auto-capture and direct Capture removal --- tests/ARSAS.Tests/IoListFatFieldUxRegressionTests.cs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/ARSAS.Tests/IoListFatFieldUxRegressionTests.cs b/tests/ARSAS.Tests/IoListFatFieldUxRegressionTests.cs index a340d4dfc..143b0dd6f 100644 --- a/tests/ARSAS.Tests/IoListFatFieldUxRegressionTests.cs +++ b/tests/ARSAS.Tests/IoListFatFieldUxRegressionTests.cs @@ -15,14 +15,15 @@ public void FatCommandDefaults_EnableInterlockAndSynchronismOnlyOnFirstAttach() [Fact] public void AutomaticAnalogValuePair_DoesNotExposeNormalCaptureButtons() { - var ux = File.ReadAllText(FindRepoFile("IoListTestingWindow.FatV2Ux.cs")); + var presentation = File.ReadAllText(FindRepoFile("IoListTestingWindow.P0Presentation.cs")); var capture = File.ReadAllText(FindRepoFile("Services/IoTesting/FatAutoCaptureCoordinator.cs")); - Assert.Contains("nameof(IoTestPointPlan.IsOperatorSnapshot)", ux, StringComparison.Ordinal); - Assert.Contains("CanCaptureOperatorSnapshot", ux, StringComparison.Ordinal); + Assert.Contains("Equals(button.Content, \"✓ Capture\")", presentation, StringComparison.Ordinal); + Assert.Contains("button.Visibility = Visibility.Collapsed;", presentation, StringComparison.Ordinal); + Assert.Contains("Static DataSet", capture, StringComparison.Ordinal); + Assert.Contains("Report-backed analog Value 1 captured automatically", capture, StringComparison.Ordinal); + Assert.Contains("Report-backed analog Value 2 captured automatically", capture, StringComparison.Ordinal); Assert.Contains("AnalogStableSampleCount = 3", capture, StringComparison.Ordinal); - Assert.Contains("Stable analog Value 1 captured", capture, StringComparison.Ordinal); - Assert.Contains("Stable analog Value 2 captured", capture, StringComparison.Ordinal); } [Fact] From 7f466df07253d5aca05a2af39f4e07b204141d26 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 6 Sep 2026 05:34:22 +0700 Subject: [PATCH 10/16] Add P0 Build 1888 recovery regression gates --- .../P0Fat1888RecoveryRegressionTests.cs | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 tests/ARSAS.Tests/P0Fat1888RecoveryRegressionTests.cs diff --git a/tests/ARSAS.Tests/P0Fat1888RecoveryRegressionTests.cs b/tests/ARSAS.Tests/P0Fat1888RecoveryRegressionTests.cs new file mode 100644 index 000000000..47d356aeb --- /dev/null +++ b/tests/ARSAS.Tests/P0Fat1888RecoveryRegressionTests.cs @@ -0,0 +1,100 @@ +namespace ARSAS.Tests; + +public sealed class P0Fat1888RecoveryRegressionTests +{ + [Fact] + public void FatLiveProjection_IsEventDrivenAndNeverChangesVirtualizationMode() + { + var runtime = File.ReadAllText(FindRepoFile("MainWindow.IoFatRuntimeAuthority.cs")); + var presentation = File.ReadAllText(FindRepoFile("IoListTestingWindow.P0Presentation.cs")); + + Assert.Contains("_runtime.PointUpdated += Runtime_IoFatRuntimeAuthorityPointUpdated", runtime, StringComparison.Ordinal); + Assert.Contains("IoFatValuePresentation.Canonicalize(snapshot.Value)", runtime, StringComparison.Ordinal); + Assert.DoesNotContain("DispatcherTimer", runtime, StringComparison.Ordinal); + Assert.DoesNotContain("SetVirtualizationMode", runtime, StringComparison.Ordinal); + Assert.DoesNotContain("SetVirtualizationMode", presentation, StringComparison.Ordinal); + Assert.DoesNotContain("VirtualizationMode", presentation, StringComparison.Ordinal); + } + + [Fact] + public void BooleanPresentation_IsCanonicalTrueFalseFromFatBoundary() + { + var source = File.ReadAllText(FindRepoFile("Services/IoTesting/IoFatValuePresentation.cs")); + + Assert.Contains("return boolean ? \"True\" : \"False\";", source, StringComparison.Ordinal); + Assert.Contains("bool.TryParse", source, StringComparison.Ordinal); + Assert.Contains("return text;", source, StringComparison.Ordinal); + } + + [Fact] + public void EngineeringCommandChecks_AreInitializedOnceWithoutTimerPolling() + { + var source = File.ReadAllText(FindRepoFile("MainWindow.P0CommandDefaults.cs")); + + Assert.Contains("_p0CommandDefaultsInitialized.Add(signal)", source, StringComparison.Ordinal); + Assert.Contains("signal.ControlInterlockCheck = true;", source, StringComparison.Ordinal); + Assert.Contains("signal.ControlSynchroCheck = true;", source, StringComparison.Ordinal); + Assert.Contains("CommandSignals.CollectionChanged", source, StringComparison.Ordinal); + Assert.DoesNotContain("DispatcherTimer", source, StringComparison.Ordinal); + Assert.DoesNotContain("_uiFlushTimer", source, StringComparison.Ordinal); + } + + [Fact] + public void FatFirstStaticDataSetSelection_CannotBeDemotedToHybridByGenericMarker() + { + var source = File.ReadAllText(FindRepoFile("MainWindow.SharedSclWorkspace.cs")); + + Assert.Contains("_pendingSharedSclSelectionMode == SclSignalSelectionMode.StaticDataSet", source, StringComparison.Ordinal); + Assert.Contains("ApplyStaticDataSetSelection(device);", source, StringComparison.Ordinal); + Assert.Contains("if (IsSharedStaticDataSetAuthority(device))", source, StringComparison.Ordinal); + Assert.Contains("Iec61850MonitoringModeRegistry.UseStaticDataSetReportOnly(device);", source, StringComparison.Ordinal); + Assert.Contains("Iec61850MonitoringModeRegistry.UseHybrid(device);", source, StringComparison.Ordinal); + } + + [Fact] + public void ReportBackedAnalog_AutoCapturesWhilePollingRetainsSettlingGate() + { + var source = File.ReadAllText(FindRepoFile("Services/IoTesting/FatAutoCaptureCoordinator.cs")); + + Assert.Contains("IsAuthoritativeReportObservation(observation)", source, StringComparison.Ordinal); + Assert.Contains("Report-backed analog Value 1 captured automatically", source, StringComparison.Ordinal); + Assert.Contains("Report-backed analog Value 2 captured automatically", source, StringComparison.Ordinal); + Assert.Contains("AnalogStableSampleCount = 3", source, StringComparison.Ordinal); + Assert.Contains("next.Count < AnalogStableSampleCount", source, StringComparison.Ordinal); + } + + [Fact] + public void P0Patch_DoesNotTouchGoldenAriecPinOrIntroduceLegacyScrollHack() + { + var lockFile = File.ReadAllText(FindRepoFile("engines/ARIEC61850.lock.json")); + var p0Files = new[] + { + "MainWindow.IoFatRuntimeAuthority.cs", + "IoListTestingWindow.P0Presentation.cs", + "MainWindow.P0CommandDefaults.cs" + }; + + Assert.Contains("11ab2304482600c19ba979f4fc9021ddb46b9af9", lockFile, StringComparison.OrdinalIgnoreCase); + foreach (var path in p0Files) + { + var source = File.ReadAllText(FindRepoFile(path)); + Assert.DoesNotContain("VirtualizingPanel.SetVirtualizationMode", source, StringComparison.Ordinal); + Assert.DoesNotContain("VirtualizationMode.Standard", source, StringComparison.Ordinal); + } + } + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + + throw new FileNotFoundException( + $"Could not locate repository file '{relativePath}' from '{AppContext.BaseDirectory}'."); + } +} From 6d04bb46918994ebbf3016091f5c04585a39b164 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 6 Sep 2026 05:36:20 +0700 Subject: [PATCH 11/16] Expose shared Static DataSet authority to FAT presentation --- MainWindow.P0FatWorkspaceAuthority.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 MainWindow.P0FatWorkspaceAuthority.cs diff --git a/MainWindow.P0FatWorkspaceAuthority.cs b/MainWindow.P0FatWorkspaceAuthority.cs new file mode 100644 index 000000000..5a11d2213 --- /dev/null +++ b/MainWindow.P0FatWorkspaceAuthority.cs @@ -0,0 +1,15 @@ +using ArIED61850Tester.Models.IoTesting; + +namespace ArIED61850Tester; + +public partial class MainWindow +{ + internal bool HasP0SharedStaticDataSetAuthority(IoTestIedPlan ied) + { + ArgumentNullException.ThrowIfNull(ied); + var device = ResolveIoTestDevice(ied.LiveDeviceId) + ?? ResolveIoTestDevice(ied.IpAddress) + ?? ResolveIoTestDevice(ied.IedName); + return device is not null && IsSharedStaticDataSetAuthority(device); + } +} From baf0bb685a740c9cf2151a2cea835f8e6539319c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 6 Sep 2026 05:36:39 +0700 Subject: [PATCH 12/16] Restore Static DataSet FAT row membership before first view refresh --- IoListTestingWindow.P0Presentation.cs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/IoListTestingWindow.P0Presentation.cs b/IoListTestingWindow.P0Presentation.cs index 854266352..a41512124 100644 --- a/IoListTestingWindow.P0Presentation.cs +++ b/IoListTestingWindow.P0Presentation.cs @@ -29,6 +29,23 @@ private static void P0FatPresentation_Loaded(object sender, RoutedEventArgs e) window._p0FatPresentationInstalled = true; + // A same-source snapshot can contain stale WorkspaceSelected=false values from an + // earlier FAT session. When the current shared Engineering device explicitly owns + // Static DataSet authority, that authority is stronger than the stale snapshot: the + // complete imported static membership is the workspace selection. Repair it before + // FAT V2 installs its CollectionView filter, so rows never paint and then disappear. + if (window.Owner is MainWindow engineeringWindow) + { + foreach (var ied in window.Project.Ieds) + { + if (!engineeringWindow.HasP0SharedStaticDataSetAuthority(ied)) + continue; + + foreach (var point in ied.TestPoints.Where(point => point.ImportReady)) + point.WorkspaceSelected = true; + } + } + // The bind-time image may come directly from ARIEC as lowercase Boolean text. // Canonicalize it before the operator starts interacting with FAT. Subsequent live // snapshots pass through the same formatter in MainWindow.IoFatRuntimeAuthority. From 79008b8975fa401eed551f04dd3ffd1db368f44c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 6 Sep 2026 05:37:32 +0700 Subject: [PATCH 13/16] Restore Static DataSet FAT membership in one guarded batch --- MainWindow.P0FatWorkspaceAuthority.cs | 30 +++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/MainWindow.P0FatWorkspaceAuthority.cs b/MainWindow.P0FatWorkspaceAuthority.cs index 5a11d2213..03bc52bc9 100644 --- a/MainWindow.P0FatWorkspaceAuthority.cs +++ b/MainWindow.P0FatWorkspaceAuthority.cs @@ -4,12 +4,38 @@ namespace ArIED61850Tester; public partial class MainWindow { - internal bool HasP0SharedStaticDataSetAuthority(IoTestIedPlan ied) + internal bool RestoreP0SharedStaticDataSetMembership(IoTestIedPlan ied) { ArgumentNullException.ThrowIfNull(ied); var device = ResolveIoTestDevice(ied.LiveDeviceId) ?? ResolveIoTestDevice(ied.IpAddress) ?? ResolveIoTestDevice(ied.IedName); - return device is not null && IsSharedStaticDataSetAuthority(device); + if (device is null || !IsSharedStaticDataSetAuthority(device)) + return false; + + var missing = ied.TestPoints + .Where(point => point.ImportReady && !point.WorkspaceSelected) + .ToArray(); + if (missing.Length == 0) + return true; + + // Static DataSet is the current explicit shared workspace authority. A stale local + // snapshot must not filter those exact imported members out of FAT. Guard the batch + // so 50+ rows do not fan out into 50+ Engineering bridge/save/refresh operations. + _ioFatSelectionBridgeActive = true; + try + { + foreach (var point in missing) + point.WorkspaceSelected = true; + } + finally + { + _ioFatSelectionBridgeActive = false; + } + + ScheduleIoFatSelectionSave(device); + _loadedIoFatWindow?.Storage?.ScheduleSave(); + RaiseWorkspaceCounts(); + return true; } } From 2425c14015f45d7e08b62b5e9d03ebdffdca5018 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 6 Sep 2026 05:37:50 +0700 Subject: [PATCH 14/16] Use guarded Static DataSet membership repair before FAT filtering --- IoListTestingWindow.P0Presentation.cs | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/IoListTestingWindow.P0Presentation.cs b/IoListTestingWindow.P0Presentation.cs index a41512124..3286f7716 100644 --- a/IoListTestingWindow.P0Presentation.cs +++ b/IoListTestingWindow.P0Presentation.cs @@ -31,19 +31,13 @@ private static void P0FatPresentation_Loaded(object sender, RoutedEventArgs e) // A same-source snapshot can contain stale WorkspaceSelected=false values from an // earlier FAT session. When the current shared Engineering device explicitly owns - // Static DataSet authority, that authority is stronger than the stale snapshot: the - // complete imported static membership is the workspace selection. Repair it before - // FAT V2 installs its CollectionView filter, so rows never paint and then disappear. + // Static DataSet authority, that authority is stronger than the stale snapshot. Do + // one guarded batch repair before FAT V2 installs its CollectionView filter, so rows + // never paint and then disappear and the repair does not fan out one refresh per row. if (window.Owner is MainWindow engineeringWindow) { foreach (var ied in window.Project.Ieds) - { - if (!engineeringWindow.HasP0SharedStaticDataSetAuthority(ied)) - continue; - - foreach (var point in ied.TestPoints.Where(point => point.ImportReady)) - point.WorkspaceSelected = true; - } + engineeringWindow.RestoreP0SharedStaticDataSetMembership(ied); } // The bind-time image may come directly from ARIEC as lowercase Boolean text. From 955ff3a118f490f4f7d27e6e6b931bb7df65fdb3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 6 Sep 2026 05:40:46 +0700 Subject: [PATCH 15/16] Align FAT v2 tests with report-backed analog auto-capture --- tests/ARSAS.Tests/IoFatV2WorkspaceRegressionTests.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/ARSAS.Tests/IoFatV2WorkspaceRegressionTests.cs b/tests/ARSAS.Tests/IoFatV2WorkspaceRegressionTests.cs index 3042e168a..c4d8bd8f1 100644 --- a/tests/ARSAS.Tests/IoFatV2WorkspaceRegressionTests.cs +++ b/tests/ARSAS.Tests/IoFatV2WorkspaceRegressionTests.cs @@ -52,6 +52,10 @@ public void OperatorSnapshot_Value1Value2Recapture_IsJournalFirstAndKeepsSession public void OperatorSnapshot_JournalFailure_DoesNotPromoteCurrentEvidencePointer() { var fixture = ManualFixture(journalFactory: (_, _, _, _) => new FailAfterStartupJournal()); + // This test exercises the explicit operator/Recapture override. Poll-backed analog + // keeps the settling gate, so startup does not auto-promote Value 1 before the + // synthetic journal failure is exercised. + fixture.LivePoint.SourceMode = "MMS"; using var controller = fixture.Controller; Assert.True(controller.Start(fixture.Ied).Succeeded); @@ -69,6 +73,9 @@ public void OperatorSnapshot_LiveRefreshDoesNotRaiseSessionProgressForEverySampl using var controller = fixture.Controller; Assert.True(controller.Start(fixture.Ied).Succeeded); + // BRCB startup owns authoritative Value 1. A subsequent sample still inside the + // analog settling band updates Live Value but must not create Value 2 or fan out + // session-progress notifications for every measurement refresh. var sessionNotifications = 0; controller.PropertyChanged += (_, _) => sessionNotifications++; controller.Enqueue(new Iec61850EventEntry @@ -82,14 +89,15 @@ public void OperatorSnapshot_LiveRefreshDoesNotRaiseSessionProgressForEverySampl SignalName = fixture.LivePoint.SignalName, IecReference = fixture.LivePoint.IecReference, OldValue = "12.34", - NewValue = "12.35", + NewValue = "12.3401", Quality = "Good", SourceMode = "BRCB", Reason = "periodic-refresh" }); - Assert.Equal("12.35", fixture.Point.Runtime.CurrentValue); + Assert.Equal("12.3401", fixture.Point.Runtime.CurrentValue); Assert.Equal(0, sessionNotifications); + Assert.Null(fixture.Point.Runtime.Value2Evidence); } [Fact] From 7f4841e81d508a39d81c79b04ca0eac747c2f2e0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 6 Sep 2026 05:41:07 +0700 Subject: [PATCH 16/16] Exercise report-backed analog auto-capture behavior --- .../FatAutoCaptureCoordinatorP0Tests.cs | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 tests/ARSAS.Tests/FatAutoCaptureCoordinatorP0Tests.cs diff --git a/tests/ARSAS.Tests/FatAutoCaptureCoordinatorP0Tests.cs b/tests/ARSAS.Tests/FatAutoCaptureCoordinatorP0Tests.cs new file mode 100644 index 000000000..cd0a969a2 --- /dev/null +++ b/tests/ARSAS.Tests/FatAutoCaptureCoordinatorP0Tests.cs @@ -0,0 +1,78 @@ +using ArIED61850Tester.Models.IoTesting; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class FatAutoCaptureCoordinatorP0Tests +{ + [Fact] + public void StaticDataSetAnalog_CapturesValue1AndValue2FromAuthoritativeReports() + { + var point = AnalogPoint(); + var coordinator = new FatAutoCaptureCoordinator(); + + var first = coordinator.Observe(point, Observation("12.34", 1, "Static DataSet: StaticBrcb")); + Assert.NotNull(first.Evidence); + Assert.Equal(FatValueSlot.Value1, first.Evidence!.Slot); + Assert.Equal("12.34", first.Evidence.RawValue); + point.Runtime.SetFatValueEvidence(first.Evidence); + + var second = coordinator.Observe(point, Observation("18.90", 2, "BRCB InformationReport")); + Assert.NotNull(second.Evidence); + Assert.Equal(FatValueSlot.Value2, second.Evidence!.Slot); + Assert.Equal("18.90", second.Evidence.RawValue); + } + + [Fact] + public void PollBackedAnalog_StillRequiresStableSampleWindow() + { + var point = AnalogPoint(); + var coordinator = new FatAutoCaptureCoordinator(); + + Assert.Null(coordinator.Observe(point, Observation("12.3400", 1, "MMS" )).Evidence); + Assert.Null(coordinator.Observe(point, Observation("12.3401", 2, "MMS" )).Evidence); + var third = coordinator.Observe(point, Observation("12.3402", 3, "MMS" )); + + Assert.NotNull(third.Evidence); + Assert.Equal(FatValueSlot.Value1, third.Evidence!.Slot); + } + + [Theory] + [InlineData("true", "True")] + [InlineData("TRUE", "True")] + [InlineData("false", "False")] + [InlineData("False", "False")] + [InlineData("Closed", "Closed")] + [InlineData("12.34 A", "12.34 A")] + public void FatPresentation_CanonicalizesOnlyBooleanText(string raw, string expected) + => Assert.Equal(expected, IoFatValuePresentation.Canonicalize(raw)); + + private static IoTestPointPlan AnalogPoint() + => new() + { + TestPointId = "P0-ANALOG-1", + IedName = "IED1", + IpAddress = "192.0.2.1", + SignalName = "Current A", + ObjectReference = "IED1MEAS/MMXU1.A.phsA.cVal.mag.f", + FunctionalConstraint = "MX", + ExpectedOnText = "Value 1", + ExpectedOffText = "Value 2", + DataType = "FLOAT32", + SignalKind = FatSignalKind.Analog, + CaptureMode = FatCaptureMode.OperatorSnapshot, + ImportReady = true, + BindingStatus = "SCL_DATASET_AUTHORITY" + }; + + private static IoTestObservation Observation(string value, long sequence, string source) + => new( + null, + value, + new DateTimeOffset(2026, 9, 6, 1, 0, 0, TimeSpan.Zero).AddSeconds(sequence), + new DateTimeOffset(2026, 9, 6, 1, 0, 0, TimeSpan.Zero).AddSeconds(sequence).AddMilliseconds(-2), + "Good", + source, + sequence, + 1); +}