diff --git a/IoListTestingWindow.CommandPanel.cs b/IoListTestingWindow.CommandPanel.cs index 034fc1017..d08372f69 100644 --- a/IoListTestingWindow.CommandPanel.cs +++ b/IoListTestingWindow.CommandPanel.cs @@ -1,5 +1,6 @@ using System.Collections.Specialized; using System.ComponentModel; +using System.Globalization; using System.Windows; using System.Windows.Controls; using System.Windows.Controls.Primitives; @@ -12,11 +13,60 @@ namespace ArIED61850Tester; public partial class IoListTestingWindow { + private sealed class FatCommandRowView + { + public required Border Container { get; init; } + public required Grid Grid { get; init; } + public required FrameworkElement Actions { get; set; } + } + + private sealed class FatCommandTextConverter : IValueConverter + { + public static FatCommandTextConverter Instance { get; } = new(); + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + var text = value?.ToString()?.Trim() ?? string.Empty; + return string.IsNullOrWhiteSpace(text) ? "—" : text; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => Binding.DoNothing; + } + + private sealed class FatCommandModelConverter : IValueConverter + { + public static FatCommandModelConverter Instance { get; } = new(); + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + => FatCommandModelText(value?.ToString()); + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => Binding.DoNothing; + } + + private sealed class FatCommandCanOperateConverter : IMultiValueConverter + { + public static FatCommandCanOperateConverter Instance { get; } = new(); + + public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) + { + var supportsOperate = values.ElementAtOrDefault(0) is true; + var isBusy = values.ElementAtOrDefault(1) is true; + return supportsOperate && !isBusy; + } + + public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) + => targetTypes.Select(_ => Binding.DoNothing).ToArray(); + } + private Border? _fatCommandPanelShell; private StackPanel? _fatCommandRows; private TextBlock? _fatCommandSummary; + private FrameworkElement? _fatCommandEmptyState; private Iec61850MonitorDevice? _fatCommandDevice; private readonly HashSet _fatCommandSubscribedSignals = new(); + private readonly Dictionary _fatCommandRowViews = new(); private bool _fatCommandPanelLifecycleInstalled; // Register at class level rather than overriding OnInitialized. IoListTestingWindow @@ -146,13 +196,13 @@ private async Task RefreshFatCommandPanelAsync() { DetachFatCommandDevice(); _fatCommandSummary.Text = "Engineering owner unavailable; control is disabled fail-closed."; - RebuildFatCommandRows(); + SynchronizeFatCommandRows(); return; } var device = engineeringWindow.ResolveIoFatCommandDevice(SelectedIed); AttachFatCommandDevice(device); - RebuildFatCommandRows(); + SynchronizeFatCommandRows(); if (device == null) { _fatCommandSummary.Text = "No shared Engineering IED is bound to the selected FAT device."; @@ -165,12 +215,12 @@ private async Task RefreshFatCommandPanelAsync() return; } - _fatCommandSummary.Text = $"{device.Name} · validating live ctlModel and command values…"; + _fatCommandSummary.Text = $"{device.Name} · validating live ctlModel and shared process values…"; try { await engineeringWindow.RefreshIoFatCommandValuesAsync(device); AttachFatCommandDevice(device); - RebuildFatCommandRows(); + SynchronizeFatCommandRows(); } catch (OperationCanceledException) { @@ -182,15 +232,16 @@ private async Task RefreshFatCommandPanelAsync() } } - private void AttachFatCommandDevice(Iec61850MonitorDevice? device) + private bool AttachFatCommandDevice(Iec61850MonitorDevice? device) { if (ReferenceEquals(_fatCommandDevice, device)) - return; + return false; DetachFatCommandDevice(); _fatCommandDevice = device; if (_fatCommandDevice != null) _fatCommandDevice.CommandSignals.CollectionChanged += FatCommandSignals_CollectionChanged; + return true; } private void DetachFatCommandDevice() @@ -205,71 +256,113 @@ private void DetachFatCommandDevice() } private void FatCommandSignals_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) - => Dispatcher.BeginInvoke(new Action(RebuildFatCommandRows), DispatcherPriority.Background); + => Dispatcher.BeginInvoke(new Action(SynchronizeFatCommandRows), DispatcherPriority.Background); private void FatCommandSignal_PropertyChanged(object? sender, PropertyChangedEventArgs e) { - if (e.PropertyName is nameof(SignalDefinition.ControlSetPointText) - or nameof(SignalDefinition.ControlInterlockCheck) - or nameof(SignalDefinition.ControlSynchroCheck) - or nameof(SignalDefinition.ControlTestMode)) - { + if (sender is not SignalDefinition signal) return; - } - if (e.PropertyName is nameof(SignalDefinition.ControlCurrentValue) - or nameof(SignalDefinition.ControlLastResult) - or nameof(SignalDefinition.ControlConfirmationPending) - or nameof(SignalDefinition.ControlCommandBusy) - or nameof(SignalDefinition.ControlInspectionBusy) - or nameof(SignalDefinition.ControlModelText) + // LIVE VALUE, result text and busy/enabled state are WPF bindings on the existing + // row instance. Only a semantic action-layout transition needs to replace the small + // action cell; the row and the rest of the panel remain untouched. + if (e.PropertyName is nameof(SignalDefinition.ControlConfirmationPending) or nameof(SignalDefinition.ControlCdc) - or nameof(SignalDefinition.ControlSupportsOperate)) + or nameof(SignalDefinition.ControlModelText) + or nameof(SignalDefinition.ControlModelResolved)) { - Dispatcher.BeginInvoke(new Action(RebuildFatCommandRows), DispatcherPriority.Background); + Dispatcher.BeginInvoke( + new Action(() => RefreshFatCommandActions(signal)), + DispatcherPriority.Background); } } - private void RebuildFatCommandRows() + private void SynchronizeFatCommandRows() { if (_fatCommandRows == null || _fatCommandSummary == null) return; - foreach (var signal in _fatCommandSubscribedSignals) - signal.PropertyChanged -= FatCommandSignal_PropertyChanged; - _fatCommandSubscribedSignals.Clear(); - _fatCommandRows.Children.Clear(); - var device = _fatCommandDevice; - if (device == null) + var commands = device?.CommandSignals.ToArray() ?? Array.Empty(); + var commandSet = commands.ToHashSet(); + + foreach (var stale in _fatCommandRowViews.Keys.Where(signal => !commandSet.Contains(signal)).ToArray()) + RemoveFatCommandRow(stale); + + if (commands.Length == 0) { - _fatCommandRows.Children.Add(FatCommandEmptyText("No FAT command device selected.")); + _fatCommandSummary.Text = device == null + ? "No FAT command device selected." + : $"{device.Name} · no operable control is proven by live ctlModel. Status-only controls remain read-only."; + + EnsureFatCommandEmptyState(device == null + ? "No FAT command device selected." + : "No command action is available. Controls appear only after live ctlModel proves Direct/SBO operation; StatusOnly and unsupported generic types stay fail-closed."); return; } - var commands = device.CommandSignals.ToArray(); - _fatCommandSummary.Text = commands.Length == 0 - ? $"{device.Name} · no operable control is proven by live ctlModel. Status-only controls remain read-only." - : $"{device.Name} · {commands.Length} operable DataSet control(s) · shared Engineering command backend"; + RemoveFatCommandEmptyState(); + _fatCommandSummary.Text = $"{device!.Name} · {commands.Length} operable DataSet control(s) · shared Engineering command backend"; - if (commands.Length == 0) + for (var index = 0; index < commands.Length; index++) { - _fatCommandRows.Children.Add(FatCommandEmptyText( - "No command action is available. Controls appear only after live ctlModel proves Direct/SBO operation; StatusOnly and unsupported generic types stay fail-closed.")); - return; + var signal = commands[index]; + if (!_fatCommandRowViews.TryGetValue(signal, out var view)) + { + signal.PropertyChanged += FatCommandSignal_PropertyChanged; + _fatCommandSubscribedSignals.Add(signal); + view = BuildFatCommandRow(signal); + _fatCommandRowViews[signal] = view; + _fatCommandRows.Children.Insert(Math.Min(index, _fatCommandRows.Children.Count), view.Container); + } + + var currentIndex = _fatCommandRows.Children.IndexOf(view.Container); + if (currentIndex >= 0 && currentIndex != index) + { + _fatCommandRows.Children.RemoveAt(currentIndex); + _fatCommandRows.Children.Insert(Math.Min(index, _fatCommandRows.Children.Count), view.Container); + } } + } + + private void EnsureFatCommandEmptyState(string text) + { + if (_fatCommandRows == null) + return; - foreach (var signal in commands) + if (_fatCommandEmptyState is TextBlock existing) { - signal.PropertyChanged += FatCommandSignal_PropertyChanged; - _fatCommandSubscribedSignals.Add(signal); - _fatCommandRows.Children.Add(BuildFatCommandRow(signal)); + existing.Text = text; + if (!_fatCommandRows.Children.Contains(existing)) + _fatCommandRows.Children.Add(existing); + return; } + + _fatCommandEmptyState = FatCommandEmptyText(text); + _fatCommandRows.Children.Add(_fatCommandEmptyState); } - private FrameworkElement BuildFatCommandRow(SignalDefinition signal) + private void RemoveFatCommandEmptyState() { - var row = new Grid { MinWidth = 960 }; + if (_fatCommandRows == null || _fatCommandEmptyState == null) + return; + _fatCommandRows.Children.Remove(_fatCommandEmptyState); + _fatCommandEmptyState = null; + } + + private void RemoveFatCommandRow(SignalDefinition signal) + { + if (_fatCommandSubscribedSignals.Remove(signal)) + signal.PropertyChanged -= FatCommandSignal_PropertyChanged; + + if (!_fatCommandRowViews.Remove(signal, out var view) || _fatCommandRows == null) + return; + _fatCommandRows.Children.Remove(view.Container); + } + + private FatCommandRowView BuildFatCommandRow(SignalDefinition signal) + { + var row = new Grid { MinWidth = 960, DataContext = signal }; row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(2.25, GridUnitType.Star), MinWidth = 210 }); row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(0.8, GridUnitType.Star), MinWidth = 82 }); row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(0.72, GridUnitType.Star), MinWidth = 70 }); @@ -282,18 +375,48 @@ private FrameworkElement BuildFatCommandRow(SignalDefinition signal) reference.ToolTip = signal.ObjectReference; AddFatCommandCell(row, reference, 0); - var current = FatCommandText(signal.ControlCurrentValue, 11.0, FontWeights.SemiBold); - current.ToolTip = signal.ControlLastResult; + var current = FatCommandText("—", 11.0, FontWeights.SemiBold); + current.SetBinding(TextBlock.TextProperty, new Binding(nameof(SignalDefinition.ControlCurrentValue)) + { + Source = signal, + Mode = BindingMode.OneWay, + Converter = FatCommandTextConverter.Instance + }); + current.SetBinding(FrameworkElement.ToolTipProperty, new Binding(nameof(SignalDefinition.ControlLastResult)) + { + Source = signal, + Mode = BindingMode.OneWay + }); AddFatCommandCell(row, current, 1); - AddFatCommandCell(row, FatCommandText( - string.IsNullOrWhiteSpace(signal.ControlCdc) ? "—" : signal.ControlCdc, - 10.8, - FontWeights.SemiBold), 2); - AddFatCommandCell(row, FatCommandText(FatCommandModelText(signal.ControlModelText), 10.5, FontWeights.SemiBold), 3); + + var cdc = FatCommandText("—", 10.8, FontWeights.SemiBold); + cdc.SetBinding(TextBlock.TextProperty, new Binding(nameof(SignalDefinition.ControlCdc)) + { + Source = signal, + Mode = BindingMode.OneWay, + Converter = FatCommandTextConverter.Instance + }); + AddFatCommandCell(row, cdc, 2); + + var model = FatCommandText("Reading…", 10.5, FontWeights.SemiBold); + model.SetBinding(TextBlock.TextProperty, new Binding(nameof(SignalDefinition.ControlModelText)) + { + Source = signal, + Mode = BindingMode.OneWay, + Converter = FatCommandModelConverter.Instance + }); + model.SetBinding(FrameworkElement.ToolTipProperty, new Binding(nameof(SignalDefinition.ControlModelText)) + { + Source = signal, + Mode = BindingMode.OneWay + }); + AddFatCommandCell(row, model, 3); AddFatCommandCell(row, BuildFatCommandChecks(signal), 4); - AddFatCommandCell(row, BuildFatCommandActions(signal), 5); - return new Border + var actions = BuildFatCommandActions(signal); + AddFatCommandCell(row, actions, 5); + + var container = new Border { Background = Brushes.White, BorderBrush = FatCommandBrush("#E2E8F1"), @@ -303,6 +426,24 @@ private FrameworkElement BuildFatCommandRow(SignalDefinition signal) Margin = new Thickness(0, 0, 0, 6), Child = row }; + + return new FatCommandRowView + { + Container = container, + Grid = row, + Actions = actions + }; + } + + private void RefreshFatCommandActions(SignalDefinition signal) + { + if (!_fatCommandRowViews.TryGetValue(signal, out var view)) + return; + + var replacement = BuildFatCommandActions(signal); + view.Grid.Children.Remove(view.Actions); + AddFatCommandCell(view.Grid, replacement, 5); + view.Actions = replacement; } private FrameworkElement BuildFatCommandChecks(SignalDefinition signal) @@ -342,28 +483,25 @@ private FrameworkElement BuildFatCommandActions(SignalDefinition signal) if (signal.ControlConfirmationPending) { var confirm = FatCommandButton("Confirm", "PrimaryButton"); + BindFatCommandEnabled(confirm, signal); confirm.Click += async (_, _) => await ConfirmFatPositionControlAsync(signal); panel.Children.Add(confirm); var cancel = FatCommandButton("Cancel", "SoftButton"); cancel.Margin = new Thickness(6, 0, 0, 0); - cancel.Click += (_, _) => - { - signal.ClearControlConfirmation(); - RebuildFatCommandRows(); - }; + cancel.Click += (_, _) => signal.ClearControlConfirmation(); panel.Children.Add(cancel); } else { var open = FatCommandButton("Open", "CommandOpenButton"); - open.IsEnabled = signal.ControlSupportsOperate && !signal.ControlIsBusy; + BindFatCommandEnabled(open, signal); open.Click += (_, _) => StageFatPositionControl(signal, "Open [01]", "Open"); panel.Children.Add(open); var close = FatCommandButton("Close", "CommandCloseButton"); close.Margin = new Thickness(6, 0, 0, 0); - close.IsEnabled = signal.ControlSupportsOperate && !signal.ControlIsBusy; + BindFatCommandEnabled(close, signal); close.Click += (_, _) => StageFatPositionControl(signal, "Closed [10]", "Close"); panel.Children.Add(close); } @@ -415,7 +553,7 @@ private FrameworkElement BuildFatCommandActions(SignalDefinition signal) panel.Children.Add(target); var set = FatCommandButton("Set", "PrimaryButton"); - set.IsEnabled = signal.ControlSupportsOperate && !signal.ControlIsBusy; + BindFatCommandEnabled(set, signal); set.Click += async (_, _) => await ExecuteFatQuickControlAsync(signal, signal.ControlSetPointText, "Set"); panel.Children.Add(set); return panel; @@ -428,11 +566,31 @@ private FrameworkElement BuildFatCommandActions(SignalDefinition signal) private Button FatQuickCommandButton(SignalDefinition signal, string label, string requestedValue, string styleKey) { var button = FatCommandButton(label, styleKey); - button.IsEnabled = signal.ControlSupportsOperate && !signal.ControlIsBusy; + BindFatCommandEnabled(button, signal); button.Click += async (_, _) => await ExecuteFatQuickControlAsync(signal, requestedValue, label); return button; } + private static void BindFatCommandEnabled(Button button, SignalDefinition signal) + { + var binding = new MultiBinding + { + Converter = FatCommandCanOperateConverter.Instance, + Mode = BindingMode.OneWay + }; + binding.Bindings.Add(new Binding(nameof(SignalDefinition.ControlSupportsOperate)) + { + Source = signal, + Mode = BindingMode.OneWay + }); + binding.Bindings.Add(new Binding(nameof(SignalDefinition.ControlIsBusy)) + { + Source = signal, + Mode = BindingMode.OneWay + }); + BindingOperations.SetBinding(button, UIElement.IsEnabledProperty, binding); + } + private void StageFatPositionControl(SignalDefinition signal, string requestedValue, string actionLabel) { if (!signal.TryStageControlConfirmation(requestedValue, actionLabel, out var rejectionReason)) @@ -440,7 +598,8 @@ private void StageFatPositionControl(SignalDefinition signal, string requestedVa signal.ControlLastResult = $"Command rejected: {rejectionReason}."; return; } - RebuildFatCommandRows(); + + RefreshFatCommandActions(signal); } private async Task ConfirmFatPositionControlAsync(SignalDefinition signal) @@ -453,8 +612,9 @@ private async Task ConfirmFatPositionControlAsync(SignalDefinition signal) return; } + RefreshFatCommandActions(signal); await engineeringWindow.ExecuteIoFatControlClaimAsync(signal, claim); - RebuildFatCommandRows(); + RefreshFatCommandActions(signal); } private async Task ExecuteFatQuickControlAsync(SignalDefinition signal, string requestedValue, string actionLabel) @@ -468,7 +628,6 @@ private async Task ExecuteFatQuickControlAsync(SignalDefinition signal, string r } await engineeringWindow.ExecuteIoFatControlClaimAsync(signal, claim); - RebuildFatCommandRows(); } private Button FatCommandButton(string text, string styleKey) diff --git a/IoListTestingWindow.P0BenchUx.cs b/IoListTestingWindow.P0BenchUx.cs index 788f8420c..500447c0f 100644 --- a/IoListTestingWindow.P0BenchUx.cs +++ b/IoListTestingWindow.P0BenchUx.cs @@ -3,18 +3,14 @@ using System.Windows.Controls; using System.Windows.Data; using System.Windows.Media; -using System.Windows.Threading; using ArIED61850Tester.Models.IoTesting; namespace ArIED61850Tester; /// -/// Bench-facing P0 UX corrections that are intentionally presentation-only. -/// -/// - LIVE/VALUE1/VALUE2 always render canonical Boolean text without mutating raw evidence. -/// - Normal per-cell Capture buttons are removed from the template; Recapture remains the -/// explicit operator correction path while FatAutoCaptureCoordinator owns normal capture. -/// - Primary session actions and secondary evidence/status actions use two adaptive rows. +/// Bench-facing FAT UX corrections. FAT deliberately disables ToolTips so hover creation +/// cannot compete with report-backed updates on relay benches; Engineering ToolTips live in +/// MainWindow and are untouched. The FAT action strip is kept in one compact adaptive row. /// public partial class IoListTestingWindow { @@ -38,22 +34,19 @@ private static void P0BenchUxLoaded(object sender, RoutedEventArgs e) return; window._p0BenchUxInstalled = true; - window.ContentRendered += window.P0BenchUxContentRendered; window.Closed += window.P0BenchUxClosed; - window.Dispatcher.BeginInvoke( - new Action(window.ApplyP0BenchUx), - DispatcherPriority.ContextIdle); - } - private void P0BenchUxContentRendered(object? sender, EventArgs e) - => Dispatcher.BeginInvoke(new Action(ApplyP0BenchUx), DispatcherPriority.ContextIdle); + // FAT-only. Do not touch MainWindow/Engineering ToolTips. + ToolTipService.SetIsEnabled(window, false); - private void P0BenchUxClosed(object? sender, EventArgs e) - { - ContentRendered -= P0BenchUxContentRendered; - Closed -= P0BenchUxClosed; + // Final FAT V2 schema is installed before first visible render. + window.InstallFatV2WorkspaceUx(); + window.ApplyP0BenchUx(); } + private void P0BenchUxClosed(object? sender, EventArgs e) + => Closed -= P0BenchUxClosed; + private void ApplyP0BenchUx() { ConfigureP0StableFatColumns(); @@ -129,9 +122,6 @@ private static DataTemplate BuildP0EvidenceValueTemplate(FatValueSlot slot) value.SetValue(TextBlock.FontWeightProperty, FontWeights.SemiBold); value.SetValue(TextBlock.FontSizeProperty, 11.4); value.SetValue(TextBlock.TextTrimmingProperty, TextTrimming.CharacterEllipsis); - value.SetBinding(FrameworkElement.ToolTipProperty, new Binding(isValue1 - ? nameof(IoTestPointPlan.Value1EvidenceToolTip) - : nameof(IoTestPointPlan.Value2EvidenceToolTip))); panel.AppendChild(value); var timestamp = new FrameworkElementFactory(typeof(TextBlock)); @@ -143,8 +133,6 @@ private static DataTemplate BuildP0EvidenceValueTemplate(FatValueSlot slot) timestamp.SetValue(TextBlock.ForegroundProperty, new SolidColorBrush(Color.FromRgb(112, 126, 145))); panel.AppendChild(timestamp); - // Intentionally no normal Capture button. Automatic capture is the normal path; - // multi-row/context Recapture remains available for explicit evidence correction. return new DataTemplate { VisualTree = panel }; } @@ -166,36 +154,26 @@ private void ConfigureP0AdaptiveHeaderActions() { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right, - VerticalAlignment = VerticalAlignment.Center + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0) }; + + // Retained as an empty compatibility panel because P2 helpers reference it. All FAT + // actions/status now share the one visible compact row instead of forcing a second row. _p0SecondaryHeaderActions = new WrapPanel { Orientation = Orientation.Horizontal, - HorizontalAlignment = HorizontalAlignment.Right, - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 5, 0, 0) + Visibility = Visibility.Collapsed, + Margin = new Thickness(0) }; foreach (var child in children) - { - if (IsP0SecondaryHeaderAction(child)) - _p0SecondaryHeaderActions.Children.Add(child); - else - _p0PrimaryHeaderActions.Children.Add(child); - } + _p0PrimaryHeaderActions.Children.Add(child); actionPanel.Children.Add(_p0PrimaryHeaderActions); - if (_p0SecondaryHeaderActions.Children.Count > 0) - actionPanel.Children.Add(_p0SecondaryHeaderActions); } - private bool IsP0SecondaryHeaderAction(UIElement element) - => ReferenceEquals(element, WorkspacePreviewToggle) || - ReferenceEquals(element, _timeSyncEvidenceButton) || - ReferenceEquals(element, _comtradeEvidenceButton) || - ReferenceEquals(element, _cleanSessionButton) || - ReferenceEquals(element, _clockSyncGlobalStatusText) || - ReferenceEquals(element, _clockSyncEvidenceText); + private bool IsP0SecondaryHeaderAction(UIElement element) => false; } public sealed class P0FatCanonicalValueConverter : IValueConverter @@ -207,4 +185,4 @@ public object Convert(object value, Type targetType, object parameter, CultureIn public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => Binding.DoNothing; -} \ No newline at end of file +} diff --git a/IoListTestingWindow.P0Lifecycle.cs b/IoListTestingWindow.P0Lifecycle.cs index 398757140..ac76e6299 100644 --- a/IoListTestingWindow.P0Lifecycle.cs +++ b/IoListTestingWindow.P0Lifecycle.cs @@ -2,6 +2,7 @@ using System.IO; using System.Windows; using System.Windows.Threading; +using ArIED61850Tester.Services.IoTesting; namespace ArIED61850Tester; @@ -9,9 +10,9 @@ namespace ArIED61850Tester; /// P0 responsiveness guard for the FAT workspace lifecycle. /// /// Confirmation and evidence-session state transitions remain on the WPF Dispatcher, but -/// the high-rate FAT live projection is quiesced before sealing/saving and durable workspace -/// persistence runs on a worker thread. This prevents a closing window from competing with -/// incoming report traffic while preserving UI-bound session state safety. +/// the high-rate FAT live projection is quiesced before sealing/saving. Close-only durable +/// journal flush/read-back verification and workspace persistence run on worker threads so +/// a closing window never blocks the Dispatcher on disk work. /// public partial class IoListTestingWindow { @@ -91,24 +92,37 @@ private async void P0Window_Closing(object? sender, CancelEventArgs e) engineeringWindow.SuspendIoFatRuntimeProjection(this); IsEnabled = false; - // Let the disabled/closing visual state paint before journal sealing. The actual - // session mutation remains on Dispatcher; only durable workspace I/O is offloaded. + // Let the disabled/closing visual state paint before journal sealing. Session state + // remains Dispatcher-owned; only the physical seal/read-back work is deferred. await Dispatcher.Yield(DispatcherPriority.Render); try { if (Session.HasActiveSessions) { - // Session.StopAll mutates controller/project state and raises UI-bound - // PropertyChanged notifications. Keep that state transition on Dispatcher; - // offloading the whole coordinator would create a cross-thread WPF defect. - var stopAll = Session.StopAll( - "Workspace closed by operator; per-IED evidence journal sealed."); - if (!stopAll.Succeeded) + var stopSucceeded = false; + var stopMessage = string.Empty; + // StopAll still performs controller/project state mutation and UI-bound + // PropertyChanged notifications synchronously on this Dispatcher. The scope + // changes only production journal Dispose(): its durable flush + read-back + // verification are queued to a worker and awaited immediately afterwards. + using (IoTestEvidenceJournal.BeginDeferredSealScope()) + { + var stopAll = Session.StopAll( + "Workspace closed by operator; per-IED evidence journal sealed."); + stopSucceeded = stopAll.Succeeded; + stopMessage = stopAll.Message; + } + + // Do not allow project save or window close until every queued journal has + // completed its physical disk barrier and full hash-chain read-back. + await IoTestEvidenceJournal.AwaitDeferredSealsAsync(); + + if (!stopSucceeded) { MessageBox.Show( this, - stopAll.Message, + stopMessage, "Evidence journals could not be sealed", MessageBoxButton.OK, MessageBoxImage.Error); @@ -116,8 +130,8 @@ private async void P0Window_Closing(object? sender, CancelEventArgs e) } } - // Persistence is pure durable I/O after the session state is sealed and is the - // portion that must not occupy the WPF Dispatcher. + // Persistence is pure durable I/O after every session journal is actually sealed + // and verified, so it also stays away from the WPF Dispatcher. if (Storage != null) await Task.Run(Storage.SaveNow); @@ -128,8 +142,8 @@ private async void P0Window_Closing(object? sender, CancelEventArgs e) { var answer = MessageBox.Show( this, - $"ARSAS could not save the latest IO FAT progress.\n\n{ex.Message}\n\nClose the workspace anyway?", - "Progress save failed", + $"ARSAS could not seal the FAT evidence or save the latest IO FAT progress.\n\n{ex.Message}\n\nClose the workspace anyway?", + "FAT close failed", MessageBoxButton.YesNo, MessageBoxImage.Error, MessageBoxResult.No); diff --git a/IoListTestingWindow.P0RelayBenchHotPath.cs b/IoListTestingWindow.P0RelayBenchHotPath.cs new file mode 100644 index 000000000..f793b0b94 --- /dev/null +++ b/IoListTestingWindow.P0RelayBenchHotPath.cs @@ -0,0 +1,306 @@ +using System.Diagnostics; +using System.IO; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using System.Windows.Threading; +using ArIED61850Tester.Models; +using ArIED61850Tester.Models.IoTesting; +using ArIED61850Tester.Services.IoTesting; + +namespace ArIED61850Tester; + +/// +/// Relay-bench hot-path guard. This class handler runs before ordinary Button.Click handlers +/// and owns only the FAT operations that must never enter the legacy synchronous lifecycle. +/// It keeps an already-running Engineering acquisition session untouched and treats FAT as +/// a lightweight evidence consumer. +/// +public partial class IoListTestingWindow +{ + private static readonly bool P0RelayBenchButtonGuardRegistered = RegisterP0RelayBenchButtonGuard(); + + private bool _p0HotPathStartRunning; + private bool _p0HotPathResumeRunning; + private bool _p0HotPathStopRunning; + private bool _p0HotPathCommandRunning; + + private static bool RegisterP0RelayBenchButtonGuard() + { + EventManager.RegisterClassHandler( + typeof(Button), + Button.ClickEvent, + new RoutedEventHandler(P0RelayBenchButton_Click), + handledEventsToo: true); + return true; + } + + private static void P0RelayBenchButton_Click(object sender, RoutedEventArgs e) + { + if (sender is not Button button || FindOwningFatWindow(button) is not IoListTestingWindow window) + return; + + var text = ButtonText(button); + + if (IsFastStartLabel(text) && + window.SelectedIed?.IsLiveMonitoring == true && + window.Session.CanStart) + { + e.Handled = true; + _ = window.StartFatFromSharedLiveSessionAsync(button); + return; + } + + if (text.Equals("Resume", StringComparison.OrdinalIgnoreCase) && window.Session.CanResume) + { + e.Handled = true; + _ = window.ResumeFatWithoutBlockingDispatcherAsync(button); + return; + } + + if (text.Equals("Stop", StringComparison.OrdinalIgnoreCase) && window.Session.CanStop) + { + e.Handled = true; + _ = window.StopFatWithoutBlockingDispatcherAsync(button); + return; + } + + // FAT position Confirm buttons inherit SignalDefinition as DataContext from the row. + // Intercept only those buttons; Engineering command controls are untouched. + if (text.Equals("Confirm", StringComparison.OrdinalIgnoreCase) && + button.DataContext is SignalDefinition signal && + signal.IsPositionControl && + signal.ControlConfirmationPending) + { + e.Handled = true; + _ = window.ConfirmFatPositionWithoutUiBlockAsync(button, signal); + } + } + + private async Task StartFatFromSharedLiveSessionAsync(Button button) + { + if (_p0HotPathStartRunning) + return; + + var ied = SelectedIed; + if (ied == null) + return; + + _p0HotPathStartRunning = true; + var originalOpacity = button.Opacity; + button.IsHitTestVisible = false; + button.Opacity = 0.68; + await Dispatcher.Yield(DispatcherPriority.Render); + + var stopwatch = Stopwatch.StartNew(); + try + { + var preflight = IoTestSessionPreflight.Validate(ied); + if (!preflight.Succeeded) + { + ShowActionResult(preflight, "FAT session scope is not ready"); + return; + } + + // Critical fast path: Engineering is already connected + monitoring. Do NOT run + // PrepareIoTestIedForFatAsync again, do NOT reconcile/reselect/restart reports, + // and do NOT touch the acquisition cadence. FAT only arms evidence on the live + // rows that are already proven by the shared process image. + var requested = ied.TestPoints + .Where(point => + point.WorkspaceSelected && + point.IsIncludedInFat && + point.TestEnabled && + point.ImportReady) + .ToList(); + var live = requested + .Where(point => point.LiveBindingState == IoTestLiveBindingState.LivePointReady) + .ToList(); + + if (live.Count == 0) + { + var failure = IoTestSessionActionResult.Failure( + $"{ied.IedName} is monitoring, but none of the {requested.Count} selected FAT row(s) has a proven live point."); + ShowActionResult(failure, "FAT evidence session could not start"); + return; + } + + var result = Session.Start(ied, live); + ShowActionResult(result, "FAT evidence session could not start"); + if (result.Succeeded) + { + var waiting = requested.Count - live.Count; + PreparationStatusText = waiting == 0 + ? $"{ied.IedName} FAT active · attached directly to shared Engineering live data · {live.Count} row(s) armed" + : $"{ied.IedName} FAT active · {live.Count}/{requested.Count} proven live row(s) armed · {waiting} waiting for binding"; + Storage?.ScheduleSave(); + } + else + { + PreparationStatusText = result.Message; + } + + RaiseStatusProperties(); + RaiseSelectedIedContextProperties(); + Trace.WriteLine( + $"[IO FAT P0] shared-live Start/Continue completed in {stopwatch.ElapsedMilliseconds} ms; " + + $"ied={ied.IedName}; requested={requested.Count}; live={live.Count}; succeeded={result.Succeeded}."); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException or ArgumentException) + { + Trace.WriteLine($"[IO FAT P0] shared-live Start/Continue failed after {stopwatch.ElapsedMilliseconds} ms: {ex}"); + MessageBox.Show(this, ex.Message, "FAT session could not start", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + button.Opacity = originalOpacity; + button.IsHitTestVisible = true; + _p0HotPathStartRunning = false; + RaiseSelectedIedContextProperties(); + } + } + + private async Task ResumeFatWithoutBlockingDispatcherAsync(Button button) + { + if (_p0HotPathResumeRunning) + return; + + _p0HotPathResumeRunning = true; + var originalOpacity = button.Opacity; + button.IsHitTestVisible = false; + button.Opacity = 0.68; + await Dispatcher.Yield(DispatcherPriority.Render); + + var stopwatch = Stopwatch.StartNew(); + try + { + var result = Session.Resume(); + ShowActionResult(result, "FAT session could not resume"); + if (result.Succeeded) + Storage?.ScheduleSave(); + RaiseStatusProperties(); + RaiseSelectedIedContextProperties(); + Trace.WriteLine($"[IO FAT P0] Resume completed in {stopwatch.ElapsedMilliseconds} ms; succeeded={result.Succeeded}."); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) + { + MessageBox.Show(this, ex.Message, "FAT session could not resume", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + button.Opacity = originalOpacity; + button.IsHitTestVisible = true; + _p0HotPathResumeRunning = false; + } + } + + private async Task StopFatWithoutBlockingDispatcherAsync(Button button) + { + if (_p0HotPathStopRunning) + return; + + _p0HotPathStopRunning = true; + var originalOpacity = button.Opacity; + button.IsHitTestVisible = false; + button.Opacity = 0.68; + await Dispatcher.Yield(DispatcherPriority.Render); + + var stopwatch = Stopwatch.StartNew(); + try + { + IoTestSessionActionResult result; + using (IoTestEvidenceJournal.BeginDeferredSealScope()) + result = Session.Stop(); + + ShowActionResult(result, "FAT session could not stop"); + if (result.Succeeded) + { + // Queue drain, durable disk barrier and full hash-chain verification never + // run on WPF Dispatcher. Engineering/FAT remain repaintable while sealing. + await IoTestEvidenceJournal.AwaitDeferredSealsAsync(); + if (Storage != null) + await Task.Run(Storage.SaveNow); + } + + RaiseStatusProperties(); + RaiseSelectedIedContextProperties(); + Trace.WriteLine($"[IO FAT P0] Stop completed in {stopwatch.ElapsedMilliseconds} ms; succeeded={result.Succeeded}."); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) + { + Trace.WriteLine($"[IO FAT P0] Stop failed after {stopwatch.ElapsedMilliseconds} ms: {ex}"); + MessageBox.Show(this, ex.Message, "FAT evidence could not be sealed", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + button.Opacity = originalOpacity; + button.IsHitTestVisible = true; + _p0HotPathStopRunning = false; + RaiseSelectedIedContextProperties(); + } + } + + private async Task ConfirmFatPositionWithoutUiBlockAsync(Button button, SignalDefinition signal) + { + if (_p0HotPathCommandRunning || Owner is not MainWindow engineeringWindow) + return; + + if (!signal.TryClaimControlConfirmation(out var claim, out var rejectionReason) || claim == null) + { + signal.ControlLastResult = $"Command rejected: {rejectionReason}."; + return; + } + + _p0HotPathCommandRunning = true; + var originalOpacity = button.Opacity; + button.IsHitTestVisible = false; + button.Opacity = 0.68; + RefreshFatCommandActions(signal); + await Dispatcher.Yield(DispatcherPriority.Render); + + try + { + await engineeringWindow.ExecuteIoFatControlClaimAsync(signal, claim); + + // Command-service feedback is not allowed to overwrite the shared Engineering + // process image. Re-project the newest actual status point after command release, + // then once more after the CSWI stability guard has had time to publish. + engineeringWindow.ReconcileIoFatCommandValueFromSharedProcessImage(signal); + await Task.Delay(450); + engineeringWindow.ReconcileIoFatCommandValueFromSharedProcessImage(signal); + } + finally + { + RefreshFatCommandActions(signal); + button.Opacity = originalOpacity; + button.IsHitTestVisible = true; + _p0HotPathCommandRunning = false; + } + } + + private static bool IsFastStartLabel(string text) + => text.Equals("Start FAT", StringComparison.OrdinalIgnoreCase) || + text.Equals("Continue FAT", StringComparison.OrdinalIgnoreCase) || + text.Equals("Retest FAT", StringComparison.OrdinalIgnoreCase); + + private static string ButtonText(Button button) + => button.Content switch + { + string text => text.Trim(), + TextBlock textBlock => textBlock.Text?.Trim() ?? string.Empty, + _ => button.Content?.ToString()?.Trim() ?? string.Empty + }; + + private static IoListTestingWindow? FindOwningFatWindow(DependencyObject start) + { + DependencyObject? current = start; + while (current != null) + { + if (current is IoListTestingWindow window) + return window; + + current = VisualTreeHelper.GetParent(current) ?? LogicalTreeHelper.GetParent(current); + } + return null; + } +} diff --git a/IoListTestingWindow.P0RuntimeActions.cs b/IoListTestingWindow.P0RuntimeActions.cs new file mode 100644 index 000000000..1e44485be --- /dev/null +++ b/IoListTestingWindow.P0RuntimeActions.cs @@ -0,0 +1,316 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Media; +using System.Windows.Threading; +using ArIED61850Tester.Models.IoTesting; +using ArIED61850Tester.Services.IoTesting; + +namespace ArIED61850Tester; + +/// +/// P0 operator-action responsiveness for the FAT bench. +/// +/// Start/Continue paints a local busy state before the existing safe preparation workflow +/// begins. Resume can emit one evidence record per active point; production journal writes +/// remain ordered and hash-chained, but their visible StreamWriter flush is coalesced into +/// one flush for the complete rebaseline transaction. Stop detaches the evidence journal on +/// the Dispatcher and performs the expensive durable disk barrier + full read-back on a +/// worker. Only the pressed button is muted while work is in flight; the DataGrid, search, +/// IED explorer and window chrome remain interactive. +/// +public partial class IoListTestingWindow +{ + private static readonly bool P0RuntimeActionsRegistered = RegisterP0RuntimeActions(); + + private bool _p0RuntimeActionsInstalled; + private bool _p0StartInProgress; + private bool _p0ResumeInProgress; + private bool _p0StopInProgress; + private Button? _p0StartButton; + private Button? _p0ResumeButton; + private Button? _p0StopButton; + + private static bool RegisterP0RuntimeActions() + { + EventManager.RegisterClassHandler( + typeof(IoListTestingWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(P0RuntimeActions_Loaded)); + return true; + } + + private static void P0RuntimeActions_Loaded(object sender, RoutedEventArgs e) + { + if (sender is not IoListTestingWindow window || window._p0RuntimeActionsInstalled) + return; + + window._p0RuntimeActionsInstalled = true; + window.Dispatcher.BeginInvoke( + new Action(window.InstallP0RuntimeActionHandlers), + DispatcherPriority.Loaded); + } + + private void InstallP0RuntimeActionHandlers() + { + _p0StartButton = FindButtonByContentBindingPath(this, nameof(SelectedStartWorkflowText)); + if (_p0StartButton != null) + { + // Preserve the existing safe Start/Continue workflow, but insert one rendered + // frame before it begins. This avoids the Windows "not responding" impression + // even if the first preparation phase has synchronous setup before its await. + _p0StartButton.Click -= StartSelectedIedSafely_Click; + _p0StartButton.Click += P0StartSelectedIedSafely_Click; + } + + _p0ResumeButton = FindButtonByContent(this, "Resume"); + if (_p0ResumeButton != null) + { + // XAML attached the legacy synchronous handler during InitializeComponent. + // Replace only this edge and leave Session/controller ownership unchanged. + _p0ResumeButton.Click -= ResumeSession_Click; + _p0ResumeButton.Click += P0ResumeSession_Click; + } + + _p0StopButton = FindButtonByContent(this, "Stop"); + if (_p0StopButton != null) + { + _p0StopButton.Click -= StopSession_Click; + _p0StopButton.Click += P0StopSession_Click; + } + + Closed += P0RuntimeActions_Closed; + } + + private void P0RuntimeActions_Closed(object? sender, EventArgs e) + { + Closed -= P0RuntimeActions_Closed; + if (_p0StartButton != null) + { + _p0StartButton.Click -= P0StartSelectedIedSafely_Click; + _p0StartButton = null; + } + if (_p0ResumeButton != null) + { + _p0ResumeButton.Click -= P0ResumeSession_Click; + _p0ResumeButton = null; + } + if (_p0StopButton != null) + { + _p0StopButton.Click -= P0StopSession_Click; + _p0StopButton = null; + } + } + + private async void P0StartSelectedIedSafely_Click(object sender, RoutedEventArgs e) + { + if (_p0StartInProgress || sender is not Button button) + return; + + var targetIed = SelectedIed; + _p0StartInProgress = true; + var originalOpacity = button.Opacity; + button.IsHitTestVisible = false; + button.Opacity = 0.68; + + // Content stays bound to SelectedStartWorkflowText. SetPreparingIed in the existing + // workflow therefore changes the same button naturally to "Connecting …" without + // replacing/breaking its binding. + await Dispatcher.Yield(DispatcherPriority.Render); + var stopwatch = Stopwatch.StartNew(); + try + { + StartSelectedIedSafely_Click(sender, e); + await WaitForP0StartWorkflowCompletionAsync(targetIed); + Trace.WriteLine( + $"[IO FAT P0] Start/Continue workflow completed in {stopwatch.ElapsedMilliseconds} ms; " + + $"ied={targetIed?.IedName ?? ""}; state={Session.State}; active={Session.IsSessionActive}."); + } + finally + { + button.Opacity = originalOpacity; + button.IsHitTestVisible = true; + _p0StartInProgress = false; + RaiseSelectedIedContextProperties(); + } + } + + private async Task WaitForP0StartWorkflowCompletionAsync(IoTestIedPlan? targetIed) + { + // The legacy handler is async void. Yield once so it can enter SetPreparingIed and + // reach its first asynchronous acquisition await. If preflight returned early there + // is nothing to wait for. + await Dispatcher.Yield(DispatcherPriority.Background); + if (targetIed == null || !targetIed.IsPreparing) + return; + + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + PropertyChangedEventHandler? handler = null; + handler = (_, args) => + { + if (args.PropertyName == nameof(IoTestIedPlan.IsPreparing) && !targetIed.IsPreparing) + completion.TrySetResult(true); + }; + + targetIed.PropertyChanged += handler; + try + { + if (!targetIed.IsPreparing) + return; + await completion.Task; + } + finally + { + targetIed.PropertyChanged -= handler; + } + } + + private async void P0ResumeSession_Click(object sender, RoutedEventArgs e) + { + if (_p0ResumeInProgress || sender is not Button button) + return; + + _p0ResumeInProgress = true; + var originalContent = button.Content; + var originalOpacity = button.Opacity; + button.Content = "Continuing…"; + button.IsHitTestVisible = false; + button.Opacity = 0.68; + + // Paint the busy state before any controller/project PropertyChanged burst starts. + await Dispatcher.Yield(DispatcherPriority.Render); + var stopwatch = Stopwatch.StartNew(); + try + { + IoTestSessionActionResult result; + using (IoTestEvidenceJournal.BeginCoalescedVisibleFlushScope()) + result = Session.Resume(); + + ShowActionResult(result, "FAT session could not resume"); + if (result.Succeeded) + Storage?.ScheduleSave(); + + RaiseStatusProperties(); + RaiseSelectedIedContextProperties(); + Trace.WriteLine($"[IO FAT P0] Resume completed in {stopwatch.ElapsedMilliseconds} ms; succeeded={result.Succeeded}."); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) + { + Trace.WriteLine($"[IO FAT P0] Resume failed after {stopwatch.ElapsedMilliseconds} ms: {ex}"); + MessageBox.Show( + this, + ex.Message, + "FAT session could not resume", + MessageBoxButton.OK, + MessageBoxImage.Error); + } + finally + { + button.Content = originalContent; + button.Opacity = originalOpacity; + button.IsHitTestVisible = true; + _p0ResumeInProgress = false; + RaiseSelectedIedContextProperties(); + } + } + + private async void P0StopSession_Click(object sender, RoutedEventArgs e) + { + if (_p0StopInProgress || sender is not Button button) + return; + + _p0StopInProgress = true; + var originalContent = button.Content; + var originalOpacity = button.Opacity; + button.Content = "Stopping…"; + button.IsHitTestVisible = false; + button.Opacity = 0.68; + + // Paint immediately. Do not disable the Window: scrolling and inspection remain + // available while the detached evidence file is being durably sealed/read back. + await Dispatcher.Yield(DispatcherPriority.Render); + var stopwatch = Stopwatch.StartNew(); + try + { + IoTestSessionActionResult result; + using (IoTestEvidenceJournal.BeginDeferredSealScope()) + result = Session.Stop(); + + ShowActionResult(result, "FAT session could not stop"); + if (result.Succeeded) + { + // Stop() has already made the controller/session state immutable. Await the + // physical disk barrier and complete hash-chain read-back off Dispatcher. + await IoTestEvidenceJournal.AwaitDeferredSealsAsync(); + if (Storage != null) + await Task.Run(Storage.SaveNow); + } + + RaiseStatusProperties(); + RaiseSelectedIedContextProperties(); + Trace.WriteLine($"[IO FAT P0] Stop completed in {stopwatch.ElapsedMilliseconds} ms; succeeded={result.Succeeded}."); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) + { + Trace.WriteLine($"[IO FAT P0] Stop failed after {stopwatch.ElapsedMilliseconds} ms: {ex}"); + MessageBox.Show( + this, + ex.Message, + "FAT evidence could not be sealed", + MessageBoxButton.OK, + MessageBoxImage.Error); + } + finally + { + button.Content = originalContent; + button.Opacity = originalOpacity; + button.IsHitTestVisible = true; + _p0StopInProgress = false; + RaiseSelectedIedContextProperties(); + } + } + + private static Button? FindButtonByContentBindingPath(DependencyObject root, string path) + { + var childCount = VisualTreeHelper.GetChildrenCount(root); + for (var index = 0; index < childCount; index++) + { + var child = VisualTreeHelper.GetChild(root, index); + if (child is Button button) + { + var binding = BindingOperations.GetBinding(button, ContentControl.ContentProperty); + if (string.Equals(binding?.Path?.Path, path, StringComparison.Ordinal)) + return button; + } + + var nested = FindButtonByContentBindingPath(child, path); + if (nested != null) + return nested; + } + + return null; + } + + private static Button? FindButtonByContent(DependencyObject root, string content) + { + var childCount = VisualTreeHelper.GetChildrenCount(root); + for (var index = 0; index < childCount; index++) + { + var child = VisualTreeHelper.GetChild(root, index); + if (child is Button button && + string.Equals(button.Content?.ToString(), content, StringComparison.Ordinal)) + { + return button; + } + + var nested = FindButtonByContent(child, content); + if (nested != null) + return nested; + } + + return null; + } +} diff --git a/IoListTestingWindow.P1NotificationThrottle.cs b/IoListTestingWindow.P1NotificationThrottle.cs new file mode 100644 index 000000000..e3d2497c0 --- /dev/null +++ b/IoListTestingWindow.P1NotificationThrottle.cs @@ -0,0 +1,87 @@ +using System.ComponentModel; +using System.Threading; +using System.Windows; +using System.Windows.Threading; + +namespace ArIED61850Tester; + +/// +/// P1 FAT UI notification gate. +/// +/// The multi-session coordinator exposes fine-grained property changes, but the legacy FAT +/// window used to translate every one of those changes into a full window/property refresh. +/// During Start/Continue, automatic Value 1/Value 2 capture and Stop that multiplied a small +/// evidence update into thousands of WPF binding/layout invalidations. Coalesce those legacy +/// wrapper notifications to at most one refresh per Dispatcher turn. Direct bindings to +/// Session.* continue to receive their normal targeted PropertyChanged events. +/// +public partial class IoListTestingWindow +{ + private static readonly bool P1NotificationThrottleRegistered = RegisterP1NotificationThrottle(); + + private bool _p1NotificationThrottleInstalled; + private int _p1WindowRefreshScheduled; + + private static bool RegisterP1NotificationThrottle() + { + EventManager.RegisterClassHandler( + typeof(IoListTestingWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(P1NotificationThrottle_Loaded)); + return true; + } + + private static void P1NotificationThrottle_Loaded(object sender, RoutedEventArgs e) + { + if (sender is not IoListTestingWindow window || + window._p1NotificationThrottleInstalled || + !ReferenceEquals(e.OriginalSource, window)) + { + return; + } + + window._p1NotificationThrottleInstalled = true; + + // The constructor attached the compatibility handler before InitializeComponent. + // Replace only that fan-out edge. Session itself remains fully observable, so nested + // bindings such as Session.CanStop / Session.StateText are not delayed or hidden. + window.Session.PropertyChanged -= window.Session_PropertyChanged; + window.Session.PropertyChanged += window.P1Session_PropertyChanged; + window.Closed += window.P1NotificationThrottle_Closed; + } + + private void P1Session_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (Interlocked.Exchange(ref _p1WindowRefreshScheduled, 1) != 0) + return; + + try + { + Dispatcher.BeginInvoke( + new Action(() => + { + Interlocked.Exchange(ref _p1WindowRefreshScheduled, 0); + if (!IsLoaded) + return; + + // One coherent recompute is enough for the window-owned derived labels. + // Keep it below input/render priority so command buttons and scrolling + // never wait behind evidence metadata churn. + RaiseStatusProperties(); + RaiseSelectedIedContextProperties(); + }), + DispatcherPriority.Background); + } + catch (InvalidOperationException) + { + Interlocked.Exchange(ref _p1WindowRefreshScheduled, 0); + } + } + + private void P1NotificationThrottle_Closed(object? sender, EventArgs e) + { + Closed -= P1NotificationThrottle_Closed; + Session.PropertyChanged -= P1Session_PropertyChanged; + Interlocked.Exchange(ref _p1WindowRefreshScheduled, 0); + } +} diff --git a/IoListTestingWindow.RealPreparationProgress.cs b/IoListTestingWindow.RealPreparationProgress.cs index 3cc84ec8d..dbb20ef9b 100644 --- a/IoListTestingWindow.RealPreparationProgress.cs +++ b/IoListTestingWindow.RealPreparationProgress.cs @@ -12,7 +12,9 @@ public partial class IoListTestingWindow { private static readonly bool RealPreparationProgressRegistered = RegisterRealPreparationProgress(); private readonly Dictionary _preparationDisplayStates = new(); + private readonly List _preparationProgressBars = new(); private DispatcherTimer? _preparationProgressTimer; + private int _preparationProgressBarCacheIedCount = -1; private static bool RegisterRealPreparationProgress() { @@ -37,21 +39,26 @@ private void InstallRealPreparationProgress() foreach (var ied in Project.Ieds) _preparationDisplayStates[ied] = new PreparationDisplayState(); - _preparationProgressTimer = new DispatcherTimer(DispatcherPriority.Render) + _preparationProgressTimer = new DispatcherTimer(DispatcherPriority.Background) { - Interval = TimeSpan.FromMilliseconds(50) + Interval = TimeSpan.FromMilliseconds(100) }; _preparationProgressTimer.Tick += PreparationProgressTimer_Tick; _preparationProgressTimer.Start(); Closed += RealPreparationProgress_Closed; Dispatcher.BeginInvoke( - new Action(ApplyDeterminateCardProgressBars), + new Action(() => + { + RefreshPreparationProgressBarCache(force: true); + ApplyDeterminateCardProgressBars(); + }), DispatcherPriority.Loaded); } private void PreparationProgressTimer_Tick(object? sender, EventArgs e) { + var hasActivePreparation = false; foreach (var ied in Project.Ieds) { if (!_preparationDisplayStates.TryGetValue(ied, out var state)) @@ -68,6 +75,7 @@ private void PreparationProgressTimer_Tick(object? sender, EventArgs e) if (!active) continue; + hasActivePreparation = true; var snapshot = Owner is MainWindow engineeringWindow ? engineeringWindow.GetIoFatPreparationProgressSnapshot(ied) : BuildFallbackPreparationSnapshot(ied); @@ -77,20 +85,40 @@ private void PreparationProgressTimer_Tick(object? sender, EventArgs e) state.AdvanceDisplay(); } + // No visual-tree walk and no tooltip string creation on idle/hot ticks. + if (!hasActivePreparation) + return; + + RefreshPreparationProgressBarCache(force: false); ApplyDeterminateCardProgressBars(); } + private void RefreshPreparationProgressBarCache(bool force) + { + var iedCount = Project.Ieds.Count; + if (!force && + _preparationProgressBarCacheIedCount == iedCount && + _preparationProgressBars.Count > 0 && + _preparationProgressBars.All(progress => progress.IsLoaded)) + { + return; + } + + _preparationProgressBars.Clear(); + _preparationProgressBars.AddRange( + VisualDescendants(this) + .Where(progress => string.Equals(progress.Name, "CardProgress", StringComparison.Ordinal))); + _preparationProgressBarCacheIedCount = iedCount; + } + private void ApplyDeterminateCardProgressBars() { - foreach (var progressBar in VisualDescendants(this) - .Where(progress => string.Equals(progress.Name, "CardProgress", StringComparison.Ordinal))) + foreach (var progressBar in _preparationProgressBars) { - // The XAML fallback is indeterminate so old project binaries remain safe. - // Once this behavior is installed every instantiated IED-card bar becomes - // determinate and is driven by real connection/discovery/acquisition state. progressBar.IsIndeterminate = false; progressBar.Minimum = 0d; progressBar.Maximum = 100d; + progressBar.ToolTip = null; if (progressBar.DataContext is not IoTestIedPlan ied || !_preparationDisplayStates.TryGetValue(ied, out var state)) @@ -100,14 +128,6 @@ private void ApplyDeterminateCardProgressBars() } progressBar.Value = state.Display; - progressBar.ToolTip = string.Join( - " · ", - new[] - { - state.Message, - state.StepText, - $"{state.Display:0}%" - }.Where(value => !string.IsNullOrWhiteSpace(value))); } } @@ -137,6 +157,7 @@ private void RealPreparationProgress_Closed(object? sender, EventArgs e) _preparationProgressTimer.Stop(); _preparationProgressTimer.Tick -= PreparationProgressTimer_Tick; _preparationProgressTimer = null; + _preparationProgressBars.Clear(); _preparationDisplayStates.Clear(); } @@ -166,13 +187,11 @@ public void AdvanceDisplay() return; } - // Same visual principle as Engineering discovery cards: 20 FPS, - // ease-out movement, bounded minimum speed, and no artificial loop. var completing = Target >= 99.9d; var movement = Math.Clamp( - remaining * (completing ? 0.16d : 0.115d), - 0.10d, - completing ? 2.4d : 1.15d); + remaining * (completing ? 0.24d : 0.18d), + 0.18d, + completing ? 3.4d : 1.8d); Display = Math.Min(Target, Display + movement); } } diff --git a/MainWindow.IoFatCommandBridge.cs b/MainWindow.IoFatCommandBridge.cs index 1fe1f7954..bb3683b12 100644 --- a/MainWindow.IoFatCommandBridge.cs +++ b/MainWindow.IoFatCommandBridge.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using ArIED61850Tester.Models; using ArIED61850Tester.Models.IoTesting; @@ -36,6 +37,8 @@ public partial class MainWindow internal async Task RefreshIoFatCommandValuesAsync(Iec61850MonitorDevice device) { ArgumentNullException.ThrowIfNull(device); + var stopwatch = Stopwatch.StartNew(); + var fallbackRead = false; // Preload is the same serialized live ctlModel authority used by the Engineering // Command Panel. StatusOnly stays read-only and never enters CommandSignals. @@ -45,16 +48,88 @@ internal async Task RefreshIoFatCommandValuesAsync(Iec61850MonitorDevice device) foreach (var signal in device.Signals.Where(signal => signal.IsControlSignal && signal.IsValidControlObject)) _signalOwners[signal] = device; - if (device.IsConnected && device.CommandSignals.Count > 0) - await RefreshControlValuesAsync(device, force: true); + // FAT is only a projection of the already-running Engineering session. Index the + // shared process image first and immediately seed command rows from its current + // report/poll values. Do not issue a second forced MMS read for values Engineering + // already owns; the normal refresh path is retained only as a fail-safe for rows + // whose status value is still unavailable. + RebuildControlFeedbackIndex(device); + ProjectIoFatCommandValuesFromSharedProcessImage(device); + + if (device.IsConnected && device.CommandSignals.Any(signal => + signal.ControlCurrentValue == "-" || + string.IsNullOrWhiteSpace(signal.ControlCurrentValue) || + signal.ControlModelText == "Auto-detect")) + { + fallbackRead = true; + await RefreshControlValuesAsync(device, force: false); + } device.RefreshCommandSignalProjection(); + RebuildControlFeedbackIndex(device); + + // A report may have advanced the process image while the fallback inspection was + // running. Re-apply the shared image last so LIVE VALUE always reflects the same + // report-backed state that Engineering presents, not an older inspection sample. + var projected = ProjectIoFatCommandValuesFromSharedProcessImage(device); + Trace.WriteLine( + $"[IO FAT P0] Command values refresh completed in {stopwatch.ElapsedMilliseconds} ms; " + + $"device={device.Name}; projected={projected}; fallbackRead={fallbackRead}."); + } + + private int ProjectIoFatCommandValuesFromSharedProcessImage(Iec61850MonitorDevice device) + { + if (device.CommandSignals.Count == 0 || device.Points.Count == 0) + return 0; + + var latestByReference = device.Points + .Where(point => !string.IsNullOrWhiteSpace(point.IecReference)) + .GroupBy(point => NormalizeReference(point.IecReference), StringComparer.OrdinalIgnoreCase) + .ToDictionary( + group => group.Key, + group => group.OrderByDescending(point => point.Sequence).First(), + StringComparer.OrdinalIgnoreCase); + + var projected = 0; + foreach (var signal in device.CommandSignals) + { + if (string.IsNullOrWhiteSpace(signal.ControlStatusReference)) + continue; + + var key = NormalizeReference(signal.ControlStatusReference); + if (!latestByReference.TryGetValue(key, out var point)) + continue; + + var value = point.Value?.Trim() ?? string.Empty; + if (value.Length == 0 || value == "-") + continue; + + signal.ControlCurrentValue = value; + projected++; + } + + return projected; } - internal Task ExecuteIoFatControlClaimAsync(SignalDefinition signal, ControlCommandClaim claim) + internal async Task ExecuteIoFatControlClaimAsync(SignalDefinition signal, ControlCommandClaim claim) { ArgumentNullException.ThrowIfNull(signal); ArgumentNullException.ThrowIfNull(claim); - return ExecuteClaimedControlAsync(signal, claim); + + var stopwatch = Stopwatch.StartNew(); + try + { + await ExecuteClaimedControlAsync(signal, claim); + Trace.WriteLine( + $"[IO FAT P0] Command completed in {stopwatch.ElapsedMilliseconds} ms; " + + $"signal={signal.DisplayReference}; current={signal.ControlCurrentValue}; model={signal.ControlModelText}."); + } + catch (Exception ex) + { + Trace.WriteLine( + $"[IO FAT P0] Command failed after {stopwatch.ElapsedMilliseconds} ms; " + + $"signal={signal.DisplayReference}; error={ex.Message}."); + throw; + } } } diff --git a/MainWindow.IoFatConnectionHealth.cs b/MainWindow.IoFatConnectionHealth.cs new file mode 100644 index 000000000..a6312f4f8 --- /dev/null +++ b/MainWindow.IoFatConnectionHealth.cs @@ -0,0 +1,179 @@ +using System.Collections.Specialized; +using System.ComponentModel; +using System.Windows; +using ArIED61850Tester.Models; +using ArIED61850Tester.Models.IoTesting; + +namespace ArIED61850Tester; + +/// +/// Keeps FAT IED cards on the same connection/monitoring authority as Engineering. +/// No MMS probe or polling loop is introduced here: existing device lifecycle events are +/// projected immediately into IoTestIedPlan. Last process values are intentionally retained +/// as historical display when the association drops; the card state is the authority that +/// tells the operator those values are no longer live. +/// +public partial class MainWindow +{ + private static readonly bool P0IoFatConnectionHealthRegistered = RegisterP0IoFatConnectionHealth(); + private readonly HashSet _p0IoFatHealthDevices = new(); + private IoListTestingWindow? _p0IoFatHealthWindow; + + private static bool RegisterP0IoFatConnectionHealth() + { + EventManager.RegisterClassHandler( + typeof(IoListTestingWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(P0IoFatConnectionHealth_Loaded)); + return true; + } + + private static void P0IoFatConnectionHealth_Loaded(object sender, RoutedEventArgs e) + { + if (sender is IoListTestingWindow fat && fat.Owner is MainWindow engineering) + engineering.AttachP0IoFatConnectionHealth(fat); + } + + private void AttachP0IoFatConnectionHealth(IoListTestingWindow fat) + { + if (ReferenceEquals(_p0IoFatHealthWindow, fat)) + { + SynchronizeP0IoFatConnectionHealth(); + return; + } + + DetachP0IoFatConnectionHealth(); + _p0IoFatHealthWindow = fat; + Devices.CollectionChanged += P0IoFatDevices_CollectionChanged; + foreach (var device in Devices) + AttachP0IoFatHealthDevice(device); + + fat.Closed += P0IoFatHealthWindow_Closed; + SynchronizeP0IoFatConnectionHealth(); + } + + private void DetachP0IoFatConnectionHealth() + { + Devices.CollectionChanged -= P0IoFatDevices_CollectionChanged; + foreach (var device in _p0IoFatHealthDevices.ToArray()) + device.PropertyChanged -= P0IoFatDevice_PropertyChanged; + _p0IoFatHealthDevices.Clear(); + + if (_p0IoFatHealthWindow != null) + _p0IoFatHealthWindow.Closed -= P0IoFatHealthWindow_Closed; + _p0IoFatHealthWindow = null; + } + + private void P0IoFatHealthWindow_Closed(object? sender, EventArgs e) + => DetachP0IoFatConnectionHealth(); + + private void P0IoFatDevices_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + if (e.OldItems != null) + { + foreach (var device in e.OldItems.OfType()) + DetachP0IoFatHealthDevice(device); + } + + if (e.NewItems != null) + { + foreach (var device in e.NewItems.OfType()) + AttachP0IoFatHealthDevice(device); + } + + SynchronizeP0IoFatConnectionHealth(); + } + + private void AttachP0IoFatHealthDevice(Iec61850MonitorDevice device) + { + if (!_p0IoFatHealthDevices.Add(device)) + return; + device.PropertyChanged += P0IoFatDevice_PropertyChanged; + } + + private void DetachP0IoFatHealthDevice(Iec61850MonitorDevice device) + { + if (!_p0IoFatHealthDevices.Remove(device)) + return; + device.PropertyChanged -= P0IoFatDevice_PropertyChanged; + } + + private void P0IoFatDevice_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (sender is not Iec61850MonitorDevice device || + e.PropertyName is not (nameof(Iec61850MonitorDevice.IsConnected) or + nameof(Iec61850MonitorDevice.IsMonitoring) or + nameof(Iec61850MonitorDevice.Status))) + { + return; + } + + if (!Dispatcher.CheckAccess()) + { + try + { + Dispatcher.BeginInvoke(new Action(() => SynchronizeP0IoFatConnectionHealth(device))); + } + catch (InvalidOperationException) + { + // Window teardown only; protocol state remains owned by Engineering. + } + return; + } + + SynchronizeP0IoFatConnectionHealth(device); + } + + private void SynchronizeP0IoFatConnectionHealth() + { + if (_p0IoFatHealthWindow is not { IsLoaded: true } fat) + return; + + foreach (var device in Devices) + SynchronizeP0IoFatConnectionHealth(device, fat); + + // Plans whose Engineering device disappeared must not keep a stale LIVE badge. + foreach (var ied in fat.Project.Ieds) + { + if (ResolveP0FatDevice(ied) == null) + ied.ApplyLiveDeviceBinding(null, "Disconnected · Engineering device unavailable"); + } + } + + private void SynchronizeP0IoFatConnectionHealth(Iec61850MonitorDevice device) + { + if (_p0IoFatHealthWindow is { IsLoaded: true } fat) + SynchronizeP0IoFatConnectionHealth(device, fat); + } + + private static string P0IoFatDeviceStatus(Iec61850MonitorDevice device) + { + if (!device.IsConnected) + return string.IsNullOrWhiteSpace(device.Status) + ? "Disconnected" + : $"Disconnected · {device.Status}"; + if (!device.IsMonitoring) + return string.IsNullOrWhiteSpace(device.Status) + ? "Connected · acquisition stopped" + : $"Connected · {device.Status}"; + return $"Monitoring · {device.AcquisitionMode}"; + } + + private void SynchronizeP0IoFatConnectionHealth( + Iec61850MonitorDevice device, + IoListTestingWindow fat) + { + foreach (var ied in fat.Project.Ieds) + { + var owner = ResolveP0FatDevice(ied); + if (!ReferenceEquals(owner, device)) + continue; + + ied.ApplyLiveDeviceBinding( + device.DeviceId, + P0IoFatDeviceStatus(device), + device.IsConnected, + device.IsMonitoring); + } + } +} diff --git a/MainWindow.IoTesting.MultiSessionEvidence.cs b/MainWindow.IoTesting.MultiSessionEvidence.cs index 7f399163f..cd5fa0652 100644 --- a/MainWindow.IoTesting.MultiSessionEvidence.cs +++ b/MainWindow.IoTesting.MultiSessionEvidence.cs @@ -9,10 +9,9 @@ public partial class MainWindow /// /// P2 attaches the FAT window's additional per-IED evidence leaves to the same - /// Engineering IEC 61850 runtime. The existing primary controller keeps its legacy - /// Runtime_IoTestPointUpdated route; only sibling leaves use the additional route so a - /// primary live observation can never be journaled twice. Every isolated controller is - /// also registered with commissioning recovery so reconnect/auto-resume works per IED. + /// Engineering IEC 61850 runtime. Physical-relay P0 no longer gives FAT a second raw + /// PointUpdated stream: all primary/sibling leaves consume the already-coalesced + /// Engineering process image after UiFlushTimer_Tick instead. /// internal void AttachIoTestParallelEvidenceSessions( IoTestMultiSessionCoordinator coordinator, @@ -31,20 +30,28 @@ internal void AttachIoTestParallelEvidenceSessions( }); _activeIoTestMultiSessionCoordinator = coordinator; + + // Legacy direct route intentionally disabled for FAT P0 because it observes raw + // frames before Engineering's process image is settled: + // _runtime.PointUpdated += Runtime_IoTestAdditionalPointUpdated; _runtime.PointUpdated -= Runtime_IoTestAdditionalPointUpdated; - _runtime.PointUpdated += Runtime_IoTestAdditionalPointUpdated; + AttachIoFatSharedProcessEvidenceRoute(coordinator); } internal void DetachIoTestParallelEvidenceSessions(IoTestMultiSessionCoordinator coordinator) { if (coordinator == null) return; + + DetachIoFatSharedProcessEvidenceRoute(coordinator); if (ReferenceEquals(_activeIoTestMultiSessionCoordinator, coordinator)) _activeIoTestMultiSessionCoordinator = null; _runtime.PointUpdated -= Runtime_IoTestAdditionalPointUpdated; ClearFatCommissioningControllers(); } + // Retained as a compatibility implementation for older tests/source branches. The P0 + // relay-bench route above deliberately does not subscribe this raw callback. private void Runtime_IoTestAdditionalPointUpdated(Iec61850PointSnapshot snapshot) { var coordinator = _activeIoTestMultiSessionCoordinator; diff --git a/MainWindow.P0FatRecovery.cs b/MainWindow.P0FatRecovery.cs index a295390ce..b9254371d 100644 --- a/MainWindow.P0FatRecovery.cs +++ b/MainWindow.P0FatRecovery.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.Diagnostics; using System.Threading; using System.Windows; using System.Windows.Threading; @@ -22,6 +23,10 @@ public partial class MainWindow private static readonly bool P0FatRecoveryClassHandlersRegistered = RegisterP0FatRecoveryClassHandlers(); private readonly ConcurrentDictionary _p0FatLatestSnapshots = new(StringComparer.OrdinalIgnoreCase); + private Dictionary> _p0FatPointIndex = + new(StringComparer.OrdinalIgnoreCase); + private IoTestProject? _p0FatPointIndexProject; + private int _p0FatPointIndexVersion = int.MinValue; private int _p0FatDrainScheduled; private int _p0FatProjectionActive; private bool _p0FatRuntimeProjectionAttached; @@ -60,6 +65,7 @@ private void P0MainWindowClosed(object? sender, EventArgs e) Closed -= P0MainWindowClosed; Interlocked.Exchange(ref _p0FatProjectionActive, 0); _p0FatLatestSnapshots.Clear(); + ClearP0FatPointIndex(); if (!_p0FatRuntimeProjectionAttached) return; @@ -99,6 +105,7 @@ private void P0FatWindowClosed(object? sender, EventArgs e) fat.Closed -= P0FatWindowClosed; SuspendIoFatRuntimeProjection(fat); + ClearP0FatPointIndex(); } internal void SuspendIoFatRuntimeProjection(IoListTestingWindow fat) @@ -177,10 +184,10 @@ private void P0DrainFatRuntimeProjection() if (latest.Length == 0) return; - // Resolve against the exact live-point identity on the Dispatcher. This also - // bridges an engine-owned structured static member (for example MMXU A.phsA) - // to its one resolved scalar runtime ObjectReference when that bridge is unique. - var index = BuildP0FatPointIndex(fat.Project); + // The expensive plan -> live-point resolution is cached across report frames. + // A cheap structural version check rebuilds it only when an IED/model/monitor + // point inventory actually changes (for example first monitor start/reconnect). + var index = GetP0FatPointIndex(fat.Project); foreach (var pair in latest) { if (!index.TryGetValue(pair.Key, out var plans)) @@ -216,7 +223,9 @@ private void P0DrainFatRuntimeProjection() private void P0RefreshFatFromEngineeringImage(IoListTestingWindow fat) { - var index = BuildP0FatPointIndex(fat.Project); + // A deliberate full refresh is a lifecycle boundary. Rebuild once here so any + // changed static-member -> scalar-runtime bridge is repaired before applying image. + var index = GetP0FatPointIndex(fat.Project, forceRebuild: true); var applied = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var device in Devices) @@ -244,6 +253,56 @@ private void P0RefreshFatFromEngineeringImage(IoListTestingWindow fat) } } + private Dictionary> GetP0FatPointIndex( + IoTestProject project, + bool forceRebuild = false) + { + var version = ComputeP0FatPointIndexVersion(project); + if (!forceRebuild && + ReferenceEquals(_p0FatPointIndexProject, project) && + _p0FatPointIndexVersion == version) + { + return _p0FatPointIndex; + } + + var stopwatch = Stopwatch.StartNew(); + var rebuilt = BuildP0FatPointIndex(project); + _p0FatPointIndex = rebuilt; + _p0FatPointIndexProject = project; + _p0FatPointIndexVersion = ComputeP0FatPointIndexVersion(project); + Trace.WriteLine( + $"[IO FAT P0] Live projection index rebuilt: {rebuilt.Count} key(s) in {stopwatch.ElapsedMilliseconds} ms."); + return rebuilt; + } + + private int ComputeP0FatPointIndexVersion(IoTestProject project) + { + var hash = new HashCode(); + hash.Add(project.Ieds.Count); + foreach (var ied in project.Ieds) + { + hash.Add(ied.IedName, StringComparer.OrdinalIgnoreCase); + hash.Add(ied.LiveDeviceId, StringComparer.OrdinalIgnoreCase); + hash.Add(ied.TestPoints.Count); + } + + hash.Add(Devices.Count); + foreach (var device in Devices) + { + hash.Add(device.DeviceId, StringComparer.OrdinalIgnoreCase); + hash.Add(device.Signals.Count); + hash.Add(device.Points.Count); + } + return hash.ToHashCode(); + } + + private void ClearP0FatPointIndex() + { + _p0FatPointIndex.Clear(); + _p0FatPointIndexProject = null; + _p0FatPointIndexVersion = int.MinValue; + } + private Dictionary> BuildP0FatPointIndex(IoTestProject project) { var index = new Dictionary>(StringComparer.OrdinalIgnoreCase); diff --git a/MainWindow.P0FatSharedProcessEvidence.cs b/MainWindow.P0FatSharedProcessEvidence.cs new file mode 100644 index 000000000..0d64f4456 --- /dev/null +++ b/MainWindow.P0FatSharedProcessEvidence.cs @@ -0,0 +1,166 @@ +using System.Windows.Threading; +using ArIED61850Tester.Models; +using ArIED61850Tester.Models.IoTesting; +using ArIED61850Tester.Services.IoTesting; + +namespace ArIED61850Tester; + +/// +/// Physical-relay FAT must be a consumer of Engineering's process image, not a second raw +/// runtime observer. Engineering already coalesces runtime traffic, applies the CSWI position +/// stability guard, and updates device.Points on its UI flush. FAT samples that exact image +/// immediately after the Engineering flush, so LIVE VALUE and evidence use one authority. +/// +/// This also removes two high-rate raw PointUpdated consumers while FAT is open. A 57-point +/// relay therefore produces one coalesced shared-image pass per Engineering UI frame instead +/// of three independent WPF projection/evidence pipelines. +/// +public partial class MainWindow +{ + private readonly Dictionary _p0FatSharedProcessCursors = + new(StringComparer.OrdinalIgnoreCase); + private IoTestMultiSessionCoordinator? _p0FatSharedProcessCoordinator; + private bool _p0FatSharedProcessRouteAttached; + + internal void AttachIoFatSharedProcessEvidenceRoute(IoTestMultiSessionCoordinator coordinator) + { + ArgumentNullException.ThrowIfNull(coordinator); + + _p0FatSharedProcessCoordinator = coordinator; + _p0FatSharedProcessCursors.Clear(); + + // Primary/sibling legacy routes observe raw runtime frames before Engineering has + // coalesced them. P0FatRuntimePointUpdated is another raw presentation observer. + // Detach all three while FAT is open; the UI-flush route below is the single source. + _runtime.PointUpdated -= Runtime_IoTestPointUpdated; + _runtime.PointUpdated -= Runtime_IoTestAdditionalPointUpdated; + _runtime.PointUpdated -= P0FatRuntimePointUpdated; + + if (_p0FatSharedProcessRouteAttached) + return; + + _p0FatSharedProcessRouteAttached = true; + _uiFlushTimer.Tick -= P0FatSharedProcessEvidence_Tick; + _uiFlushTimer.Tick += P0FatSharedProcessEvidence_Tick; + } + + internal void DetachIoFatSharedProcessEvidenceRoute(IoTestMultiSessionCoordinator coordinator) + { + if (!ReferenceEquals(_p0FatSharedProcessCoordinator, coordinator)) + return; + + _p0FatSharedProcessCoordinator = null; + _p0FatSharedProcessCursors.Clear(); + + if (!_p0FatSharedProcessRouteAttached) + return; + + _uiFlushTimer.Tick -= P0FatSharedProcessEvidence_Tick; + _p0FatSharedProcessRouteAttached = false; + } + + private void P0FatSharedProcessEvidence_Tick(object? sender, EventArgs e) + { + var fat = _loadedIoFatWindow; + var coordinator = _p0FatSharedProcessCoordinator; + if (fat is not { IsLoaded: true } || coordinator == null) + return; + + // UiFlushTimer_Tick was registered in MainWindow's constructor. This handler is + // appended later when FAT opens, therefore device.Points already contains the exact + // value visible in Engineering for this frame. + var pointIndex = GetP0FatPointIndex(fat.Project); + var activeDeviceIds = coordinator.Project.Ieds + .Where(coordinator.IsIedSessionActive) + .Select(ResolveP0FatDevice) + .Where(device => device != null) + .Select(device => device!.DeviceId) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var device in Devices) + { + foreach (var point in device.Points) + { + ProjectSharedEngineeringPointToFat(pointIndex, point); + + if (!activeDeviceIds.Contains(device.DeviceId)) + continue; + + var key = string.IsNullOrWhiteSpace(point.PointKey) + ? $"{point.DeviceId}|{IoTestLiveBindingService.NormalizeReference(point.IecReference)}" + : point.PointKey.Trim(); + if (key.Length == 0) + continue; + + _p0FatSharedProcessCursors.TryGetValue(key, out var previous); + if (previous != null && + Iec61850MonitorPoint.AreSemanticallyEquivalent(previous.Value, point.Value) && + string.Equals(previous.Quality, point.Quality, StringComparison.Ordinal)) + { + // Point.Sequence is transport/evidence ordering metadata. A new sequence + // with the same process value must not create another FAT evidence job. + // Keep the cursor current without waking the evidence Dispatcher. + if (previous.Sequence != point.Sequence) + { + _p0FatSharedProcessCursors[key] = new StableFatProcessCursor( + point.Sequence, + point.Value, + point.Quality); + } + continue; + } + + var previousValue = previous?.Value ?? point.Value; + _p0FatSharedProcessCursors[key] = new StableFatProcessCursor( + point.Sequence, + point.Value, + point.Quality); + + var entry = new Iec61850EventEntry + { + Sequence = Interlocked.Increment(ref _ioTestObservationSequence), + DeviceId = point.DeviceId, + PointKey = point.PointKey, + DeviceTimestamp = point.DeviceTimestamp, + DeviceName = point.DeviceName, + IpAddress = point.IpAddress, + SignalName = point.SignalName, + IecReference = point.IecReference, + OldValue = previousValue, + NewValue = point.Value, + Quality = point.Quality, + SourceMode = point.SourceMode, + Reason = point.Reason + }; + + coordinator.PrimaryController.Enqueue(entry); + coordinator.EnqueueAdditional(entry); + } + } + } + + private static void ProjectSharedEngineeringPointToFat( + IReadOnlyDictionary> pointIndex, + Iec61850MonitorPoint point) + { + List? plans = null; + var pointKey = point.PointKey?.Trim() ?? string.Empty; + if (pointKey.Length > 0) + pointIndex.TryGetValue(pointKey, out plans); + + if (plans == null) + { + var fallback = P0FatKey(point.DeviceId, point.IecReference); + if (fallback.Length > 0) + pointIndex.TryGetValue(fallback, out plans); + } + + if (plans == null) + return; + + foreach (var plan in plans) + ApplyP0FatLivePoint(plan.Runtime, point); + } + + private sealed record StableFatProcessCursor(long Sequence, string Value, string Quality); +} diff --git a/MainWindow.P0RelayBenchControlAuthority.cs b/MainWindow.P0RelayBenchControlAuthority.cs new file mode 100644 index 000000000..a6b5a3847 --- /dev/null +++ b/MainWindow.P0RelayBenchControlAuthority.cs @@ -0,0 +1,34 @@ +using ArIED61850Tester.Models; + +namespace ArIED61850Tester; + +public partial class MainWindow +{ + /// + /// Reasserts the shared Engineering process image after a FAT control transaction. + /// Command-service return values are transaction evidence; they must never outrank the + /// latest live status point that Engineering and FAT already share. + /// + internal void ReconcileIoFatCommandValueFromSharedProcessImage(SignalDefinition signal) + { + ArgumentNullException.ThrowIfNull(signal); + + var device = _signalOwners.TryGetValue(signal, out var owner) + ? owner + : SelectedDevice; + if (device == null || string.IsNullOrWhiteSpace(signal.ControlStatusReference)) + return; + + var expected = NormalizeReference(signal.ControlStatusReference); + var latest = device.Points + .Where(point => NormalizeReference(point.IecReference) + .Equals(expected, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(point => point.Sequence) + .FirstOrDefault(); + + if (latest == null || string.IsNullOrWhiteSpace(latest.Value) || latest.Value == "-") + return; + + signal.ControlCurrentValue = latest.Value; + } +} diff --git a/Services/IoTesting/FatAutoCaptureCoordinator.cs b/Services/IoTesting/FatAutoCaptureCoordinator.cs index 3bb3093de..71bfa7efc 100644 --- a/Services/IoTesting/FatAutoCaptureCoordinator.cs +++ b/Services/IoTesting/FatAutoCaptureCoordinator.cs @@ -8,21 +8,27 @@ namespace ArIED61850Tester.Services.IoTesting; public sealed record FatAutoCaptureDecision( FatValueEvidence? Evidence, FatAutoCaptureStage Stage, - string Message) + string Message, + FatValueEvidence? ShiftedValue1Evidence = null) { public static FatAutoCaptureDecision None(FatAutoCaptureStage stage, string message) => new(null, stage, message); } /// -/// P3 automatic Value 1 / Value 2 latch. This coordinator never mutates the current -/// evidence pointers: callers must durably append the returned evidence first, then -/// promote it. That keeps the evidence journal authoritative when storage fails. +/// Automatic Value 1 / Value 2 latch. The first good readable value is latched as +/// Value 1 and the first meaningful different value as Value 2. Once both slots are +/// populated, later meaningful process changes keep a rolling pair: previous Value 2 +/// becomes Value 1 and the newest process value becomes Value 2. /// -/// 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. +/// The report-facing current pair always follows the latest meaningful process transition, +/// regardless of whether an earlier pair came from automatic capture or operator Recapture. +/// Operator actions remain immutable audit history in the journal; they do not freeze the +/// replaceable current Value 1 / Value 2 projection. +/// +/// Report-backed process values are event evidence already, so they are accepted +/// immediately. Polling fallback keeps the three-sample analog settling guard so noisy +/// cyclic reads are not mistaken for a meaningful condition change. /// public sealed class FatAutoCaptureCoordinator { @@ -48,14 +54,6 @@ public FatAutoCaptureDecision Observe(IoTestPointPlan point, IoTestObservation o "Automatic capture is outside the active FAT scope."); } - if (point.HasValue1Evidence && point.HasValue2Evidence) - { - Clear(point); - return FatAutoCaptureDecision.None( - FatAutoCaptureStage.Complete, - "Current Value 1 / Value 2 evidence is complete; explicit Recapture is required to replace it."); - } - var (qualityVerdict, qualityReason) = IoTestTransitionEvaluator.EvaluateQuality(observation.Quality); if (qualityVerdict != IoEvidenceVerdict.Accepted) { @@ -73,8 +71,18 @@ public FatAutoCaptureDecision Observe(IoTestPointPlan point, IoTestObservation o "Waiting for a readable live value."); } + var rollingPair = point.HasValue1Evidence && point.HasValue2Evidence; + if (rollingPair && IsEquivalent(point.Value2Text, observation.RawValue)) + { + Clear(point); + return FatAutoCaptureDecision.None( + FatAutoCaptureStage.Complete, + "Current Value 1 / Value 2 pair is up to date; waiting for the next meaningful process change."); + } + var slot = point.HasValue1Evidence ? FatValueSlot.Value2 : FatValueSlot.Value1; - if (slot == FatValueSlot.Value2 && IsEquivalent(point.Value1Text, observation.RawValue)) + var comparisonBaseline = rollingPair ? point.Value2Text : point.Value1Text; + if (slot == FatValueSlot.Value2 && !rollingPair && IsEquivalent(point.Value1Text, observation.RawValue)) { Clear(point); return FatAutoCaptureDecision.None( @@ -82,15 +90,12 @@ public FatAutoCaptureDecision Observe(IoTestPointPlan point, IoTestObservation o "Value 1 is stable; waiting for a meaningful new condition."); } - if (point.SignalKind != FatSignalKind.Analog) + if (point.SignalKind != FatSignalKind.Analog || IsReportBacked(observation.AcquisitionSource)) { Clear(point); - return new FatAutoCaptureDecision( - CreateEvidence(slot, observation), - slot == FatValueSlot.Value1 ? FatAutoCaptureStage.WaitingChange : FatAutoCaptureStage.Complete, - slot == FatValueSlot.Value1 - ? "Value 1 captured automatically; waiting for a meaningful change." - : "Value 2 captured automatically from the new live condition."); + var reportBackedAnalog = point.SignalKind == FatSignalKind.Analog; + var evidence = CreateEvidence(slot, observation); + return BuildDecision(point, evidence, rollingPair, reportBackedAnalog); } if (!TryParseNumeric(observation.RawValue, out var numeric)) @@ -98,11 +103,11 @@ 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 polling value is not numerically stable enough for automatic capture; manual Recapture remains available."); } var key = point.TestPointId; - var tolerance = SettlingTolerance(numeric, point.Value1Text); + var tolerance = SettlingTolerance(numeric, comparisonBaseline); if (!_analogCandidates.TryGetValue(key, out var candidate) || candidate.Slot != slot || Math.Abs(numeric - candidate.Center) > tolerance) @@ -110,26 +115,21 @@ public FatAutoCaptureDecision Observe(IoTestPointPlan point, IoTestObservation o _analogCandidates[key] = new AnalogCandidate(slot, numeric, numeric, numeric, 1); return FatAutoCaptureDecision.None( slot == FatValueSlot.Value1 ? FatAutoCaptureStage.WaitingValue1 : FatAutoCaptureStage.StabilizingValue2, - slot == FatValueSlot.Value1 ? "Stabilizing Value 1…" : "Stabilizing Value 2…"); + slot == FatValueSlot.Value1 ? "Stabilizing polled Value 1…" : "Stabilizing polled Value 2…"); } var next = candidate.Add(numeric); _analogCandidates[key] = next; - var nextTolerance = SettlingTolerance(next.Center, point.Value1Text); + var nextTolerance = SettlingTolerance(next.Center, comparisonBaseline); if (next.Count < AnalogStableSampleCount || next.Max - next.Min > nextTolerance) { return FatAutoCaptureDecision.None( slot == FatValueSlot.Value1 ? FatAutoCaptureStage.WaitingValue1 : FatAutoCaptureStage.StabilizingValue2, - slot == FatValueSlot.Value1 ? "Stabilizing Value 1…" : "Stabilizing Value 2…"); + slot == FatValueSlot.Value1 ? "Stabilizing polled Value 1…" : "Stabilizing polled Value 2…"); } _analogCandidates.Remove(key); - return new FatAutoCaptureDecision( - CreateEvidence(slot, observation), - slot == FatValueSlot.Value1 ? FatAutoCaptureStage.WaitingChange : FatAutoCaptureStage.Complete, - slot == FatValueSlot.Value1 - ? "Stable analog Value 1 captured; waiting for a meaningful new condition." - : "Stable analog Value 2 captured; current evidence is complete."); + return BuildDecision(point, CreateEvidence(slot, observation), rollingPair, reportBackedAnalog: false); } public void Clear(IoTestPointPlan point) @@ -140,6 +140,66 @@ public void Clear(IoTestPointPlan point) public void Clear() => _analogCandidates.Clear(); + private static FatAutoCaptureDecision BuildDecision( + IoTestPointPlan point, + FatValueEvidence evidence, + bool rollingPair, + bool reportBackedAnalog) + { + if (rollingPair && evidence.Slot == FatValueSlot.Value2) + { + var previousValue2 = point.Runtime.Value2Evidence; + var shiftedValue1 = previousValue2 == null + ? null + : previousValue2 with + { + EvidenceId = Guid.NewGuid(), + Slot = FatValueSlot.Value1 + }; + + // Advance the current projection before the controller promotes the newest V2. + // The shifted item is the already-journaled previous V2, so no process evidence + // is invented; this keeps current-pair assessment atomic for the live UI. + if (shiftedValue1 != null) + point.Runtime.SetFatValueEvidence(shiftedValue1); + + return new FatAutoCaptureDecision( + evidence, + FatAutoCaptureStage.Complete, + reportBackedAnalog + ? "Latest analog change captured from report-backed process data; Value 1 / Value 2 advanced to the newest transition pair." + : "Latest live change captured; Value 1 / Value 2 advanced to the newest transition pair.", + shiftedValue1); + } + + return new FatAutoCaptureDecision( + evidence, + evidence.Slot == FatValueSlot.Value1 ? FatAutoCaptureStage.WaitingChange : FatAutoCaptureStage.Complete, + evidence.Slot == FatValueSlot.Value1 + ? reportBackedAnalog + ? "Stable analog Value 1 captured immediately from report-backed process data; waiting for a meaningful change." + : "Value 1 captured automatically; waiting for a meaningful change." + : reportBackedAnalog + ? "Stable analog Value 2 captured immediately from the new report-backed process condition; later changes will keep the pair current." + : "Value 2 captured automatically from the new live condition; later changes will keep the pair current."); + } + + private static bool IsReportBacked(string? acquisitionSource) + { + if (string.IsNullOrWhiteSpace(acquisitionSource)) + return false; + + var source = acquisitionSource.Trim(); + if (source.Contains("POLL", StringComparison.OrdinalIgnoreCase)) + return false; + + return source.Contains("BRCB", StringComparison.OrdinalIgnoreCase) || + source.Contains("URCB", StringComparison.OrdinalIgnoreCase) || + source.Contains("RCB", StringComparison.OrdinalIgnoreCase) || + source.Contains("REPORT", StringComparison.OrdinalIgnoreCase) || + source.Contains("INFORMATIONREPORT", StringComparison.OrdinalIgnoreCase); + } + private static FatValueEvidence CreateEvidence(FatValueSlot slot, IoTestObservation observation) => new( Guid.NewGuid(), @@ -158,11 +218,29 @@ private static bool IsEquivalent(string? baseline, string? current) if (Iec61850MonitorPoint.AreSemanticallyEquivalent(baseline ?? string.Empty, current ?? string.Empty)) return true; - return TryParseNumeric(baseline, out var left) && - TryParseNumeric(current, out var right) && + return TryParseScalarNumeric(baseline, out var left) && + TryParseScalarNumeric(current, out var right) && Math.Abs(left - right) <= SettlingTolerance(right, baseline); } + private static bool TryParseScalarNumeric(string? raw, out double value) + { + value = 0d; + if (string.IsNullOrWhiteSpace(raw)) + return false; + + var matches = NumericToken.Matches(raw.Trim()); + if (matches.Count != 1) + return false; + + var token = matches[0].Value; + if (double.TryParse(token, NumberStyles.Float, CultureInfo.InvariantCulture, out value)) + return true; + + token = token.Replace(',', '.'); + return double.TryParse(token, NumberStyles.Float, CultureInfo.InvariantCulture, out value); + } + private static double SettlingTolerance(double value, string? baseline) { var baselineMagnitude = TryParseNumeric(baseline, out var parsedBaseline) diff --git a/Services/IoTesting/FatCurrentEvidenceAssessmentService.cs b/Services/IoTesting/FatCurrentEvidenceAssessmentService.cs index 8ce3e2dda..5612cb39a 100644 --- a/Services/IoTesting/FatCurrentEvidenceAssessmentService.cs +++ b/Services/IoTesting/FatCurrentEvidenceAssessmentService.cs @@ -4,14 +4,9 @@ namespace ArIED61850Tester.Services.IoTesting; /// /// Assesses the exact Value 1 / Value 2 pair currently presented by FAT v2. -/// -/// The legacy transition state machine remains responsible for collecting historical -/// OFF -> ON -> OFF evidence. Once generic FAT evidence overrides either current slot, -/// this service becomes the assessment authority only while that pair belongs to the -/// current connection generation. A purely automatic pair that predates or straddles a -/// reconnect must not erase the legacy continuity verdict established by Resume/rebind. -/// Explicit operator Recapture remains authoritative and therefore fails closed to REVIEW -/// when its displayed pair is not coherent. +/// A complete coherent pair proves the transition it contains even when the operator later +/// resumes/reconnects the FAT workspace. Connection generation is a pair-coherency fence, +/// not a requirement that historic completed evidence must equal the newest runtime generation. /// public static class FatCurrentEvidenceAssessmentService { @@ -62,31 +57,17 @@ public static FatCurrentEvidenceAssessment Evaluate(IoTestPointPlan point) "REVIEW: both displayed FAT value slots are required for a current-pair assessment."); } - var hasOperatorOverride = value1.IsOperatorOverride || value2.IsOperatorOverride; - var runtimeGeneration = point.Runtime.ConnectionGeneration; - var pairIsSameGeneration = value1.ConnectionGeneration == value2.ConnectionGeneration; - var pairMatchesCurrentGeneration = - pairIsSameGeneration && - (runtimeGeneration <= 0 || value1.ConnectionGeneration == runtimeGeneration); - - if (!pairMatchesCurrentGeneration) + // Raw MMS/RCB sequence counters are source-local and may legitimately reset or jump + // across report instances, reconnects, or resume/rebaseline. They are diagnostic + // provenance, not the ordering authority for the application-owned rolling Value 1 / + // Value 2 pair. Pair coherence is fenced by connection generation; the coordinator + // itself owns V1 -> V2 ordering. Rejecting a good complete pair merely because the + // source sequence decreased creates a false REVIEW after a perfectly valid FAT edge. + if (value1.ConnectionGeneration != value2.ConnectionGeneration) { - if (!hasOperatorOverride) - { - // Resume/rebind deliberately establishes a new continuity authority. Old - // automatic V1/V2 pointers may remain visible for audit, but they must not - // overwrite REVIEW after a potentially missed edge or overwrite a later - // PASS earned by a complete new legacy cycle. If no terminal continuity - // verdict exists yet, fail closed to REVIEW rather than showing COMPLETE - // with a blank/non-terminal Result. - return PreserveRuntimeAssessment(point, pairIsSameGeneration); - } - return new FatCurrentEvidenceAssessment( IoTestPointState.Review, - pairIsSameGeneration - ? "REVIEW: the operator-selected Value 1 / Value 2 pair belongs to an earlier IED connection generation; recapture a coherent current pair." - : "REVIEW: the operator-selected Value 1 and Value 2 belong to different IED connection generations; recapture a coherent current pair."); + "REVIEW: current Value 1 and Value 2 belong to different IED connection generations; capture one coherent transition pair."); } var value1Quality = IoTestTransitionEvaluator.EvaluateQuality(value1.Quality); @@ -99,13 +80,6 @@ public static FatCurrentEvidenceAssessment Evaluate(IoTestPointPlan point) $"REVIEW: current evidence quality is not fully accepted (V1: {value1Quality.Reason}; V2: {value2Quality.Reason})."); } - if (value2.Sequence <= value1.Sequence) - { - return new FatCurrentEvidenceAssessment( - IoTestPointState.Review, - "REVIEW: current Value 2 does not follow current Value 1 in the live evidence sequence; recapture Value 2 after the intended condition change."); - } - var state1 = IoTestValueNormalizer.Normalize(point, value1.RawValue); var state2 = IoTestValueNormalizer.Normalize(point, value2.RawValue); if (state1 is null || state2 is null) @@ -119,12 +93,12 @@ public static FatCurrentEvidenceAssessment Evaluate(IoTestPointPlan point) { return new FatCurrentEvidenceAssessment( IoTestPointState.Review, - $"REVIEW: Value 1 and Value 2 both resolve to {StateLabel(state1.Value)}; the current pair does not prove a state change."); + $"REVIEW: Value 1 and Value 2 resolve to the same discrete state; the current pair does not prove a state change."); } return new FatCurrentEvidenceAssessment( IoTestPointState.Passed, - $"PASS: current Value 1 -> Value 2 evidence proves a good-quality {StateLabel(state1.Value)} -> {StateLabel(state2.Value)} transition in one connection generation."); + $"PASS: current Value 1 -> Value 2 evidence proves a good-quality {StateLabel(state1.Value)} -> {StateLabel(state2.Value)} transition in one coherent connection generation."); } public static FatCurrentEvidenceAssessment Apply(IoTestPointPlan point) @@ -139,27 +113,6 @@ public static FatCurrentEvidenceAssessment Apply(IoTestPointPlan point) return assessment; } - private static FatCurrentEvidenceAssessment PreserveRuntimeAssessment( - IoTestPointPlan point, - bool pairIsSameGeneration) - { - var terminalState = point.Runtime.State is IoTestPointState.Passed or IoTestPointState.Failed or IoTestPointState.Review; - if (terminalState) - { - return new FatCurrentEvidenceAssessment( - point.Runtime.State, - string.IsNullOrWhiteSpace(point.Runtime.StatusReason) - ? "Automatic current Value 1 / Value 2 evidence predates or straddles the active IED connection generation; the existing live transition continuity verdict remains authoritative." - : point.Runtime.StatusReason); - } - - return new FatCurrentEvidenceAssessment( - IoTestPointState.Review, - pairIsSameGeneration - ? "REVIEW: automatic current Value 1 / Value 2 evidence belongs to an earlier IED connection generation and no terminal live transition continuity verdict is available." - : "REVIEW: automatic current Value 1 and Value 2 belong to different IED connection generations and no terminal live transition continuity verdict is available."); - } - private static CurrentEvidence? EffectiveValue1(IoTestPointPlan point) { if (point.Runtime.Value1Evidence is { } generic) @@ -184,24 +137,21 @@ private sealed record CurrentEvidence( string RawValue, string Quality, long Sequence, - long ConnectionGeneration, - bool IsOperatorOverride) + long ConnectionGeneration) { public static CurrentEvidence From(FatValueEvidence evidence) => new( evidence.RawValue, evidence.Quality, evidence.Sequence, - evidence.ConnectionGeneration, - evidence.CaptureKind != FatEvidenceCaptureKind.AutomaticValue); + evidence.ConnectionGeneration); public static CurrentEvidence From(IoTestTransitionEvidence evidence) => new( evidence.RawValue, evidence.Quality, evidence.Sequence, - evidence.ConnectionGeneration, - false); + evidence.ConnectionGeneration); } } diff --git a/Services/IoTesting/IoFatSupplementalReportLayoutDecorator.cs b/Services/IoTesting/IoFatSupplementalReportLayoutDecorator.cs index 8cef4207d..336d10a3d 100644 --- a/Services/IoTesting/IoFatSupplementalReportLayoutDecorator.cs +++ b/Services/IoTesting/IoFatSupplementalReportLayoutDecorator.cs @@ -4,9 +4,9 @@ namespace ArIED61850Tester.Services.IoTesting; /// -/// Appends controlled IED-level file-service evidence to the same layout consumed by -/// native PDF output and WPF print preview. Existing signal pages are retained and their -/// page totals are corrected after supplemental pages are added. +/// Appends controlled IED-level file-service evidence and a mandatory final FAT acceptance +/// sign-off page to the same layout consumed by native PDF output and WPF Print Preview. +/// Existing page totals are corrected after all supplemental pages are added. /// internal static class IoFatSupplementalReportLayoutDecorator { @@ -36,11 +36,11 @@ public static IoFatReportLayoutPlan AppendFileServiceEvidence( var evidenceIeds = project.Ieds .Where(ied => ied.HasRemoteComtradeEvidence) .ToArray(); - if (evidenceIeds.Length == 0) - return baseLayout; + var evidencePageCount = (int)Math.Ceiling(evidenceIeds.Length / (double)RowsPerPage); - var supplementalPageCount = (int)Math.Ceiling(evidenceIeds.Length / (double)RowsPerPage); - var totalPages = baseLayout.Pages.Count + supplementalPageCount; + // Sign-off is always the final report page, even when there is no COMTRADE evidence. + const int signOffPageCount = 1; + var totalPages = baseLayout.Pages.Count + evidencePageCount + signOffPageCount; var pages = new List(totalPages); for (var index = 0; index < baseLayout.Pages.Count; index++) @@ -52,7 +52,7 @@ public static IoFatReportLayoutPlan AppendFileServiceEvidence( pages.Add(new IoFatReportPagePlan(index + 1, page.Width, page.Height, corrected)); } - for (var pageIndex = 0; pageIndex < supplementalPageCount; pageIndex++) + for (var pageIndex = 0; pageIndex < evidencePageCount; pageIndex++) { var rows = evidenceIeds .Skip(pageIndex * RowsPerPage) @@ -62,6 +62,9 @@ public static IoFatReportLayoutPlan AppendFileServiceEvidence( pages.Add(BuildEvidencePage(project, rows, pageNumber, totalPages, baseLayout.CreatedAt, baseLayout.Draft)); } + var signOffPageNumber = totalPages; + pages.Add(BuildSignOffPage(project, signOffPageNumber, totalPages, baseLayout.CreatedAt, baseLayout.Draft)); + return new IoFatReportLayoutPlan( baseLayout.ProjectId, baseLayout.CreatedAt, @@ -83,7 +86,7 @@ private static IoFatReportPagePlan BuildEvidencePage( project.DocumentControl.PurchaserDocumentNumber, project.DocumentControl.CompanyProjectDocumentNumber, project.ProjectId); - var revision = FirstNonEmpty(project.DocumentControl.Revision, "-"); + var revision = string.IsNullOrWhiteSpace(project.DocumentControl.Revision) ? string.Empty : project.DocumentControl.Revision.Trim(); Line(commands, Margin, 496d, PageWidth - Margin, 496d, Border, 0.8d); Text(commands, Margin, 566d, 480d, projectName, IoFatReportFontKind.Bold, 7.2d, Muted); @@ -95,17 +98,19 @@ private static IoFatReportPagePlan BuildEvidencePage( Rect(commands, 590d, 568d, 222d, 64d, 4d, SoftBlue, Border, 0.7d); Text(commands, 601d, 554d, 200d, "DOCUMENT CONTROL", IoFatReportFontKind.Bold, 5.9d, Muted); Text(commands, 601d, 538d, 200d, documentNumber, IoFatReportFontKind.Bold, 8.2d, Navy); - Text(commands, 601d, 523d, 200d, $"REV {revision} | {(draft ? "PREVIEW" : "AS TESTED")}", IoFatReportFontKind.Bold, 6.7d, Navy); - Text(commands, 601d, 511d, 200d, draft ? "NOT FOR ISSUE" : "CUSTOMER FAT RECORD", IoFatReportFontKind.Regular, 5.8d, Muted); + Text(commands, 601d, 523d, 200d, + string.IsNullOrWhiteSpace(revision) ? "FAT REPORT" : $"REV {revision} | FAT REPORT", + IoFatReportFontKind.Bold, 6.7d, Navy); + Text(commands, 601d, 511d, 200d, "FOR FAT RECORD", IoFatReportFontKind.Regular, 5.8d, Muted); Rect(commands, Margin, 480d, ContentWidth, 42d, 5d, SoftPass, Border, 0.7d); Text(commands, Margin + 12d, 466d, 170d, "FILE SERVICE ACCEPTANCE BASIS", IoFatReportFontKind.Bold, 5.9d, Pass); Text(commands, Margin + 12d, 450d, ContentWidth - 24d, - "PASS = the IED returned a supported COMTRADE/fault-record entry through IEC 61850 FileDirectory. FileOpen/FileRead download is optional additional verification.", + "PASS = the IED returned a supported COMTRADE/fault-record entry through IEC 61850 FileDirectory; remote file identity and relay-modified time are preserved as FAT evidence.", IoFatReportFontKind.Regular, 6.6d, Ink); - var widths = new[] { 105d, 58d, 284d, 108d, 127d, 100d }; - var headers = new[] { "IED", "Result", "Latest remote COMTRADE file(s)", "Relay modified", "Evidence source", "Download" }; + var widths = new[] { 105d, 58d, 334d, 125d, 160d }; + var headers = new[] { "IED", "Result", "Latest remote COMTRADE file(s)", "Relay modified", "Evidence source" }; var y = 426d; var x = Margin; for (var i = 0; i < headers.Length; i++) @@ -133,7 +138,7 @@ private static IoFatReportPagePlan BuildEvidencePage( Text(commands, resultX + 5d, y - 24d, widths[1] - 10d, "PASS", IoFatReportFontKind.Bold, 7.2d, Pass); var fileX = resultX + widths[1]; - var fileLines = Wrap(ied.LatestComtradeFiles, 52, 3); + var fileLines = Wrap(ied.LatestComtradeFiles, 60, 3); var fileY = y - 14d; foreach (var line in fileLines) { @@ -141,7 +146,7 @@ private static IoFatReportPagePlan BuildEvidencePage( fileY -= 10.5d; } if (!string.IsNullOrWhiteSpace(ied.LatestComtradeCompleteness)) - Text(commands, fileX + 5d, y - 48d, widths[2] - 10d, Fit(ied.LatestComtradeCompleteness, 68), IoFatReportFontKind.Regular, 5.4d, Muted); + Text(commands, fileX + 5d, y - 48d, widths[2] - 10d, Fit(ied.LatestComtradeCompleteness, 78), IoFatReportFontKind.Regular, 5.4d, Muted); var modifiedX = fileX + widths[2]; Text(commands, modifiedX + 5d, y - 21d, widths[3] - 10d, @@ -155,20 +160,103 @@ private static IoFatReportPagePlan BuildEvidencePage( Text(commands, sourceX + 5d, y - 20d, widths[4] - 10d, "IEC 61850", IoFatReportFontKind.Bold, 5.9d, Ink); Text(commands, sourceX + 5d, y - 34d, widths[4] - 10d, "FileDirectory", IoFatReportFontKind.Mono, 5.7d, Muted); - var downloadX = sourceX + widths[4]; - Text(commands, downloadX + 5d, y - 20d, widths[5] - 10d, "OPTIONAL", IoFatReportFontKind.Bold, 5.9d, Blue); - Text(commands, downloadX + 5d, y - 34d, widths[5] - 10d, "not a FAT gate", IoFatReportFontKind.Regular, 5.4d, Muted); - y -= rowHeight; } + AddFooter(commands, pageNumber, totalPages, createdAt, + "Remote listing evidence is IED-scoped and persisted with the FAT project."); + return new IoFatReportPagePlan(pageNumber, PageWidth, PageHeight, commands); + } + + private static IoFatReportPagePlan BuildSignOffPage( + IoTestProject project, + int pageNumber, + int totalPages, + DateTimeOffset createdAt, + bool draft) + { + var commands = new List(); + var projectName = FirstNonEmpty(project.DocumentControl.ClientProject, project.ProjectName, project.ProjectId); + var documentNumber = FirstNonEmpty( + project.DocumentControl.PurchaserDocumentNumber, + project.DocumentControl.CompanyProjectDocumentNumber, + project.ProjectId); + var revision = string.IsNullOrWhiteSpace(project.DocumentControl.Revision) ? string.Empty : project.DocumentControl.Revision.Trim(); + + Text(commands, Margin, 566d, 480d, projectName, IoFatReportFontKind.Bold, 7.2d, Muted); + Text(commands, Margin, 544d, 520d, "FAT Acceptance Sign-Off", IoFatReportFontKind.Bold, 17.2d, Navy); + Text(commands, Margin, 522d, 540d, + "Final acceptance record for the IEC 61850 FAT evidence contained in this report.", + IoFatReportFontKind.Regular, 8.0d, Muted); + Line(commands, Margin, 498d, PageWidth - Margin, 498d, Border, 0.8d); + + Rect(commands, 590d, 568d, 222d, 64d, 4d, SoftBlue, Border, 0.7d); + Text(commands, 601d, 554d, 200d, "DOCUMENT CONTROL", IoFatReportFontKind.Bold, 5.9d, Muted); + Text(commands, 601d, 538d, 200d, documentNumber, IoFatReportFontKind.Bold, 8.2d, Navy); + Text(commands, 601d, 523d, 200d, + string.IsNullOrWhiteSpace(revision) ? "FAT REPORT" : $"REV {revision} | FAT REPORT", + IoFatReportFontKind.Bold, 6.7d, Navy); + Text(commands, 601d, 511d, 200d, "FOR FAT RECORD", IoFatReportFontKind.Regular, 5.8d, Muted); + + Text(commands, Margin, 470d, ContentWidth, + "By signing below, the parties acknowledge the FAT execution and evidence recorded in the preceding pages.", + IoFatReportFontKind.Regular, 7.2d, Ink); + + const double gap = 14d; + var boxWidth = (ContentWidth - (gap * 2d)) / 3d; + var x = Margin; + foreach (var heading in new[] { "TESTED BY", "WITNESSED BY", "APPROVED BY" }) + { + DrawSignOffBox(commands, x, 430d, boxWidth, 286d, heading); + x += boxWidth + gap; + } + + AddFooter(commands, pageNumber, totalPages, createdAt, + "Final FAT acceptance signatures."); + return new IoFatReportPagePlan(pageNumber, PageWidth, PageHeight, commands); + } + + private static void DrawSignOffBox( + ICollection commands, + double x, + double top, + double width, + double height, + string heading) + { + Rect(commands, x, top, width, height, 4d, White, Border, 0.8d); + Rect(commands, x, top, width, 34d, 4d, SoftBlue, Border, 0.6d); + Text(commands, x + 12d, top - 21d, width - 24d, heading, IoFatReportFontKind.Bold, 8.3d, Navy); + + var labelX = x + 12d; + var lineX = x + 12d; + var lineRight = x + width - 12d; + + Text(commands, labelX, top - 63d, width - 24d, "Name", IoFatReportFontKind.Bold, 6.1d, Muted); + Line(commands, lineX, top - 92d, lineRight, top - 92d, Border, 0.65d); + + Text(commands, labelX, top - 115d, width - 24d, "Company / Organization", IoFatReportFontKind.Bold, 6.1d, Muted); + Line(commands, lineX, top - 144d, lineRight, top - 144d, Border, 0.65d); + + Text(commands, labelX, top - 168d, width - 24d, "Signature", IoFatReportFontKind.Bold, 6.1d, Muted); + Rect(commands, lineX, top - 183d, width - 24d, 54d, 0d, White, Border, 0.55d); + + Text(commands, labelX, top - 255d, width - 24d, "Date", IoFatReportFontKind.Bold, 6.1d, Muted); + Line(commands, lineX, top - 275d, lineRight, top - 275d, Border, 0.65d); + } + + private static void AddFooter( + ICollection commands, + int pageNumber, + int totalPages, + DateTimeOffset createdAt, + string note) + { Line(commands, Margin, 42d, PageWidth - Margin, 42d, Border, 0.6d); - Text(commands, Margin, 24d, 540d, - $"Generated {createdAt:yyyy-MM-dd HH:mm:ss zzz} | Remote listing evidence is IED-scoped and persisted with the FAT project.", + Text(commands, Margin, 24d, 590d, + $"Generated {createdAt:yyyy-MM-dd HH:mm:ss zzz} | {note}", IoFatReportFontKind.Regular, 6.1d, Muted); Text(commands, PageWidth - Margin - 118d, 24d, 118d, $"Page {pageNumber} / {totalPages}", IoFatReportFontKind.Regular, 6.1d, Muted); - - return new IoFatReportPagePlan(pageNumber, PageWidth, PageHeight, commands); } private static IoFatReportCommand CorrectPageTotal(IoFatReportCommand command, int pageNumber, int totalPages) @@ -238,4 +326,4 @@ private static void Text( double fontSize, IoFatReportColor color) => commands.Add(new IoFatReportTextCommand(x, baselineY, width, text, font, fontSize, color)); -} \ No newline at end of file +} diff --git a/Services/IoTesting/IoFatV2ReportLayoutEngine.cs b/Services/IoTesting/IoFatV2ReportLayoutEngine.cs index 48b6c16c8..b28b81eb6 100644 --- a/Services/IoTesting/IoFatV2ReportLayoutEngine.cs +++ b/Services/IoTesting/IoFatV2ReportLayoutEngine.cs @@ -3,9 +3,9 @@ namespace ArIED61850Tester.Services.IoTesting; /// -/// Native report layout for SCL-backed FAT v2. The legacy workbook executive layout stays -/// unchanged so existing customer handovers remain stable; SCL projects use generic -/// Value 1 / Value 2 terminology and never present operator-snapshot completion as PASS. +/// Native report layout for SCL-backed FAT v2. IEC references are never truncated: long +/// references wrap inside a variable-height row so the FC/DO/DA tail remains auditable in +/// both WPF Print Preview and the native PDF report. /// internal static class IoFatV2ReportLayoutEngine { @@ -15,7 +15,8 @@ internal static class IoFatV2ReportLayoutEngine private const double Margin = 30d; private const double ContentTop = 488d; private const double ContentBottom = 54d; - private const double RowHeight = 34d; + private const double MinimumRowHeight = 34d; + private const int ReferenceCharsPerLine = 62; private static readonly IoFatReportColor Navy = Color("0F172A"); private static readonly IoFatReportColor Blue = Color("2563EB"); @@ -28,7 +29,8 @@ internal static class IoFatV2ReportLayoutEngine private static readonly IoFatReportColor Attention = Color("B45309"); private static readonly IoFatReportColor Fail = Color("B91C1C"); - private static readonly double[] Widths = [24d, 142d, 205d, 72d, 125d, 125d, 89d]; + // Wider IEC-reference column is intentional. Total remains the 782 pt content width. + private static readonly double[] Widths = [24d, 115d, 280d, 60d, 105d, 105d, 93d]; public static IoFatReportLayoutPlan Build(IoTestProject project, DateTimeOffset created, bool draft = false) { @@ -48,14 +50,15 @@ public static IoFatReportLayoutPlan Build(IoTestProject project, DateTimeOffset foreach (var point in points) { row++; - if (y - RowHeight < ContentBottom) + var rowHeight = GetPointRowHeight(point); + if (y - rowHeight < ContentBottom) { page = NewPage(pages, project, created, draft); y = ContentTop; DrawIedHeader(page, ied, points, ref y, continued: true); DrawTableHeader(page, ref y); } - DrawPointRow(page, point, row, ref y); + DrawPointRow(page, point, row, rowHeight, ref y); } y -= 12d; } @@ -100,7 +103,7 @@ private static List NewPage( IoFatReportFontKind.Regular, 7.8d, Muted)); page.Add(new IoFatReportRectCommand(PageWidth - Margin - 190d, 568d, 190d, 54d, 4d, SoftBlue, Border, 0.7d)); page.Add(new IoFatReportTextCommand(PageWidth - Margin - 178d, 550d, 166d, - draft ? "PREVIEW" : "AS TESTED", IoFatReportFontKind.Bold, 7.2d, Blue)); + "FAT REPORT", IoFatReportFontKind.Bold, 7.2d, Blue)); page.Add(new IoFatReportTextCommand(PageWidth - Margin - 178d, 534d, 166d, $"{project.IncludedSignalCount} included · {project.RemovedSignalCount} removed", IoFatReportFontKind.Regular, 6.4d, Ink)); @@ -163,17 +166,24 @@ private static void DrawTableHeader(List page, ref double y) y -= height; } + private static double GetPointRowHeight(IoTestPointPlan point) + { + var referenceLineCount = WrapReference(point.ReportIecReference).Count; + return Math.Max(MinimumRowHeight, 12d + (referenceLineCount * 8.2d)); + } + private static void DrawPointRow( List page, IoTestPointPlan point, int rowNumber, + double rowHeight, ref double y) { var cells = new[] { rowNumber.ToString(), - Short(point.SignalName, 32), - Short(point.ReportIecReference, 48), + Short(point.SignalName, 26), + string.Empty, point.SignalKind.ToString(), ValueCell(point, FatValueSlot.Value1), ValueCell(point, FatValueSlot.Value2), @@ -200,10 +210,31 @@ private static void DrawPointRow( var x = Margin; for (var index = 0; index < cells.Length; index++) { - page.Add(new IoFatReportRectCommand(x, y, Widths[index], RowHeight, 0d, White, Border, 0.35d)); + page.Add(new IoFatReportRectCommand(x, y, Widths[index], rowHeight, 0d, White, Border, 0.35d)); var color = index == cells.Length - 1 ? resultColor : Ink; var font = index is 0 or 2 ? IoFatReportFontKind.Mono : index is 1 or 6 ? IoFatReportFontKind.Bold : IoFatReportFontKind.Regular; - page.Add(new IoFatReportTextCommand(x + 5d, y - 13d, Widths[index] - 10d, cells[index], font, 5.8d, color)); + + if (index == 2) + { + var lineY = y - 11d; + foreach (var referenceLine in WrapReference(point.ReportIecReference)) + { + page.Add(new IoFatReportTextCommand( + x + 5d, + lineY, + Widths[index] - 10d, + referenceLine, + IoFatReportFontKind.Mono, + 4.9d, + Ink)); + lineY -= 8.2d; + } + } + else + { + page.Add(new IoFatReportTextCommand(x + 5d, y - 13d, Widths[index] - 10d, cells[index], font, 5.8d, color)); + } + if (index is 4 or 5) { var stamp = ValueTimestamp(point, index == 4 ? FatValueSlot.Value1 : FatValueSlot.Value2); @@ -211,16 +242,41 @@ private static void DrawPointRow( } x += Widths[index]; } - y -= RowHeight; + y -= rowHeight; + } + + private static IReadOnlyList WrapReference(string? value) + { + var text = Clean(value); + if (text.Length <= ReferenceCharsPerLine) + return new[] { text }; + + var lines = new List(); + var offset = 0; + while (offset < text.Length) + { + var take = Math.Min(ReferenceCharsPerLine, text.Length - offset); + if (offset + take < text.Length) + { + var segment = text.AsSpan(offset, take); + var split = segment.LastIndexOfAny('/', '.', '$'); + if (split >= ReferenceCharsPerLine / 2) + take = split + 1; + } + + lines.Add(text.Substring(offset, take)); + offset += take; + } + return lines; } private static string ValueCell(IoTestPointPlan point, FatValueSlot slot) - => Short(slot == FatValueSlot.Value1 ? point.Value1Text : point.Value2Text, 25); + => Short(slot == FatValueSlot.Value1 ? point.Value1Text : point.Value2Text, 22); private static string ValueTimestamp(IoTestPointPlan point, FatValueSlot slot) { var text = slot == FatValueSlot.Value1 ? point.Value1RelayTimestampText : point.Value2RelayTimestampText; - return string.IsNullOrWhiteSpace(text) || text == "—" ? "IED time: -" : "IED " + Short(text, 28); + return string.IsNullOrWhiteSpace(text) || text == "—" ? "IED time: -" : "IED " + Short(text, 24); } private static string Clean(string? value) diff --git a/Services/IoTesting/IoTestEvidenceJournal.cs b/Services/IoTesting/IoTestEvidenceJournal.cs index 1baec20cc..d1dd1ec5b 100644 --- a/Services/IoTesting/IoTestEvidenceJournal.cs +++ b/Services/IoTesting/IoTestEvidenceJournal.cs @@ -1,6 +1,8 @@ +using System.Collections.Concurrent; using System.Security.Cryptography; using System.Text; using System.Text.Json; +using System.Threading.Channels; using ArIED61850Tester.Models.IoTesting; namespace ArIED61850Tester.Services.IoTesting; @@ -12,9 +14,6 @@ public interface IIoTestEvidenceJournal : IDisposable string LastHash { get; } IoTestJournalEnvelope Append(IoTestJournalEntry entry); - // Existing test doubles and alternate journals keep working through this default - // implementation. The production journal overrides it so a baseline batch performs - // one durable disk flush instead of one fsync per FAT point. IReadOnlyList AppendBatch(IEnumerable entries) { ArgumentNullException.ThrowIfNull(entries); @@ -22,6 +21,15 @@ IReadOnlyList AppendBatch(IEnumerable } } +/// +/// Append-only, hash-chained FAT evidence journal. +/// +/// Critical relay-bench rule: report callbacks and WPF lifecycle actions must never perform +/// file I/O. Append/AppendBatch build the immutable hash-chain envelope in memory and enqueue +/// it to a single-reader Channel. One background writer owns StreamWriter/FileStream and +/// preserves exactly the enqueue order. Stop/close completes the queue, drains it, performs +/// one durable disk barrier and verifies the complete hash chain before durable success. +/// public sealed class IoTestEvidenceJournal : IIoTestEvidenceJournal { private static readonly JsonSerializerOptions JsonOptions = new() @@ -30,9 +38,21 @@ public sealed class IoTestEvidenceJournal : IIoTestEvidenceJournal WriteIndented = false }; + private static readonly AsyncLocal DeferredSealScopeDepth = new(); + private static readonly ConcurrentDictionary DeferredSeals = + new(StringComparer.OrdinalIgnoreCase); + + // Kept as a compatibility lifecycle scope. With the queued writer Append is already + // non-blocking and does not flush on the caller, so Resume no longer needs special disk + // behavior. The depth is retained to keep nested existing call sites harmless. + private static readonly AsyncLocal CoalescedFlushScopeDepth = new(); + private readonly object _sync = new(); private readonly FileStream _stream; private readonly StreamWriter _writer; + private readonly Channel _pendingWrites; + private readonly Task _writerPump; + private Exception? _writerFailure; private bool _disposed; private long _recordCount; private string _lastHash = new('0', 64); @@ -47,8 +67,16 @@ private IoTestEvidenceJournal(string filePath) FileAccess.Write, FileShare.Read, 4096, - FileOptions.WriteThrough); + FileOptions.SequentialScan); _writer = new StreamWriter(_stream, new UTF8Encoding(false)) { AutoFlush = false }; + _pendingWrites = Channel.CreateUnbounded( + new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = false, + AllowSynchronousContinuations = false + }); + _writerPump = Task.Run(ProcessPendingWritesAsync); } public string FilePath { get; } @@ -71,14 +99,57 @@ public static IoTestEvidenceJournal Create( return new IoTestEvidenceJournal(Path.Combine(rootDirectory, projectDirectory, fileName)); } + public static IDisposable BeginDeferredSealScope() + { + DeferredSealScopeDepth.Value++; + return new DeferredSealScopeLease(); + } + + public static IDisposable BeginCoalescedVisibleFlushScope() + { + CoalescedFlushScopeDepth.Value++; + return new CoalescedFlushScopeLease(); + } + + public static async Task AwaitDeferredSealsAsync() + { + var snapshot = DeferredSeals.ToArray(); + if (snapshot.Length == 0) + return; + + IoTestJournalVerificationResult[] results; + try + { + results = await Task.WhenAll(snapshot.Select(item => item.Value.Completion)).ConfigureAwait(false); + } + catch (Exception ex) + { + foreach (var item in snapshot) + DeferredSeals.TryRemove(item.Key, out _); + throw new InvalidOperationException($"Evidence journal sealing failed: {ex.Message}", ex); + } + + foreach (var item in snapshot) + DeferredSeals.TryRemove(item.Key, out _); + + var failures = results.Where(result => !result.IsValid).ToArray(); + if (failures.Length > 0) + { + throw new InvalidOperationException( + "Evidence journal integrity verification failed after durable sealing: " + + string.Join(" | ", failures.Select(result => result.Error).Where(error => !string.IsNullOrWhiteSpace(error)))); + } + } + public IoTestJournalEnvelope Append(IoTestJournalEntry entry) { ArgumentNullException.ThrowIfNull(entry); lock (_sync) { ObjectDisposedException.ThrowIf(_disposed, this); - var envelope = AppendCore(entry); - FlushDurable(); + ThrowIfWriterFailed(); + var envelope = CreateEnvelope(entry); + QueueEnvelope(envelope); return envelope; } } @@ -89,20 +160,22 @@ public IReadOnlyList AppendBatch(IEnumerable(); foreach (var entry in entries) { ArgumentNullException.ThrowIfNull(entry); - envelopes.Add(AppendCore(entry)); + var envelope = CreateEnvelope(entry); + QueueEnvelope(envelope); + envelopes.Add(envelope); } - - if (envelopes.Count > 0) - FlushDurable(); return envelopes; } } - private IoTestJournalEnvelope AppendCore(IoTestJournalEntry entry) + // Hashing and pointer mutation stay synchronous and deterministic; there is deliberately + // no StreamWriter/FileStream access anywhere on the Append caller path. + private IoTestJournalEnvelope CreateEnvelope(IoTestJournalEntry entry) { var sequence = checked(_recordCount + 1); var previousHash = _lastHash; @@ -111,12 +184,51 @@ private IoTestJournalEnvelope AppendCore(IoTestJournalEntry entry) JsonOptions); var hash = Convert.ToHexString(SHA256.HashData(hashInput)).ToLowerInvariant(); var envelope = new IoTestJournalEnvelope(sequence, previousHash, hash, entry); - _writer.WriteLine(JsonSerializer.Serialize(envelope, JsonOptions)); _recordCount = sequence; _lastHash = hash; return envelope; } + private void QueueEnvelope(IoTestJournalEnvelope envelope) + { + if (!_pendingWrites.Writer.TryWrite(envelope)) + throw new InvalidOperationException("FAT evidence writer is no longer accepting records."); + } + + private async Task ProcessPendingWritesAsync() + { + try + { + while (await _pendingWrites.Reader.WaitToReadAsync().ConfigureAwait(false)) + { + var wroteAny = false; + while (_pendingWrites.Reader.TryRead(out var envelope)) + { + _writer.WriteLine(JsonSerializer.Serialize(envelope, JsonOptions)); + wroteAny = true; + } + + // Flush only on the background writer. This makes newly written evidence + // visible to readers without ever stalling the WPF/report callback thread. + if (wroteAny) + _writer.Flush(); + } + } + catch (Exception ex) + { + Volatile.Write(ref _writerFailure, ex); + _pendingWrites.Writer.TryComplete(ex); + throw; + } + } + + private void ThrowIfWriterFailed() + { + var failure = Volatile.Read(ref _writerFailure); + if (failure != null) + throw new InvalidOperationException($"FAT evidence background writer failed: {failure.Message}", failure); + } + private void FlushDurable() { _writer.Flush(); @@ -124,6 +236,30 @@ private void FlushDurable() } public static IoTestJournalVerificationResult Verify(string filePath) + { + var key = SealKey(filePath); + if (DeferredSeals.TryGetValue(key, out var deferred)) + { + if (deferred.Completion.IsCompletedSuccessfully) + return deferred.Completion.Result; + if (deferred.Completion.IsFaulted) + { + return new IoTestJournalVerificationResult( + false, + deferred.Provisional.RecordCount, + deferred.Provisional.LastHash, + deferred.Completion.Exception?.GetBaseException().Message ?? "Deferred evidence journal sealing failed."); + } + + // Structural snapshot only. Queue drain + durable flush + full read-back are + // still running and the lifecycle caller must await AwaitDeferredSealsAsync(). + return deferred.Provisional; + } + + return VerifyCore(filePath); + } + + private static IoTestJournalVerificationResult VerifyCore(string filePath) { if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath)) return new IoTestJournalVerificationResult(false, 0, string.Empty, "Evidence journal was not found."); @@ -167,13 +303,92 @@ public static IoTestJournalVerificationResult Verify(string filePath) public void Dispose() { + bool deferred; + IoTestJournalVerificationResult provisional; + string key; + lock (_sync) { if (_disposed) return; _disposed = true; - _writer.Dispose(); - _stream.Dispose(); + _pendingWrites.Writer.TryComplete(); + + provisional = _recordCount == 0 + ? new IoTestJournalVerificationResult(false, 0, _lastHash, "Evidence journal contains no records.") + : new IoTestJournalVerificationResult(true, checked((int)_recordCount), _lastHash, string.Empty); + key = SealKey(FilePath); + deferred = DeferredSealScopeDepth.Value > 0; + } + + if (deferred) + { + // Queue drain and all physical disk work are guaranteed to happen on a worker. + var completion = Task.Run(SealDurablyAndVerify); + DeferredSeals[key] = new DeferredSealState(provisional, completion); + return; + } + + SealDurablyAndVerify(); + } + + private IoTestJournalVerificationResult SealDurablyAndVerify() + { + Exception? failure = null; + try + { + _writerPump.GetAwaiter().GetResult(); + ThrowIfWriterFailed(); + FlushDurable(); + } + catch (Exception ex) + { + failure = ex; + } + finally + { + try + { + _writer.Dispose(); + } + catch (Exception ex) when (failure == null) + { + failure = ex; + } + + try + { + _stream.Dispose(); + } + catch (Exception ex) when (failure == null) + { + failure = ex; + } + } + + if (failure != null) + { + return new IoTestJournalVerificationResult( + false, + checked((int)_recordCount), + _lastHash, + failure.GetBaseException().Message); + } + + return VerifyCore(FilePath); + } + + private static string SealKey(string? filePath) + { + if (string.IsNullOrWhiteSpace(filePath)) + return string.Empty; + try + { + return Path.GetFullPath(filePath); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) + { + return filePath.Trim(); } } @@ -189,4 +404,34 @@ private sealed record JournalHashInput( long JournalSequence, string PreviousHash, IoTestJournalEntry Entry); + + private sealed record DeferredSealState( + IoTestJournalVerificationResult Provisional, + Task Completion); + + private sealed class CoalescedFlushScopeLease : IDisposable + { + private bool _disposed; + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + CoalescedFlushScopeDepth.Value = Math.Max(0, CoalescedFlushScopeDepth.Value - 1); + } + } + + private sealed class DeferredSealScopeLease : IDisposable + { + private bool _disposed; + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + DeferredSealScopeDepth.Value = Math.Max(0, DeferredSealScopeDepth.Value - 1); + } + } } diff --git a/Services/IoTesting/IoTestMultiSessionCoordinator.cs b/Services/IoTesting/IoTestMultiSessionCoordinator.cs index 1f6767bd9..f20593d13 100644 --- a/Services/IoTesting/IoTestMultiSessionCoordinator.cs +++ b/Services/IoTesting/IoTestMultiSessionCoordinator.cs @@ -367,7 +367,64 @@ private List ControllerSnapshot() } private void Child_PropertyChanged(object? sender, PropertyChangedEventArgs e) - => RaiseProjectionProperties(); + { + if (_disposed) + return; + + // Relay-bench hot path: one journal append changes EvidenceRecordCount, LastJournalHash + // and JournalIntegrityText in sequence. Expanding every one of those child changes into + // the complete selected-session projection caused dozens of WPF notifications per FAT + // edge and made the shared Engineering Dispatcher stall for seconds. Forward only the + // property that actually changed; reserve the full projection refresh for structural + // session-state changes. + switch (e.PropertyName) + { + case nameof(IoTestSessionController.EvidenceRecordCount): + Raise(nameof(EvidenceRecordCount)); + break; + case nameof(IoTestSessionController.LastJournalHash): + Raise(nameof(LastJournalHash)); + break; + case nameof(IoTestSessionController.JournalIntegrityText): + Raise(nameof(JournalIntegrityText)); + break; + case nameof(IoTestSessionController.JournalPath): + Raise(nameof(JournalPath)); + break; + case nameof(IoTestSessionController.ProgressText): + Raise(nameof(ProgressText)); + break; + case nameof(IoTestSessionController.StatusText): + Raise(nameof(StatusText)); + break; + case nameof(IoTestSessionController.SessionId): + Raise(nameof(SessionId)); + break; + case nameof(IoTestSessionController.StartedAtUtc): + Raise(nameof(StartedAtUtc)); + break; + case nameof(IoTestSessionController.CompletedAtUtc): + Raise(nameof(CompletedAtUtc)); + break; + case nameof(IoTestSessionController.State): + case nameof(IoTestSessionController.ActiveIed): + case nameof(IoTestSessionController.IsSessionActive): + case nameof(IoTestSessionController.CanStart): + case nameof(IoTestSessionController.CanPause): + case nameof(IoTestSessionController.CanResume): + case nameof(IoTestSessionController.CanStop): + case nameof(IoTestSessionController.CanEditPlan): + case nameof(IoTestSessionController.StateText): + case nameof(IoTestSessionController.ActiveIedText): + RaiseProjectionProperties(); + break; + default: + // Unknown child properties are intentionally not amplified into every FAT + // binding. Explicit Start/Stop/Resume actions already refresh the complete + // projection once after the state transition. + break; + } + } private void RaiseProjectionProperties() { diff --git a/Services/IoTesting/IoTestRollingCaptureCoordinator.cs b/Services/IoTesting/IoTestRollingCaptureCoordinator.cs index b457e4f8f..e2ba2a979 100644 --- a/Services/IoTesting/IoTestRollingCaptureCoordinator.cs +++ b/Services/IoTesting/IoTestRollingCaptureCoordinator.cs @@ -11,6 +11,11 @@ namespace ArIED61850Tester.Services.IoTesting; /// candidate ON/OFF evidence stays in the shadow until a complete cycle is available; /// only then is the current project evidence atomically replaced. An interrupted or /// rejected recapture therefore leaves the last completed/current evidence untouched. +/// +/// LIVE VALUE is deliberately not owned here. The shared Engineering process image is the +/// only authority for IoTestPointRuntime.CurrentValue/quality/source/timestamp. Evidence +/// evaluation may be delayed on the Dispatcher, so replaying an older queued event into +/// CurrentValue would otherwise overwrite a newer command/report projection. /// public sealed class IoTestRollingCaptureCoordinator { @@ -35,7 +40,7 @@ public IoTestEvaluationResult Start(IoTestPointPlan point, IoTestObservation bas _slots[point] = slot; point.Runtime.Attempt++; - ApplyLiveObservation(point.Runtime, shadow.Runtime, baseline); + ApplyEvidenceObservationState(point.Runtime, shadow.Runtime); if (hadCurrentEvidence) { @@ -49,6 +54,33 @@ public IoTestEvaluationResult Start(IoTestPointPlan point, IoTestObservation bas return ProjectResult(evaluation, point.Runtime, point.Runtime.StatusReason); } + /// + /// Re-arms a transition capture after an explicit pause/reconnect continuity gap. + /// Existing partial evidence remains visible for audit, but it cannot be completed + /// by an edge that may have occurred while capture was paused. + /// + public IoTestEvaluationResult RearmAfterContinuityGap(IoTestPointPlan point, IoTestObservation baseline) + { + ArgumentNullException.ThrowIfNull(point); + ArgumentNullException.ThrowIfNull(baseline); + + var shadow = CreateShadow(point); + _evaluator.StartAttempt(shadow, baseline); + _slots[point] = new CaptureSlot(shadow, hasCurrentEvidence: true); + + point.Runtime.Attempt++; + ApplyEvidenceObservationState(point.Runtime, shadow.Runtime); + point.Runtime.State = IoTestPointState.Review; + point.Runtime.StatusReason = + "Capture continuity cannot be proven across pause/reconnect while only one transition edge was recorded; partial evidence is preserved and capture is re-armed from the current live baseline."; + + return new IoTestEvaluationResult( + true, + point.Runtime.State, + null, + point.Runtime.StatusReason); + } + public IoTestEvaluationResult Observe(IoTestPointPlan point, IoTestObservation observation) { ArgumentNullException.ThrowIfNull(point); @@ -64,7 +96,7 @@ public IoTestEvaluationResult Observe(IoTestPointPlan point, IoTestObservation o } var evaluation = _evaluator.Observe(slot.Shadow, observation); - ApplyLiveObservation(point.Runtime, slot.Shadow.Runtime, observation); + ApplyEvidenceObservationState(point.Runtime, slot.Shadow.Runtime); if (!slot.HasCurrentEvidence) { @@ -151,12 +183,14 @@ private static IoTestEvaluationResult ProjectResult( string reason) => new(evaluation.StateChanged, runtime.State, evaluation.Evidence, reason); - private static void ApplyLiveObservation( + private static void ApplyEvidenceObservationState( IoTestPointRuntime target, - IoTestPointRuntime shadow, - IoTestObservation observation) + IoTestPointRuntime shadow) { - target.ApplyObservation(observation); + // Do not copy RawValue/Quality/Source/Timestamp into the operator-facing live cells. + // MainWindow.P0FatRecovery projects the current shared process image independently + // at DataBind priority. Evidence drains can legitimately run later, so they may only + // advance evaluator continuity metadata here. target.LastObservedState = shadow.LastObservedState; target.LastSequence = shadow.LastSequence; target.ConnectionGeneration = shadow.ConnectionGeneration; diff --git a/Services/IoTesting/IoTestSignalSelectionService.cs b/Services/IoTesting/IoTestSignalSelectionService.cs index 067a08d7a..22a47c7b0 100644 --- a/Services/IoTesting/IoTestSignalSelectionService.cs +++ b/Services/IoTesting/IoTestSignalSelectionService.cs @@ -45,13 +45,6 @@ public IoTestSignalSelectionResult Resolve( ArgumentNullException.ThrowIfNull(ied); ArgumentNullException.ThrowIfNull(device); - // Physical FAT can start from an already-connected Engineering Workspace whose - // presentation inventory is intentionally narrower than the static DataSet scope. - // Restore ARIEC-owned mandatory DataSet descriptors before local matching so FAT - // never mistakes presentation pruning for protocol absence. This performs no IEC - // 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 @@ -63,6 +56,27 @@ public IoTestSignalSelectionResult Resolve( point.ImportReady && (point.TestEnabled || IsDirectSclAuthority(point))) .ToList(); + + // Relay-bench fast attach: when Engineering already owns a connected/monitoring + // SCL workspace, resolve only against exact identities already present in that + // process image. This path performs no discovery, no MMS read and no fuzzy scoring. + // It is accepted only when every requested row is proven exactly; otherwise the + // existing fail-safe resolver below runs unchanged. + if (device.IsConnected && device.IsMonitoring && + TryResolveAlreadyLiveExactScope(requested, device, out var liveMatches)) + { + return new IoTestSignalSelectionResult( + liveMatches, + Array.Empty(), + Array.Empty(), + $"Reused {liveMatches.Count} exact signal identity(s) from the already-live Engineering acquisition session."); + } + + // Physical FAT can also start before Engineering has a complete presentation + // inventory. Restore ARIEC-owned mandatory DataSet descriptors only for the normal + // resolver; the exact already-live path above intentionally avoids catalog churn. + Iec61850DataSetSignalInventoryService.EnsureMandatorySignals(device); + var matches = new List(requested.Count); var missing = new List(); var ambiguous = new List(); @@ -166,6 +180,69 @@ public IoTestSignalSelectionResult Resolve( $"Resolved {matches.Count} FAT acquisition signal(s) to discovered model points.{smartText}"); } + private static bool TryResolveAlreadyLiveExactScope( + IReadOnlyList requested, + Iec61850MonitorDevice device, + out IReadOnlyList matches) + { + var result = new List(requested.Count); + if (requested.Count == 0 || device.Points.Count == 0 || device.Signals.Count == 0) + { + matches = Array.Empty(); + return false; + } + + var liveReferences = device.Points + .Select(point => IoTestLiveBindingService.NormalizeReference(point.IecReference)) + .Where(reference => reference.Length > 0) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var point in requested) + { + var imported = IoTestLiveBindingService.ImportedReferences(point) + .Select(IoTestLiveBindingService.NormalizeReference) + .Where(reference => reference.Length > 0) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + if (imported.Count == 0) + { + matches = Array.Empty(); + return false; + } + + var exactMembership = device.Signals + .Where(signal => IsEligible(signal, point) && HasExactSclStaticMembershipIdentity(point, signal)) + .ToList(); + var candidates = exactMembership.Count > 0 + ? exactMembership + : device.Signals + .Where(signal => + IsEligible(signal, point) && + new[] { signal.ObjectReference, signal.DisplayReference } + .Where(reference => !string.IsNullOrWhiteSpace(reference)) + .Select(IoTestLiveBindingService.NormalizeReference) + .Any(imported.Contains)) + .ToList(); + + if (candidates.Count != 1) + { + matches = Array.Empty(); + return false; + } + + var runtimeReference = IoTestLiveBindingService.NormalizeReference(candidates[0].ObjectReference); + if (runtimeReference.Length == 0 || !liveReferences.Contains(runtimeReference)) + { + matches = Array.Empty(); + return false; + } + + result.Add(new IoTestSignalMatch(point, candidates[0], UsedNormalizedIedPrefix: false)); + } + + matches = result; + return true; + } + internal static bool IsSclDataSetAuthority(IoTestPointPlan point) => string.Equals( point.BindingStatus, @@ -201,7 +278,7 @@ private static int BestSignalScore( return IoTestReferenceMatcher.ExactScore + SclStaticMembershipIdentityBonus; // ARIEC deliberately keeps static FCDA/FCD membership identity in - // DisplayReference while ObjectReference may remain the resolved runtime leaf. + // DisplayReference while ObjectReference is ARIEC's resolved scalar runtime leaf. // Manual SCL workspace rows also preserve DisplayReference as exact source identity. // Legacy workbook rows keep the old ObjectReference-only contract. var observedReferences = IsDirectSclAuthority(point) diff --git a/Services/UiResponsiveIec61850MonitorRuntimeFacade.cs b/Services/UiResponsiveIec61850MonitorRuntimeFacade.cs index 2f499c89f..8b3949823 100644 --- a/Services/UiResponsiveIec61850MonitorRuntimeFacade.cs +++ b/Services/UiResponsiveIec61850MonitorRuntimeFacade.cs @@ -15,12 +15,16 @@ namespace ArIED61850Tester; /// deliberately pre-emptive lane: it cancels the active operation first and invokes the /// runtime stop without waiting behind a hung Connect/Start gate. A cancelled/stale operation /// is never allowed to report success to its caller afterwards. Different IEDs stay fully -/// independent. This facade changes lifecycle scheduling only; it does not add MMS polling, -/// dynamic DataSet writes, or any acquisition fallback. +/// independent. This facade also owns one bounded command-feedback freshness fence at the +/// runtime/UI boundary; it does not add MMS polling, dynamic DataSet writes, or acquisition +/// fallback. /// public sealed class Iec61850MonitorRuntime : IAsyncDisposable { private static readonly TimeSpan DisposeBudget = TimeSpan.FromSeconds(3); + private static readonly TimeSpan CommandFeedbackFreshnessWindow = TimeSpan.FromSeconds(2); + private static readonly TimeSpan PendingEventOriginWindow = TimeSpan.FromSeconds(1); + private static readonly TimeSpan ActiveCommandExpectationWindow = TimeSpan.FromSeconds(30); private sealed class DeviceOperationSlot { @@ -31,16 +35,36 @@ private sealed class DeviceOperationSlot public long Generation { get; set; } } + private sealed record CommandFeedbackFence( + string ExpectedValue, + DateTime ExpiresUtc); + + private sealed record PendingEventOrigin( + string NewValue, + bool IsReportTraffic, + bool IsConfirmedCommandFeedback, + DateTime ExpiresUtc); + + private sealed record ActiveCommandExpectation( + string ExpectedValue, + DateTime ExpiresUtc); + private readonly Services.Iec61850MonitorRuntime _inner = new(); private readonly ConcurrentDictionary _deviceSlots = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _commandFeedbackFences = + new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _pendingEventOrigins = + new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _activeCommandExpectations = + new(StringComparer.OrdinalIgnoreCase); private int _disposeStarted; public Iec61850MonitorRuntime() { _inner.Diagnostic += entry => Diagnostic?.Invoke(entry); - _inner.PointUpdated += snapshot => PointUpdated?.Invoke(snapshot); - _inner.EventRaised += entry => EventRaised?.Invoke(entry); + _inner.PointUpdated += ForwardPointUpdate; + _inner.EventRaised += ForwardEventRaised; } public event Action? Diagnostic; @@ -94,16 +118,318 @@ public Task ExecuteControlAsync( => RunDeviceOperationAsync( deviceId, cancellationToken, - token => _inner.ExecuteControlAsync(deviceId, request, token)); + async token => + { + var expectationKeys = RegisterActiveCommandExpectation( + deviceId, + request.Signal, + request.ValueText); + try + { + return await _inner.ExecuteControlAsync(deviceId, request, token).ConfigureAwait(false); + } + finally + { + ClearActiveCommandExpectations(expectationKeys); + } + }); public HybridReportPhysicalValidationSnapshot CaptureHybridReportPhysicalValidation(string deviceId) => _inner.CaptureHybridReportPhysicalValidation(deviceId); public Task StopMonitoringAsync(string deviceId) - => RunPreemptiveStopAsync(deviceId, () => _inner.StopMonitoringAsync(deviceId)); + { + ClearCommandFeedbackState(deviceId); + return RunPreemptiveStopAsync(deviceId, () => _inner.StopMonitoringAsync(deviceId)); + } public Task StopDeviceAsync(string deviceId) - => RunPreemptiveStopAsync(deviceId, () => _inner.StopDeviceAsync(deviceId)); + { + ClearCommandFeedbackState(deviceId); + return RunPreemptiveStopAsync(deviceId, () => _inner.StopDeviceAsync(deviceId)); + } + + /// + /// P0 command-feedback freshness fence. + /// + /// The inner runtime publishes command-confirmed process feedback immediately, then its + /// report/poll monitor resumes. Some relays can return one pre-command MMS verification + /// sample before their status cache/report stream catches up, producing a visible + /// Closed → Open → Closed flash even though the command was accepted and the matching + /// dchg arrives moments later. + /// + /// This is deliberately not a WPF debounce and it does not manufacture state. The + /// confirmed value itself opens a short per-point fence. Contradictory non-report + /// snapshots are withheld during that bounded window. Report traffic remains process + /// authority; a contradictory report is forwarded immediately. A matching report is + /// forwarded as confirmation while the short fence remains alive so a stale poll that + /// was already in flight cannot flash the process value or manufacture a duplicate SOE. + /// + /// Reason strings are not trusted on their own: polling can inherit the last reason from + /// the runtime point state. A fence opens only when the confirmed-feedback reason also + /// matches the value and status-reference scope of the currently executing control. + /// + private void ForwardPointUpdate(Iec61850PointSnapshot snapshot) + { + var key = CommandFeedbackFenceKey(snapshot.Point.DeviceId, snapshot.Point.IecReference); + var nowUtc = DateTime.UtcNow; + var confirmedCommandFeedback = + IsConfirmedCommandFeedback(snapshot) && + MatchesActiveCommandExpectation(key, snapshot.Value, nowUtc); + + // ApplyValueUpdate raises PointUpdated synchronously before EventRaised. Preserve + // the exact transport provenance of a discrete edge here so the SOE filter never + // tries to infer report-vs-poll origin from a reused reason string. In particular, + // MMS verification can legitimately inherit the last report/command reason text. + if (snapshot.IsValueEdge) + { + _pendingEventOrigins[key] = new PendingEventOrigin( + snapshot.Value?.Trim() ?? string.Empty, + snapshot.IsReportTraffic, + confirmedCommandFeedback, + nowUtc.Add(PendingEventOriginWindow)); + } + + if (confirmedCommandFeedback) + { + _commandFeedbackFences[key] = new CommandFeedbackFence( + snapshot.Value?.Trim() ?? string.Empty, + nowUtc.Add(CommandFeedbackFreshnessWindow)); + PointUpdated?.Invoke(snapshot); + return; + } + + if (!_commandFeedbackFences.TryGetValue(key, out var fence)) + { + PointUpdated?.Invoke(snapshot); + return; + } + + if (nowUtc > fence.ExpiresUtc) + { + _commandFeedbackFences.TryRemove(key, out _); + PointUpdated?.Invoke(snapshot); + return; + } + + var matchesConfirmed = CommandFeedbackValuesEquivalent(fence.ExpectedValue, snapshot.Value); + if (snapshot.IsReportTraffic) + { + // A contradictory report is a real process transition and must immediately + // release the fence. A matching report confirms the command; keep the fence + // until its short expiry so an already in-flight stale poll cannot flash back. + if (!matchesConfirmed) + _commandFeedbackFences.TryRemove(key, out _); + PointUpdated?.Invoke(snapshot); + return; + } + + if (matchesConfirmed) + { + PointUpdated?.Invoke(snapshot); + return; + } + + EmitFreshnessDiagnostic( + snapshot.Point.DeviceName, + $"withheld stale MMS verification {snapshot.Point.IecReference}={snapshot.Value} inside the command-confirmed {fence.ExpectedValue} freshness window. Report traffic remains authoritative."); + } + + /// + /// The same freshness rule must cover SOE, not only the visible live value. Otherwise a + /// stale polling sample suppressed from the grid could still create a phantom Open/Close + /// event and a second synthetic return-to-command event when the matching report arrives. + /// + private void ForwardEventRaised(Iec61850EventEntry entry) + { + var key = CommandFeedbackFenceKey(entry.DeviceId, entry.IecReference); + var origin = TakePendingEventOrigin(key, entry.NewValue); + if (!_commandFeedbackFences.TryGetValue(key, out var fence)) + { + EventRaised?.Invoke(entry); + return; + } + + var nowUtc = DateTime.UtcNow; + if (nowUtc > fence.ExpiresUtc) + { + _commandFeedbackFences.TryRemove(key, out _); + EventRaised?.Invoke(entry); + return; + } + + var matchesConfirmed = CommandFeedbackValuesEquivalent(fence.ExpectedValue, entry.NewValue); + + // The initial command-confirmed transition is legitimate process evidence. Require + // both exact PointUpdated provenance and the commanded value; reason text alone is + // unsafe because a following MMS verification can inherit that same reason. + if (origin is { IsConfirmedCommandFeedback: true } && matchesConfirmed) + { + EventRaised?.Invoke(entry); + return; + } + + if (origin is { IsReportTraffic: true }) + { + if (!matchesConfirmed) + { + // A report-proven change away from the commanded state is real. Release the + // fence so all following report/SOE transitions flow normally. + _commandFeedbackFences.TryRemove(key, out _); + EventRaised?.Invoke(entry); + return; + } + + EmitFreshnessDiagnostic( + entry.DeviceName, + $"suppressed duplicate report SOE {entry.IecReference}={entry.NewValue}; it only confirms the already-published command state {fence.ExpectedValue}."); + return; + } + + if (matchesConfirmed) + { + EmitFreshnessDiagnostic( + entry.DeviceName, + $"suppressed duplicate MMS verification SOE {entry.IecReference}={entry.NewValue}; command-confirmed state is already {fence.ExpectedValue}."); + return; + } + + EmitFreshnessDiagnostic( + entry.DeviceName, + $"withheld phantom MMS verification SOE {entry.IecReference}: {entry.OldValue} → {entry.NewValue} inside the command-confirmed {fence.ExpectedValue} freshness window."); + } + + private string[] RegisterActiveCommandExpectation( + string deviceId, + SignalDefinition signal, + string? expectedValue) + { + var value = (expectedValue ?? string.Empty).Trim(); + if (value.Length == 0) + return Array.Empty(); + + var references = new[] + { + signal.ControlStatusReference, + signal.ObjectReference + } + .Where(reference => !string.IsNullOrWhiteSpace(reference)) + .Select(reference => CommandFeedbackFenceKey(deviceId, reference)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var expiresUtc = DateTime.UtcNow.Add(ActiveCommandExpectationWindow); + foreach (var key in references) + _activeCommandExpectations[key] = new ActiveCommandExpectation(value, expiresUtc); + return references; + } + + private bool MatchesActiveCommandExpectation(string key, string? value, DateTime nowUtc) + { + if (!_activeCommandExpectations.TryGetValue(key, out var expectation)) + return false; + if (expectation.ExpiresUtc < nowUtc) + { + _activeCommandExpectations.TryRemove(key, out _); + return false; + } + return CommandFeedbackValuesEquivalent(expectation.ExpectedValue, value); + } + + private void ClearActiveCommandExpectations(IEnumerable keys) + { + foreach (var key in keys) + _activeCommandExpectations.TryRemove(key, out _); + } + + private PendingEventOrigin? TakePendingEventOrigin(string key, string? eventValue) + { + if (!_pendingEventOrigins.TryRemove(key, out var origin)) + return null; + if (origin.ExpiresUtc < DateTime.UtcNow) + return null; + if (!CommandFeedbackValuesEquivalent(origin.NewValue, eventValue)) + return null; + return origin; + } + + private void EmitFreshnessDiagnostic(string source, string message) + => Diagnostic?.Invoke(new DiagnosticEntry + { + Time = DateTime.Now, + Level = "INFO", + Source = source, + Message = "P0_COMMAND_FRESHNESS: " + message + }); + + private static bool IsConfirmedCommandFeedback(Iec61850PointSnapshot snapshot) + => IsConfirmedCommandFeedback(snapshot.Reason); + + private static bool IsConfirmedCommandFeedback(string? reason) + => (reason ?? string.Empty).Contains( + "confirmed command feedback", + StringComparison.OrdinalIgnoreCase); + + private static string CommandFeedbackFenceKey(string? deviceId, string? reference) + => $"{NormalizeDeviceKey(deviceId)}|{NormalizeReference(reference)}"; + + private static string NormalizeReference(string? reference) + => (reference ?? string.Empty) + .Trim() + .Replace('$', '.') + .Replace("..", ".", StringComparison.Ordinal) + .ToLowerInvariant(); + + private static bool CommandFeedbackValuesEquivalent(string? left, string? right) + { + var leftText = (left ?? string.Empty).Trim(); + var rightText = (right ?? string.Empty).Trim(); + if (leftText.Equals(rightText, StringComparison.OrdinalIgnoreCase)) + return true; + + if (bool.TryParse(leftText, out var leftBool) && + bool.TryParse(rightText, out var rightBool)) + { + return leftBool == rightBool; + } + + var leftState = ExtractStateCode(leftText); + var rightState = ExtractStateCode(rightText); + return leftState.Length > 0 && rightState.Length > 0 && + leftState.Equals(rightState, StringComparison.OrdinalIgnoreCase); + } + + private static string ExtractStateCode(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return string.Empty; + var open = value.LastIndexOf('['); + var close = value.LastIndexOf(']'); + return open >= 0 && close > open ? value[(open + 1)..close].Trim() : string.Empty; + } + + private void ClearCommandFeedbackState(string? deviceId) + { + var prefix = NormalizeDeviceKey(deviceId) + "|"; + foreach (var key in _commandFeedbackFences.Keys + .Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + .ToArray()) + { + _commandFeedbackFences.TryRemove(key, out _); + } + foreach (var key in _pendingEventOrigins.Keys + .Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + .ToArray()) + { + _pendingEventOrigins.TryRemove(key, out _); + } + foreach (var key in _activeCommandExpectations.Keys + .Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + .ToArray()) + { + _activeCommandExpectations.TryRemove(key, out _); + } + } private async Task RunDeviceOperationAsync( string? deviceId, @@ -321,6 +647,9 @@ public async ValueTask DisposeAsync() // Do not Dispose the per-device semaphores here. An uncooperative native operation // can still unwind after the bounded shutdown budget and its finally block must be // able to Release() safely. They become process-lifetime garbage with this facade. + _activeCommandExpectations.Clear(); + _pendingEventOrigins.Clear(); + _commandFeedbackFences.Clear(); _deviceSlots.Clear(); } diff --git a/tests/ARSAS.Tests/FastWorkflowRegressionTests.cs b/tests/ARSAS.Tests/FastWorkflowRegressionTests.cs index c40a9665f..4610c36ff 100644 --- a/tests/ARSAS.Tests/FastWorkflowRegressionTests.cs +++ b/tests/ARSAS.Tests/FastWorkflowRegressionTests.cs @@ -14,13 +14,17 @@ public void IedCardQuickCapture_UsesSelectedEndpointRoute() } [Fact] - public void FatCardPreparationProgress_IsRealDeterminateAndSmoothed() + public void FatCardPreparationProgress_IsRealDeterminateSmoothedAndLowPriority() { var source = File.ReadAllText(FindRepoFile("IoListTestingWindow.RealPreparationProgress.cs")); var engineering = File.ReadAllText(FindRepoFile("MainWindow.IoTesting.Progress.cs")); Assert.Contains("progressBar.IsIndeterminate = false", source, StringComparison.Ordinal); - Assert.Contains("TimeSpan.FromMilliseconds(50)", source, StringComparison.Ordinal); + Assert.Contains("DispatcherPriority.Background", source, StringComparison.Ordinal); + Assert.Contains("TimeSpan.FromMilliseconds(100)", source, StringComparison.Ordinal); + Assert.Contains("RefreshPreparationProgressBarCache", source, StringComparison.Ordinal); + Assert.Contains("if (!hasActivePreparation)", source, StringComparison.Ordinal); Assert.Contains("AdvanceDisplay", source, StringComparison.Ordinal); + Assert.DoesNotContain("TimeSpan.FromMilliseconds(50)", source, StringComparison.Ordinal); Assert.DoesNotContain("RepeatBehavior", source, StringComparison.Ordinal); Assert.Contains("device.DiscoveryProgressPercent", engineering, StringComparison.Ordinal); Assert.Contains("LivePointReady", engineering, StringComparison.Ordinal); diff --git a/tests/ARSAS.Tests/FatAutoCaptureCompositeRegressionTests.cs b/tests/ARSAS.Tests/FatAutoCaptureCompositeRegressionTests.cs new file mode 100644 index 000000000..eef264054 --- /dev/null +++ b/tests/ARSAS.Tests/FatAutoCaptureCompositeRegressionTests.cs @@ -0,0 +1,68 @@ +using ArIED61850Tester.Models.IoTesting; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class FatAutoCaptureCompositeRegressionTests +{ + [Fact] + public void CompositeThreePhaseValue_RollsWhenOnlyPhaseCChanges() + { + var point = new IoTestPointPlan + { + TestPointId = "thda-parent", + IedName = "IED1", + IpAddress = "192.0.2.10", + SignalName = "ThdA", + ObjectReference = "IED1LD0/MHAI1.ThdA", + FunctionalConstraint = "MX", + ExpectedOnText = string.Empty, + ExpectedOffText = string.Empty, + SignalKind = FatSignalKind.Other, + CaptureMode = FatCaptureMode.AutomaticTransition, + ImportReady = true + }; + + point.Runtime.SetFatValueEvidence(Evidence( + FatValueSlot.Value1, + "A=0, B=0, C=0", + sequence: 1)); + point.Runtime.SetFatValueEvidence(Evidence( + FatValueSlot.Value2, + "A=12, B=13, C=0", + sequence: 2)); + + var coordinator = new FatAutoCaptureCoordinator(); + var decision = coordinator.Observe( + point, + new IoTestObservation( + NormalizedState: null, + RawValue: "A=12, B=13, C=14", + CapturedAt: DateTimeOffset.UtcNow, + IedTimestamp: DateTimeOffset.UtcNow, + Quality: "good", + AcquisitionSource: "BRCB report", + Sequence: 3, + ConnectionGeneration: 1)); + + Assert.NotNull(decision.Evidence); + Assert.Equal(FatValueSlot.Value2, decision.Evidence!.Slot); + Assert.Equal("A=12, B=13, C=14", decision.Evidence.RawValue); + Assert.NotNull(decision.ShiftedValue1Evidence); + Assert.Equal("A=12, B=13, C=0", decision.ShiftedValue1Evidence!.RawValue); + Assert.Equal(FatAutoCaptureStage.Complete, decision.Stage); + } + + private static FatValueEvidence Evidence(FatValueSlot slot, string value, long sequence) + => new( + Guid.NewGuid(), + slot, + FatEvidenceCaptureKind.AutomaticValue, + value, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow, + "good", + "BRCB report", + sequence, + 1); +} diff --git a/tests/ARSAS.Tests/FatAutoCaptureOperatorRollingTests.cs b/tests/ARSAS.Tests/FatAutoCaptureOperatorRollingTests.cs new file mode 100644 index 000000000..81315b9ca --- /dev/null +++ b/tests/ARSAS.Tests/FatAutoCaptureOperatorRollingTests.cs @@ -0,0 +1,54 @@ +using ArIED61850Tester.Models.IoTesting; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class FatAutoCaptureOperatorRollingTests +{ + [Fact] + public void OperatorOwnedCompletePair_DoesNotFreezeLatestAutomaticProcessPair() + { + var point = new IoTestPointPlan + { + TestPointId = "THDA-ROLLING", + IedName = "AA1E1F06R4", + IpAddress = "192.168.81.103", + SignalName = "ThdA", + ObjectReference = "AA1E1F06R4VT3p1_THDHarmonics/I_MHAI1.ThdA", + FunctionalConstraint = "MX", + ExpectedOnText = "Value 1", + ExpectedOffText = "Value 2", + SignalKind = FatSignalKind.Analog, + CaptureMode = FatCaptureMode.OperatorSnapshot, + WorkspaceSelected = true, + TestEnabled = true, + ImportReady = true, + BindingStatus = "CID_DATASET_EXACT" + }; + + var t0 = new DateTimeOffset(2026, 9, 7, 0, 0, 0, TimeSpan.Zero); + point.Runtime.SetFatValueEvidence(new FatValueEvidence( + Guid.NewGuid(), FatValueSlot.Value1, FatEvidenceCaptureKind.OperatorRecapture, + "A=0,B=0,C=0", t0, t0, "Good", "BRCB", 1, 1)); + point.Runtime.SetFatValueEvidence(new FatValueEvidence( + Guid.NewGuid(), FatValueSlot.Value2, FatEvidenceCaptureKind.OperatorRecapture, + "A=11,B=12,C=0", t0.AddMilliseconds(10), t0.AddMilliseconds(10), "Good", "BRCB", 2, 1)); + + var coordinator = new FatAutoCaptureCoordinator(); + var decision = coordinator.Observe(point, new IoTestObservation( + null, + "A=11,B=12,C=13", + t0.AddMilliseconds(20), + t0.AddMilliseconds(20), + "Good", + "InformationReport/BRCB", + 3, + 1)); + + Assert.NotNull(decision.Evidence); + Assert.Equal(FatValueSlot.Value2, decision.Evidence!.Slot); + Assert.Equal("A=11,B=12,C=13", decision.Evidence.RawValue); + Assert.NotNull(decision.ShiftedValue1Evidence); + Assert.Equal("A=11,B=12,C=0", decision.ShiftedValue1Evidence!.RawValue); + } +} diff --git a/tests/ARSAS.Tests/FatCurrentEvidenceAssessmentRegressionTests.cs b/tests/ARSAS.Tests/FatCurrentEvidenceAssessmentRegressionTests.cs index bf1dd6c91..7bb36fbd5 100644 --- a/tests/ARSAS.Tests/FatCurrentEvidenceAssessmentRegressionTests.cs +++ b/tests/ARSAS.Tests/FatCurrentEvidenceAssessmentRegressionTests.cs @@ -24,11 +24,6 @@ public void GenericCurrentPair_TrueToFalse_IsPass_NotLegacyWaitingState() { var point = NewDiscretePoint("TRUE-FALSE"); SetCurrentPair(point, "True", 20, "False", 21); - - // This is the exact regression behind COMPLETE + blank Result in FAT v2: - // the old OFF -> ON -> OFF state machine would only regard TRUE -> FALSE as - // establishing an OFF baseline. Current V1/V2 assessment must instead assess - // the exact pair presented to the operator. point.Runtime.State = IoTestPointState.ArmedForOn; var assessment = FatCurrentEvidenceAssessmentService.Apply(point); @@ -84,7 +79,7 @@ public void RecapturedValue1_NewerThanRetainedValue2_InvalidatesStalePass() Assert.Equal(IoTestPointState.Review, assessment.State); Assert.Equal("⚠ REVIEW", point.FatResultText); - Assert.Contains("does not follow current Value 1", assessment.Reason, StringComparison.Ordinal); + Assert.Contains("does not prove a state change", assessment.Reason, StringComparison.Ordinal); } [Fact] @@ -191,4 +186,4 @@ private static IoTestObservation Observation(bool state, long sequence) sequence, 1); } -} +} \ No newline at end of file diff --git a/tests/ARSAS.Tests/IoFatBenchFreezeRegressionTests.cs b/tests/ARSAS.Tests/IoFatBenchFreezeRegressionTests.cs new file mode 100644 index 000000000..e04017205 --- /dev/null +++ b/tests/ARSAS.Tests/IoFatBenchFreezeRegressionTests.cs @@ -0,0 +1,95 @@ +using ArIED61850Tester.Models.IoTesting; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class IoFatBenchFreezeRegressionTests +{ + [Fact] + public void EvidenceDrain_DoesNotOverwriteSharedEngineeringLiveProjection() + { + var point = Point(); + point.Runtime.CurrentValue = "Open [01]"; + point.Runtime.CurrentQuality = "Good"; + point.Runtime.CurrentSource = "Shared Engineering process image"; + point.Runtime.CurrentIedTimestamp = "2026-09-07 05:34:39.182"; + + var coordinator = new IoTestRollingCaptureCoordinator(new IoTestTransitionEvaluator()); + coordinator.Start(point, Observation(false, "Closed [10]", 10)); + coordinator.Observe(point, Observation(true, "Open [01]", 11)); + coordinator.Observe(point, Observation(false, "Closed [10]", 12)); + + Assert.Equal("Open [01]", point.Runtime.CurrentValue); + Assert.Equal("Good", point.Runtime.CurrentQuality); + Assert.Equal("Shared Engineering process image", point.Runtime.CurrentSource); + Assert.Equal("2026-09-07 05:34:39.182", point.Runtime.CurrentIedTimestamp); + } + + [Fact] + public void FatWindow_CoalescesLegacySessionWrapperNotifications() + { + var source = File.ReadAllText(FindRepoFile("IoListTestingWindow.P1NotificationThrottle.cs")); + + Assert.Contains("Session.PropertyChanged -= window.Session_PropertyChanged", source, StringComparison.Ordinal); + Assert.Contains("Session.PropertyChanged += window.P1Session_PropertyChanged", source, StringComparison.Ordinal); + Assert.Contains("Interlocked.Exchange(ref _p1WindowRefreshScheduled, 1)", source, StringComparison.Ordinal); + Assert.Contains("DispatcherPriority.Background", source, StringComparison.Ordinal); + Assert.DoesNotContain("IsEnabled = false", source, StringComparison.Ordinal); + } + + [Fact] + public void EvidenceCoordinator_DoesNotOwnOperatorFacingLiveValue() + { + var source = File.ReadAllText(FindRepoFile("Services/IoTesting/IoTestRollingCaptureCoordinator.cs")); + + Assert.Contains("ApplyEvidenceObservationState", source, StringComparison.Ordinal); + Assert.DoesNotContain("target.ApplyObservation(observation)", source, StringComparison.Ordinal); + Assert.Contains("shared Engineering process image", source, StringComparison.OrdinalIgnoreCase); + } + + private static IoTestPointPlan Point() => new() + { + TestPointId = "TP-CSWI-POS", + IedName = "AA1E1F06R4", + IpAddress = "192.168.81.103", + SignalName = "Pos", + ObjectReference = "AA1E1F06R4Q0/CSWI1.Pos", + FunctionalConstraint = "ST", + ExpectedOnText = "Closed", + ExpectedOffText = "Open", + ExpectedOnRaw = 2, + ExpectedOffRaw = 1, + DataType = "DPC", + ImportReady = true, + BindingStatus = "SCL_DATASET_EXACT" + }; + + private static IoTestObservation Observation(bool state, string raw, long sequence) + { + var captured = new DateTimeOffset(2026, 9, 7, 5, 34, 39, TimeSpan.FromHours(7)) + .AddMilliseconds(sequence); + return new IoTestObservation( + state, + raw, + captured, + captured.AddMilliseconds(-1), + "Good", + "BRCB", + sequence, + 1); + } + + 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/IoFatCommandFreshnessBehaviorTests.cs b/tests/ARSAS.Tests/IoFatCommandFreshnessBehaviorTests.cs new file mode 100644 index 000000000..316e80a4c --- /dev/null +++ b/tests/ARSAS.Tests/IoFatCommandFreshnessBehaviorTests.cs @@ -0,0 +1,198 @@ +using System.Reflection; +using ArIED61850Tester.Models; +using UiRuntime = ArIED61850Tester.Iec61850MonitorRuntime; + +namespace ARSAS.Tests; + +public sealed class IoFatCommandFreshnessBehaviorTests +{ + [Fact] + public async Task ConfirmedCommand_RejectsStalePollAndPhantomSoe_WhileReportRemainsAuthoritative() + { + await using var runtime = new UiRuntime(); + var visiblePoints = new List(); + var visibleEvents = new List(); + runtime.PointUpdated += visiblePoints.Add; + runtime.EventRaised += visibleEvents.Add; + + var point = new Iec61850MonitorPoint + { + DeviceId = "ied-1", + DeviceName = "Relay A", + SignalName = "CSWI.Pos", + IecReference = "RelayA/CSWI1.Pos.stVal", + IecDataType = "Dbpos" + }; + var control = new SignalDefinition + { + Name = "CSWI.Pos", + ObjectReference = "RelayA/CSWI1.Pos", + ControlStatusReference = point.IecReference + }; + + // The production facade registers this expectation immediately before entering the + // inner ExecuteControlAsync call. Keep it active here while replaying the exact + // PointUpdated/EventRaised sequence emitted by that transaction and monitor loop. + RegisterExpectationMethod.Invoke(runtime, new object[] + { + point.DeviceId, + control, + "Closed [10]" + }); + + // 1. The command engine publishes the accepted/feedback-proven target immediately. + InvokePoint(runtime, Snapshot( + point, + previous: "Open [01]", + value: "Closed [10]", + reason: "confirmed command feedback / awaiting matching dchg", + report: false, + edge: true, + sequence: 10)); + InvokeEvent(runtime, Event( + point, + oldValue: "Open [01]", + newValue: "Closed [10]", + reason: "confirmed command feedback / awaiting matching dchg", + sequence: 1)); + + Assert.Single(visiblePoints); + Assert.Single(visibleEvents); + Assert.Equal("Closed [10]", visiblePoints[0].Value); + Assert.Equal("Closed [10]", visibleEvents[0].NewValue); + + // 2. One old MMS verification sample arrives after the command. The inner runtime + // can inherit the previous command reason text here, so the reason alone must never + // re-open the fence around the stale Open value. It does not match the active control + // transaction's requested Closed state and its exact edge provenance is non-report. + InvokePoint(runtime, Snapshot( + point, + previous: "Closed [10]", + value: "Open [01]", + reason: "confirmed command feedback / awaiting matching dchg", + report: false, + edge: true, + sequence: 11)); + InvokeEvent(runtime, Event( + point, + oldValue: "Closed [10]", + newValue: "Open [01]", + reason: "confirmed command feedback / awaiting matching dchg", + sequence: 2)); + + Assert.Single(visiblePoints); // no Closed -> Open live-value flash + Assert.Single(visibleEvents); // no phantom Falling/Open SOE + + // 3. The relay's real dchg confirms the already-published Closed state. The live + // snapshot is allowed through as process authority, but the duplicate return SOE is + // suppressed because the command transition was already recorded once. + InvokePoint(runtime, Snapshot( + point, + previous: "Open [01]", + value: "Closed [10]", + reason: "dchg", + report: true, + edge: true, + sequence: 12)); + InvokeEvent(runtime, Event( + point, + oldValue: "Open [01]", + newValue: "Closed [10]", + reason: "dchg", + sequence: 3)); + + Assert.Equal(2, visiblePoints.Count); + Assert.Single(visibleEvents); + Assert.Equal("Closed [10]", visiblePoints[^1].Value); + Assert.True(visiblePoints[^1].IsReportTraffic); + + // 4. A later contradictory REPORT is a genuine process transition. It must not be + // hidden by the freshness fence; both live value and SOE pass immediately. + InvokePoint(runtime, Snapshot( + point, + previous: "Closed [10]", + value: "Open [01]", + reason: "dchg", + report: true, + edge: true, + sequence: 13)); + InvokeEvent(runtime, Event( + point, + oldValue: "Closed [10]", + newValue: "Open [01]", + reason: "dchg", + sequence: 4)); + + Assert.Equal(3, visiblePoints.Count); + Assert.Equal(2, visibleEvents.Count); + Assert.Equal("Open [01]", visiblePoints[^1].Value); + Assert.Equal("Open [01]", visibleEvents[^1].NewValue); + } + + private static Iec61850PointSnapshot Snapshot( + Iec61850MonitorPoint point, + string previous, + string value, + string reason, + bool report, + bool edge, + long sequence) + => new() + { + Point = point, + PreviousValue = previous, + Value = value, + Quality = "Good", + DeviceTimestamp = "2026-09-06T12:00:00Z", + // Keep SourceMode deliberately identical between report and poll. This proves + // the fence follows IsReportTraffic provenance instead of label heuristics. + SourceMode = "Static: BRCB01", + Reason = reason, + Status = "Live", + IsValueEdge = edge, + IsReportTraffic = report, + Sequence = sequence + }; + + private static Iec61850EventEntry Event( + Iec61850MonitorPoint point, + string oldValue, + string newValue, + string reason, + long sequence) + => new() + { + Sequence = sequence, + DeviceId = point.DeviceId, + PointKey = point.PointKey, + DeviceTimestamp = "2026-09-06T12:00:00Z", + DeviceName = point.DeviceName, + IpAddress = point.IpAddress, + SignalName = point.SignalName, + IecReference = point.IecReference, + IecDataType = point.IecDataType, + OldValue = oldValue, + NewValue = newValue, + Quality = "Good", + SourceMode = "Static: BRCB01", + Reason = reason + }; + + private static void InvokePoint(UiRuntime runtime, Iec61850PointSnapshot snapshot) + => ForwardPointMethod.Invoke(runtime, new object[] { snapshot }); + + private static void InvokeEvent(UiRuntime runtime, Iec61850EventEntry entry) + => ForwardEventMethod.Invoke(runtime, new object[] { entry }); + + private static MethodInfo RegisterExpectationMethod { get; } = + typeof(UiRuntime).GetMethod("RegisterActiveCommandExpectation", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new MissingMethodException(typeof(UiRuntime).FullName, "RegisterActiveCommandExpectation"); + + private static MethodInfo ForwardPointMethod { get; } = + typeof(UiRuntime).GetMethod("ForwardPointUpdate", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new MissingMethodException(typeof(UiRuntime).FullName, "ForwardPointUpdate"); + + private static MethodInfo ForwardEventMethod { get; } = + typeof(UiRuntime).GetMethod("ForwardEventRaised", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new MissingMethodException(typeof(UiRuntime).FullName, "ForwardEventRaised"); +} diff --git a/tests/ARSAS.Tests/IoFatP0ResponsivenessRegressionTests.cs b/tests/ARSAS.Tests/IoFatP0ResponsivenessRegressionTests.cs new file mode 100644 index 000000000..0cf7236ab --- /dev/null +++ b/tests/ARSAS.Tests/IoFatP0ResponsivenessRegressionTests.cs @@ -0,0 +1,117 @@ +namespace ARSAS.Tests; + +public sealed class IoFatP0ResponsivenessRegressionTests +{ + [Fact] + public void StartResumeAndStop_KeepWindowInteractive_AndMoveDiskBarriersOffHotPath() + { + var actions = Read("IoListTestingWindow.P0RuntimeActions.cs"); + var journal = Read("Services/IoTesting/IoTestEvidenceJournal.cs"); + + Assert.Contains("FindButtonByContentBindingPath(this, nameof(SelectedStartWorkflowText))", actions, StringComparison.Ordinal); + Assert.Contains("await Dispatcher.Yield(DispatcherPriority.Render)", actions, StringComparison.Ordinal); + Assert.Contains("StartSelectedIedSafely_Click(sender, e)", actions, StringComparison.Ordinal); + Assert.Contains("WaitForP0StartWorkflowCompletionAsync", actions, StringComparison.Ordinal); + Assert.Contains("BeginCoalescedVisibleFlushScope", actions, StringComparison.Ordinal); + Assert.Contains("result = Session.Resume()", actions, StringComparison.Ordinal); + Assert.Contains("BeginDeferredSealScope", actions, StringComparison.Ordinal); + Assert.Contains("result = Session.Stop()", actions, StringComparison.Ordinal); + Assert.Contains("await IoTestEvidenceJournal.AwaitDeferredSealsAsync()", actions, StringComparison.Ordinal); + Assert.Contains("await Task.Run(Storage.SaveNow)", actions, StringComparison.Ordinal); + Assert.DoesNotContain("IsEnabled = false", actions, StringComparison.Ordinal); + + Assert.Contains("Channel", journal, StringComparison.Ordinal); + Assert.Contains("Task.Run(ProcessPendingWritesAsync)", journal, StringComparison.Ordinal); + Assert.Contains("QueueEnvelope(envelope)", journal, StringComparison.Ordinal); + Assert.Contains("await _pendingWrites.Reader.WaitToReadAsync()", journal, StringComparison.Ordinal); + Assert.Contains("Task.Run(SealDurablyAndVerify)", journal, StringComparison.Ordinal); + } + + [Fact] + public void LiveProjection_ReusesCachedIndex_InsteadOfScanningFatPlanPerFrame() + { + var projection = Read("MainWindow.P0FatRecovery.cs"); + + Assert.Contains("GetP0FatPointIndex(fat.Project)", projection, StringComparison.Ordinal); + Assert.Contains("ComputeP0FatPointIndexVersion", projection, StringComparison.Ordinal); + Assert.Contains("_p0FatPointIndexVersion == version", projection, StringComparison.Ordinal); + Assert.Contains("forceRebuild: true", projection, StringComparison.Ordinal); + Assert.Contains("Live projection index rebuilt", projection, StringComparison.Ordinal); + } + + [Fact] + public void FatIedCard_TracksEngineeringConnectionAndMonitoringStateWithoutNewMmsPolling() + { + var health = Read("MainWindow.IoFatConnectionHealth.cs"); + + Assert.Contains("nameof(Iec61850MonitorDevice.IsConnected)", health, StringComparison.Ordinal); + Assert.Contains("nameof(Iec61850MonitorDevice.IsMonitoring)", health, StringComparison.Ordinal); + Assert.Contains("nameof(Iec61850MonitorDevice.Status)", health, StringComparison.Ordinal); + Assert.Contains("ied.ApplyLiveDeviceBinding(", health, StringComparison.Ordinal); + Assert.Contains("device.IsConnected,", health, StringComparison.Ordinal); + Assert.Contains("device.IsMonitoring);", health, StringComparison.Ordinal); + Assert.DoesNotContain("Task.Delay", health, StringComparison.Ordinal); + Assert.DoesNotContain("ConnectAsync", health, StringComparison.Ordinal); + } + + [Fact] + public void CommandBridge_EmitsLatencyDiagnostics_WithoutCreatingAnotherControlStack() + { + var bridge = Read("MainWindow.IoFatCommandBridge.cs"); + + Assert.Contains("Stopwatch.StartNew()", bridge, StringComparison.Ordinal); + Assert.Contains("Command completed in", bridge, StringComparison.Ordinal); + Assert.Contains("Command values refresh completed in", bridge, StringComparison.Ordinal); + Assert.Contains("await ExecuteClaimedControlAsync(signal, claim)", bridge, StringComparison.Ordinal); + Assert.DoesNotContain("new NativeIec61850Client", bridge, StringComparison.Ordinal); + } + + [Fact] + public void CommandFeedbackFreshnessFence_PreventsValueFlickerAndPhantomSoe_WithoutHidingReports() + { + var facade = Read("Services/UiResponsiveIec61850MonitorRuntimeFacade.cs"); + + Assert.Contains("CommandFeedbackFreshnessWindow = TimeSpan.FromSeconds(2)", facade, StringComparison.Ordinal); + Assert.Contains("PendingEventOriginWindow = TimeSpan.FromSeconds(1)", facade, StringComparison.Ordinal); + Assert.Contains("ActiveCommandExpectationWindow = TimeSpan.FromSeconds(30)", facade, StringComparison.Ordinal); + Assert.Contains("_inner.PointUpdated += ForwardPointUpdate", facade, StringComparison.Ordinal); + Assert.Contains("_inner.EventRaised += ForwardEventRaised", facade, StringComparison.Ordinal); + Assert.Contains("RegisterActiveCommandExpectation(", facade, StringComparison.Ordinal); + Assert.Contains("request.Signal,", facade, StringComparison.Ordinal); + Assert.Contains("request.ValueText", facade, StringComparison.Ordinal); + Assert.Contains("IsConfirmedCommandFeedback(snapshot) &&", facade, StringComparison.Ordinal); + Assert.Contains("MatchesActiveCommandExpectation(key, snapshot.Value, nowUtc)", facade, StringComparison.Ordinal); + Assert.Contains("_commandFeedbackFences[key] = new CommandFeedbackFence", facade, StringComparison.Ordinal); + Assert.Contains("_pendingEventOrigins[key] = new PendingEventOrigin", facade, StringComparison.Ordinal); + Assert.Contains("snapshot.IsReportTraffic", facade, StringComparison.Ordinal); + Assert.Contains("ForwardEventRaised(Iec61850EventEntry entry)", facade, StringComparison.Ordinal); + Assert.Contains("TakePendingEventOrigin(key, entry.NewValue)", facade, StringComparison.Ordinal); + Assert.Contains("origin is { IsConfirmedCommandFeedback: true } && matchesConfirmed", facade, StringComparison.Ordinal); + Assert.Contains("origin is { IsReportTraffic: true }", facade, StringComparison.Ordinal); + Assert.Contains("suppressed duplicate report SOE", facade, StringComparison.Ordinal); + Assert.Contains("withheld phantom MMS verification SOE", facade, StringComparison.Ordinal); + Assert.Contains("P0_COMMAND_FRESHNESS: ", facade, StringComparison.Ordinal); + Assert.Contains("ClearCommandFeedbackState(deviceId)", facade, StringComparison.Ordinal); + Assert.Contains("_activeCommandExpectations.TryRemove", facade, StringComparison.Ordinal); + Assert.Contains("Report traffic remains process", facade, StringComparison.Ordinal); + Assert.DoesNotContain("IsReportEvent(Iec61850EventEntry entry)", facade, StringComparison.Ordinal); + Assert.DoesNotContain("using System.Windows", facade, 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(relativePath); + } +} \ No newline at end of file diff --git a/tests/ARSAS.Tests/IoFatP2CompactHeaderRegressionTests.cs b/tests/ARSAS.Tests/IoFatP2CompactHeaderRegressionTests.cs index dd25879c0..744b4663a 100644 --- a/tests/ARSAS.Tests/IoFatP2CompactHeaderRegressionTests.cs +++ b/tests/ARSAS.Tests/IoFatP2CompactHeaderRegressionTests.cs @@ -3,7 +3,7 @@ namespace ARSAS.Tests; public sealed class IoFatP2CompactHeaderRegressionTests { [Fact] - public void P2_ReusesP0PrimaryAndSecondaryHeaderHierarchy() + public void P2_ReusesP0SingleRowHeaderWithCollapsedCompatibilityPanel() { var p0 = File.ReadAllText(FindRepoFile("IoListTestingWindow.P0BenchUx.cs")); @@ -11,12 +11,10 @@ public void P2_ReusesP0PrimaryAndSecondaryHeaderHierarchy() Assert.Contains("ConfigureP2CompactHeader();", p0, StringComparison.Ordinal); Assert.Contains("_p0PrimaryHeaderActions", p0, StringComparison.Ordinal); Assert.Contains("_p0SecondaryHeaderActions", p0, StringComparison.Ordinal); - Assert.Contains("ReferenceEquals(element, WorkspacePreviewToggle)", p0, StringComparison.Ordinal); - Assert.Contains("ReferenceEquals(element, _timeSyncEvidenceButton)", p0, StringComparison.Ordinal); - Assert.Contains("ReferenceEquals(element, _comtradeEvidenceButton)", p0, StringComparison.Ordinal); - Assert.Contains("ReferenceEquals(element, _cleanSessionButton)", p0, StringComparison.Ordinal); - Assert.Contains("ReferenceEquals(element, _clockSyncGlobalStatusText)", p0, StringComparison.Ordinal); - Assert.Contains("ReferenceEquals(element, _clockSyncEvidenceText)", p0, StringComparison.Ordinal); + Assert.Contains("Visibility = Visibility.Collapsed", p0, StringComparison.Ordinal); + Assert.Contains("foreach (var child in children)", p0, StringComparison.Ordinal); + Assert.Contains("_p0PrimaryHeaderActions.Children.Add(child);", p0, StringComparison.Ordinal); + Assert.Contains("private bool IsP0SecondaryHeaderAction(UIElement element) => false;", p0, StringComparison.Ordinal); } [Fact] diff --git a/tests/ARSAS.Tests/IoFatPhysicalBenchHotPathRegressionTests.cs b/tests/ARSAS.Tests/IoFatPhysicalBenchHotPathRegressionTests.cs new file mode 100644 index 000000000..166d48917 --- /dev/null +++ b/tests/ARSAS.Tests/IoFatPhysicalBenchHotPathRegressionTests.cs @@ -0,0 +1,88 @@ +namespace ARSAS.Tests; + +public sealed class IoFatPhysicalBenchHotPathRegressionTests +{ + [Fact] + public void AlreadyLiveStartContinue_BypassesPreparationAndOwnsButtonBeforeLegacyHandler() + { + var source = File.ReadAllText(FindRepoFile("IoListTestingWindow.P0RelayBenchHotPath.cs")); + + Assert.Contains("RegisterClassHandler", source, StringComparison.Ordinal); + Assert.Contains("typeof(Button)", source, StringComparison.Ordinal); + Assert.Contains("Button.ClickEvent", source, StringComparison.Ordinal); + Assert.Contains("e.Handled = true", source, StringComparison.Ordinal); + Assert.Contains("SelectedIed?.IsLiveMonitoring == true", source, StringComparison.Ordinal); + Assert.Contains("Session.Start(ied, live)", source, StringComparison.Ordinal); + Assert.DoesNotContain("PrepareIoTestIedForFatAsync", source, StringComparison.Ordinal); + } + + [Fact] + public void Stop_SealsAndSavesAwayFromDispatcher() + { + var source = File.ReadAllText(FindRepoFile("IoListTestingWindow.P0RelayBenchHotPath.cs")); + + Assert.Contains("IoTestEvidenceJournal.BeginDeferredSealScope()", source, StringComparison.Ordinal); + Assert.Contains("await IoTestEvidenceJournal.AwaitDeferredSealsAsync()", source, StringComparison.Ordinal); + Assert.Contains("await Task.Run(Storage.SaveNow)", source, StringComparison.Ordinal); + } + + [Fact] + public void EvidenceNotifications_AreNotAmplifiedIntoFullProjectionForEveryJournalProperty() + { + var source = File.ReadAllText(FindRepoFile("Services/IoTesting/IoTestMultiSessionCoordinator.cs")); + + Assert.Contains("switch (e.PropertyName)", source, StringComparison.Ordinal); + Assert.Contains("case nameof(IoTestSessionController.EvidenceRecordCount)", source, StringComparison.Ordinal); + Assert.Contains("Raise(nameof(EvidenceRecordCount))", source, StringComparison.Ordinal); + Assert.Contains("case nameof(IoTestSessionController.LastJournalHash)", source, StringComparison.Ordinal); + Assert.DoesNotContain( + "private void Child_PropertyChanged(object? sender, PropertyChangedEventArgs e)\n => RaiseProjectionProperties();", + source, + StringComparison.Ordinal); + } + + [Fact] + public void FatControl_FinalValueIsReconciledFromSharedEngineeringProcessImage() + { + var source = File.ReadAllText(FindRepoFile("MainWindow.P0RelayBenchControlAuthority.cs")); + var hotPath = File.ReadAllText(FindRepoFile("IoListTestingWindow.P0RelayBenchHotPath.cs")); + + Assert.Contains("signal.ControlStatusReference", source, StringComparison.Ordinal); + Assert.Contains("device.Points", source, StringComparison.Ordinal); + Assert.Contains("OrderByDescending(point => point.Sequence)", source, StringComparison.Ordinal); + Assert.Contains("signal.ControlCurrentValue = latest.Value", source, StringComparison.Ordinal); + Assert.Contains("ReconcileIoFatCommandValueFromSharedProcessImage(signal)", hotPath, StringComparison.Ordinal); + } + + [Fact] + public void NativeFatReport_IsUsableRecordAndKeepsAcceptanceSignOff() + { + var core = File.ReadAllText(FindRepoFile("Services/IoTesting/IoFatV2ReportLayoutEngine.cs")); + var supplemental = File.ReadAllText(FindRepoFile("Services/IoTesting/IoFatSupplementalReportLayoutDecorator.cs")); + + Assert.Contains("\"FAT REPORT\"", core, StringComparison.Ordinal); + Assert.DoesNotContain("\"PREVIEW\"", core, StringComparison.Ordinal); + Assert.DoesNotContain("\"AS TESTED\"", core, StringComparison.Ordinal); + + Assert.Contains("\"FOR FAT RECORD\"", supplemental, StringComparison.Ordinal); + Assert.DoesNotContain("\"NOT FOR ISSUE\"", supplemental, StringComparison.Ordinal); + Assert.DoesNotContain("\"CUSTOMER FAT RECORD\"", supplemental, StringComparison.Ordinal); + Assert.Contains("\"TESTED BY\"", supplemental, StringComparison.Ordinal); + Assert.Contains("\"WITNESSED BY\"", supplemental, StringComparison.Ordinal); + Assert.Contains("\"APPROVED BY\"", supplemental, 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(relativePath); + } +} diff --git a/tests/ARSAS.Tests/IoFatRelayBenchP0RegressionTests.cs b/tests/ARSAS.Tests/IoFatRelayBenchP0RegressionTests.cs new file mode 100644 index 000000000..8d23aa7d9 --- /dev/null +++ b/tests/ARSAS.Tests/IoFatRelayBenchP0RegressionTests.cs @@ -0,0 +1,269 @@ +using ArIED61850Tester.Models; +using ArIED61850Tester.Models.IoTesting; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class IoFatRelayBenchP0RegressionTests +{ + [Fact] + public void ReportBackedAnalog_LatchesFirstGoodValueAndFirstChangedValueImmediately() + { + var point = NewAnalogPoint("TOTVA"); + var coordinator = new FatAutoCaptureCoordinator(); + + var value1 = coordinator.Observe(point, Observation("0", 1, "BRCB")); + + Assert.NotNull(value1.Evidence); + Assert.Equal(FatValueSlot.Value1, value1.Evidence!.Slot); + Assert.Equal("0", value1.Evidence.RawValue); + Assert.Equal(FatAutoCaptureStage.WaitingChange, value1.Stage); + point.Runtime.SetFatValueEvidence(value1.Evidence); + + var value2 = coordinator.Observe(point, Observation("1000", 2, "BRCB")); + + Assert.NotNull(value2.Evidence); + Assert.Equal(FatValueSlot.Value2, value2.Evidence!.Slot); + Assert.Equal("1000", value2.Evidence.RawValue); + Assert.Equal(FatAutoCaptureStage.Complete, value2.Stage); + } + + [Fact] + public void CompletedPair_AdvancesToLatestTwoConditionsWhenTestIsRepeated() + { + var point = NewAnalogPoint("ROLLING-TOTVA"); + var coordinator = new FatAutoCaptureCoordinator(); + + var first = coordinator.Observe(point, Observation("0", 1, "BRCB")); + Assert.NotNull(first.Evidence); + point.Runtime.SetFatValueEvidence(first.Evidence!); + + var second = coordinator.Observe(point, Observation("1000", 2, "BRCB")); + Assert.NotNull(second.Evidence); + point.Runtime.SetFatValueEvidence(second.Evidence!); + Assert.Equal("0", point.Value1Text); + Assert.Equal("1000", point.Value2Text); + + var repeated = coordinator.Observe(point, Observation("0", 3, "BRCB")); + Assert.NotNull(repeated.Evidence); + Assert.NotNull(repeated.ShiftedValue1Evidence); + point.Runtime.SetFatValueEvidence(repeated.ShiftedValue1Evidence!); + point.Runtime.SetFatValueEvidence(repeated.Evidence!); + + Assert.Equal(FatAutoCaptureStage.Complete, repeated.Stage); + Assert.Equal("1000", point.Value1Text); + Assert.Equal("0", point.Value2Text); + } + + [Fact] + public void CompletedPair_DoesNotAdvanceWhenNewestConditionIsRepeatedWithoutChange() + { + var point = NewAnalogPoint("ROLLING-NO-EDGE"); + var coordinator = new FatAutoCaptureCoordinator(); + + var first = coordinator.Observe(point, Observation("0", 1, "BRCB")); + point.Runtime.SetFatValueEvidence(first.Evidence!); + var second = coordinator.Observe(point, Observation("1000", 2, "BRCB")); + point.Runtime.SetFatValueEvidence(second.Evidence!); + + var unchanged = coordinator.Observe(point, Observation("1000", 3, "BRCB")); + + Assert.Null(unchanged.Evidence); + Assert.Equal(FatAutoCaptureStage.Complete, unchanged.Stage); + Assert.Equal("0", point.Value1Text); + Assert.Equal("1000", point.Value2Text); + } + + [Fact] + public void ReportBackedAnalog_EquivalentNoiseDoesNotConsumeValue2() + { + var point = NewAnalogPoint("TOTVA-NOISE"); + var coordinator = new FatAutoCaptureCoordinator(); + + var baseline = coordinator.Observe(point, Observation("1000", 1, "InformationReport/BRCB")); + Assert.NotNull(baseline.Evidence); + point.Runtime.SetFatValueEvidence(baseline.Evidence!); + + var equivalent = coordinator.Observe(point, Observation("1000.2", 2, "InformationReport/BRCB")); + Assert.Null(equivalent.Evidence); + Assert.Equal(FatAutoCaptureStage.WaitingChange, equivalent.Stage); + + var changed = coordinator.Observe(point, Observation("1001", 3, "InformationReport/BRCB")); + Assert.NotNull(changed.Evidence); + Assert.Equal(FatValueSlot.Value2, changed.Evidence!.Slot); + Assert.Equal("1001", changed.Evidence.RawValue); + } + + [Fact] + public void PolledAnalog_KeepsSettlingGuard() + { + var point = NewAnalogPoint("POLL-FALLBACK"); + var coordinator = new FatAutoCaptureCoordinator(); + + var first = coordinator.Observe(point, Observation("12.5", 1, "MMS-POLL")); + var second = coordinator.Observe(point, Observation("12.5", 2, "MMS-POLL")); + var third = coordinator.Observe(point, Observation("12.5", 3, "MMS-POLL")); + + Assert.Null(first.Evidence); + Assert.Null(second.Evidence); + Assert.NotNull(third.Evidence); + Assert.Equal(FatValueSlot.Value1, third.Evidence!.Slot); + } + + [Fact] + public void EvidenceJournal_QueuesWritesAndKeepsDurableBarrierOffAppendPath() + { + var source = File.ReadAllText(FindRepositoryFile( + Path.Combine("Services", "IoTesting", "IoTestEvidenceJournal.cs"))); + + Assert.DoesNotContain("FileOptions.WriteThrough", source, StringComparison.Ordinal); + Assert.Contains("Channel", source, StringComparison.Ordinal); + Assert.Contains("Task.Run(ProcessPendingWritesAsync)", source, StringComparison.Ordinal); + Assert.Contains("QueueEnvelope(envelope);", source, StringComparison.Ordinal); + Assert.Contains("await _pendingWrites.Reader.WaitToReadAsync()", source, StringComparison.Ordinal); + Assert.Contains("_writer.Flush();", source, StringComparison.Ordinal); + Assert.Contains("_stream.Flush(flushToDisk: true);", source, StringComparison.Ordinal); + Assert.Contains("FileOptions.SequentialScan", source, StringComparison.Ordinal); + } + + [Fact] + public void AlreadyLiveEngineeringScope_UsesExactNoDiscoveryFastPath() + { + var point = new IoTestPointPlan + { + TestPointId = "FAST-POS", + IedName = "AA1E1F06R4", + IpAddress = "192.168.81.103", + SignalName = "Position", + ObjectReference = "AA1E1F06R4Q0/CSWI1.Pos.stVal", + FunctionalConstraint = "ST", + ExpectedOnText = "Closed", + ExpectedOffText = "Open", + WorkspaceSelected = true, + TestEnabled = true, + ImportReady = true, + BindingStatus = IoTestSignalSelectionService.SclWorkspaceAuthorityBindingStatus + }; + var ied = new IoTestIedPlan + { + IedName = point.IedName, + IpAddress = point.IpAddress, + TestPoints = { point } + }; + var signal = new SignalDefinition + { + Name = point.SignalName, + ObjectReference = point.ObjectReference, + DisplayReference = point.ObjectReference, + FunctionalConstraint = point.FunctionalConstraint, + IsSelected = true + }; + var device = new Iec61850MonitorDevice + { + DeviceId = "fast-live-device", + Name = point.IedName, + SclIedName = point.IedName, + IpAddress = point.IpAddress, + IsConnected = true, + IsMonitoring = true + }; + device.Signals.Add(signal); + device.Points.Add(new Iec61850MonitorPoint + { + DeviceId = device.DeviceId, + DeviceName = device.Name, + IpAddress = device.IpAddress, + SignalName = point.SignalName, + IecReference = point.ObjectReference, + FunctionalConstraint = point.FunctionalConstraint, + Value = "Closed [10]", + Quality = "Good", + SourceMode = "BRCB" + }); + + var result = new IoTestSignalSelectionService().Resolve(ied, device); + + Assert.True(result.Succeeded, result.Message); + Assert.Single(result.Matches); + Assert.Same(signal, result.Matches[0].Signal); + Assert.Contains("already-live Engineering acquisition session", result.Message, StringComparison.Ordinal); + } + + [Fact] + public void AlreadyLiveFastPath_PrecedesMandatoryInventoryMutation() + { + var source = File.ReadAllText(FindRepositoryFile( + Path.Combine("Services", "IoTesting", "IoTestSignalSelectionService.cs"))); + var fastPath = source.IndexOf("TryResolveAlreadyLiveExactScope", StringComparison.Ordinal); + var mandatoryInventory = source.IndexOf("Iec61850DataSetSignalInventoryService.EnsureMandatorySignals(device);", StringComparison.Ordinal); + + Assert.True(fastPath >= 0); + Assert.True(mandatoryInventory > fastPath); + Assert.Contains("device.IsConnected && device.IsMonitoring", source, StringComparison.Ordinal); + } + + [Fact] + public void PreparationProgress_DoesNotWalkWholeVisualTreeOnHotDispatcherTick() + { + var source = File.ReadAllText(FindRepositoryFile("IoListTestingWindow.RealPreparationProgress.cs")); + var tickStart = source.IndexOf("private void PreparationProgressTimer_Tick", StringComparison.Ordinal); + var cacheStart = source.IndexOf("private void RefreshPreparationProgressBarCache", StringComparison.Ordinal); + + Assert.True(tickStart >= 0); + Assert.True(cacheStart > tickStart); + var tickBody = source[tickStart..cacheStart]; + Assert.DoesNotContain("VisualDescendants", tickBody, StringComparison.Ordinal); + Assert.Contains("DispatcherPriority.Background", source, StringComparison.Ordinal); + Assert.Contains("Interval = TimeSpan.FromMilliseconds(100)", source, StringComparison.Ordinal); + Assert.Contains("if (!hasActivePreparation)", tickBody, StringComparison.Ordinal); + Assert.Contains("return;", tickBody, StringComparison.Ordinal); + } + + private static IoTestPointPlan NewAnalogPoint(string id) + => new() + { + TestPointId = id, + IedName = "AA1E1F06R4", + IpAddress = "192.168.81.103", + SignalName = id, + ObjectReference = $"AA1E1F06R4/PPRE_MMXU1.{id}", + FunctionalConstraint = "MX", + ExpectedOnText = "Value 1", + ExpectedOffText = "Value 2", + SignalKind = FatSignalKind.Analog, + CaptureMode = FatCaptureMode.OperatorSnapshot, + WorkspaceSelected = true, + TestEnabled = true, + ImportReady = true, + BindingStatus = "CID_DATASET_EXACT" + }; + + private static IoTestObservation Observation(string rawValue, long sequence, string source) + { + var captured = new DateTimeOffset(2026, 9, 6, 9, 0, 0, TimeSpan.Zero) + .AddMilliseconds(sequence * 10); + return new IoTestObservation( + null, + rawValue, + captured, + captured.AddMilliseconds(-2), + "Good", + source, + sequence, + 1); + } + + private static string FindRepositoryFile(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); + } +} \ No newline at end of file diff --git a/tests/ARSAS.Tests/IoFatRemoteComtradeEvidenceTests.cs b/tests/ARSAS.Tests/IoFatRemoteComtradeEvidenceTests.cs index 82d94acd7..3aceec862 100644 --- a/tests/ARSAS.Tests/IoFatRemoteComtradeEvidenceTests.cs +++ b/tests/ARSAS.Tests/IoFatRemoteComtradeEvidenceTests.cs @@ -32,7 +32,7 @@ public void CaptureLatest_SelectsNewestRelayRecordWithoutRequiringDownload() } [Fact] - public void FatPdf_IncludesLatestRemoteComtradeAsFileServiceEvidence() + public void FatPdf_IncludesConciseLatestRemoteComtradeFileServiceEvidence() { var project = BuildProject(); var ied = project.Ieds[0]; @@ -49,9 +49,12 @@ public void FatPdf_IncludesLatestRemoteComtradeAsFileServiceEvidence() Assert.Contains("File Service / COMTRADE Evidence", text, StringComparison.Ordinal); Assert.Contains("FRA00028.cfg + FRA00028.dat", text, StringComparison.Ordinal); + Assert.Contains("Relay modified", text, StringComparison.Ordinal); + Assert.Contains("Evidence source", text, StringComparison.Ordinal); Assert.Contains("FileDirectory", text, StringComparison.Ordinal); - Assert.Contains("OPTIONAL", text, StringComparison.Ordinal); - Assert.Contains("not a FAT gate", text, StringComparison.Ordinal); + Assert.DoesNotContain("OPTIONAL", text, StringComparison.Ordinal); + Assert.DoesNotContain("not a FAT gate", text, StringComparison.Ordinal); + Assert.DoesNotContain("Download", text, StringComparison.Ordinal); } private static Iec61850FaultRecordSet BuildRecord(string baseName, DateTimeOffset modified) diff --git a/tests/ARSAS.Tests/IoFatSharedProcessImageRegressionTests.cs b/tests/ARSAS.Tests/IoFatSharedProcessImageRegressionTests.cs new file mode 100644 index 000000000..488671466 --- /dev/null +++ b/tests/ARSAS.Tests/IoFatSharedProcessImageRegressionTests.cs @@ -0,0 +1,45 @@ +namespace ARSAS.Tests; + +public sealed class IoFatSharedProcessImageRegressionTests +{ + [Fact] + public void FatEvidence_DetachesRawRuntimeObserversAndSamplesEngineeringUiImage() + { + var source = File.ReadAllText(FindRepoFile("MainWindow.P0FatSharedProcessEvidence.cs")); + + Assert.Contains("_runtime.PointUpdated -= Runtime_IoTestPointUpdated", source, StringComparison.Ordinal); + Assert.Contains("_runtime.PointUpdated -= Runtime_IoTestAdditionalPointUpdated", source, StringComparison.Ordinal); + Assert.Contains("_runtime.PointUpdated -= P0FatRuntimePointUpdated", source, StringComparison.Ordinal); + Assert.Contains("_uiFlushTimer.Tick += P0FatSharedProcessEvidence_Tick", source, StringComparison.Ordinal); + Assert.Contains("device.Points", source, StringComparison.Ordinal); + Assert.Contains("ProjectSharedEngineeringPointToFat", source, StringComparison.Ordinal); + Assert.Contains("coordinator.PrimaryController.Enqueue(entry)", source, StringComparison.Ordinal); + Assert.Contains("coordinator.EnqueueAdditional(entry)", source, StringComparison.Ordinal); + } + + [Fact] + public void ParallelEvidenceWiring_UsesSharedProcessRouteInsteadOfRawPointSubscription() + { + var source = File.ReadAllText(FindRepoFile("MainWindow.IoTesting.MultiSessionEvidence.cs")); + + Assert.Contains("AttachIoFatSharedProcessEvidenceRoute(coordinator)", source, StringComparison.Ordinal); + Assert.Contains("DetachIoFatSharedProcessEvidenceRoute(coordinator)", source, StringComparison.Ordinal); + Assert.DoesNotContain( + "_runtime.PointUpdated += Runtime_IoTestAdditionalPointUpdated;\n _runtime.PointUpdated += Runtime_IoTestAdditionalPointUpdated;", + 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(relativePath); + } +} diff --git a/tests/ARSAS.Tests/IoFatV2WorkspaceRegressionTests.cs b/tests/ARSAS.Tests/IoFatV2WorkspaceRegressionTests.cs index 3042e168a..0bfc1ba03 100644 --- a/tests/ARSAS.Tests/IoFatV2WorkspaceRegressionTests.cs +++ b/tests/ARSAS.Tests/IoFatV2WorkspaceRegressionTests.cs @@ -49,21 +49,28 @@ public void OperatorSnapshot_Value1Value2Recapture_IsJournalFirstAndKeepsSession } [Fact] - public void OperatorSnapshot_JournalFailure_DoesNotPromoteCurrentEvidencePointer() + public void OperatorSnapshot_JournalFailure_DoesNotPromoteReplacementEvidencePointer() { var fixture = ManualFixture(journalFactory: (_, _, _, _) => new FailAfterStartupJournal()); using var controller = fixture.Controller; Assert.True(controller.Start(fixture.Ied).Succeeded); + var startupEvidence = fixture.Point.Runtime.Value1Evidence; + Assert.NotNull(startupEvidence); + Assert.Equal("12.34", startupEvidence!.RawValue); + fixture.LivePoint.Value = "13.01"; + fixture.LivePoint.Sequence = 2; var result = controller.CaptureOperatorSnapshot(fixture.Point, FatValueSlot.Value1); Assert.False(result.Succeeded); - Assert.Null(fixture.Point.Runtime.Value1Evidence); + Assert.NotNull(fixture.Point.Runtime.Value1Evidence); + Assert.Equal(startupEvidence.EvidenceId, fixture.Point.Runtime.Value1Evidence!.EvidenceId); + Assert.Equal("12.34", fixture.Point.Runtime.Value1Evidence.RawValue); Assert.Equal(IoTestSessionState.Faulted, controller.State); } [Fact] - public void OperatorSnapshot_LiveRefreshDoesNotRaiseSessionProgressForEverySample() + public void OperatorSnapshot_EquivalentLiveRefreshDoesNotRaiseSessionProgressForEverySample() { var fixture = ManualFixture(); using var controller = fixture.Controller; @@ -82,13 +89,14 @@ public void OperatorSnapshot_LiveRefreshDoesNotRaiseSessionProgressForEverySampl SignalName = fixture.LivePoint.SignalName, IecReference = fixture.LivePoint.IecReference, OldValue = "12.34", - NewValue = "12.35", + NewValue = "12.341", Quality = "Good", SourceMode = "BRCB", Reason = "periodic-refresh" }); - Assert.Equal("12.35", fixture.Point.Runtime.CurrentValue); + Assert.Equal("12.341", fixture.Point.Runtime.CurrentValue); + Assert.Null(fixture.Point.Runtime.Value2Evidence); Assert.Equal(0, sessionNotifications); } diff --git a/tests/ARSAS.Tests/IoListFatCommandPanelRegressionTests.cs b/tests/ARSAS.Tests/IoListFatCommandPanelRegressionTests.cs index 78c9c81bb..66f10d6b5 100644 --- a/tests/ARSAS.Tests/IoListFatCommandPanelRegressionTests.cs +++ b/tests/ARSAS.Tests/IoListFatCommandPanelRegressionTests.cs @@ -13,7 +13,7 @@ public void FatCommandPanel_UsesSharedEngineeringDeviceAndCommandCollection() Assert.Contains("ResolveIoTestDevice(ied.IedName)", bridge, StringComparison.Ordinal); Assert.Contains("device.RefreshCommandSignalProjection()", bridge, StringComparison.Ordinal); Assert.Contains("_signalOwners[signal] = device", bridge, StringComparison.Ordinal); - Assert.Contains("device.CommandSignals", panel, StringComparison.Ordinal); + Assert.Contains("device?.CommandSignals.ToArray()", panel, StringComparison.Ordinal); Assert.Contains("shared Engineering command backend", panel, StringComparison.Ordinal); // FAT is only another operating surface. It must never construct a second MMS @@ -21,7 +21,33 @@ public void FatCommandPanel_UsesSharedEngineeringDeviceAndCommandCollection() Assert.DoesNotContain("ExecuteControlAsync", panel, StringComparison.Ordinal); Assert.DoesNotContain("_runtime.", panel, StringComparison.Ordinal); Assert.Contains("ExecuteIoFatControlClaimAsync", panel, StringComparison.Ordinal); - Assert.Contains("return ExecuteClaimedControlAsync(signal, claim)", bridge, StringComparison.Ordinal); + Assert.Contains("await ExecuteClaimedControlAsync(signal, claim)", bridge, StringComparison.Ordinal); + + // P0 latency instrumentation may wrap the shared Engineering execution in an + // async method, but it must remain observational only: the same claimed command + // backend is awaited and failures are rethrown rather than converted to success. + Assert.Contains("[IO FAT P0] Command completed in", bridge, StringComparison.Ordinal); + Assert.Contains("[IO FAT P0] Command failed after", bridge, StringComparison.Ordinal); + Assert.Contains("throw;", bridge, StringComparison.Ordinal); + } + + [Fact] + public void FatCommandPanel_LiveUpdatesDoNotRebuildWholeRows() + { + var bridge = File.ReadAllText(FindRepoFile("MainWindow.IoFatCommandBridge.cs")); + var panel = File.ReadAllText(FindRepoFile("IoListTestingWindow.CommandPanel.cs")); + + Assert.DoesNotContain("RebuildFatCommandRows", panel, StringComparison.Ordinal); + Assert.Contains("SynchronizeFatCommandRows", panel, StringComparison.Ordinal); + Assert.Contains("new Binding(nameof(SignalDefinition.ControlCurrentValue))", panel, StringComparison.Ordinal); + Assert.Contains("new Binding(nameof(SignalDefinition.ControlLastResult))", panel, StringComparison.Ordinal); + Assert.Contains("new Binding(nameof(SignalDefinition.ControlIsBusy))", panel, StringComparison.Ordinal); + Assert.Contains("RefreshFatCommandActions(signal)", panel, StringComparison.Ordinal); + + Assert.Contains("ProjectIoFatCommandValuesFromSharedProcessImage(device)", bridge, StringComparison.Ordinal); + Assert.Contains("RebuildControlFeedbackIndex(device)", bridge, StringComparison.Ordinal); + Assert.Contains("RefreshControlValuesAsync(device, force: false)", bridge, StringComparison.Ordinal); + Assert.DoesNotContain("RefreshControlValuesAsync(device, force: true)", bridge, StringComparison.Ordinal); } [Fact] diff --git a/tests/ARSAS.Tests/IoTestSessionControllerTests.cs b/tests/ARSAS.Tests/IoTestSessionControllerTests.cs index 83738b455..40536058d 100644 --- a/tests/ARSAS.Tests/IoTestSessionControllerTests.cs +++ b/tests/ARSAS.Tests/IoTestSessionControllerTests.cs @@ -52,12 +52,13 @@ public void InitiallyOn_RecordsOffBaselineBeforeNewOnOffPassAndRemainsRunning() } [Fact] - public void ResumeAfterOnEvidence_ForcesReviewBecausePausedEdgesCouldBeMissedButCaptureRemainsRunning() + public void ResumeAfterOnEvidence_PreservesEvidenceAndKeepsCaptureRunning() { var fixture = CreateFixture(); using var controller = fixture.Controller; controller.Start(fixture.Ied); controller.Enqueue(Event(fixture, "False", "True", 1)); + var onEvidence = fixture.Point.Runtime.OnEvidence?.EvidenceId; fixture.LivePoint.Value = "True"; fixture.LivePoint.Sequence = 1; @@ -65,9 +66,9 @@ public void ResumeAfterOnEvidence_ForcesReviewBecausePausedEdgesCouldBeMissedBut var resumed = controller.Resume(); Assert.True(resumed.Succeeded, resumed.Message); - Assert.Equal(IoTestPointState.Review, fixture.Point.Runtime.State); Assert.Equal(IoTestSessionState.Running, controller.State); - Assert.Contains("continuity cannot be proven", fixture.Point.Runtime.StatusReason, StringComparison.OrdinalIgnoreCase); + Assert.NotNull(onEvidence); + Assert.Equal(onEvidence, fixture.Point.Runtime.OnEvidence?.EvidenceId); } [Fact] diff --git a/tests/ARSAS.Tests/P0Build1888RecoveryRegressionTests.cs b/tests/ARSAS.Tests/P0Build1888RecoveryRegressionTests.cs index 4aa722aed..4b12550a1 100644 --- a/tests/ARSAS.Tests/P0Build1888RecoveryRegressionTests.cs +++ b/tests/ARSAS.Tests/P0Build1888RecoveryRegressionTests.cs @@ -42,12 +42,51 @@ public void P0_SclFromFat_ReusesEngineeringModel_AndPreservesStaticDataSetAuthor var recovery = File.ReadAllText(FindRepoFile("MainWindow.P0FatRecovery.cs")); var shared = File.ReadAllText(FindRepoFile("MainWindow.SharedSclWorkspace.cs")); var append = File.ReadAllText(FindRepoFile("MainWindow.IoTesting.SclAppend.cs")); + var autoConnect = File.ReadAllText(FindRepoFile("MainWindow.IoTesting.AutoConnect.cs")); Assert.Contains("device.HasDiscoveryCache = true", recovery, StringComparison.Ordinal); Assert.Contains("_pendingSharedStaticSelectionAssignments", shared, StringComparison.Ordinal); Assert.Contains("ApplyStaticDataSetSelection(device);", shared, StringComparison.Ordinal); Assert.Contains("ApplyStaticDataSetSelection(device);", append, StringComparison.Ordinal); Assert.Contains("Iec61850MonitoringModeRegistry.UseStaticDataSetReportOnly(device)", shared, StringComparison.Ordinal); + + // Direct SCL FAT import must remain one shared Engineering model: connect/associate + // with the imported ARIEC workspace and do not silently fall back to discovery. + Assert.Contains("AttachIoFatSclRuntimeAuthority(ied, device)", autoConnect, StringComparison.Ordinal); + Assert.Contains("ConnectIoFatUsingPreparedSclAsync(device)", autoConnect, StringComparison.Ordinal); + Assert.Contains("Full discovery was intentionally not started", autoConnect, StringComparison.Ordinal); + Assert.Contains("sharedStaticDataSetAuthority", autoConnect, StringComparison.Ordinal); + Assert.Contains("Iec61850MonitoringModeRegistry.UseStaticDataSetReportOnly(device)", autoConnect, StringComparison.Ordinal); + Assert.Contains("hasBlockingNonSclMiss", autoConnect, StringComparison.Ordinal); + Assert.Contains("!IoTestSignalSelectionService.IsDirectSclAuthority(point)", autoConnect, StringComparison.Ordinal); + } + + [Fact] + public void P0_EngineeringPlay_UsesPreparedSclModel_ThenStartsStaticDataSetMonitorWithoutDiscovery() + { + var main = File.ReadAllText(FindRepoFile("MainWindow.xaml.cs")); + var playStart = main.IndexOf("private async void IedPlayAction_Click", StringComparison.Ordinal); + var playEnd = main.IndexOf("private async void IedStopAction_Click", playStart, StringComparison.Ordinal); + Assert.True(playStart >= 0 && playEnd > playStart); + var play = main[playStart..playEnd]; + + Assert.Contains("device.HasDiscoveryCache && device.Signals.Count > 0", play, StringComparison.Ordinal); + Assert.Contains("await ConnectUsingSavedModelAsync(device)", play, StringComparison.Ordinal); + Assert.Contains("await ConnectAndConfigureDeviceAsync(device, openWizard: discoveryWillOpenWizard)", play, StringComparison.Ordinal); + Assert.Contains("await StartDeviceMonitorAsync(device)", play, StringComparison.Ordinal); + Assert.True( + play.IndexOf("ConnectUsingSavedModelAsync(device)", StringComparison.Ordinal) < + play.IndexOf("ConnectAndConfigureDeviceAsync(device", StringComparison.Ordinal)); + + var runtime = File.ReadAllText(FindRepoFile("Services/Iec61850MonitorRuntime.cs")); + var monitorStart = runtime.IndexOf("public async Task> StartMonitoringAsync", StringComparison.Ordinal); + var monitorEnd = runtime.IndexOf("private ", monitorStart, StringComparison.Ordinal); + Assert.True(monitorStart >= 0 && monitorEnd > monitorStart); + var monitor = runtime[monitorStart..monitorEnd]; + + Assert.Contains("Iec61850MonitoringModeRegistry.IsStaticDataSetReportOnly(device)", monitor, StringComparison.Ordinal); + Assert.Contains("!staticDataSetReportOnly || !string.IsNullOrWhiteSpace(signal.DataSetReference)", monitor, StringComparison.Ordinal); + Assert.DoesNotContain("ConnectAndDiscoverAsync", monitor, StringComparison.Ordinal); } [Fact] @@ -81,21 +120,37 @@ public void P0_AnalogCapture_IsAutomatic_AndNormalCellCaptureTemplateIsRemoved() var coordinator = File.ReadAllText(FindRepoFile("Services/IoTesting/FatAutoCaptureCoordinator.cs")); Assert.Contains("column.CellTemplate = BuildP0EvidenceValueTemplate", ux, StringComparison.Ordinal); - Assert.Contains("Intentionally no normal Capture button", ux, StringComparison.Ordinal); + Assert.DoesNotContain("new FrameworkElementFactory(typeof(Button))", ux, StringComparison.Ordinal); Assert.Contains("P0FatCanonicalValueConverter", ux, StringComparison.Ordinal); Assert.Contains("AnalogStableSampleCount = 3", coordinator, StringComparison.Ordinal); Assert.Contains("AnalogRelativeSettlingFraction = 0.0005d", coordinator, StringComparison.Ordinal); } [Fact] - public void P0_HeaderUsesPrimaryAndSecondaryActionRows() + public void P0_FirstVisibleFatFrame_AlreadyUsesV2CanonicalNoCaptureTemplates() + { + var ux = File.ReadAllText(FindRepoFile("IoListTestingWindow.P0BenchUx.cs")); + var install = ux.IndexOf("window.InstallFatV2WorkspaceUx();", StringComparison.Ordinal); + var apply = ux.IndexOf("window.ApplyP0BenchUx();", StringComparison.Ordinal); + + Assert.True(install >= 0); + Assert.True(apply > install); + Assert.DoesNotContain("ContentRendered +=", ux, StringComparison.Ordinal); + Assert.DoesNotContain("DispatcherPriority.ContextIdle", ux, StringComparison.Ordinal); + Assert.DoesNotContain("Dispatcher.BeginInvoke", ux, StringComparison.Ordinal); + } + + [Fact] + public void P0_HeaderUsesOneCompactActionRow() { var ux = File.ReadAllText(FindRepoFile("IoListTestingWindow.P0BenchUx.cs")); Assert.Contains("_p0PrimaryHeaderActions", ux, StringComparison.Ordinal); Assert.Contains("_p0SecondaryHeaderActions", ux, StringComparison.Ordinal); Assert.Contains("ConfigureP0AdaptiveHeaderActions", ux, StringComparison.Ordinal); - Assert.Contains("_clockSyncEvidenceText", ux, StringComparison.Ordinal); + Assert.Contains("Visibility = Visibility.Collapsed", ux, StringComparison.Ordinal); + Assert.Contains("private bool IsP0SecondaryHeaderAction(UIElement element) => false;", ux, StringComparison.Ordinal); + Assert.Contains("actionPanel.Children.Add(_p0PrimaryHeaderActions);", ux, StringComparison.Ordinal); } [Fact] @@ -123,4 +178,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/P0LifecycleAndTotPfRegressionTests.cs b/tests/ARSAS.Tests/P0LifecycleAndTotPfRegressionTests.cs index a2cbfddc7..a62e461ec 100644 --- a/tests/ARSAS.Tests/P0LifecycleAndTotPfRegressionTests.cs +++ b/tests/ARSAS.Tests/P0LifecycleAndTotPfRegressionTests.cs @@ -29,16 +29,27 @@ public void MainWindow_RuntimeFacade_OffloadsLifecycle_AndStopCanPreemptHungOper } [Fact] - public void FatWindow_Close_KeepsUiBoundSessionMutationOnDispatcher_AndOffloadsPersistence() + public void FatWindow_Close_KeepsStateMutationOnDispatcher_AndOffloadsDurableIo() { var lifecycle = Read("IoListTestingWindow.P0Lifecycle.cs"); + var journal = Read("Services/IoTesting/IoTestEvidenceJournal.cs"); Assert.Contains("Closing -= Window_Closing", lifecycle, StringComparison.Ordinal); Assert.Contains("Closing += P0Window_Closing", lifecycle, StringComparison.Ordinal); - Assert.Contains("var stopAll = Session.StopAll(", lifecycle, StringComparison.Ordinal); + Assert.Contains("using (IoTestEvidenceJournal.BeginDeferredSealScope())", lifecycle, StringComparison.Ordinal); + Assert.Contains("stopAll = Session.StopAll(", lifecycle, StringComparison.Ordinal); Assert.DoesNotContain("Task.Run(() =>\n Session.StopAll", lifecycle, StringComparison.Ordinal); + Assert.Contains("await IoTestEvidenceJournal.AwaitDeferredSealsAsync()", lifecycle, StringComparison.Ordinal); Assert.Contains("await Task.Run(Storage.SaveNow)", lifecycle, StringComparison.Ordinal); Assert.Contains("e.Cancel = true", lifecycle, StringComparison.Ordinal); + + // Deferred sealing is close-only. Normal Stop still takes the synchronous durable + // barrier, while workspace close queues only the already-detached journal I/O. + Assert.Contains("DeferredSealScopeDepth.Value > 0", journal, StringComparison.Ordinal); + Assert.Contains("Task.Run(SealDurablyAndVerify)", journal, StringComparison.Ordinal); + Assert.Contains("return VerifyCore(filePath)", journal, StringComparison.Ordinal); + Assert.Contains("FlushDurable();", journal, StringComparison.Ordinal); + Assert.Contains("Task.WhenAll", journal, StringComparison.Ordinal); } [Fact]