diff --git a/IoListTestingWindow.ReleaseNavigationGuard.cs b/IoListTestingWindow.ReleaseNavigationGuard.cs new file mode 100644 index 000000000..bb7e0f31a --- /dev/null +++ b/IoListTestingWindow.ReleaseNavigationGuard.cs @@ -0,0 +1,56 @@ +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using System.Windows.Threading; + +namespace ArIED61850Tester; + +/// +/// Release guard for the obsolete compatibility navigation control. Native FAT now lives in +/// the Engineering workstation itself, so the old "Engineering" return button has no valid +/// user-facing purpose and can re-enter the compatibility mount/unmount path unexpectedly. +/// Keep the legacy host code available for explicit compatibility work, but remove its risky +/// navigation button from the shipped UI. +/// +public partial class IoListTestingWindow +{ + private bool _releaseObsoleteEngineeringButtonHidden; + + [ModuleInitializer] + internal static void RegisterIoListFatReleaseNavigationGuard() + { + EventManager.RegisterClassHandler( + typeof(IoListTestingWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(IoListFatReleaseNavigationGuard_Loaded), + handledEventsToo: true); + } + + private static void IoListFatReleaseNavigationGuard_Loaded(object sender, RoutedEventArgs e) + { + if (sender is not IoListTestingWindow window || window._releaseObsoleteEngineeringButtonHidden) + return; + + window._releaseObsoleteEngineeringButtonHidden = true; + window.Dispatcher.BeginInvoke( + new Action(() => HideObsoleteEngineeringNavigation(window)), + DispatcherPriority.Loaded); + } + + private static void HideObsoleteEngineeringNavigation(DependencyObject root) + { + if (root is Button button && + string.Equals(button.Content?.ToString()?.Trim(), "Engineering", StringComparison.OrdinalIgnoreCase)) + { + button.IsEnabled = false; + button.Focusable = false; + button.Visibility = Visibility.Collapsed; + return; + } + + var childCount = VisualTreeHelper.GetChildrenCount(root); + for (var index = 0; index < childCount; index++) + HideObsoleteEngineeringNavigation(VisualTreeHelper.GetChild(root, index)); + } +} diff --git a/MainWindow.CommandPanelUx.cs b/MainWindow.CommandPanelUx.cs index 5d8f96b7c..2a30f9860 100644 --- a/MainWindow.CommandPanelUx.cs +++ b/MainWindow.CommandPanelUx.cs @@ -74,6 +74,7 @@ public object ConvertBack(object value, Type targetType, object parameter, Cultu private readonly ConditionalWeakTable _configuredCommandButtons = new(); private readonly ConditionalWeakTable _tactileButtonStates = new(); + private readonly ConditionalWeakTable _controlSafetyDefaultsApplied = new(); private readonly HashSet _controlModelPreloadAttempts = new(StringComparer.OrdinalIgnoreCase); private readonly SemaphoreSlim _controlModelPreloadGate = new(1, 1); @@ -170,6 +171,8 @@ private void ConfigureCommandPanelButton(Button button) if (button.DataContext is not SignalDefinition signal) return; + EnsureDefaultControlSafetyChecks(signal); + var content = button.Content?.ToString()?.Trim() ?? string.Empty; if (content.Equals("Technical details", StringComparison.OrdinalIgnoreCase) || content.Equals("Not available", StringComparison.OrdinalIgnoreCase)) @@ -212,6 +215,20 @@ private void ConfigureCommandPanelButton(Button button) _configuredCommandButtons.Add(button, new Marker()); } + private void EnsureDefaultControlSafetyChecks(SignalDefinition signal) + { + ArgumentNullException.ThrowIfNull(signal); + _controlSafetyDefaultsApplied.GetValue(signal, current => + { + // Safe command defaults are applied to the model once, not painted into XAML. + // After this first initialization the operator remains free to clear either flag; + // the periodic command-panel UX refresh must never force a user choice back on. + current.ControlInterlockCheck = true; + current.ControlSynchroCheck = true; + return new Marker(); + }); + } + private static bool IsCommandActionButton(string content) => content is "Open" or "Close" or "True" or "False" or "Raise" or "Lower" or "Set"; @@ -251,6 +268,9 @@ private async Task PreloadControlModelsAsync() { foreach (var device in Devices.Where(device => device.IsConnected && device.SelectedControlSignalCount > 0)) { + foreach (var signal in device.Signals.Where(signal => signal.IsSelected && signal.IsValidControlObject)) + EnsureDefaultControlSafetyChecks(signal); + // One MMS association is serialized. Do not queue background ctlModel // inspection while an operator command owns the session. if (device.CommandSignals.Any(signal => signal.ControlCommandBusy)) diff --git a/MainWindow.ControlDiagnostics.cs b/MainWindow.ControlDiagnostics.cs index 14b2e1cf4..91299a1ca 100644 --- a/MainWindow.ControlDiagnostics.cs +++ b/MainWindow.ControlDiagnostics.cs @@ -475,10 +475,13 @@ private bool HasFreshStableEvidence(ActivePositionCommand state) private static string ResolveControlFeedbackKey(SignalDefinition signal) { - var reference = string.IsNullOrWhiteSpace(signal.ControlStatusReference) - ? $"{signal.ObjectReference}.stVal" - : signal.ControlStatusReference; - return NormalizeReference(reference); + // P4E fail-closed contract: stable command confirmation is allowed only when live + // control discovery supplied an explicit status reference. Never infer .stVal from + // ObjectReference, SignalName, row order, or any runtime identifier. + if (string.IsNullOrWhiteSpace(signal.ControlStatusReference)) + return string.Empty; + + return NormalizeReference(signal.ControlStatusReference); } private async Task ExpirePositionCommandAsync(ActivePositionCommand state) diff --git a/MainWindow.FieldPresentationFix.cs b/MainWindow.FieldPresentationFix.cs index 9d15f5781..99097c04f 100644 --- a/MainWindow.FieldPresentationFix.cs +++ b/MainWindow.FieldPresentationFix.cs @@ -19,6 +19,9 @@ internal static class MainWindowFieldPresentationFix { private const string IedTimestampHeader = "IED Timestamp"; private const string SignalHeader = "Signal"; + internal static SolidColorBrush CommandTargetForegroundBrush { get; } = FrozenBrush(0x58, 0x6B, 0x82); + internal static SolidColorBrush CommandTargetBackgroundBrush { get; } = FrozenBrush(0xF4, 0xF7, 0xFB); + internal static SolidColorBrush CommandTargetBorderBrush { get; } = FrozenBrush(0xD6, 0xE0, 0xEC); [ModuleInitializer] internal static void Register() @@ -148,12 +151,36 @@ private static void ApplySemanticSignalColumns(MainWindow window) private static void ApplyDarkCommandHeaderContrast(MainWindow window) { - if (window.FindName("CommandPanelExpander") is not Expander expander || expander.Header is not DependencyObject header) + if (window.FindName("CommandPanelExpander") is not Expander expander) + return; + + ApplyDarkCommandHeaderContrast(expander); + } + + internal static void ApplyDarkCommandHeaderContrast(Expander expander) + { + if (expander.Header is not DependencyObject header) return; expander.Foreground = Brushes.White; + var targetBadge = VisualDescendants(header) + .FirstOrDefault(border => Equals(border.Tag, "P0CommandTargetBadge")); + var targetTexts = targetBadge == null + ? new HashSet() + : VisualDescendants(targetBadge).ToHashSet(); + + if (targetBadge != null) + { + targetBadge.Background = CommandTargetBackgroundBrush; + targetBadge.BorderBrush = CommandTargetBorderBrush; + } + foreach (var text in VisualDescendants(header).Prepend(header as TextBlock).OfType()) - text.Foreground = Brushes.White; + { + text.Foreground = targetTexts.Contains(text) + ? CommandTargetForegroundBrush + : Brushes.White; + } } /// @@ -216,6 +243,13 @@ private static IEnumerable VisualDescendants(DependencyObject root) where } } + private static SolidColorBrush FrozenBrush(byte red, byte green, byte blue) + { + var brush = new SolidColorBrush(Color.FromRgb(red, green, blue)); + brush.Freeze(); + return brush; + } + private sealed class RoundedIedTimestampConverter : IValueConverter { internal static readonly RoundedIedTimestampConverter Instance = new(); diff --git a/MainWindow.NativeFatCanonicalGrid.cs b/MainWindow.NativeFatCanonicalGrid.cs new file mode 100644 index 000000000..79171a996 --- /dev/null +++ b/MainWindow.NativeFatCanonicalGrid.cs @@ -0,0 +1,737 @@ +using System.Diagnostics; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Media; +using System.Windows.Threading; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services.IoTesting; + +namespace ArIED61850Tester; + +public partial class MainWindow +{ + private readonly Dictionary _nativeFatSessionByIed = + new(StringComparer.OrdinalIgnoreCase); + private readonly NativeFatArmCoordinator _nativeFatArmCoordinator = new(); + private readonly NativeFatEvidenceHydrationService _nativeFatEvidenceHydrationService = new(); + private readonly Dictionary _nativeFatEvidencePersistCtsByIed = + new(StringComparer.OrdinalIgnoreCase); + + private DataGrid? _nativeFatCanonicalGrid; + private TextBlock? _nativeFatIedText; + private TextBlock? _nativeFatRowCountText; + private TextBlock? _nativeFatStatusText; + private Button? _nativeFatStartButton; + private string? _nativeFatBoundIedKey; + private bool _nativeFatArmEventsHooked; + private CancellationTokenSource? _nativeFatEvidenceHydrationCts; + private DispatcherTimer? _nativeFatEvidenceClock; + private int _nativeFatEvidenceClockPhase; + private long _nativeFatEvidenceHydrationGeneration; + + /// + /// P1A: FAT renders the exact Engineering live-row objects. There is no projection, + /// SCL parse, IoTestPointPlan collection, or second acquisition owner in this surface. + /// P1B adds only three sparse evidence columns keyed outside those canonical rows. + /// P1C reuses the Engineering grid visual authority and virtualization contract. + /// P1D makes Start FAT an ARM-only operation over those already-live row objects. + /// P2 hydrates only sparse evidence asynchronously; canonical rows and live Value never wait. + /// P3 builds no report until Print Preview is clicked, then renders an immutable selected-IED snapshot. + /// P4C makes the canonical workspace itself own the exact seven-column FAT thin-view contract. + /// + private FrameworkElement BuildNativeFatCanonicalWorkspace(string? statusText = null) + { + if (!_nativeFatArmEventsHooked) + { + _nativeFatArmCoordinator.EvidenceChanged += NativeFatArmCoordinator_EvidenceChanged; + _nativeFatArmEventsHooked = true; + } + + var root = new Grid + { + Margin = new Thickness(16) + }; + root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + root.RowDefinitions.Add(new RowDefinition { Height = new GridLength(10) }); + root.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); + + var header = new Border + { + Padding = new Thickness(14, 10, 14, 10), + CornerRadius = new CornerRadius(12), + Background = TryFindResource("CardBackground") as Brush ?? Brushes.White, + BorderBrush = TryFindResource("CardBorder") as Brush ?? new SolidColorBrush(Color.FromRgb(220, 228, 239)), + BorderThickness = new Thickness(1) + }; + + var headerGrid = new Grid(); + headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + var titlePanel = new StackPanel(); + _nativeFatIedText = new TextBlock + { + Text = "FAT · select an Engineering IED", + FontSize = 16, + FontWeight = FontWeights.SemiBold, + Foreground = TryFindResource("Ink") as Brush ?? Brushes.Black + }; + _nativeFatStatusText = new TextBlock + { + Text = string.IsNullOrWhiteSpace(statusText) + ? "Canonical Engineering live rows · shared acquisition · sparse FAT evidence overlay" + : statusText, + Margin = new Thickness(0, 3, 0, 0), + FontSize = 10.8, + Foreground = TryFindResource("Muted") as Brush ?? Brushes.DimGray + }; + titlePanel.Children.Add(_nativeFatIedText); + titlePanel.Children.Add(_nativeFatStatusText); + headerGrid.Children.Add(titlePanel); + + var actionPanel = new StackPanel + { + Orientation = Orientation.Horizontal, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(16, 0, 0, 0) + }; + _nativeFatRowCountText = new TextBlock + { + Text = "0 rows", + FontSize = 11, + FontWeight = FontWeights.SemiBold, + Foreground = TryFindResource("Accent") as Brush ?? Brushes.RoyalBlue, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 12, 0) + }; + actionPanel.Children.Add(_nativeFatRowCountText); + + _nativeFatPrintPreviewButton = new Button + { + Content = "Print Preview", + MinWidth = 104, + Padding = new Thickness(12, 6, 12, 6), + Margin = new Thickness(0, 0, 8, 0), + Style = TryFindResource("SoftButton") as Style, + IsEnabled = false, + ToolTip = "Capture an immutable Print Preview for the selected Engineering IED only." + }; + _nativeFatPrintPreviewButton.Click += NativeFatPrintPreviewButton_Click; + actionPanel.Children.Add(_nativeFatPrintPreviewButton); + + _nativeFatStartButton = new Button + { + Content = "Start FAT", + MinWidth = 92, + Padding = new Thickness(12, 6, 12, 6), + Style = TryFindResource("PrimaryButton") as Style, + IsEnabled = false, + ToolTip = "Arm FAT evidence on the already-running Engineering live stream." + }; + _nativeFatStartButton.Click += NativeFatStartButton_Click; + actionPanel.Children.Add(_nativeFatStartButton); + + Grid.SetColumn(actionPanel, 1); + headerGrid.Children.Add(actionPanel); + header.Child = headerGrid; + root.Children.Add(header); + + var modernDataGridStyle = FindResource("ModernDataGrid") as Style + ?? throw new InvalidOperationException("ModernDataGrid visual authority was not found."); + + _nativeFatCanonicalGrid = new DataGrid + { + Style = modernDataGridStyle, + RowStyle = BuildEngineeringLiveRowStyle(), + CellStyle = BuildEngineeringLiveCellStyle(), + AutoGenerateColumns = false, + CanUserAddRows = false, + CanUserDeleteRows = false, + IsReadOnly = false, + FrozenColumnCount = 2, + EnableRowVirtualization = true, + EnableColumnVirtualization = true + }; + VirtualizingPanel.SetIsVirtualizing(_nativeFatCanonicalGrid, true); + VirtualizingPanel.SetVirtualizationMode(_nativeFatCanonicalGrid, VirtualizationMode.Recycling); + ScrollViewer.SetCanContentScroll(_nativeFatCanonicalGrid, true); + ScrollViewer.SetHorizontalScrollBarVisibility(_nativeFatCanonicalGrid, ScrollBarVisibility.Auto); + _nativeFatCanonicalGrid.CellEditEnding += NativeFatCanonicalGrid_CellEditEnding; + _nativeFatCanonicalGrid.BeginningEdit += NativeFatCanonicalGrid_BeginningEdit; + + // P4C single authority: the canonical workspace owns the exact seven-column FAT view. + // ProductionFatTab must not clear/rebuild columns after this point. + ApplyNativeFatP4CColumnContract(); + + Grid.SetRow(_nativeFatCanonicalGrid, 2); + root.Children.Add(_nativeFatCanonicalGrid); + return root; + } + + private Style BuildEngineeringLiveRowStyle() + { + var style = new Style(typeof(DataGridRow), FindResource(typeof(DataGridRow)) as Style); + var changed = new DataTrigger + { + Binding = new Binding(nameof(Iec61850MonitorPoint.IsRecentlyChanged)), + Value = true + }; + changed.Setters.Add(new Setter(Control.BackgroundProperty, new SolidColorBrush(Color.FromRgb(255, 240, 168)))); + changed.Setters.Add(new Setter(Control.BorderBrushProperty, new SolidColorBrush(Color.FromRgb(245, 158, 11)))); + changed.Setters.Add(new Setter(Control.BorderThicknessProperty, new Thickness(4, 1, 1, 1))); + style.Triggers.Add(changed); + return style; + } + + private Style BuildEngineeringLiveCellStyle() + { + var style = new Style(typeof(DataGridCell), FindResource(typeof(DataGridCell)) as Style); + var changed = new DataTrigger + { + Binding = new Binding(nameof(Iec61850MonitorPoint.IsRecentlyChanged)), + Value = true + }; + changed.Setters.Add(new Setter(Control.BackgroundProperty, new SolidColorBrush(Color.FromRgb(255, 245, 194)))); + changed.Setters.Add(new Setter(Control.ForegroundProperty, new SolidColorBrush(Color.FromRgb(92, 59, 0)))); + style.Triggers.Add(changed); + return style; + } + + private void AddCanonicalTextColumn(string header, string path, double width) + { + if (_nativeFatCanonicalGrid == null) + return; + + _nativeFatCanonicalGrid.Columns.Add(new DataGridTextColumn + { + Header = header, + Binding = new Binding(path) { Mode = BindingMode.OneWay }, + Width = new DataGridLength(width), + IsReadOnly = true + }); + } + + private void AddCanonicalTemplateColumn(string header, string templateKey, double width) + { + if (_nativeFatCanonicalGrid == null) + return; + + var template = FindResource(templateKey) as DataTemplate + ?? throw new InvalidOperationException($"Engineering cell template '{templateKey}' was not found."); + _nativeFatCanonicalGrid.Columns.Add(new DataGridTemplateColumn + { + Header = header, + CellTemplate = template, + Width = new DataGridLength(width), + IsReadOnly = true + }); + } + + private void BindNativeFatCanonicalRows() + { + if (_nativeFatCanonicalGrid == null || _productionFatWindow is { IsLoaded: true }) + return; + + // Finish any in-cell evidence edit against the previously bound IED before switching. + _nativeFatCanonicalGrid.CommitEdit(DataGridEditingUnit.Cell, true); + _nativeFatCanonicalGrid.CommitEdit(DataGridEditingUnit.Row, true); + SaveNativeFatSessionState(); + CancelNativeFatEvidenceHydration(resetOldState: true); + + var device = SelectedDevice; + _nativeFatBoundIedKey = device?.DeviceId; + + // P1A invariant: canonical rows and live values bind FIRST and synchronously. + // P2 evidence hydration starts only after this exact Engineering collection is visible. + _nativeFatCanonicalGrid.ItemsSource = device?.Points; + + _nativeFatIedText!.Text = device == null + ? "FAT · select an Engineering IED" + : $"FAT · {device.Name} · {device.IpAddress}:{device.Port}"; + _nativeFatRowCountText!.Text = device == null ? "0 rows" : $"{device.Points.Count} rows"; + if (_nativeFatPrintPreviewButton != null) + _nativeFatPrintPreviewButton.IsEnabled = device?.Points.Count > 0; + + RestoreNativeFatSessionState(device); + UpdateNativeFatArmUi(device); + BeginNativeFatEvidenceHydration(device); + } + + private void BeginNativeFatEvidenceHydration(Iec61850MonitorDevice? device) + { + if (device == null) + { + StopNativeFatEvidenceClock(); + return; + } + + var cache = GetNativeFatSession(device.DeviceId); + if (cache.EvidenceHydrationState == NativeFatEvidenceHydrationState.Resolved) + { + StopNativeFatEvidenceClock(); + RefreshAllVisibleNativeFatEvidenceCells(); + return; + } + + var generation = Interlocked.Increment(ref _nativeFatEvidenceHydrationGeneration); + var cts = new CancellationTokenSource(); + _nativeFatEvidenceHydrationCts = cts; + cache.EvidenceHydrationGeneration = generation; + cache.EvidenceHydrationState = NativeFatEvidenceHydrationState.Hydrating; + cache.EvidenceHydrationError = string.Empty; + cache.EvidenceHydratedAt = null; + + StartNativeFatEvidenceClock(); + RefreshAllVisibleNativeFatEvidenceCells(); + _ = HydrateNativeFatEvidenceAsync(device, cache, generation, cts.Token); + } + + private async Task HydrateNativeFatEvidenceAsync( + Iec61850MonitorDevice device, + NativeFatIedSessionCacheState cache, + long generation, + CancellationToken cancellationToken) + { + try + { + var result = await _nativeFatEvidenceHydrationService.HydrateAsync(device, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + + if (generation != cache.EvidenceHydrationGeneration || + !string.Equals(_nativeFatBoundIedKey, device.DeviceId, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + cache.LastHydrationElapsedMilliseconds = result.ElapsedMilliseconds; + if (result.Succeeded) + { + var merged = NativeFatCanonicalEvidenceOverlay.MergeMissing(cache, result.EvidenceByRow); + cache.EvidenceHydrationState = NativeFatEvidenceHydrationState.Resolved; + cache.EvidenceHydratedAt = DateTimeOffset.Now; + cache.EvidenceHydrationError = string.Empty; + + var armed = cache.IsArmed || _nativeFatArmCoordinator.IsArmed(device.DeviceId); + var evidenceStatus = result.SnapshotFound + ? $"evidence ready · {merged} persisted row(s) restored in {result.ElapsedMilliseconds} ms" + : $"evidence ready · no saved evidence · {result.ElapsedMilliseconds} ms"; + UpdateNativeFatArmUi( + device, + armed + ? $"FAT armed · shared Engineering acquisition untouched · {evidenceStatus}" + : $"Canonical Engineering live rows · {evidenceStatus}"); + } + else + { + cache.EvidenceHydrationState = NativeFatEvidenceHydrationState.Failed; + cache.EvidenceHydratedAt = DateTimeOffset.Now; + cache.EvidenceHydrationError = result.Message; + UpdateNativeFatArmUi( + device, + $"Canonical rows are live · saved FAT evidence could not be hydrated: {result.Message}"); + } + + Trace.WriteLine( + $"[FAT P2] evidence hydration completed in {result.ElapsedMilliseconds} ms; " + + $"ied={device.Name}; deviceId={device.DeviceId}; found={result.SnapshotFound}; " + + $"loaded={result.LoadedRows}; ignored={result.IgnoredRows}; succeeded={result.Succeeded}; " + + "canonicalRowsBlocked=false; networkCalls=0; rowRebuilds=0."); + } + catch (OperationCanceledException) + { + if (generation == cache.EvidenceHydrationGeneration && + cache.EvidenceHydrationState == NativeFatEvidenceHydrationState.Hydrating) + { + cache.EvidenceHydrationState = NativeFatEvidenceHydrationState.NotStarted; + } + } + finally + { + if (generation == cache.EvidenceHydrationGeneration && + string.Equals(_nativeFatBoundIedKey, device.DeviceId, StringComparison.OrdinalIgnoreCase)) + { + StopNativeFatEvidenceClock(); + RefreshAllVisibleNativeFatEvidenceCells(); + } + } + } + + private void StartNativeFatEvidenceClock() + { + _nativeFatEvidenceClock ??= CreateNativeFatEvidenceClock(); + if (!_nativeFatEvidenceClock.IsEnabled) + _nativeFatEvidenceClock.Start(); + } + + private DispatcherTimer CreateNativeFatEvidenceClock() + { + var timer = new DispatcherTimer(DispatcherPriority.Background, Dispatcher) + { + Interval = TimeSpan.FromMilliseconds(320) + }; + timer.Tick += NativeFatEvidenceClock_Tick; + return timer; + } + + private void NativeFatEvidenceClock_Tick(object? sender, EventArgs e) + { + if (string.IsNullOrWhiteSpace(_nativeFatBoundIedKey) || + !_nativeFatSessionByIed.TryGetValue(_nativeFatBoundIedKey, out var cache) || + !cache.IsEvidenceHydrating) + { + StopNativeFatEvidenceClock(); + return; + } + + _nativeFatEvidenceClockPhase = (_nativeFatEvidenceClockPhase + 1) % 3; + RefreshAllVisibleNativeFatEvidenceCells(); + } + + private void StopNativeFatEvidenceClock() + { + _nativeFatEvidenceClock?.Stop(); + _nativeFatEvidenceClockPhase = 0; + } + + private void CancelNativeFatEvidenceHydration(bool resetOldState) + { + _nativeFatEvidenceHydrationCts?.Cancel(); + _nativeFatEvidenceHydrationCts?.Dispose(); + _nativeFatEvidenceHydrationCts = null; + + if (resetOldState && + !string.IsNullOrWhiteSpace(_nativeFatBoundIedKey) && + _nativeFatSessionByIed.TryGetValue(_nativeFatBoundIedKey, out var oldCache) && + oldCache.EvidenceHydrationState == NativeFatEvidenceHydrationState.Hydrating) + { + oldCache.EvidenceHydrationState = NativeFatEvidenceHydrationState.NotStarted; + } + + StopNativeFatEvidenceClock(); + } + + private void UpdateNativeFatArmUi(Iec61850MonitorDevice? device, string? overrideStatus = null) + { + if (_nativeFatStatusText == null || _nativeFatStartButton == null) + return; + + if (device == null) + { + _nativeFatStartButton.Content = "Start FAT"; + _nativeFatStartButton.IsEnabled = false; + _nativeFatStatusText.Text = overrideStatus ?? "Select an Engineering IED with canonical live rows."; + return; + } + + var cache = GetNativeFatSession(device.DeviceId); + var armed = cache.IsArmed || _nativeFatArmCoordinator.IsArmed(device.DeviceId); + _nativeFatStartButton.Content = armed ? "FAT Armed" : "Start FAT"; + _nativeFatStartButton.IsEnabled = !armed && device.IsConnected && device.IsMonitoring && device.Points.Count > 0; + _nativeFatStartButton.ToolTip = armed + ? "FAT evidence is armed on the existing Engineering acquisition stream." + : device.IsConnected && device.IsMonitoring + ? "Arm FAT evidence only. No reconnect, SCL import, discovery, report restart, or polling change." + : "Start Engineering monitoring first; FAT will reuse that live acquisition."; + + _nativeFatStatusText.Text = overrideStatus ?? (armed + ? $"FAT armed · shared Engineering acquisition untouched · {device.Points.Count} canonical row(s)" + : cache.IsEvidenceHydrating + ? "Canonical Engineering rows are live · restoring only FAT evidence in background" + : "Canonical Engineering live rows · Start FAT only arms evidence; acquisition remains untouched"); + } + + private void NativeFatStartButton_Click(object sender, RoutedEventArgs e) + { + var stopwatch = Stopwatch.StartNew(); + var device = SelectedDevice; + if (device == null) + { + UpdateNativeFatArmUi(null, "Select an Engineering IED before starting FAT."); + return; + } + + var cache = GetNativeFatSession(device.DeviceId); + var result = _nativeFatArmCoordinator.Arm(device, cache); + stopwatch.Stop(); + cache.LastArmElapsedMilliseconds = stopwatch.ElapsedMilliseconds; + + var status = result.Succeeded + ? result.AlreadyArmed + ? result.Message + : $"{device.Name} FAT armed in {stopwatch.ElapsedMilliseconds} ms · {result.ArmedRows} canonical row(s) · {result.SeededValue1Rows} Value 1 seeded · no acquisition restart" + : result.Message; + + UpdateNativeFatArmUi(device, status); + SetStatus(result.Succeeded + ? $"FAT · {device.Name} armed on shared Engineering live data in {stopwatch.ElapsedMilliseconds} ms" + : $"FAT · {result.Message}"); + + Trace.WriteLine( + $"[FAT P1D] ARM completed in {stopwatch.ElapsedMilliseconds} ms; " + + $"ied={device.Name}; deviceId={device.DeviceId}; rows={device.Points.Count}; " + + $"seededV1={result.SeededValue1Rows}; alreadyArmed={result.AlreadyArmed}; succeeded={result.Succeeded}; " + + "networkPrepare=false; reconnect=false; sclImport=false; discovery=false; reportRestart=false; pollingChange=false."); + } + + private void NativeFatArmCoordinator_EvidenceChanged(object? sender, NativeFatEvidenceChangedEventArgs e) + { + if (!Dispatcher.CheckAccess()) + { + Dispatcher.BeginInvoke(() => NativeFatArmCoordinator_EvidenceChanged(sender, e)); + return; + } + + ScheduleNativeFatEvidencePersist(e.DeviceId); + if (!string.Equals(_nativeFatBoundIedKey, e.DeviceId, StringComparison.OrdinalIgnoreCase)) + return; + + RefreshNativeFatEvidenceCells(e.Point); + } + + private void ScheduleNativeFatEvidencePersist(string deviceId) + { + var device = Devices.FirstOrDefault(candidate => + candidate.DeviceId.Equals(deviceId, StringComparison.OrdinalIgnoreCase)); + if (device == null || !_nativeFatSessionByIed.TryGetValue(deviceId, out var cache)) + return; + + if (_nativeFatEvidencePersistCtsByIed.Remove(deviceId, out var previous)) + { + previous.Cancel(); + previous.Dispose(); + } + + var cts = new CancellationTokenSource(); + _nativeFatEvidencePersistCtsByIed[deviceId] = cts; + _ = PersistNativeFatEvidenceAfterDebounceAsync(device, cache, cts); + } + + private async Task PersistNativeFatEvidenceAfterDebounceAsync( + Iec61850MonitorDevice device, + NativeFatIedSessionCacheState cache, + CancellationTokenSource owner) + { + try + { + await Task.Delay(350, owner.Token); + await _nativeFatEvidenceHydrationService.SaveAsync(device, cache, owner.Token); + Trace.WriteLine( + $"[FAT P2] sparse evidence persisted asynchronously; ied={device.Name}; deviceId={device.DeviceId}; dispatcherBlocked=false."); + } + catch (OperationCanceledException) + { + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) + { + Trace.WriteLine($"[FAT P2] sparse evidence persistence failed for {device.Name}: {ex.Message}"); + } + finally + { + if (_nativeFatEvidencePersistCtsByIed.TryGetValue(device.DeviceId, out var current) && + ReferenceEquals(current, owner)) + { + _nativeFatEvidencePersistCtsByIed.Remove(device.DeviceId); + owner.Dispose(); + } + } + } + + private void RefreshNativeFatEvidenceCells(Iec61850MonitorPoint point) + { + if (_nativeFatCanonicalGrid == null) + return; + + foreach (var column in _nativeFatCanonicalGrid.Columns.OfType()) + { + if (column.GetCellContent(point) is TextBlock textBlock) + textBlock.Text = ReadNativeFatEvidence(point, column.Field); + } + } + + private void RefreshAllVisibleNativeFatEvidenceCells() + { + if (_nativeFatCanonicalGrid == null) + return; + + foreach (var point in _nativeFatCanonicalGrid.Items.OfType()) + RefreshNativeFatEvidenceCells(point); + } + + private void SaveNativeFatSessionState() + { + if (_nativeFatCanonicalGrid == null || string.IsNullOrWhiteSpace(_nativeFatBoundIedKey)) + return; + + var cache = GetNativeFatSession(_nativeFatBoundIedKey); + if (_nativeFatCanonicalGrid.SelectedItem is Iec61850MonitorPoint point) + cache.ActiveRowKey = NativeFatCanonicalEvidenceOverlay.BuildRowKey(point); + cache.LastScrollIndex = Math.Max(0, _nativeFatCanonicalGrid.SelectedIndex); + } + + private void RestoreNativeFatSessionState(Iec61850MonitorDevice? device) + { + if (_nativeFatCanonicalGrid == null || device == null || string.IsNullOrWhiteSpace(_nativeFatBoundIedKey)) + return; + + if (!_nativeFatSessionByIed.TryGetValue(_nativeFatBoundIedKey, out var cache)) + return; + + Iec61850MonitorPoint? target = null; + if (!string.IsNullOrWhiteSpace(cache.ActiveRowKey)) + { + target = device.Points.FirstOrDefault(point => + string.Equals( + NativeFatCanonicalEvidenceOverlay.BuildRowKey(point), + cache.ActiveRowKey, + StringComparison.OrdinalIgnoreCase)); + } + + if (target == null && cache.LastScrollIndex >= 0 && cache.LastScrollIndex < device.Points.Count) + target = device.Points[cache.LastScrollIndex]; + + if (target == null) + return; + + _nativeFatCanonicalGrid.SelectedItem = target; + _nativeFatCanonicalGrid.ScrollIntoView(target); + } + + private NativeFatIedSessionCacheState GetNativeFatSession(string iedKey) + { + if (!_nativeFatSessionByIed.TryGetValue(iedKey, out var cache)) + { + cache = new NativeFatIedSessionCacheState(); + _nativeFatSessionByIed[iedKey] = cache; + } + return cache; + } + + private string ReadNativeFatEvidence(Iec61850MonitorPoint point, NativeFatEvidenceField field) + { + if (string.IsNullOrWhiteSpace(_nativeFatBoundIedKey) || + !_nativeFatSessionByIed.TryGetValue(_nativeFatBoundIedKey, out var cache)) + { + return string.Empty; + } + + var evidence = NativeFatCanonicalEvidenceOverlay.Read(cache, point, field); + if (!string.IsNullOrWhiteSpace(evidence)) + return evidence; + + return cache.IsEvidenceHydrating + ? NativeFatEvidenceLoadingPresentation.RollingDots(_nativeFatEvidenceClockPhase) + : string.Empty; + } + + private void NativeFatCanonicalGrid_BeginningEdit(object? sender, DataGridBeginningEditEventArgs e) + { + if (e.Column is not NativeFatEvidenceColumn || string.IsNullOrWhiteSpace(_nativeFatBoundIedKey)) + return; + + if (_nativeFatSessionByIed.TryGetValue(_nativeFatBoundIedKey, out var cache) && cache.IsEvidenceHydrating) + e.Cancel = true; + } + + private void NativeFatCanonicalGrid_CellEditEnding(object? sender, DataGridCellEditEndingEventArgs e) + { + if (e.EditAction != DataGridEditAction.Commit || + e.Row.Item is not Iec61850MonitorPoint point || + e.Column is not NativeFatEvidenceColumn evidenceColumn || + e.EditingElement is not TextBox editor || + string.IsNullOrWhiteSpace(_nativeFatBoundIedKey)) + { + return; + } + + var cache = GetNativeFatSession(_nativeFatBoundIedKey); + NativeFatCanonicalEvidenceOverlay.Write(cache, point, evidenceColumn.Field, editor.Text); + cache.ActiveRowKey = NativeFatCanonicalEvidenceOverlay.BuildRowKey(point); + ScheduleNativeFatEvidencePersist(_nativeFatBoundIedKey); + } + + private void DisposeNativeFatArmCoordinator() + { + CancelNativeFatEvidenceHydration(resetOldState: false); + if (_nativeFatEvidenceClock != null) + { + _nativeFatEvidenceClock.Tick -= NativeFatEvidenceClock_Tick; + _nativeFatEvidenceClock.Stop(); + _nativeFatEvidenceClock = null; + } + + foreach (var cts in _nativeFatEvidencePersistCtsByIed.Values) + { + cts.Cancel(); + cts.Dispose(); + } + _nativeFatEvidencePersistCtsByIed.Clear(); + + if (_nativeFatArmEventsHooked) + { + _nativeFatArmCoordinator.EvidenceChanged -= NativeFatArmCoordinator_EvidenceChanged; + _nativeFatArmEventsHooked = false; + } + + _nativeFatArmCoordinator.Dispose(); + _nativeFatEvidenceHydrationService.Dispose(); + if (_nativeFatStartButton != null) + _nativeFatStartButton.Click -= NativeFatStartButton_Click; + _nativeFatStartButton = null; + if (_nativeFatPrintPreviewButton != null) + _nativeFatPrintPreviewButton.Click -= NativeFatPrintPreviewButton_Click; + _nativeFatPrintPreviewButton = null; + + if (_nativeFatCanonicalGrid != null) + { + _nativeFatCanonicalGrid.CellEditEnding -= NativeFatCanonicalGrid_CellEditEnding; + _nativeFatCanonicalGrid.BeginningEdit -= NativeFatCanonicalGrid_BeginningEdit; + } + } + + private sealed class NativeFatEvidenceColumn : DataGridColumn + { + private readonly MainWindow _owner; + + internal NativeFatEvidenceColumn( + MainWindow owner, + string header, + NativeFatEvidenceField field, + double width) + { + _owner = owner; + Header = header; + Field = field; + Width = new DataGridLength(width); + MinWidth = 78; + } + + internal NativeFatEvidenceField Field { get; } + + protected override FrameworkElement GenerateElement(DataGridCell cell, object dataItem) + { + return new TextBlock + { + Text = dataItem is Iec61850MonitorPoint point + ? _owner.ReadNativeFatEvidence(point, Field) + : string.Empty, + VerticalAlignment = VerticalAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis + }; + } + + protected override FrameworkElement GenerateEditingElement(DataGridCell cell, object dataItem) + { + return new TextBox + { + Text = dataItem is Iec61850MonitorPoint point + ? _owner.ReadNativeFatEvidence(point, Field) + : string.Empty, + VerticalContentAlignment = VerticalAlignment.Center, + BorderThickness = new Thickness(0), + Background = Brushes.Transparent, + Padding = new Thickness(0) + }; + } + } +} diff --git a/MainWindow.NativeFatDiagnostics.cs b/MainWindow.NativeFatDiagnostics.cs new file mode 100644 index 000000000..f5e5960e5 --- /dev/null +++ b/MainWindow.NativeFatDiagnostics.cs @@ -0,0 +1,383 @@ +using System.Text; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using System.Windows.Threading; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; +using ArIED61850Tester.Services.IoTesting; + +namespace ArIED61850Tester; + +public partial class MainWindow +{ + private Button? _nativeFatComtradeButton; + private Button? _nativeFatTimeSyncButton; + private CancellationTokenSource? _nativeFatComtradeDiscoveryCts; + private long _nativeFatComtradeDiscoveryGeneration; + private DispatcherTimer? _nativeFatDiagnosticRefreshTimer; + private readonly NativeFatAuxiliaryEvidenceCache _nativeFatAuxiliaryEvidenceCache = new(); + + private void InstallNativeFatDiagnosticButtons() + { + if (_nativeFatPrintPreviewButton?.Parent is not StackPanel actionPanel) + return; + + if (_nativeFatComtradeButton != null && actionPanel.Children.Contains(_nativeFatComtradeButton)) + return; + + var printIndex = actionPanel.Children.IndexOf(_nativeFatPrintPreviewButton); + if (printIndex < 0) + return; + + _nativeFatComtradeButton = new Button + { + Content = "COMTRADE —", + MinWidth = 112, + Padding = new Thickness(12, 6, 12, 6), + Margin = new Thickness(0, 0, 8, 0), + Style = TryFindResource("SoftButton") as Style, + IsEnabled = false, + ToolTip = "Actual IEC 61850 FileDirectory evidence for the selected IED." + }; + _nativeFatComtradeButton.Click += NativeFatComtradeButton_Click; + + _nativeFatTimeSyncButton = new Button + { + Content = "Time Sync Review", + MinWidth = 116, + Padding = new Thickness(12, 6, 12, 6), + Margin = new Thickness(0, 0, 8, 0), + Style = TryFindResource("SoftButton") as Style, + IsEnabled = false, + ToolTip = "Device-side time synchronization evidence. SNTP activity alone never grants OK." + }; + _nativeFatTimeSyncButton.Click += NativeFatTimeSyncButton_Click; + + actionPanel.Children.Insert(printIndex, _nativeFatComtradeButton); + actionPanel.Children.Insert(printIndex + 1, _nativeFatTimeSyncButton); + } + + private void BindNativeFatDiagnostics(Iec61850MonitorDevice? device) + { + CancelNativeFatComtradeDiscovery(); + + if (_nativeFatComtradeButton == null || _nativeFatTimeSyncButton == null) + return; + + if (device == null) + { + _nativeFatComtradeButton.Content = "COMTRADE —"; + _nativeFatComtradeButton.IsEnabled = false; + _nativeFatComtradeButton.ToolTip = "Select a connected Engineering IED to inspect its IEC 61850 file store."; + _nativeFatTimeSyncButton.Content = "Time Sync Review"; + _nativeFatTimeSyncButton.IsEnabled = false; + _nativeFatTimeSyncButton.ToolTip = "Select an Engineering IED to inspect device-side time evidence."; + StopNativeFatDiagnosticRefreshTimer(); + return; + } + + _nativeFatTimeSyncButton.IsEnabled = device.Points.Count > 0; + RefreshNativeFatTimeSyncButton(device); + StartNativeFatDiagnosticRefreshTimer(); + + if (!device.IsConnected || string.IsNullOrWhiteSpace(device.IpAddress)) + { + _nativeFatAuxiliaryEvidenceCache.ClearComtrade(device); + _nativeFatComtradeButton.Content = "COMTRADE —"; + _nativeFatComtradeButton.IsEnabled = false; + _nativeFatComtradeButton.ToolTip = "FileDirectory evidence is unavailable while the selected IED is disconnected."; + return; + } + + _nativeFatComtradeButton.IsEnabled = true; + BeginNativeFatComtradeDiscovery(device); + } + + private void BeginNativeFatComtradeDiscovery(Iec61850MonitorDevice device) + { + CancelNativeFatComtradeDiscovery(); + var generation = Interlocked.Increment(ref _nativeFatComtradeDiscoveryGeneration); + var cts = new CancellationTokenSource(); + _nativeFatComtradeDiscoveryCts = cts; + _ = DiscoverNativeFatComtradeAsync(device, generation, cts.Token); + } + + private async Task DiscoverNativeFatComtradeAsync( + Iec61850MonitorDevice device, + long generation, + CancellationToken cancellationToken) + { + if (_nativeFatComtradeButton == null) + return; + + _nativeFatComtradeButton.Content = "COMTRADE …"; + _nativeFatComtradeButton.ToolTip = "Reading the selected IED's IEC 61850 FileDirectory catalog…"; + + try + { + await using var client = new FaultRecordTransferClient(); + await client.ConnectAsync(device.IpAddress, device.Port, cancellationToken); + var catalog = await client.DiscoverAsync(remoteDirectory: null, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + + await _nativeFatAuxiliaryEvidenceCache.RecordComtradeDiscoveryAsync( + device, + catalog.Records, + DateTimeOffset.UtcNow, + cancellationToken); + + if (generation != _nativeFatComtradeDiscoveryGeneration || + !string.Equals(SelectedDevice?.DeviceId, device.DeviceId, StringComparison.OrdinalIgnoreCase) || + _nativeFatComtradeButton == null) + { + return; + } + + var fileCount = NativeFatComtradeDiagnosticService.CountDetectedFiles(catalog.Records); + _nativeFatComtradeButton.Content = $"COMTRADE {fileCount} Files"; + _nativeFatComtradeButton.ToolTip = + $"IEC 61850 FileDirectory: {fileCount:N0} actual file(s) across {catalog.Records.Count:N0} discovered fault record(s). " + + "Click to open the existing COMTRADE / fault-record file workflow."; + } + catch (OperationCanceledException) + { + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException or TimeoutException) + { + if (generation != _nativeFatComtradeDiscoveryGeneration || _nativeFatComtradeButton == null) + return; + + _nativeFatAuxiliaryEvidenceCache.ClearComtrade(device); + + _nativeFatComtradeButton.Content = "COMTRADE —"; + _nativeFatComtradeButton.ToolTip = + $"IEC 61850 FileDirectory could not be verified: {ex.Message}\nNo COMTRADE count is fabricated."; + } + } + + private void NativeFatComtradeButton_Click(object sender, RoutedEventArgs e) + { + var device = SelectedDevice; + if (device == null || !device.IsConnected || string.IsNullOrWhiteSpace(device.IpAddress)) + return; + + CancelNativeFatComtradeDiscovery(); + var window = new FaultRecordWindow(device.Name, device.IpAddress, device.Port) + { + Owner = this + }; + window.Closed += (_, _) => + { + if (string.Equals(SelectedDevice?.DeviceId, device.DeviceId, StringComparison.OrdinalIgnoreCase) && + device.IsConnected) + { + BeginNativeFatComtradeDiscovery(device); + } + }; + window.Show(); + } + + private void NativeFatTimeSyncButton_Click(object sender, RoutedEventArgs e) + { + var device = SelectedDevice; + if (device == null) + return; + + var evaluatedAt = DateTimeOffset.UtcNow; + var diagnostic = NativeFatTimeSyncDiagnosticService.Evaluate(device, evaluatedAt); + _nativeFatAuxiliaryEvidenceCache.RecordTimeSyncEvaluation(device, diagnostic, evaluatedAt); + var text = BuildNativeFatTimeSyncDiagnosticText(device, diagnostic); + var body = new TextBox + { + Text = text, + IsReadOnly = true, + IsReadOnlyCaretVisible = true, + AcceptsReturn = true, + TextWrapping = TextWrapping.Wrap, + VerticalScrollBarVisibility = ScrollBarVisibility.Auto, + HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled, + FontFamily = new FontFamily("Consolas"), + FontSize = 12.2, + Padding = new Thickness(16), + BorderThickness = new Thickness(0), + Background = TryFindResource("CardBackground") as Brush ?? Brushes.White, + Foreground = TryFindResource("Ink") as Brush ?? Brushes.Black + }; + + var window = new Window + { + Title = $"Time Synchronization · {device.Name}", + Owner = this, + Width = 760, + Height = 640, + MinWidth = 620, + MinHeight = 480, + WindowStartupLocation = WindowStartupLocation.CenterOwner, + Content = body + }; + window.Show(); + } + + private string BuildNativeFatTimeSyncDiagnosticText( + Iec61850MonitorDevice device, + NativeFatTimeSyncDiagnosticResult diagnostic) + { + var snapshot = ClockSyncSnapshot; + var builder = new StringBuilder(); + builder.AppendLine($"IED : {device.Name}"); + builder.AppendLine($"Endpoint : {device.IpAddress}:{device.Port}"); + builder.AppendLine($"ARSAS verdict : {diagnostic.Verdict}"); + builder.AppendLine($"Reason : {diagnostic.Summary}"); + builder.AppendLine(); + builder.AppendLine("DEVICE-SIDE AUTHORITY"); + builder.AppendLine($"LTMS present : {diagnostic.LtmsPresent}"); + builder.AppendLine($"LTMS trusted : {diagnostic.LtmsTrusted}"); + builder.AppendLine($"Fresh IEC stamps : {diagnostic.FreshPrimaryTimestampCount}"); + builder.AppendLine($"Allowed delta : ±{NativeFatTimeSyncDiagnosticService.MaximumTrustedClockDelta.TotalSeconds:0} s"); + builder.AppendLine($"Negative sync flag: {diagnostic.ExplicitNegativeSyncStatus}"); + + if (diagnostic.PrimaryEvidence.Count == 0) + { + builder.AppendLine(" — no authoritative LTMS/fresh timestamp evidence in the current canonical live rows"); + } + else + { + foreach (var evidence in diagnostic.PrimaryEvidence) + { + builder.AppendLine( + $" [{evidence.Role}] {(evidence.Trusted ? "trusted" : "review")} · {ValueOrDash(evidence.IecReference)} · " + + $"Q={ValueOrDash(evidence.Quality)} · T={ValueOrDash(evidence.DeviceTimestamp)}" + + (evidence.DeltaSeconds.HasValue ? $" · Δ={evidence.DeltaSeconds.Value:0.000}s" : string.Empty)); + } + } + + builder.AppendLine(); + builder.AppendLine("SECONDARY IED TELEMETRY"); + if (diagnostic.SecondaryTelemetry.Count == 0) + { + builder.AppendLine(" — Server 1 / Server 2 / Current server / TimeSynchrnz not exposed in current live rows"); + } + else + { + foreach (var evidence in diagnostic.SecondaryTelemetry) + { + builder.AppendLine( + $" {ValueOrDash(evidence.SignalName)} · {ValueOrDash(evidence.IecReference)} · " + + $"Value={ValueOrDash(evidence.Value)} · Q={ValueOrDash(evidence.Quality)}"); + } + } + + builder.AppendLine(); + builder.AppendLine("ARSAS SNTP PACKET TELEMETRY (SUPPORTING ONLY)"); + builder.AppendLine($"Enabled : {IsClockSyncEnabled}"); + builder.AppendLine($"Service state : {snapshot.State}"); + builder.AppendLine($"Transport : {snapshot.TransportMode}"); + builder.AppendLine($"Binding : {snapshot.Binding?.Summary ?? "—"}"); + builder.AppendLine($"Broadcasts : {snapshot.BroadcastCount}"); + builder.AppendLine($"Client requests : {snapshot.ClientRequestCount}"); + builder.AppendLine($"Replies sent : {snapshot.ReplyCount}"); + builder.AppendLine($"Selected IED request observed: {_clockSyncObservedClients.Contains(device.IpAddress)}"); + builder.AppendLine($"Selected IED reply sent : {_clockSyncRepliedClients.Contains(device.IpAddress)}"); + builder.AppendLine(); + builder.AppendLine("SNTP server activity, request/reply counters and a positive TimeSynchrnz value are supporting evidence only."); + builder.AppendLine("They never grant 'Time Sync OK' without the device-side LTMS/timestamp evidence evaluated above."); + return builder.ToString(); + } + + private void RefreshNativeFatTimeSyncButton(Iec61850MonitorDevice? device) + { + if (_nativeFatTimeSyncButton == null) + return; + + if (device == null || device.Points.Count == 0) + { + if (device != null) + _nativeFatAuxiliaryEvidenceCache.ClearTimeSync(device); + _nativeFatTimeSyncButton.Content = "Time Sync Review"; + _nativeFatTimeSyncButton.IsEnabled = false; + _nativeFatTimeSyncButton.ToolTip = "No canonical live rows are available for device-side time evidence."; + return; + } + + var evaluatedAt = DateTimeOffset.UtcNow; + var diagnostic = NativeFatTimeSyncDiagnosticService.Evaluate(device, evaluatedAt); + _nativeFatAuxiliaryEvidenceCache.RecordTimeSyncEvaluation(device, diagnostic, evaluatedAt); + _nativeFatTimeSyncButton.IsEnabled = true; + _nativeFatTimeSyncButton.Content = diagnostic.IsSynchronized + ? "Time Sync OK" + : diagnostic.ExplicitNegativeSyncStatus + ? "Time Sync NOT OK" + : "Time Sync Review"; + _nativeFatTimeSyncButton.ToolTip = + $"{diagnostic.Verdict}: {diagnostic.Summary}\n" + + "Click for LTMS, IEC timestamp/quality, vendor time telemetry and ARSAS SNTP packet evidence."; + _nativeFatTimeSyncButton.Foreground = diagnostic.IsSynchronized + ? Brushes.SeaGreen + : diagnostic.ExplicitNegativeSyncStatus + ? Brushes.Firebrick + : TryFindResource("MutedInk") as Brush ?? Brushes.DarkSlateGray; + } + + private void StartNativeFatDiagnosticRefreshTimer() + { + _nativeFatDiagnosticRefreshTimer ??= CreateNativeFatDiagnosticRefreshTimer(); + if (!_nativeFatDiagnosticRefreshTimer.IsEnabled) + _nativeFatDiagnosticRefreshTimer.Start(); + } + + private DispatcherTimer CreateNativeFatDiagnosticRefreshTimer() + { + var timer = new DispatcherTimer(DispatcherPriority.Background, Dispatcher) + { + Interval = TimeSpan.FromSeconds(3) + }; + timer.Tick += NativeFatDiagnosticRefreshTimer_Tick; + return timer; + } + + private void NativeFatDiagnosticRefreshTimer_Tick(object? sender, EventArgs e) + { + var device = SelectedDevice; + if (device == null || !string.Equals(_nativeFatBoundIedKey, device.DeviceId, StringComparison.OrdinalIgnoreCase)) + { + StopNativeFatDiagnosticRefreshTimer(); + return; + } + + // In-memory canonical-row evaluation only. This timer performs zero network reads. + RefreshNativeFatTimeSyncButton(device); + } + + private void StopNativeFatDiagnosticRefreshTimer() + => _nativeFatDiagnosticRefreshTimer?.Stop(); + + private void CancelNativeFatComtradeDiscovery() + { + Interlocked.Increment(ref _nativeFatComtradeDiscoveryGeneration); + _nativeFatComtradeDiscoveryCts?.Cancel(); + _nativeFatComtradeDiscoveryCts?.Dispose(); + _nativeFatComtradeDiscoveryCts = null; + } + + private void DisposeNativeFatDiagnostics() + { + CancelNativeFatComtradeDiscovery(); + if (_nativeFatDiagnosticRefreshTimer != null) + { + _nativeFatDiagnosticRefreshTimer.Tick -= NativeFatDiagnosticRefreshTimer_Tick; + _nativeFatDiagnosticRefreshTimer.Stop(); + _nativeFatDiagnosticRefreshTimer = null; + } + + if (_nativeFatComtradeButton != null) + _nativeFatComtradeButton.Click -= NativeFatComtradeButton_Click; + if (_nativeFatTimeSyncButton != null) + _nativeFatTimeSyncButton.Click -= NativeFatTimeSyncButton_Click; + _nativeFatComtradeButton = null; + _nativeFatTimeSyncButton = null; + } + + private static string ValueOrDash(string? value) + => string.IsNullOrWhiteSpace(value) ? "—" : value.Trim(); +} diff --git a/MainWindow.NativeFatEvidenceBindingRuntime.cs b/MainWindow.NativeFatEvidenceBindingRuntime.cs new file mode 100644 index 000000000..e0ddb815c --- /dev/null +++ b/MainWindow.NativeFatEvidenceBindingRuntime.cs @@ -0,0 +1,316 @@ +using System.Globalization; +using System.Diagnostics; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Media; +using System.Windows.Threading; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services.IoTesting; + +namespace ArIED61850Tester; + +/// +/// Evidence column whose text is a real WPF binding to the current row DataContext. +/// Recycling a DataGridRow therefore re-evaluates the evidence against the new canonical +/// Iec61850MonitorPoint instead of retaining imperative TextBlock.Text from the prior row. +/// +internal sealed class NativeFatEvidenceBindingColumn : DataGridColumn +{ + private readonly NativeFatEvidenceBindingConverter _converter; + + internal NativeFatEvidenceBindingColumn( + string header, + NativeFatEvidenceField field, + double width, + Func reader) + { + ArgumentNullException.ThrowIfNull(reader); + Header = header; + Field = field; + Width = new DataGridLength(width); + MinWidth = 78; + _converter = new NativeFatEvidenceBindingConverter(field, reader); + } + + internal NativeFatEvidenceField Field { get; } + + protected override FrameworkElement GenerateElement(DataGridCell cell, object dataItem) + { + var block = new TextBlock + { + VerticalAlignment = VerticalAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis + }; + block.SetBinding(TextBlock.TextProperty, CreateBinding()); + return block; + } + + protected override FrameworkElement GenerateEditingElement(DataGridCell cell, object dataItem) + { + var editor = new TextBox + { + VerticalContentAlignment = VerticalAlignment.Center, + BorderThickness = new Thickness(0), + Background = Brushes.Transparent, + Padding = new Thickness(0) + }; + editor.SetBinding(TextBox.TextProperty, CreateBinding()); + return editor; + } + + internal void RefreshTarget(Iec61850MonitorPoint point) + { + if (GetCellContent(point) is TextBlock block) + { + block.GetBindingExpression(TextBlock.TextProperty)?.UpdateTarget(); + return; + } + + if (GetCellContent(point) is TextBox editor) + editor.GetBindingExpression(TextBox.TextProperty)?.UpdateTarget(); + } + + private Binding CreateBinding() + => new() + { + Path = new PropertyPath("."), + Mode = BindingMode.OneWay, + Converter = _converter + }; + + private sealed class NativeFatEvidenceBindingConverter : IValueConverter + { + private readonly NativeFatEvidenceField _field; + private readonly Func _reader; + + internal NativeFatEvidenceBindingConverter( + NativeFatEvidenceField field, + Func reader) + { + _field = field; + _reader = reader; + } + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + => value is Iec61850MonitorPoint point + ? _reader(point, _field) + : string.Empty; + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => Binding.DoNothing; + } +} + +public partial class MainWindow +{ + private DataGrid? _nativeFatEvidenceBindingGrid; + private bool _nativeFatEvidenceBindingArmHooked; + private DispatcherTimer? _nativeFatEvidenceBindingHydrationTimer; + private bool _nativeFatEvidenceBindingHydrationWasActive; + + /// + /// Hooks editing/refresh behavior for the bound evidence columns. The canonical grid still + /// owns SelectedDevice.Points and keeps row virtualization/recycling enabled. + /// + private void InstallNativeFatEvidenceBindingRuntime() + { + var grid = _nativeFatCanonicalGrid; + if (grid == null) + return; + + if (!ReferenceEquals(_nativeFatEvidenceBindingGrid, grid)) + { + if (_nativeFatEvidenceBindingGrid != null) + { + _nativeFatEvidenceBindingGrid.BeginningEdit -= NativeFatEvidenceBinding_BeginningEdit; + _nativeFatEvidenceBindingGrid.CellEditEnding -= NativeFatEvidenceBinding_CellEditEnding; + } + + _nativeFatEvidenceBindingGrid = grid; + grid.BeginningEdit += NativeFatEvidenceBinding_BeginningEdit; + grid.CellEditEnding += NativeFatEvidenceBinding_CellEditEnding; + } + + if (!_nativeFatEvidenceBindingArmHooked) + { + _nativeFatArmCoordinator.EvidenceChanged += NativeFatEvidenceBinding_EvidenceChanged; + _nativeFatEvidenceBindingArmHooked = true; + } + } + + /// + /// Called after each selected-IED bind. Hydration changes the sparse cache rather than the + /// canonical row object, so this short-lived timer only refreshes binding targets while + /// hydration is active and performs one final refresh when hydration resolves. + /// + private void RefreshNativeFatEvidenceBindingRuntime() + { + RefreshAllNativeFatEvidenceBindingTargets(); + + var hydrating = IsNativeFatEvidenceBindingHydrating(); + _nativeFatEvidenceBindingHydrationWasActive = hydrating; + if (!hydrating) + { + _nativeFatEvidenceBindingHydrationTimer?.Stop(); + return; + } + + _nativeFatEvidenceBindingHydrationTimer ??= CreateNativeFatEvidenceBindingHydrationTimer(); + if (!_nativeFatEvidenceBindingHydrationTimer.IsEnabled) + _nativeFatEvidenceBindingHydrationTimer.Start(); + } + + private DispatcherTimer CreateNativeFatEvidenceBindingHydrationTimer() + { + var timer = new DispatcherTimer(DispatcherPriority.Background, Dispatcher) + { + Interval = TimeSpan.FromMilliseconds(320) + }; + timer.Tick += NativeFatEvidenceBindingHydrationTimer_Tick; + return timer; + } + + private void NativeFatEvidenceBindingHydrationTimer_Tick(object? sender, EventArgs e) + { + var hydrating = IsNativeFatEvidenceBindingHydrating(); + if (hydrating) + { + _nativeFatEvidenceBindingHydrationWasActive = true; + RefreshAllNativeFatEvidenceBindingTargets(); + return; + } + + if (_nativeFatEvidenceBindingHydrationWasActive) + RefreshAllNativeFatEvidenceBindingTargets(); + + _nativeFatEvidenceBindingHydrationWasActive = false; + _nativeFatEvidenceBindingHydrationTimer?.Stop(); + } + + private bool IsNativeFatEvidenceBindingHydrating() + => !string.IsNullOrWhiteSpace(_nativeFatBoundIedKey) && + _nativeFatSessionByIed.TryGetValue(_nativeFatBoundIedKey, out var cache) && + cache.IsEvidenceHydrating; + + private void NativeFatEvidenceBinding_EvidenceChanged(object? sender, NativeFatEvidenceChangedEventArgs e) + { + if (!Dispatcher.CheckAccess()) + { + Dispatcher.BeginInvoke(() => NativeFatEvidenceBinding_EvidenceChanged(sender, e)); + return; + } + + if (!string.Equals(_nativeFatBoundIedKey, e.DeviceId, StringComparison.OrdinalIgnoreCase)) + return; + + RefreshNativeFatEvidenceBindingTargets(e.Point); + } + + private void RefreshNativeFatEvidenceBindingTargets(Iec61850MonitorPoint point) + { + if (_nativeFatCanonicalGrid == null) + return; + + foreach (var column in _nativeFatCanonicalGrid.Columns.OfType()) + column.RefreshTarget(point); + } + + private void RefreshAllNativeFatEvidenceBindingTargets() + { + if (_nativeFatCanonicalGrid == null) + return; + + foreach (var point in _nativeFatCanonicalGrid.Items.OfType()) + RefreshNativeFatEvidenceBindingTargets(point); + } + + private void NativeFatEvidenceBinding_BeginningEdit(object? sender, DataGridBeginningEditEventArgs e) + { + if (e.Column is not NativeFatEvidenceBindingColumn || string.IsNullOrWhiteSpace(_nativeFatBoundIedKey)) + return; + + if (_nativeFatSessionByIed.TryGetValue(_nativeFatBoundIedKey, out var cache) && cache.IsEvidenceHydrating) + e.Cancel = true; + } + + private void NativeFatEvidenceBinding_CellEditEnding(object? sender, DataGridCellEditEndingEventArgs e) + { + if (e.EditAction != DataGridEditAction.Commit || + e.Row.Item is not Iec61850MonitorPoint point || + e.Column is not NativeFatEvidenceBindingColumn evidenceColumn || + e.EditingElement is not TextBox editor || + string.IsNullOrWhiteSpace(_nativeFatBoundIedKey)) + { + return; + } + + var cache = GetNativeFatSession(_nativeFatBoundIedKey); + NativeFatCanonicalEvidenceOverlay.Write(cache, point, evidenceColumn.Field, editor.Text); + cache.ActiveRowKey = NativeFatCanonicalEvidenceOverlay.BuildRowKey(point); + ScheduleNativeFatEvidencePersist(_nativeFatBoundIedKey); + Dispatcher.BeginInvoke( + () => evidenceColumn.RefreshTarget(point), + DispatcherPriority.DataBind); + } + + /// + /// Flush sparse FAT evidence before the arm/persistence services are disposed. Snapshot + /// paths are IEDName-based and row identity remains IEDName + IEC Telegram. + /// + private void FlushNativeFatEvidenceBeforeShutdown() + { + foreach (var device in Devices.ToArray()) + { + if (!_nativeFatSessionByIed.TryGetValue(device.DeviceId, out var cache)) + continue; + + bool hasEvidence; + lock (cache.EvidenceByRow) + hasEvidence = cache.EvidenceByRow.Count > 0; + if (!hasEvidence) + continue; + + try + { + _nativeFatEvidenceHydrationService + .SaveAsync(device, cache, CancellationToken.None) + .GetAwaiter() + .GetResult(); + + Trace.WriteLine( + $"[FAT field] evidence flush completed before shutdown; ied={device.Name}; deviceId={device.DeviceId}; rows={cache.EvidenceByRow.Count}."); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) + { + Trace.WriteLine( + $"[FAT field] evidence flush failed before shutdown for {device.Name}: {ex.Message}"); + } + } + } + + private void DisposeNativeFatEvidenceBindingRuntime() + { + if (_nativeFatEvidenceBindingGrid != null) + { + _nativeFatEvidenceBindingGrid.BeginningEdit -= NativeFatEvidenceBinding_BeginningEdit; + _nativeFatEvidenceBindingGrid.CellEditEnding -= NativeFatEvidenceBinding_CellEditEnding; + _nativeFatEvidenceBindingGrid = null; + } + + if (_nativeFatEvidenceBindingArmHooked) + { + _nativeFatArmCoordinator.EvidenceChanged -= NativeFatEvidenceBinding_EvidenceChanged; + _nativeFatEvidenceBindingArmHooked = false; + } + + if (_nativeFatEvidenceBindingHydrationTimer != null) + { + _nativeFatEvidenceBindingHydrationTimer.Tick -= NativeFatEvidenceBindingHydrationTimer_Tick; + _nativeFatEvidenceBindingHydrationTimer.Stop(); + _nativeFatEvidenceBindingHydrationTimer = null; + } + + _nativeFatEvidenceBindingHydrationWasActive = false; + } +} diff --git a/MainWindow.NativeFatEvidenceDurability.cs b/MainWindow.NativeFatEvidenceDurability.cs new file mode 100644 index 000000000..214395884 --- /dev/null +++ b/MainWindow.NativeFatEvidenceDurability.cs @@ -0,0 +1,336 @@ +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Threading; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services.IoTesting; + +namespace ArIED61850Tester; + +/// +/// Native FAT evidence lifecycle: auto-load by stable IEDName, capture-only Start FAT, +/// detached sparse persistence, and no dependency on Engineering Points during file IO. +/// +public partial class MainWindow +{ + private bool _nativeFatEvidenceDurabilityInstalled; + private DataGrid? _nativeFatEvidenceDurabilityGrid; + private NativeFatEvidenceStore? _nativeFatEvidenceStore; + private NativeFatEvidencePersistenceCoordinator? _nativeFatEvidencePersistenceCoordinator; + private readonly Dictionary _nativeFatEvidenceStoreLoadCtsBySession = + new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _nativeFatEvidenceStoreLoadedSessions = + new(StringComparer.OrdinalIgnoreCase); + + [ModuleInitializer] + internal static void RegisterNativeFatEvidenceDurability() + { + EventManager.RegisterClassHandler( + typeof(MainWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(NativeFatEvidenceDurability_MainWindowLoaded), + handledEventsToo: true); + } + + private static void NativeFatEvidenceDurability_MainWindowLoaded(object sender, RoutedEventArgs e) + { + if (sender is not MainWindow window || window._nativeFatEvidenceDurabilityInstalled) + return; + + window.Dispatcher.BeginInvoke( + new Action(window.InstallNativeFatEvidenceDurability), + DispatcherPriority.ApplicationIdle); + } + + private void InstallNativeFatEvidenceDurability() + { + if (_nativeFatEvidenceDurabilityInstalled || !IsLoaded) + return; + + _nativeFatEvidenceDurabilityInstalled = true; + _nativeFatEvidenceStore ??= new NativeFatEvidenceStore(); + _nativeFatEvidencePersistenceCoordinator ??= + new NativeFatEvidencePersistenceCoordinator(_nativeFatEvidenceStore); + + _nativeFatArmCoordinator.EvidenceChanged += NativeFatEvidenceDurability_EvidenceChanged; + Devices.CollectionChanged += NativeFatEvidenceDurability_DevicesCollectionChanged; + MainTabs.SelectionChanged += NativeFatEvidenceDurability_MainTabsSelectionChanged; + PropertyChanged += NativeFatEvidenceDurability_MainWindowPropertyChanged; + Closed += NativeFatEvidenceDurability_MainWindowClosed; + EnsureNativeFatEvidenceDurabilityGridHook(); + + // Prepare every IED already present in the Engineering workspace. This is intentionally + // independent of FAT tab activation and Start FAT, and also covers multi-IED SCL files. + foreach (var device in Devices) + BeginNativeFatEvidenceStoreLoad(device); + } + + private void NativeFatEvidenceDurability_DevicesCollectionChanged( + object? sender, + NotifyCollectionChangedEventArgs e) + { + if (e.NewItems == null) + return; + + foreach (var device in e.NewItems.OfType()) + { + var addedDevice = device; + // Open SCL inserts a new device before ApplySclWorkspaceToDevice assigns the + // authoritative workspace.IedName. Defer one dispatcher turn so the store lookup + // never runs against the constructor placeholder name "IED". + Dispatcher.BeginInvoke( + new Action(() => BeginNativeFatEvidenceStoreLoad(addedDevice)), + DispatcherPriority.Background); + } + } + + private void NativeFatEvidenceDurability_MainTabsSelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (!ReferenceEquals(e.Source, MainTabs)) + return; + + EnsureNativeFatEvidenceDurabilityGridHook(); + BeginNativeFatEvidenceStoreLoad(SelectedDevice); + } + + private void NativeFatEvidenceDurability_MainWindowPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(SelectedDevice)) + BeginNativeFatEvidenceStoreLoad(SelectedDevice); + } + + private void EnsureNativeFatEvidenceDurabilityGridHook() + { + var grid = _nativeFatCanonicalGrid; + if (ReferenceEquals(_nativeFatEvidenceDurabilityGrid, grid)) + return; + + if (_nativeFatEvidenceDurabilityGrid != null) + _nativeFatEvidenceDurabilityGrid.CellEditEnding -= NativeFatEvidenceDurability_CellEditEnding; + + _nativeFatEvidenceDurabilityGrid = grid; + if (_nativeFatEvidenceDurabilityGrid != null) + _nativeFatEvidenceDurabilityGrid.CellEditEnding += NativeFatEvidenceDurability_CellEditEnding; + } + + private void NativeFatEvidenceDurability_EvidenceChanged( + object? sender, + NativeFatEvidenceChangedEventArgs e) + { + if (!Dispatcher.CheckAccess()) + { + Dispatcher.BeginInvoke( + () => NativeFatEvidenceDurability_EvidenceChanged(sender, e), + DispatcherPriority.DataBind); + return; + } + + QueueFrozenNativeFatEvidence( + e.DeviceId, + "capture", + e.Point.DeviceName, + e.Point.IpAddress); + } + + private void NativeFatEvidenceDurability_CellEditEnding( + object? sender, + DataGridCellEditEndingEventArgs e) + { + if (e.EditAction != DataGridEditAction.Commit || + e.Row.Item is not Iec61850MonitorPoint point || + e.Column is not NativeFatEvidenceBindingColumn evidenceColumn || + e.EditingElement is not TextBox editor || + string.IsNullOrWhiteSpace(_nativeFatBoundIedKey)) + { + return; + } + + // Keep this order-independent from the existing binding handler. The write is + // idempotent for unchanged V1/V2 values. + var cache = GetNativeFatSession(_nativeFatBoundIedKey); + NativeFatCanonicalEvidenceOverlay.Write(cache, point, evidenceColumn.Field, editor.Text); + cache.ActiveRowKey = NativeFatCanonicalEvidenceOverlay.BuildRowKey(point); + QueueFrozenNativeFatEvidence( + _nativeFatBoundIedKey, + "operator edit", + point.DeviceName, + point.IpAddress); + } + + private void QueueFrozenNativeFatEvidence( + string deviceId, + string reason, + string? fallbackIedName = null, + string? fallbackIpAddress = null) + { + if (!_nativeFatSessionByIed.TryGetValue(deviceId, out var cache)) + return; + + var device = Devices.FirstOrDefault(candidate => + candidate.DeviceId.Equals(deviceId, StringComparison.OrdinalIgnoreCase)); + var iedName = device?.Name ?? fallbackIedName ?? string.Empty; + var ipAddress = device?.IpAddress ?? fallbackIpAddress ?? string.Empty; + if (string.IsNullOrWhiteSpace(iedName)) + return; + + // Retire the old live-device debounce for this generation. Its SaveAsync walks + // device.Points later; the new store writes only the already-detached sparse payload. + if (_nativeFatEvidencePersistCtsByIed.Remove(deviceId, out var pending)) + { + pending.Cancel(); + pending.Dispose(); + } + + _nativeFatEvidenceStore ??= new NativeFatEvidenceStore(); + _nativeFatEvidencePersistenceCoordinator ??= + new NativeFatEvidencePersistenceCoordinator(_nativeFatEvidenceStore); + + var snapshot = device != null + ? NativeFatEvidenceDurabilitySnapshot.Capture(device, cache) + : NativeFatEvidenceDurabilitySnapshot.Capture(deviceId, iedName, ipAddress, cache); + _nativeFatEvidencePersistenceCoordinator.Queue(snapshot); + + Trace.WriteLine( + $"[FAT evidence store] queued detached sparse evidence at {reason}; " + + $"ied={iedName}; deviceId={deviceId}; evidenceRows={snapshot.EvidenceByRow.Count}; " + + $"liveDevicePresent={device != null}."); + } + + private void BeginNativeFatEvidenceStoreLoad(Iec61850MonitorDevice? device) + { + if (device == null || string.IsNullOrWhiteSpace(device.Name)) + return; + + var sessionKey = BuildNativeFatEvidenceStoreSessionKey(device.DeviceId, device.Name); + if (_nativeFatEvidenceStoreLoadedSessions.Contains(sessionKey) || + _nativeFatEvidenceStoreLoadCtsBySession.ContainsKey(sessionKey)) + { + return; + } + + _nativeFatEvidenceStore ??= new NativeFatEvidenceStore(); + var cts = new CancellationTokenSource(); + _nativeFatEvidenceStoreLoadCtsBySession[sessionKey] = cts; + _ = LoadNativeFatEvidenceStoreAsync( + device.DeviceId, + device.Name, + sessionKey, + cts); + } + + private async Task LoadNativeFatEvidenceStoreAsync( + string runtimeDeviceId, + string iedName, + string sessionKey, + CancellationTokenSource owner) + { + try + { + _nativeFatEvidenceStore ??= new NativeFatEvidenceStore(); + var result = await _nativeFatEvidenceStore.LoadAsync(iedName, owner.Token); + owner.Token.ThrowIfCancellationRequested(); + + if (result.Succeeded) + _nativeFatEvidenceStoreLoadedSessions.Add(sessionKey); + + var cache = GetNativeFatSession(runtimeDeviceId); + if (result.Succeeded && result.SnapshotFound) + { + ReplaceNativeFatEvidenceForIed(cache, iedName, result.EvidenceByRow); + cache.EvidenceHydrationState = NativeFatEvidenceHydrationState.Resolved; + cache.EvidenceHydratedAt = DateTimeOffset.Now; + cache.EvidenceHydrationError = string.Empty; + + if (string.Equals(_nativeFatBoundIedKey, runtimeDeviceId, StringComparison.OrdinalIgnoreCase)) + { + RefreshAllVisibleNativeFatEvidenceCells(); + RefreshNativeFatEvidenceBindingRuntime(); + UpdateNativeFatArmUi( + Devices.FirstOrDefault(candidate => candidate.DeviceId.Equals(runtimeDeviceId, StringComparison.OrdinalIgnoreCase)), + $"Canonical Engineering live rows · evidence ready · {result.LoadedRows} persisted row(s) auto-loaded by IEDName"); + } + + Trace.WriteLine( + $"[FAT evidence store] auto-loaded; ied={iedName}; deviceId={runtimeDeviceId}; " + + $"rows={result.LoadedRows}; ignored={result.IgnoredRows}; source={result.SourcePath}; " + + $"elapsedMs={result.ElapsedMilliseconds}; StartFATRequired=false."); + } + else if (!result.Succeeded) + { + Trace.WriteLine( + $"[FAT evidence store] auto-load failed for {iedName}: {result.Message}"); + } + } + catch (OperationCanceledException) + { + } + finally + { + if (_nativeFatEvidenceStoreLoadCtsBySession.TryGetValue(sessionKey, out var current) && + ReferenceEquals(current, owner)) + { + _nativeFatEvidenceStoreLoadCtsBySession.Remove(sessionKey); + owner.Dispose(); + } + } + } + + private static string BuildNativeFatEvidenceStoreSessionKey(string deviceId, string iedName) + => $"{deviceId.Trim().ToLowerInvariant()}|{NativeFatCanonicalEvidenceOverlay.NormalizeIedName(iedName)}"; + + private static void ReplaceNativeFatEvidenceForIed( + NativeFatIedSessionCacheState cache, + string iedName, + IReadOnlyDictionary evidenceByRow) + { + var ownerPrefix = NativeFatCanonicalEvidenceOverlay.NormalizeIedName(iedName) + "|"; + lock (cache.EvidenceByRow) + { + foreach (var key in cache.EvidenceByRow.Keys + .Where(key => key.StartsWith(ownerPrefix, StringComparison.OrdinalIgnoreCase)) + .ToArray()) + { + cache.EvidenceByRow.Remove(key); + } + + foreach (var pair in evidenceByRow) + cache.EvidenceByRow[pair.Key] = pair.Value; + } + } + + private void NativeFatEvidenceDurability_MainWindowClosed(object? sender, EventArgs e) + { + foreach (var cts in _nativeFatEvidenceStoreLoadCtsBySession.Values) + { + cts.Cancel(); + cts.Dispose(); + } + _nativeFatEvidenceStoreLoadCtsBySession.Clear(); + + try + { + _nativeFatEvidencePersistenceCoordinator?.DrainAllAsync().GetAwaiter().GetResult(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) + { + Trace.WriteLine($"[FAT evidence store] final drain failed: {ex.Message}"); + } + + _nativeFatArmCoordinator.EvidenceChanged -= NativeFatEvidenceDurability_EvidenceChanged; + Devices.CollectionChanged -= NativeFatEvidenceDurability_DevicesCollectionChanged; + MainTabs.SelectionChanged -= NativeFatEvidenceDurability_MainTabsSelectionChanged; + PropertyChanged -= NativeFatEvidenceDurability_MainWindowPropertyChanged; + Closed -= NativeFatEvidenceDurability_MainWindowClosed; + if (_nativeFatEvidenceDurabilityGrid != null) + _nativeFatEvidenceDurabilityGrid.CellEditEnding -= NativeFatEvidenceDurability_CellEditEnding; + _nativeFatEvidenceDurabilityGrid = null; + _nativeFatEvidenceStoreLoadedSessions.Clear(); + + _nativeFatEvidenceStore?.Dispose(); + _nativeFatEvidenceStore = null; + _nativeFatEvidencePersistenceCoordinator = null; + } +} diff --git a/MainWindow.NativeFatP4CColumnContract.cs b/MainWindow.NativeFatP4CColumnContract.cs new file mode 100644 index 000000000..889e03dbe --- /dev/null +++ b/MainWindow.NativeFatP4CColumnContract.cs @@ -0,0 +1,153 @@ +using System.Globalization; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using System.Windows.Threading; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services.IoTesting; + +namespace ArIED61850Tester; + +public partial class MainWindow +{ + private bool _nativeFatObservationStatusHooked; + + /// + /// Native FAT is a thin view over the canonical IEC Explorer rows. The grid keeps + /// SelectedDevice.Points as its ItemsSource and adds only sparse evidence columns. + /// Values and timestamps are deliberately separate so the operator can scan evidence + /// without parsing a combined presentation string. + /// + private void ApplyNativeFatP4CColumnContract() + { + if (_nativeFatCanonicalGrid == null) + return; + + if (!_nativeFatObservationStatusHooked) + { + // Keep observation progress on the same evidence event authority as V1/V2/Result. + // The callback is posted at Background priority so the ARM click's generic status + // cannot overwrite the more useful row-level "1 / 2" or "2 / 2" confirmation. + _nativeFatArmCoordinator.EvidenceChanged += NativeFatObservationStatus_EvidenceChanged; + _nativeFatObservationStatusHooked = true; + } + + _nativeFatCanonicalGrid.Columns.Clear(); + _nativeFatCanonicalGrid.FrozenColumnCount = 2; + + AddCanonicalTextColumn("Signal", nameof(Iec61850MonitorPoint.SignalName), 190); + AddCanonicalTextColumn("IEC Telegram", nameof(Iec61850MonitorPoint.IecTelegram), 370); + AddCanonicalTextColumn("Quality", nameof(Iec61850MonitorPoint.Quality), 95); + AddCanonicalTemplateColumn("Live Value", "ProcessValueBadgeTemplate", 150); + + // P0 field hardening: evidence text is bound to the current DataContext. WPF row + // recycling therefore re-evaluates IEDName + IEC Telegram for the newly assigned + // canonical point instead of carrying imperative TextBlock.Text from a previous row. + _nativeFatCanonicalGrid.Columns.Add( + new NativeFatEvidenceBindingColumn("Value 1", NativeFatEvidenceField.Value1, 155, ReadNativeFatEvidence)); + _nativeFatCanonicalGrid.Columns.Add( + new NativeFatEvidenceBindingColumn("V1 Timestamp", NativeFatEvidenceField.Value1Timestamp, 185, ReadNativeFatEvidence) + { + IsReadOnly = true + }); + _nativeFatCanonicalGrid.Columns.Add( + new NativeFatEvidenceBindingColumn("Value 2", NativeFatEvidenceField.Value2, 155, ReadNativeFatEvidence)); + _nativeFatCanonicalGrid.Columns.Add( + new NativeFatEvidenceBindingColumn("V2 Timestamp", NativeFatEvidenceField.Value2Timestamp, 185, ReadNativeFatEvidence) + { + IsReadOnly = true + }); + _nativeFatCanonicalGrid.Columns.Add( + new NativeFatEvidenceBindingColumn("Result", NativeFatEvidenceField.Result, 68, ReadNativeFatEvidence)); + } + + private void NativeFatObservationStatus_EvidenceChanged(object? sender, NativeFatEvidenceChangedEventArgs e) + { + void UpdateObservationStatus() + { + if (_nativeFatStatusText == null || + !string.Equals(_nativeFatBoundIedKey, e.DeviceId, StringComparison.OrdinalIgnoreCase) || + !_nativeFatSessionByIed.TryGetValue(e.DeviceId, out var cache)) + { + return; + } + + var value1 = NativeFatCanonicalEvidenceOverlay.Read(cache, e.Point, NativeFatEvidenceField.Value1); + var value2 = NativeFatCanonicalEvidenceOverlay.Read(cache, e.Point, NativeFatEvidenceField.Value2); + var observations = (string.IsNullOrWhiteSpace(value1) ? 0 : 1) + + (string.IsNullOrWhiteSpace(value2) ? 0 : 1); + var result = NativeFatCanonicalEvidenceOverlay.Read(cache, e.Point, NativeFatEvidenceField.Result); + var signal = string.IsNullOrWhiteSpace(e.Point.SignalName) + ? e.Point.IecTelegram + : e.Point.SignalName.Trim(); + + _nativeFatStatusText.Text = string.IsNullOrWhiteSpace(result) + ? $"{signal} · {observations} / 2 observations" + : $"{signal} · {observations} / 2 observations · {result}"; + } + + Dispatcher.BeginInvoke( + DispatcherPriority.Background, + new Action(UpdateObservationStatus)); + } + + // Retained as an isolated formatter for report/tests and compatibility paths. The visible + // grid routes all evidence fields through NativeFatEvidenceBindingColumn. + private string ReadNativeFatTimestamp(Iec61850MonitorPoint point, NativeFatEvidenceField field) + { + if (string.IsNullOrWhiteSpace(_nativeFatBoundIedKey) || + !_nativeFatSessionByIed.TryGetValue(_nativeFatBoundIedKey, out var cache)) + { + return string.Empty; + } + + var evidence = NativeFatCanonicalEvidenceOverlay.ReadCapture(cache, point, field); + if (evidence is null) + return string.Empty; + + var timestamp = evidence.IedTimestamp ?? evidence.CapturedAt; + return timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture); + } + + /// + /// Compatibility timestamp column retained for source/binary compatibility. Production + /// native FAT uses NativeFatEvidenceBindingColumn for timestamp fields as well. + /// + private sealed class NativeFatEvidenceTimestampColumn : DataGridColumn + { + private readonly MainWindow _owner; + + internal NativeFatEvidenceTimestampColumn( + MainWindow owner, + string header, + NativeFatEvidenceField field, + double width) + { + _owner = owner; + Header = header; + Field = field; + Width = new DataGridLength(width); + MinWidth = 130; + IsReadOnly = true; + } + + internal NativeFatEvidenceField Field { get; } + + protected override FrameworkElement GenerateElement(DataGridCell cell, object dataItem) + { + return new TextBlock + { + Text = dataItem is Iec61850MonitorPoint point + ? _owner.ReadNativeFatTimestamp(point, Field) + : string.Empty, + FontFamily = new FontFamily("Consolas"), + FontSize = 11, + VerticalAlignment = VerticalAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis + }; + } + + protected override FrameworkElement GenerateEditingElement(DataGridCell cell, object dataItem) + => GenerateElement(cell, dataItem); + } +} diff --git a/MainWindow.NativeFatPrintPreview.cs b/MainWindow.NativeFatPrintPreview.cs new file mode 100644 index 000000000..5dd71e71b --- /dev/null +++ b/MainWindow.NativeFatPrintPreview.cs @@ -0,0 +1,454 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Shapes; +using System.Windows.Threading; +using ArIED61850Tester.Services.IoTesting; +using Microsoft.Win32; + +namespace ArIED61850Tester; + +public partial class MainWindow +{ + private const string NativeFatPrintPreviewTitle = "IEC 61850 FAT Evidence Report"; + private Button? _nativeFatPrintPreviewButton; + + private enum NativePreviewLucideIcon + { + Printer, + Minus, + Plus, + Maximize2, + ChevronLeft, + ChevronRight, + RefreshCw, + ImagePlus, + Save + } + + private void NativeFatPrintPreviewButton_Click(object sender, RoutedEventArgs e) + { + var device = SelectedDevice; + if (device == null || device.Points.Count == 0) + { + SetStatus("FAT · select an Engineering IED with canonical rows before opening Print Preview"); + return; + } + + CommitNativeFatEvidenceEdits(); + var snapshot = NativeFatPrintPreviewSnapshot.Capture( + device, + GetNativeFatSession(device.DeviceId), + _nativeFatAuxiliaryEvidenceCache.Capture(device)); + ShowNativeFatPrintPreview(snapshot); + SetStatus( + $"FAT · Print Preview captured {snapshot.Rows.Count} immutable canonical row(s) for {snapshot.IedName}"); + } + + /// + /// Professional native preview: immutable selected-IED snapshot -> shared layout adapter -> + /// existing FixedDocument renderer. The stock DocumentViewer toolbar is hidden and the + /// ARSAS/Lucide-style toolbar owns print, zoom, whole-page fit, page navigation, refresh and Save PDF. + /// Preview and Save PDF always consume the exact same IoFatReportLayoutPlan instance. + /// + private void ShowNativeFatPrintPreview(NativeFatPrintPreviewSnapshot snapshot) + { + ArgumentNullException.ThrowIfNull(snapshot); + + var currentSnapshot = snapshot; + var currentLogo = NativeFatReportLogoService.TryLoadDefault(); + var currentLayout = NativeFatP4DReportAdapter.Build(currentSnapshot, draft: true); + currentLayout = NativeFatReportLogoService.Apply(currentLayout, currentLogo); + var document = IoFatReportPreviewDocumentBuilder.Render(currentLayout); + + var preview = new Window + { + Owner = this, + Title = NativeFatPrintPreviewTitle, + Width = 1260, + Height = 880, + MinWidth = 960, + MinHeight = 660, + WindowStartupLocation = WindowStartupLocation.CenterOwner, + Background = new SolidColorBrush(Color.FromRgb(232, 237, 244)) + }; + + var root = new Grid(); + root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + root.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); + + var toolbar = new Border + { + Padding = new Thickness(14, 9, 14, 9), + Background = Brushes.White, + BorderBrush = new SolidColorBrush(Color.FromRgb(216, 224, 234)), + BorderThickness = new Thickness(0, 0, 0, 1) + }; + var toolbarGrid = new Grid(); + toolbarGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + toolbarGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + toolbarGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + + var titleStack = new StackPanel { VerticalAlignment = VerticalAlignment.Center }; + titleStack.Children.Add(new TextBlock + { + Text = "Report Preview", + FontSize = 13.5, + FontWeight = FontWeights.SemiBold, + Foreground = new SolidColorBrush(Color.FromRgb(15, 23, 42)) + }); + var summary = new TextBlock + { + Text = NativeFatPreviewSummary(currentSnapshot), + Margin = new Thickness(0, 2, 0, 0), + FontSize = 10.5, + Foreground = new SolidColorBrush(Color.FromRgb(100, 116, 139)) + }; + titleStack.Children.Add(summary); + toolbarGrid.Children.Add(titleStack); + + var viewer = new DocumentViewer + { + Document = document, + Margin = new Thickness(12), + HorizontalAlignment = HorizontalAlignment.Stretch, + VerticalAlignment = VerticalAlignment.Stretch, + Zoom = 100d + }; + + var actions = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + }; + + var pageText = new TextBlock + { + Text = "Page — / —", + MinWidth = 76, + Margin = new Thickness(7, 0, 7, 0), + VerticalAlignment = VerticalAlignment.Center, + TextAlignment = TextAlignment.Center, + FontSize = 10.5, + FontWeight = FontWeights.SemiBold, + Foreground = new SolidColorBrush(Color.FromRgb(71, 85, 105)) + }; + var zoomText = new TextBlock + { + Text = "100%", + MinWidth = 46, + Margin = new Thickness(5, 0, 5, 0), + VerticalAlignment = VerticalAlignment.Center, + TextAlignment = TextAlignment.Center, + FontSize = 10.5, + FontWeight = FontWeights.SemiBold, + Foreground = new SolidColorBrush(Color.FromRgb(71, 85, 105)) + }; + + void UpdateViewerState() + { + pageText.Text = viewer.PageCount > 0 + ? $"Page {Math.Max(1, viewer.MasterPageNumber)} / {viewer.PageCount}" + : "Page — / —"; + zoomText.Text = $"{viewer.Zoom:0}%"; + } + + void RenderCurrentLayout() + { + currentLayout = NativeFatP4DReportAdapter.Build(currentSnapshot, draft: true); + currentLayout = NativeFatReportLogoService.Apply(currentLayout, currentLogo); + viewer.Document = IoFatReportPreviewDocumentBuilder.Render(currentLayout); + summary.Text = NativeFatPreviewSummary(currentSnapshot); + preview.Dispatcher.BeginInvoke( + () => + { + FitNativeReportPage(viewer); + UpdateViewerState(); + }, + DispatcherPriority.Background); + } + + Button IconButton(NativePreviewLucideIcon icon, string toolTip, Action action) + { + var button = new Button + { + Width = 32, + Height = 30, + Padding = new Thickness(6), + Margin = new Thickness(2, 0, 2, 0), + Style = TryFindResource("SoftButton") as Style, + ToolTip = toolTip, + Cursor = Cursors.Hand, + Content = BuildNativePreviewLucideIcon(icon) + }; + button.Click += (_, _) => + { + action(); + preview.Dispatcher.BeginInvoke(UpdateViewerState, DispatcherPriority.Background); + }; + return button; + } + + actions.Children.Add(IconButton(NativePreviewLucideIcon.Printer, "Print report", viewer.Print)); + actions.Children.Add(IconButton(NativePreviewLucideIcon.Minus, "Zoom out", viewer.DecreaseZoom)); + actions.Children.Add(zoomText); + actions.Children.Add(IconButton(NativePreviewLucideIcon.Plus, "Zoom in", viewer.IncreaseZoom)); + actions.Children.Add(IconButton(NativePreviewLucideIcon.Maximize2, "Fit whole report page", () => FitNativeReportPage(viewer))); + actions.Children.Add(IconButton(NativePreviewLucideIcon.ChevronLeft, "Previous page", viewer.PreviousPage)); + actions.Children.Add(pageText); + actions.Children.Add(IconButton(NativePreviewLucideIcon.ChevronRight, "Next page", viewer.NextPage)); + + actions.Children.Add(IconButton(NativePreviewLucideIcon.RefreshCw, "Refresh from current FAT evidence", () => + { + var device = SelectedDevice; + if (device == null || device.Points.Count == 0) + return; + + CommitNativeFatEvidenceEdits(); + currentSnapshot = NativeFatPrintPreviewSnapshot.Capture( + device, + GetNativeFatSession(device.DeviceId), + _nativeFatAuxiliaryEvidenceCache.Capture(device)); + RenderCurrentLayout(); + SetStatus($"FAT · Print Preview refreshed from {currentSnapshot.IedName} evidence"); + })); + + var addLogoButton = new Button + { + Height = 30, + MinWidth = 92, + Padding = new Thickness(9, 0, 10, 0), + Margin = new Thickness(8, 0, 2, 0), + Style = TryFindResource("SoftButton") as Style, + ToolTip = "Replace the report logo for this Preview and its Save PDF output.", + Cursor = Cursors.Hand, + Content = BuildNativePreviewLabeledContent(NativePreviewLucideIcon.ImagePlus, "Add Logo") + }; + addLogoButton.Click += (_, _) => + { + var dialog = new OpenFileDialog + { + Title = "Add Report Logo", + Filter = "Image files (*.png;*.jpg;*.jpeg)|*.png;*.jpg;*.jpeg|PNG image (*.png)|*.png|JPEG image (*.jpg;*.jpeg)|*.jpg;*.jpeg", + Multiselect = false, + CheckFileExists = true + }; + + if (dialog.ShowDialog(preview) != true) + return; + + try + { + currentLogo = NativeFatReportLogoService.LoadFromFile(dialog.FileName); + RenderCurrentLayout(); + SetStatus($"FAT · report logo added · {System.IO.Path.GetFileName(dialog.FileName)}"); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException or ArgumentException or InvalidOperationException) + { + SetStatus($"FAT · report logo could not be loaded · {ex.Message}"); + MessageBox.Show( + preview, + ex.Message, + "Add Logo", + MessageBoxButton.OK, + MessageBoxImage.Error); + } + }; + actions.Children.Add(addLogoButton); + + var savePdfButton = new Button + { + Height = 30, + MinWidth = 94, + Padding = new Thickness(9, 0, 10, 0), + Margin = new Thickness(8, 0, 2, 0), + Style = TryFindResource("PrimaryButton") as Style, + ToolTip = "Save the exact layout currently shown in Print Preview as PDF.", + Cursor = Cursors.Hand, + Content = BuildNativePreviewLabeledContent(NativePreviewLucideIcon.Save, "Save PDF") + }; + savePdfButton.Click += (_, _) => SaveNativeFatPreviewPdf(preview, currentSnapshot, currentLayout); + actions.Children.Add(savePdfButton); + + Grid.SetColumn(actions, 1); + toolbarGrid.Children.Add(actions); + toolbar.Child = toolbarGrid; + root.Children.Add(toolbar); + + viewer.Loaded += (_, _) => + { + CollapseNativeDocumentViewerChrome(viewer); + FitNativeReportPage(viewer); + preview.Dispatcher.BeginInvoke(UpdateViewerState, DispatcherPriority.Background); + }; + viewer.PageViewsChanged += (_, _) => UpdateViewerState(); + Grid.SetRow(viewer, 1); + root.Children.Add(viewer); + + preview.Content = root; + preview.Show(); + } + + private static void FitNativeReportPage(DocumentViewer viewer) + { + ArgumentNullException.ThrowIfNull(viewer); + + viewer.FitToWidth(); + var widthZoom = viewer.Zoom; + viewer.FitToHeight(); + var heightZoom = viewer.Zoom; + viewer.Zoom = Math.Min(widthZoom, heightZoom); + } + + private void CommitNativeFatEvidenceEdits() + { + _nativeFatCanonicalGrid?.CommitEdit(DataGridEditingUnit.Cell, true); + _nativeFatCanonicalGrid?.CommitEdit(DataGridEditingUnit.Row, true); + } + + private static string NativeFatPreviewSummary(NativeFatPrintPreviewSnapshot snapshot) + => $"{snapshot.IedName} · {snapshot.Rows.Count} row(s) · {snapshot.ProgressText} · captured {snapshot.CapturedAt:dd-MM-yyyy}"; + + private static FrameworkElement BuildNativePreviewLabeledContent(NativePreviewLucideIcon icon, string label) + { + var panel = new StackPanel + { + Orientation = Orientation.Horizontal, + VerticalAlignment = VerticalAlignment.Center, + HorizontalAlignment = HorizontalAlignment.Center + }; + panel.Children.Add(BuildNativePreviewLucideIcon(icon)); + panel.Children.Add(new TextBlock + { + Text = label, + Margin = new Thickness(6, 0, 0, 0), + VerticalAlignment = VerticalAlignment.Center, + FontSize = 10.5, + FontWeight = FontWeights.SemiBold + }); + return panel; + } + + private static Viewbox BuildNativePreviewLucideIcon(NativePreviewLucideIcon icon) + { + // Same 24x24 vector language used by the legacy professional report UX. + var geometry = icon switch + { + NativePreviewLucideIcon.Printer => "M6,9 L6,2 L18,2 L18,9 M6,18 L4,18 C2.9,18 2,17.1 2,16 L2,11 C2,9.9 2.9,9 4,9 L20,9 C21.1,9 22,9.9 22,11 L22,16 C22,17.1 21.1,18 20,18 L18,18 M6,14 L18,14 L18,22 L6,22 Z", + NativePreviewLucideIcon.Minus => "M5,12 L19,12", + NativePreviewLucideIcon.Plus => "M12,5 L12,19 M5,12 L19,12", + NativePreviewLucideIcon.Maximize2 => "M8,3 L3,3 L3,8 M16,3 L21,3 L21,8 M8,21 L3,21 L3,16 M16,21 L21,21 L21,16", + NativePreviewLucideIcon.ChevronLeft => "M15,18 L9,12 L15,6", + NativePreviewLucideIcon.ChevronRight => "M9,18 L15,12 L9,6", + NativePreviewLucideIcon.RefreshCw => "M3,12 A9,9 0 0 1 12,3 A9.75,9.75 0 0 1 18.74,5.74 L21,8 M21,3 L21,8 L16,8 M21,12 A9,9 0 0 1 12,21 A9.75,9.75 0 0 1 5.26,18.26 L3,16 M8,16 L3,16 L3,21", + NativePreviewLucideIcon.ImagePlus => "M3,5 L15,5 L15,19 L3,19 Z M5.5,15.5 L8.5,12.5 L10.8,14.8 L13,12.6 M17,6 L21,6 M19,4 L19,8 M7.5,9 A1,1 0 1 0 7.5,9.01", + NativePreviewLucideIcon.Save => "M15.2,3 A2,2 0 0 1 16.6,3.6 L20.4,7.4 A2,2 0 0 1 21,8.8 L21,19 A2,2 0 0 1 19,21 L5,21 A2,2 0 0 1 3,19 L3,5 A2,2 0 0 1 5,3 Z M17,21 L17,14 A1,1 0 0 0 16,13 L8,13 A1,1 0 0 0 7,14 L7,21 M7,3 L7,7 A1,1 0 0 0 8,8 L15,8", + _ => "M5,12 L19,12" + }; + + var path = new System.Windows.Shapes.Path + { + Data = Geometry.Parse(geometry), + Fill = Brushes.Transparent, + StrokeThickness = 1.8, + StrokeStartLineCap = PenLineCap.Round, + StrokeEndLineCap = PenLineCap.Round, + StrokeLineJoin = PenLineJoin.Round, + Stretch = Stretch.Uniform + }; + path.SetBinding( + Shape.StrokeProperty, + new Binding(nameof(Control.Foreground)) + { + RelativeSource = new RelativeSource(RelativeSourceMode.FindAncestor, typeof(Button), 1) + }); + + return new Viewbox + { + Width = 16, + Height = 16, + Child = path, + Stretch = Stretch.Uniform, + VerticalAlignment = VerticalAlignment.Center, + HorizontalAlignment = HorizontalAlignment.Center + }; + } + + private static void CollapseNativeDocumentViewerChrome(DocumentViewer viewer) + { + foreach (var toolbar in NativePreviewVisualDescendants(viewer)) + toolbar.Visibility = Visibility.Collapsed; + } + + private static IEnumerable NativePreviewVisualDescendants(DependencyObject root) + where T : DependencyObject + { + var count = VisualTreeHelper.GetChildrenCount(root); + for (var index = 0; index < count; index++) + { + var child = VisualTreeHelper.GetChild(root, index); + if (child is T typed) + yield return typed; + foreach (var nested in NativePreviewVisualDescendants(child)) + yield return nested; + } + } + + private void SaveNativeFatPreviewPdf( + Window owner, + NativeFatPrintPreviewSnapshot snapshot, + IoFatReportLayoutPlan layout) + { + var dialog = new SaveFileDialog + { + Title = "Save IEC 61850 FAT Evidence Report", + Filter = "PDF document (*.pdf)|*.pdf", + AddExtension = true, + DefaultExt = ".pdf", + OverwritePrompt = true, + FileName = BuildNativeFatPdfFileName(snapshot.IedName) + }; + + if (dialog.ShowDialog(owner) != true) + return; + + try + { + var primaryReference = snapshot.Rows + .Select(row => row.IecTelegram) + .FirstOrDefault(reference => !string.IsNullOrWhiteSpace(reference)) + ?? snapshot.DeviceId; + + // Critical P4D invariant: serialize the exact layout already rendered above. + IoFatPdfReportService.SaveLayout( + dialog.FileName, + layout, + snapshot.IedName, + primaryReference); + SetStatus($"FAT · PDF saved · {dialog.FileName}"); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) + { + SetStatus($"FAT · PDF save failed · {ex.Message}"); + MessageBox.Show( + owner, + ex.Message, + "Save PDF", + MessageBoxButton.OK, + MessageBoxImage.Error); + } + } + + private static string BuildNativeFatPdfFileName(string? iedName) + { + var source = string.IsNullOrWhiteSpace(iedName) ? "IED" : iedName.Trim(); + var invalid = System.IO.Path.GetInvalidFileNameChars(); + var safe = new string(source.Select(ch => invalid.Contains(ch) ? '_' : ch).ToArray()).Trim(); + if (safe.Length == 0) + safe = "IED"; + return $"{safe}-FAT-Evidence.pdf"; + } +} diff --git a/MainWindow.NavigationLayoutFix.cs b/MainWindow.NavigationLayoutFix.cs index 55e5b0f0b..2051bf249 100644 --- a/MainWindow.NavigationLayoutFix.cs +++ b/MainWindow.NavigationLayoutFix.cs @@ -12,8 +12,8 @@ namespace ArIED61850Tester; /// /// The original XAML used a 760 px shell split into seven equal columns while the /// selection pill moved in hard-coded 150 px steps. That was barely large enough for -/// short labels and clipped "IEC 61850 Explorer" / "GOOSE Subscriber" once the center -/// workspace switch and live connection/status chips were also present. This behavior +/// short labels and clipped "IEC 61850 Explorer" / "GOOSE Subscriber" once connection +/// and status chips were also present. This behavior /// keeps the header single-line at normal desktop sizes, deliberately compacts labels /// at smaller widths, and derives the selection pill from the real nav cell width. /// @@ -62,7 +62,7 @@ private static void OnMainWindowLoaded(object sender, RoutedEventArgs e) if (sender is not MainWindow window) return; - // Loaded may be raised again after window hide/show (e.g. IO List FAT switch). + // Loaded may be raised again after a window hide/show lifecycle. // Remove first so the responsive hooks always exist exactly once. window.SizeChanged -= MainWindow_SizeChanged; window.SizeChanged += MainWindow_SizeChanged; @@ -76,9 +76,8 @@ private static void OnMainWindowLoaded(object sender, RoutedEventArgs e) ApplyResponsiveLayout(window); QueuePillCorrection(window, animate: false); - // WorkspaceModeSwitch is installed by a separate Loaded class handler. Run one - // deferred pass so its dynamically inserted controls are included regardless of - // module/class-handler registration order. + // Run one deferred pass after all Loaded handlers have finished so the final + // navigation geometry is based on the materialized header. window.Dispatcher.BeginInvoke( DispatcherPriority.ContextIdle, new Action(() => @@ -111,19 +110,6 @@ private static void MainWindow_SizeChanged(object sender, SizeChangedEventArgs e QueuePillCorrection(window, animate: false); } - private static void WorkspaceModeChild_SizeChanged(object sender, SizeChangedEventArgs e) - { - if (sender is not FrameworkElement element || Window.GetWindow(element) is not MainWindow window) - return; - - // The FAT button can change to "... LOADED" after the window is already shown. - // Re-apply the current breakpoint so that state text is compacted intentionally - // instead of making the top bar overflow. - window.Dispatcher.BeginInvoke( - DispatcherPriority.Loaded, - new Action(() => ApplyResponsiveLayout(window))); - } - private static void MainTabs_SelectionChanged(object sender, SelectionChangedEventArgs e) { if (sender is not TabControl tabs || !ReferenceEquals(e.Source, tabs)) @@ -184,7 +170,6 @@ private static void ApplyResponsiveLayout(MainWindow window) } UpdatePillGeometry(window, shellWidth); - ApplyWorkspaceSwitchDensity(window, wide, medium); } private static Button?[] GetNavigationButtons(MainWindow window) @@ -215,47 +200,6 @@ private static void UpdatePillGeometry(MainWindow window, double shellWidth) pill.ClipToBounds = false; } - private static void ApplyWorkspaceSwitchDensity(MainWindow window, bool wide, bool medium) - { - // WorkspaceModeSwitch is inserted dynamically into header column 1. At wide - // desktop widths retain the descriptive labels. At compact widths reduce only - // those redundant mode labels; the actual workspace functions remain present. - if (window.Content is not Grid root) - return; - - var header = root.Children.OfType().FirstOrDefault(child => Grid.GetRow(child) == 0); - if (header == null) - return; - - var modeShell = header.Children.OfType() - .FirstOrDefault(child => Equals(child.Tag, "ARSAS_WORKSPACE_MODE_SWITCH")) as Border; - if (modeShell?.Child is not StackPanel modes) - return; - - modeShell.Margin = new Thickness(wide ? 10 : 6, 0, wide ? 10 : 6, 0); - - if (modes.Children.Count > 0 && modes.Children[0] is Border engineering && - engineering.Child is TextBlock engineeringText) - { - engineeringText.Text = medium ? "ENGINEERING" : "ENG"; - engineering.Padding = new Thickness(medium ? 12 : 9, 7, medium ? 12 : 9, 7); - } - - if (modes.Children.Count > 1 && modes.Children[1] is Button fatButton) - { - fatButton.SizeChanged -= WorkspaceModeChild_SizeChanged; - fatButton.SizeChanged += WorkspaceModeChild_SizeChanged; - - // Do not overwrite the LOADED state used by WorkspaceModeSwitch; compact it - // while preserving that state signal. - var loaded = fatButton.Content?.ToString()?.Contains("LOADED", StringComparison.OrdinalIgnoreCase) == true; - fatButton.Content = medium - ? loaded ? "IO LIST FAT · LOADED" : "IO LIST FAT" - : loaded ? "FAT · LOADED" : "FAT"; - fatButton.Padding = new Thickness(medium ? 12 : 9, 7, medium ? 12 : 9, 7); - } - } - private static void QueuePillCorrection(MainWindow window, bool animate) { window.Dispatcher.BeginInvoke( diff --git a/MainWindow.PersistentWorkbench.cs b/MainWindow.PersistentWorkbench.cs index 6ba326562..0f95df6bc 100644 --- a/MainWindow.PersistentWorkbench.cs +++ b/MainWindow.PersistentWorkbench.cs @@ -222,14 +222,14 @@ private TextBlock DecorateP0CommandDockHeader() Text = "TARGET · NONE", FontSize = 10.2, FontWeight = FontWeights.SemiBold, - Foreground = new SolidColorBrush(Color.FromRgb(0x58, 0x6B, 0x82)), + Foreground = MainWindowFieldPresentationFix.CommandTargetForegroundBrush, VerticalAlignment = VerticalAlignment.Center }; var targetBadge = new Border { Tag = "P0CommandTargetBadge", - Background = new SolidColorBrush(Color.FromRgb(0xF4, 0xF7, 0xFB)), - BorderBrush = new SolidColorBrush(Color.FromRgb(0xD6, 0xE0, 0xEC)), + Background = MainWindowFieldPresentationFix.CommandTargetBackgroundBrush, + BorderBrush = MainWindowFieldPresentationFix.CommandTargetBorderBrush, BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(7), Padding = new Thickness(7, 3, 7, 3), diff --git a/MainWindow.ProductionFatEngineeringBootstrap.cs b/MainWindow.ProductionFatEngineeringBootstrap.cs deleted file mode 100644 index 28ef4686d..000000000 --- a/MainWindow.ProductionFatEngineeringBootstrap.cs +++ /dev/null @@ -1,217 +0,0 @@ -using System.ComponentModel; -using System.Runtime.CompilerServices; -using System.Text.Json; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Threading; -using ArIED61850Tester.Services.IoTesting; - -namespace ArIED61850Tester; - -/// -/// Makes the embedded production FAT workspace a projection of the Engineering workspace. -/// Opening SCL in Explorer is therefore sufficient: selecting FAT reuses the already-parsed -/// ARIEC SCL/static DataSet authority and the existing Engineering acquisition session. -/// -public partial class MainWindow -{ - private bool _productionFatEngineeringBootstrapInstalled; - private bool _productionFatEngineeringBootstrapBusy; - private CancellationTokenSource? _productionFatEngineeringBootstrapCts; - - [ModuleInitializer] - internal static void RegisterProductionFatEngineeringBootstrap() - { - EventManager.RegisterClassHandler( - typeof(MainWindow), - FrameworkElement.LoadedEvent, - new RoutedEventHandler(ProductionFatEngineeringBootstrap_Loaded), - handledEventsToo: true); - } - - private static void ProductionFatEngineeringBootstrap_Loaded(object sender, RoutedEventArgs e) - { - if (sender is not MainWindow window || window._productionFatEngineeringBootstrapInstalled) - return; - - window._productionFatEngineeringBootstrapInstalled = true; - window.MainTabs.SelectionChanged += window.ProductionFatEngineeringBootstrap_SelectionChanged; - window.PropertyChanged += window.ProductionFatEngineeringBootstrap_PropertyChanged; - window.Closed += window.ProductionFatEngineeringBootstrap_Closed; - } - - private void ProductionFatEngineeringBootstrap_SelectionChanged(object sender, SelectionChangedEventArgs e) - { - if (!ReferenceEquals(e.Source, MainTabs) || MainTabs.SelectedIndex != NativeFatWorkspaceIndex) - return; - QueueProductionFatEngineeringBootstrap(); - } - - private void ProductionFatEngineeringBootstrap_PropertyChanged(object? sender, PropertyChangedEventArgs e) - { - if (e.PropertyName != nameof(SelectedDevice) || MainTabs.SelectedIndex != NativeFatWorkspaceIndex) - return; - - // A running production FAT session owns its latched IED. The normal embedded-host - // selection bridge handles idle retargeting. Bootstrap is only needed before a FAT - // workspace exists and only while the operator is actually entering FAT. - if (_productionFatWindow == null && _loadedIoFatWindow == null) - QueueProductionFatEngineeringBootstrap(); - } - - private void QueueProductionFatEngineeringBootstrap() - { - // FAT preparation must never run in the background while Engineering is connecting - // or monitoring. The shared Engineering acquisition session remains authoritative; - // clicking FAT is the only navigation event allowed to build the FAT projection. - if (!_productionFatEngineeringBootstrapInstalled || MainTabs.SelectedIndex != NativeFatWorkspaceIndex) - return; - - Dispatcher.BeginInvoke( - DispatcherPriority.ContextIdle, - new Action(async () => await EnsureProductionFatFromEngineeringAsync())); - } - - private async Task EnsureProductionFatFromEngineeringAsync() - { - if (_productionFatEngineeringBootstrapBusy || - MainTabs.SelectedIndex != NativeFatWorkspaceIndex || - !ProductionFatTabReady) - { - return; - } - - if (_productionFatWindow is { IsLoaded: true } || _loadedIoFatWindow is { IsLoaded: true }) - { - SynchronizeProductionFatSelectedIed(); - return; - } - - var selected = SelectedDevice; - if (selected?.SclWorkspace == null || - selected.SclWorkspace.DesignModel.DataSets.Sum(dataSet => dataSet.Members.Count) == 0) - { - var message = selected == null - ? "Select an Engineering IED with a static DataSet to prepare FAT." - : $"{selected.Name} has no static DataSet scope in the Engineering SCL model."; - ShowProductionFatBootstrapState(message, isBusy: false); - SetStatus(selected == null - ? "FAT · select an Engineering IED with a static DataSet." - : $"FAT · {selected.Name} has no static DataSet scope in the Engineering SCL model."); - return; - } - - var engineeringDevices = Devices - .Where(device => device.SclWorkspace != null) - .Where(device => device.SclWorkspace!.DesignModel.DataSets.Sum(dataSet => dataSet.Members.Count) > 0) - .Where(device => !string.IsNullOrWhiteSpace(device.SclSourcePath)) - .ToArray(); - if (engineeringDevices.All(device => !ReferenceEquals(device, selected))) - { - var message = $"Engineering source provenance for {selected.Name} is unavailable. Reopen the SCL source to restore FAT authority."; - ShowProductionFatBootstrapState(message, isBusy: false); - SetStatus($"FAT · Engineering source provenance for {selected.Name} is unavailable; use Open SCL to restore the source authority."); - return; - } - - _productionFatEngineeringBootstrapBusy = true; - _productionFatEngineeringBootstrapCts?.Cancel(); - _productionFatEngineeringBootstrapCts?.Dispose(); - _productionFatEngineeringBootstrapCts = CancellationTokenSource.CreateLinkedTokenSource(_applicationCancellation.Token); - var token = _productionFatEngineeringBootstrapCts.Token; - ShowProductionFatBootstrapState( - $"Reusing {selected.Name} from the Engineering static DataSet authority. No reconnect or SCL re-import is started.", - isBusy: true); - SetStatus($"FAT · preparing {selected.Name} from the Engineering static DataSet…"); - - try - { - var projection = await IoFatEngineeringWorkspaceProjectionService.BuildAsync( - engineeringDevices, - token); - token.ThrowIfCancellationRequested(); - - // Register the exact same ARIEC workspace instances already owned by Explorer. - // Production FAT preparation can therefore prove shared SCL authority without - // reparsing XML or starting a second model/acquisition stack. - _ioFatSclProjectImportService.AdoptEngineeringRuntimeWorkspaces(projection.RuntimeWorkspaces); - - // Projection already SHA-256-described the canonical Engineering SCL source set. - // Carry those exact identities through bootstrap/persistence instead of hashing - // the same files again. Staging still verifies every copied byte against SHA-256. - var launch = await IoTestWorkspaceBootstrapService.OpenDescribedSourcesAsync( - projection.Project, - projection.DescribedSources, - IoTestingProjectsRoot(), - IoTestingEvidenceRoot(), - CreateIoTestSession, - token); - token.ThrowIfCancellationRequested(); - - SynchronizeImportedSclFatWithEngineering(launch.Project); - - // This automatic entry path is explicitly Static DataSet FAT. Engineering may - // also expose selected scalar aliases outside the DataSet, and older saved P2 - // projects may contain scl-manual-* rows created from those aliases. Keep such - // rows/evidence in the project for audit continuity, but do not arm them in the - // shared workspace here. Otherwise a static member and its scalar alias can both - // resolve to the same live primary leaf and correctly trip session preflight. - var retiredManualRows = launch.Project.Ieds.Sum( - IoFatEngineeringSelectionBridge.RetireManualWorkspaceRowsForStaticDataSetMode); - if (retiredManualRows > 0) - { - AddLog( - "INFO", - "FAT", - $"Automatic Static DataSet scope retired {retiredManualRows} manual SCL workspace overlay(s); static membership remains authoritative."); - } - - RegisterSharedSclSourcePaths(launch.Project, launch.Project.Ieds, projection.SourceInputs); - foreach (var ied in launch.Project.Ieds) - { - var device = ResolveIoTestDevice(ied.LiveDeviceId) - ?? ResolveIoTestDevice(ied.IpAddress) - ?? ResolveIoTestDevice(ied.IedName); - if (device is not null) - PreserveSharedStaticDataSetAuthority(device); - } - - launch.Workspace.ScheduleSave(); - await ShowIoTestingWorkspaceAsync(launch, importWarningCount: 0); - SynchronizeProductionFatSelectedIed(); - SetStatus($"FAT ready · {selected.Name} · Engineering static DataSet authority reused · no SCL re-import."); - } - catch (OperationCanceledException) - { - // Fast navigation/close is normal. No modal interruption is appropriate here. - if (MainTabs.SelectedIndex == NativeFatWorkspaceIndex && _productionFatWindow == null) - { - ShowProductionFatBootstrapState( - "FAT preparation was cancelled. Select the FAT tab again to retry from the current Engineering authority.", - isBusy: false); - } - } - catch (Exception ex) when (ex is IOException or JsonException or InvalidDataException or UnauthorizedAccessException or ArgumentException or InvalidOperationException) - { - AddLog("WARN", "FAT", $"Automatic Engineering FAT bootstrap unavailable: {ex.Message}"); - ShowProductionFatBootstrapState( - $"FAT could not reuse the current Engineering static DataSet: {ex.Message}", - isBusy: false); - SetStatus($"FAT · could not reuse the Engineering static DataSet automatically: {ex.Message}"); - } - finally - { - _productionFatEngineeringBootstrapBusy = false; - } - } - - private void ProductionFatEngineeringBootstrap_Closed(object? sender, EventArgs e) - { - MainTabs.SelectionChanged -= ProductionFatEngineeringBootstrap_SelectionChanged; - PropertyChanged -= ProductionFatEngineeringBootstrap_PropertyChanged; - Closed -= ProductionFatEngineeringBootstrap_Closed; - _productionFatEngineeringBootstrapCts?.Cancel(); - _productionFatEngineeringBootstrapCts?.Dispose(); - _productionFatEngineeringBootstrapCts = null; - } -} diff --git a/MainWindow.ProductionFatNoFlicker.cs b/MainWindow.ProductionFatNoFlicker.cs deleted file mode 100644 index f346e68f2..000000000 --- a/MainWindow.ProductionFatNoFlicker.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System.Windows; - -namespace ArIED61850Tester; - -/// -/// P0 field fix for the embedded Engineering FAT transition. -/// -/// The legacy production FAT launcher owns a historical MainWindow.Hide() / child Show() -/// hand-off. That is still required by standalone compatibility flows, but it is wrong for -/// the automatic Engineering -> embedded FAT path because the FAT tab is already visible -/// and the child Window exists only as a hidden controller/lifecycle owner. Suppress that -/// single hide while the automatic embedded bootstrap is in flight so the user never sees -/// the desktop/black frame between two WPF windows. -/// -public partial class MainWindow -{ - public new void Hide() - { - if (ShouldKeepEngineeringVisibleDuringProductionFatBootstrap()) - return; - - base.Hide(); - } - - private bool ShouldKeepEngineeringVisibleDuringProductionFatBootstrap() - => _productionFatEngineeringBootstrapBusy && - ProductionFatTabReady; -} diff --git a/MainWindow.ProductionFatTab.cs b/MainWindow.ProductionFatTab.cs index f09a3ae39..156c1482e 100644 --- a/MainWindow.ProductionFatTab.cs +++ b/MainWindow.ProductionFatTab.cs @@ -1,22 +1,19 @@ using System.Runtime.CompilerServices; using System.Windows; using System.Windows.Controls; -using System.Windows.Media; using System.Windows.Threading; -using ArIED61850Tester.Models; namespace ArIED61850Tester; /// -/// Engineering FAT pivot: the permanent XAML FAT tab hosts the proven production -/// IoListTestingWindow workspace rather than a second/manual FAT implementation. -/// The global Engineering IED Explorer and shared Command Dock remain authoritative. +/// Engineering FAT pivot. The normal FAT destination is a native view over the exact +/// Engineering live-row collection. The legacy IoListTestingWindow host remains available +/// only for explicit/manual compatibility workflows and is never bootstrapped by navigation. /// public partial class MainWindow { private bool _productionFatTabInstalled; private IoListTestingWindow? _productionFatWindow; - private FrameworkElement? _productionFatSurface; private DispatcherTimer? _productionFatInstallRetry; internal bool ProductionFatTabReady => _productionFatTabInstalled && NativeFatTab != null; @@ -36,9 +33,6 @@ private static void ProductionFatTab_MainWindowLoaded(object sender, RoutedEvent if (sender is not MainWindow window || window._productionFatTabInstalled) return; - // MainWindow.Loaded runs after XAML has materialized the canonical seventh tab but - // before the first normal render. Install the permanent FAT shell immediately so the - // operator never sees an empty/black seventh workspace while idle-dispatcher work waits. window.TryInstallProductionFatTabPivot(); } @@ -47,8 +41,6 @@ private void TryInstallProductionFatTabPivot() if (_productionFatTabInstalled || !IsLoaded) return; - // M7: MainWindow.xaml is the sole owner of the seventh destination. Wait only - // until the canonical XAML tab is present; there is no native FAT runtime to install. if (MainTabs.Items.Count <= NativeFatWorkspaceIndex || !ReferenceEquals(MainTabs.Items[NativeFatWorkspaceIndex], NativeFatTab)) { @@ -66,12 +58,11 @@ private void TryInstallProductionFatTabPivot() _productionFatTabInstalled = true; NativeFatTab.Content = BuildProductionFatPermanentHost(); - // Bootstrap remains navigation-gated. Queueing here is harmless because the - // Engineering bootstrap itself refuses to build FAT unless the FAT tab is active. - QueueProductionFatEngineeringBootstrap(); + // P5 normal-entry boundary: installing/navigating FAT is only a canonical view bind. + // No Engineering -> IoTest projection/bootstrap module exists on this path. + SynchronizeProductionFatSelectedIed(); - // MainWindow.xaml owns both style and click routing for the seventh nav button. - NavNativeFatButton.ToolTip = "Production FAT workspace · automatic Value 1 / Value 2 evidence capture"; + NavNativeFatButton.ToolTip = "Factory Acceptance Test · canonical Engineering rows + sparse evidence"; PropertyChanged += ProductionFat_MainWindowPropertyChanged; MainTabs.SelectionChanged += ProductionFat_MainTabsSelectionChanged; @@ -86,79 +77,12 @@ private void ProductionFatInstallRetry_Tick(object? sender, EventArgs e) TryInstallProductionFatTabPivot(); } - private FrameworkElement BuildProductionFatPermanentHost( - string? statusText = null, - bool isBusy = false) + private FrameworkElement BuildProductionFatPermanentHost() { - var root = new Grid { Margin = new Thickness(0) }; - var card = new Border - { - MaxWidth = 520, - Padding = new Thickness(28, 24, 28, 22), - CornerRadius = new CornerRadius(18), - Background = TryFindResource("CardBackground") as Brush ?? Brushes.White, - BorderBrush = TryFindResource("CardBorder") as Brush ?? new SolidColorBrush(Color.FromRgb(220, 228, 239)), - BorderThickness = new Thickness(1), - HorizontalAlignment = HorizontalAlignment.Center, - VerticalAlignment = VerticalAlignment.Center - }; - var panel = new StackPanel - { - HorizontalAlignment = HorizontalAlignment.Stretch - }; - panel.Children.Add(new TextBlock - { - Text = "IEC 61850 FAT", - FontSize = 11, - FontWeight = FontWeights.SemiBold, - Foreground = TryFindResource("Accent") as Brush ?? Brushes.RoyalBlue, - HorizontalAlignment = HorizontalAlignment.Center - }); - panel.Children.Add(new TextBlock - { - Text = isBusy ? "Preparing production workspace" : "Production FAT workspace", - FontSize = 18, - FontWeight = FontWeights.SemiBold, - Foreground = TryFindResource("Ink") as Brush ?? Brushes.Black, - Margin = new Thickness(0, 5, 0, 0), - HorizontalAlignment = HorizontalAlignment.Center - }); - panel.Children.Add(new TextBlock - { - Text = string.IsNullOrWhiteSpace(statusText) - ? "Select an Engineering IED with static DataSet scope, then open FAT." - : statusText, - FontSize = 11.5, - Foreground = TryFindResource("Muted") as Brush ?? Brushes.DimGray, - TextAlignment = TextAlignment.Center, - TextWrapping = TextWrapping.Wrap, - Margin = new Thickness(0, 8, 0, 0) - }); - - if (isBusy) - { - panel.Children.Add(new ProgressBar - { - Height = 4, - IsIndeterminate = true, - BorderThickness = new Thickness(0), - Margin = new Thickness(0, 18, 0, 0), - Foreground = TryFindResource("Accent") as Brush ?? Brushes.RoyalBlue, - Background = new SolidColorBrush(Color.FromRgb(220, 231, 248)) - }); - } - - card.Child = panel; - root.Children.Add(card); - return root; - } - - internal void ShowProductionFatBootstrapState(string message, bool isBusy) - { - if (!ProductionFatTabReady || _productionFatWindow is { IsLoaded: true }) - return; - - NativeFatTab.Content = BuildProductionFatPermanentHost(message, isBusy); + var host = BuildNativeFatCanonicalWorkspace(); + InstallNativeFatEvidenceBindingRuntime(); + InstallNativeFatDiagnosticButtons(); + return host; } private void ProductionFat_MainTabsSelectionChanged(object sender, SelectionChangedEventArgs e) @@ -171,9 +95,11 @@ private void ProductionFat_MainTabsSelectionChanged(object sender, SelectionChan { SynchronizeProductionFatSelectedIed(); _productionFatWindow?.NotifyEmbeddedHostActivated(); + _nativeFatCanonicalGrid?.Focus(); } else { + SaveNativeFatSessionState(); _productionFatWindow?.Storage?.ScheduleSave(); } } @@ -185,8 +111,20 @@ private void ProductionFat_MainWindowPropertyChanged(object? sender, System.Comp } private void SynchronizeProductionFatSelectedIed() - => _productionFatWindow?.SelectEngineeringDeviceForEmbeddedFat(SelectedDevice); + { + if (_productionFatWindow is { IsLoaded: true }) + { + _productionFatWindow.SelectEngineeringDeviceForEmbeddedFat(SelectedDevice); + return; + } + + BindNativeFatCanonicalRows(); + BindNativeFatDiagnostics(SelectedDevice); + RefreshNativeFatEvidenceBindingRuntime(); + } + // Compatibility host contract: the global Engineering IED Explorer and shared Command Dock remain authoritative. + // Mounting the explicit/manual IoListTestingWindow center must never replace those workstation shell owners. internal bool MountProductionFatWorkspace(IoListTestingWindow window, FrameworkElement surface) { ArgumentNullException.ThrowIfNull(window); @@ -194,8 +132,8 @@ internal bool MountProductionFatWorkspace(IoListTestingWindow window, FrameworkE if (!ProductionFatTabReady) return false; + SaveNativeFatSessionState(); _productionFatWindow = window; - _productionFatSurface = surface; surface.DataContext = window; NativeFatTab.Content = surface; if (_persistentWorkbench != null) @@ -205,13 +143,11 @@ internal bool MountProductionFatWorkspace(IoListTestingWindow window, FrameworkE window.Closed += ProductionFatWindow_Closed; SynchronizeProductionFatSelectedIed(); - // Passive mount: prewarming must never navigate, hide/show, activate, or steal - // focus from the operator's current Engineering destination. window.RegisterEmbeddedHostCloseCleanup(); QueueNativeFatNavigationGeometry(); if (MainTabs.SelectedIndex == NativeFatWorkspaceIndex) - SetStatus($"FAT ready in Engineering tab · {window.Project.Ieds.Count} IED · production auto-capture workflow."); + SetStatus($"FAT compatibility workspace · {window.Project.Ieds.Count} IED."); return true; } @@ -222,8 +158,8 @@ internal void UnmountProductionFatWorkspace(IoListTestingWindow window) window.Closed -= ProductionFatWindow_Closed; _productionFatWindow = null; - _productionFatSurface = null; NativeFatTab.Content = BuildProductionFatPermanentHost(); + SynchronizeProductionFatSelectedIed(); } private void ProductionFatWindow_Closed(object? sender, EventArgs e) @@ -234,12 +170,26 @@ private void ProductionFatWindow_Closed(object? sender, EventArgs e) private void ProductionFat_MainWindowClosed(object? sender, EventArgs e) { + // Persistence is fail-closed: write the latest sparse snapshot before any debounce + // cancellation or service disposal can discard the final FAT transition. + FlushNativeFatEvidenceBeforeShutdown(); + PropertyChanged -= ProductionFat_MainWindowPropertyChanged; MainTabs.SelectionChanged -= ProductionFat_MainTabsSelectionChanged; Closed -= ProductionFat_MainWindowClosed; _productionFatInstallRetry?.Stop(); _productionFatInstallRetry = null; _productionFatWindow = null; - _productionFatSurface = null; + if (_nativeFatCanonicalGrid != null) + _nativeFatCanonicalGrid.CellEditEnding -= NativeFatCanonicalGrid_CellEditEnding; + DisposeNativeFatEvidenceBindingRuntime(); + DisposeNativeFatDiagnostics(); + DisposeNativeFatArmCoordinator(); + _nativeFatCanonicalGrid = null; + _nativeFatIedText = null; + _nativeFatRowCountText = null; + _nativeFatStatusText = null; + _nativeFatSessionByIed.Clear(); + _nativeFatBoundIedKey = null; } } diff --git a/MainWindow.SharedSclWorkspace.cs b/MainWindow.SharedSclWorkspace.cs index 1fcf55dd0..7be2401f6 100644 --- a/MainWindow.SharedSclWorkspace.cs +++ b/MainWindow.SharedSclWorkspace.cs @@ -99,10 +99,10 @@ private void ApplyStaticDataSetSelection(Iec61850MonitorDevice device) LogStaticDataSetReportFeasibility(device); _ = ObserveInitialStaticReportEvidenceAsync(device); - // M2 permanent FAT host: authority establishment is also a readiness trigger. - // This covers SCL refresh on the same SelectedDevice, where PropertyChanged for - // SelectedDevice would otherwise not fire. - QueueProductionFatEngineeringBootstrap(); + // P5 native FAT is a thin view over SelectedDevice.Points. Re-synchronize after + // static DataSet authority refresh so same-IED SCL refreshes are visible without + // reviving the retired Engineering -> legacy IoTest bootstrap. + SynchronizeProductionFatSelectedIed(); } private void ClearSharedSignalSelection(Iec61850MonitorDevice device) @@ -240,4 +240,4 @@ await OpenSignalSelectionWizardAsync( MarkSharedSelectionAuthority(device); } } -} \ No newline at end of file +} diff --git a/MainWindow.WorkspaceModeSwitch.cs b/MainWindow.WorkspaceModeSwitch.cs index 71983c289..6ab35c25b 100644 --- a/MainWindow.WorkspaceModeSwitch.cs +++ b/MainWindow.WorkspaceModeSwitch.cs @@ -2,110 +2,14 @@ // SPDX-License-Identifier: Apache-2.0 using System.Windows; -using System.Windows.Controls; -using System.Windows.Controls.Primitives; -using System.Windows.Input; -using System.Windows.Media; using System.Windows.Threading; namespace ArIED61850Tester; public partial class MainWindow { - private const string WorkspaceModeSwitchTag = "ARSAS_WORKSPACE_MODE_SWITCH"; - private static readonly bool WorkspaceModeSwitchRegistered = RegisterWorkspaceModeSwitch(); - private Button? _workspaceFatButton; private IoListTestingWindow? _loadedIoFatWindow; - private static bool RegisterWorkspaceModeSwitch() - { - EventManager.RegisterClassHandler( - typeof(MainWindow), - FrameworkElement.LoadedEvent, - new RoutedEventHandler(WorkspaceModeSwitch_Loaded)); - return true; - } - - private static void WorkspaceModeSwitch_Loaded(object sender, RoutedEventArgs e) - { - if (sender is MainWindow window) - window.InstallWorkspaceModeSwitch(); - } - - private void InstallWorkspaceModeSwitch() - { - if (Content is not Grid root) - return; - - var header = root.Children.OfType().FirstOrDefault(child => Grid.GetRow(child) == 0); - if (header == null || header.Children.OfType() - .Any(child => Equals(child.Tag, WorkspaceModeSwitchTag))) - return; - - var shell = new Border - { - Tag = WorkspaceModeSwitchTag, - Background = WorkspaceBrush("#E7ECF5"), - BorderBrush = WorkspaceBrush("#D5DEEB"), - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(16), - Padding = new Thickness(4), - Margin = new Thickness(10, 0, 10, 0), - HorizontalAlignment = HorizontalAlignment.Right, - VerticalAlignment = VerticalAlignment.Center, - ToolTip = "Switch between Engineering and IO List FAT workspaces" - }; - Grid.SetColumn(shell, 1); - - var modes = new StackPanel { Orientation = Orientation.Horizontal }; - modes.Children.Add(new Border - { - Background = TryFindResource("Accent") as Brush ?? WorkspaceBrush("#2563EB"), - CornerRadius = new CornerRadius(12), - Padding = new Thickness(12, 7, 12, 7), - Child = new TextBlock - { - Text = "ENGINEERING", - Foreground = Brushes.White, - FontSize = 10.5, - FontWeight = FontWeights.Bold, - VerticalAlignment = VerticalAlignment.Center - } - }); - - _workspaceFatButton = new Button - { - Content = "IO LIST FAT", - Style = TryFindResource("SoftButton") as Style, - Padding = new Thickness(12, 7, 12, 7), - Margin = new Thickness(4, 0, 0, 0), - FontSize = 10.5, - FontWeight = FontWeights.Bold, - Cursor = Cursors.Hand, - ToolTip = "Open or return to the IO List FAT workspace" - }; - _workspaceFatButton.Click += OpenOrResumeIoFatWorkspace_Click; - modes.Children.Add(_workspaceFatButton); - - var menuButton = new Button - { - Content = "▾", - Style = TryFindResource("SoftButton") as Style, - Padding = new Thickness(8, 7, 8, 7), - Margin = new Thickness(2, 0, 0, 0), - FontSize = 10.5, - FontWeight = FontWeights.Bold, - Cursor = Cursors.Hand, - ToolTip = "Load SCL/CID, IO List workbook, or portable ARSAS project" - }; - menuButton.Click += OpenIoFatWorkspaceMenu_Click; - modes.Children.Add(menuButton); - - shell.Child = modes; - header.Children.Add(shell); - UpdateIoFatWorkspaceModeState(); - } - internal void RegisterLoadedIoFatWindow(IoListTestingWindow window) { ArgumentNullException.ThrowIfNull(window); @@ -117,7 +21,6 @@ internal void RegisterLoadedIoFatWindow(IoListTestingWindow window) _loadedIoFatWindow = window; _loadedIoFatWindow.Closed += LoadedIoFatWindow_Closed; - UpdateIoFatWorkspaceModeState(); } internal void ShowEngineeringWorkspaceFromFat(IoListTestingWindow window) @@ -133,114 +36,6 @@ internal void ShowEngineeringWorkspaceFromFat(IoListTestingWindow window) WindowState = WindowState.Normal; Activate(); SetStatus($"Engineering workspace active · IO List FAT project '{window.Project.ProjectName}' remains loaded."); - UpdateIoFatWorkspaceModeState(); - } - - private async void OpenOrResumeIoFatWorkspace_Click(object sender, RoutedEventArgs e) - { - if (ShowLoadedIoFatWorkspace()) - return; - - // Engineering and FAT are two views over the same imported SCL workspace. If an - // Engineering SCL is already open, the primary FAT mode button projects that exact - // source and its existing checkbox authority without asking for another import. - var sharedSources = CurrentEngineeringSclSourcePaths(); - if (sharedSources.Length > 0) - { - await OpenSclFatSourcesAsync(sharedSources, selectionMode: null); - return; - } - - if (sender is Button anchor) - OpenIoFatWorkspaceMenu(anchor); - } - - private bool ShowLoadedIoFatWorkspace() - { - var window = _loadedIoFatWindow; - if (window == null || !window.IsLoaded) - return false; - - SetStatus($"Returning to loaded IO List FAT project '{window.Project.ProjectName}'."); - IsEnabled = false; - Hide(); - window.Show(); - if (window.WindowState == WindowState.Minimized) - window.WindowState = WindowState.Normal; - window.Activate(); - return true; - } - - private void OpenIoFatWorkspaceMenu_Click(object sender, RoutedEventArgs e) - { - if (sender is Button anchor) - OpenIoFatWorkspaceMenu(anchor); - } - - private void OpenIoFatWorkspaceMenu(Button anchor) - { - var menu = new ContextMenu - { - PlacementTarget = anchor, - Placement = PlacementMode.Bottom, - VerticalOffset = 5, - StaysOpen = false - }; - - if (_loadedIoFatWindow is { IsLoaded: true } loaded) - { - var resume = new MenuItem - { - Header = $"Continue loaded FAT project · {loaded.Project.ProjectName}", - FontWeight = FontWeights.SemiBold - }; - resume.Click += (_, _) => ShowLoadedIoFatWorkspace(); - menu.Items.Add(resume); - menu.Items.Add(new Separator()); - } - - var importScl = new MenuItem - { - Header = _loadedIoFatWindow is { IsLoaded: true } - ? "Add SCL / CID to loaded FAT workspace" - : "Import SCL / CID files" - }; - importScl.Click += (_, _) => - { - if (_loadedIoFatWindow is { IsLoaded: true } loaded) - { - // P0.4: SCL is additive while a FAT workspace is loaded. Existing IED - // connections/session evidence stay alive; replacement is reserved for - // explicit workbook/project open flows below. - _ = OpenSclForLoadedFatAppendAsync(loaded); - return; - } - - OpenSclFatTesting_Click(this, new RoutedEventArgs()); - }; - - var importWorkbook = new MenuItem - { - Header = _loadedIoFatWindow == null - ? "Import IO List Excel workbook" - : "Import another IO List Excel workbook" - }; - importWorkbook.Click += (_, _) => QueueIoFatWorkspaceReplacement( - () => OpenIoListTesting_Click(this, new RoutedEventArgs())); - - var openProject = new MenuItem - { - Header = _loadedIoFatWindow == null - ? "Open portable .arsas project" - : "Open another portable .arsas project" - }; - openProject.Click += (_, _) => QueueIoFatWorkspaceReplacement( - () => OpenIoListPackage_Click(this, new RoutedEventArgs())); - - menu.Items.Add(importScl); - menu.Items.Add(importWorkbook); - menu.Items.Add(openProject); - menu.IsOpen = true; } private void QueueIoFatWorkspaceReplacement(Action openReplacement) @@ -277,25 +72,5 @@ private void LoadedIoFatWindow_Closed(object? sender, EventArgs e) if (ReferenceEquals(_loadedIoFatWindow, sender)) _loadedIoFatWindow = null; IsEnabled = true; - UpdateIoFatWorkspaceModeState(); - } - - private void UpdateIoFatWorkspaceModeState() - { - if (_workspaceFatButton == null) - return; - - var loaded = _loadedIoFatWindow is { IsLoaded: true }; - _workspaceFatButton.Content = loaded ? "IO LIST FAT · LOADED" : "IO LIST FAT"; - _workspaceFatButton.ToolTip = loaded - ? "Return instantly to the loaded IO List FAT workspace" - : "Open an IO List FAT workbook or portable ARSAS project"; - } - - private static SolidColorBrush WorkspaceBrush(string hex) - { - var brush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(hex)); - brush.Freeze(); - return brush; } } diff --git a/Models/NativeFatEvidenceOverlayState.cs b/Models/NativeFatEvidenceOverlayState.cs new file mode 100644 index 000000000..408fef61e --- /dev/null +++ b/Models/NativeFatEvidenceOverlayState.cs @@ -0,0 +1,52 @@ +using ArIED61850Tester.Models.IoTesting; + +namespace ArIED61850Tester.Models; + +/// +/// FAT-only evidence payload layered over one canonical Engineering row. +/// Value1/Value2 keep their raw text for backwards compatibility while the structured +/// FatValueEvidence objects preserve timestamp, quality, acquisition source and sequence. +/// +public sealed class NativeFatEvidenceSlotState +{ + public string Value1 { get; set; } = string.Empty; + public string Value2 { get; set; } = string.Empty; + public FatValueEvidence? Value1Evidence { get; set; } + public FatValueEvidence? Value2Evidence { get; set; } + public string Result { get; set; } = string.Empty; +} + +public enum NativeFatEvidenceHydrationState +{ + NotStarted, + Hydrating, + Resolved, + Failed +} + +/// +/// Per-IED UI/evidence state. EvidenceByRow is sparse and keyed by stable +/// IEDName + IEC Telegram identity; it is not a second signal/row collection. +/// +public sealed class NativeFatIedSessionCacheState +{ + public string? ActiveRowKey { get; set; } + public int LastScrollIndex { get; set; } + public bool IsArmed { get; set; } + public DateTimeOffset? ArmedAt { get; set; } + public long LastArmElapsedMilliseconds { get; set; } + + // P2 evidence hydration is deliberately independent from canonical row binding. + // Engineering rows render immediately; only the three sparse evidence columns wait. + public NativeFatEvidenceHydrationState EvidenceHydrationState { get; set; } = + NativeFatEvidenceHydrationState.NotStarted; + public long EvidenceHydrationGeneration { get; set; } + public DateTimeOffset? EvidenceHydratedAt { get; set; } + public string EvidenceHydrationError { get; set; } = string.Empty; + public long LastHydrationElapsedMilliseconds { get; set; } + public bool IsEvidenceHydrating => + EvidenceHydrationState == NativeFatEvidenceHydrationState.Hydrating; + + public Dictionary EvidenceByRow { get; } = + new(StringComparer.OrdinalIgnoreCase); +} diff --git a/Properties/InternalsVisibleTo.cs b/Properties/InternalsVisibleTo.cs new file mode 100644 index 000000000..63d6d0efe --- /dev/null +++ b/Properties/InternalsVisibleTo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("ARSAS.Tests")] diff --git a/Services/IoTesting/IoFatEngineeringWorkspaceProjectionService.cs b/Services/IoTesting/IoFatEngineeringWorkspaceProjectionService.cs deleted file mode 100644 index 81eb06dd3..000000000 --- a/Services/IoTesting/IoFatEngineeringWorkspaceProjectionService.cs +++ /dev/null @@ -1,303 +0,0 @@ -using AR.Iec61850.Scl.Workspace; -using ArIED61850Tester.Models; -using ArIED61850Tester.Models.IoTesting; - -namespace ArIED61850Tester.Services.IoTesting; - -public sealed record IoFatEngineeringWorkspaceProjection( - IoTestProject Project, - IReadOnlyList SourceInputs, - IReadOnlyList DescribedSources, - IReadOnlyList RuntimeWorkspaces); - -/// -/// Builds the production FAT project directly from the already-parsed Engineering SCL -/// workspaces. This is deliberately not an SCL importer: it never opens/parses XML and it -/// never creates a second IEC 61850 model. Engineering remains the static DataSet/live-value -/// authority; FAT adds only its production evidence/session lifecycle on top. -/// -public static class IoFatEngineeringWorkspaceProjectionService -{ - public static async Task BuildAsync( - IReadOnlyCollection devices, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(devices); - - var usable = devices - .Where(device => device.SclWorkspace != null) - .Where(device => device.SclWorkspace!.DesignModel.DataSets.Sum(dataSet => dataSet.Members.Count) > 0) - .Where(device => !string.IsNullOrWhiteSpace(device.SclSourcePath)) - .GroupBy(device => device.DeviceId, StringComparer.OrdinalIgnoreCase) - .Select(group => group.Last()) - .ToArray(); - if (usable.Length == 0) - { - throw new InvalidDataException( - "No Engineering IED has an already-parsed SCL workspace with static DataSet members and source provenance."); - } - - // Describe every candidate source once so conflicting Engineering authorities are - // detected before canonicalization. The resulting canonical source set is then used - // for the FAT projection/staging path so duplicate Explorer entries do not multiply - // static DataSet rows or repeat downstream workspace work. - var candidateSourceInputs = usable - .Select(device => Path.GetFullPath(device.SclSourcePath)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .Select(path => new IoFatSourceInput(path, IoFatSourceKinds.Scl)) - .ToArray(); - var described = await IoFatSourceWorkspaceService.DescribeAsync(candidateSourceInputs, cancellationToken) - .ConfigureAwait(false); - var descriptorByPath = described.ToDictionary( - item => Path.GetFullPath(item.OriginalPath), - item => item.Source, - StringComparer.OrdinalIgnoreCase); - - foreach (var device in usable) - { - cancellationToken.ThrowIfCancellationRequested(); - var path = Path.GetFullPath(device.SclSourcePath); - if (!descriptorByPath.TryGetValue(path, out var descriptor)) - throw new InvalidDataException($"Engineering SCL provenance is unavailable for '{device.Name}'."); - if (!string.IsNullOrWhiteSpace(device.SclSourceSha256) && - !descriptor.Sha256.Equals(device.SclSourceSha256, StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidDataException( - $"The Engineering SCL source for '{device.Name}' changed on disk after it was parsed. FAT will not bind a stale in-memory model to different source bytes."); - } - } - - var canonicalDevices = CanonicalizeEngineeringDevices(usable, descriptorByPath); - var sourceInputs = canonicalDevices - .Select(device => Path.GetFullPath(device.SclSourcePath)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .Select(path => new IoFatSourceInput(path, IoFatSourceKinds.Scl)) - .ToArray(); - var canonicalDescribedSources = sourceInputs - .Select(input => - { - var path = Path.GetFullPath(input.FilePath); - return new IoFatDescribedSource(descriptorByPath[path], path); - }) - .ToArray(); - - var workspaceSources = canonicalDevices - .Select(device => - { - var descriptor = descriptorByPath[Path.GetFullPath(device.SclSourcePath)]; - return new FatSclWorkspaceSource( - descriptor.FileName, - descriptor.Sha256, - device.SclWorkspace!); - }) - .ToArray(); - var verification = FatSclWorkspaceImportService.Import(workspaceSources).Project; - - var deviceByWorkspace = canonicalDevices - .ToDictionary( - device => WorkspaceIdentity(device.SclWorkspace!), - device => device, - StringComparer.OrdinalIgnoreCase); - var descriptorByWorkspace = canonicalDevices - .ToDictionary( - device => WorkspaceIdentity(device.SclWorkspace!), - device => descriptorByPath[Path.GetFullPath(device.SclSourcePath)], - StringComparer.OrdinalIgnoreCase); - - var plans = new List(); - foreach (var workspaceGroup in workspaceSources - .GroupBy(source => WorkspaceIdentity(source.Workspace), StringComparer.OrdinalIgnoreCase) - .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase)) - { - var workspace = workspaceGroup.First().Workspace; - var key = WorkspaceIdentity(workspace); - var device = deviceByWorkspace[key]; - var descriptor = descriptorByWorkspace[key]; - var signals = verification.Signals - .Where(signal => WorkspaceIdentity(signal.IedName, signal.AccessPointName) - .Equals(key, StringComparison.OrdinalIgnoreCase)) - .OrderBy(signal => signal.DataSetReference, StringComparer.OrdinalIgnoreCase) - .ThenBy(signal => signal.DataSetMemberIndex) - .ToArray(); - - var endpoint = !string.IsNullOrWhiteSpace(device.IpAddress) - ? device.IpAddress - : workspace.PreferredEndpoint?.HasUsableAddress == true - ? workspace.PreferredEndpoint.IpAddress - : string.Empty; - var plan = new IoTestIedPlan - { - IedName = workspace.IedName, - IpAddress = endpoint, - IedRole = FirstNonEmpty(workspace.IedType, workspace.Manufacturer), - TestPoints = signals.Select(signal => ToPointPlan(signal, descriptor, workspace, endpoint)).ToList() - }; - plan.ApplyLiveDeviceBinding( - device.DeviceId, - device.IsMonitoring ? "Engineering acquisition active" : device.IsConnected ? "Engineering association ready" : "Engineering SCL model ready", - device.IsConnected, - device.IsMonitoring); - plans.Add(plan); - } - - var sourceDescriptors = canonicalDevices - .Select(device => descriptorByPath[Path.GetFullPath(device.SclSourcePath)]) - .GroupBy(source => source.SourceId, StringComparer.OrdinalIgnoreCase) - .Select(group => group.First()) - .OrderBy(source => source.SourceId, StringComparer.Ordinal) - .ToArray(); - var sourceFingerprint = IoFatSourceIdentity.ComputeSetFingerprint(sourceDescriptors); - var project = new IoTestProject - { - ProjectId = "FAT-SCL-" + sourceFingerprint[..16], - SchemaVersion = "ARSAS-FAT-SCL-1.0", - ProjectName = sourceDescriptors.Length == 1 - ? Path.GetFileNameWithoutExtension(sourceDescriptors[0].FileName) + " FAT" - : $"IEC 61850 SCL FAT ({sourceDescriptors.Length} sources)", - DocumentControl = new IoFatDocumentControl - { - DocumentTitle = "IEC 61850 FAT", - SourceDocumentName = string.Join("; ", sourceDescriptors.Select(source => source.FileName)) - }, - Ieds = plans - }; - project.SetSources(sourceDescriptors, sourceFingerprint); - project.InitializeRuntimeNotifications(); - - var staticMemberCount = canonicalDevices.Sum(device => - device.SclWorkspace!.DesignModel.DataSets.Sum(dataSet => dataSet.Members.Count)); - if (project.SignalCount != verification.Signals.Count || project.SignalCount != staticMemberCount) - { - throw new InvalidDataException( - $"Engineering FAT projection produced {project.SignalCount} row(s), but the authoritative static DataSet scope contains {staticMemberCount}. FAT refuses a partial projection."); - } - - return new IoFatEngineeringWorkspaceProjection( - project, - sourceInputs, - canonicalDescribedSources, - canonicalDevices.Select(device => device.SclWorkspace!).ToArray()); - } - - private static IReadOnlyList CanonicalizeEngineeringDevices( - IReadOnlyCollection devices, - IReadOnlyDictionary descriptorByPath) - { - var canonical = new List(); - foreach (var identityGroup in devices - .GroupBy(device => WorkspaceIdentity(device.SclWorkspace!), StringComparer.OrdinalIgnoreCase) - .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase)) - { - var candidates = identityGroup - .Select(device => - { - var path = Path.GetFullPath(device.SclSourcePath); - return new - { - Device = device, - Path = path, - Descriptor = descriptorByPath[path] - }; - }) - .ToArray(); - - var distinctHashes = candidates - .Select(candidate => candidate.Descriptor.Sha256) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - if (distinctHashes.Length > 1) - { - throw new InvalidDataException( - $"Conflicting Engineering SCL sources define the same IED/AccessPoint '{identityGroup.Key}'. " + - "FAT will not silently merge competing static DataSet authorities."); - } - - var distinctEndpoints = candidates - .Select(candidate => candidate.Device.IpAddress?.Trim() ?? string.Empty) - .Where(endpoint => !string.IsNullOrWhiteSpace(endpoint)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - if (distinctEndpoints.Length > 1) - { - throw new InvalidDataException( - $"Engineering exposes IED/AccessPoint '{identityGroup.Key}' through multiple endpoints ({string.Join(", ", distinctEndpoints)}). " + - "FAT will not guess which physical IED owns the evidence session."); - } - - // Exact same-content duplicates are already harmless according to the lower SCL - // importer contract. Collapse them here as well so projection counts, runtime - // workspaces and staged source files all share one canonical authority. Prefer the - // currently monitoring/connected Engineering device so FAT remains attached to the - // live session that the operator is already using. - var selected = candidates - .OrderByDescending(candidate => candidate.Device.IsMonitoring) - .ThenByDescending(candidate => candidate.Device.IsConnected) - .ThenBy(candidate => candidate.Descriptor.FileName, StringComparer.OrdinalIgnoreCase) - .ThenBy(candidate => candidate.Path, StringComparer.OrdinalIgnoreCase) - .ThenBy(candidate => candidate.Device.DeviceId, StringComparer.OrdinalIgnoreCase) - .First(); - canonical.Add(selected.Device); - } - - return canonical; - } - - private static IoTestPointPlan ToPointPlan( - FatVerificationSignal signal, - IoFatSourceDescriptor source, - SclIedWorkspace workspace, - string endpoint) - { - var discrete = signal.SignalKind == FatSignalKind.Discrete; - return new IoTestPointPlan - { - TestPointId = $"scl-{source.SourceId}-{signal.SignalId}", - IedName = signal.IedName, - IpAddress = endpoint, - SignalName = signal.SignalName, - ObjectReference = FirstNonEmpty(signal.RuntimeReference, signal.StaticMemberReference), - FunctionalConstraint = signal.FunctionalConstraint, - ExpectedOnText = discrete ? "TRUE" : "Value 1", - ExpectedOffText = discrete ? "FALSE" : "Value 2", - ExpectedOnRaw = 1, - ExpectedOffRaw = 0, - DataType = signal.DataType, - SignalAddress = source.SourceId, - DataSetName = signal.DataSetReference, - SourceIecReference = signal.StaticMemberReference, - ReportDisplayReference = signal.StaticMemberReference, - EventLogSearchReference = signal.StaticMemberReference, - EvidenceExpected = signal.CaptureMode == FatCaptureMode.AutomaticTransition - ? "Automatic Value 1 / Value 2 transition capture" - : "Operator Value 1 / Value 2 snapshot capture", - SourceSheet = source.FileName, - SourceRow = signal.DataSetMemberIndex + 1, - SignalKind = signal.SignalKind, - CaptureMode = signal.CaptureMode, - TestEnabled = true, - ImportReady = true, - BindingStatus = "ENGINEERING_SCL_DATASET_AUTHORITY", - BindingEvidence = string.Join(" • ", new[] - { - "shared Engineering ARIEC static DataSet authority", - $"sourceId={source.SourceId}", - $"sourceSha256={source.Sha256}", - $"workspace={workspace.WorkspaceKey}", - $"dataset={signal.DataSetReference}", - $"memberIndex={signal.DataSetMemberIndex}", - $"static={signal.StaticMemberReference}", - $"kind={signal.SignalKind}", - $"capture={signal.CaptureMode}" - }) - }; - } - - private static string WorkspaceIdentity(SclIedWorkspace workspace) - => WorkspaceIdentity(workspace.IedName, workspace.AccessPointName); - - private static string WorkspaceIdentity(string? iedName, string? accessPointName) - => $"{(iedName ?? string.Empty).Trim()}|{(accessPointName ?? string.Empty).Trim()}"; - - private static string FirstNonEmpty(params string?[] values) - => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty; -} diff --git a/Services/IoTesting/IoFatNativePdfWriter.cs b/Services/IoTesting/IoFatNativePdfWriter.cs index dc55ae4a1..04ec11be1 100644 --- a/Services/IoTesting/IoFatNativePdfWriter.cs +++ b/Services/IoTesting/IoFatNativePdfWriter.cs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 using System.Globalization; +using System.IO.Compression; using System.Text; using ArIED61850Tester.Models.IoTesting; @@ -17,11 +18,36 @@ internal static class IoFatNativePdfWriter { public static byte[] Build(IoFatReportLayoutPlan layout, IoTestProject project) { - ArgumentNullException.ThrowIfNull(layout); ArgumentNullException.ThrowIfNull(project); + var primaryReference = project.Ieds + .SelectMany(ied => ied.TestPoints) + .Select(point => point.ObjectReference) + .FirstOrDefault(reference => !string.IsNullOrWhiteSpace(reference)) + ?? project.ProjectId; + return Build(layout, project.ProjectName, primaryReference); + } + + /// + /// P4D layout-first PDF path. Native FAT already owns an immutable canonical snapshot, + /// so PDF serialization receives the exact same layout instance as DocumentViewer and + /// needs only document metadata, never a reconstructed IoTestProject/runtime workspace. + /// + public static byte[] Build( + IoFatReportLayoutPlan layout, + string reportName, + string primaryReference) + { + ArgumentNullException.ThrowIfNull(layout); if (layout.Pages.Count == 0) throw new InvalidOperationException("At least one PDF page is required."); + var safeReportName = string.IsNullOrWhiteSpace(reportName) + ? "ARSAS FAT" + : reportName.Trim(); + var safePrimaryReference = string.IsNullOrWhiteSpace(primaryReference) + ? layout.ProjectId + : primaryReference.Trim(); + var fonts = IoFatReportTypography.ResolvePdfFonts(); var objects = new List(); @@ -42,23 +68,33 @@ int AddObjectBytes(byte[] bytes) foreach (var page in layout.Pages) { + var pageImages = page.Commands.OfType().ToArray(); + var imageObjectIds = new List(pageImages.Length); + foreach (var image in pageImages) + imageObjectIds.Add(AddImageObject(image, AddObjectBytes)); + var content = BuildPageContent(page); var contentBytes = Encoding.ASCII.GetBytes(content); var contentId = AddObjectBytes(BuildStreamObject(contentBytes)); + + var resources = new StringBuilder() + .Append("/Font << /F1 ").Append(fontRegularId).Append(" 0 R /F2 ").Append(fontBoldId).Append(" 0 R >>"); + if (imageObjectIds.Count > 0) + { + resources.Append(" /XObject <<"); + for (var index = 0; index < imageObjectIds.Count; index++) + resources.Append(" /Im").Append(index + 1).Append(' ').Append(imageObjectIds[index]).Append(" 0 R"); + resources.Append(" >>"); + } + var pageId = AddObject( $"<< /Type /Page /Parent {pagesId} 0 R /MediaBox [0 0 {Number(page.Width)} {Number(page.Height)}] " + - $"/Resources << /Font << /F1 {fontRegularId} 0 R /F2 {fontBoldId} 0 R >> >> " + - $"/Contents {contentId} 0 R >>"); + $"/Resources << {resources} >> /Contents {contentId} 0 R >>"); pageIds.Add(pageId); } - var primaryReference = project.Ieds - .SelectMany(ied => ied.TestPoints) - .Select(point => point.ObjectReference) - .FirstOrDefault(reference => !string.IsNullOrWhiteSpace(reference)) - ?? project.ProjectId; - var title = $"{project.ProjectName} - IEC 61850 FAT Evidence Report"; - var subject = $"Customer-readable FAT summary. Detailed evidence is retained in the ARSAS project and Excel export. Primary IEC 61850 reference: {primaryReference}"; + var title = $"{safeReportName} - IEC 61850 FAT Evidence Report"; + var subject = $"Immutable IEC 61850 FAT evidence report. Primary IEC 61850 reference: {safePrimaryReference}"; var infoId = AddObject( $"<< /Title ({EscapeLiteral(IoFatReportLayoutEngine.SanitizeReportText(title))}) " + $"/Subject ({EscapeLiteral(IoFatReportLayoutEngine.SanitizeReportText(subject))}) " + @@ -113,6 +149,31 @@ private static int AddEmbeddedTrueTypeFont( $"/Widths [{widths}] /FontDescriptor {descriptorId} 0 R /Encoding /WinAnsiEncoding >>"); } + private static int AddImageObject( + IoFatReportImageCommand image, + Func addBinaryObject) + { + if (image.PixelWidth <= 0 || image.PixelHeight <= 0 || + image.RgbPixels.Length != image.PixelWidth * image.PixelHeight * 3) + { + throw new InvalidOperationException("Report image RGB payload is invalid."); + } + + var compressed = Compress(image.RgbPixels); + var header = + $"<< /Type /XObject /Subtype /Image /Width {image.PixelWidth} /Height {image.PixelHeight} " + + $"/ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /FlateDecode /Length {compressed.Length} >>\nstream\n"; + return addBinaryObject(BuildBinaryStreamObject(compressed, header)); + } + + private static byte[] Compress(byte[] payload) + { + using var output = new MemoryStream(); + using (var compressor = new ZLibStream(output, CompressionLevel.Optimal, leaveOpen: true)) + compressor.Write(payload, 0, payload.Length); + return output.ToArray(); + } + private static byte[] BuildStreamObject(byte[] payload) => BuildBinaryStreamObject(payload, $"<< /Length {payload.Length.ToString(CultureInfo.InvariantCulture)} >>\nstream\n"); @@ -133,6 +194,7 @@ private static byte[] BuildBinaryStreamObject(byte[] payload, string header) private static string BuildPageContent(IoFatReportPagePlan page) { var output = new StringBuilder(32_000); + var imageIndex = 0; foreach (var command in page.Commands) { switch (command) @@ -149,6 +211,10 @@ private static string BuildPageContent(IoFatReportPagePlan page) case IoFatReportRectCommand rect: WriteRect(output, rect); break; + case IoFatReportImageCommand image: + imageIndex++; + WriteImage(output, image, imageIndex); + break; } } return output.ToString(); @@ -223,6 +289,15 @@ private static void WriteRoundRect(StringBuilder output, IoFatReportRectCommand .Append(command.StrokeThickness > 0d ? " c B\n" : " c f\n"); } + private static void WriteImage(StringBuilder output, IoFatReportImageCommand command, int imageIndex) + { + var y = command.TopY - command.Height; + output.Append("q ") + .Append(Number(command.Width)).Append(" 0 0 ").Append(Number(command.Height)).Append(' ') + .Append(Number(command.X)).Append(' ').Append(Number(y)).Append(" cm ") + .Append("/Im").Append(imageIndex).Append(" Do Q\n"); + } + private static string Fill(IoFatReportColor color) => $"{Channel(color.R)} {Channel(color.G)} {Channel(color.B)} rg"; diff --git a/Services/IoTesting/IoFatPdfReportService.cs b/Services/IoTesting/IoFatPdfReportService.cs index f43c76f74..8f2e640b0 100644 --- a/Services/IoTesting/IoFatPdfReportService.cs +++ b/Services/IoTesting/IoFatPdfReportService.cs @@ -46,10 +46,40 @@ internal static IoFatReportLayoutPlan BuildLayout( return IoFatSupplementalReportLayoutDecorator.AppendFileServiceEvidence(reportProject, layout); } + /// + /// P4D native FAT export. The immutable selected-IED snapshot has already been mapped + /// to one report layout plan, so both DocumentViewer and PDF serialize that same plan. + /// No IoTestProject/runtime workspace is rebuilt for export. + /// + internal static byte[] GenerateLayout( + IoFatReportLayoutPlan layout, + string reportName, + string primaryReference) + { + ArgumentNullException.ThrowIfNull(layout); + return IoFatNativePdfWriter.Build(layout, reportName, primaryReference); + } + public static void Save(string fileName, IoTestProject project, DateTimeOffset? generatedAt = null) { ArgumentException.ThrowIfNullOrWhiteSpace(fileName); var bytes = Generate(project, generatedAt); + SaveBytesAtomic(fileName, bytes); + } + + internal static void SaveLayout( + string fileName, + IoFatReportLayoutPlan layout, + string reportName, + string primaryReference) + { + ArgumentException.ThrowIfNullOrWhiteSpace(fileName); + var bytes = GenerateLayout(layout, reportName, primaryReference); + SaveBytesAtomic(fileName, bytes); + } + + private static void SaveBytesAtomic(string fileName, byte[] bytes) + { var fullPath = Path.GetFullPath(fileName); Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); var temporary = fullPath + ".tmp-" + Guid.NewGuid().ToString("N"); diff --git a/Services/IoTesting/IoFatReportPreviewDocumentBuilder.cs b/Services/IoTesting/IoFatReportPreviewDocumentBuilder.cs index fc8b7ce30..302c1fcbe 100644 --- a/Services/IoTesting/IoFatReportPreviewDocumentBuilder.cs +++ b/Services/IoTesting/IoFatReportPreviewDocumentBuilder.cs @@ -6,6 +6,7 @@ using System.Windows.Documents; using System.Windows.Markup; using System.Windows.Media; +using System.Windows.Media.Imaging; using System.Windows.Shapes; using ArIED61850Tester.Models.IoTesting; @@ -62,6 +63,9 @@ public static FixedDocument Render(IoFatReportLayoutPlan layout) case IoFatReportTextCommand text: AddText(fixedPage, pagePlan.Height, text); break; + case IoFatReportImageCommand image: + AddImage(fixedPage, pagePlan.Height, image); + break; } } @@ -158,6 +162,41 @@ private static void AddText(FixedPage page, double pageHeight, IoFatReportTextCo Math.Max(fontSize + 6d, fontSize * 1.65d)); } + private static void AddImage(FixedPage page, double pageHeight, IoFatReportImageCommand command) + { + if (command.PixelWidth <= 0 || command.PixelHeight <= 0 || + command.RgbPixels.Length != command.PixelWidth * command.PixelHeight * 3) + { + return; + } + + var bitmap = BitmapSource.Create( + command.PixelWidth, + command.PixelHeight, + 96d, + 96d, + PixelFormats.Rgb24, + null, + command.RgbPixels, + command.PixelWidth * 3); + bitmap.Freeze(); + + var image = new Image + { + Source = bitmap, + Stretch = Stretch.Uniform, + SnapsToDevicePixels = true + }; + + Add( + page, + image, + command.X * DipPerPdfPoint, + (pageHeight - command.TopY) * DipPerPdfPoint, + command.Width * DipPerPdfPoint, + command.Height * DipPerPdfPoint); + } + private static void Add(FixedPage page, UIElement element, double x, double y, double width, double height) { element.SetValue(FrameworkElement.WidthProperty, width); diff --git a/Services/IoTesting/IoFatSclProjectImportService.cs b/Services/IoTesting/IoFatSclProjectImportService.cs index da208b075..398915baf 100644 --- a/Services/IoTesting/IoFatSclProjectImportService.cs +++ b/Services/IoTesting/IoFatSclProjectImportService.cs @@ -67,22 +67,6 @@ internal bool TryGetRuntimeWorkspace( } } - /// - /// Registers ARIEC workspaces that are already owned by Engineering. This is the - /// zero-reparse bridge used by the embedded FAT tab: the exact SclIedWorkspace objects - /// already attached to Explorer devices become the production FAT runtime authority. - /// - internal void AdoptEngineeringRuntimeWorkspaces(IEnumerable workspaces) - { - ArgumentNullException.ThrowIfNull(workspaces); - var stable = workspaces - .Where(workspace => workspace != null) - .GroupBy(workspace => workspace.WorkspaceKey, StringComparer.OrdinalIgnoreCase) - .Select(group => group.Last()) - .ToArray(); - SetRuntimeWorkspaces(stable); - } - public Task ImportAsync( IReadOnlyCollection sclPaths, string? projectName = null, diff --git a/Services/IoTesting/NativeFatArmCoordinator.cs b/Services/IoTesting/NativeFatArmCoordinator.cs new file mode 100644 index 000000000..3655dd31c --- /dev/null +++ b/Services/IoTesting/NativeFatArmCoordinator.cs @@ -0,0 +1,306 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Threading; +using System.Windows.Threading; +using ArIED61850Tester.Models; +using ArIED61850Tester.Models.IoTesting; + +namespace ArIED61850Tester.Services.IoTesting; + +public sealed record NativeFatArmResult( + bool Succeeded, + bool AlreadyArmed, + int ArmedRows, + int SeededValue1Rows, + long ElapsedMilliseconds, + string Message); + +public sealed class NativeFatEvidenceChangedEventArgs : EventArgs +{ + public NativeFatEvidenceChangedEventArgs( + string deviceId, + Iec61850MonitorPoint point, + NativeFatEvidenceField field) + { + DeviceId = deviceId; + Point = point; + Field = field; + } + + public string DeviceId { get; } + public Iec61850MonitorPoint Point { get; } + public NativeFatEvidenceField Field { get; } +} + +/// +/// P1D evidence ARM coordinator for the native Engineering FAT surface. +/// It never connects, discovers, imports SCL, changes reporting, changes polling cadence, +/// or creates a second point collection. It only subscribes to the canonical Engineering +/// Iec61850MonitorPoint instances that are already live and records sparse evidence. +/// +public sealed class NativeFatArmCoordinator : IDisposable +{ + private readonly Dictionary _armedDevices = + new(StringComparer.OrdinalIgnoreCase); + + public event EventHandler? EvidenceChanged; + + public NativeFatArmResult Arm( + Iec61850MonitorDevice device, + NativeFatIedSessionCacheState cache) + { + ArgumentNullException.ThrowIfNull(device); + ArgumentNullException.ThrowIfNull(cache); + + var stopwatch = Stopwatch.StartNew(); + + if (!device.IsConnected || !device.IsMonitoring) + { + stopwatch.Stop(); + return new NativeFatArmResult( + false, + false, + 0, + 0, + stopwatch.ElapsedMilliseconds, + $"{device.Name} must already be connected and monitoring in Engineering before FAT can be armed."); + } + + if (device.Points.Count == 0) + { + stopwatch.Stop(); + return new NativeFatArmResult( + false, + false, + 0, + 0, + stopwatch.ElapsedMilliseconds, + $"{device.Name} has no canonical Engineering live rows to arm."); + } + + if (_armedDevices.TryGetValue(device.DeviceId, out var existing)) + { + stopwatch.Stop(); + cache.IsArmed = true; + cache.ArmedAt ??= existing.ArmedAt; + cache.LastArmElapsedMilliseconds = stopwatch.ElapsedMilliseconds; + return new NativeFatArmResult( + true, + true, + existing.Subscriptions.Count, + 0, + stopwatch.ElapsedMilliseconds, + $"{device.Name} FAT is already armed on the shared Engineering acquisition stream."); + } + + var armed = new ArmedDevice(device.DeviceId, cache, DateTimeOffset.Now); + var seeded = 0; + + // P4A: only one-to-one IEDName + IEC Telegram identities may own evidence. + // Missing or duplicate identities are skipped instead of being guessed by order, + // SignalName, SelectedIndex or runtime DeviceId. + var identityCounts = device.Points + .Select(point => NativeFatCanonicalEvidenceOverlay.TryBuildRowKey(point, out var key) + ? key + : string.Empty) + .Where(key => key.Length > 0) + .GroupBy(key => key, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.Count(), StringComparer.OrdinalIgnoreCase); + + foreach (var point in device.Points) + { + if (!NativeFatCanonicalEvidenceOverlay.TryBuildRowKey(point, out var rowKey) || + !identityCounts.TryGetValue(rowKey, out var identityCount) || + identityCount != 1) + { + continue; + } + + PropertyChangedEventHandler handler = (_, args) => + { + if (args.PropertyName is not (nameof(Iec61850MonitorPoint.Value) or nameof(Iec61850MonitorPoint.DisplayValue))) + return; + + // The Engineering UI applies Value before timestamp/quality/source/sequence in + // one dispatcher flush. Post the capture to that same synchronization context so + // P4B reads one coherent sample after the remaining metadata has been applied. + var context = SynchronizationContext.Current; + if (context is DispatcherSynchronizationContext) + context.Post(_ => ObserveCanonicalValue(armed, point), null); + else + ObserveCanonicalValue(armed, point); + }; + + point.PropertyChanged += handler; + armed.Subscriptions.Add(new PointSubscription(point, handler)); + if (ObserveCanonicalValue(armed, point)) + seeded++; + } + + if (armed.Subscriptions.Count == 0) + { + stopwatch.Stop(); + return new NativeFatArmResult( + false, + false, + 0, + 0, + stopwatch.ElapsedMilliseconds, + $"{device.Name} has no uniquely addressable IEDName + IEC Telegram FAT rows; evidence was not armed."); + } + + _armedDevices[device.DeviceId] = armed; + cache.IsArmed = true; + cache.ArmedAt = armed.ArmedAt; + stopwatch.Stop(); + cache.LastArmElapsedMilliseconds = stopwatch.ElapsedMilliseconds; + + return new NativeFatArmResult( + true, + false, + armed.Subscriptions.Count, + seeded, + stopwatch.ElapsedMilliseconds, + $"{device.Name} FAT armed on {armed.Subscriptions.Count} canonical Engineering row(s) in {stopwatch.ElapsedMilliseconds} ms; acquisition was not restarted."); + } + + public bool IsArmed(string? deviceId) + => !string.IsNullOrWhiteSpace(deviceId) && _armedDevices.ContainsKey(deviceId); + + public void Dispose() + { + foreach (var armed in _armedDevices.Values) + { + foreach (var subscription in armed.Subscriptions) + subscription.Point.PropertyChanged -= subscription.Handler; + armed.Cache.IsArmed = false; + } + + _armedDevices.Clear(); + } + + private bool ObserveCanonicalValue(ArmedDevice armed, Iec61850MonitorPoint point) + { + var value = point.DisplayValue?.Trim() ?? string.Empty; + if (!IsEvidenceCandidate(point, value)) + return false; + + if (!NativeFatCanonicalEvidenceOverlay.TryBuildRowKey(point, out var rowKey)) + return false; + + lock (armed.Gate) + { + if (!armed.Cache.EvidenceByRow.TryGetValue(rowKey, out var slot)) + { + NativeFatCanonicalEvidenceOverlay.WriteCapture( + armed.Cache, + point, + NativeFatEvidenceField.Value1, + value, + FatEvidenceCaptureKind.AutomaticValue, + DateTimeOffset.Now); + RaiseEvidenceChanged(armed.DeviceId, point, NativeFatEvidenceField.Value1); + return true; + } + + if (string.IsNullOrWhiteSpace(slot.Value1)) + { + NativeFatCanonicalEvidenceOverlay.WriteCapture( + armed.Cache, + point, + NativeFatEvidenceField.Value1, + value, + FatEvidenceCaptureKind.AutomaticValue, + DateTimeOffset.Now); + RaiseEvidenceChanged(armed.DeviceId, point, NativeFatEvidenceField.Value1); + return true; + } + + if (string.IsNullOrWhiteSpace(slot.Value2)) + { + if (Iec61850MonitorPoint.AreSemanticallyEquivalent(slot.Value1, value)) + return false; + + NativeFatCanonicalEvidenceOverlay.WriteCapture( + armed.Cache, + point, + NativeFatEvidenceField.Value2, + value, + FatEvidenceCaptureKind.AutomaticTransition, + DateTimeOffset.Now); + RaiseEvidenceChanged(armed.DeviceId, point, NativeFatEvidenceField.Value2); + return true; + } + + // Once a pair exists, only the newest Value 2 is the duplicate guard. A return + // to the prior Value 1 is itself a real transition and must advance the pair. + if (Iec61850MonitorPoint.AreSemanticallyEquivalent(slot.Value2, value)) + return false; + + // Keep the current pair aligned to the latest meaningful transition without + // touching Result, which remains an operator/report assessment field. + NativeFatCanonicalEvidenceOverlay.PromoteValue2ToValue1( + armed.Cache, + point); + RaiseEvidenceChanged(armed.DeviceId, point, NativeFatEvidenceField.Value1); + + NativeFatCanonicalEvidenceOverlay.WriteCapture( + armed.Cache, + point, + NativeFatEvidenceField.Value2, + value, + FatEvidenceCaptureKind.AutomaticTransition, + DateTimeOffset.Now); + RaiseEvidenceChanged(armed.DeviceId, point, NativeFatEvidenceField.Value2); + return true; + } + } + + private static bool IsEvidenceCandidate(Iec61850MonitorPoint point, string value) + { + if (string.IsNullOrWhiteSpace(value) || value is "-" or "—") + return false; + + if (value.Equals("Pending", StringComparison.OrdinalIgnoreCase) || + value.Equals("Unknown", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var quality = point.Quality ?? string.Empty; + return !quality.Contains("bad", StringComparison.OrdinalIgnoreCase) && + !quality.Contains("invalid", StringComparison.OrdinalIgnoreCase) && + !quality.Contains("questionable", StringComparison.OrdinalIgnoreCase); + } + + private void RaiseEvidenceChanged( + string deviceId, + Iec61850MonitorPoint point, + NativeFatEvidenceField field) + => EvidenceChanged?.Invoke( + this, + new NativeFatEvidenceChangedEventArgs(deviceId, point, field)); + + private sealed class ArmedDevice + { + public ArmedDevice( + string deviceId, + NativeFatIedSessionCacheState cache, + DateTimeOffset armedAt) + { + DeviceId = deviceId; + Cache = cache; + ArmedAt = armedAt; + } + + public string DeviceId { get; } + public NativeFatIedSessionCacheState Cache { get; } + public DateTimeOffset ArmedAt { get; } + public object Gate { get; } = new(); + public List Subscriptions { get; } = new(); + } + + private sealed record PointSubscription( + Iec61850MonitorPoint Point, + PropertyChangedEventHandler Handler); +} diff --git a/Services/IoTesting/NativeFatAuxiliaryEvidenceCache.cs b/Services/IoTesting/NativeFatAuxiliaryEvidenceCache.cs new file mode 100644 index 000000000..fba58c5e8 --- /dev/null +++ b/Services/IoTesting/NativeFatAuxiliaryEvidenceCache.cs @@ -0,0 +1,344 @@ +using System.Collections.ObjectModel; +using AR.Iec61850.FaultRecords; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester.Services.IoTesting; + +public sealed record NativeFatComtradeRecordEvidence( + string RecordName, + DateTimeOffset? RecordDateUtc, + long KnownSizeBytes, + bool HasUnknownSize, + string Result); + +public sealed record NativeFatTimeSyncReportPoint( + string Role, + string SignalName, + string IecReference, + string Value, + string Quality, + string DeviceTimestamp, + double? DeltaSeconds, + string Result); + +public sealed class NativeFatTimeSyncReportEvidence +{ + private readonly ReadOnlyCollection _supportingPoints; + + internal NativeFatTimeSyncReportEvidence( + DateTimeOffset verifiedAtUtc, + string verdict, + string summary, + bool ltmsPresent, + int freshPrimaryTimestampCount, + IEnumerable supportingPoints) + { + VerifiedAtUtc = verifiedAtUtc.ToUniversalTime(); + Verdict = Copy(verdict); + Summary = Copy(summary); + LtmsPresent = ltmsPresent; + FreshPrimaryTimestampCount = freshPrimaryTimestampCount; + _supportingPoints = Array.AsReadOnly(supportingPoints.ToArray()); + } + + public DateTimeOffset VerifiedAtUtc { get; } + public string Verdict { get; } + public string Summary { get; } + public bool LtmsPresent { get; } + public int FreshPrimaryTimestampCount { get; } + public IReadOnlyList SupportingPoints => _supportingPoints; + public bool IsSynchronized => Verdict.Equals("OK", StringComparison.OrdinalIgnoreCase); + + internal NativeFatTimeSyncReportEvidence Copy() + => new( + VerifiedAtUtc, + Verdict, + Summary, + LtmsPresent, + FreshPrimaryTimestampCount, + _supportingPoints); + + private static string Copy(string? value) + => value?.Trim() ?? string.Empty; +} + +public sealed class NativeFatAuxiliaryEvidenceSnapshot +{ + private readonly ReadOnlyCollection _comtradeRecords; + + internal NativeFatAuxiliaryEvidenceSnapshot( + DateTimeOffset? comtradeVerifiedAtUtc, + IEnumerable comtradeRecords, + NativeFatTimeSyncReportEvidence? timeSync) + { + ComtradeVerifiedAtUtc = comtradeVerifiedAtUtc?.ToUniversalTime(); + _comtradeRecords = Array.AsReadOnly(comtradeRecords.ToArray()); + TimeSync = timeSync?.Copy(); + } + + public static NativeFatAuxiliaryEvidenceSnapshot Empty { get; } = + new(null, Array.Empty(), null); + + public DateTimeOffset? ComtradeVerifiedAtUtc { get; } + public IReadOnlyList ComtradeRecords => _comtradeRecords; + public NativeFatTimeSyncReportEvidence? TimeSync { get; } + + internal NativeFatAuxiliaryEvidenceSnapshot Copy() + => new(ComtradeVerifiedAtUtc, _comtradeRecords, TimeSync); +} + +/// +/// Thread-safe, in-memory, per-IED cache for evidence already obtained by the native FAT +/// diagnostics. Report capture only copies this state; it never reconnects, discovers files, +/// or starts acquisition. A completed empty/failed evaluation clears that evidence type so +/// report inclusion always fails closed. +/// +internal sealed class NativeFatAuxiliaryEvidenceCache +{ + private readonly object _gate = new(); + private readonly Dictionary _stateByIed = new(StringComparer.OrdinalIgnoreCase); + + public void RecordComtradeDiscovery( + Iec61850MonitorDevice device, + IEnumerable? records, + DateTimeOffset verifiedAtUtc) + { + ArgumentNullException.ThrowIfNull(device); + var projected = ProjectComtrade(records, CancellationToken.None); + Update(device, state => state with + { + ComtradeVerifiedAtUtc = verifiedAtUtc.ToUniversalTime(), + ComtradeRecords = projected + }); + } + + public async Task RecordComtradeDiscoveryAsync( + Iec61850MonitorDevice device, + IEnumerable? records, + DateTimeOffset verifiedAtUtc, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(device); + var key = ResolveStableIedKey(device); + var projected = await Task.Run( + () => ProjectComtrade(records, cancellationToken), + cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + Update(key, state => state with + { + ComtradeVerifiedAtUtc = verifiedAtUtc.ToUniversalTime(), + ComtradeRecords = projected + }); + } + + public void ClearComtrade(Iec61850MonitorDevice device) + { + ArgumentNullException.ThrowIfNull(device); + Update(device, state => state with + { + ComtradeVerifiedAtUtc = null, + ComtradeRecords = Array.Empty() + }); + } + + public void RecordTimeSyncEvaluation( + Iec61850MonitorDevice device, + NativeFatTimeSyncDiagnosticResult diagnostic, + DateTimeOffset evaluatedAtUtc) + { + ArgumentNullException.ThrowIfNull(device); + ArgumentNullException.ThrowIfNull(diagnostic); + + var evidence = diagnostic.IsSynchronized + ? ProjectTimeSync(diagnostic, evaluatedAtUtc) + : null; + Update(device, state => state with { TimeSync = evidence }); + } + + public void ClearTimeSync(Iec61850MonitorDevice device) + { + ArgumentNullException.ThrowIfNull(device); + Update(device, state => state with { TimeSync = null }); + } + + public NativeFatAuxiliaryEvidenceSnapshot Capture(Iec61850MonitorDevice device) + { + ArgumentNullException.ThrowIfNull(device); + var key = ResolveStableIedKey(device); + lock (_gate) + { + if (!_stateByIed.TryGetValue(key, out var state)) + return NativeFatAuxiliaryEvidenceSnapshot.Empty; + + return new NativeFatAuxiliaryEvidenceSnapshot( + state.ComtradeVerifiedAtUtc, + state.ComtradeRecords, + state.TimeSync); + } + } + + private void Update(Iec61850MonitorDevice device, Func update) + => Update(ResolveStableIedKey(device), update); + + private void Update(string key, Func update) + { + lock (_gate) + { + _stateByIed.TryGetValue(key, out var current); + _stateByIed[key] = update(current ?? EvidenceState.Empty); + } + } + + private static IReadOnlyList ProjectComtrade( + IEnumerable? records, + CancellationToken cancellationToken) + { + if (records == null) + return Array.Empty(); + + var projected = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var record in records) + { + cancellationToken.ThrowIfCancellationRequested(); + if (record?.Files == null) + continue; + + var validFiles = record.Files + .Where(file => file != null && + (!string.IsNullOrWhiteSpace(file.RemotePath) || !string.IsNullOrWhiteSpace(file.Name))) + .GroupBy( + file => !string.IsNullOrWhiteSpace(file.RemotePath) ? file.RemotePath.Trim() : file.Name.Trim(), + StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) + .ToArray(); + if (validFiles.Length == 0) + continue; + + var name = FirstNonEmpty( + record.BaseName, + record.RecordId, + validFiles[0].BaseName, + validFiles[0].Name); + if (name.Length == 0) + continue; + + var knownSize = record.KnownSizeBytes > 0 + ? record.KnownSizeBytes + : SumKnownSize(validFiles); + var date = record.LastModifiedUtc ?? validFiles + .Where(file => file.LastModifiedUtc.HasValue) + .Select(file => file.LastModifiedUtc) + .Max(); + var evidence = new NativeFatComtradeRecordEvidence( + name, + date?.ToUniversalTime(), + knownSize, + record.HasUnknownSize || validFiles.Any(file => !file.SizeBytes.HasValue), + "OK"); + + var recordKey = FirstNonEmpty(record.RecordId, $"{record.RemoteDirectory}/{name}"); + projected[recordKey] = evidence; + } + + return projected.Values + .OrderByDescending(record => record.RecordDateUtc) + .ThenBy(record => record.RecordName, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + private static NativeFatTimeSyncReportEvidence ProjectTimeSync( + NativeFatTimeSyncDiagnosticResult diagnostic, + DateTimeOffset evaluatedAtUtc) + { + var supporting = new List(2); + if (diagnostic.LtmsPresent) + { + NativeFatTimeSyncPointEvidence? ltms = null; + NativeFatTimeSyncPointEvidence? timestamp = null; + foreach (var point in diagnostic.PrimaryEvidence) + { + if (!point.Trusted) + continue; + if (point.Role.Equals("LTMS", StringComparison.OrdinalIgnoreCase)) + ltms ??= point; + else + timestamp ??= point; + if (ltms != null && timestamp != null) + break; + } + if (ltms != null) supporting.Add(ltms); + if (timestamp != null) supporting.Add(timestamp); + } + else + { + foreach (var point in diagnostic.PrimaryEvidence) + { + if (point.Trusted) + supporting.Add(point); + if (supporting.Count == 2) + break; + } + } + + return new NativeFatTimeSyncReportEvidence( + evaluatedAtUtc, + diagnostic.Verdict, + diagnostic.Summary, + diagnostic.LtmsPresent, + diagnostic.FreshPrimaryTimestampCount, + supporting.Select(point => new NativeFatTimeSyncReportPoint( + Copy(point.Role), + Copy(point.SignalName), + Copy(point.IecReference), + Copy(point.Value), + Copy(point.Quality), + Copy(point.DeviceTimestamp), + point.DeltaSeconds, + "OK"))); + } + + private static long SumKnownSize(IEnumerable files) + { + long total = 0; + foreach (var file in files) + { + var size = (long)(file.SizeBytes ?? 0u); + total = long.MaxValue - total < size ? long.MaxValue : total + size; + } + return total; + } + + private static string ResolveStableIedKey(Iec61850MonitorDevice device) + { + var namedIdentity = new[] { device.SclIedName, device.Name } + .FirstOrDefault(value => !string.IsNullOrWhiteSpace(value) && !IsPlaceholderIdentity(value.Trim())) + ?.Trim() ?? string.Empty; + if (namedIdentity.Length > 0) + return $"ied:{namedIdentity}"; + + if (!string.IsNullOrWhiteSpace(device.IpAddress)) + return $"endpoint:{device.IpAddress.Trim()}:{device.Port}"; + + return $"device:{device.DeviceId.Trim()}"; + } + + private static bool IsPlaceholderIdentity(string value) + => value.Equals("IED", StringComparison.OrdinalIgnoreCase) || + value.Equals("New IED", StringComparison.OrdinalIgnoreCase) || + value.Equals("Device", StringComparison.OrdinalIgnoreCase); + + private static string FirstNonEmpty(params string?[] values) + => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty; + + private static string Copy(string? value) + => value?.Trim() ?? string.Empty; + + private sealed record EvidenceState( + DateTimeOffset? ComtradeVerifiedAtUtc, + IReadOnlyList ComtradeRecords, + NativeFatTimeSyncReportEvidence? TimeSync) + { + public static EvidenceState Empty { get; } = + new(null, Array.Empty(), null); + } +} diff --git a/Services/IoTesting/NativeFatAuxiliaryReportDecorator.cs b/Services/IoTesting/NativeFatAuxiliaryReportDecorator.cs new file mode 100644 index 000000000..28b4eb903 --- /dev/null +++ b/Services/IoTesting/NativeFatAuxiliaryReportDecorator.cs @@ -0,0 +1,331 @@ +using System.Globalization; + +namespace ArIED61850Tester.Services.IoTesting; + +/// +/// Appends only successfully verified auxiliary evidence already present in the immutable +/// native FAT snapshot. It performs no device access and leaves final page-total correction +/// to . +/// +internal static class NativeFatAuxiliaryReportDecorator +{ + private const double PageWidth = 842d; + private const double PageHeight = 595d; + private const double Margin = 30d; + private const double ContentWidth = PageWidth - (Margin * 2d); + private const int ComtradeRowsPerPage = 10; + private const double TableHeaderHeight = 26d; + private const double TableRowHeight = 30d; + private const double TableBodyFontSize = 7.2d; + private const double TableMonoFontSize = 6.2d; + private const double TableHeaderFontSize = 6.8d; + + private static readonly IoFatReportColor Navy = IoFatReportColor.FromHex("0F172A"); + private static readonly IoFatReportColor Blue = IoFatReportColor.FromHex("2563EB"); + private static readonly IoFatReportColor SoftBlue = IoFatReportColor.FromHex("EFF6FF"); + private static readonly IoFatReportColor Border = IoFatReportColor.FromHex("D9E4F0"); + private static readonly IoFatReportColor Muted = IoFatReportColor.FromHex("64748B"); + private static readonly IoFatReportColor Ink = IoFatReportColor.FromHex("1F2937"); + private static readonly IoFatReportColor White = IoFatReportColor.FromHex("FFFFFF"); + private static readonly IoFatReportColor Pass = IoFatReportColor.FromHex("15803D"); + private static readonly IoFatReportColor SoftPass = IoFatReportColor.FromHex("F0FDF4"); + + public static IoFatReportLayoutPlan AppendSuccessfulEvidence( + IoFatReportLayoutPlan baseLayout, + NativeFatPrintPreviewSnapshot snapshot) + { + ArgumentNullException.ThrowIfNull(baseLayout); + ArgumentNullException.ThrowIfNull(snapshot); + + var pages = baseLayout.Pages.ToList(); + var auxiliary = snapshot.AuxiliaryEvidence; + + if (auxiliary.ComtradeVerifiedAtUtc.HasValue && auxiliary.ComtradeRecords.Count > 0) + { + foreach (var chunk in auxiliary.ComtradeRecords.Chunk(ComtradeRowsPerPage)) + { + var pageNumber = pages.Count + 1; + pages.Add(BuildComtradePage( + snapshot, + auxiliary.ComtradeVerifiedAtUtc.Value, + chunk, + pageNumber, + continued: pageNumber > baseLayout.Pages.Count + 1, + baseLayout.CreatedAt)); + } + } + + if (auxiliary.TimeSync is { IsSynchronized: true } timeSync) + { + var pageNumber = pages.Count + 1; + pages.Add(BuildTimeSyncPage(snapshot, timeSync, pageNumber, baseLayout.CreatedAt)); + } + + return pages.Count == baseLayout.Pages.Count + ? baseLayout + : new IoFatReportLayoutPlan(baseLayout.ProjectId, baseLayout.CreatedAt, baseLayout.Draft, pages); + } + + private static IoFatReportPagePlan BuildComtradePage( + NativeFatPrintPreviewSnapshot snapshot, + DateTimeOffset verifiedAtUtc, + IReadOnlyList records, + int pageNumber, + bool continued, + DateTimeOffset createdAt) + { + var commands = new List(); + AddHeader( + commands, + "IEC 61850 Fault Records (COMTRADE)", + continued ? "Available Fault Records · continued" : "Available Fault Records"); + AddScopeCard( + commands, + snapshot, + $"Fault record directory verified · {NativeFatReportFormatting.LocalTimestamp(verifiedAtUtc)} · Local time", + RecordCountText(snapshot.AuxiliaryEvidence.ComtradeRecords.Count)); + + var widths = new[] { 330d, 190d, 160d, 102d }; + var headers = new[] { "Record Name", "File Timestamp", "Total Size", "Status" }; + var y = 438d; + DrawTableHeader(commands, widths, headers, y); + y -= TableHeaderHeight; + + foreach (var record in records) + { + var values = new[] + { + Fit(record.RecordName, 66), + NativeFatReportFormatting.LocalTimestamp(record.RecordDateUtc), + FormatSize(record.KnownSizeBytes, record.HasUnknownSize), + "Complete" + }; + DrawRow(commands, widths, values, y, TableRowHeight, resultColumn: 3); + y -= TableRowHeight; + } + + AddFooter(commands, pageNumber, createdAt, "Fault record directory verified via IEC 61850 file services."); + return new IoFatReportPagePlan(pageNumber, PageWidth, PageHeight, commands); + } + + private static IoFatReportPagePlan BuildTimeSyncPage( + NativeFatPrintPreviewSnapshot snapshot, + NativeFatTimeSyncReportEvidence evidence, + int pageNumber, + DateTimeOffset createdAt) + { + var commands = new List(); + AddHeader(commands, "IEC 61850 Time Synchronization Evidence", "Verified device-side time evidence"); + AddScopeCard( + commands, + snapshot, + $"Evaluated · {NativeFatReportFormatting.LocalTimestamp(evidence.VerifiedAtUtc)} · Local time", + "Time Sync OK"); + + Rect(commands, Margin, 438d, ContentWidth, 68d, 4d, SoftPass, Border, 0.65d); + Text(commands, Margin + 12d, 417d, 110d, "RESULT", IoFatReportFontKind.Bold, 6.4d, Muted); + Text(commands, Margin + 12d, 394d, 110d, "OK", IoFatReportFontKind.Bold, 13.2d, Pass); + Text(commands, Margin + 126d, 417d, ContentWidth - 138d, "VERIFICATION BASIS", IoFatReportFontKind.Bold, 6.4d, Muted); + var summaryLines = Wrap(evidence.Summary, 104, 2); + for (var index = 0; index < summaryLines.Count; index++) + Text(commands, Margin + 126d, 399d - (index * 12d), ContentWidth - 138d, summaryLines[index], IoFatReportFontKind.Regular, 7.4d, Ink); + + Text( + commands, + Margin, + 350d, + ContentWidth, + evidence.LtmsPresent + ? $"LTMS verified · {evidence.FreshPrimaryTimestampCount:N0} fresh independent IEC timestamp(s)" + : $"LTMS not exposed · {evidence.FreshPrimaryTimestampCount:N0} fresh independent IEC timestamps verified", + IoFatReportFontKind.Bold, + 7.4d, + Navy); + + var widths = new[] { 82d, 226d, 92d, 68d, 158d, 88d, 68d }; + var headers = new[] { "Evidence", "IEC 61850 Reference", "Value", "Quality", "IED Timestamp", "Delta", "Result" }; + var y = 330d; + DrawTableHeader(commands, widths, headers, y); + y -= TableHeaderHeight; + + foreach (var point in evidence.SupportingPoints) + { + var values = new[] + { + Fit(point.Role, 14), + Fit(FirstNonEmpty(point.IecReference, point.SignalName), 42), + Fit(point.Value, 16), + Fit(NativeFatReportFormatting.Quality(point.Quality), 12), + Fit(NativeFatReportFormatting.LocalTimestamp(point.DeviceTimestamp), 28), + point.DeltaSeconds.HasValue + ? $"{point.DeltaSeconds.Value:0.000} s" + : "—", + "OK" + }; + DrawRow(commands, widths, values, y, TableRowHeight, resultColumn: 6); + y -= TableRowHeight; + } + + AddFooter(commands, pageNumber, createdAt, "Read-only evaluator; SNTP activity alone does not grant OK."); + return new IoFatReportPagePlan(pageNumber, PageWidth, PageHeight, commands); + } + + private static void AddHeader(ICollection commands, string title, string subtitle) + { + NativeFatReportBranding.AddLogo(commands, PageWidth - Margin - 102d, 582d); + Text(commands, Margin, 566d, 490d, "IEC 61850 FAT", IoFatReportFontKind.Bold, 7.2d, Muted); + Text(commands, Margin, 544d, 570d, title, IoFatReportFontKind.Bold, 16.4d, Navy); + Text(commands, Margin, 522d, 560d, subtitle, IoFatReportFontKind.Regular, 8.0d, Muted); + Line(commands, Margin, 498d, PageWidth - Margin, 498d, Border, 0.8d); + } + + private static void AddScopeCard( + ICollection commands, + NativeFatPrintPreviewSnapshot snapshot, + string detail, + string result) + { + Rect(commands, Margin, 482d, ContentWidth, 34d, 3d, SoftBlue, Border, 0.6d); + Text(commands, Margin + 10d, 461d, 300d, + $"IED: {Clean(snapshot.IedName)} · Endpoint: {Clean(snapshot.IpAddress)}:{snapshot.Port}", + IoFatReportFontKind.Bold, 8.0d, Ink); + Text(commands, Margin + 310d, 461d, 340d, detail, IoFatReportFontKind.Regular, 6.6d, Muted); + Text(commands, PageWidth - Margin - 118d, 461d, 108d, result, IoFatReportFontKind.Bold, 7.5d, Pass); + } + + private static void DrawTableHeader( + ICollection commands, + IReadOnlyList widths, + IReadOnlyList headers, + double y) + { + var x = Margin; + for (var index = 0; index < headers.Count; index++) + { + Rect(commands, x, y, widths[index], TableHeaderHeight, 0d, SoftBlue, Border, 0.45d); + Text(commands, x + 5d, CenteredBaseline(y, TableHeaderHeight), widths[index] - 10d, headers[index], IoFatReportFontKind.Bold, TableHeaderFontSize, Blue); + x += widths[index]; + } + } + + private static void DrawRow( + ICollection commands, + IReadOnlyList widths, + IReadOnlyList values, + double y, + double height, + int resultColumn) + { + var x = Margin; + var baseline = CenteredBaseline(y, height); + for (var index = 0; index < values.Count; index++) + { + Rect(commands, x, y, widths[index], height, 0d, White, Border, 0.4d); + Text( + commands, + x + 5d, + baseline, + widths[index] - 10d, + values[index], + index == resultColumn ? IoFatReportFontKind.Bold : index is 1 or 4 or 5 ? IoFatReportFontKind.Mono : IoFatReportFontKind.Regular, + index is 1 or 4 or 5 ? TableMonoFontSize : TableBodyFontSize, + index == resultColumn ? Pass : Ink); + x += widths[index]; + } + } + + private static double CenteredBaseline(double top, double height) + => top - (height / 2d) - 2d; + + private static void AddFooter( + ICollection commands, + int pageNumber, + DateTimeOffset createdAt, + string note) + { + Line(commands, Margin, 42d, PageWidth - Margin, 42d, Border, 0.6d); + Text(commands, Margin, 24d, 650d, + $"FAT evidence captured · {NativeFatReportFormatting.LocalTimestamp(createdAt)} · Local time | {note}", + IoFatReportFontKind.Regular, 6.5d, Muted); + Text(commands, PageWidth - Margin - 118d, 24d, 118d, + $"Page {pageNumber} / {pageNumber}", + IoFatReportFontKind.Regular, 6.5d, Muted); + } + + private static string RecordCountText(int count) + => count == 1 ? "1 record" : $"{count:N0} records"; + + private static string FormatSize(long knownSizeBytes, bool hasUnknownSize) + { + var prefix = hasUnknownSize ? ">= " : string.Empty; + var size = Math.Max(0L, knownSizeBytes); + if (size >= 1024L * 1024L * 1024L) + return $"{prefix}{size / (1024d * 1024d * 1024d):0.##} GB"; + if (size >= 1024L * 1024L) + return $"{prefix}{size / (1024d * 1024d):0.##} MB"; + if (size >= 1024L) + return $"{prefix}{size / 1024d:0.##} KB"; + return hasUnknownSize && size == 0 ? "Unknown" : $"{size:N0} B"; + } + + private static IReadOnlyList Wrap(string? value, int maxChars, int maxLines) + { + var remaining = Clean(value); + var lines = new List(); + while (remaining.Length > maxChars && lines.Count < maxLines - 1) + { + var split = remaining.LastIndexOf(' ', maxChars); + if (split < maxChars / 2) + split = maxChars; + lines.Add(remaining[..split].Trim()); + remaining = remaining[split..].Trim(); + } + lines.Add(Fit(remaining, maxChars)); + return lines; + } + + private static string Fit(string? value, int maxChars) + { + var text = Clean(value); + return text.Length <= maxChars ? text : text[..Math.Max(1, maxChars - 1)] + "…"; + } + + private static string FirstNonEmpty(params string?[] values) + => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? "—"; + + private static string Clean(string? value) + => string.IsNullOrWhiteSpace(value) ? "—" : value.Trim(); + + private static void Rect( + ICollection commands, + double x, + double top, + double width, + double height, + double radius, + IoFatReportColor fill, + IoFatReportColor stroke, + double strokeThickness) + => commands.Add(new IoFatReportRectCommand(x, top, width, height, radius, fill, stroke, strokeThickness)); + + private static void Line( + ICollection commands, + double x1, + double y1, + double x2, + double y2, + IoFatReportColor stroke, + double strokeThickness) + => commands.Add(new IoFatReportLineCommand(x1, y1, x2, y2, stroke, strokeThickness)); + + private static void Text( + ICollection commands, + double x, + double baselineY, + double width, + string text, + IoFatReportFontKind font, + double fontSize, + IoFatReportColor color) + => commands.Add(new IoFatReportTextCommand(x, baselineY, width, text, font, fontSize, color)); +} diff --git a/Services/IoTesting/NativeFatCanonicalEvidenceOverlay.cs b/Services/IoTesting/NativeFatCanonicalEvidenceOverlay.cs new file mode 100644 index 000000000..a22a87416 --- /dev/null +++ b/Services/IoTesting/NativeFatCanonicalEvidenceOverlay.cs @@ -0,0 +1,441 @@ +using System.Globalization; +using ArIED61850Tester.Models; +using ArIED61850Tester.Models.IoTesting; + +namespace ArIED61850Tester.Services.IoTesting; + +public enum NativeFatEvidenceField +{ + Value1, + Value1Timestamp, + Value2, + Value2Timestamp, + Result +} + +/// +/// Sparse FAT-only evidence keyed exclusively by stable IEC identity: IEDName + IEC Telegram. +/// Runtime DeviceId, row index, SelectedIndex and display labels are never evidence identity. +/// +public static class NativeFatCanonicalEvidenceOverlay +{ + public static string BuildRowKey(Iec61850MonitorPoint point) + { + ArgumentNullException.ThrowIfNull(point); + return TryBuildRowKey(point, out var rowKey) ? rowKey : string.Empty; + } + + public static bool TryBuildRowKey(Iec61850MonitorPoint point, out string rowKey) + { + ArgumentNullException.ThrowIfNull(point); + return TryBuildRowKey(point.DeviceName, point.IecTelegram, out rowKey); + } + + internal static bool TryBuildRowKey(string? iedName, string? iecTelegram, out string rowKey) + { + var normalizedIedName = NormalizeIedName(iedName); + var normalizedTelegram = NormalizeTelegram(iecTelegram); + if (normalizedIedName.Length == 0 || normalizedTelegram.Length == 0) + { + rowKey = string.Empty; + return false; + } + + rowKey = $"{normalizedIedName}|{normalizedTelegram}"; + return true; + } + + public static string Read( + NativeFatIedSessionCacheState cache, + Iec61850MonitorPoint point, + NativeFatEvidenceField field) + { + ArgumentNullException.ThrowIfNull(cache); + ArgumentNullException.ThrowIfNull(point); + if (!TryBuildRowKey(point, out var key)) + return string.Empty; + + lock (cache.EvidenceByRow) + { + if (!cache.EvidenceByRow.TryGetValue(key, out var slot)) + return string.Empty; + + return field switch + { + NativeFatEvidenceField.Value1 => RawValue(slot.Value1Evidence, slot.Value1), + NativeFatEvidenceField.Value1Timestamp => TimestampValue(slot.Value1Evidence), + NativeFatEvidenceField.Value2 => RawValue(slot.Value2Evidence, slot.Value2), + NativeFatEvidenceField.Value2Timestamp => TimestampValue(slot.Value2Evidence), + NativeFatEvidenceField.Result => ResolveResult(slot), + _ => string.Empty + }; + } + } + + public static string ReadRaw( + NativeFatIedSessionCacheState cache, + Iec61850MonitorPoint point, + NativeFatEvidenceField field) + { + ArgumentNullException.ThrowIfNull(cache); + ArgumentNullException.ThrowIfNull(point); + if (!TryBuildRowKey(point, out var key)) + return string.Empty; + + lock (cache.EvidenceByRow) + { + if (!cache.EvidenceByRow.TryGetValue(key, out var slot)) + return string.Empty; + + return field switch + { + NativeFatEvidenceField.Value1 => RawValue(slot.Value1Evidence, slot.Value1), + NativeFatEvidenceField.Value1Timestamp => TimestampValue(slot.Value1Evidence), + NativeFatEvidenceField.Value2 => RawValue(slot.Value2Evidence, slot.Value2), + NativeFatEvidenceField.Value2Timestamp => TimestampValue(slot.Value2Evidence), + NativeFatEvidenceField.Result => slot.Result, + _ => string.Empty + }; + } + } + + /// + /// Compatibility display form retained for legacy/manual consumers. Native FAT no longer + /// uses this combined text because values and timestamps have dedicated columns. + /// + public static string ReadDisplay( + NativeFatIedSessionCacheState cache, + Iec61850MonitorPoint point, + NativeFatEvidenceField field) + { + ArgumentNullException.ThrowIfNull(cache); + ArgumentNullException.ThrowIfNull(point); + if (!TryBuildRowKey(point, out var key)) + return string.Empty; + + lock (cache.EvidenceByRow) + { + if (!cache.EvidenceByRow.TryGetValue(key, out var slot)) + return string.Empty; + + return field switch + { + NativeFatEvidenceField.Value1 => DisplayValue(slot.Value1Evidence, slot.Value1), + NativeFatEvidenceField.Value1Timestamp => TimestampValue(slot.Value1Evidence), + NativeFatEvidenceField.Value2 => DisplayValue(slot.Value2Evidence, slot.Value2), + NativeFatEvidenceField.Value2Timestamp => TimestampValue(slot.Value2Evidence), + NativeFatEvidenceField.Result => ResolveResult(slot), + _ => string.Empty + }; + } + } + + public static FatValueEvidence? ReadCapture( + NativeFatIedSessionCacheState cache, + Iec61850MonitorPoint point, + NativeFatEvidenceField field) + { + ArgumentNullException.ThrowIfNull(cache); + ArgumentNullException.ThrowIfNull(point); + if (!TryBuildRowKey(point, out var key)) + return null; + + lock (cache.EvidenceByRow) + { + if (!cache.EvidenceByRow.TryGetValue(key, out var slot)) + return null; + + return field switch + { + NativeFatEvidenceField.Value1 or NativeFatEvidenceField.Value1Timestamp => slot.Value1Evidence, + NativeFatEvidenceField.Value2 or NativeFatEvidenceField.Value2Timestamp => slot.Value2Evidence, + _ => null + }; + } + } + + public static void Write( + NativeFatIedSessionCacheState cache, + Iec61850MonitorPoint point, + NativeFatEvidenceField field, + string? value) + { + ArgumentNullException.ThrowIfNull(cache); + ArgumentNullException.ThrowIfNull(point); + if (field is NativeFatEvidenceField.Value1Timestamp or NativeFatEvidenceField.Value2Timestamp) + return; + if (!TryBuildRowKey(point, out var key)) + return; + + var supplied = value ?? string.Empty; + if ((field is NativeFatEvidenceField.Value1 or NativeFatEvidenceField.Value2) && + string.Equals(ReadRaw(cache, point, field).Trim(), supplied.Trim(), StringComparison.Ordinal)) + { + return; + } + + var text = (field is NativeFatEvidenceField.Value1 or NativeFatEvidenceField.Value2) + ? StripDisplayTimestamp(supplied) + : supplied.Trim(); + + if ((field is NativeFatEvidenceField.Value1 or NativeFatEvidenceField.Value2) && + !string.IsNullOrWhiteSpace(text)) + { + WriteCapture(cache, point, field, text, FatEvidenceCaptureKind.OperatorRecapture, DateTimeOffset.Now); + return; + } + + lock (cache.EvidenceByRow) + { + if (!cache.EvidenceByRow.TryGetValue(key, out var slot)) + { + if (string.IsNullOrWhiteSpace(text)) + return; + slot = new NativeFatEvidenceSlotState(); + cache.EvidenceByRow[key] = slot; + } + + switch (field) + { + case NativeFatEvidenceField.Value1: + slot.Value1 = text; + slot.Value1Evidence = null; + break; + case NativeFatEvidenceField.Value2: + slot.Value2 = text; + slot.Value2Evidence = null; + break; + case NativeFatEvidenceField.Result: + slot.Result = text; + break; + } + + RemoveIfEmpty(cache, key, slot); + } + } + + public static void WriteCapture( + NativeFatIedSessionCacheState cache, + Iec61850MonitorPoint point, + NativeFatEvidenceField field, + string rawValue, + FatEvidenceCaptureKind captureKind, + DateTimeOffset capturedAt) + { + ArgumentNullException.ThrowIfNull(cache); + ArgumentNullException.ThrowIfNull(point); + if (field is not (NativeFatEvidenceField.Value1 or NativeFatEvidenceField.Value2)) + throw new ArgumentOutOfRangeException(nameof(field), field, "Only Value 1 / Value 2 are structured captures."); + if (!TryBuildRowKey(point, out var key) || string.IsNullOrWhiteSpace(rawValue)) + return; + + var slotKind = field == NativeFatEvidenceField.Value1 ? FatValueSlot.Value1 : FatValueSlot.Value2; + var evidence = new FatValueEvidence( + Guid.NewGuid(), + slotKind, + captureKind, + rawValue.Trim(), + capturedAt, + IoTestValueNormalizer.ParseIedTimestamp(point.DeviceTimestamp), + string.IsNullOrWhiteSpace(point.Quality) ? "Unknown" : point.Quality.Trim(), + string.IsNullOrWhiteSpace(point.SourceMode) ? "Engineering live" : point.SourceMode.Trim(), + point.Sequence, + -1); + + lock (cache.EvidenceByRow) + { + if (!cache.EvidenceByRow.TryGetValue(key, out var slot)) + { + slot = new NativeFatEvidenceSlotState(); + cache.EvidenceByRow[key] = slot; + } + + if (field == NativeFatEvidenceField.Value1) + { + slot.Value1 = evidence.RawValue; + slot.Value1Evidence = evidence; + } + else + { + slot.Value2 = evidence.RawValue; + slot.Value2Evidence = evidence; + } + } + } + + public static bool PromoteValue2ToValue1(NativeFatIedSessionCacheState cache, Iec61850MonitorPoint point) + { + ArgumentNullException.ThrowIfNull(cache); + ArgumentNullException.ThrowIfNull(point); + if (!TryBuildRowKey(point, out var key)) + return false; + + lock (cache.EvidenceByRow) + { + if (!cache.EvidenceByRow.TryGetValue(key, out var slot)) + return false; + + var raw = RawValue(slot.Value2Evidence, slot.Value2); + if (string.IsNullOrWhiteSpace(raw)) + return false; + + slot.Value1 = raw; + slot.Value1Evidence = slot.Value2Evidence is null ? null : slot.Value2Evidence with { Slot = FatValueSlot.Value1 }; + return true; + } + } + + public static int MergeMissing( + NativeFatIedSessionCacheState cache, + IReadOnlyDictionary hydratedEvidence) + { + ArgumentNullException.ThrowIfNull(cache); + ArgumentNullException.ThrowIfNull(hydratedEvidence); + var mergedRows = 0; + + lock (cache.EvidenceByRow) + { + foreach (var pair in hydratedEvidence) + { + if (string.IsNullOrWhiteSpace(pair.Key) || pair.Value is null || IsEmpty(pair.Value)) + continue; + + var incoming = pair.Value; + if (!cache.EvidenceByRow.TryGetValue(pair.Key, out var current)) + { + cache.EvidenceByRow[pair.Key] = Clone(incoming); + mergedRows++; + continue; + } + + var changed = false; + if (!HasValue1(current) && HasValue1(incoming)) + { + current.Value1 = RawValue(incoming.Value1Evidence, incoming.Value1); + current.Value1Evidence = incoming.Value1Evidence; + changed = true; + } + if (!HasValue2(current) && HasValue2(incoming)) + { + current.Value2 = RawValue(incoming.Value2Evidence, incoming.Value2); + current.Value2Evidence = incoming.Value2Evidence; + changed = true; + } + if (string.IsNullOrWhiteSpace(current.Result) && !string.IsNullOrWhiteSpace(incoming.Result)) + { + current.Result = incoming.Result; + changed = true; + } + if (changed) + mergedRows++; + } + } + + return mergedRows; + } + + public static IReadOnlyDictionary Snapshot(NativeFatIedSessionCacheState cache) + { + ArgumentNullException.ThrowIfNull(cache); + lock (cache.EvidenceByRow) + { + return cache.EvidenceByRow.ToDictionary( + pair => pair.Key, + pair => Clone(pair.Value), + StringComparer.OrdinalIgnoreCase); + } + } + + internal static string NormalizeIedName(string? iedName) + => (iedName ?? string.Empty).Trim().ToLowerInvariant(); + + internal static string NormalizeTelegram(string? iecTelegram) + { + var text = (iecTelegram ?? string.Empty) + .Trim() + .Replace('$', '.') + .Replace("..", ".", StringComparison.Ordinal) + .ToLowerInvariant(); + while (text.Contains("..", StringComparison.Ordinal)) + text = text.Replace("..", ".", StringComparison.Ordinal); + return text.Trim('.'); + } + + private static string ResolveResult(NativeFatEvidenceSlotState slot) + { + var result = !string.IsNullOrWhiteSpace(slot.Result) + ? slot.Result.Trim() + : HasValue1(slot) && HasValue2(slot) ? "COMPLETE" : string.Empty; + + // COMPLETE remains the native/raw evidence state. Customer/operator-facing reads + // present the completed state as OK; ReadRaw and persisted slot.Result stay untouched. + return result.Equals("COMPLETE", StringComparison.OrdinalIgnoreCase) ? "OK" : result; + } + + private static string StripDisplayTimestamp(string value) + { + var text = value?.Trim() ?? string.Empty; + var separator = text.LastIndexOf(" - ", StringComparison.Ordinal); + if (separator <= 0) + return text; + + var suffix = text[(separator + 3)..]; + return DateTime.TryParseExact( + suffix, + "yyyy-MM-dd HH:mm:ss.fff", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out _) + ? text[..separator].Trim() + : text; + } + + private static string DisplayValue(FatValueEvidence? evidence, string legacyRaw) + { + var raw = RawValue(evidence, legacyRaw); + if (string.IsNullOrWhiteSpace(raw)) + return string.Empty; + if (evidence is null) + return raw; + var timestamp = evidence.IedTimestamp ?? evidence.CapturedAt; + return $"{raw} - {timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture)}"; + } + + private static string TimestampValue(FatValueEvidence? evidence) + { + if (evidence is null) + return string.Empty; + var timestamp = evidence.IedTimestamp ?? evidence.CapturedAt; + return timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture); + } + + private static string RawValue(FatValueEvidence? evidence, string legacyRaw) + => !string.IsNullOrWhiteSpace(evidence?.RawValue) + ? evidence.RawValue.Trim() + : legacyRaw?.Trim() ?? string.Empty; + + private static bool HasValue1(NativeFatEvidenceSlotState slot) + => !string.IsNullOrWhiteSpace(RawValue(slot.Value1Evidence, slot.Value1)); + + private static bool HasValue2(NativeFatEvidenceSlotState slot) + => !string.IsNullOrWhiteSpace(RawValue(slot.Value2Evidence, slot.Value2)); + + private static bool IsEmpty(NativeFatEvidenceSlotState slot) + => !HasValue1(slot) && !HasValue2(slot) && string.IsNullOrWhiteSpace(slot.Result); + + private static NativeFatEvidenceSlotState Clone(NativeFatEvidenceSlotState source) + => new() + { + Value1 = RawValue(source.Value1Evidence, source.Value1), + Value2 = RawValue(source.Value2Evidence, source.Value2), + Value1Evidence = source.Value1Evidence, + Value2Evidence = source.Value2Evidence, + Result = source.Result + }; + + private static void RemoveIfEmpty(NativeFatIedSessionCacheState cache, string key, NativeFatEvidenceSlotState slot) + { + if (IsEmpty(slot)) + cache.EvidenceByRow.Remove(key); + } +} diff --git a/Services/IoTesting/NativeFatCommandFeedbackCorrelation.cs b/Services/IoTesting/NativeFatCommandFeedbackCorrelation.cs new file mode 100644 index 000000000..8ebcb3dd3 --- /dev/null +++ b/Services/IoTesting/NativeFatCommandFeedbackCorrelation.cs @@ -0,0 +1,63 @@ +using ArIED61850Tester.Models; + +namespace ArIED61850Tester.Services.IoTesting; + +/// +/// P4E correlation boundary between an already-executed IEC 61850 control request and +/// the canonical Engineering feedback row. Correlation is identity-only: the control +/// model's explicit StatusReference is converted to IEDName + IEC Telegram and must match +/// exactly one canonical row. Missing, cross-IED, or duplicate matches fail closed. +/// +/// This service owns no command transport, reconnect, polling, SCL parsing, or live rows. +/// +internal static class NativeFatCommandFeedbackCorrelation +{ + internal static Iec61850MonitorPoint? Resolve( + Iec61850MonitorDevice device, + Iec61850ControlCapabilities capabilities) + { + ArgumentNullException.ThrowIfNull(device); + ArgumentNullException.ThrowIfNull(capabilities); + + return Resolve(device, capabilities.StatusReference); + } + + internal static Iec61850MonitorPoint? Resolve( + Iec61850MonitorDevice device, + string? statusReference) + { + ArgumentNullException.ThrowIfNull(device); + if (string.IsNullOrWhiteSpace(device.Name) || string.IsNullOrWhiteSpace(statusReference)) + return null; + + var feedbackTelegram = Iec61850MonitorPoint.StripIedNamePrefix( + statusReference, + device.Name); + if (!NativeFatCanonicalEvidenceOverlay.TryBuildRowKey( + device.Name, + feedbackTelegram, + out var expectedKey)) + { + return null; + } + + Iec61850MonitorPoint? match = null; + foreach (var point in device.Points) + { + if (!NativeFatCanonicalEvidenceOverlay.TryBuildRowKey(point, out var candidateKey) || + !string.Equals(candidateKey, expectedKey, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + // Ambiguous canonical identity is unsafe for FAT evidence correlation. + // Never pick first/last/index ordering as a tiebreaker. + if (match != null) + return null; + + match = point; + } + + return match; + } +} diff --git a/Services/IoTesting/NativeFatDiagnosticsService.cs b/Services/IoTesting/NativeFatDiagnosticsService.cs new file mode 100644 index 000000000..eab92ee30 --- /dev/null +++ b/Services/IoTesting/NativeFatDiagnosticsService.cs @@ -0,0 +1,284 @@ +using System.Globalization; +using AR.Iec61850.FaultRecords; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester.Services.IoTesting; + +public sealed record NativeFatTimeSyncPointEvidence( + string Role, + string SignalName, + string IecReference, + string Value, + string Quality, + string DeviceTimestamp, + double? DeltaSeconds, + bool Trusted); + +public sealed record NativeFatTimeSyncDiagnosticResult( + bool IsSynchronized, + string Verdict, + string Summary, + bool LtmsPresent, + bool LtmsTrusted, + int FreshPrimaryTimestampCount, + bool ExplicitNegativeSyncStatus, + IReadOnlyList PrimaryEvidence, + IReadOnlyList SecondaryTelemetry); + +/// +/// Read-only native FAT diagnostics over the canonical Engineering rows. +/// No network reads, polling changes, reconnects, or hidden acquisition are allowed here. +/// LTMS plus fresh IEC timestamp/quality evidence is authoritative. When LTMS is absent, +/// two independent fresh IEC timestamps are required as a conservative fallback. +/// Vendor sync flags (TimeSynchrnz/SyncSt/TimeSync) are secondary only: a positive flag +/// can never create an OK verdict, while an explicit negative flag fails closed. +/// +public static class NativeFatTimeSyncDiagnosticService +{ + public static readonly TimeSpan MaximumTrustedClockDelta = TimeSpan.FromSeconds(10); + + public static NativeFatTimeSyncDiagnosticResult Evaluate( + Iec61850MonitorDevice device, + DateTimeOffset nowUtc) + { + ArgumentNullException.ThrowIfNull(device); + nowUtc = nowUtc.ToUniversalTime(); + + var primary = new List(); + var secondary = new List(); + var ltmsPresent = false; + var ltmsTrusted = false; + var freshPrimaryCount = 0; + var explicitNegative = false; + + foreach (var point in device.Points) + { + var isLtms = IsLtms(point); + var isSecondary = IsSecondaryTelemetry(point); + var parsed = TryParseDeviceTimestamp(point.DeviceTimestamp); + var delta = parsed.HasValue + ? (nowUtc - parsed.Value.ToUniversalTime()).Duration() + : (TimeSpan?)null; + var goodQuality = Iec61850QualityPresentation.Classify(point.Quality) == Iec61850QualityPresentation.Good; + var timestampTrusted = parsed.HasValue && + delta.HasValue && + goodQuality && + delta.Value <= MaximumTrustedClockDelta; + + if (isLtms) + { + ltmsPresent = true; + var trusted = goodQuality && (timestampTrusted || IsUsable(point.Value)); + ltmsTrusted |= trusted; + primary.Add(ToEvidence("LTMS", point, delta, trusted)); + continue; + } + + if (isSecondary) + { + if (IsSyncStatusPoint(point) && NormalizeSyncBoolean(point.Value) == false) + explicitNegative = true; + secondary.Add(ToEvidence("Secondary", point, delta, timestampTrusted)); + continue; + } + + if (timestampTrusted) + { + freshPrimaryCount++; + primary.Add(ToEvidence("IEC timestamp", point, delta, true)); + } + } + + if (explicitNegative) + { + return new NativeFatTimeSyncDiagnosticResult( + false, + "NOT OK", + "Device-side synchronization telemetry explicitly reports not synchronized.", + ltmsPresent, + ltmsTrusted, + freshPrimaryCount, + true, + primary, + secondary); + } + + if (ltmsPresent) + { + if (ltmsTrusted && freshPrimaryCount >= 1) + { + return new NativeFatTimeSyncDiagnosticResult( + true, + "OK", + "LTMS evidence is present and cross-checked by a fresh good-quality IEC timestamp.", + true, + true, + freshPrimaryCount, + false, + primary, + secondary); + } + + return new NativeFatTimeSyncDiagnosticResult( + false, + "REVIEW", + ltmsTrusted + ? "LTMS is present, but no separate fresh good-quality IEC timestamp currently cross-checks it." + : "LTMS is present, but its live evidence is not currently trustworthy enough to prove synchronization.", + true, + ltmsTrusted, + freshPrimaryCount, + false, + primary, + secondary); + } + + if (freshPrimaryCount >= 2) + { + return new NativeFatTimeSyncDiagnosticResult( + true, + "OK", + "LTMS is not exposed; two or more independent fresh good-quality IEC timestamps agree with the ARSAS clock window.", + false, + false, + freshPrimaryCount, + false, + primary, + secondary); + } + + return new NativeFatTimeSyncDiagnosticResult( + false, + "REVIEW", + "Synchronization is not proven. LTMS is absent and fewer than two independent fresh good-quality IEC timestamps are available.", + false, + false, + freshPrimaryCount, + false, + primary, + secondary); + } + + private static NativeFatTimeSyncPointEvidence ToEvidence( + string role, + Iec61850MonitorPoint point, + TimeSpan? delta, + bool trusted) + => new( + role, + point.SignalName ?? string.Empty, + point.IecReference ?? string.Empty, + point.Value ?? string.Empty, + point.Quality ?? string.Empty, + point.DeviceTimestamp ?? string.Empty, + delta?.TotalSeconds, + trusted); + + private static bool IsLtms(Iec61850MonitorPoint point) + { + var reference = Normalize(point.IecReference); + var signal = Normalize(point.SignalName); + return reference.Contains("/ltms", StringComparison.Ordinal) || + reference.Contains(".ltms", StringComparison.Ordinal) || + reference.StartsWith("ltms", StringComparison.Ordinal) || + signal.Contains("ltms", StringComparison.Ordinal); + } + + private static bool IsSecondaryTelemetry(Iec61850MonitorPoint point) + { + var text = $"{Normalize(point.IecReference)} {Normalize(point.SignalName)}"; + return text.Contains("timesynchrnz", StringComparison.Ordinal) || + text.Contains("syncst", StringComparison.Ordinal) || + text.Contains("time sync", StringComparison.Ordinal) || + text.Contains("timesync", StringComparison.Ordinal) || + text.Contains("server 1", StringComparison.Ordinal) || + text.Contains("server1", StringComparison.Ordinal) || + text.Contains("server 2", StringComparison.Ordinal) || + text.Contains("server2", StringComparison.Ordinal) || + text.Contains("current server", StringComparison.Ordinal) || + text.Contains("currentserver", StringComparison.Ordinal); + } + + private static bool IsSyncStatusPoint(Iec61850MonitorPoint point) + { + var text = $"{Normalize(point.IecReference)} {Normalize(point.SignalName)}"; + return text.Contains("timesynchrnz", StringComparison.Ordinal) || + text.Contains("syncst", StringComparison.Ordinal) || + text.Contains("time sync", StringComparison.Ordinal) || + text.Contains("timesync", StringComparison.Ordinal); + } + + private static bool? NormalizeSyncBoolean(string? value) + { + var text = Normalize(value); + if (!IsUsable(text)) + return null; + + if (text is "true" or "1" or "1.0" or "on" or "active" or "synchronized" or "synchronised" or "synced" or "ok") + return true; + if (text is "false" or "0" or "0.0" or "off" or "inactive" or "not synchronized" or "not synchronised" or "unsynchronized" or "unsynchronised" or "not synced") + return false; + return null; + } + + private static DateTimeOffset? TryParseDeviceTimestamp(string? value) + { + var text = (value ?? string.Empty).Trim(); + if (!IsUsable(text)) + return null; + + if (DateTimeOffset.TryParse( + text, + CultureInfo.InvariantCulture, + DateTimeStyles.AllowWhiteSpaces | DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out var parsed)) + { + return parsed.ToUniversalTime(); + } + + return null; + } + + private static bool IsUsable(string? value) + { + var text = (value ?? string.Empty).Trim(); + return text.Length > 0 && text != "-" && text != "—" && + !text.Equals("unknown", StringComparison.OrdinalIgnoreCase) && + !text.Equals("pending", StringComparison.OrdinalIgnoreCase) && + !text.Contains("not probed", StringComparison.OrdinalIgnoreCase); + } + + private static string Normalize(string? value) + => (value ?? string.Empty).Trim().Replace('$', '.').ToLowerInvariant(); +} + +/// +/// Counts only files actually returned by the IEC 61850 fault-record/FileDirectory catalog. +/// No synthetic record/file count is permitted on the native FAT surface. +/// +public static class NativeFatComtradeDiagnosticService +{ + public static int CountDetectedFiles(IEnumerable? records) + { + if (records == null) + return 0; + + var files = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var record in records) + { + if (record?.Files == null) + continue; + + foreach (var file in record.Files) + { + var key = !string.IsNullOrWhiteSpace(file.RemotePath) + ? file.RemotePath.Trim() + : file.Name?.Trim(); + if (!string.IsNullOrWhiteSpace(key)) + files.Add(key); + } + } + + return files.Count; + } +} diff --git a/Services/IoTesting/NativeFatEvidenceHydrationService.cs b/Services/IoTesting/NativeFatEvidenceHydrationService.cs new file mode 100644 index 000000000..24fabb8aa --- /dev/null +++ b/Services/IoTesting/NativeFatEvidenceHydrationService.cs @@ -0,0 +1,823 @@ +using System.Diagnostics; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using ArIED61850Tester.Models; +using ArIED61850Tester.Models.IoTesting; + +namespace ArIED61850Tester.Services.IoTesting; + +public sealed record NativeFatEvidenceHydrationResult( + bool Succeeded, + bool SnapshotFound, + int LoadedRows, + int IgnoredRows, + long ElapsedMilliseconds, + string Message, + IReadOnlyDictionary EvidenceByRow); + +/// +/// P2 local evidence persistence/hydration for the native Engineering FAT surface. +/// This service owns only sparse Value 1 / Value 2 / Result data. It never touches +/// Engineering acquisition, SCL, reports, MMS sessions, polling cadence, or canonical rows. +/// Existing IO FAT snapshots are read as passive evidence provenance only; opening FAT never +/// opens/restores their workspace model or starts any legacy bootstrap path. +/// +public sealed class NativeFatEvidenceHydrationService : IDisposable +{ + internal const string SnapshotSchema = "ARSAS-NATIVE-FAT-EVIDENCE-2.0"; + internal const string LegacySnapshotSchema = "ARSAS-NATIVE-FAT-EVIDENCE-1.0"; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; + + private static readonly HashSet FunctionalConstraintTokens = new( + new[] { "st", "mx", "sp", "sv", "cf", "dc", "sg", "se", "sr", "or", "bl", "ex", "co", "us", "ms", "rp", "br", "lg", "go", "gs" }, + StringComparer.OrdinalIgnoreCase); + + private readonly string _rootDirectory; + private readonly string _legacyProjectsRoot; + private readonly SemaphoreSlim _ioGate = new(1, 1); + private bool _disposed; + + public NativeFatEvidenceHydrationService( + string? rootDirectory = null, + string? legacyProjectsRoot = null) + { + var usingDefaultRoot = string.IsNullOrWhiteSpace(rootDirectory); + _rootDirectory = usingDefaultRoot + ? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "ARSAS", + "Native FAT Evidence") + : Path.GetFullPath(rootDirectory!); + + _legacyProjectsRoot = !string.IsNullOrWhiteSpace(legacyProjectsRoot) + ? Path.GetFullPath(legacyProjectsRoot) + : usingDefaultRoot + ? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "ARSAS", + "IO Testing Projects") + : string.Empty; + } + + public async Task HydrateAsync( + Iec61850MonitorDevice device, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(device); + ThrowIfDisposed(); + + // Snapshot canonical identity synchronously on the caller/UI thread. Everything + // after this point is file IO / JSON work and does not enumerate the live collection. + var identity = CaptureIdentity(device); + var canonical = CaptureCanonicalIdentity(device); + var stopwatch = Stopwatch.StartNew(); + var path = SnapshotPath(identity.DeviceName); + var legacyNativePath = LegacyDeviceSnapshotPath(identity.DeviceId); + + await _ioGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + cancellationToken.ThrowIfCancellationRequested(); + var sourcePath = File.Exists(path) + ? path + : File.Exists(legacyNativePath) + ? legacyNativePath + : FindLegacyNativeSnapshotByIedName(identity.DeviceName); + if (sourcePath == null) + { + // First native launch may still have evidence in the pre-P1 persistent + // project snapshot. Read it passively and map only uniquely covered rows. + var legacy = await TryHydrateLegacySnapshotAsync( + identity, + canonical, + cancellationToken).ConfigureAwait(false); + stopwatch.Stop(); + if (legacy != null) + { + return new NativeFatEvidenceHydrationResult( + true, + true, + legacy.EvidenceByRow.Count, + legacy.IgnoredRows, + stopwatch.ElapsedMilliseconds, + $"Restored {legacy.EvidenceByRow.Count} legacy FAT evidence row(s) for {identity.DeviceName} in {stopwatch.ElapsedMilliseconds} ms without opening the legacy workspace.", + legacy.EvidenceByRow); + } + + return EmptyResult( + stopwatch.ElapsedMilliseconds, + $"No saved FAT evidence exists yet for {identity.DeviceName}."); + } + + var bytes = await File.ReadAllBytesAsync(sourcePath, cancellationToken).ConfigureAwait(false); + var document = JsonSerializer.Deserialize(bytes, JsonOptions) + ?? throw new InvalidDataException("Native FAT evidence snapshot is invalid."); + + if (!string.Equals(document.Schema, SnapshotSchema, StringComparison.Ordinal) && + !string.Equals(document.Schema, LegacySnapshotSchema, StringComparison.Ordinal)) + { + throw new InvalidDataException($"Unsupported native FAT evidence schema '{document.Schema}'."); + } + + // P4A/P4B identity is domain-stable. Runtime DeviceId is diagnostics only and + // may legitimately change after Engineering recreation or application restart. + if (!string.IsNullOrWhiteSpace(document.DeviceName) && + !NativeFatCanonicalEvidenceOverlay.NormalizeIedName(document.DeviceName) + .Equals( + NativeFatCanonicalEvidenceOverlay.NormalizeIedName(identity.DeviceName), + StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException("Native FAT evidence belongs to a different IEDName."); + } + + var loaded = new Dictionary(StringComparer.OrdinalIgnoreCase); + var ignored = 0; + foreach (var pair in document.EvidenceByRow ?? new Dictionary()) + { + cancellationToken.ThrowIfCancellationRequested(); + var resolvedKey = ResolvePersistedRowKey(pair.Key, identity, canonical, document.DeviceName); + if (resolvedKey == null) + { + ignored++; + continue; + } + + var source = pair.Value; + if (source == null || IsEmpty(source)) + continue; + + loaded[resolvedKey] = Clone(source); + } + + stopwatch.Stop(); + return new NativeFatEvidenceHydrationResult( + true, + true, + loaded.Count, + ignored, + stopwatch.ElapsedMilliseconds, + $"Restored {loaded.Count} sparse evidence row(s) for {identity.DeviceName} in {stopwatch.ElapsedMilliseconds} ms.", + loaded); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or InvalidDataException) + { + stopwatch.Stop(); + return new NativeFatEvidenceHydrationResult( + false, + File.Exists(path) || File.Exists(legacyNativePath), + 0, + 0, + stopwatch.ElapsedMilliseconds, + ex.Message, + new Dictionary(StringComparer.OrdinalIgnoreCase)); + } + finally + { + _ioGate.Release(); + } + } + + public async Task SaveAsync( + Iec61850MonitorDevice device, + NativeFatIedSessionCacheState cache, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(device); + ArgumentNullException.ThrowIfNull(cache); + ThrowIfDisposed(); + + // Capture device/row identity before yielding so background persistence never walks + // a WPF-bound ObservableCollection. Evidence itself is copied under its own lock. + var identity = CaptureIdentity(device); + var canonicalKeys = device.Points + .Select(NativeFatCanonicalEvidenceOverlay.BuildRowKey) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var evidence = NativeFatCanonicalEvidenceOverlay.Snapshot(cache) + .Where(pair => canonicalKeys.Contains(pair.Key)) + .ToDictionary( + pair => pair.Key, + pair => Clone(pair.Value), + StringComparer.OrdinalIgnoreCase); + var document = new NativeFatEvidenceDocument + { + Schema = SnapshotSchema, + SavedAtUtc = DateTimeOffset.UtcNow, + DeviceId = identity.DeviceId, + DeviceName = identity.DeviceName, + IpAddress = identity.IpAddress, + EvidenceByRow = evidence + }; + var bytes = JsonSerializer.SerializeToUtf8Bytes(document, JsonOptions); + var path = SnapshotPath(identity.DeviceName); + + await _ioGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + cancellationToken.ThrowIfCancellationRequested(); + Directory.CreateDirectory(_rootDirectory); + var temporary = path + ".tmp-" + Guid.NewGuid().ToString("N"); + try + { + await File.WriteAllBytesAsync(temporary, bytes, cancellationToken).ConfigureAwait(false); + File.Move(temporary, path, true); + } + finally + { + if (File.Exists(temporary)) + File.Delete(temporary); + } + } + finally + { + _ioGate.Release(); + } + } + + internal string SnapshotPath(string iedName) + { + var stableIdentity = NativeFatCanonicalEvidenceOverlay.NormalizeIedName(iedName); + var digest = SHA256.HashData(Encoding.UTF8.GetBytes(stableIdentity)); + var token = Convert.ToHexString(digest).ToLowerInvariant()[..24]; + return Path.Combine(_rootDirectory, $"{token}.native-fat-evidence.json"); + } + + private string LegacyDeviceSnapshotPath(string deviceId) + { + var digest = SHA256.HashData(Encoding.UTF8.GetBytes(deviceId.Trim().ToLowerInvariant())); + var token = Convert.ToHexString(digest).ToLowerInvariant()[..24]; + return Path.Combine(_rootDirectory, $"{token}.native-fat-evidence.json"); + } + + private string? FindLegacyNativeSnapshotByIedName(string iedName) + { + if (!Directory.Exists(_rootDirectory)) + return null; + + var normalized = NativeFatCanonicalEvidenceOverlay.NormalizeIedName(iedName); + foreach (var candidate in Directory + .EnumerateFiles(_rootDirectory, "*.native-fat-evidence.json", SearchOption.TopDirectoryOnly) + .OrderByDescending(File.GetLastWriteTimeUtc)) + { + try + { + using var stream = File.OpenRead(candidate); + using var document = JsonDocument.Parse(stream); + if (!document.RootElement.TryGetProperty("deviceName", out var name)) + continue; + if (NativeFatCanonicalEvidenceOverlay.NormalizeIedName(name.GetString()) == normalized) + return candidate; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) + { + continue; + } + } + + return null; + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + _ioGate.Dispose(); + } + + private async Task TryHydrateLegacySnapshotAsync( + NativeFatDeviceIdentity identity, + CanonicalEvidenceIdentity canonical, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(_legacyProjectsRoot) || !Directory.Exists(_legacyProjectsRoot)) + return null; + + string[] candidates; + try + { + candidates = Directory + .EnumerateFiles(_legacyProjectsRoot, "project.snapshot.json", SearchOption.AllDirectories) + .OrderByDescending(File.GetLastWriteTimeUtc) + .ToArray(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return null; + } + + foreach (var candidate in candidates) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + var bytes = await File.ReadAllBytesAsync(candidate, cancellationToken).ConfigureAwait(false); + using var document = JsonDocument.Parse(bytes); + if (!TryGetProjectIeds(document.RootElement, out var ieds) || + !TryFindLegacyIed(ieds, identity, out var legacyIed)) + { + continue; + } + + // Newest matching snapshot is authoritative, including an intentionally + // empty evidence set. Never resurrect older evidence after a later clear. + return ExtractLegacyEvidence(legacyIed, canonical); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or InvalidDataException) + { + // A damaged/unreadable unrelated historical snapshot must not block FAT. + continue; + } + } + + return null; + } + + private static bool TryGetProjectIeds(JsonElement root, out JsonElement ieds) + { + ieds = default; + if (!root.TryGetProperty("project", out var project) || + !project.TryGetProperty("ieds", out ieds) || + ieds.ValueKind != JsonValueKind.Array) + { + return false; + } + return true; + } + + private static bool TryFindLegacyIed( + JsonElement ieds, + NativeFatDeviceIdentity identity, + out JsonElement legacyIed) + { + legacyIed = default; + JsonElement? nameAndIp = null; + JsonElement? uniqueIp = null; + var ipMatches = 0; + + foreach (var ied in ieds.EnumerateArray()) + { + var liveDeviceId = GetString(ied, "liveDeviceId"); + if (!string.IsNullOrWhiteSpace(liveDeviceId) && + liveDeviceId.Equals(identity.DeviceId, StringComparison.OrdinalIgnoreCase)) + { + legacyIed = ied; + return true; + } + + var ip = GetString(ied, "ipAddress"); + if (!ip.Equals(identity.IpAddress, StringComparison.OrdinalIgnoreCase)) + continue; + + ipMatches++; + uniqueIp = ied; + if (GetString(ied, "iedName").Equals(identity.DeviceName, StringComparison.OrdinalIgnoreCase)) + nameAndIp = ied; + } + + if (nameAndIp.HasValue) + { + legacyIed = nameAndIp.Value; + return true; + } + + if (ipMatches == 1 && uniqueIp.HasValue) + { + legacyIed = uniqueIp.Value; + return true; + } + + return false; + } + + private static LegacyHydration ExtractLegacyEvidence( + JsonElement legacyIed, + CanonicalEvidenceIdentity canonical) + { + var loaded = new Dictionary(StringComparer.OrdinalIgnoreCase); + var ignored = 0; + if (!legacyIed.TryGetProperty("testPoints", out var testPoints) || + testPoints.ValueKind != JsonValueKind.Array) + { + return new LegacyHydration(loaded, ignored); + } + + foreach (var point in testPoints.EnumerateArray()) + { + if (!point.TryGetProperty("runtime", out var runtime) || runtime.ValueKind != JsonValueKind.Object) + continue; + + var value1Capture = GetEvidenceCapture(runtime, "value1Evidence", FatValueSlot.Value1) + ?? GetEvidenceCapture(runtime, "onEvidence", FatValueSlot.Value1); + var value2Capture = GetEvidenceCapture(runtime, "value2Evidence", FatValueSlot.Value2) + ?? GetEvidenceCapture(runtime, "offEvidence", FatValueSlot.Value2); + var value1 = value1Capture?.RawValue ?? GetEvidenceRaw(runtime, "value1Evidence"); + if (string.IsNullOrWhiteSpace(value1)) + value1 = GetEvidenceRaw(runtime, "onEvidence"); + var value2 = value2Capture?.RawValue ?? GetEvidenceRaw(runtime, "value2Evidence"); + if (string.IsNullOrWhiteSpace(value2)) + value2 = GetEvidenceRaw(runtime, "offEvidence"); + var result = ReadLegacyResult(point, runtime, value1, value2); + + if (string.IsNullOrWhiteSpace(value1) && + string.IsNullOrWhiteSpace(value2) && + string.IsNullOrWhiteSpace(result)) + { + continue; + } + + var rowKey = ResolveLegacyRowKey(point, canonical); + if (string.IsNullOrWhiteSpace(rowKey)) + { + ignored++; + continue; + } + + if (!loaded.TryGetValue(rowKey, out var slot)) + { + slot = new NativeFatEvidenceSlotState(); + loaded[rowKey] = slot; + } + + if (string.IsNullOrWhiteSpace(slot.Value1) && !string.IsNullOrWhiteSpace(value1)) + { + slot.Value1 = value1; + slot.Value1Evidence = value1Capture; + } + if (string.IsNullOrWhiteSpace(slot.Value2) && !string.IsNullOrWhiteSpace(value2)) + { + slot.Value2 = value2; + slot.Value2Evidence = value2Capture; + } + if (string.IsNullOrWhiteSpace(slot.Result) && !string.IsNullOrWhiteSpace(result)) + slot.Result = result; + } + + return new LegacyHydration(loaded, ignored); + } + + private static string? ResolvePersistedRowKey( + string persistedKey, + NativeFatDeviceIdentity identity, + CanonicalEvidenceIdentity canonical, + string persistedIedName) + { + if (string.IsNullOrWhiteSpace(persistedKey)) + return null; + if (canonical.RowKeys.Contains(persistedKey)) + return persistedKey; + + // Pre-P4A snapshots used runtime DeviceId|reference. Recover only through the + // existing unique IEC-reference alias map; ambiguity remains fail-closed. + var separator = persistedKey.IndexOf('|'); + if (separator <= 0 || separator >= persistedKey.Length - 1) + return null; + + var owner = persistedKey[..separator].Trim(); + var reference = persistedKey[(separator + 1)..].Trim(); + var ownerMatchesRuntime = owner.Equals(identity.DeviceId, StringComparison.OrdinalIgnoreCase); + var ownerMatchesIed = NativeFatCanonicalEvidenceOverlay.NormalizeIedName(owner) + .Equals( + NativeFatCanonicalEvidenceOverlay.NormalizeIedName(identity.DeviceName), + StringComparison.OrdinalIgnoreCase); + var documentMatchesIed = !string.IsNullOrWhiteSpace(persistedIedName) && + NativeFatCanonicalEvidenceOverlay.NormalizeIedName(persistedIedName) + .Equals( + NativeFatCanonicalEvidenceOverlay.NormalizeIedName(identity.DeviceName), + StringComparison.OrdinalIgnoreCase); + if (!ownerMatchesRuntime && !ownerMatchesIed && !documentMatchesIed) + return null; + + var matches = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var alias in ReferenceAliases(reference)) + { + if (canonical.AliasToRowKey.TryGetValue(alias, out var rowKey) && + !string.IsNullOrWhiteSpace(rowKey)) + { + matches.Add(rowKey); + } + } + return matches.Count == 1 ? matches.First() : null; + } + + private static string? ResolveLegacyRowKey( + JsonElement point, + CanonicalEvidenceIdentity canonical) + { + var matches = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var property in new[] + { + "sourceIecReference", + "eventLogSearchReference", + "reportDisplayReference", + "objectReference", + "signalAddress" + }) + { + var reference = GetString(point, property); + foreach (var alias in ReferenceAliases(reference)) + { + if (canonical.AliasToRowKey.TryGetValue(alias, out var rowKey) && + !string.IsNullOrWhiteSpace(rowKey)) + { + matches.Add(rowKey); + } + } + } + + return matches.Count == 1 ? matches.First() : null; + } + + private static string ReadLegacyResult( + JsonElement point, + JsonElement runtime, + string value1, + string value2) + { + var state = ReadStateOrdinal(runtime); + if (state == 5) return "PASS"; + if (state == 6) return "REVIEW"; + if (state == 7) return "FAILED"; + + var reviewStatus = GetString(point, "reviewStatus").Trim(); + if (reviewStatus.Equals("PASS", StringComparison.OrdinalIgnoreCase) || + reviewStatus.Equals("REVIEW", StringComparison.OrdinalIgnoreCase) || + reviewStatus.Equals("FAILED", StringComparison.OrdinalIgnoreCase)) + { + return reviewStatus.ToUpperInvariant(); + } + + var captureMode = ReadCaptureModeOrdinal(point); + return captureMode == 1 && + !string.IsNullOrWhiteSpace(value1) && + !string.IsNullOrWhiteSpace(value2) + ? "COMPLETE" + : string.Empty; + } + + private static int ReadStateOrdinal(JsonElement runtime) + { + if (!runtime.TryGetProperty("state", out var state)) + return -1; + if (state.ValueKind == JsonValueKind.Number && state.TryGetInt32(out var ordinal)) + return ordinal; + if (state.ValueKind != JsonValueKind.String) + return -1; + + return (state.GetString() ?? string.Empty).Trim() switch + { + "Passed" => 5, + "Review" => 6, + "Failed" => 7, + _ => -1 + }; + } + + private static int ReadCaptureModeOrdinal(JsonElement point) + { + if (!point.TryGetProperty("captureMode", out var mode)) + return -1; + if (mode.ValueKind == JsonValueKind.Number && mode.TryGetInt32(out var ordinal)) + return ordinal; + if (mode.ValueKind == JsonValueKind.String && + string.Equals(mode.GetString(), "OperatorSnapshot", StringComparison.OrdinalIgnoreCase)) + { + return 1; + } + return 0; + } + + private static FatValueEvidence? GetEvidenceCapture( + JsonElement runtime, + string property, + FatValueSlot slot) + { + if (!runtime.TryGetProperty(property, out var evidence) || evidence.ValueKind != JsonValueKind.Object) + return null; + + var raw = GetString(evidence, "rawValue").Trim(); + if (raw.Length == 0) + return null; + + var capturedAt = GetDateTimeOffset(evidence, "capturedAt"); + var iedTimestamp = GetDateTimeOffset(evidence, "iedTimestamp"); + if (capturedAt == null && iedTimestamp == null) + return null; + + return new FatValueEvidence( + Guid.NewGuid(), + slot, + FatEvidenceCaptureKind.AutomaticValue, + raw, + capturedAt ?? iedTimestamp!.Value, + iedTimestamp, + GetString(evidence, "quality"), + GetString(evidence, "acquisitionSource"), + GetInt64(evidence, "sequence"), + GetInt64(evidence, "connectionGeneration", -1)); + } + + private static DateTimeOffset? GetDateTimeOffset(JsonElement element, string property) + { + var text = GetString(element, property); + return DateTimeOffset.TryParse( + text, + System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.AllowWhiteSpaces | System.Globalization.DateTimeStyles.RoundtripKind, + out var parsed) + ? parsed + : null; + } + + private static long GetInt64(JsonElement element, string property, long fallback = 0) + { + if (!element.TryGetProperty(property, out var value)) + return fallback; + if (value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out var number)) + return number; + return long.TryParse(value.ToString(), out number) ? number : fallback; + } + + private static string GetEvidenceRaw(JsonElement runtime, string property) + { + if (!runtime.TryGetProperty(property, out var evidence) || + evidence.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined || + evidence.ValueKind != JsonValueKind.Object) + { + return string.Empty; + } + + return GetString(evidence, "rawValue").Trim(); + } + + private static string GetString(JsonElement element, string property) + { + if (!element.TryGetProperty(property, out var value)) + return string.Empty; + return value.ValueKind == JsonValueKind.String + ? value.GetString() ?? string.Empty + : value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined + ? string.Empty + : value.ToString(); + } + + private static CanonicalEvidenceIdentity CaptureCanonicalIdentity(Iec61850MonitorDevice device) + { + var keys = new HashSet(StringComparer.OrdinalIgnoreCase); + var aliases = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var point in device.Points) + { + if (!NativeFatCanonicalEvidenceOverlay.TryBuildRowKey(point, out var key)) + continue; + keys.Add(key); + AddAliases(aliases, point.IecReference, key); + AddAliases(aliases, point.IecTelegram, key); + } + return new CanonicalEvidenceIdentity(keys, aliases); + } + + private static void AddAliases( + Dictionary aliases, + string? reference, + string rowKey) + { + foreach (var alias in ReferenceAliases(reference)) + { + if (aliases.TryGetValue(alias, out var existing)) + { + if (!string.Equals(existing, rowKey, StringComparison.OrdinalIgnoreCase)) + aliases[alias] = null; + } + else + { + aliases[alias] = rowKey; + } + } + } + + private static IEnumerable ReferenceAliases(string? reference) + { + var normalized = NormalizeReference(reference); + if (normalized.Length == 0) + yield break; + + yield return normalized; + var withoutFc = RemoveFunctionalConstraint(normalized); + if (!withoutFc.Equals(normalized, StringComparison.OrdinalIgnoreCase)) + yield return withoutFc; + } + + private static string NormalizeReference(string? reference) + { + var text = (reference ?? string.Empty) + .Trim() + .Replace('$', '.') + .Replace("..", ".", StringComparison.Ordinal) + .ToLowerInvariant(); + while (text.Contains("..", StringComparison.Ordinal)) + text = text.Replace("..", ".", StringComparison.Ordinal); + return text.Trim('.'); + } + + private static string RemoveFunctionalConstraint(string normalized) + { + var slash = normalized.IndexOf('/'); + if (slash < 0 || slash >= normalized.Length - 1) + return normalized; + + var domain = normalized[..(slash + 1)]; + var path = normalized[(slash + 1)..].Split('.', StringSplitOptions.RemoveEmptyEntries); + if (path.Length < 3 || !FunctionalConstraintTokens.Contains(path[1])) + return normalized; + + return domain + string.Join('.', path.Where((_, index) => index != 1)); + } + + private static NativeFatDeviceIdentity CaptureIdentity(Iec61850MonitorDevice device) + => new( + device.DeviceId, + device.Name, + device.IpAddress); + + private static NativeFatEvidenceSlotState Clone(NativeFatEvidenceSlotState source) + => new() + { + Value1 = source.Value1Evidence?.RawValue ?? source.Value1, + Value2 = source.Value2Evidence?.RawValue ?? source.Value2, + Value1Evidence = source.Value1Evidence, + Value2Evidence = source.Value2Evidence, + Result = source.Result + }; + + private static bool IsEmpty(NativeFatEvidenceSlotState slot) + => string.IsNullOrWhiteSpace(slot.Value1Evidence?.RawValue ?? slot.Value1) && + string.IsNullOrWhiteSpace(slot.Value2Evidence?.RawValue ?? slot.Value2) && + string.IsNullOrWhiteSpace(slot.Result); + + private static NativeFatEvidenceHydrationResult EmptyResult(long elapsedMilliseconds, string message) + => new( + true, + false, + 0, + 0, + elapsedMilliseconds, + message, + new Dictionary(StringComparer.OrdinalIgnoreCase)); + + private void ThrowIfDisposed() + { + ObjectDisposedException.ThrowIf(_disposed, this); + } + + private sealed record NativeFatDeviceIdentity( + string DeviceId, + string DeviceName, + string IpAddress); + + private sealed record CanonicalEvidenceIdentity( + HashSet RowKeys, + Dictionary AliasToRowKey); + + private sealed record LegacyHydration( + IReadOnlyDictionary EvidenceByRow, + int IgnoredRows); + + private sealed class NativeFatEvidenceDocument + { + public string Schema { get; set; } = SnapshotSchema; + public DateTimeOffset SavedAtUtc { get; set; } + public string DeviceId { get; set; } = string.Empty; + public string DeviceName { get; set; } = string.Empty; + public string IpAddress { get; set; } = string.Empty; + public Dictionary EvidenceByRow { get; set; } = + new(StringComparer.OrdinalIgnoreCase); + } +} + +public static class NativeFatEvidenceLoadingPresentation +{ + /// + /// One shared UI clock advances this phase for every unresolved evidence cell. + /// No cell owns a timer or animation object. + /// + public static string RollingDots(int phase) + => (Math.Abs(phase) % 3) switch + { + 0 => "·", + 1 => "··", + _ => "···" + }; +} diff --git a/Services/IoTesting/NativeFatEvidencePersistenceCoordinator.cs b/Services/IoTesting/NativeFatEvidencePersistenceCoordinator.cs new file mode 100644 index 000000000..f0a57f0fe --- /dev/null +++ b/Services/IoTesting/NativeFatEvidencePersistenceCoordinator.cs @@ -0,0 +1,204 @@ +using System.Diagnostics; +using ArIED61850Tester.Models; +using ArIED61850Tester.Models.IoTesting; + +namespace ArIED61850Tester.Services.IoTesting; + +/// +/// Immutable sparse FAT evidence captured at the evidence-change boundary. It contains only +/// stable IED identity and detached evidence; it never retains Engineering Points or WPF rows. +/// +internal sealed class NativeFatEvidenceDurabilitySnapshot +{ + private NativeFatEvidenceDurabilitySnapshot( + string deviceId, + string iedName, + string ipAddress, + IReadOnlyDictionary evidenceByRow) + { + DeviceId = deviceId; + IedName = iedName; + IpAddress = ipAddress; + StableIedName = NativeFatCanonicalEvidenceOverlay.NormalizeIedName(iedName); + EvidenceByRow = evidenceByRow; + } + + internal string DeviceId { get; } + internal string IedName { get; } + internal string IpAddress { get; } + internal string StableIedName { get; } + internal IReadOnlyDictionary EvidenceByRow { get; } + + internal static NativeFatEvidenceDurabilitySnapshot Capture( + Iec61850MonitorDevice source, + NativeFatIedSessionCacheState sourceCache) + { + ArgumentNullException.ThrowIfNull(source); + return Capture(source.DeviceId, source.Name, source.IpAddress, sourceCache); + } + + internal static NativeFatEvidenceDurabilitySnapshot Capture( + string deviceId, + string iedName, + string ipAddress, + NativeFatIedSessionCacheState sourceCache) + { + ArgumentNullException.ThrowIfNull(sourceCache); + + var detached = NativeFatCanonicalEvidenceOverlay.Snapshot(sourceCache) + .ToDictionary( + pair => pair.Key, + pair => Clone(pair.Value), + StringComparer.OrdinalIgnoreCase); + + return new NativeFatEvidenceDurabilitySnapshot( + deviceId?.Trim() ?? string.Empty, + iedName?.Trim() ?? string.Empty, + ipAddress?.Trim() ?? string.Empty, + detached); + } + + private static NativeFatEvidenceSlotState Clone(NativeFatEvidenceSlotState source) + => new() + { + Value1 = source.Value1Evidence?.RawValue ?? source.Value1, + Value2 = source.Value2Evidence?.RawValue ?? source.Value2, + Value1Evidence = source.Value1Evidence, + Value2Evidence = source.Value2Evidence, + Result = source.Result + }; +} + +/// +/// Lightweight per-IED persistence worker. No permanent thread exists: a worker is created +/// only while one stable IEDName has dirty evidence, coalesces short bursts, and serializes +/// writes so the newest generation is always the final JSON on disk. +/// +internal sealed class NativeFatEvidencePersistenceCoordinator +{ + private static readonly TimeSpan CoalesceWindow = TimeSpan.FromMilliseconds(120); + + private readonly NativeFatEvidenceStore _store; + private readonly object _gate = new(); + private readonly Dictionary _stateByIed = + new(StringComparer.OrdinalIgnoreCase); + + internal NativeFatEvidencePersistenceCoordinator(NativeFatEvidenceStore store) + => _store = store ?? throw new ArgumentNullException(nameof(store)); + + internal void Queue(NativeFatEvidenceDurabilitySnapshot snapshot) + { + ArgumentNullException.ThrowIfNull(snapshot); + if (string.IsNullOrWhiteSpace(snapshot.StableIedName)) + return; + + lock (_gate) + { + if (!_stateByIed.TryGetValue(snapshot.StableIedName, out var state)) + { + state = new IedWriteState(); + _stateByIed[snapshot.StableIedName] = state; + } + + state.Latest = snapshot; + state.Generation++; + if (state.Worker == null || state.Worker.IsCompleted) + state.Worker = Task.Run(() => RunWorkerAsync(state)); + } + } + + internal async Task DrainAsync(string iedName) + { + var key = NativeFatCanonicalEvidenceOverlay.NormalizeIedName(iedName); + while (true) + { + Task? worker; + lock (_gate) + { + if (!_stateByIed.TryGetValue(key, out var state) || state.Worker == null) + return; + worker = state.Worker; + } + + await worker.ConfigureAwait(false); + + lock (_gate) + { + if (!_stateByIed.TryGetValue(key, out var state) || + state.Worker == null || + ReferenceEquals(state.Worker, worker)) + { + return; + } + } + } + } + + internal async Task DrainAllAsync() + { + while (true) + { + Task[] workers; + lock (_gate) + { + workers = _stateByIed.Values + .Select(state => state.Worker) + .Where(worker => worker != null) + .Cast() + .Distinct() + .ToArray(); + } + + if (workers.Length == 0) + return; + + await Task.WhenAll(workers).ConfigureAwait(false); + } + } + + private async Task RunWorkerAsync(IedWriteState state) + { + while (true) + { + await Task.Delay(CoalesceWindow).ConfigureAwait(false); + + NativeFatEvidenceDurabilitySnapshot snapshot; + long generation; + lock (_gate) + { + snapshot = state.Latest + ?? throw new InvalidOperationException("FAT evidence worker has no pending snapshot."); + generation = state.Generation; + } + + try + { + await _store.SaveAsync(snapshot, CancellationToken.None).ConfigureAwait(false); + Trace.WriteLine( + $"[FAT evidence store] persisted {snapshot.EvidenceByRow.Count} sparse row(s); " + + $"ied={snapshot.IedName}; generation={generation}."); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) + { + Trace.WriteLine( + $"[FAT evidence store] persistence failed for {snapshot.IedName}: {ex.Message}"); + } + + lock (_gate) + { + if (generation == state.Generation) + { + state.Worker = null; + return; + } + } + } + } + + private sealed class IedWriteState + { + internal NativeFatEvidenceDurabilitySnapshot? Latest { get; set; } + internal long Generation { get; set; } + internal Task? Worker { get; set; } + } +} diff --git a/Services/IoTesting/NativeFatEvidenceStore.cs b/Services/IoTesting/NativeFatEvidenceStore.cs new file mode 100644 index 000000000..faaf3c0dc --- /dev/null +++ b/Services/IoTesting/NativeFatEvidenceStore.cs @@ -0,0 +1,348 @@ +using System.Diagnostics; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using ArIED61850Tester.Models; +using ArIED61850Tester.Models.IoTesting; + +namespace ArIED61850Tester.Services.IoTesting; + +/// +/// Durable sparse FAT evidence authority keyed by stable IEDName + IEC Telegram. +/// Loading never depends on Engineering Points being materialized and saving never walks +/// the live WPF-bound row collection. Start FAT is only a producer of evidence; this store +/// is available for automatic load as soon as an IED identity is known. +/// +internal sealed class NativeFatEvidenceStore : IDisposable +{ + internal const string SnapshotSchema = "ARSAS-NATIVE-FAT-EVIDENCE-2.0"; + private const string LegacySnapshotSchema = "ARSAS-NATIVE-FAT-EVIDENCE-1.0"; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; + + private readonly string _rootDirectory; + private readonly SemaphoreSlim _ioGate = new(1, 1); + private bool _disposed; + + internal NativeFatEvidenceStore(string? rootDirectory = null) + { + _rootDirectory = string.IsNullOrWhiteSpace(rootDirectory) + ? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "ARSAS", + "Native FAT Evidence") + : Path.GetFullPath(rootDirectory!); + } + + internal async Task LoadAsync( + string iedName, + CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + var normalizedIedName = NativeFatCanonicalEvidenceOverlay.NormalizeIedName(iedName); + if (normalizedIedName.Length == 0) + return NativeFatEvidenceStoreLoadResult.Empty("IEDName is empty."); + + var stopwatch = Stopwatch.StartNew(); + var preferredPath = SnapshotPath(iedName); + + await _ioGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + cancellationToken.ThrowIfCancellationRequested(); + var sourcePath = File.Exists(preferredPath) + ? preferredPath + : FindNewestSnapshotByIedName(iedName); + if (sourcePath == null) + { + stopwatch.Stop(); + return NativeFatEvidenceStoreLoadResult.Empty( + $"No saved FAT evidence exists yet for {iedName}.", + stopwatch.ElapsedMilliseconds); + } + + var bytes = await File.ReadAllBytesAsync(sourcePath, cancellationToken).ConfigureAwait(false); + var document = JsonSerializer.Deserialize(bytes, JsonOptions) + ?? throw new InvalidDataException("Native FAT evidence snapshot is invalid."); + + if (!string.Equals(document.Schema, SnapshotSchema, StringComparison.Ordinal) && + !string.Equals(document.Schema, LegacySnapshotSchema, StringComparison.Ordinal)) + { + throw new InvalidDataException($"Unsupported native FAT evidence schema '{document.Schema}'."); + } + + if (!string.IsNullOrWhiteSpace(document.DeviceName) && + !NativeFatCanonicalEvidenceOverlay.NormalizeIedName(document.DeviceName) + .Equals(normalizedIedName, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException("Native FAT evidence belongs to a different IEDName."); + } + + var loaded = new Dictionary(StringComparer.OrdinalIgnoreCase); + var ignored = 0; + foreach (var pair in document.EvidenceByRow ?? new Dictionary()) + { + cancellationToken.ThrowIfCancellationRequested(); + if (pair.Value == null || IsEmpty(pair.Value) || + !TryNormalizeRowKey(pair.Key, iedName, document.DeviceName, out var stableRowKey)) + { + ignored++; + continue; + } + + loaded[stableRowKey] = CloneForPersistence(pair.Value); + } + + stopwatch.Stop(); + return new NativeFatEvidenceStoreLoadResult( + true, + true, + sourcePath, + loaded.Count, + ignored, + stopwatch.ElapsedMilliseconds, + $"Loaded {loaded.Count} FAT evidence row(s) for {iedName}.", + loaded); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or InvalidDataException) + { + stopwatch.Stop(); + return new NativeFatEvidenceStoreLoadResult( + false, + File.Exists(preferredPath), + preferredPath, + 0, + 0, + stopwatch.ElapsedMilliseconds, + ex.Message, + new Dictionary(StringComparer.OrdinalIgnoreCase)); + } + finally + { + _ioGate.Release(); + } + } + + internal async Task SaveAsync( + NativeFatEvidenceDurabilitySnapshot snapshot, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(snapshot); + ThrowIfDisposed(); + + var evidence = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var pair in snapshot.EvidenceByRow) + { + if (!TryNormalizeRowKey(pair.Key, snapshot.IedName, snapshot.IedName, out var stableRowKey) || + pair.Value == null || IsEmpty(pair.Value)) + { + continue; + } + + evidence[stableRowKey] = CloneForPersistence(pair.Value); + } + + var document = new NativeFatEvidenceDocument + { + Schema = SnapshotSchema, + SavedAtUtc = DateTimeOffset.UtcNow, + DeviceId = snapshot.DeviceId, + DeviceName = snapshot.IedName, + IpAddress = snapshot.IpAddress, + EvidenceByRow = evidence + }; + var bytes = JsonSerializer.SerializeToUtf8Bytes(document, JsonOptions); + var path = SnapshotPath(snapshot.IedName); + + await _ioGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + cancellationToken.ThrowIfCancellationRequested(); + Directory.CreateDirectory(_rootDirectory); + var temporary = path + ".tmp-" + Guid.NewGuid().ToString("N"); + try + { + await File.WriteAllBytesAsync(temporary, bytes, cancellationToken).ConfigureAwait(false); + File.Move(temporary, path, true); + } + finally + { + if (File.Exists(temporary)) + File.Delete(temporary); + } + } + finally + { + _ioGate.Release(); + } + } + + internal string SnapshotPath(string iedName) + => Path.Combine(_rootDirectory, $"{SafeIedFileToken(iedName)}.native-fat-evidence.json"); + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + _ioGate.Dispose(); + } + + private string? FindNewestSnapshotByIedName(string iedName) + { + if (!Directory.Exists(_rootDirectory)) + return null; + + var normalized = NativeFatCanonicalEvidenceOverlay.NormalizeIedName(iedName); + foreach (var candidate in Directory + .EnumerateFiles(_rootDirectory, "*.native-fat-evidence.json", SearchOption.TopDirectoryOnly) + .OrderByDescending(File.GetLastWriteTimeUtc)) + { + try + { + using var stream = File.OpenRead(candidate); + using var document = JsonDocument.Parse(stream); + if (!document.RootElement.TryGetProperty("deviceName", out var name)) + continue; + if (NativeFatCanonicalEvidenceOverlay.NormalizeIedName(name.GetString()) == normalized) + return candidate; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) + { + continue; + } + } + + return null; + } + + private static bool TryNormalizeRowKey( + string? persistedKey, + string iedName, + string? persistedIedName, + out string stableRowKey) + { + stableRowKey = string.Empty; + if (string.IsNullOrWhiteSpace(persistedKey)) + return false; + + var separator = persistedKey.IndexOf('|'); + if (separator <= 0 || separator >= persistedKey.Length - 1) + return false; + + var owner = persistedKey[..separator].Trim(); + var reference = persistedKey[(separator + 1)..].Trim(); + var normalizedIedName = NativeFatCanonicalEvidenceOverlay.NormalizeIedName(iedName); + var ownerMatchesIed = NativeFatCanonicalEvidenceOverlay.NormalizeIedName(owner) + .Equals(normalizedIedName, StringComparison.OrdinalIgnoreCase); + var documentMatchesIed = !string.IsNullOrWhiteSpace(persistedIedName) && + NativeFatCanonicalEvidenceOverlay.NormalizeIedName(persistedIedName) + .Equals(normalizedIedName, StringComparison.OrdinalIgnoreCase); + if (!ownerMatchesIed && !documentMatchesIed) + return false; + + var telegram = Iec61850MonitorPoint.StripIedNamePrefix(reference, iedName); + return NativeFatCanonicalEvidenceOverlay.TryBuildRowKey(iedName, telegram, out stableRowKey); + } + + private static NativeFatEvidenceSlotState CloneForPersistence(NativeFatEvidenceSlotState source) + { + var value1 = source.Value1Evidence?.RawValue ?? source.Value1; + var value2 = source.Value2Evidence?.RawValue ?? source.Value2; + var result = source.Result?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(result) && + !string.IsNullOrWhiteSpace(value1) && + !string.IsNullOrWhiteSpace(value2)) + { + result = "COMPLETE"; + } + + return new NativeFatEvidenceSlotState + { + Value1 = value1, + Value2 = value2, + Value1Evidence = source.Value1Evidence, + Value2Evidence = source.Value2Evidence, + Result = result + }; + } + + private static bool IsEmpty(NativeFatEvidenceSlotState slot) + => string.IsNullOrWhiteSpace(slot.Value1Evidence?.RawValue ?? slot.Value1) && + string.IsNullOrWhiteSpace(slot.Value2Evidence?.RawValue ?? slot.Value2) && + string.IsNullOrWhiteSpace(slot.Result); + + private static string SafeIedFileToken(string iedName) + { + var source = string.IsNullOrWhiteSpace(iedName) ? "IED" : iedName.Trim(); + var invalid = Path.GetInvalidFileNameChars().ToHashSet(); + var builder = new StringBuilder(source.Length); + foreach (var character in source) + builder.Append(invalid.Contains(character) || character is '/' or '\\' ? '_' : character); + + var token = builder.ToString().Trim().TrimEnd('.'); + if (token.Length == 0) + token = "IED"; + + var reserved = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "CON", "PRN", "AUX", "NUL", + "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9" + }; + if (reserved.Contains(token)) + token += "_IED"; + + if (!string.Equals(token, source, StringComparison.Ordinal)) + { + var digest = SHA256.HashData(Encoding.UTF8.GetBytes(source)); + token += "-" + Convert.ToHexString(digest).ToLowerInvariant()[..8]; + } + + return token; + } + + private void ThrowIfDisposed() + => ObjectDisposedException.ThrowIf(_disposed, this); + + private sealed class NativeFatEvidenceDocument + { + public string Schema { get; set; } = SnapshotSchema; + public DateTimeOffset SavedAtUtc { get; set; } + public string DeviceId { get; set; } = string.Empty; + public string DeviceName { get; set; } = string.Empty; + public string IpAddress { get; set; } = string.Empty; + public Dictionary EvidenceByRow { get; set; } = + new(StringComparer.OrdinalIgnoreCase); + } +} + +internal sealed record NativeFatEvidenceStoreLoadResult( + bool Succeeded, + bool SnapshotFound, + string SourcePath, + int LoadedRows, + int IgnoredRows, + long ElapsedMilliseconds, + string Message, + IReadOnlyDictionary EvidenceByRow) +{ + internal static NativeFatEvidenceStoreLoadResult Empty(string message, long elapsedMilliseconds = 0) + => new( + true, + false, + string.Empty, + 0, + 0, + elapsedMilliseconds, + message, + new Dictionary(StringComparer.OrdinalIgnoreCase)); +} diff --git a/Services/IoTesting/NativeFatP4DReportAdapter.cs b/Services/IoTesting/NativeFatP4DReportAdapter.cs new file mode 100644 index 000000000..f731098be --- /dev/null +++ b/Services/IoTesting/NativeFatP4DReportAdapter.cs @@ -0,0 +1,285 @@ +namespace ArIED61850Tester.Services.IoTesting; + +/// +/// P4D bridge from the immutable native FAT snapshot to the shared report command model. +/// This class owns no acquisition/runtime state and performs no SCL import or reconnect. +/// Rendering remains delegated to IoFatReportPreviewDocumentBuilder.Render so WPF preview +/// and the native report stack keep one FixedDocument authority. +/// +internal static class NativeFatP4DReportAdapter +{ + private const double PageWidth = 842d; + private const double PageHeight = 595d; + private const double Margin = 30d; + private const double ContentTop = 466d; + private const double ContentBottom = 52d; + private const double HeaderHeight = 26d; + private const double TableRowHeight = 30d; + private const double TableBodyFontSize = 7.2d; + private const double TableTimestampFontSize = 6.2d; + private const double TelegramBaseFontSize = 6.8d; + private const double TelegramMinimumFontSize = 5.2d; + + // Customer-facing evidence table. Total width = 782 pt (842 - 2 * 30 margin). + // Live Value is intentionally omitted from the report: FAT evidence is Value 1 / Value 2. + // IEC 61850 Reference keeps the dominant width; the status column is widened enough for + // the explicit customer-facing Evidence Status wording without sacrificing timestamps. + private static readonly double[] Widths = [66d, 280d, 40d, 72d, 92d, 72d, 92d, 68d]; + private static readonly string[] Headers = + ["Signal", "IEC 61850 Reference", "Quality", "Value 1", "V1 Timestamp", "Value 2", "V2 Timestamp", "Evidence Status"]; + + private static readonly IoFatReportColor Navy = IoFatReportColor.FromHex("0F172A"); + private static readonly IoFatReportColor Blue = IoFatReportColor.FromHex("2563EB"); + private static readonly IoFatReportColor SoftBlue = IoFatReportColor.FromHex("EFF6FF"); + private static readonly IoFatReportColor Border = IoFatReportColor.FromHex("DCE5F0"); + private static readonly IoFatReportColor Ink = IoFatReportColor.FromHex("243146"); + private static readonly IoFatReportColor Muted = IoFatReportColor.FromHex("64748B"); + private static readonly IoFatReportColor White = IoFatReportColor.FromHex("FFFFFF"); + private static readonly IoFatReportColor Pass = IoFatReportColor.FromHex("15803D"); + private static readonly IoFatReportColor Attention = IoFatReportColor.FromHex("B45309"); + private static readonly IoFatReportColor Fail = IoFatReportColor.FromHex("B91C1C"); + + public static IoFatReportLayoutPlan Build(NativeFatPrintPreviewSnapshot snapshot, bool draft = true) + { + ArgumentNullException.ThrowIfNull(snapshot); + + var pages = new List>(); + var page = NewPage(pages, snapshot, continued: false); + var y = ContentTop; + DrawTableHeader(page, ref y); + + foreach (var row in snapshot.Rows) + { + var height = GetRowHeight(row); + if (y - height < ContentBottom) + { + page = NewPage(pages, snapshot, continued: true); + y = ContentTop; + DrawTableHeader(page, ref y); + } + + DrawRow(page, row, height, ref y); + } + + if (snapshot.Rows.Count == 0) + { + page.Add(new IoFatReportTextCommand( + Margin, + y - 20d, + 600d, + "No FAT signal is available for this report.", + IoFatReportFontKind.Bold, + 8.5d, + Attention)); + } + + for (var index = 0; index < pages.Count; index++) + { + pages[index].Add(new IoFatReportLineCommand(Margin, 42d, PageWidth - Margin, 42d, Border, 0.6d)); + pages[index].Add(new IoFatReportTextCommand( + Margin, + 24d, + 560d, + $"FAT evidence captured · {NativeFatReportFormatting.LocalTimestamp(snapshot.CapturedAt)} · Local time", + IoFatReportFontKind.Regular, + 6.5d, + Muted)); + pages[index].Add(new IoFatReportTextCommand( + PageWidth - Margin - 100d, + 24d, + 100d, + $"Page {index + 1} / {pages.Count}", + IoFatReportFontKind.Regular, + 6.5d, + Muted)); + } + + var baseLayout = new IoFatReportLayoutPlan( + snapshot.DeviceId, + snapshot.CapturedAt, + draft, + pages.Select((commands, index) => + new IoFatReportPagePlan(index + 1, PageWidth, PageHeight, commands.ToArray())).ToArray()); + + // Auxiliary pages consume only evidence already copied into the immutable snapshot. + // Final acceptance sign-off remains the last page shared by Preview and Save PDF. + var withAuxiliaryEvidence = NativeFatAuxiliaryReportDecorator.AppendSuccessfulEvidence(baseLayout, snapshot); + return NativeFatReportFinalization.AppendSignOff(withAuxiliaryEvidence, snapshot); + } + + private static List NewPage( + List> pages, + NativeFatPrintPreviewSnapshot snapshot, + bool continued) + { + var page = new List(); + pages.Add(page); + + NativeFatReportBranding.AddLogo(page, PageWidth - Margin - 102d, 582d); + page.Add(new IoFatReportTextCommand( + Margin, + 562d, + 520d, + "IEC 61850 FAT Evidence Report", + IoFatReportFontKind.Bold, + 16.8d, + Navy)); + page.Add(new IoFatReportTextCommand( + Margin, + 542d, + 560d, + "Factory Acceptance Test · IEC 61850 Signal Evidence", + IoFatReportFontKind.Regular, + 7.6d, + Muted)); + page.Add(new IoFatReportRectCommand( + Margin, + 520d, + PageWidth - (Margin * 2d), + 38d, + 3d, + SoftBlue, + Border, + 0.6d)); + page.Add(new IoFatReportTextCommand( + Margin + 12d, + 499d, + 470d, + continued + ? $"IED: {Clean(snapshot.IedName)} (continued) · Endpoint: {Clean(snapshot.IpAddress)}:{snapshot.Port}" + : $"IED: {Clean(snapshot.IedName)} · Endpoint: {Clean(snapshot.IpAddress)}:{snapshot.Port}", + IoFatReportFontKind.Bold, + 8.6d, + Ink)); + page.Add(new IoFatReportTextCommand( + PageWidth - Margin - 250d, + 499d, + 238d, + snapshot.ProgressText, + IoFatReportFontKind.Bold, + 8.2d, + snapshot.CompleteCount == snapshot.Rows.Count && snapshot.Rows.Count > 0 ? Pass : Blue)); + page.Add(new IoFatReportLineCommand(Margin, 482d, PageWidth - Margin, 482d, Border, 0.7d)); + return page; + } + + private static void DrawTableHeader(List page, ref double y) + { + var x = Margin; + for (var index = 0; index < Headers.Length; index++) + { + page.Add(new IoFatReportRectCommand(x, y, Widths[index], HeaderHeight, 0d, SoftBlue, Border, 0.45d)); + page.Add(new IoFatReportTextCommand( + x + 4d, + CenteredBaseline(y, HeaderHeight), + Widths[index] - 8d, + Headers[index], + IoFatReportFontKind.Bold, + index is 4 or 6 ? 6.2d : index is 1 or 7 ? 6.4d : 6.8d, + Blue)); + x += Widths[index]; + } + y -= HeaderHeight; + } + + private static double GetRowHeight(NativeFatPrintPreviewRow row) + => TableRowHeight; + + private static void DrawRow( + List page, + NativeFatPrintPreviewRow row, + double height, + ref double y) + { + var reportResult = ReportResult(row.Result); + var cells = new[] + { + Clean(row.Signal), + string.Empty, + NativeFatReportFormatting.Quality(row.Quality), + Clean(row.Value1), + Clean(row.Value1TimestampText), + Clean(row.Value2), + Clean(row.Value2TimestampText), + reportResult + }; + + var baseline = CenteredBaseline(y, height); + var x = Margin; + for (var index = 0; index < cells.Length; index++) + { + page.Add(new IoFatReportRectCommand(x, y, Widths[index], height, 0d, White, Border, 0.35d)); + + if (index == 1) + { + page.Add(new IoFatReportTextCommand( + x + 4d, + baseline, + Widths[index] - 8d, + Clean(row.IecTelegram), + IoFatReportFontKind.Mono, + TelegramFontSize(row.IecTelegram), + Ink)); + } + else + { + var isTimestamp = index is 4 or 6; + page.Add(new IoFatReportTextCommand( + x + 4d, + baseline, + Widths[index] - 8d, + cells[index], + isTimestamp + ? IoFatReportFontKind.Mono + : index is 0 or 7 ? IoFatReportFontKind.Bold : IoFatReportFontKind.Regular, + isTimestamp ? TableTimestampFontSize : TableBodyFontSize, + index == 7 ? ResultColor(reportResult) : Ink)); + } + + x += Widths[index]; + } + + y -= height; + } + + private static double CenteredBaseline(double top, double height) + => top - (height / 2d) - 2d; + + private static double TelegramFontSize(string? value) + { + var text = Clean(value); + if (text.Length == 0) + return TelegramBaseFontSize; + + var availableWidth = Widths[1] - 8d; + var fitted = availableWidth / (text.Length * 0.62d); + return Math.Clamp(fitted, TelegramMinimumFontSize, TelegramBaseFontSize); + } + + private static string ReportResult(string? result) + { + var value = Clean(result); + return value.Equals("COMPLETE", StringComparison.OrdinalIgnoreCase) || + value.Equals("OK", StringComparison.OrdinalIgnoreCase) + ? "Complete" + : value; + } + + private static IoFatReportColor ResultColor(string? result) + { + var value = Clean(result); + if (value.Equals("Complete", StringComparison.OrdinalIgnoreCase) || + value.Equals("OK", StringComparison.OrdinalIgnoreCase) || + value.Contains("PASS", StringComparison.OrdinalIgnoreCase) || + value.Contains("COMPLETE", StringComparison.OrdinalIgnoreCase)) + return Pass; + if (value.Contains("FAIL", StringComparison.OrdinalIgnoreCase)) + return Fail; + if (value.Contains("REVIEW", StringComparison.OrdinalIgnoreCase)) + return Attention; + return Muted; + } + + private static string Clean(string? value) + => string.IsNullOrWhiteSpace(value) ? "—" : value.Trim(); +} diff --git a/Services/IoTesting/NativeFatPrintPreviewSnapshot.cs b/Services/IoTesting/NativeFatPrintPreviewSnapshot.cs new file mode 100644 index 000000000..a35de1c98 --- /dev/null +++ b/Services/IoTesting/NativeFatPrintPreviewSnapshot.cs @@ -0,0 +1,113 @@ +using System.Collections.ObjectModel; +using ArIED61850Tester.Models; +using ArIED61850Tester.Models.IoTesting; + +namespace ArIED61850Tester.Services.IoTesting; + +public sealed record NativeFatPrintPreviewRow( + string Signal, + string IecTelegram, + string Quality, + string LiveValue, + string Value1, + string Value1TimestampText, + string Value2, + string Value2TimestampText, + string Result); + +/// +/// Immutable selected-IED-only report input for native Engineering FAT. +/// Capture copies the exact native FAT row order plus sparse evidence overlay. No live +/// row/evidence object is retained after Capture returns and acquisition is never restarted. +/// +public sealed class NativeFatPrintPreviewSnapshot +{ + private readonly ReadOnlyCollection _rows; + + private NativeFatPrintPreviewSnapshot( + DateTimeOffset capturedAt, + string deviceId, + string iedName, + string ipAddress, + int port, + IReadOnlyCollection rows, + NativeFatAuxiliaryEvidenceSnapshot auxiliaryEvidence) + { + CapturedAt = capturedAt; + DeviceId = deviceId; + IedName = iedName; + IpAddress = ipAddress; + Port = port; + _rows = Array.AsReadOnly(rows.ToArray()); + AuxiliaryEvidence = auxiliaryEvidence.Copy(); + } + + public DateTimeOffset CapturedAt { get; } + public string DeviceId { get; } + public string IedName { get; } + public string IpAddress { get; } + public int Port { get; } + public IReadOnlyList Rows => _rows; + public NativeFatAuxiliaryEvidenceSnapshot AuxiliaryEvidence { get; } + public int CompleteCount => _rows.Count(row => HasEvidence(row.Value1) && HasEvidence(row.Value2)); + public string ProgressText => $"Evidence complete: {CompleteCount} / {_rows.Count} signals"; + + public static NativeFatPrintPreviewSnapshot Capture( + Iec61850MonitorDevice device, + NativeFatIedSessionCacheState cache, + NativeFatAuxiliaryEvidenceSnapshot? auxiliaryEvidence = null) + { + ArgumentNullException.ThrowIfNull(device); + ArgumentNullException.ThrowIfNull(cache); + + var rows = device.Points.Select(point => + { + var value1 = NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, point, NativeFatEvidenceField.Value1); + var value2 = NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, point, NativeFatEvidenceField.Value2); + var capture1 = NativeFatCanonicalEvidenceOverlay.ReadCapture(cache, point, NativeFatEvidenceField.Value1); + var capture2 = NativeFatCanonicalEvidenceOverlay.ReadCapture(cache, point, NativeFatEvidenceField.Value2); + var rawResult = NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, point, NativeFatEvidenceField.Result); + var result = !string.IsNullOrWhiteSpace(rawResult) + ? rawResult.Trim() + : HasEvidence(value1) && HasEvidence(value2) ? "COMPLETE" : string.Empty; + var displaySignal = IoFatSignalDisplayNameFormatter.Format(point.SignalName, point.IecReference); + + return new NativeFatPrintPreviewRow( + Copy(displaySignal), + Copy(point.IecTelegram), + Copy(point.Quality), + Display(point.DisplayValue), + Display(value1), + DisplayTimestamp(capture1), + Display(value2), + DisplayTimestamp(capture2), + Display(result)); + }).ToArray(); + + return new NativeFatPrintPreviewSnapshot( + DateTimeOffset.Now, + Copy(device.DeviceId), + Copy(device.Name), + Copy(device.IpAddress), + device.Port, + rows, + auxiliaryEvidence ?? NativeFatAuxiliaryEvidenceSnapshot.Empty); + } + + private static string DisplayTimestamp(FatValueEvidence? evidence) + { + if (evidence is null) + return "—"; + var timestamp = evidence.IedTimestamp ?? evidence.CapturedAt; + return NativeFatReportFormatting.LocalTimestamp(timestamp); + } + + private static bool HasEvidence(string? value) + => !string.IsNullOrWhiteSpace(value) && value.Trim() != "—"; + + private static string Copy(string? value) + => value?.Trim() ?? string.Empty; + + private static string Display(string? value) + => string.IsNullOrWhiteSpace(value) ? "—" : value.Trim(); +} diff --git a/Services/IoTesting/NativeFatReportBranding.cs b/Services/IoTesting/NativeFatReportBranding.cs new file mode 100644 index 000000000..13b388ff3 --- /dev/null +++ b/Services/IoTesting/NativeFatReportBranding.cs @@ -0,0 +1,44 @@ +namespace ArIED61850Tester.Services.IoTesting; + +/// +/// Shared vector branding for native FAT report layouts. The mark is expressed only through +/// report commands so the WPF preview and PDF writer render the exact same logo without an +/// external bitmap dependency. +/// +internal static class NativeFatReportBranding +{ + private static readonly IoFatReportColor Navy = IoFatReportColor.FromHex("0F172A"); + private static readonly IoFatReportColor Blue = IoFatReportColor.FromHex("2563EB"); + private static readonly IoFatReportColor White = IoFatReportColor.FromHex("FFFFFF"); + + public static void AddLogo(ICollection commands, double x, double topY) + { + ArgumentNullException.ThrowIfNull(commands); + + commands.Add(new IoFatReportRectCommand( + x, + topY, + 22d, + 22d, + 4d, + Blue, + Blue, + 0d)); + commands.Add(new IoFatReportTextCommand( + x + 5.2d, + topY - 15.2d, + 12d, + "A", + IoFatReportFontKind.Bold, + 11.2d, + White)); + commands.Add(new IoFatReportTextCommand( + x + 29d, + topY - 15.4d, + 72d, + "ARSAS", + IoFatReportFontKind.Bold, + 10.2d, + Navy)); + } +} \ No newline at end of file diff --git a/Services/IoTesting/NativeFatReportFinalization.cs b/Services/IoTesting/NativeFatReportFinalization.cs new file mode 100644 index 000000000..8729263d2 --- /dev/null +++ b/Services/IoTesting/NativeFatReportFinalization.cs @@ -0,0 +1,157 @@ +namespace ArIED61850Tester.Services.IoTesting; + +/// +/// Finalizes the immutable native FAT layout without rebuilding an IoTestProject. +/// The sign-off page is intentionally blank evidence: it provides the controlled +/// TESTED BY / WITNESSED BY / APPROVED BY acceptance fields and never invents names, +/// signatures or dates. Auxiliary evidence is appended from the immutable snapshot before +/// this finalization step; this class never invents COMTRADE or time-sync facts. +/// +internal static class NativeFatReportFinalization +{ + private const double PageWidth = 842d; + private const double PageHeight = 595d; + private const double Margin = 30d; + private const double ContentWidth = PageWidth - (Margin * 2d); + + private static readonly IoFatReportColor Navy = IoFatReportColor.FromHex("0F172A"); + private static readonly IoFatReportColor SoftBlue = IoFatReportColor.FromHex("EFF6FF"); + private static readonly IoFatReportColor Border = IoFatReportColor.FromHex("D9E4F0"); + private static readonly IoFatReportColor Muted = IoFatReportColor.FromHex("64748B"); + private static readonly IoFatReportColor Ink = IoFatReportColor.FromHex("1F2937"); + private static readonly IoFatReportColor White = IoFatReportColor.FromHex("FFFFFF"); + + public static IoFatReportLayoutPlan AppendSignOff( + IoFatReportLayoutPlan baseLayout, + NativeFatPrintPreviewSnapshot snapshot) + { + ArgumentNullException.ThrowIfNull(baseLayout); + ArgumentNullException.ThrowIfNull(snapshot); + + var totalPages = baseLayout.Pages.Count + 1; + var pages = new List(totalPages); + + for (var index = 0; index < baseLayout.Pages.Count; index++) + { + var corrected = baseLayout.Pages[index].Commands + .Select(command => CorrectPageTotal(command, index + 1, totalPages)) + .ToArray(); + pages.Add(new IoFatReportPagePlan(index + 1, PageWidth, PageHeight, corrected)); + } + + pages.Add(BuildSignOffPage(snapshot, totalPages, totalPages, baseLayout.CreatedAt)); + return new IoFatReportLayoutPlan(baseLayout.ProjectId, baseLayout.CreatedAt, baseLayout.Draft, pages); + } + + private static IoFatReportPagePlan BuildSignOffPage( + NativeFatPrintPreviewSnapshot snapshot, + int pageNumber, + int totalPages, + DateTimeOffset createdAt) + { + var commands = new List(); + + NativeFatReportBranding.AddLogo(commands, PageWidth - Margin - 102d, 582d); + Text(commands, Margin, 566d, 490d, "IEC 61850 FAT", IoFatReportFontKind.Bold, 7.2d, Muted); + Text(commands, Margin, 544d, 520d, "FAT Acceptance Sign-Off", IoFatReportFontKind.Bold, 17.2d, Navy); + Text(commands, Margin, 522d, 570d, + "Acceptance sign-off for the IEC 61850 FAT evidence documented in this report.", + IoFatReportFontKind.Regular, 8.0d, Muted); + Line(commands, Margin, 498d, PageWidth - Margin, 498d, Border, 0.8d); + + Text(commands, Margin, 478d, ContentWidth, + $"IED: {Clean(snapshot.IedName)} · Report: IEC 61850 FAT Evidence", + IoFatReportFontKind.Bold, 7.4d, Navy); + Text(commands, Margin, 458d, ContentWidth, + "By signing below, the undersigned confirm that they have reviewed the FAT execution and evidence documented in this report in accordance with their respective roles.", + IoFatReportFontKind.Regular, 7.0d, 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, 414d, boxWidth, 282d, heading); + x += boxWidth + gap; + } + + Line(commands, Margin, 42d, PageWidth - Margin, 42d, Border, 0.6d); + Text(commands, Margin, 24d, 620d, + $"FAT evidence captured · {NativeFatReportFormatting.LocalTimestamp(createdAt)} · Local time", + IoFatReportFontKind.Regular, 6.2d, Muted); + Text(commands, PageWidth - Margin - 118d, 24d, 118d, + $"Page {pageNumber} / {totalPages}", + IoFatReportFontKind.Regular, 6.2d, Muted); + + 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 lineX = x + 12d; + var lineRight = x + width - 12d; + Text(commands, lineX, top - 56d, width - 24d, "Name", IoFatReportFontKind.Bold, 6.3d, Muted); + Line(commands, lineX, top - 78d, lineRight, top - 78d, Border, 0.65d); + Text(commands, lineX, top - 101d, width - 24d, "Title / Role", IoFatReportFontKind.Bold, 6.3d, Muted); + Line(commands, lineX, top - 123d, lineRight, top - 123d, Border, 0.65d); + Text(commands, lineX, top - 146d, width - 24d, "Company / Organization", IoFatReportFontKind.Bold, 6.3d, Muted); + Line(commands, lineX, top - 168d, lineRight, top - 168d, Border, 0.65d); + Text(commands, lineX, top - 191d, width - 24d, "Signature", IoFatReportFontKind.Bold, 6.3d, Muted); + Rect(commands, lineX, top - 204d, width - 24d, 42d, 0d, White, Border, 0.55d); + Text(commands, lineX, top - 257d, width - 24d, "Date", IoFatReportFontKind.Bold, 6.3d, Muted); + Line(commands, lineX, top - 276d, lineRight, top - 276d, Border, 0.65d); + } + + private static IoFatReportCommand CorrectPageTotal(IoFatReportCommand command, int pageNumber, int totalPages) + { + if (command is IoFatReportTextCommand text && text.Text.StartsWith("Page ", StringComparison.Ordinal)) + return text with { Text = $"Page {pageNumber} / {totalPages}" }; + return command; + } + + private static void Rect( + ICollection commands, + double x, + double top, + double width, + double height, + double radius, + IoFatReportColor fill, + IoFatReportColor stroke, + double strokeThickness) + => commands.Add(new IoFatReportRectCommand(x, top, width, height, radius, fill, stroke, strokeThickness)); + + private static void Line( + ICollection commands, + double x1, + double y1, + double x2, + double y2, + IoFatReportColor stroke, + double strokeThickness) + => commands.Add(new IoFatReportLineCommand(x1, y1, x2, y2, stroke, strokeThickness)); + + private static void Text( + ICollection commands, + double x, + double baselineY, + double width, + string text, + IoFatReportFontKind font, + double fontSize, + IoFatReportColor color) + => commands.Add(new IoFatReportTextCommand(x, baselineY, width, text, font, fontSize, color)); + + private static string Clean(string? value) + => string.IsNullOrWhiteSpace(value) ? "—" : value.Trim(); +} diff --git a/Services/IoTesting/NativeFatReportFormatting.cs b/Services/IoTesting/NativeFatReportFormatting.cs new file mode 100644 index 000000000..404f2ea71 --- /dev/null +++ b/Services/IoTesting/NativeFatReportFormatting.cs @@ -0,0 +1,43 @@ +using System.Globalization; + +namespace ArIED61850Tester.Services.IoTesting; + +/// +/// Customer-facing formatting authority shared by every native FAT report section. +/// Runtime/cache timestamps keep their original identity; only report presentation is +/// converted to the workstation's local time and rendered with one unambiguous format. +/// +internal static class NativeFatReportFormatting +{ + internal const string LocalTimestampFormat = "dd/MM/yyyy HH:mm:ss.fff"; + + internal static string LocalTimestamp(DateTimeOffset value) + => value.ToLocalTime().ToString(LocalTimestampFormat, CultureInfo.InvariantCulture); + + internal static string LocalTimestamp(DateTimeOffset? value) + => value.HasValue ? LocalTimestamp(value.Value) : "—"; + + internal static string LocalTimestamp(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return "—"; + + var text = value.Trim(); + return DateTimeOffset.TryParse( + text, + CultureInfo.InvariantCulture, + DateTimeStyles.AllowWhiteSpaces, + out var timestamp) + ? LocalTimestamp(timestamp) + : text; + } + + internal static string Quality(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return "—"; + + var text = value.Trim(); + return text.Equals("good", StringComparison.OrdinalIgnoreCase) ? "Good" : text; + } +} diff --git a/Services/IoTesting/NativeFatReportImage.cs b/Services/IoTesting/NativeFatReportImage.cs new file mode 100644 index 000000000..eb46b2808 --- /dev/null +++ b/Services/IoTesting/NativeFatReportImage.cs @@ -0,0 +1,283 @@ +using System.Windows; +using System.Windows.Media; +using System.Windows.Media.Imaging; + +namespace ArIED61850Tester.Services.IoTesting; + +/// +/// Raster image command shared by the WPF report preview and native PDF writer. +/// Pixels are stored as opaque RGB so the exact same decoded image is rendered by both paths. +/// +internal sealed record IoFatReportImageCommand( + double X, + double TopY, + double Width, + double Height, + int PixelWidth, + int PixelHeight, + byte[] RgbPixels) : IoFatReportCommand; + +internal sealed record NativeFatReportLogo( + int PixelWidth, + int PixelHeight, + byte[] RgbPixels, + string SourceName); + +internal readonly record struct NativeFatLogoPlacement( + double X, + double TopY, + double Width, + double Height); + +/// +/// Native FAT report logo authority. The default mark is the real packaged ARSAS app icon. +/// Custom logos are decoded once and stored in the immutable report command stream, so Preview +/// and Save PDF never depend on the source file after selection. +/// +internal static class NativeFatReportLogoService +{ + private const int MaxPixelDimension = 512; + private const byte VisibleAlphaThreshold = 8; + + // Legacy synthetic ARSAS mark coordinates retained only so the decorator can remove it. + private const double LegacySyntheticLogoX = 710d; + private const double LegacySyntheticLogoTop = 582d; + private const double LegacySyntheticLogoSize = 22d; + + // Professional adaptive header slot. The top header has substantially more room than the + // legacy 22 x 22 icon box. Wide corporate wordmarks can now use the available width while + // square/circular marks use the full height without distortion or cropping. + private const double HeaderLogoSlotLeft = 656d; + private const double HeaderLogoSlotTopY = 578d; + private const double HeaderLogoSlotWidth = 156d; + private const double HeaderLogoSlotHeight = 42d; + + private static readonly string[] DefaultLogoUris = + [ + "pack://application:,,,/ARSAS;component/Assets/app-icon-256.png", + "pack://application:,,,/ARSAS;component/Assets/app-icon.png" + ]; + + public static NativeFatReportLogo? TryLoadDefault() + { + foreach (var uriText in DefaultLogoUris) + { + try + { + var resource = Application.GetResourceStream(new Uri(uriText, UriKind.Absolute)); + if (resource?.Stream == null) + continue; + using (resource.Stream) + return Decode(resource.Stream, uriText); + } + catch (IOException) + { + } + catch (InvalidOperationException) + { + } + } + + return null; + } + + public static NativeFatReportLogo LoadFromFile(string path) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("Logo file path is required.", nameof(path)); + + using var stream = File.OpenRead(path); + return Decode(stream, Path.GetFileName(path)); + } + + public static IoFatReportLayoutPlan Apply(IoFatReportLayoutPlan layout, NativeFatReportLogo? logo) + { + ArgumentNullException.ThrowIfNull(layout); + + var pages = layout.Pages + .Select(page => + { + var commands = new List(page.Commands.Count + 1); + foreach (var command in page.Commands) + { + if (IsLegacyBrandingCommand(command)) + continue; + commands.Add(command); + } + + if (logo != null) + { + var placement = CalculatePlacement(logo); + if (placement.Width > 0d && placement.Height > 0d) + { + commands.Add(new IoFatReportImageCommand( + placement.X, + placement.TopY, + placement.Width, + placement.Height, + logo.PixelWidth, + logo.PixelHeight, + logo.RgbPixels)); + } + } + + return new IoFatReportPagePlan(page.PageNumber, page.Width, page.Height, commands.ToArray()); + }) + .ToArray(); + + return new IoFatReportLayoutPlan(layout.ProjectId, layout.CreatedAt, layout.Draft, pages); + } + + /// + /// Uniform-fit placement inside one fixed header field. This deliberately preserves aspect + /// ratio: wide logos consume width, square/circular logos consume height, and neither is + /// stretched or cropped. The result is right-aligned and vertically centered in the slot. + /// + internal static NativeFatLogoPlacement CalculatePlacement(NativeFatReportLogo logo) + { + ArgumentNullException.ThrowIfNull(logo); + if (logo.PixelWidth <= 0 || logo.PixelHeight <= 0) + return default; + + var scale = Math.Min( + HeaderLogoSlotWidth / logo.PixelWidth, + HeaderLogoSlotHeight / logo.PixelHeight); + if (!double.IsFinite(scale) || scale <= 0d) + return default; + + var width = logo.PixelWidth * scale; + var height = logo.PixelHeight * scale; + var x = HeaderLogoSlotLeft + HeaderLogoSlotWidth - width; + var topY = HeaderLogoSlotTopY - ((HeaderLogoSlotHeight - height) / 2d); + return new NativeFatLogoPlacement(x, topY, width, height); + } + + private static bool IsLegacyBrandingCommand(IoFatReportCommand command) + { + if (command is IoFatReportRectCommand rect) + { + return Near(rect.X, LegacySyntheticLogoX) && + Near(rect.TopY, LegacySyntheticLogoTop) && + Near(rect.Width, LegacySyntheticLogoSize) && + Near(rect.Height, LegacySyntheticLogoSize); + } + + if (command is IoFatReportTextCommand text) + { + var syntheticA = string.Equals(text.Text, "A", StringComparison.Ordinal) && + Near(text.X, LegacySyntheticLogoX + 5.2d) && + Near(text.BaselineY, LegacySyntheticLogoTop - 15.2d); + var legacyWordmark = string.Equals(text.Text, "ARSAS", StringComparison.Ordinal) && + Near(text.X, LegacySyntheticLogoX + 29d) && + Near(text.BaselineY, LegacySyntheticLogoTop - 15.4d); + return syntheticA || legacyWordmark; + } + + return false; + } + + private static NativeFatReportLogo Decode(Stream stream, string sourceName) + { + var decoder = BitmapDecoder.Create( + stream, + BitmapCreateOptions.PreservePixelFormat, + BitmapCacheOption.OnLoad); + BitmapSource source = decoder.Frames[0]; + + // Remove transparent canvas padding before sizing. Corporate PNGs often contain a + // large transparent artboard; fitting the full canvas would make the visible logo look + // artificially tiny even when the destination slot itself is large. + source = TrimTransparentPadding(source); + + var largest = Math.Max(source.PixelWidth, source.PixelHeight); + if (largest > MaxPixelDimension) + { + var scale = MaxPixelDimension / (double)largest; + source = new TransformedBitmap(source, new ScaleTransform(scale, scale)); + } + + var converted = new FormatConvertedBitmap(source, PixelFormats.Bgra32, null, 0d); + var width = converted.PixelWidth; + var height = converted.PixelHeight; + var bgraStride = checked(width * 4); + var bgra = new byte[checked(bgraStride * height)]; + converted.CopyPixels(bgra, bgraStride, 0); + + var rgb = new byte[checked(width * height * 3)]; + var targetOffset = 0; + for (var sourceOffset = 0; sourceOffset < bgra.Length; sourceOffset += 4, targetOffset += 3) + { + var blue = bgra[sourceOffset]; + var green = bgra[sourceOffset + 1]; + var red = bgra[sourceOffset + 2]; + var alpha = bgra[sourceOffset + 3]; + + // PDF image XObjects here are RGB-only. Composite transparency onto the white + // report page so transparent PNG logos remain visually correct in both renderers. + rgb[targetOffset] = CompositeOnWhite(red, alpha); + rgb[targetOffset + 1] = CompositeOnWhite(green, alpha); + rgb[targetOffset + 2] = CompositeOnWhite(blue, alpha); + } + + return new NativeFatReportLogo(width, height, rgb, sourceName); + } + + private static BitmapSource TrimTransparentPadding(BitmapSource source) + { + var converted = new FormatConvertedBitmap(source, PixelFormats.Bgra32, null, 0d); + var width = converted.PixelWidth; + var height = converted.PixelHeight; + if (width <= 0 || height <= 0) + return converted; + + var stride = checked(width * 4); + var pixels = new byte[checked(stride * height)]; + converted.CopyPixels(pixels, stride, 0); + var bounds = FindVisibleBounds(pixels, width, height); + if (bounds.IsEmpty || + (bounds.X == 0 && bounds.Y == 0 && bounds.Width == width && bounds.Height == height)) + { + return converted; + } + + return new CroppedBitmap(converted, bounds); + } + + internal static Int32Rect FindVisibleBounds(byte[] bgra, int width, int height) + { + ArgumentNullException.ThrowIfNull(bgra); + if (width <= 0 || height <= 0 || bgra.Length < checked(width * height * 4)) + return Int32Rect.Empty; + + var minX = width; + var minY = height; + var maxX = -1; + var maxY = -1; + + for (var y = 0; y < height; y++) + { + var rowOffset = y * width * 4; + for (var x = 0; x < width; x++) + { + var alpha = bgra[rowOffset + (x * 4) + 3]; + if (alpha <= VisibleAlphaThreshold) + continue; + + minX = Math.Min(minX, x); + minY = Math.Min(minY, y); + maxX = Math.Max(maxX, x); + maxY = Math.Max(maxY, y); + } + } + + return maxX < minX || maxY < minY + ? Int32Rect.Empty + : new Int32Rect(minX, minY, maxX - minX + 1, maxY - minY + 1); + } + + private static byte CompositeOnWhite(byte channel, byte alpha) + => (byte)((channel * alpha + 255 * (255 - alpha) + 127) / 255); + + private static bool Near(double left, double right) + => Math.Abs(left - right) < 0.01d; +} diff --git a/tests/ARSAS.Tests/ARSAS.Tests.csproj b/tests/ARSAS.Tests/ARSAS.Tests.csproj index bc7961d33..c70414d91 100644 --- a/tests/ARSAS.Tests/ARSAS.Tests.csproj +++ b/tests/ARSAS.Tests/ARSAS.Tests.csproj @@ -2,6 +2,7 @@ net8.0-windows true + true false true enable diff --git a/tests/ARSAS.Tests/FatControlSafetyDefaultsRegressionTests.cs b/tests/ARSAS.Tests/FatControlSafetyDefaultsRegressionTests.cs new file mode 100644 index 000000000..98432c6ce --- /dev/null +++ b/tests/ARSAS.Tests/FatControlSafetyDefaultsRegressionTests.cs @@ -0,0 +1,54 @@ +namespace ARSAS.Tests; + +public sealed class FatControlSafetyDefaultsRegressionTests +{ + [Fact] + public void CommandSafetyDefaults_AreAppliedToModelOnceAndRemainOperatorEditable() + { + var source = File.ReadAllText(FindRepoFile("MainWindow.CommandPanelUx.cs")); + var xaml = File.ReadAllText(FindRepoFile("MainWindow.xaml")); + + Assert.Contains("ConditionalWeakTable _controlSafetyDefaultsApplied", source, StringComparison.Ordinal); + Assert.Contains("EnsureDefaultControlSafetyChecks(signal);", source, StringComparison.Ordinal); + Assert.Contains("current.ControlInterlockCheck = true;", source, StringComparison.Ordinal); + Assert.Contains("current.ControlSynchroCheck = true;", source, StringComparison.Ordinal); + Assert.Contains("the periodic command-panel UX refresh must never force a user choice back on", source, StringComparison.Ordinal); + + Assert.Contains("Content=\"Interlock\"", xaml, StringComparison.Ordinal); + Assert.Contains("IsChecked=\"{Binding ControlInterlockCheck, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}\"", xaml, StringComparison.Ordinal); + Assert.Contains("Content=\"Sync\"", xaml, StringComparison.Ordinal); + Assert.Contains("IsChecked=\"{Binding ControlSynchroCheck, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}\"", xaml, StringComparison.Ordinal); + + Assert.DoesNotContain("IsChecked=\"True\"", ExtractChecksColumn(xaml), StringComparison.OrdinalIgnoreCase); + } + + private static string ExtractChecksColumn(string source) + { + var start = source.IndexOf("= 0); + var end = source.IndexOf("", start, StringComparison.Ordinal); + Assert.True(end > start); + return source[start..(end + "".Length)]; + } + + private static string FindRepoFile(string relativePath) + => Path.Combine(FindRepoRoot(), relativePath); + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + if (File.Exists(Path.Combine(directory.FullName, "MainWindow.xaml")) && + Directory.Exists(Path.Combine(directory.FullName, "tests", "ARSAS.Tests"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException( + $"Could not locate repository root from '{AppContext.BaseDirectory}'."); + } +} diff --git a/tests/ARSAS.Tests/FieldRegressionFixTests.cs b/tests/ARSAS.Tests/FieldRegressionFixTests.cs index f15e789ee..48812ef4e 100644 --- a/tests/ARSAS.Tests/FieldRegressionFixTests.cs +++ b/tests/ARSAS.Tests/FieldRegressionFixTests.cs @@ -1,4 +1,6 @@ using ArIED61850Tester; +using System.Windows.Controls; +using System.Windows.Media; namespace ARSAS.Tests; @@ -23,13 +25,32 @@ public void IedTimestamp_LiveDisplayRoundsToMilliseconds_WhileFullSourceRemainsA } [Fact] - public void CommandPanel_DarkHeaderForcesReadableWhiteCaptions() + public void CommandPanel_DarkHeaderKeepsTargetBadgeReadableAcrossRefreshes() { - var source = File.ReadAllText(FindRepoFile("MainWindow.FieldPresentationFix.cs")); + RunInSta(() => + { + var title = new TextBlock { Text = "Command Dock" }; + var target = new TextBlock { Text = "TARGET · AA1EIF06R4" }; + var badge = new Border + { + Tag = "P0CommandTargetBadge", + Background = Brushes.Black, + BorderBrush = Brushes.Black, + Child = target + }; + var header = new StackPanel { Orientation = Orientation.Horizontal }; + header.Children.Add(title); + header.Children.Add(badge); + var expander = new Expander { Header = header }; - Assert.Contains("CommandPanelExpander", source, StringComparison.Ordinal); - Assert.Contains("expander.Foreground = Brushes.White", source, StringComparison.Ordinal); - Assert.Contains("text.Foreground = Brushes.White", source, StringComparison.Ordinal); + MainWindowFieldPresentationFix.ApplyDarkCommandHeaderContrast(expander); + MainWindowFieldPresentationFix.ApplyDarkCommandHeaderContrast(expander); + + Assert.Equal(Colors.White, Assert.IsType(title.Foreground).Color); + Assert.Equal(Color.FromRgb(0x58, 0x6B, 0x82), Assert.IsType(target.Foreground).Color); + Assert.Equal(Color.FromRgb(0xF4, 0xF7, 0xFB), Assert.IsType(badge.Background).Color); + Assert.Equal(Color.FromRgb(0xD6, 0xE0, 0xEC), Assert.IsType(badge.BorderBrush).Color); + }); } [Fact] @@ -81,4 +102,25 @@ private static string FindRepoFile(string relativePath) throw new FileNotFoundException( $"Could not locate repository file '{relativePath}' from '{AppContext.BaseDirectory}'."); } + + private static void RunInSta(Action action) + { + Exception? failure = null; + var thread = new Thread(() => + { + try + { + action(); + } + catch (Exception ex) + { + failure = ex; + } + }); + + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + Assert.True(thread.Join(TimeSpan.FromSeconds(8)), "WPF command-header regression timed out."); + Assert.Null(failure); + } } diff --git a/tests/ARSAS.Tests/GlobalUsings.Arsas.cs b/tests/ARSAS.Tests/GlobalUsings.Arsas.cs new file mode 100644 index 000000000..0d49110c5 --- /dev/null +++ b/tests/ARSAS.Tests/GlobalUsings.Arsas.cs @@ -0,0 +1,3 @@ +global using System.IO; +global using ArIED61850Tester; +global using Iec61850MonitorRuntime = ArIED61850Tester.Services.Iec61850MonitorRuntime; diff --git a/tests/ARSAS.Tests/IoFatSclAppendWorkflowRegressionTests.cs b/tests/ARSAS.Tests/IoFatSclAppendWorkflowRegressionTests.cs index 4a6a50719..8cc4c1359 100644 --- a/tests/ARSAS.Tests/IoFatSclAppendWorkflowRegressionTests.cs +++ b/tests/ARSAS.Tests/IoFatSclAppendWorkflowRegressionTests.cs @@ -3,20 +3,23 @@ namespace ARSAS.Tests; public sealed class IoFatSclAppendWorkflowRegressionTests { [Fact] - public void P04_LoadedFatRoutesSclToAppendInsteadOfWorkspaceReplacement() + public void P04_LegacyWorkspaceOwnsExplicitSclAppendWithoutAHeaderSwitcherEntryPoint() { - var modeSwitch = ReadRepoFile("MainWindow.WorkspaceModeSwitch.cs"); + var lifecycle = ReadRepoFile("MainWindow.WorkspaceModeSwitch.cs"); + var addIed = ReadRepoFile("IoListTestingWindow.AddIed.cs"); + var append = ReadRepoFile("MainWindow.IoTesting.SclAppend.cs"); + var host = ReadRepoFile("MainWindow.IoTesting.cs"); - Assert.Contains("Add SCL / CID to loaded FAT workspace", modeSwitch, StringComparison.Ordinal); - Assert.Contains("OpenSclForLoadedFatAppendAsync(loaded)", modeSwitch, StringComparison.Ordinal); - Assert.DoesNotContain( - "QueueIoFatWorkspaceReplacement(\n () => OpenSclFatTesting_Click", - modeSwitch, - StringComparison.Ordinal); + Assert.DoesNotContain("InstallWorkspaceModeSwitch", lifecycle, StringComparison.Ordinal); + Assert.DoesNotContain("OpenIoFatWorkspaceMenu", lifecycle, StringComparison.Ordinal); + Assert.Contains("ImportAdditionalSclSourcesAsync(engineeringWindow, dialog.FileNames)", addIed, StringComparison.Ordinal); + Assert.Contains("AppendSclIedsToLoadedFatAsync(this, sclPaths)", addIed, StringComparison.Ordinal); + Assert.Contains("Title = \"Add IEC 61850 SCL to loaded FAT workspace\"", append, StringComparison.Ordinal); + Assert.Contains("ImportAdditionalSclSourcesAsync(this, dialog.FileNames)", append, StringComparison.Ordinal); // Workbook and portable project opens intentionally retain replacement semantics. - Assert.Contains("() => OpenIoListTesting_Click(this, new RoutedEventArgs())", modeSwitch, StringComparison.Ordinal); - Assert.Contains("() => OpenIoListPackage_Click(this, new RoutedEventArgs())", modeSwitch, StringComparison.Ordinal); + Assert.Contains("QueueIoFatWorkspaceReplacement(() => OpenIoListTesting_Click(sender, e))", host, StringComparison.Ordinal); + Assert.Contains("QueueIoFatWorkspaceReplacement(() => OpenIoListPackage_Click(sender, e))", host, StringComparison.Ordinal); } [Fact] diff --git a/tests/ARSAS.Tests/MainWindowTopBarLayoutRegressionTests.cs b/tests/ARSAS.Tests/MainWindowTopBarLayoutRegressionTests.cs index 7a2a4aca7..ad7d1a80c 100644 --- a/tests/ARSAS.Tests/MainWindowTopBarLayoutRegressionTests.cs +++ b/tests/ARSAS.Tests/MainWindowTopBarLayoutRegressionTests.cs @@ -63,14 +63,14 @@ public void ResponsiveLabels_DoNotReplaceDiagnosticsAlertContentTree() } [Fact] - public void CompactHeader_DoesNotRemoveWorkspaceFunctions() + public void CompactHeader_HasNoLegacyWorkspaceSwitcherLayoutPath() { var source = File.ReadAllText(FindRepoFile("MainWindow.NavigationLayoutFix.cs")); - Assert.Contains("engineeringText.Text = medium ? \"ENGINEERING\" : \"ENG\"", source, StringComparison.Ordinal); - Assert.Contains("loaded ? \"FAT · LOADED\" : \"FAT\"", source, StringComparison.Ordinal); - Assert.Contains("WorkspaceModeChild_SizeChanged", source, StringComparison.Ordinal); - Assert.DoesNotContain("modeShell.Visibility = Visibility.Collapsed", source, StringComparison.Ordinal); + Assert.DoesNotContain("ApplyWorkspaceSwitchDensity", source, StringComparison.Ordinal); + Assert.DoesNotContain("WorkspaceModeChild_SizeChanged", source, StringComparison.Ordinal); + Assert.DoesNotContain("ARSAS_WORKSPACE_MODE_SWITCH", source, StringComparison.Ordinal); + Assert.Contains("NavNativeFatButton", source, StringComparison.Ordinal); } private static string FindRepoFile(string relativePath) diff --git a/tests/ARSAS.Tests/NativeFatCanonicalEvidenceOverlayTests.cs b/tests/ARSAS.Tests/NativeFatCanonicalEvidenceOverlayTests.cs new file mode 100644 index 000000000..d6e03e25b --- /dev/null +++ b/tests/ARSAS.Tests/NativeFatCanonicalEvidenceOverlayTests.cs @@ -0,0 +1,88 @@ +using ArIED61850Tester.Models; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class NativeFatCanonicalEvidenceOverlayTests +{ + [Fact] + public void BuildRowKey_UsesIedNameAndIecTelegram_NotDeviceIdOrDisplayName() + { + var first = Point("runtime-a", "Trip A", "AA1E1F06R4LD0/GGIO1.Ind1.stVal"); + var recreated = Point("runtime-b", "Renamed display text", "AA1E1F06R4LD0/GGIO1.Ind1.stVal"); + var secondSignal = Point("runtime-a", "Trip A", "AA1E1F06R4LD0/GGIO1.Ind2.stVal"); + + Assert.Equal("aa1e1f06r4|ld0/ggio1.ind1.stval", NativeFatCanonicalEvidenceOverlay.BuildRowKey(first)); + Assert.Equal( + NativeFatCanonicalEvidenceOverlay.BuildRowKey(first), + NativeFatCanonicalEvidenceOverlay.BuildRowKey(recreated)); + Assert.NotEqual( + NativeFatCanonicalEvidenceOverlay.BuildRowKey(first), + NativeFatCanonicalEvidenceOverlay.BuildRowKey(secondSignal)); + Assert.DoesNotContain("runtime-a", NativeFatCanonicalEvidenceOverlay.BuildRowKey(first), StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("Trip A", NativeFatCanonicalEvidenceOverlay.BuildRowKey(first), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void MissingStableIdentity_FailsClosedWithoutSignalNameFallback() + { + var cache = new NativeFatIedSessionCacheState(); + var point = new Iec61850MonitorPoint + { + DeviceId = "runtime-a", + DeviceName = "AA1E1F06R4", + SignalName = "CSWI.Pos", + IecReference = string.Empty, + IecDataType = "BOOLEAN" + }; + + NativeFatCanonicalEvidenceOverlay.Write(cache, point, NativeFatEvidenceField.Value1, "SHOULD-NOT-BIND"); + + Assert.Equal(string.Empty, NativeFatCanonicalEvidenceOverlay.BuildRowKey(point)); + Assert.Equal(string.Empty, NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, point, NativeFatEvidenceField.Value1)); + Assert.Empty(cache.EvidenceByRow); + } + + [Fact] + public void ReadUntouchedEvidence_DoesNotAllocateShadowRow() + { + var cache = new NativeFatIedSessionCacheState(); + var point = Point("dev-1", "Breaker", "AA1E1F06R4LD0/XCBR1.Pos.stVal"); + + Assert.Equal(string.Empty, + NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, point, NativeFatEvidenceField.Value1)); + Assert.Empty(cache.EvidenceByRow); + } + + [Fact] + public void WriteEvidence_UsesOneSparseSlotAndClearingLastValueRemovesIt() + { + var cache = new NativeFatIedSessionCacheState(); + var point = Point("dev-1", "Breaker", "AA1E1F06R4LD0/XCBR1.Pos.stVal"); + + NativeFatCanonicalEvidenceOverlay.Write(cache, point, NativeFatEvidenceField.Value1, "OPEN"); + NativeFatCanonicalEvidenceOverlay.Write(cache, point, NativeFatEvidenceField.Value2, "CLOSE"); + NativeFatCanonicalEvidenceOverlay.Write(cache, point, NativeFatEvidenceField.Result, "PASS"); + + Assert.Single(cache.EvidenceByRow); + Assert.Equal("OPEN", NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, point, NativeFatEvidenceField.Value1)); + Assert.Equal("CLOSE", NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, point, NativeFatEvidenceField.Value2)); + Assert.Equal("PASS", NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, point, NativeFatEvidenceField.Result)); + + NativeFatCanonicalEvidenceOverlay.Write(cache, point, NativeFatEvidenceField.Value1, ""); + NativeFatCanonicalEvidenceOverlay.Write(cache, point, NativeFatEvidenceField.Value2, ""); + NativeFatCanonicalEvidenceOverlay.Write(cache, point, NativeFatEvidenceField.Result, ""); + + Assert.Empty(cache.EvidenceByRow); + } + + private static Iec61850MonitorPoint Point(string deviceId, string signalName, string reference) + => new() + { + DeviceId = deviceId, + DeviceName = "AA1E1F06R4", + SignalName = signalName, + IecReference = reference, + IecDataType = "BOOLEAN" + }; +} diff --git a/tests/ARSAS.Tests/NativeFatDiagnosticsRegressionTests.cs b/tests/ARSAS.Tests/NativeFatDiagnosticsRegressionTests.cs new file mode 100644 index 000000000..ad2c7a74b --- /dev/null +++ b/tests/ARSAS.Tests/NativeFatDiagnosticsRegressionTests.cs @@ -0,0 +1,221 @@ +using System.Globalization; +using AR.Iec61850.FaultRecords; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class NativeFatDiagnosticsRegressionTests +{ + [Fact] + public void ComtradeCount_UsesOnlyDistinctFilesReturnedByFileDirectoryCatalog() + { + var modified = new DateTimeOffset(2026, 9, 13, 3, 0, 0, TimeSpan.Zero); + var records = new[] + { + BuildRecord("FRA00027", modified, "FRA00027.cfg", "FRA00027.dat"), + BuildRecord("FRA00028", modified.AddMinutes(1), "FRA00028.cfg", "FRA00028.dat", "FRA00028.hdr"), + BuildRecord("FRA00028-copy", modified.AddMinutes(2), "FRA00028.cfg") + }; + + Assert.Equal(5, NativeFatComtradeDiagnosticService.CountDetectedFiles(records)); + Assert.Equal(0, NativeFatComtradeDiagnosticService.CountDetectedFiles(Array.Empty())); + } + + [Fact] + public void TimeSync_LtmsPlusFreshIndependentTimestamp_IsOk() + { + var now = new DateTimeOffset(2026, 9, 13, 3, 15, 0, TimeSpan.Zero); + var device = BuildDevice( + Point("LTMS health", "IEDLD0/LTMS1.Health.stVal", "Good", "Good", now.AddSeconds(-1)), + Point("Breaker event", "IEDLD0/XCBR1.Pos.stVal", "Open [01]", "Good", now.AddSeconds(-2))); + + var result = NativeFatTimeSyncDiagnosticService.Evaluate(device, now); + + Assert.True(result.IsSynchronized); + Assert.Equal("OK", result.Verdict); + Assert.True(result.LtmsPresent); + Assert.True(result.LtmsTrusted); + Assert.Equal(1, result.FreshPrimaryTimestampCount); + } + + [Fact] + public void TimeSync_PositiveTimeSynchrnzWithoutPrimaryCrossCheck_NeverGrantsOk() + { + var now = new DateTimeOffset(2026, 9, 13, 3, 15, 0, TimeSpan.Zero); + var device = BuildDevice( + Point("LTMS health", "IEDLD0/LTMS1.Health.stVal", "Good", "Good", now.AddSeconds(-1)), + Point("TimeSynchrnz", "IEDLD0/LLN0.TimeSynchrnz.stVal", "true", "Good", now.AddSeconds(-1))); + + var result = NativeFatTimeSyncDiagnosticService.Evaluate(device, now); + + Assert.False(result.IsSynchronized); + Assert.Equal("REVIEW", result.Verdict); + Assert.Equal(0, result.FreshPrimaryTimestampCount); + Assert.Contains(result.SecondaryTelemetry, item => item.IecReference.Contains("TimeSynchrnz", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void TimeSync_WithoutLtms_RequiresTwoIndependentFreshGoodTimestamps() + { + var now = new DateTimeOffset(2026, 9, 13, 3, 15, 0, TimeSpan.Zero); + var onePoint = BuildDevice( + Point("Breaker event", "IEDLD0/XCBR1.Pos.stVal", "Open [01]", "Good", now.AddSeconds(-1))); + var twoPoints = BuildDevice( + Point("Breaker event", "IEDLD0/XCBR1.Pos.stVal", "Open [01]", "Good", now.AddSeconds(-1)), + Point("Disconnector event", "IEDLD0/XSWI1.Pos.stVal", "Closed [10]", "Good", now.AddSeconds(-3))); + + var insufficient = NativeFatTimeSyncDiagnosticService.Evaluate(onePoint, now); + var fallback = NativeFatTimeSyncDiagnosticService.Evaluate(twoPoints, now); + + Assert.False(insufficient.IsSynchronized); + Assert.Equal("REVIEW", insufficient.Verdict); + Assert.True(fallback.IsSynchronized); + Assert.Equal("OK", fallback.Verdict); + Assert.False(fallback.LtmsPresent); + Assert.Equal(2, fallback.FreshPrimaryTimestampCount); + } + + [Fact] + public void TimeSync_ExplicitNegativeVendorStatus_VetoesOtherwiseGoodPrimaryEvidence() + { + var now = new DateTimeOffset(2026, 9, 13, 3, 15, 0, TimeSpan.Zero); + var device = BuildDevice( + Point("LTMS health", "IEDLD0/LTMS1.Health.stVal", "Good", "Good", now.AddSeconds(-1)), + Point("Breaker event", "IEDLD0/XCBR1.Pos.stVal", "Open [01]", "Good", now.AddSeconds(-1)), + Point("TimeSynchrnz", "IEDLD0/LLN0.TimeSynchrnz.stVal", "false", "Good", now.AddSeconds(-1))); + + var result = NativeFatTimeSyncDiagnosticService.Evaluate(device, now); + + Assert.False(result.IsSynchronized); + Assert.Equal("NOT OK", result.Verdict); + Assert.True(result.ExplicitNegativeSyncStatus); + } + + [Fact] + public void TimeSync_StaleOrBadQualityTimestamp_FailsClosed() + { + var now = new DateTimeOffset(2026, 9, 13, 3, 15, 0, TimeSpan.Zero); + var device = BuildDevice( + Point("LTMS health", "IEDLD0/LTMS1.Health.stVal", "Good", "Good", now.AddSeconds(-1)), + Point("Stale event", "IEDLD0/XCBR1.Pos.stVal", "Open [01]", "Good", now.AddMinutes(-2)), + Point("Bad quality event", "IEDLD0/XSWI1.Pos.stVal", "Closed [10]", "Invalid", now.AddSeconds(-1))); + + var result = NativeFatTimeSyncDiagnosticService.Evaluate(device, now); + + Assert.False(result.IsSynchronized); + Assert.Equal("REVIEW", result.Verdict); + Assert.Equal(0, result.FreshPrimaryTimestampCount); + } + + [Fact] + public void NativeFatDiagnostics_ReusesExistingFileWorkflow_AndDoesNotStartSecondAcquisition() + { + var ui = File.ReadAllText(FindRepoFile("MainWindow.NativeFatDiagnostics.cs")); + var pivot = File.ReadAllText(FindRepoFile("MainWindow.ProductionFatTab.cs")); + var service = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatDiagnosticsService.cs")); + + Assert.Contains("COMTRADE {fileCount} Files", ui, StringComparison.Ordinal); + Assert.Contains("new FaultRecordWindow(device.Name, device.IpAddress, device.Port)", ui, StringComparison.Ordinal); + Assert.Contains("Time Sync OK", ui, StringComparison.Ordinal); + Assert.Contains("SNTP server activity", ui, StringComparison.Ordinal); + Assert.Contains("NativeFatTimeSyncDiagnosticService.Evaluate", ui, StringComparison.Ordinal); + Assert.Contains("InstallNativeFatDiagnosticButtons()", pivot, StringComparison.Ordinal); + Assert.Contains("BindNativeFatDiagnostics(SelectedDevice)", pivot, StringComparison.Ordinal); + Assert.Contains("FileDirectory", service, StringComparison.Ordinal); + + foreach (var forbidden in new[] + { + "ConnectAndDiscoverAsync", + "StartMonitoringAsync", + "PrepareIoTestIedForFatAsync", + "OpenDescribedSourcesAsync", + "IoFatEngineeringWorkspaceProjectionService" + }) + { + Assert.DoesNotContain(forbidden, ui, StringComparison.Ordinal); + Assert.DoesNotContain(forbidden, service, StringComparison.Ordinal); + } + } + + private static Iec61850MonitorDevice BuildDevice(params Iec61850MonitorPoint[] points) + { + var device = new Iec61850MonitorDevice + { + DeviceId = "native-fat-time-sync-test", + Name = "AA1E1F06R4", + IpAddress = "192.168.81.103", + Port = 102, + IsConnected = true, + IsMonitoring = true + }; + foreach (var point in points) + { + point.DeviceId = device.DeviceId; + point.DeviceName = device.Name; + point.IpAddress = device.IpAddress; + device.Points.Add(point); + } + return device; + } + + private static Iec61850MonitorPoint Point( + string name, + string reference, + string value, + string quality, + DateTimeOffset timestamp) + => new() + { + SignalName = name, + IecReference = reference, + Value = value, + Quality = quality, + DeviceTimestamp = timestamp.UtcDateTime.ToString("yyyy-MM-dd HH:mm:ss.fffffff", CultureInfo.InvariantCulture), + SourceMode = "IEC 61850 report", + Status = "Live" + }; + + private static Iec61850FaultRecordSet BuildRecord( + string baseName, + DateTimeOffset modified, + params string[] fileNames) + => new() + { + RecordId = baseName, + BaseName = baseName, + RemoteDirectory = string.Empty, + LastModifiedUtc = modified, + Completeness = "Detected", + Files = fileNames.Select(name => new Iec61850FaultRecordFile + { + Name = name, + RemotePath = name, + BaseName = Path.GetFileNameWithoutExtension(name), + Extension = Path.GetExtension(name), + LastModifiedUtc = modified, + SizeBytes = 1024 + }).ToArray() + }; + + private static string FindRepoFile(string relativePath) + => Path.Combine(FindRepoRoot(), relativePath); + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + if (File.Exists(Path.Combine(directory.FullName, "MainWindow.xaml")) && + Directory.Exists(Path.Combine(directory.FullName, "tests", "ARSAS.Tests"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException( + $"Could not locate repository root from '{AppContext.BaseDirectory}'."); + } +} diff --git a/tests/ARSAS.Tests/NativeFatEvidenceDurabilityRegressionTests.cs b/tests/ARSAS.Tests/NativeFatEvidenceDurabilityRegressionTests.cs new file mode 100644 index 000000000..33c91368e --- /dev/null +++ b/tests/ARSAS.Tests/NativeFatEvidenceDurabilityRegressionTests.cs @@ -0,0 +1,244 @@ +using ArIED61850Tester.Models; +using ArIED61850Tester.Models.IoTesting; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class NativeFatEvidenceDurabilityRegressionTests +{ + [Fact] + public async Task IedOwnedStore_LoadsBeforeRowsExist_AndSurvivesImmediateTeardown() + { + var root = Path.Combine( + Path.GetTempPath(), + "arsas-native-fat-store-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + + try + { + using var store = new NativeFatEvidenceStore(root); + var coordinator = new NativeFatEvidencePersistenceCoordinator(store); + + var before = Device("runtime-before"); + var cswiBefore = Point(before, "CSWI Pos", "Q0/CSWI1.Pos.stVal", "Closed [10]"); + var thdBefore = Point( + before, + "ThdPPV PhsBC", + "VI3p1_THDHarmonics/V_MHAI1.ThdPPV.phsBC.cVal.mag.f", + "0"); + before.Points.Add(cswiBefore); + before.Points.Add(thdBefore); + + var cache = new NativeFatIedSessionCacheState(); + cswiBefore.DeviceTimestamp = "2026-09-13T14:10:11.123+07:00"; + NativeFatCanonicalEvidenceOverlay.WriteCapture( + cache, + cswiBefore, + NativeFatEvidenceField.Value1, + "Closed [10]", + FatEvidenceCaptureKind.AutomaticValue, + DateTimeOffset.Parse("2026-09-13T14:10:11.123+07:00")); + + cswiBefore.DeviceTimestamp = "2026-09-13T14:10:19.456+07:00"; + NativeFatCanonicalEvidenceOverlay.WriteCapture( + cache, + cswiBefore, + NativeFatEvidenceField.Value2, + "Open [01]", + FatEvidenceCaptureKind.AutomaticTransition, + DateTimeOffset.Parse("2026-09-13T14:10:19.456+07:00")); + + // Capture evidence first, then tear down every Engineering row immediately. + // The worker must never need before.Points again. + coordinator.Queue(NativeFatEvidenceDurabilitySnapshot.Capture(before, cache)); + before.Points.Clear(); + await coordinator.DrainAsync(before.Name); + + var path = store.SnapshotPath(before.Name); + Assert.Equal("AA1EIF06R4.native-fat-evidence.json", Path.GetFileName(path)); + Assert.True(File.Exists(path)); + + var json = await File.ReadAllTextAsync(path); + Assert.Contains("\"deviceName\":\"AA1EIF06R4\"", json, StringComparison.Ordinal); + Assert.Contains("\"value1\":\"Closed [10]\"", json, StringComparison.Ordinal); + Assert.Contains("\"value2\":\"Open [01]\"", json, StringComparison.Ordinal); + Assert.Contains("\"result\":\"COMPLETE\"", json, StringComparison.Ordinal); + + // Reopen creates a different runtime DeviceId. Load happens before canonical rows + // exist and therefore cannot depend on Start FAT, Points, row order or selection. + var after = Device("runtime-after"); + var load = await store.LoadAsync(after.Name); + Assert.True(load.Succeeded); + Assert.True(load.SnapshotFound); + Assert.Equal(1, load.LoadedRows); + + var restored = new NativeFatIedSessionCacheState(); + NativeFatCanonicalEvidenceOverlay.MergeMissing(restored, load.EvidenceByRow); + + // Canonical rows materialize later from the SCL/Engineering workspace. + var thdAfter = Point( + after, + "ThdPPV PhsBC", + "VI3p1_THDHarmonics/V_MHAI1.ThdPPV.phsBC.cVal.mag.f", + "0"); + var cswiAfter = Point(after, "CSWI Pos", "Q0/CSWI1.Pos.stVal", "Open [01]"); + after.Points.Add(thdAfter); + after.Points.Add(cswiAfter); + + Assert.Equal( + "Closed [10]", + NativeFatCanonicalEvidenceOverlay.ReadRaw(restored, cswiAfter, NativeFatEvidenceField.Value1)); + Assert.Equal( + "2026-09-13 14:10:11.123", + NativeFatCanonicalEvidenceOverlay.Read(restored, cswiAfter, NativeFatEvidenceField.Value1Timestamp)); + Assert.Equal( + "Open [01]", + NativeFatCanonicalEvidenceOverlay.ReadRaw(restored, cswiAfter, NativeFatEvidenceField.Value2)); + Assert.Equal( + "2026-09-13 14:10:19.456", + NativeFatCanonicalEvidenceOverlay.Read(restored, cswiAfter, NativeFatEvidenceField.Value2Timestamp)); + Assert.Equal( + "OK", + NativeFatCanonicalEvidenceOverlay.Read(restored, cswiAfter, NativeFatEvidenceField.Result)); + Assert.Equal( + "COMPLETE", + NativeFatCanonicalEvidenceOverlay.ReadRaw(restored, cswiAfter, NativeFatEvidenceField.Result)); + + Assert.Equal( + string.Empty, + NativeFatCanonicalEvidenceOverlay.ReadRaw(restored, thdAfter, NativeFatEvidenceField.Value1)); + Assert.Equal( + string.Empty, + NativeFatCanonicalEvidenceOverlay.ReadRaw(restored, thdAfter, NativeFatEvidenceField.Value2)); + + var preview = NativeFatPrintPreviewSnapshot.Capture(after, restored); + var previewCswi = Assert.Single( + preview.Rows.Where(row => row.IecTelegram.Equals(cswiAfter.IecTelegram, StringComparison.OrdinalIgnoreCase))); + var expectedV1 = DateTimeOffset.Parse("2026-09-13T14:10:11.123+07:00") + .ToLocalTime() + .ToString("dd/MM/yyyy HH:mm:ss.fff", System.Globalization.CultureInfo.InvariantCulture); + var expectedV2 = DateTimeOffset.Parse("2026-09-13T14:10:19.456+07:00") + .ToLocalTime() + .ToString("dd/MM/yyyy HH:mm:ss.fff", System.Globalization.CultureInfo.InvariantCulture); + Assert.Equal("Closed [10]", previewCswi.Value1); + Assert.Equal(expectedV1, previewCswi.Value1TimestampText); + Assert.Equal("Open [01]", previewCswi.Value2); + Assert.Equal(expectedV2, previewCswi.Value2TimestampText); + Assert.Equal("COMPLETE", previewCswi.Result); + } + finally + { + try + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + catch + { + } + } + } + + [Fact] + public async Task LoadedPair_StartFatDoesNotOverwriteUntilARealTransitionOccurs() + { + var device = Device("runtime-reopen"); + var point = Point(device, "CSWI Pos", "Q0/CSWI1.Pos.stVal", "Open [01]"); + device.Points.Add(point); + + var cache = new NativeFatIedSessionCacheState(); + NativeFatCanonicalEvidenceOverlay.WriteCapture( + cache, + point, + NativeFatEvidenceField.Value1, + "Closed [10]", + FatEvidenceCaptureKind.AutomaticValue, + DateTimeOffset.Parse("2026-09-13T14:10:11.123+07:00")); + NativeFatCanonicalEvidenceOverlay.WriteCapture( + cache, + point, + NativeFatEvidenceField.Value2, + "Open [01]", + FatEvidenceCaptureKind.AutomaticTransition, + DateTimeOffset.Parse("2026-09-13T14:10:19.456+07:00")); + + using var arm = new NativeFatArmCoordinator(); + var changes = new List(); + arm.EvidenceChanged += (_, e) => changes.Add(e); + + var armed = arm.Arm(device, cache); + Assert.True(armed.Succeeded); + Assert.Equal(0, armed.SeededValue1Rows); + Assert.Empty(changes); + Assert.Equal("Closed [10]", NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, point, NativeFatEvidenceField.Value1)); + Assert.Equal("Open [01]", NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, point, NativeFatEvidenceField.Value2)); + + point.Value = "Closed [10]"; + await Task.Delay(25); + + Assert.Equal("Open [01]", NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, point, NativeFatEvidenceField.Value1)); + Assert.Equal("Closed [10]", NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, point, NativeFatEvidenceField.Value2)); + Assert.Equal("OK", NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Result)); + Assert.NotEmpty(changes); + } + + [Fact] + public void ReleaseGuard_RemovesObsoleteEngineeringCompatibilityButtonFromShippedUi() + { + var root = FindRepoRoot(); + var guard = File.ReadAllText(Path.Combine(root, "IoListTestingWindow.ReleaseNavigationGuard.cs")); + + Assert.Contains("\"Engineering\"", guard, StringComparison.Ordinal); + Assert.Contains("Visibility.Collapsed", guard, StringComparison.Ordinal); + Assert.Contains("button.IsEnabled = false", guard, StringComparison.Ordinal); + } + + private static Iec61850MonitorDevice Device(string deviceId) + => new() + { + DeviceId = deviceId, + Name = "AA1EIF06R4", + IpAddress = "192.168.81.103", + Port = 102, + IsConnected = true, + IsMonitoring = true + }; + + private static Iec61850MonitorPoint Point( + Iec61850MonitorDevice device, + string signal, + string reference, + string value) + => new() + { + DeviceId = device.DeviceId, + DeviceName = device.Name, + IpAddress = device.IpAddress, + SignalName = signal, + IecReference = reference, + IecDataType = "DbPos", + Quality = "Good", + Status = "Live", + SourceMode = "IEC 61850 report", + Value = value, + DeviceTimestamp = "2026-09-13T14:10:00.000+07:00" + }; + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + if (File.Exists(Path.Combine(directory.FullName, "MainWindow.xaml")) && + Directory.Exists(Path.Combine(directory.FullName, "tests", "ARSAS.Tests"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException( + $"Could not locate repository root from '{AppContext.BaseDirectory}'."); + } +} diff --git a/tests/ARSAS.Tests/NativeFatFieldEvidenceRegressionTests.cs b/tests/ARSAS.Tests/NativeFatFieldEvidenceRegressionTests.cs new file mode 100644 index 000000000..cd08f82d5 --- /dev/null +++ b/tests/ARSAS.Tests/NativeFatFieldEvidenceRegressionTests.cs @@ -0,0 +1,377 @@ +using System.Runtime.ExceptionServices; +using System.Threading; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Threading; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class NativeFatFieldEvidenceRegressionTests +{ + [Fact] + public async Task RestartWithNewRuntimeDeviceId_RestoresOnlyExactIedNameAndTelegram() + { + var root = TempRoot(); + try + { + using var service = new NativeFatEvidenceHydrationService(root); + + var before = Device("runtime-before"); + var cswiBefore = Point(before, "CSWI Pos", "Q0/CSWI1.Pos.stVal", "Open [01]"); + var sfBefore = Point(before, "SF62ndCB", "ADD/GGIO5.SF62ndCB.stVal", "false"); + before.Points.Add(cswiBefore); + before.Points.Add(sfBefore); + + var saved = new NativeFatIedSessionCacheState(); + NativeFatCanonicalEvidenceOverlay.WriteCapture( + saved, + cswiBefore, + NativeFatEvidenceField.Value1, + "Open [01]", + ArIED61850Tester.Models.IoTesting.FatEvidenceCaptureKind.AutomaticValue, + DateTimeOffset.Parse("2026-09-13T11:01:37.116+07:00")); + NativeFatCanonicalEvidenceOverlay.WriteCapture( + saved, + cswiBefore, + NativeFatEvidenceField.Value2, + "Closed [10]", + ArIED61850Tester.Models.IoTesting.FatEvidenceCaptureKind.AutomaticTransition, + DateTimeOffset.Parse("2026-09-13T11:01:51.329+07:00")); + await service.SaveAsync(before, saved); + + var after = Device("runtime-after"); + var sfAfter = Point(after, "SF62ndCB", "ADD/GGIO5.SF62ndCB.stVal", "false"); + var cswiAfter = Point(after, "CSWI Pos", "Q0/CSWI1.Pos.stVal", "Closed [10]"); + after.Points.Add(sfAfter); + after.Points.Add(cswiAfter); + + var hydration = await service.HydrateAsync(after); + var restored = new NativeFatIedSessionCacheState(); + NativeFatCanonicalEvidenceOverlay.MergeMissing(restored, hydration.EvidenceByRow); + + Assert.True(hydration.Succeeded); + Assert.True(hydration.SnapshotFound); + Assert.Equal("Open [01]", NativeFatCanonicalEvidenceOverlay.ReadRaw(restored, cswiAfter, NativeFatEvidenceField.Value1)); + Assert.Equal("Closed [10]", NativeFatCanonicalEvidenceOverlay.ReadRaw(restored, cswiAfter, NativeFatEvidenceField.Value2)); + Assert.Equal(string.Empty, NativeFatCanonicalEvidenceOverlay.ReadRaw(restored, sfAfter, NativeFatEvidenceField.Value1)); + Assert.Equal(string.Empty, NativeFatCanonicalEvidenceOverlay.ReadRaw(restored, sfAfter, NativeFatEvidenceField.Value2)); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void RecyclingDataGrid_BoundEvidenceNeverMovesFromCswiToAnotherTelegram() + { + RunSta(() => + { + var device = Device("runtime-ui"); + for (var index = 0; index < 72; index++) + { + device.Points.Add(Point( + device, + $"Signal {index:00}", + $"ADD/GGIO{index / 8 + 1}.Ind{index:00}.stVal", + "false")); + } + + var cswi = Point(device, "CSWI Pos", "Q0/CSWI1.Pos.stVal", "Closed [10]"); + device.Points.Insert(8, cswi); + var thd = Point(device, "ThdPPV PhsBC", "VI3p1_THDHarmonics/V_MHAI1.ThdPPV.phsBC.cVal.mag.f", "0"); + device.Points.Insert(54, thd); + + var cache = new NativeFatIedSessionCacheState(); + NativeFatCanonicalEvidenceOverlay.WriteCapture( + cache, + cswi, + NativeFatEvidenceField.Value1, + "Closed [10]", + ArIED61850Tester.Models.IoTesting.FatEvidenceCaptureKind.AutomaticValue, + DateTimeOffset.Parse("2026-09-13T11:01:51.329+07:00")); + NativeFatCanonicalEvidenceOverlay.WriteCapture( + cache, + cswi, + NativeFatEvidenceField.Value2, + "Open [01]", + ArIED61850Tester.Models.IoTesting.FatEvidenceCaptureKind.AutomaticTransition, + DateTimeOffset.Parse("2026-09-13T11:32:48.064+07:00")); + + string Read(Iec61850MonitorPoint point, NativeFatEvidenceField field) + => NativeFatCanonicalEvidenceOverlay.Read(cache, point, field); + + var value1 = new NativeFatEvidenceBindingColumn("Value 1", NativeFatEvidenceField.Value1, 120, Read); + var value2 = new NativeFatEvidenceBindingColumn("Value 2", NativeFatEvidenceField.Value2, 120, Read); + var result = new NativeFatEvidenceBindingColumn("Result", NativeFatEvidenceField.Result, 100, Read); + var grid = new DataGrid + { + Width = 620, + Height = 180, + AutoGenerateColumns = false, + CanUserAddRows = false, + EnableRowVirtualization = true, + EnableColumnVirtualization = true, + ItemsSource = device.Points + }; + VirtualizingPanel.SetIsVirtualizing(grid, true); + VirtualizingPanel.SetVirtualizationMode(grid, VirtualizationMode.Recycling); + ScrollViewer.SetCanContentScroll(grid, true); + grid.Columns.Add(new DataGridTextColumn + { + Header = "Signal", + Binding = new Binding(nameof(Iec61850MonitorPoint.SignalName)), + Width = 180 + }); + grid.Columns.Add(value1); + grid.Columns.Add(value2); + grid.Columns.Add(result); + + var window = new Window + { + Width = 660, + Height = 220, + ShowActivated = false, + ShowInTaskbar = false, + WindowStyle = WindowStyle.ToolWindow, + Content = grid + }; + + window.Show(); + try + { + Pump(grid); + AssertRealizedRowsMatch(grid, value1, value2, result, cache); + + foreach (var target in new[] + { + device.Points[0], + thd, + device.Points[^1], + cswi, + thd, + device.Points[20], + cswi + }) + { + grid.ScrollIntoView(target); + grid.UpdateLayout(); + Pump(grid); + AssertRealizedRowsMatch(grid, value1, value2, result, cache); + } + + grid.ScrollIntoView(thd); + grid.UpdateLayout(); + Pump(grid); + Assert.Equal(string.Empty, CellText(value1, thd)); + Assert.Equal(string.Empty, CellText(value2, thd)); + Assert.Equal(string.Empty, CellText(result, thd)); + + grid.ScrollIntoView(cswi); + grid.UpdateLayout(); + Pump(grid); + Assert.Equal("Closed [10]", CellText(value1, cswi)); + Assert.Equal("Open [01]", CellText(value2, cswi)); + Assert.Equal("OK", CellText(result, cswi)); + } + finally + { + window.Close(); + Pump(grid); + } + }); + } + + [Fact] + public void EvidenceBindingRuntime_UsesBindingTargetsAndHasNoRowRecycleTextPatch() + { + var binding = File.ReadAllText(FindRepoFile("MainWindow.NativeFatEvidenceBindingRuntime.cs")); + var tab = File.ReadAllText(FindRepoFile("MainWindow.ProductionFatTab.cs")); + + Assert.Contains("Path = new PropertyPath(\".\")", binding, StringComparison.Ordinal); + Assert.Contains("GetBindingExpression(TextBlock.TextProperty)?.UpdateTarget()", binding, StringComparison.Ordinal); + Assert.Contains("NativeFatEvidenceBindingColumn", binding, StringComparison.Ordinal); + Assert.DoesNotContain("row.DataContextChanged", binding, StringComparison.Ordinal); + Assert.DoesNotContain("textBlock.Text =", binding, StringComparison.Ordinal); + Assert.Contains("FlushNativeFatEvidenceBeforeShutdown();", tab, StringComparison.Ordinal); + Assert.False(File.Exists(FindRepoFile("MainWindow.NativeFatFieldEvidenceFixes.cs"))); + } + + [Fact] + public void ReportLogoPlacement_WideAndSquareMarksUseHeaderSpaceWithoutDistortion() + { + var wide = new NativeFatReportLogo(400, 100, Array.Empty(), "wide"); + var square = new NativeFatReportLogo(256, 256, Array.Empty(), "square"); + + var widePlacement = NativeFatReportLogoService.CalculatePlacement(wide); + var squarePlacement = NativeFatReportLogoService.CalculatePlacement(square); + + Assert.InRange(widePlacement.Width, 150d, 156d); + Assert.InRange(widePlacement.Height, 37d, 40d); + Assert.Equal(4d, widePlacement.Width / widePlacement.Height, 6); + + Assert.Equal(42d, squarePlacement.Width, 6); + Assert.Equal(42d, squarePlacement.Height, 6); + Assert.True(squarePlacement.X > widePlacement.X); + Assert.InRange(squarePlacement.TopY, 577.9d, 578.1d); + } + + [Fact] + public void ReportLogo_TransparentCanvasIsTrimmedBeforeAdaptiveFit() + { + const int width = 12; + const int height = 10; + var bgra = new byte[width * height * 4]; + for (var y = 3; y <= 6; y++) + { + for (var x = 2; x <= 9; x++) + bgra[((y * width) + x) * 4 + 3] = 255; + } + + var bounds = NativeFatReportLogoService.FindVisibleBounds(bgra, width, height); + + Assert.Equal(2, bounds.X); + Assert.Equal(3, bounds.Y); + Assert.Equal(8, bounds.Width); + Assert.Equal(4, bounds.Height); + } + + private static void AssertRealizedRowsMatch( + DataGrid grid, + NativeFatEvidenceBindingColumn value1, + NativeFatEvidenceBindingColumn value2, + NativeFatEvidenceBindingColumn result, + NativeFatIedSessionCacheState cache) + { + foreach (var item in grid.Items.OfType()) + { + if (grid.ItemContainerGenerator.ContainerFromItem(item) is not DataGridRow) + continue; + + Assert.Equal( + NativeFatCanonicalEvidenceOverlay.Read(cache, item, NativeFatEvidenceField.Value1), + CellText(value1, item)); + Assert.Equal( + NativeFatCanonicalEvidenceOverlay.Read(cache, item, NativeFatEvidenceField.Value2), + CellText(value2, item)); + Assert.Equal( + NativeFatCanonicalEvidenceOverlay.Read(cache, item, NativeFatEvidenceField.Result), + CellText(result, item)); + } + } + + private static string CellText(NativeFatEvidenceBindingColumn column, Iec61850MonitorPoint point) + => column.GetCellContent(point) switch + { + TextBlock block => block.Text, + TextBox editor => editor.Text, + _ => string.Empty + }; + + private static void Pump(DispatcherObject owner) + => owner.Dispatcher.Invoke(DispatcherPriority.ApplicationIdle, new Action(() => { })); + + private static void RunSta(Action action) + { + Exception? failure = null; + using var done = new ManualResetEventSlim(false); + var thread = new Thread(() => + { + try + { + action(); + } + catch (Exception ex) + { + failure = ex; + } + finally + { + done.Set(); + } + }); + thread.SetApartmentState(ApartmentState.STA); + thread.IsBackground = true; + thread.Start(); + + if (!done.Wait(TimeSpan.FromSeconds(25))) + throw new TimeoutException("STA WPF recycling regression test exceeded 25 seconds."); + + thread.Join(); + if (failure != null) + ExceptionDispatchInfo.Capture(failure).Throw(); + } + + private static Iec61850MonitorDevice Device(string deviceId) + => new() + { + DeviceId = deviceId, + Name = "AA1EIF06R4", + IpAddress = "192.168.81.103", + Port = 102, + IsConnected = true, + IsMonitoring = true + }; + + private static Iec61850MonitorPoint Point( + Iec61850MonitorDevice device, + string signal, + string reference, + string value) + => new() + { + DeviceId = device.DeviceId, + DeviceName = device.Name, + SignalName = signal, + IecReference = reference, + IecDataType = "DbPos", + Quality = "Good", + Status = "Live", + SourceMode = "IEC 61850 report", + Value = value, + DeviceTimestamp = "2026-09-13T11:01:51.329+07:00" + }; + + private static string TempRoot() + { + var path = Path.Combine(Path.GetTempPath(), "arsas-native-fat-field-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + return path; + } + + private static void TryDelete(string path) + { + try + { + if (Directory.Exists(path)) + Directory.Delete(path, recursive: true); + } + catch + { + } + } + + private static string FindRepoFile(string relativePath) + => Path.Combine(FindRepoRoot(), relativePath); + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + if (File.Exists(Path.Combine(directory.FullName, "MainWindow.xaml")) && + Directory.Exists(Path.Combine(directory.FullName, "tests", "ARSAS.Tests"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException( + $"Could not locate repository root from '{AppContext.BaseDirectory}'."); + } +} diff --git a/tests/ARSAS.Tests/NativeFatP1DArmCoordinatorTests.cs b/tests/ARSAS.Tests/NativeFatP1DArmCoordinatorTests.cs new file mode 100644 index 000000000..0f642c403 --- /dev/null +++ b/tests/ARSAS.Tests/NativeFatP1DArmCoordinatorTests.cs @@ -0,0 +1,160 @@ +using ArIED61850Tester.Models; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class NativeFatP1DArmCoordinatorTests +{ + [Fact] + public void Arm_RequiresAlreadyRunningEngineeringMonitoring() + { + using var coordinator = new NativeFatArmCoordinator(); + var device = Device(isConnected: true, isMonitoring: false); + var cache = new NativeFatIedSessionCacheState(); + + var result = coordinator.Arm(device, cache); + + Assert.False(result.Succeeded); + Assert.False(cache.IsArmed); + Assert.Empty(cache.EvidenceByRow); + } + + [Fact] + public void Arm_SeedsValue1AndCapturesValue2WithoutReplacingCanonicalRows() + { + using var coordinator = new NativeFatArmCoordinator(); + var device = Device(isConnected: true, isMonitoring: true); + var point = Point(device.DeviceId, "Breaker", "AA1E1F06R4LD0/XCBR1.Pos.stVal", "Open [01]"); + device.Points.Add(point); + var canonicalReference = device.Points[0]; + var cache = new NativeFatIedSessionCacheState(); + + var result = coordinator.Arm(device, cache); + + Assert.True(result.Succeeded); + Assert.True(cache.IsArmed); + Assert.Equal(1, result.ArmedRows); + Assert.Equal(1, result.SeededValue1Rows); + Assert.Single(device.Points); + Assert.Same(canonicalReference, device.Points[0]); + Assert.Equal("Open [01]", NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, point, NativeFatEvidenceField.Value1)); + Assert.Equal(string.Empty, NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, point, NativeFatEvidenceField.Value2)); + + point.Value = "Closed [10]"; + + Assert.Single(device.Points); + Assert.Same(canonicalReference, device.Points[0]); + Assert.Equal("Open [01]", NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, point, NativeFatEvidenceField.Value1)); + Assert.Equal("Closed [10]", NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, point, NativeFatEvidenceField.Value2)); + Assert.Equal(string.Empty, NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, point, NativeFatEvidenceField.Result)); + } + + [Fact] + public void Arm_LatestMeaningfulTransitionRollsValuePairAndLeavesResultOperatorOwned() + { + using var coordinator = new NativeFatArmCoordinator(); + var device = Device(isConnected: true, isMonitoring: true); + var point = Point(device.DeviceId, "Status", "AA1E1F06R4LD0/GGIO1.Ind1.stVal", "False"); + device.Points.Add(point); + var cache = new NativeFatIedSessionCacheState(); + NativeFatCanonicalEvidenceOverlay.Write(cache, point, NativeFatEvidenceField.Result, "REVIEW"); + + Assert.True(coordinator.Arm(device, cache).Succeeded); + point.Value = "True"; + point.Value = "False"; + + Assert.Equal("True", NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, point, NativeFatEvidenceField.Value1)); + Assert.Equal("False", NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, point, NativeFatEvidenceField.Value2)); + Assert.Equal("REVIEW", NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, point, NativeFatEvidenceField.Result)); + } + + [Fact] + public void NativeStartHandler_IsSynchronousArmOnlyAndCannotEnterLegacyPreparation() + { + var source = File.ReadAllText(FindRepoFile("MainWindow.NativeFatCanonicalGrid.cs")); + var method = ExtractMethod(source, "private void NativeFatStartButton_Click(object sender, RoutedEventArgs e)"); + + Assert.Contains("Stopwatch.StartNew()", method, StringComparison.Ordinal); + Assert.Contains("_nativeFatArmCoordinator.Arm(device, cache)", method, StringComparison.Ordinal); + Assert.Contains("networkPrepare=false", method, StringComparison.Ordinal); + Assert.Contains("reconnect=false", method, StringComparison.Ordinal); + Assert.Contains("sclImport=false", method, StringComparison.Ordinal); + Assert.Contains("discovery=false", method, StringComparison.Ordinal); + Assert.Contains("reportRestart=false", method, StringComparison.Ordinal); + Assert.Contains("pollingChange=false", method, StringComparison.Ordinal); + + Assert.DoesNotContain("await ", method, StringComparison.Ordinal); + Assert.DoesNotContain("PrepareIoTestIedForFatAsync", method, StringComparison.Ordinal); + Assert.DoesNotContain("OpenDescribedSourcesAsync", method, StringComparison.Ordinal); + Assert.DoesNotContain("ConnectAndDiscoverAsync", method, StringComparison.Ordinal); + Assert.DoesNotContain("StartMonitoringAsync", method, StringComparison.Ordinal); + Assert.DoesNotContain("IoFatEngineeringWorkspaceProjectionService", method, StringComparison.Ordinal); + } + + private static Iec61850MonitorDevice Device(bool isConnected, bool isMonitoring) + => new() + { + DeviceId = "dev-aa1e1f06r4", + Name = "AA1E1F06R4", + IpAddress = "192.168.81.103", + Port = 102, + IsConnected = isConnected, + IsMonitoring = isMonitoring + }; + + private static Iec61850MonitorPoint Point( + string deviceId, + string signalName, + string reference, + string value) + => new() + { + DeviceId = deviceId, + DeviceName = "AA1E1F06R4", + SignalName = signalName, + IecReference = reference, + IecDataType = "BOOLEAN", + Quality = "Good", + Status = "Live", + SourceMode = "Static DataSet reporting", + Value = value + }; + + private static string ExtractMethod(string source, string signature) + { + var start = source.IndexOf(signature, StringComparison.Ordinal); + Assert.True(start >= 0, $"Could not find method '{signature}'."); + var openBrace = source.IndexOf('{', start); + Assert.True(openBrace >= 0, $"Could not find opening brace for '{signature}'."); + + var depth = 0; + for (var index = openBrace; index < source.Length; index++) + { + if (source[index] == '{') depth++; + else if (source[index] == '}' && --depth == 0) return source[start..(index + 1)]; + } + + throw new InvalidDataException($"Method '{signature}' has no balanced closing brace."); + } + + private static string FindRepoFile(string relativePath) + => Path.Combine(FindRepoRoot(), relativePath); + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + if (File.Exists(Path.Combine(directory.FullName, "MainWindow.xaml")) && + Directory.Exists(Path.Combine(directory.FullName, "tests", "ARSAS.Tests"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException( + $"Could not locate repository root from '{AppContext.BaseDirectory}'."); + } +} diff --git a/tests/ARSAS.Tests/NativeFatP2EvidenceHydrationTests.cs b/tests/ARSAS.Tests/NativeFatP2EvidenceHydrationTests.cs new file mode 100644 index 000000000..0b2189a2f --- /dev/null +++ b/tests/ARSAS.Tests/NativeFatP2EvidenceHydrationTests.cs @@ -0,0 +1,281 @@ +using System.Text.Json; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class NativeFatP2EvidenceHydrationTests +{ + [Fact] + public async Task PersistThenHydrate_RestoresOnlySparseEvidenceForCanonicalRows() + { + var root = TempRoot(); + try + { + using var service = new NativeFatEvidenceHydrationService(root); + var device = Device(); + var first = Point(device.DeviceId, "Breaker", "AA1E1F06R4LD0/XCBR1.Pos.stVal"); + var second = Point(device.DeviceId, "Trip", "AA1E1F06R4LD0/GGIO1.Ind1.stVal"); + device.Points.Add(first); + device.Points.Add(second); + + var saved = new NativeFatIedSessionCacheState(); + NativeFatCanonicalEvidenceOverlay.Write(saved, first, NativeFatEvidenceField.Value1, "Open [01]"); + NativeFatCanonicalEvidenceOverlay.Write(saved, first, NativeFatEvidenceField.Value2, "Closed [10]"); + NativeFatCanonicalEvidenceOverlay.Write(saved, first, NativeFatEvidenceField.Result, "PASS"); + await service.SaveAsync(device, saved); + + var result = await service.HydrateAsync(device); + var restored = new NativeFatIedSessionCacheState(); + var merged = NativeFatCanonicalEvidenceOverlay.MergeMissing(restored, result.EvidenceByRow); + + Assert.True(result.Succeeded); + Assert.True(result.SnapshotFound); + Assert.Equal(1, result.LoadedRows); + Assert.Equal(1, merged); + Assert.Equal("Open [01]", NativeFatCanonicalEvidenceOverlay.ReadRaw(restored, first, NativeFatEvidenceField.Value1)); + Assert.Equal("Closed [10]", NativeFatCanonicalEvidenceOverlay.ReadRaw(restored, first, NativeFatEvidenceField.Value2)); + Assert.Equal("PASS", NativeFatCanonicalEvidenceOverlay.ReadRaw(restored, first, NativeFatEvidenceField.Result)); + Assert.Equal(string.Empty, NativeFatCanonicalEvidenceOverlay.ReadRaw(restored, second, NativeFatEvidenceField.Value1)); + Assert.Equal(2, device.Points.Count); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public async Task HydrationMerge_NeverOverwritesEvidenceCapturedAfterHydrationStarted() + { + var root = TempRoot(); + try + { + using var service = new NativeFatEvidenceHydrationService(root); + var device = Device(); + var point = Point(device.DeviceId, "Breaker", "AA1E1F06R4LD0/XCBR1.Pos.stVal"); + device.Points.Add(point); + + var persisted = new NativeFatIedSessionCacheState(); + NativeFatCanonicalEvidenceOverlay.Write(persisted, point, NativeFatEvidenceField.Value1, "OLD-V1"); + NativeFatCanonicalEvidenceOverlay.Write(persisted, point, NativeFatEvidenceField.Value2, "OLD-V2"); + NativeFatCanonicalEvidenceOverlay.Write(persisted, point, NativeFatEvidenceField.Result, "PASS"); + await service.SaveAsync(device, persisted); + + var result = await service.HydrateAsync(device); + var live = new NativeFatIedSessionCacheState(); + NativeFatCanonicalEvidenceOverlay.Write(live, point, NativeFatEvidenceField.Value1, "NEW-LIVE-V1"); + + NativeFatCanonicalEvidenceOverlay.MergeMissing(live, result.EvidenceByRow); + + Assert.Equal("NEW-LIVE-V1", NativeFatCanonicalEvidenceOverlay.ReadRaw(live, point, NativeFatEvidenceField.Value1)); + Assert.Equal("OLD-V2", NativeFatCanonicalEvidenceOverlay.ReadRaw(live, point, NativeFatEvidenceField.Value2)); + Assert.Equal("PASS", NativeFatCanonicalEvidenceOverlay.ReadRaw(live, point, NativeFatEvidenceField.Result)); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public async Task FirstNativeOpen_PassivelyHydratesUniqueLegacyEvidenceWithoutOpeningLegacyWorkspace() + { + var nativeRoot = TempRoot(); + var legacyRoot = TempRoot(); + try + { + var device = Device(); + var point = Point(device.DeviceId, "Breaker", "AA1E1F06R4LD0/XCBR1.Pos.stVal"); + device.Points.Add(point); + + var legacyProject = new + { + project = new + { + ieds = new[] + { + new + { + iedName = "AA1E1F06R4", + ipAddress = "192.168.81.103", + liveDeviceId = device.DeviceId, + testPoints = new[] + { + new + { + testPointId = "scl-manual-7496d038be4fdc18e340", + sourceIecReference = "LD0/XCBR1$ST$Pos$stVal", + eventLogSearchReference = "", + reportDisplayReference = "", + objectReference = "LD0/XCBR1$ST$Pos$stVal", + signalAddress = "", + captureMode = 0, + reviewStatus = "", + runtime = new + { + state = 5, + value1Evidence = new { rawValue = "Open [01]" }, + value2Evidence = new { rawValue = "Closed [10]" } + } + } + } + } + } + } + }; + var legacyDirectory = Path.Combine(legacyRoot, "legacy-project"); + Directory.CreateDirectory(legacyDirectory); + await File.WriteAllTextAsync( + Path.Combine(legacyDirectory, "project.snapshot.json"), + JsonSerializer.Serialize(legacyProject)); + + using var service = new NativeFatEvidenceHydrationService(nativeRoot, legacyRoot); + var result = await service.HydrateAsync(device); + var restored = new NativeFatIedSessionCacheState(); + NativeFatCanonicalEvidenceOverlay.MergeMissing(restored, result.EvidenceByRow); + + Assert.True(result.Succeeded); + Assert.True(result.SnapshotFound); + Assert.Equal(1, result.LoadedRows); + Assert.Equal("Open [01]", NativeFatCanonicalEvidenceOverlay.ReadRaw(restored, point, NativeFatEvidenceField.Value1)); + Assert.Equal("Closed [10]", NativeFatCanonicalEvidenceOverlay.ReadRaw(restored, point, NativeFatEvidenceField.Value2)); + Assert.Equal("PASS", NativeFatCanonicalEvidenceOverlay.ReadRaw(restored, point, NativeFatEvidenceField.Result)); + Assert.Single(device.Points); + } + finally + { + TryDelete(nativeRoot); + TryDelete(legacyRoot); + } + } + + [Fact] + public async Task MissingSnapshot_ResolvesAsEmptyWithoutInventingEvidence() + { + var root = TempRoot(); + try + { + using var service = new NativeFatEvidenceHydrationService(root); + var device = Device(); + device.Points.Add(Point(device.DeviceId, "Trip", "AA1E1F06R4LD0/GGIO1.Ind1.stVal")); + + var result = await service.HydrateAsync(device); + + Assert.True(result.Succeeded); + Assert.False(result.SnapshotFound); + Assert.Empty(result.EvidenceByRow); + } + finally + { + TryDelete(root); + } + } + + [Theory] + [InlineData(0, "·")] + [InlineData(1, "··")] + [InlineData(2, "···")] + [InlineData(3, "·")] + public void RollingDots_UsesOneSharedPhase(int phase, string expected) + => Assert.Equal(expected, NativeFatEvidenceLoadingPresentation.RollingDots(phase)); + + [Fact] + public void P2_GridBindsCanonicalRowsBeforeStartingEvidenceHydration_AndUsesOneClock() + { + var gridSource = File.ReadAllText(FindRepoFile("MainWindow.NativeFatCanonicalGrid.cs")); + var serviceSource = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatEvidenceHydrationService.cs")); + + var bind = gridSource.IndexOf("_nativeFatCanonicalGrid.ItemsSource = device?.Points;", StringComparison.Ordinal); + var hydrate = gridSource.IndexOf("BeginNativeFatEvidenceHydration(device);", bind, StringComparison.Ordinal); + Assert.True(bind >= 0); + Assert.True(hydrate > bind, "Canonical Engineering rows must be visible before evidence hydration begins."); + + Assert.Contains("DispatcherTimer? _nativeFatEvidenceClock", gridSource, StringComparison.Ordinal); + Assert.Equal(1, CountOccurrences(gridSource, "new DispatcherTimer")); + Assert.Contains("NativeFatEvidenceLoadingPresentation.RollingDots", gridSource, StringComparison.Ordinal); + Assert.Contains("cache.IsEvidenceHydrating", gridSource, StringComparison.Ordinal); + + Assert.DoesNotContain("PrepareIoTestIedForFatAsync", serviceSource, StringComparison.Ordinal); + Assert.DoesNotContain("OpenDescribedSourcesAsync", serviceSource, StringComparison.Ordinal); + Assert.DoesNotContain("IoFatEngineeringWorkspaceProjectionService", serviceSource, StringComparison.Ordinal); + Assert.DoesNotContain("ConnectAndDiscoverAsync", serviceSource, StringComparison.Ordinal); + Assert.DoesNotContain("StartMonitoringAsync", serviceSource, StringComparison.Ordinal); + } + + private static int CountOccurrences(string source, string token) + { + var count = 0; + var offset = 0; + while ((offset = source.IndexOf(token, offset, StringComparison.Ordinal)) >= 0) + { + count++; + offset += token.Length; + } + return count; + } + + private static Iec61850MonitorDevice Device() + => new() + { + DeviceId = "dev-aa1e1f06r4", + Name = "AA1E1F06R4", + IpAddress = "192.168.81.103", + Port = 102, + IsConnected = true, + IsMonitoring = true + }; + + private static Iec61850MonitorPoint Point(string deviceId, string name, string reference) + => new() + { + DeviceId = deviceId, + DeviceName = "AA1E1F06R4", + SignalName = name, + IecReference = reference, + IecDataType = "BOOLEAN", + Quality = "Good", + Status = "Live", + Value = "False" + }; + + private static string TempRoot() + { + var path = Path.Combine(Path.GetTempPath(), "arsas-native-fat-p2-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + return path; + } + + private static void TryDelete(string path) + { + try + { + if (Directory.Exists(path)) + Directory.Delete(path, recursive: true); + } + catch + { + } + } + + private static string FindRepoFile(string relativePath) + => Path.Combine(FindRepoRoot(), relativePath); + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + if (File.Exists(Path.Combine(directory.FullName, "MainWindow.xaml")) && + Directory.Exists(Path.Combine(directory.FullName, "tests", "ARSAS.Tests"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException( + $"Could not locate repository root from '{AppContext.BaseDirectory}'."); + } +} diff --git a/tests/ARSAS.Tests/NativeFatP3PrintPreviewTests.cs b/tests/ARSAS.Tests/NativeFatP3PrintPreviewTests.cs new file mode 100644 index 000000000..99b8d871c --- /dev/null +++ b/tests/ARSAS.Tests/NativeFatP3PrintPreviewTests.cs @@ -0,0 +1,210 @@ +using ArIED61850Tester.Models; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class NativeFatP3PrintPreviewTests +{ + [Fact] + public void Capture_CopiesSelectedCanonicalRowsInCurrentOrderAndSparseEvidence() + { + var device = Device("dev-aa1e1f06r4", "AA1E1F06R4", "192.168.81.103"); + var breaker = Point(device.DeviceId, device.Name, "Breaker", "AA1E1F06R4LD0/XCBR1.Pos.stVal", "Open [01]"); + var trip = Point(device.DeviceId, device.Name, "Trip", "AA1E1F06R4LD0/GGIO1.Ind1.stVal", "False"); + device.Points.Add(breaker); + device.Points.Add(trip); + + var cache = new NativeFatIedSessionCacheState(); + NativeFatCanonicalEvidenceOverlay.Write(cache, breaker, NativeFatEvidenceField.Value1, "Open [01]"); + NativeFatCanonicalEvidenceOverlay.Write(cache, breaker, NativeFatEvidenceField.Value2, "Closed [10]"); + NativeFatCanonicalEvidenceOverlay.Write(cache, breaker, NativeFatEvidenceField.Result, "PASS"); + NativeFatCanonicalEvidenceOverlay.Write(cache, trip, NativeFatEvidenceField.Value1, "False"); + + var snapshot = NativeFatPrintPreviewSnapshot.Capture(device, cache); + + Assert.Equal(device.DeviceId, snapshot.DeviceId); + Assert.Equal("AA1E1F06R4", snapshot.IedName); + Assert.Equal("192.168.81.103", snapshot.IpAddress); + Assert.Equal(102, snapshot.Port); + Assert.Equal(2, snapshot.Rows.Count); + Assert.Equal("Breaker", snapshot.Rows[0].Signal); + Assert.Equal("Trip", snapshot.Rows[1].Signal); + Assert.Equal("LD0/XCBR1.Pos.stVal", snapshot.Rows[0].IecTelegram); + Assert.Equal("Good", snapshot.Rows[0].Quality); + Assert.Equal("Open [01]", snapshot.Rows[0].LiveValue); + Assert.Equal("Open [01]", snapshot.Rows[0].Value1); + Assert.NotEqual("—", snapshot.Rows[0].Value1TimestampText); + Assert.Equal("Closed [10]", snapshot.Rows[0].Value2); + Assert.NotEqual("—", snapshot.Rows[0].Value2TimestampText); + Assert.Equal("PASS", snapshot.Rows[0].Result); + Assert.Equal("Evidence complete: 1 / 2 signals", snapshot.ProgressText); + } + + [Fact] + public void Capture_RemainsImmutableWhenLiveRowsAndEvidenceChange() + { + var device = Device("dev-aa1e1f06r4", "AA1E1F06R4", "192.168.81.103"); + var point = Point(device.DeviceId, device.Name, "Breaker", "AA1E1F06R4LD0/XCBR1.Pos.stVal", "Open [01]"); + device.Points.Add(point); + var cache = new NativeFatIedSessionCacheState(); + NativeFatCanonicalEvidenceOverlay.Write(cache, point, NativeFatEvidenceField.Value1, "Open [01]"); + NativeFatCanonicalEvidenceOverlay.Write(cache, point, NativeFatEvidenceField.Value2, "Closed [10]"); + NativeFatCanonicalEvidenceOverlay.Write(cache, point, NativeFatEvidenceField.Result, "PASS"); + + var snapshot = NativeFatPrintPreviewSnapshot.Capture(device, cache); + var capturedValue1 = snapshot.Rows[0].Value1; + var capturedValue1Timestamp = snapshot.Rows[0].Value1TimestampText; + var capturedValue2 = snapshot.Rows[0].Value2; + var capturedValue2Timestamp = snapshot.Rows[0].Value2TimestampText; + + point.Value = "Closed [10]"; + point.SignalName = "MUTATED"; + point.Quality = "Questionable"; + NativeFatCanonicalEvidenceOverlay.Write(cache, point, NativeFatEvidenceField.Value1, "NEW-V1"); + NativeFatCanonicalEvidenceOverlay.Write(cache, point, NativeFatEvidenceField.Value2, "NEW-V2"); + NativeFatCanonicalEvidenceOverlay.Write(cache, point, NativeFatEvidenceField.Result, "REVIEW"); + + Assert.Single(snapshot.Rows); + Assert.Equal("Breaker", snapshot.Rows[0].Signal); + Assert.Equal("LD0/XCBR1.Pos.stVal", snapshot.Rows[0].IecTelegram); + Assert.Equal("Good", snapshot.Rows[0].Quality); + Assert.Equal("Open [01]", snapshot.Rows[0].LiveValue); + Assert.Equal(capturedValue1, snapshot.Rows[0].Value1); + Assert.Equal(capturedValue1Timestamp, snapshot.Rows[0].Value1TimestampText); + Assert.Equal(capturedValue2, snapshot.Rows[0].Value2); + Assert.Equal(capturedValue2Timestamp, snapshot.Rows[0].Value2TimestampText); + Assert.Equal("Open [01]", snapshot.Rows[0].Value1); + Assert.Equal("Closed [10]", snapshot.Rows[0].Value2); + Assert.Equal("PASS", snapshot.Rows[0].Result); + } + + [Fact] + public void Capture_ContainsOnlyRequestedDevice() + { + var selected = Device("dev-aa1e1f06r4", "AA1E1F06R4", "192.168.81.103"); + selected.Points.Add(Point(selected.DeviceId, selected.Name, "Selected", "AA1E1F06R4LD0/GGIO1.Ind1.stVal", "True")); + + var other = Device("dev-other", "OTHER-IED", "192.168.81.104"); + other.Points.Add(Point(other.DeviceId, other.Name, "Other", "OTHERLD0/GGIO1.Ind1.stVal", "False")); + + var snapshot = NativeFatPrintPreviewSnapshot.Capture(selected, new NativeFatIedSessionCacheState()); + + Assert.Equal(selected.DeviceId, snapshot.DeviceId); + Assert.Equal(selected.Name, snapshot.IedName); + Assert.Single(snapshot.Rows); + Assert.Equal("Selected", snapshot.Rows[0].Signal); + Assert.DoesNotContain(snapshot.Rows, row => row.Signal == "Other"); + } + + [Fact] + public void P3_PreviewCaptureRemainsLazySelectedIedOnly() + { + var gridSource = File.ReadAllText(FindRepoFile("MainWindow.NativeFatCanonicalGrid.cs")); + var previewSource = File.ReadAllText(FindRepoFile("MainWindow.NativeFatPrintPreview.cs")); + var snapshotSource = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatPrintPreviewSnapshot.cs")); + + var build = ExtractMethod(gridSource, "private FrameworkElement BuildNativeFatCanonicalWorkspace(string? statusText = null)"); + var bind = ExtractMethod(gridSource, "private void BindNativeFatCanonicalRows()"); + var click = ExtractMethod(previewSource, "private void NativeFatPrintPreviewButton_Click(object sender, RoutedEventArgs e)"); + + Assert.Contains("Content = \"Print Preview\"", build, StringComparison.Ordinal); + Assert.DoesNotContain("NativeFatPrintPreviewSnapshot.Capture", build, StringComparison.Ordinal); + Assert.DoesNotContain("ShowNativeFatPrintPreview", build, StringComparison.Ordinal); + Assert.DoesNotContain("NativeFatPrintPreviewSnapshot.Capture", bind, StringComparison.Ordinal); + Assert.DoesNotContain("ShowNativeFatPrintPreview", bind, StringComparison.Ordinal); + Assert.Contains("NativeFatPrintPreviewSnapshot.Capture", click, StringComparison.Ordinal); + Assert.Contains("ShowNativeFatPrintPreview(snapshot)", click, StringComparison.Ordinal); + Assert.Contains("SelectedDevice", click, StringComparison.Ordinal); + + Assert.Contains("device.Points.Select", snapshotSource, StringComparison.Ordinal); + Assert.Contains("Array.AsReadOnly", snapshotSource, StringComparison.Ordinal); + Assert.Contains("Copy(point.IecTelegram)", snapshotSource, StringComparison.Ordinal); + Assert.Contains("Copy(point.Quality)", snapshotSource, StringComparison.Ordinal); + Assert.Contains("NativeFatCanonicalEvidenceOverlay.ReadRaw", snapshotSource, StringComparison.Ordinal); + Assert.Contains("NativeFatCanonicalEvidenceOverlay.ReadCapture", snapshotSource, StringComparison.Ordinal); + Assert.Contains("DisplayTimestamp(capture1)", snapshotSource, StringComparison.Ordinal); + Assert.Contains("DisplayTimestamp(capture2)", snapshotSource, StringComparison.Ordinal); + Assert.DoesNotContain("Iec61850MonitorPoint Point", snapshotSource, StringComparison.Ordinal); + + foreach (var forbidden in new[] + { + "IoTestProject", + "PrepareIoTestIedForFatAsync", + "OpenDescribedSourcesAsync", + "IoFatEngineeringWorkspaceProjectionService", + "ConnectAndDiscoverAsync", + "StartMonitoringAsync" + }) + { + Assert.DoesNotContain(forbidden, previewSource, StringComparison.Ordinal); + Assert.DoesNotContain(forbidden, snapshotSource, StringComparison.Ordinal); + } + } + + private static Iec61850MonitorDevice Device(string deviceId, string name, string ip) + => new() + { + DeviceId = deviceId, + Name = name, + IpAddress = ip, + Port = 102, + IsConnected = true, + IsMonitoring = true + }; + + private static Iec61850MonitorPoint Point( + string deviceId, + string deviceName, + string signalName, + string reference, + string value) + => new() + { + DeviceId = deviceId, + DeviceName = deviceName, + SignalName = signalName, + IecReference = reference, + IecDataType = "BOOLEAN", + Quality = "Good", + Status = "Live", + Value = value + }; + + private static string ExtractMethod(string source, string signature) + { + var start = source.IndexOf(signature, StringComparison.Ordinal); + Assert.True(start >= 0, $"Could not find method '{signature}'."); + var openBrace = source.IndexOf('{', start); + Assert.True(openBrace >= 0, $"Could not find opening brace for '{signature}'."); + + var depth = 0; + for (var index = openBrace; index < source.Length; index++) + { + if (source[index] == '{') depth++; + else if (source[index] == '}' && --depth == 0) return source[start..(index + 1)]; + } + + throw new InvalidDataException($"Method '{signature}' has no balanced closing brace."); + } + + private static string FindRepoFile(string relativePath) + => Path.Combine(FindRepoRoot(), relativePath); + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + if (File.Exists(Path.Combine(directory.FullName, "MainWindow.xaml")) && + Directory.Exists(Path.Combine(directory.FullName, "tests", "ARSAS.Tests"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException( + $"Could not locate repository root from '{AppContext.BaseDirectory}'."); + } +} diff --git a/tests/ARSAS.Tests/NativeFatP4AStableEvidenceIdentityTests.cs b/tests/ARSAS.Tests/NativeFatP4AStableEvidenceIdentityTests.cs new file mode 100644 index 000000000..8e0b62c38 --- /dev/null +++ b/tests/ARSAS.Tests/NativeFatP4AStableEvidenceIdentityTests.cs @@ -0,0 +1,184 @@ +using ArIED61850Tester.Models; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class NativeFatP4AStableEvidenceIdentityTests +{ + [Fact] + public void RecreatedAndReorderedRows_KeepCswiEvidenceOnExactTelegram_NotThdPpv() + { + var cache = new NativeFatIedSessionCacheState(); + var cswiBefore = Point( + "runtime-before", + "CSWI.Pos", + "AA1E1F06R4LD0/CSWI1.Pos.stVal", + "Open [01]"); + NativeFatCanonicalEvidenceOverlay.Write( + cache, + cswiBefore, + NativeFatEvidenceField.Value1, + "Open [01]"); + + // Runtime DeviceId and display labels change, and row order is deliberately reversed. + // Stable IEC identity must still bind only the exact telegram. + var thdAfter = Point( + "runtime-after", + "THD phase voltage renamed", + "AA1E1F06R4LD0/MMXU1.ThdPPV.phsA.cVal.mag.f", + "2.20"); + var cswiAfter = Point( + "runtime-after", + "Breaker position renamed", + "AA1E1F06R4LD0/CSWI1.Pos.stVal", + "Closed [10]"); + var reordered = new[] { thdAfter, cswiAfter }; + + Assert.Equal(thdAfter, reordered[0]); + Assert.Equal(cswiAfter, reordered[1]); + Assert.Equal( + "Open [01]", + NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, cswiAfter, NativeFatEvidenceField.Value1)); + Assert.Equal( + string.Empty, + NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, thdAfter, NativeFatEvidenceField.Value1)); + } + + [Fact] + public void Arm_DuplicateStableTelegram_FailsClosedWithoutCapturingEitherRow() + { + using var coordinator = new NativeFatArmCoordinator(); + var device = Device("runtime-arm-ambiguous"); + device.Points.Add(Point( + device.DeviceId, + "CSWI.Pos A", + "AA1E1F06R4LD0/CSWI1.Pos.stVal", + "Open [01]")); + device.Points.Add(Point( + device.DeviceId, + "CSWI.Pos B", + "AA1E1F06R4LD0/CSWI1.Pos.stVal", + "Closed [10]")); + var cache = new NativeFatIedSessionCacheState(); + + var result = coordinator.Arm(device, cache); + + Assert.False(result.Succeeded); + Assert.Equal(0, result.ArmedRows); + Assert.False(cache.IsArmed); + Assert.Empty(cache.EvidenceByRow); + } + + [Fact] + public void Arm_MixedUniqueAndAmbiguousRows_ArmsOnlyUniqueStableIdentities() + { + using var coordinator = new NativeFatArmCoordinator(); + var device = Device("runtime-arm-mixed"); + var duplicateA = Point( + device.DeviceId, + "CSWI.Pos A", + "AA1E1F06R4LD0/CSWI1.Pos.stVal", + "Open [01]"); + var duplicateB = Point( + device.DeviceId, + "CSWI.Pos B", + "AA1E1F06R4LD0/CSWI1.Pos.stVal", + "Closed [10]"); + var unique = Point( + device.DeviceId, + "Trip", + "AA1E1F06R4LD0/PTRC1.Tr.general", + "False"); + device.Points.Add(duplicateA); + device.Points.Add(unique); + device.Points.Add(duplicateB); + var cache = new NativeFatIedSessionCacheState(); + + var result = coordinator.Arm(device, cache); + + Assert.True(result.Succeeded); + Assert.Equal(1, result.ArmedRows); + Assert.Equal("False", NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, unique, NativeFatEvidenceField.Value1)); + Assert.Equal(string.Empty, NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, duplicateA, NativeFatEvidenceField.Value1)); + Assert.Equal(string.Empty, NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, duplicateB, NativeFatEvidenceField.Value1)); + } + + [Fact] + public void P4A_SourceContract_RejectsIndexDisplayAndRuntimeIdentityFallbacks() + { + var overlay = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatCanonicalEvidenceOverlay.cs")); + var arm = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatArmCoordinator.cs")); + + Assert.Contains("point.DeviceName", overlay, StringComparison.Ordinal); + Assert.Contains("point.IecTelegram", overlay, StringComparison.Ordinal); + Assert.Contains("Runtime DeviceId, row index, SelectedIndex and display labels are never evidence identity.", overlay, StringComparison.Ordinal); + Assert.DoesNotContain("SelectedIndex", ExtractMethod(overlay, "internal static bool TryBuildRowKey(string? iedName, string? iecTelegram, out string rowKey)"), StringComparison.Ordinal); + Assert.Contains("identityCount != 1", arm, StringComparison.Ordinal); + Assert.Contains("SignalName, SelectedIndex or runtime DeviceId", arm, StringComparison.Ordinal); + } + + private static Iec61850MonitorDevice Device(string deviceId) + => new() + { + DeviceId = deviceId, + Name = "AA1E1F06R4", + IpAddress = "192.168.81.103", + Port = 102, + IsConnected = true, + IsMonitoring = true + }; + + private static Iec61850MonitorPoint Point( + string deviceId, + string signalName, + string reference, + string value) + => new() + { + DeviceId = deviceId, + DeviceName = "AA1E1F06R4", + SignalName = signalName, + IecReference = reference, + IecDataType = "BOOLEAN", + Quality = "Good", + Status = "Live", + SourceMode = "Static DataSet reporting", + Value = value + }; + + private static string ExtractMethod(string source, string signature) + { + var start = source.IndexOf(signature, StringComparison.Ordinal); + Assert.True(start >= 0, $"Could not find method '{signature}'."); + var openBrace = source.IndexOf('{', start); + Assert.True(openBrace >= 0, $"Could not find opening brace for '{signature}'."); + var depth = 0; + for (var index = openBrace; index < source.Length; index++) + { + if (source[index] == '{') depth++; + else if (source[index] == '}' && --depth == 0) return source[start..(index + 1)]; + } + throw new InvalidDataException($"Method '{signature}' has no balanced closing brace."); + } + + private static string FindRepoFile(string relativePath) + => Path.Combine(FindRepoRoot(), relativePath); + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + if (File.Exists(Path.Combine(directory.FullName, "MainWindow.xaml")) && + Directory.Exists(Path.Combine(directory.FullName, "tests", "ARSAS.Tests"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException( + $"Could not locate repository root from '{AppContext.BaseDirectory}'."); + } +} \ No newline at end of file diff --git a/tests/ARSAS.Tests/NativeFatP4BStructuredTimestampEvidenceTests.cs b/tests/ARSAS.Tests/NativeFatP4BStructuredTimestampEvidenceTests.cs new file mode 100644 index 000000000..4bd0ba7d9 --- /dev/null +++ b/tests/ARSAS.Tests/NativeFatP4BStructuredTimestampEvidenceTests.cs @@ -0,0 +1,268 @@ +using ArIED61850Tester.Models; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class NativeFatP4BStructuredTimestampEvidenceTests +{ + [Fact] + public void Arm_CapturesValueWithRelayTimestampQualitySourceAndSequence() + { + using var coordinator = new NativeFatArmCoordinator(); + var device = Device("runtime-a"); + var point = Point( + device.DeviceId, + "CSWI.Pos", + "AA1E1F06R4LD0/CSWI1.Pos.stVal", + "True", + "2026-09-12T06:46:31.958+07:00", + sequence: 42); + device.Points.Add(point); + var cache = new NativeFatIedSessionCacheState(); + + var result = coordinator.Arm(device, cache); + var evidence = NativeFatCanonicalEvidenceOverlay.ReadCapture( + cache, + point, + NativeFatEvidenceField.Value1); + + Assert.True(result.Succeeded); + Assert.NotNull(evidence); + Assert.Equal("True", evidence!.RawValue); + Assert.Equal("Good", evidence.Quality); + Assert.Equal("Static DataSet reporting", evidence.AcquisitionSource); + Assert.Equal(42, evidence.Sequence); + Assert.Equal(31, evidence.IedTimestamp!.Value.Second); + Assert.Equal(958, evidence.IedTimestamp.Value.Millisecond); + Assert.Equal( + "True", + NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Value1)); + Assert.Equal( + "2026-09-12 06:46:31.958", + NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Value1Timestamp)); + Assert.Equal( + "True", + NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, point, NativeFatEvidenceField.Value1)); + } + + [Fact] + public void CaptureWithoutRelayTimestamp_UsesArsasCaptureTimeAsTimestampFallback() + { + using var coordinator = new NativeFatArmCoordinator(); + var device = Device("runtime-fallback"); + var point = Point( + device.DeviceId, + "Analog current", + "AA1E1F06R4MEAS/MMXU1.A.phsA.cVal.mag.f", + "1247.32", + "-", + sequence: 7); + device.Points.Add(point); + var cache = new NativeFatIedSessionCacheState(); + var before = DateTimeOffset.Now.AddSeconds(-1); + + Assert.True(coordinator.Arm(device, cache).Succeeded); + var after = DateTimeOffset.Now.AddSeconds(1); + var evidence = NativeFatCanonicalEvidenceOverlay.ReadCapture( + cache, + point, + NativeFatEvidenceField.Value1); + + Assert.NotNull(evidence); + Assert.Null(evidence!.IedTimestamp); + Assert.InRange(evidence.CapturedAt, before, after); + Assert.Equal("1247.32", NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Value1)); + Assert.Equal( + $"{evidence.CapturedAt:yyyy-MM-dd HH:mm:ss.fff}", + NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Value1Timestamp)); + } + + [Fact] + public void RollingPair_PreservesOriginalTimestampWhenValue2BecomesValue1() + { + using var coordinator = new NativeFatArmCoordinator(); + var device = Device("runtime-roll"); + var point = Point( + device.DeviceId, + "Breaker position", + "AA1E1F06R4LD0/CSWI1.Pos.stVal", + "Open [01]", + "2026-09-12T06:46:30.100+07:00", + sequence: 1); + device.Points.Add(point); + var cache = new NativeFatIedSessionCacheState(); + Assert.True(coordinator.Arm(device, cache).Succeeded); + + point.DeviceTimestamp = "2026-09-12T06:46:31.200+07:00"; + point.Sequence = 2; + point.Value = "Closed [10]"; + + point.DeviceTimestamp = "2026-09-12T06:46:32.300+07:00"; + point.Sequence = 3; + point.Value = "Open [01]"; + + var value1 = NativeFatCanonicalEvidenceOverlay.ReadCapture(cache, point, NativeFatEvidenceField.Value1); + var value2 = NativeFatCanonicalEvidenceOverlay.ReadCapture(cache, point, NativeFatEvidenceField.Value2); + + Assert.NotNull(value1); + Assert.NotNull(value2); + Assert.Equal("Closed [10]", value1!.RawValue); + Assert.Equal(200, value1.IedTimestamp!.Value.Millisecond); + Assert.Equal("Open [01]", value2!.RawValue); + Assert.Equal(300, value2.IedTimestamp!.Value.Millisecond); + Assert.Equal( + "Closed [10]", + NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Value1)); + Assert.Equal( + "2026-09-12 06:46:31.200", + NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Value1Timestamp)); + Assert.Equal( + "Open [01]", + NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Value2)); + Assert.Equal( + "2026-09-12 06:46:32.300", + NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Value2Timestamp)); + } + + [Fact] + public async Task PersistHydrate_UsesStableIedNameAndPreservesStructuredEvidenceAcrossRuntimeDeviceId() + { + var root = TempRoot(); + try + { + using var service = new NativeFatEvidenceHydrationService(root); + using var coordinator = new NativeFatArmCoordinator(); + var before = Device("runtime-before"); + var beforePoint = Point( + before.DeviceId, + "CSWI.Pos", + "AA1E1F06R4LD0/CSWI1.Pos.stVal", + "True", + "2026-09-12T06:46:31.958+07:00", + sequence: 77); + before.Points.Add(beforePoint); + var saved = new NativeFatIedSessionCacheState(); + Assert.True(coordinator.Arm(before, saved).Succeeded); + await service.SaveAsync(before, saved); + + var recreated = Device("runtime-after"); + var recreatedPoint = Point( + recreated.DeviceId, + "renamed display", + "AA1E1F06R4LD0/CSWI1.Pos.stVal", + "False", + "2026-09-12T07:00:00.000+07:00", + sequence: 1); + recreated.Points.Add(recreatedPoint); + + var hydration = await service.HydrateAsync(recreated); + var restored = new NativeFatIedSessionCacheState(); + NativeFatCanonicalEvidenceOverlay.MergeMissing(restored, hydration.EvidenceByRow); + var capture = NativeFatCanonicalEvidenceOverlay.ReadCapture( + restored, + recreatedPoint, + NativeFatEvidenceField.Value1); + + Assert.True(hydration.Succeeded); + Assert.True(hydration.SnapshotFound); + Assert.Equal(1, hydration.LoadedRows); + Assert.NotNull(capture); + Assert.Equal("True", capture!.RawValue); + Assert.Equal(77, capture.Sequence); + Assert.Equal(958, capture.IedTimestamp!.Value.Millisecond); + Assert.Equal( + "True", + NativeFatCanonicalEvidenceOverlay.Read(restored, recreatedPoint, NativeFatEvidenceField.Value1)); + Assert.Equal( + "2026-09-12 06:46:31.958", + NativeFatCanonicalEvidenceOverlay.Read(restored, recreatedPoint, NativeFatEvidenceField.Value1Timestamp)); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void UntouchedRenderedEvidenceCommit_DoesNotRecaptureOrChangeTimestamp() + { + var point = Point( + "runtime-edit", + "Trip", + "AA1E1F06R4LD0/PTRC1.Tr.general", + "False", + "2026-09-12T08:01:02.345+07:00", + sequence: 9); + var cache = new NativeFatIedSessionCacheState(); + NativeFatCanonicalEvidenceOverlay.WriteCapture( + cache, + point, + NativeFatEvidenceField.Value1, + "False", + ArIED61850Tester.Models.IoTesting.FatEvidenceCaptureKind.AutomaticValue, + new DateTimeOffset(2026, 9, 12, 8, 1, 3, TimeSpan.FromHours(7))); + var before = NativeFatCanonicalEvidenceOverlay.ReadCapture(cache, point, NativeFatEvidenceField.Value1); + var rendered = NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Value1); + var renderedTimestamp = NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Value1Timestamp); + + NativeFatCanonicalEvidenceOverlay.Write(cache, point, NativeFatEvidenceField.Value1, rendered); + var after = NativeFatCanonicalEvidenceOverlay.ReadCapture(cache, point, NativeFatEvidenceField.Value1); + + Assert.Same(before, after); + Assert.Equal("False", rendered); + Assert.Equal("2026-09-12 08:01:02.345", renderedTimestamp); + Assert.Equal(renderedTimestamp, NativeFatCanonicalEvidenceOverlay.Read(cache, point, NativeFatEvidenceField.Value1Timestamp)); + } + + private static Iec61850MonitorDevice Device(string deviceId) + => new() + { + DeviceId = deviceId, + Name = "AA1E1F06R4", + IpAddress = "192.168.81.103", + Port = 102, + IsConnected = true, + IsMonitoring = true + }; + + private static Iec61850MonitorPoint Point( + string deviceId, + string signalName, + string reference, + string value, + string deviceTimestamp, + long sequence) + => new() + { + DeviceId = deviceId, + DeviceName = "AA1E1F06R4", + SignalName = signalName, + IecReference = reference, + IecDataType = "BOOLEAN", + Quality = "Good", + DeviceTimestamp = deviceTimestamp, + Status = "Live", + SourceMode = "Static DataSet reporting", + Sequence = sequence, + Value = value + }; + + private static string TempRoot() + { + var path = Path.Combine(Path.GetTempPath(), "arsas-native-fat-p4b-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + return path; + } + + private static void TryDelete(string path) + { + try + { + if (Directory.Exists(path)) + Directory.Delete(path, recursive: true); + } + catch + { + } + } +} \ No newline at end of file diff --git a/tests/ARSAS.Tests/NativeFatP4CCanonicalColumnContractTests.cs b/tests/ARSAS.Tests/NativeFatP4CCanonicalColumnContractTests.cs new file mode 100644 index 000000000..49a28b1f7 --- /dev/null +++ b/tests/ARSAS.Tests/NativeFatP4CCanonicalColumnContractTests.cs @@ -0,0 +1,133 @@ +namespace ARSAS.Tests; + +public sealed class NativeFatP4CCanonicalColumnContractTests +{ + [Fact] + public void P4C_FatExposesExactNineColumnExplorerEvidenceContract() + { + var source = File.ReadAllText(FindRepoFile("MainWindow.NativeFatP4CColumnContract.cs")); + var gridSource = File.ReadAllText(FindRepoFile("MainWindow.NativeFatCanonicalGrid.cs")); + var tabSource = File.ReadAllText(FindRepoFile("MainWindow.ProductionFatTab.cs")); + + var signal = source.IndexOf("AddCanonicalTextColumn(\"Signal\"", StringComparison.Ordinal); + var telegram = source.IndexOf("AddCanonicalTextColumn(\"IEC Telegram\"", StringComparison.Ordinal); + var quality = source.IndexOf("AddCanonicalTextColumn(\"Quality\"", StringComparison.Ordinal); + var liveValue = source.IndexOf("AddCanonicalTemplateColumn(\"Live Value\"", StringComparison.Ordinal); + var value1 = source.IndexOf("\"Value 1\", NativeFatEvidenceField.Value1", StringComparison.Ordinal); + var timestamp1 = source.IndexOf("\"V1 Timestamp\", NativeFatEvidenceField.Value1Timestamp", StringComparison.Ordinal); + var value2 = source.IndexOf("\"Value 2\", NativeFatEvidenceField.Value2", StringComparison.Ordinal); + var timestamp2 = source.IndexOf("\"V2 Timestamp\", NativeFatEvidenceField.Value2Timestamp", StringComparison.Ordinal); + var result = source.IndexOf("\"Result\", NativeFatEvidenceField.Result", StringComparison.Ordinal); + + Assert.True(signal >= 0); + Assert.True(telegram > signal); + Assert.True(quality > telegram); + Assert.True(liveValue > quality); + Assert.True(value1 > liveValue); + Assert.True(timestamp1 > value1); + Assert.True(value2 > timestamp1); + Assert.True(timestamp2 > value2); + Assert.True(result > timestamp2); + + Assert.Contains("_nativeFatCanonicalGrid.Columns.Clear();", source, StringComparison.Ordinal); + Assert.Contains("AddCanonicalTextColumn(\"IEC Telegram\", nameof(Iec61850MonitorPoint.IecTelegram), 370);", source, StringComparison.Ordinal); + Assert.Contains("AddCanonicalTemplateColumn(\"Live Value\", \"ProcessValueBadgeTemplate\", 150);", source, StringComparison.Ordinal); + Assert.Contains("new NativeFatEvidenceBindingColumn(\"Value 1\", NativeFatEvidenceField.Value1, 155, ReadNativeFatEvidence)", source, StringComparison.Ordinal); + Assert.Contains("new NativeFatEvidenceBindingColumn(\"Value 2\", NativeFatEvidenceField.Value2, 155, ReadNativeFatEvidence)", source, StringComparison.Ordinal); + Assert.Contains("new NativeFatEvidenceBindingColumn(\"Result\", NativeFatEvidenceField.Result, 68, ReadNativeFatEvidence)", source, StringComparison.Ordinal); + Assert.Contains("ApplyNativeFatP4CColumnContract();", gridSource, StringComparison.Ordinal); + Assert.DoesNotContain("ApplyNativeFatP4CColumnContract();", tabSource, StringComparison.Ordinal); + Assert.Contains("_nativeFatCanonicalGrid.ItemsSource = device?.Points;", gridSource, StringComparison.Ordinal); + + Assert.DoesNotContain("\"Status\"", source, StringComparison.Ordinal); + Assert.DoesNotContain("\"Type\"", source, StringComparison.Ordinal); + Assert.DoesNotContain("\"Address\"", source, StringComparison.Ordinal); + Assert.DoesNotContain("\"Message\"", source, StringComparison.Ordinal); + Assert.DoesNotContain("\"Data Reference\"", source, StringComparison.Ordinal); + Assert.DoesNotContain("ObservableCollection", source, StringComparison.Ordinal); + Assert.DoesNotContain("new Iec61850MonitorPoint", source, StringComparison.Ordinal); + } + + [Fact] + public void P4C_TimestampColumnsShareTheEvidenceRefreshAuthority() + { + var source = File.ReadAllText(FindRepoFile("MainWindow.NativeFatP4CColumnContract.cs")); + var overlay = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatCanonicalEvidenceOverlay.cs")); + var binding = File.ReadAllText(FindRepoFile("MainWindow.NativeFatEvidenceBindingRuntime.cs")); + + Assert.Contains("NativeFatEvidenceField.Value1Timestamp", source, StringComparison.Ordinal); + Assert.Contains("NativeFatEvidenceField.Value2Timestamp", source, StringComparison.Ordinal); + Assert.Contains("IsReadOnly = true", source, StringComparison.Ordinal); + Assert.Contains("NativeFatEvidenceBindingColumn", source, StringComparison.Ordinal); + Assert.Contains("GetBindingExpression(TextBlock.TextProperty)?.UpdateTarget()", binding, StringComparison.Ordinal); + Assert.Contains("NativeFatEvidenceField.Value1Timestamp => TimestampValue(slot.Value1Evidence)", overlay, StringComparison.Ordinal); + Assert.Contains("NativeFatEvidenceField.Value2Timestamp => TimestampValue(slot.Value2Evidence)", overlay, StringComparison.Ordinal); + } + + [Fact] + public void P4C_EvidenceEventSurfacesOneOfTwoThenTwoOfTwoWithCompleteResult() + { + var source = File.ReadAllText(FindRepoFile("MainWindow.NativeFatP4CColumnContract.cs")); + var overlay = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatCanonicalEvidenceOverlay.cs")); + + Assert.Contains("_nativeFatArmCoordinator.EvidenceChanged += NativeFatObservationStatus_EvidenceChanged", source, StringComparison.Ordinal); + Assert.Contains("DispatcherPriority.Background", source, StringComparison.Ordinal); + Assert.Contains("{observations} / 2 observations", source, StringComparison.Ordinal); + Assert.Contains("NativeFatEvidenceField.Value1", source, StringComparison.Ordinal); + Assert.Contains("NativeFatEvidenceField.Value2", source, StringComparison.Ordinal); + Assert.Contains("NativeFatEvidenceField.Result", source, StringComparison.Ordinal); + Assert.Contains("HasValue1(slot) && HasValue2(slot) ? \"COMPLETE\"", overlay, StringComparison.Ordinal); + } + + [Fact] + public void P4C_CanonicalGridBuilderHasNoLegacyColumnInstallationPath() + { + var source = File.ReadAllText(FindRepoFile("MainWindow.NativeFatCanonicalGrid.cs")); + + Assert.Contains("ApplyNativeFatP4CColumnContract();", source, StringComparison.Ordinal); + Assert.DoesNotContain("AddCanonicalTextColumn(\"Status\"", source, StringComparison.Ordinal); + Assert.DoesNotContain("AddCanonicalTextColumn(\"Type\"", source, StringComparison.Ordinal); + Assert.DoesNotContain("AddCanonicalTextColumn(\"Address\"", source, StringComparison.Ordinal); + Assert.DoesNotContain("AddCanonicalTextColumn(\"Message\"", source, StringComparison.Ordinal); + Assert.DoesNotContain("AddCanonicalTextColumn(\"Data Reference\"", source, StringComparison.Ordinal); + Assert.DoesNotContain("AddCanonicalTextColumn(\"Timestamp\"", source, StringComparison.Ordinal); + Assert.DoesNotContain("AddCanonicalTemplateColumn(\"Value\"", source, StringComparison.Ordinal); + Assert.DoesNotContain("new NativeFatEvidenceColumn(this, \"Value 1\"", source, StringComparison.Ordinal); + } + + [Fact] + public void P4C_ColumnBindingsUseCanonicalExplorerRowProperties() + { + var source = File.ReadAllText(FindRepoFile("MainWindow.NativeFatP4CColumnContract.cs")); + var binding = File.ReadAllText(FindRepoFile("MainWindow.NativeFatEvidenceBindingRuntime.cs")); + + Assert.Contains("nameof(Iec61850MonitorPoint.SignalName)", source, StringComparison.Ordinal); + Assert.Contains("nameof(Iec61850MonitorPoint.IecTelegram)", source, StringComparison.Ordinal); + Assert.Contains("nameof(Iec61850MonitorPoint.Quality)", source, StringComparison.Ordinal); + Assert.Contains("\"ProcessValueBadgeTemplate\"", source, StringComparison.Ordinal); + Assert.Contains("NativeFatEvidenceBindingColumn", source, StringComparison.Ordinal); + Assert.Contains("Path = new PropertyPath(\".\")", binding, StringComparison.Ordinal); + Assert.Contains("value is Iec61850MonitorPoint point", binding, StringComparison.Ordinal); + } + + private static string FindRepoFile(string relativePath) + => Path.Combine(FindRepoRoot(), relativePath); + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + if (File.Exists(Path.Combine(directory.FullName, "MainWindow.xaml")) && + Directory.Exists(Path.Combine(directory.FullName, "tests", "ARSAS.Tests"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException( + $"Could not locate repository root from '{AppContext.BaseDirectory}'."); + } +} diff --git a/tests/ARSAS.Tests/NativeFatP4DFixedDocumentPreviewTests.cs b/tests/ARSAS.Tests/NativeFatP4DFixedDocumentPreviewTests.cs new file mode 100644 index 000000000..7b490c1f6 --- /dev/null +++ b/tests/ARSAS.Tests/NativeFatP4DFixedDocumentPreviewTests.cs @@ -0,0 +1,257 @@ +using System.Text; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class NativeFatP4DFixedDocumentPreviewTests +{ + [Fact] + public void P4D_PreviewUsesExistingFixedDocumentAuthorityWithProfessionalToolbar() + { + var preview = File.ReadAllText(FindRepoFile("MainWindow.NativeFatPrintPreview.cs")); + var adapter = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatP4DReportAdapter.cs")); + var renderer = File.ReadAllText(FindRepoFile("Services/IoTesting/IoFatReportPreviewDocumentBuilder.cs")); + + Assert.Contains("NativeFatP4DReportAdapter.Build(currentSnapshot, draft: true)", preview, StringComparison.Ordinal); + Assert.Contains("IoFatReportPreviewDocumentBuilder.Render(currentLayout)", preview, StringComparison.Ordinal); + Assert.Contains("new DocumentViewer", preview, StringComparison.Ordinal); + Assert.Contains("CollapseNativeDocumentViewerChrome(viewer)", preview, StringComparison.Ordinal); + Assert.Contains("NativePreviewLucideIcon.Printer", preview, StringComparison.Ordinal); + Assert.Contains("NativePreviewLucideIcon.Minus", preview, StringComparison.Ordinal); + Assert.Contains("NativePreviewLucideIcon.Plus", preview, StringComparison.Ordinal); + Assert.Contains("NativePreviewLucideIcon.Maximize2", preview, StringComparison.Ordinal); + Assert.Contains("NativePreviewLucideIcon.ChevronLeft", preview, StringComparison.Ordinal); + Assert.Contains("NativePreviewLucideIcon.ChevronRight", preview, StringComparison.Ordinal); + Assert.Contains("NativePreviewLucideIcon.RefreshCw", preview, StringComparison.Ordinal); + Assert.Contains("NativePreviewLucideIcon.Save", preview, StringComparison.Ordinal); + Assert.DoesNotContain("NativePreviewLucideIcon.X", preview, StringComparison.Ordinal); + Assert.Contains("HorizontalAlignment = HorizontalAlignment.Center", preview, StringComparison.Ordinal); + Assert.Contains("FitNativeReportPage(viewer)", preview, StringComparison.Ordinal); + Assert.Contains("viewer.FitToWidth()", preview, StringComparison.Ordinal); + Assert.Contains("viewer.FitToHeight()", preview, StringComparison.Ordinal); + Assert.Contains("viewer.Zoom = Math.Min(widthZoom, heightZoom)", preview, StringComparison.Ordinal); + Assert.DoesNotContain("new DataGrid", preview, StringComparison.Ordinal); + Assert.DoesNotContain("ItemsSource = snapshot.Rows", preview, StringComparison.Ordinal); + + Assert.Contains("IoFatReportLayoutPlan Build", adapter, StringComparison.Ordinal); + Assert.Contains("public static FixedDocument Render(", renderer, StringComparison.Ordinal); + Assert.Contains("IoFatReportLayoutPlan layout", renderer, StringComparison.Ordinal); + Assert.Contains("new FixedDocument()", renderer, StringComparison.Ordinal); + } + + [Fact] + public void P4D_SavePdfSerializesTheExactLayoutCurrentlyRenderedInPreview() + { + var preview = File.ReadAllText(FindRepoFile("MainWindow.NativeFatPrintPreview.cs")); + var pdfService = File.ReadAllText(FindRepoFile("Services/IoTesting/IoFatPdfReportService.cs")); + var pdfWriter = File.ReadAllText(FindRepoFile("Services/IoTesting/IoFatNativePdfWriter.cs")); + + Assert.Contains("var currentLayout = NativeFatP4DReportAdapter.Build(currentSnapshot, draft: true);", preview, StringComparison.Ordinal); + Assert.Contains("Document = document", preview, StringComparison.Ordinal); + Assert.Contains("viewer.Document = IoFatReportPreviewDocumentBuilder.Render(currentLayout)", preview, StringComparison.Ordinal); + Assert.Contains("BuildNativePreviewLabeledContent(NativePreviewLucideIcon.Save, \"Save PDF\")", preview, StringComparison.Ordinal); + Assert.Contains("SaveNativeFatPreviewPdf(preview, currentSnapshot, currentLayout)", preview, StringComparison.Ordinal); + Assert.Contains("IoFatPdfReportService.SaveLayout(", preview, StringComparison.Ordinal); + Assert.Contains("layout,", preview, StringComparison.Ordinal); + Assert.Contains("internal static void SaveLayout(", pdfService, StringComparison.Ordinal); + Assert.Contains("GenerateLayout(layout, reportName, primaryReference)", pdfService, StringComparison.Ordinal); + Assert.Contains("IoFatNativePdfWriter.Build(layout, reportName, primaryReference)", pdfService, StringComparison.Ordinal); + Assert.Contains("public static byte[] Build(", pdfWriter, StringComparison.Ordinal); + } + + [Fact] + public void P4D_LayoutFirstPdfWriterProducesNativePdfWithoutIoTestProjectRebuild() + { + var device = new Iec61850MonitorDevice + { + DeviceId = "runtime-p4d", + Name = "AA1E1F06R4", + IpAddress = "192.168.81.103", + Port = 102 + }; + var point = new Iec61850MonitorPoint + { + DeviceId = device.DeviceId, + DeviceName = device.Name, + SignalName = "52_ACB1 Status", + IecReference = "AA1E1F06R4LD0/XCBR1.Pos.stVal", + Quality = "Good", + Value = "Open [01]" + }; + device.Points.Add(point); + var cache = new NativeFatIedSessionCacheState(); + NativeFatCanonicalEvidenceOverlay.WriteCapture( + cache, + point, + NativeFatEvidenceField.Value1, + "Open [01]", + ArIED61850Tester.Models.IoTesting.FatEvidenceCaptureKind.OperatorSnapshot, + DateTimeOffset.UtcNow.AddSeconds(-1)); + NativeFatCanonicalEvidenceOverlay.WriteCapture( + cache, + point, + NativeFatEvidenceField.Value2, + "Closed [10]", + ArIED61850Tester.Models.IoTesting.FatEvidenceCaptureKind.OperatorSnapshot, + DateTimeOffset.UtcNow); + + var snapshot = NativeFatPrintPreviewSnapshot.Capture(device, cache); + var layout = NativeFatP4DReportAdapter.Build(snapshot, draft: true); + var bytes = IoFatPdfReportService.GenerateLayout(layout, snapshot.IedName, snapshot.Rows[0].IecTelegram); + var reportText = layout.Pages + .SelectMany(page => page.Commands) + .OfType() + .Select(command => command.Text) + .ToArray(); + + Assert.Equal("52_ACB1 Status", snapshot.Rows[0].Signal); + Assert.Equal("COMPLETE", snapshot.Rows[0].Result); + Assert.Equal("Open [01]", snapshot.Rows[0].Value1); + Assert.Matches(@"^\d{2}/\d{2}/\d{4} \d{2}:\d{2}:\d{2}\.\d{3}$", snapshot.Rows[0].Value1TimestampText); + Assert.Equal("Closed [10]", snapshot.Rows[0].Value2); + Assert.Matches(@"^\d{2}/\d{2}/\d{4} \d{2}:\d{2}:\d{2}\.\d{3}$", snapshot.Rows[0].Value2TimestampText); + Assert.Contains("Complete", reportText); + Assert.DoesNotContain("COMPLETE", reportText); + Assert.Contains("Evidence complete: 1 / 1 signals", reportText); + Assert.Contains("ARSAS", reportText); + Assert.DoesNotContain(reportText, text => text.Contains("COMTRADE", StringComparison.OrdinalIgnoreCase)); + Assert.True(layout.Pages.Count >= 2); + Assert.Contains(layout.Pages[^1].Commands.OfType(), command => command.Text == "TESTED BY"); + Assert.Contains(layout.Pages[^1].Commands.OfType(), command => command.Text == "WITNESSED BY"); + Assert.Contains(layout.Pages[^1].Commands.OfType(), command => command.Text == "APPROVED BY"); + Assert.Contains(layout.Pages[^1].Commands.OfType(), command => command.Text == "Title / Role"); + Assert.Contains(layout.Pages[^1].Commands.OfType(), command => command.Text == "IED: AA1E1F06R4 · Report: IEC 61850 FAT Evidence"); + Assert.True(bytes.Length > 32); + Assert.Equal("%PDF-1.4", Encoding.ASCII.GetString(bytes, 0, 8)); + } + + [Fact] + public void P4D_ReportUsesSharedSignalNamingAndCustomerFacingCopy() + { + var snapshot = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatPrintPreviewSnapshot.cs")); + var adapter = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatP4DReportAdapter.cs")); + var finalization = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatReportFinalization.cs")); + var branding = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatReportBranding.cs")); + var formatting = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatReportFormatting.cs")); + + Assert.Contains("IoFatSignalDisplayNameFormatter.Format(point.SignalName, point.IecReference)", snapshot, StringComparison.Ordinal); + Assert.Contains("NativeFatReportBranding.AddLogo", adapter, StringComparison.Ordinal); + Assert.Contains("NativeFatReportBranding.AddLogo", finalization, StringComparison.Ordinal); + Assert.Contains("\"ARSAS\"", branding, StringComparison.Ordinal); + Assert.Contains("? \"Complete\"", adapter, StringComparison.Ordinal); + Assert.Contains("Evidence complete:", snapshot, StringComparison.Ordinal); + Assert.Contains("dd/MM/yyyy HH:mm:ss.fff", formatting, StringComparison.Ordinal); + Assert.Contains("ToLocalTime()", formatting, StringComparison.Ordinal); + Assert.Contains("Acceptance sign-off for the IEC 61850 FAT evidence documented in this report.", finalization, StringComparison.Ordinal); + Assert.Contains("Title / Role", finalization, StringComparison.Ordinal); + + foreach (var internalCopy in new[] + { + "Canonical Explorer snapshot", + "Immutable Engineering FAT snapshot", + "Final acceptance record for the immutable", + "blank sign-off fields are intentionally not prefilled" + }) + { + Assert.DoesNotContain(internalCopy, adapter, StringComparison.Ordinal); + Assert.DoesNotContain(internalCopy, finalization, StringComparison.Ordinal); + } + } + + [Fact] + public void P4D_ReportAdapterUsesReadableEightColumnEvidenceContract() + { + var adapter = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatP4DReportAdapter.cs")); + var snapshot = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatPrintPreviewSnapshot.cs")); + + var signal = adapter.IndexOf("\"Signal\"", StringComparison.Ordinal); + var reference = adapter.IndexOf("\"IEC 61850 Reference\"", StringComparison.Ordinal); + var quality = adapter.IndexOf("\"Quality\"", StringComparison.Ordinal); + var value1 = adapter.IndexOf("\"Value 1\"", StringComparison.Ordinal); + var timestamp1 = adapter.IndexOf("\"V1 Timestamp\"", StringComparison.Ordinal); + var value2 = adapter.IndexOf("\"Value 2\"", StringComparison.Ordinal); + var timestamp2 = adapter.IndexOf("\"V2 Timestamp\"", StringComparison.Ordinal); + var status = adapter.IndexOf("\"Evidence Status\"", StringComparison.Ordinal); + + Assert.True(signal >= 0); + Assert.True(reference > signal); + Assert.True(quality > reference); + Assert.True(value1 > quality); + Assert.True(timestamp1 > value1); + Assert.True(value2 > timestamp1); + Assert.True(timestamp2 > value2); + Assert.True(status > timestamp2); + + Assert.DoesNotContain("\"Live Value\"", adapter, StringComparison.Ordinal); + Assert.DoesNotContain("Clean(row.LiveValue)", adapter, StringComparison.Ordinal); + Assert.DoesNotContain("WrapTelegram(", adapter, StringComparison.Ordinal); + Assert.DoesNotContain("\"IEC Telegram\"", adapter, StringComparison.Ordinal); + Assert.Contains("private static readonly double[] Widths = [66d, 280d, 40d, 72d, 92d, 72d, 92d, 68d];", adapter, StringComparison.Ordinal); + Assert.Contains("TelegramFontSize(row.IecTelegram)", adapter, StringComparison.Ordinal); + Assert.Contains("TableRowHeight = 30d", adapter, StringComparison.Ordinal); + Assert.Contains("TableBodyFontSize = 7.2d", adapter, StringComparison.Ordinal); + Assert.Contains("TableTimestampFontSize = 6.2d", adapter, StringComparison.Ordinal); + Assert.Contains("TelegramBaseFontSize = 6.8d", adapter, StringComparison.Ordinal); + Assert.Contains("CenteredBaseline(y, height)", adapter, StringComparison.Ordinal); + + Assert.DoesNotContain("\"Type\"", adapter, StringComparison.Ordinal); + Assert.DoesNotContain("\"Status\"", adapter, StringComparison.Ordinal); + Assert.DoesNotContain("\"IEC 61850 reference\"", adapter, StringComparison.Ordinal); + + Assert.Contains("string IecTelegram", snapshot, StringComparison.Ordinal); + Assert.Contains("string Quality", snapshot, StringComparison.Ordinal); + Assert.Contains("string Value1TimestampText", snapshot, StringComparison.Ordinal); + Assert.Contains("string Value2TimestampText", snapshot, StringComparison.Ordinal); + Assert.Contains("NativeFatCanonicalEvidenceOverlay.ReadRaw", snapshot, StringComparison.Ordinal); + Assert.Contains("NativeFatCanonicalEvidenceOverlay.ReadCapture", snapshot, StringComparison.Ordinal); + Assert.Contains("DisplayTimestamp(capture1)", snapshot, StringComparison.Ordinal); + Assert.Contains("DisplayTimestamp(capture2)", snapshot, StringComparison.Ordinal); + Assert.Contains("NativeFatReportFormatting.LocalTimestamp(timestamp)", snapshot, StringComparison.Ordinal); + Assert.DoesNotContain("string Type", snapshot, StringComparison.Ordinal); + Assert.DoesNotContain("string Status", snapshot, StringComparison.Ordinal); + } + + [Fact] + public void P4D_PreviewDoesNotReintroduceRuntimeOrSclBootstrap() + { + var preview = File.ReadAllText(FindRepoFile("MainWindow.NativeFatPrintPreview.cs")); + var adapter = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatP4DReportAdapter.cs")); + + foreach (var forbidden in new[] + { + "ConnectAndDiscoverAsync", + "StartMonitoringAsync", + "PrepareIoTestIedForFatAsync", + "OpenDescribedSourcesAsync", + "IoFatEngineeringWorkspaceProjectionService", + "FatSclWorkspaceImportService", + "new IoTestProject" + }) + { + Assert.DoesNotContain(forbidden, preview, StringComparison.Ordinal); + Assert.DoesNotContain(forbidden, adapter, StringComparison.Ordinal); + } + } + + private static string FindRepoFile(string relativePath) + => Path.Combine(FindRepoRoot(), relativePath); + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + if (File.Exists(Path.Combine(directory.FullName, "MainWindow.xaml")) && + Directory.Exists(Path.Combine(directory.FullName, "tests", "ARSAS.Tests"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException( + $"Could not locate repository root from '{AppContext.BaseDirectory}'."); + } +} diff --git a/tests/ARSAS.Tests/NativeFatP4ECommandFeedbackCorrelationTests.cs b/tests/ARSAS.Tests/NativeFatP4ECommandFeedbackCorrelationTests.cs new file mode 100644 index 000000000..b9391f8e6 --- /dev/null +++ b/tests/ARSAS.Tests/NativeFatP4ECommandFeedbackCorrelationTests.cs @@ -0,0 +1,164 @@ +using ArIED61850Tester.Models; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class NativeFatP4ECommandFeedbackCorrelationTests +{ + [Fact] + public void ExactStatusReference_ResolvesCanonicalFeedbackAndNotUnrelatedThdRow() + { + var device = Device("AA1E1F06R4"); + var thd = Point(device, "THD", "AA1E1F06R4LD0/MMXU1.ThdPPV.phsA.cVal.mag.f"); + var feedback = Point(device, "Breaker feedback", "AA1E1F06R4LD0/CSWI1.Pos.stVal"); + device.Points.Add(thd); + device.Points.Add(feedback); + var capabilities = new Iec61850ControlCapabilities + { + ObjectReference = "AA1E1F06R4LD0/CSWI1.Pos.Oper", + StatusReference = "AA1E1F06R4LD0/CSWI1.Pos.stVal", + ControlModel = Iec61850ControlModelKind.DirectNormal, + EngineControlServiceAvailable = true, + IsOperationallyReady = true + }; + + var resolved = NativeFatCommandFeedbackCorrelation.Resolve(device, capabilities); + + Assert.Same(feedback, resolved); + Assert.NotSame(thd, resolved); + } + + [Fact] + public void MissingStatusReference_FailsClosedWithoutObjectReferenceFallback() + { + var device = Device("AA1E1F06R4"); + device.Points.Add(Point(device, "Breaker feedback", "AA1E1F06R4LD0/CSWI1.Pos.stVal")); + var capabilities = new Iec61850ControlCapabilities + { + ObjectReference = "AA1E1F06R4LD0/CSWI1.Pos.Oper", + StatusReference = string.Empty, + ControlModel = Iec61850ControlModelKind.DirectNormal, + EngineControlServiceAvailable = true, + IsOperationallyReady = true + }; + + Assert.Null(NativeFatCommandFeedbackCorrelation.Resolve(device, capabilities)); + } + + [Fact] + public void DuplicateCanonicalFeedbackIdentity_FailsClosedInsteadOfChoosingByOrder() + { + var device = Device("AA1E1F06R4"); + device.Points.Add(Point(device, "Feedback A", "AA1E1F06R4LD0/CSWI1.Pos.stVal")); + device.Points.Add(Point(device, "Feedback B", "AA1E1F06R4LD0/CSWI1.Pos.stVal")); + + Assert.Null(NativeFatCommandFeedbackCorrelation.Resolve( + device, + "AA1E1F06R4LD0/CSWI1.Pos.stVal")); + } + + [Fact] + public void SameSignalNameOnDifferentTelegram_CannotInfluenceCorrelation() + { + var device = Device("AA1E1F06R4"); + var wrong = Point(device, "Breaker position", "AA1E1F06R4LD0/GGIO1.Ind1.stVal"); + var expected = Point(device, "Breaker position", "AA1E1F06R4LD0/CSWI1.Pos.stVal"); + device.Points.Add(wrong); + device.Points.Add(expected); + + var resolved = NativeFatCommandFeedbackCorrelation.Resolve( + device, + "AA1E1F06R4LD0/CSWI1.Pos.stVal"); + + Assert.Same(expected, resolved); + } + + [Fact] + public void StatusReferenceFromAnotherIed_DoesNotCrossContaminateCurrentDevice() + { + var device = Device("IED_A"); + device.Points.Add(Point(device, "Breaker feedback", "IED_ALD0/CSWI1.Pos.stVal")); + + Assert.Null(NativeFatCommandFeedbackCorrelation.Resolve( + device, + "IED_BLD0/CSWI1.Pos.stVal")); + } + + [Fact] + public void SourceContract_HasNoDisplayIndexOrRuntimeIdentityFallback() + { + var source = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatCommandFeedbackCorrelation.cs")); + + Assert.Contains("capabilities.StatusReference", source, StringComparison.Ordinal); + Assert.Contains("NativeFatCanonicalEvidenceOverlay.TryBuildRowKey", source, StringComparison.Ordinal); + Assert.Contains("if (match != null)", source, StringComparison.Ordinal); + Assert.DoesNotContain("SignalName", source, StringComparison.Ordinal); + Assert.DoesNotContain("SelectedIndex", source, StringComparison.Ordinal); + Assert.DoesNotContain("Items.IndexOf", source, StringComparison.Ordinal); + Assert.DoesNotContain("DeviceId", source, StringComparison.Ordinal); + Assert.DoesNotContain("ObjectReference", source, StringComparison.Ordinal); + } + + [Fact] + public void GlobalStableCommandConfirmation_AlsoRequiresExplicitControlStatusReference() + { + var source = File.ReadAllText(FindRepoFile("MainWindow.ControlDiagnostics.cs")); + var start = source.IndexOf("private static string ResolveControlFeedbackKey", StringComparison.Ordinal); + var end = source.IndexOf("private async Task ExpirePositionCommandAsync", start, StringComparison.Ordinal); + + Assert.True(start >= 0 && end > start); + var resolver = source[start..end]; + Assert.Contains("string.IsNullOrWhiteSpace(signal.ControlStatusReference)", resolver, StringComparison.Ordinal); + Assert.Contains("return string.Empty;", resolver, StringComparison.Ordinal); + Assert.Contains("NormalizeReference(signal.ControlStatusReference)", resolver, StringComparison.Ordinal); + Assert.DoesNotContain("signal.ObjectReference", resolver, StringComparison.Ordinal); + Assert.DoesNotContain("SignalName)", resolver, StringComparison.Ordinal); + Assert.DoesNotContain("signal.ObjectReference +", resolver, StringComparison.Ordinal); + Assert.DoesNotContain("$\"{signal.ObjectReference}.stVal\"", resolver, StringComparison.Ordinal); + } + + private static Iec61850MonitorDevice Device(string name) + => new() + { + DeviceId = "runtime-" + name, + Name = name, + IpAddress = "192.168.81.103", + Port = 102 + }; + + private static Iec61850MonitorPoint Point( + Iec61850MonitorDevice device, + string signalName, + string reference) + => new() + { + DeviceId = device.DeviceId, + DeviceName = device.Name, + SignalName = signalName, + IecReference = reference, + IecDataType = "BOOLEAN", + Quality = "Good", + Value = "False" + }; + + private static string FindRepoFile(string relativePath) + => Path.Combine(FindRepoRoot(), relativePath); + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + if (File.Exists(Path.Combine(directory.FullName, "MainWindow.xaml")) && + Directory.Exists(Path.Combine(directory.FullName, "tests", "ARSAS.Tests"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException( + $"Could not locate repository root from '{AppContext.BaseDirectory}'."); + } +} diff --git a/tests/ARSAS.Tests/NativeFatP4EEvidenceIsolationRegressionTests.cs b/tests/ARSAS.Tests/NativeFatP4EEvidenceIsolationRegressionTests.cs new file mode 100644 index 000000000..b777d3b74 --- /dev/null +++ b/tests/ARSAS.Tests/NativeFatP4EEvidenceIsolationRegressionTests.cs @@ -0,0 +1,294 @@ +using System.Text.Json; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class NativeFatP4EEvidenceIsolationRegressionTests +{ + [Fact] + public async Task P4E_RestartReorder_CswiEvidenceStaysOnExactTelegramAndNeverMovesToThdPpv() + { + var root = TempRoot(); + try + { + using var service = new NativeFatEvidenceHydrationService(root); + var before = Device("runtime-before", "AA1E1F06R4"); + var cswiBefore = Point( + before, + "Breaker position", + "AA1E1F06R4LD0/CSWI1.Pos.stVal", + "Open [01]", + "BOOLEAN"); + var thdBefore = Point( + before, + "THD phase voltage", + "AA1E1F06R4LD0/MMXU1.ThdPPV.phsA.cVal.mag.f", + "2.20", + "FLOAT32"); + before.Points.Add(cswiBefore); + before.Points.Add(thdBefore); + + var saved = new NativeFatIedSessionCacheState(); + NativeFatCanonicalEvidenceOverlay.WriteCapture( + saved, + cswiBefore, + NativeFatEvidenceField.Value1, + "Open [01]", + ArIED61850Tester.Models.IoTesting.FatEvidenceCaptureKind.AutomaticValue, + DateTimeOffset.UtcNow); + NativeFatCanonicalEvidenceOverlay.WriteCapture( + saved, + cswiBefore, + NativeFatEvidenceField.Value2, + "Closed [10]", + ArIED61850Tester.Models.IoTesting.FatEvidenceCaptureKind.AutomaticTransition, + DateTimeOffset.UtcNow); + await service.SaveAsync(before, saved); + + var after = Device("runtime-after", "AA1E1F06R4"); + var thdAfter = Point( + after, + "THD renamed after restart", + "AA1E1F06R4LD0/MMXU1.ThdPPV.phsA.cVal.mag.f", + "2.25", + "FLOAT32"); + var cswiAfter = Point( + after, + "Breaker renamed after restart", + "AA1E1F06R4LD0/CSWI1.Pos.stVal", + "Closed [10]", + "BOOLEAN"); + after.Points.Add(thdAfter); + after.Points.Add(cswiAfter); + + var hydration = await service.HydrateAsync(after); + var restored = new NativeFatIedSessionCacheState(); + NativeFatCanonicalEvidenceOverlay.MergeMissing(restored, hydration.EvidenceByRow); + + Assert.True(hydration.Succeeded); + Assert.Equal("Open [01]", NativeFatCanonicalEvidenceOverlay.ReadRaw(restored, cswiAfter, NativeFatEvidenceField.Value1)); + Assert.Equal("Closed [10]", NativeFatCanonicalEvidenceOverlay.ReadRaw(restored, cswiAfter, NativeFatEvidenceField.Value2)); + Assert.Equal(string.Empty, NativeFatCanonicalEvidenceOverlay.ReadRaw(restored, thdAfter, NativeFatEvidenceField.Value1)); + Assert.Equal(string.Empty, NativeFatCanonicalEvidenceOverlay.ReadRaw(restored, thdAfter, NativeFatEvidenceField.Value2)); + + var snapshot = NativeFatPrintPreviewSnapshot.Capture(after, restored); + Assert.Equal(2, snapshot.Rows.Count); + Assert.Equal("LD0/MMXU1.ThdPPV.phsA.cVal.mag.f", snapshot.Rows[0].IecTelegram); + Assert.Equal("LD0/CSWI1.Pos.stVal", snapshot.Rows[1].IecTelegram); + Assert.Equal("—", snapshot.Rows[0].Value1); + Assert.Equal("Open [01]", snapshot.Rows[1].Value1); + Assert.NotEqual("—", snapshot.Rows[1].Value1TimestampText); + Assert.Equal("Closed [10]", snapshot.Rows[1].Value2); + Assert.NotEqual("—", snapshot.Rows[1].Value2TimestampText); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void P4E_TwoIedsWithSameSignalNameCannotCrossContaminateEvidence() + { + var cache = new NativeFatIedSessionCacheState(); + var iedA = Device("runtime-a", "IED_A"); + var iedB = Device("runtime-b", "IED_B"); + var pointA = Point(iedA, "Breaker position", "IED_ALD0/CSWI1.Pos.stVal", "Open [01]", "BOOLEAN"); + var pointB = Point(iedB, "Breaker position", "IED_BLD0/CSWI1.Pos.stVal", "Closed [10]", "BOOLEAN"); + + NativeFatCanonicalEvidenceOverlay.Write(cache, pointA, NativeFatEvidenceField.Result, "PASS-A"); + NativeFatCanonicalEvidenceOverlay.Write(cache, pointB, NativeFatEvidenceField.Result, "PASS-B"); + + Assert.NotEqual( + NativeFatCanonicalEvidenceOverlay.BuildRowKey(pointA), + NativeFatCanonicalEvidenceOverlay.BuildRowKey(pointB)); + Assert.Equal("PASS-A", NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, pointA, NativeFatEvidenceField.Result)); + Assert.Equal("PASS-B", NativeFatCanonicalEvidenceOverlay.ReadRaw(cache, pointB, NativeFatEvidenceField.Result)); + Assert.Equal(2, cache.EvidenceByRow.Count); + } + + [Fact] + public async Task P4E_UnknownPersistedTelegramIsIgnoredAsOrphanAndNeverRemappedByPositionOrName() + { + var root = TempRoot(); + try + { + using var service = new NativeFatEvidenceHydrationService(root); + var device = Device("runtime-current", "AA1E1F06R4"); + var cswi = Point(device, "Same display name", "AA1E1F06R4LD0/CSWI1.Pos.stVal", "Open [01]", "BOOLEAN"); + device.Points.Add(cswi); + + Directory.CreateDirectory(root); + var orphanDocument = new + { + schema = "ARSAS-NATIVE-FAT-EVIDENCE-2.0", + savedAtUtc = DateTimeOffset.UtcNow, + deviceId = "old-runtime", + deviceName = device.Name, + ipAddress = device.IpAddress, + evidenceByRow = new Dictionary + { + ["aa1e1f06r4|ld0/unknown1.pos.stval"] = new + { + value1 = "SHOULD-NOT-MOVE", + value2 = "", + result = "PASS" + } + } + }; + await File.WriteAllTextAsync( + service.SnapshotPath(device.Name), + JsonSerializer.Serialize(orphanDocument)); + + var hydration = await service.HydrateAsync(device); + var restored = new NativeFatIedSessionCacheState(); + NativeFatCanonicalEvidenceOverlay.MergeMissing(restored, hydration.EvidenceByRow); + + Assert.True(hydration.Succeeded); + Assert.True(hydration.SnapshotFound); + Assert.Equal(0, hydration.LoadedRows); + Assert.Equal(1, hydration.IgnoredRows); + Assert.Empty(hydration.EvidenceByRow); + Assert.Equal(string.Empty, NativeFatCanonicalEvidenceOverlay.ReadRaw(restored, cswi, NativeFatEvidenceField.Value1)); + Assert.Equal(string.Empty, NativeFatCanonicalEvidenceOverlay.ReadRaw(restored, cswi, NativeFatEvidenceField.Result)); + } + finally + { + TryDelete(root); + } + } + + [Theory] + [InlineData("BOOLEAN", "True")] + [InlineData("FLOAT32", "1247.32 A")] + [InlineData("DbPos", "Closed [10]")] + [InlineData("INT32", "Tap 7")] + public void P4E_DigitalAnalogPositionAndTapEvidenceKeepMillisecondTimestamp(string dataType, string rawValue) + { + var device = Device("runtime-types", "AA1E1F06R4"); + var point = Point(device, "Evidence", "AA1E1F06R4LD0/GGIO1.Test.stVal", rawValue, dataType); + point.DeviceTimestamp = "2026-09-12T06:46:31.958+07:00"; + device.Points.Add(point); + var cache = new NativeFatIedSessionCacheState(); + + NativeFatCanonicalEvidenceOverlay.WriteCapture( + cache, + point, + NativeFatEvidenceField.Value1, + rawValue, + ArIED61850Tester.Models.IoTesting.FatEvidenceCaptureKind.AutomaticValue, + DateTimeOffset.UtcNow); + + var snapshot = NativeFatPrintPreviewSnapshot.Capture(device, cache); + Assert.Equal(rawValue, snapshot.Rows[0].Value1); + var expectedLocal = DateTimeOffset.Parse("2026-09-12T06:46:31.958+07:00") + .ToLocalTime() + .ToString("dd/MM/yyyy HH:mm:ss.fff", System.Globalization.CultureInfo.InvariantCulture); + Assert.Equal(expectedLocal, snapshot.Rows[0].Value1TimestampText); + } + + [Fact] + public void P4E_PrintPreviewRowCountAndOrderExactlyFollowCanonicalExplorerRows() + { + var device = Device("runtime-order", "AA1E1F06R4"); + device.Points.Add(Point(device, "Third", "AA1E1F06R4LD0/GGIO1.Ind3.stVal", "False", "BOOLEAN")); + device.Points.Add(Point(device, "First", "AA1E1F06R4LD0/GGIO1.Ind1.stVal", "True", "BOOLEAN")); + device.Points.Add(Point(device, "Second", "AA1E1F06R4LD0/GGIO1.Ind2.stVal", "False", "BOOLEAN")); + + var snapshot = NativeFatPrintPreviewSnapshot.Capture(device, new NativeFatIedSessionCacheState()); + + Assert.Equal(device.Points.Count, snapshot.Rows.Count); + Assert.Equal( + device.Points.Select(point => point.IecTelegram), + snapshot.Rows.Select(row => row.IecTelegram)); + Assert.Equal( + device.Points.Select(point => point.SignalName), + snapshot.Rows.Select(row => row.Signal)); + } + + [Fact] + public void P4E_RecycledEvidenceColumnReadsTheCurrentRowObjectNotIndexOrSignalName() + { + var source = File.ReadAllText(FindRepoFile("MainWindow.NativeFatCanonicalGrid.cs")); + var classStart = source.IndexOf("private sealed class NativeFatEvidenceColumn", StringComparison.Ordinal); + Assert.True(classStart >= 0); + var evidenceColumn = source[classStart..]; + + Assert.Contains("dataItem is Iec61850MonitorPoint point", evidenceColumn, StringComparison.Ordinal); + Assert.Contains("_owner.ReadNativeFatEvidence(point, Field)", evidenceColumn, StringComparison.Ordinal); + Assert.DoesNotContain("SelectedIndex", evidenceColumn, StringComparison.Ordinal); + Assert.DoesNotContain("SignalName", evidenceColumn, StringComparison.Ordinal); + Assert.DoesNotContain("Items.IndexOf", evidenceColumn, StringComparison.Ordinal); + } + + private static Iec61850MonitorDevice Device(string deviceId, string name) + => new() + { + DeviceId = deviceId, + Name = name, + IpAddress = "192.168.81.103", + Port = 102, + IsConnected = true, + IsMonitoring = true + }; + + private static Iec61850MonitorPoint Point( + Iec61850MonitorDevice device, + string signalName, + string reference, + string value, + string dataType) + => new() + { + DeviceId = device.DeviceId, + DeviceName = device.Name, + SignalName = signalName, + IecReference = reference, + IecDataType = dataType, + Quality = "Good", + Status = "Live", + SourceMode = "Static DataSet reporting", + Value = value + }; + + private static string TempRoot() + { + var path = Path.Combine(Path.GetTempPath(), "arsas-native-fat-p4e-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + return path; + } + + private static void TryDelete(string path) + { + try + { + if (Directory.Exists(path)) + Directory.Delete(path, recursive: true); + } + catch + { + } + } + + private static string FindRepoFile(string relativePath) + => Path.Combine(FindRepoRoot(), relativePath); + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + if (File.Exists(Path.Combine(directory.FullName, "MainWindow.xaml")) && + Directory.Exists(Path.Combine(directory.FullName, "tests", "ARSAS.Tests"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException( + $"Could not locate repository root from '{AppContext.BaseDirectory}'."); + } +} diff --git a/tests/ARSAS.Tests/NativeFatP5LegacyBridgeRemovalTests.cs b/tests/ARSAS.Tests/NativeFatP5LegacyBridgeRemovalTests.cs new file mode 100644 index 000000000..9fe49b698 --- /dev/null +++ b/tests/ARSAS.Tests/NativeFatP5LegacyBridgeRemovalTests.cs @@ -0,0 +1,125 @@ +namespace ARSAS.Tests; + +public sealed class NativeFatP5LegacyBridgeRemovalTests +{ + [Fact] + public void P5_NormalNativeFatRuntimeHasNoProjectionBootstrapReconnectOrSecondAcquisitionOwner() + { + var grid = File.ReadAllText(FindRepoFile("MainWindow.NativeFatCanonicalGrid.cs")); + var arm = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatArmCoordinator.cs")); + var preview = File.ReadAllText(FindRepoFile("MainWindow.NativeFatPrintPreview.cs")); + var tab = File.ReadAllText(FindRepoFile("MainWindow.ProductionFatTab.cs")); + + Assert.Contains("_nativeFatCanonicalGrid.ItemsSource = device?.Points;", grid, StringComparison.Ordinal); + Assert.Contains("NativeFatCanonicalEvidenceOverlay", arm, StringComparison.Ordinal); + Assert.Contains("NativeFatPrintPreviewSnapshot.Capture", preview, StringComparison.Ordinal); + Assert.Contains("BuildNativeFatCanonicalWorkspace", tab, StringComparison.Ordinal); + + var normalRuntimeSources = new[] { grid, arm, preview }; + foreach (var source in normalRuntimeSources) + { + foreach (var forbidden in new[] + { + "IoListTestingWindow", + "IoFatEngineeringWorkspaceProjectionService", + "IoTestWorkspaceBootstrapService", + "OpenDescribedSourcesAsync", + "PrepareIoTestIedForFatAsync", + "ConnectAndDiscoverAsync", + "ConnectUsingCachedModelAsync", + "StartMonitoringAsync", + "ShowIoTestingWorkspaceAsync", + "FatSclWorkspaceImportService" + }) + { + Assert.DoesNotContain(forbidden, source, StringComparison.Ordinal); + } + } + + foreach (var forbidden in new[] + { + "QueueProductionFatEngineeringBootstrap", + "EnsureProductionFatFromEngineeringAsync", + "IoFatEngineeringWorkspaceProjectionService", + "IoTestWorkspaceBootstrapService", + "OpenDescribedSourcesAsync", + "ShowIoTestingWorkspaceAsync" + }) + { + Assert.DoesNotContain(forbidden, tab, StringComparison.Ordinal); + } + } + + [Fact] + public void P5_ObsoleteAutomaticBridgeFilesAndProjectionAuthorityArePhysicallyRemoved() + { + var root = FindRepoRoot(); + var selection = File.ReadAllText(FindRepoFile("Services/IoTesting/IoTestSignalSelectionService.cs")); + var importer = File.ReadAllText(FindRepoFile("Services/IoTesting/IoFatSclProjectImportService.cs")); + + foreach (var relativePath in new[] + { + "MainWindow.ProductionFatEngineeringBootstrap.cs", + "MainWindow.ProductionFatNoFlicker.cs", + Path.Combine("Services", "IoTesting", "IoFatEngineeringWorkspaceProjectionService.cs"), + Path.Combine("Services", "IoTesting", "IoFatCanonicalEvidenceMigrationService.cs") + }) + { + Assert.False( + File.Exists(Path.Combine(root, relativePath)), + $"P5 requires obsolete automatic FAT bridge file '{relativePath}' to remain retired."); + } + + Assert.DoesNotContain("ENGINEERING_SCL_DATASET_AUTHORITY", selection, StringComparison.Ordinal); + Assert.DoesNotContain("EngineeringSclDataSetAuthorityBindingStatus", selection, StringComparison.Ordinal); + Assert.DoesNotContain("AdoptEngineeringRuntimeWorkspaces", importer, StringComparison.Ordinal); + } + + [Fact] + public void P5_ExplicitManualCompatibilityRemainsAnIsolatedOperatorBoundary() + { + var tab = File.ReadAllText(FindRepoFile("MainWindow.ProductionFatTab.cs")); + + Assert.Contains("MountProductionFatWorkspace", tab, StringComparison.Ordinal); + Assert.Contains("UnmountProductionFatWorkspace", tab, StringComparison.Ordinal); + Assert.Contains("FAT compatibility workspace", tab, StringComparison.Ordinal); + Assert.Contains("if (_productionFatWindow is { IsLoaded: true })", tab, StringComparison.Ordinal); + Assert.Contains("BindNativeFatCanonicalRows();", tab, StringComparison.Ordinal); + } + + [Fact] + public void P5_LegacyEvidenceHydrationIsPassiveDataMigrationNotWorkspaceBootstrap() + { + var hydration = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatEvidenceHydrationService.cs")); + + Assert.Contains("TryHydrateLegacySnapshotAsync", hydration, StringComparison.Ordinal); + Assert.Contains("without opening the legacy workspace", hydration, StringComparison.Ordinal); + Assert.DoesNotContain("IoListTestingWindow", hydration, StringComparison.Ordinal); + Assert.DoesNotContain("IoTestWorkspaceBootstrapService", hydration, StringComparison.Ordinal); + Assert.DoesNotContain("OpenDescribedSourcesAsync", hydration, StringComparison.Ordinal); + Assert.DoesNotContain("ShowIoTestingWorkspaceAsync", hydration, StringComparison.Ordinal); + Assert.DoesNotContain("ConnectAndDiscoverAsync", hydration, StringComparison.Ordinal); + Assert.DoesNotContain("StartMonitoringAsync", hydration, StringComparison.Ordinal); + } + + private static string FindRepoFile(string relativePath) + => Path.Combine(FindRepoRoot(), relativePath); + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + if (File.Exists(Path.Combine(directory.FullName, "MainWindow.xaml")) && + Directory.Exists(Path.Combine(directory.FullName, "tests", "ARSAS.Tests"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException( + $"Could not locate repository root from '{AppContext.BaseDirectory}'."); + } +} diff --git a/tests/ARSAS.Tests/NativeFatPatchBAuxiliaryEvidenceTests.cs b/tests/ARSAS.Tests/NativeFatPatchBAuxiliaryEvidenceTests.cs new file mode 100644 index 000000000..3f2de8e1c --- /dev/null +++ b/tests/ARSAS.Tests/NativeFatPatchBAuxiliaryEvidenceTests.cs @@ -0,0 +1,305 @@ +using System.Globalization; +using AR.Iec61850.FaultRecords; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class NativeFatPatchBAuxiliaryEvidenceTests +{ + [Fact] + public void ComtradeVerifiedRecords_AppearWithRequiredColumnsAndImmutableValues() + { + var device = Device("runtime-a", "AA1E1F06R4"); + var files = new List + { + new() + { + Name = "FAULT_001.cfg", + RemotePath = "/COMTRADE/FAULT_001.cfg", + BaseName = "FAULT_001", + SizeBytes = 512, + LastModifiedUtc = new DateTimeOffset(2026, 9, 10, 8, 30, 0, TimeSpan.Zero) + }, + new() + { + Name = "FAULT_001.dat", + RemotePath = "/COMTRADE/FAULT_001.dat", + BaseName = "FAULT_001", + SizeBytes = 1024, + LastModifiedUtc = new DateTimeOffset(2026, 9, 10, 8, 31, 0, TimeSpan.Zero) + } + }; + var records = new List + { + new() + { + RecordId = "/COMTRADE/FAULT_001", + BaseName = "FAULT_001", + KnownSizeBytes = 1536, + LastModifiedUtc = new DateTimeOffset(2026, 9, 10, 8, 31, 0, TimeSpan.Zero), + Files = files + } + }; + var verifiedAt = new DateTimeOffset(2026, 9, 10, 8, 32, 0, TimeSpan.Zero); + var recordAt = new DateTimeOffset(2026, 9, 10, 8, 31, 0, TimeSpan.Zero); + var cache = new NativeFatAuxiliaryEvidenceCache(); + cache.RecordComtradeDiscovery(device, records, verifiedAt); + + var auxiliary = cache.Capture(device); + records.Clear(); + files.Clear(); + var snapshot = NativeFatPrintPreviewSnapshot.Capture( + device, + new NativeFatIedSessionCacheState(), + auxiliary); + var layout = NativeFatP4DReportAdapter.Build(snapshot); + var text = ReportText(layout); + var pdf = IoFatPdfReportService.GenerateLayout(layout, snapshot.IedName, "COMTRADE"); + + Assert.Single(snapshot.AuxiliaryEvidence.ComtradeRecords); + Assert.Equal("FAULT_001", snapshot.AuxiliaryEvidence.ComtradeRecords[0].RecordName); + Assert.Contains("IEC 61850 Fault Records (COMTRADE)", text); + Assert.Contains("Available Fault Records", text); + Assert.Contains("Record Name", text); + Assert.Contains("File Timestamp", text); + Assert.Contains("Total Size", text); + Assert.Contains("Status", text); + Assert.Contains("FAULT_001", text); + Assert.Contains(recordAt.ToLocalTime().ToString("dd/MM/yyyy HH:mm:ss.fff", CultureInfo.InvariantCulture), text); + Assert.Contains(text, value => value.Contains( + $"Fault record directory verified · {verifiedAt.ToLocalTime():dd/MM/yyyy HH:mm:ss.fff}", + StringComparison.Ordinal)); + Assert.Contains("1.5 KB", text); + Assert.Contains("Complete", text); + Assert.DoesNotContain(text, value => value.Contains(" UTC", StringComparison.Ordinal)); + Assert.Equal("%PDF-1.4", System.Text.Encoding.ASCII.GetString(pdf, 0, 8)); + for (var index = 0; index < layout.Pages.Count; index++) + { + var expected = $"Page {index + 1} / {layout.Pages.Count}"; + Assert.Contains( + layout.Pages[index].Commands.OfType(), + command => command.Text == expected); + } + } + + [Fact] + public void ComtradeFailedOrEmptyDiscovery_OmitsWholeSection() + { + var device = Device("runtime-a", "AA1E1F06R4"); + var cache = new NativeFatAuxiliaryEvidenceCache(); + + Assert.DoesNotContain( + "IEC 61850 Fault Records (COMTRADE)", + ReportText(Build(device, cache))); + + cache.RecordComtradeDiscovery( + device, + [new Iec61850FaultRecordSet { RecordId = "EMPTY", BaseName = "EMPTY" }], + DateTimeOffset.UtcNow); + Assert.DoesNotContain( + "IEC 61850 Fault Records (COMTRADE)", + ReportText(Build(device, cache))); + + cache.RecordComtradeDiscovery(device, [ValidRecord("FAULT_002")], DateTimeOffset.UtcNow); + cache.ClearComtrade(device); + Assert.DoesNotContain( + "IEC 61850 Fault Records (COMTRADE)", + ReportText(Build(device, cache))); + } + + [Fact] + public void TimeSyncOk_AppearsWithOnlyBoundedSupportingEvidence() + { + var device = Device("runtime-a", "AA1E1F06R4"); + var cache = new NativeFatAuxiliaryEvidenceCache(); + var diagnostic = Diagnostic( + true, + "OK", + "LTMS evidence is present and cross-checked by a fresh good-quality IEC timestamp.", + false); + var evaluatedAt = new DateTimeOffset(2026, 9, 10, 8, 40, 0, TimeSpan.Zero); + + cache.RecordTimeSyncEvaluation(device, diagnostic, evaluatedAt); + var snapshot = NativeFatPrintPreviewSnapshot.Capture( + device, + new NativeFatIedSessionCacheState(), + cache.Capture(device)); + var text = ReportText(NativeFatP4DReportAdapter.Build(snapshot)); + + Assert.NotNull(snapshot.AuxiliaryEvidence.TimeSync); + Assert.True(snapshot.AuxiliaryEvidence.TimeSync!.IsSynchronized); + Assert.Equal(2, snapshot.AuxiliaryEvidence.TimeSync.SupportingPoints.Count); + Assert.Contains("IEC 61850 Time Synchronization Evidence", text); + Assert.Contains("Time Sync OK", text); + Assert.Contains("IEC 61850 Reference", text); + Assert.Contains(text, value => value.Contains("LTMS verified", StringComparison.Ordinal)); + Assert.Contains("AA1E1F06R4LD0/LLN0.LTMS", text); + Assert.Contains("AA1E1F06R4LD0/XCBR1.Pos.stVal", text); + Assert.Contains(evaluatedAt.ToLocalTime().ToString("dd/MM/yyyy HH:mm:ss.fff", CultureInfo.InvariantCulture), text); + Assert.DoesNotContain(text, value => value.Contains(" UTC", StringComparison.Ordinal)); + } + + [Theory] + [InlineData("REVIEW", false)] + [InlineData("NOT OK", true)] + public void TimeSyncReviewOrNotOk_OmitsWholeSection(string verdict, bool explicitNegative) + { + var device = Device("runtime-a", "AA1E1F06R4"); + var cache = new NativeFatAuxiliaryEvidenceCache(); + cache.RecordTimeSyncEvaluation(device, Diagnostic(true, "OK", "Verified.", false), DateTimeOffset.UtcNow); + cache.RecordTimeSyncEvaluation( + device, + Diagnostic(false, verdict, "Synchronization is not proven.", explicitNegative), + DateTimeOffset.UtcNow); + + var text = ReportText(Build(device, cache)); + Assert.DoesNotContain("IEC 61850 Time Synchronization Evidence", text); + Assert.DoesNotContain("Time Sync OK", text); + } + + [Fact] + public void Cache_IsScopedByStableIedNameAcrossRuntimeDeviceIds() + { + var first = Device("runtime-a", "DISPLAY-A"); + first.SclIedName = "AA1E1F06R4"; + var reopened = Device("runtime-b", "DISPLAY-B"); + reopened.SclIedName = "AA1E1F06R4"; + var other = Device("runtime-c", "AA1E1F06R5"); + var cache = new NativeFatAuxiliaryEvidenceCache(); + + cache.RecordComtradeDiscovery(first, [ValidRecord("FAULT_STABLE")], DateTimeOffset.UtcNow); + + Assert.Single(cache.Capture(reopened).ComtradeRecords); + Assert.Empty(cache.Capture(other).ComtradeRecords); + } + + [Fact] + public void PreviewAndSnapshotSources_DoNotStartDiscoveryReconnectAcquisitionOrLegacyFatWindow() + { + var preview = File.ReadAllText(FindRepoFile("MainWindow.NativeFatPrintPreview.cs")); + var snapshot = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatPrintPreviewSnapshot.cs")); + var decorator = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatAuxiliaryReportDecorator.cs")); + + Assert.Contains("_nativeFatAuxiliaryEvidenceCache.Capture(device)", preview, StringComparison.Ordinal); + foreach (var source in new[] { preview, snapshot, decorator }) + { + Assert.DoesNotContain("FaultRecordTransferClient", source, StringComparison.Ordinal); + Assert.DoesNotContain("DiscoverAsync", source, StringComparison.Ordinal); + Assert.DoesNotContain("ConnectAsync", source, StringComparison.Ordinal); + Assert.DoesNotContain("StartMonitoringAsync", source, StringComparison.Ordinal); + Assert.DoesNotContain("IoListTestingWindow", source, StringComparison.Ordinal); + } + } + + private static IoFatReportLayoutPlan Build( + Iec61850MonitorDevice device, + NativeFatAuxiliaryEvidenceCache cache) + => NativeFatP4DReportAdapter.Build(NativeFatPrintPreviewSnapshot.Capture( + device, + new NativeFatIedSessionCacheState(), + cache.Capture(device))); + + private static string[] ReportText(IoFatReportLayoutPlan layout) + => layout.Pages + .SelectMany(page => page.Commands) + .OfType() + .Select(command => command.Text) + .ToArray(); + + private static Iec61850MonitorDevice Device(string deviceId, string name) + => new() + { + DeviceId = deviceId, + Name = name, + IpAddress = "192.168.81.103", + Port = 102, + IsConnected = true + }; + + private static Iec61850FaultRecordSet ValidRecord(string name) + => new() + { + RecordId = $"/COMTRADE/{name}", + BaseName = name, + KnownSizeBytes = 2048, + LastModifiedUtc = DateTimeOffset.UtcNow, + Files = + [ + new Iec61850FaultRecordFile + { + Name = $"{name}.cff", + RemotePath = $"/COMTRADE/{name}.cff", + BaseName = name, + SizeBytes = 2048, + LastModifiedUtc = DateTimeOffset.UtcNow + } + ] + }; + + private static NativeFatTimeSyncDiagnosticResult Diagnostic( + bool synchronized, + string verdict, + string summary, + bool explicitNegative) + => new( + synchronized, + verdict, + summary, + true, + synchronized, + synchronized ? 1 : 0, + explicitNegative, + synchronized + ? + [ + new NativeFatTimeSyncPointEvidence( + "LTMS", + "LTMS", + "AA1E1F06R4LD0/LLN0.LTMS", + "2026-09-10T08:40:00Z", + "Good", + "2026-09-10T08:40:00Z", + 0.02, + true), + new NativeFatTimeSyncPointEvidence( + "IEC timestamp", + "Breaker", + "AA1E1F06R4LD0/XCBR1.Pos.stVal", + "Open [01]", + "Good", + "2026-09-10T08:40:00Z", + 0.03, + true), + new NativeFatTimeSyncPointEvidence( + "IEC timestamp", + "Extra", + "AA1E1F06R4LD0/GGIO1.Ind1.stVal", + "False", + "Good", + "2026-09-10T08:40:00Z", + 0.04, + true) + ] + : Array.Empty(), + Array.Empty()); + + private static string FindRepoFile(string relativePath) + => Path.Combine(FindRepoRoot(), relativePath); + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + if (File.Exists(Path.Combine(directory.FullName, "MainWindow.xaml")) && + Directory.Exists(Path.Combine(directory.FullName, "tests", "ARSAS.Tests"))) + { + return directory.FullName; + } + directory = directory.Parent; + } + + throw new DirectoryNotFoundException($"Could not locate repository root from '{AppContext.BaseDirectory}'."); + } +} diff --git a/tests/ARSAS.Tests/ProductionFatEngineeringTabRegressionTests.cs b/tests/ARSAS.Tests/ProductionFatEngineeringTabRegressionTests.cs index 633df0122..2045f0a77 100644 --- a/tests/ARSAS.Tests/ProductionFatEngineeringTabRegressionTests.cs +++ b/tests/ARSAS.Tests/ProductionFatEngineeringTabRegressionTests.cs @@ -3,45 +3,39 @@ namespace ARSAS.Tests; public sealed class ProductionFatEngineeringTabRegressionTests { [Fact] - public void EngineeringProjection_ReusesParsedSclWorkspaceWithoutOpeningXmlAgain() + public void ProductionFatTab_NormalEntryHasNoLegacyProjectionBootstrapModule() { - var source = File.ReadAllText(FindRepoFile("Services/IoTesting/IoFatEngineeringWorkspaceProjectionService.cs")); - - Assert.Contains("device.SclWorkspace", source, StringComparison.Ordinal); - Assert.Contains("FatSclWorkspaceImportService.Import(workspaceSources)", source, StringComparison.Ordinal); - Assert.Contains("ENGINEERING_SCL_DATASET_AUTHORITY", source, StringComparison.Ordinal); - Assert.Contains("IoFatSourceWorkspaceService.DescribeAsync", source, StringComparison.Ordinal); - Assert.DoesNotContain("SclWorkspaceService", source, StringComparison.Ordinal); - Assert.DoesNotContain("OpenAsync(", source, StringComparison.Ordinal); - Assert.DoesNotContain("LoadScl", source, StringComparison.Ordinal); - } - - [Fact] - public void ProductionFatTab_AutoBootstrapsFromSelectedEngineeringStaticDataSet() - { - var source = File.ReadAllText(FindRepoFile("MainWindow.ProductionFatEngineeringBootstrap.cs")); - - Assert.Contains("QueueProductionFatEngineeringBootstrap();", source, StringComparison.Ordinal); - Assert.Contains("selected?.SclWorkspace", source, StringComparison.Ordinal); - Assert.Contains("DesignModel.DataSets.Sum", source, StringComparison.Ordinal); - Assert.Contains("IoFatEngineeringWorkspaceProjectionService.BuildAsync", source, StringComparison.Ordinal); - Assert.Contains("AdoptEngineeringRuntimeWorkspaces", source, StringComparison.Ordinal); - Assert.Contains("IoTestWorkspaceBootstrapService.OpenDescribedSourcesAsync", source, StringComparison.Ordinal); - Assert.Contains("projection.DescribedSources", source, StringComparison.Ordinal); - Assert.Contains("SynchronizeImportedSclFatWithEngineering", source, StringComparison.Ordinal); - Assert.Contains("ShowIoTestingWorkspaceAsync", source, StringComparison.Ordinal); - Assert.Contains("no SCL re-import", source, StringComparison.Ordinal); - Assert.DoesNotContain("OpenSclFatTesting_Click", source, StringComparison.Ordinal); - } + var source = File.ReadAllText(FindRepoFile("MainWindow.ProductionFatTab.cs")); + var repoRoot = FindRepoRoot(); - [Fact] - public void ProductionFatTab_RegistersExactEngineeringRuntimeWorkspacesForSharedAcquisition() - { - var source = File.ReadAllText(FindRepoFile("Services/IoTesting/IoFatSclProjectImportService.cs")); + Assert.False( + File.Exists(Path.Combine(repoRoot, "MainWindow.ProductionFatEngineeringBootstrap.cs")), + "P5 removes the automatic Engineering -> legacy IoTest bootstrap module from normal FAT navigation."); + Assert.False( + File.Exists(Path.Combine(repoRoot, "Services", "IoTesting", "IoFatEngineeringWorkspaceProjectionService.cs")), + "P5 removes the obsolete Engineering -> IoTest projection service rather than leaving a dormant second-row authority."); + + Assert.Contains("NativeFatTab.Content = BuildProductionFatPermanentHost();", source, StringComparison.Ordinal); + Assert.Contains("SynchronizeProductionFatSelectedIed();", source, StringComparison.Ordinal); + Assert.Contains("BindNativeFatCanonicalRows();", source, StringComparison.Ordinal); + + foreach (var forbidden in new[] + { + "QueueProductionFatEngineeringBootstrap", + "EnsureProductionFatFromEngineeringAsync", + "IoFatEngineeringWorkspaceProjectionService", + "IoTestWorkspaceBootstrapService", + "OpenDescribedSourcesAsync", + "ShowIoTestingWorkspaceAsync", + "ShowProductionFatBootstrapState" + }) + { + Assert.DoesNotContain(forbidden, source, StringComparison.Ordinal); + } - Assert.Contains("AdoptEngineeringRuntimeWorkspaces", source, StringComparison.Ordinal); - Assert.Contains("SetRuntimeWorkspaces(stable)", source, StringComparison.Ordinal); - Assert.Contains("workspace.WorkspaceKey", source, StringComparison.Ordinal); + // Explicit/manual compatibility remains a deliberate operator boundary only. + Assert.Contains("MountProductionFatWorkspace", source, StringComparison.Ordinal); + Assert.Contains("FAT compatibility workspace", source, StringComparison.Ordinal); } [Fact] @@ -91,22 +85,6 @@ public void ExistingWorkspaceSelectionSideEffects_RemainProtectedWhileAddingFat( Assert.Contains("UpdateNavigationVisuals(MainTabs.SelectedIndex, animate: true)", source, StringComparison.Ordinal); } - [Fact] - public void ProductionFatSafetyBoundary_KeepsEngineeringAuthorityAndStrictPreflightCoverage() - { - var bootstrapSource = File.ReadAllText(FindRepoFile("MainWindow.ProductionFatEngineeringBootstrap.cs")); - var fieldRegressionSource = File.ReadAllText(FindRepoFile("tests/ARSAS.Tests/ProductionFatP0FieldRegressionTests.cs")); - var preflightSource = File.ReadAllText(FindRepoFile("Services/IoTesting/IoTestSessionPreflight.cs")); - var preflightTests = File.ReadAllText(FindRepoFile("tests/ARSAS.Tests/IoTestSessionPreflightTests.cs")); - - Assert.Contains("RetireManualWorkspaceRowsForStaticDataSetMode", bootstrapSource, StringComparison.Ordinal); - Assert.Contains("AutomaticStaticDataSetScope_RetiresManualAliasBeforeSessionPreflight", fieldRegressionSource, StringComparison.Ordinal); - Assert.Contains("IoTestSessionPreflight.Validate", fieldRegressionSource, StringComparison.Ordinal); - Assert.Contains("RetireRedundantManualWorkspaceRows", preflightSource, StringComparison.Ordinal); - Assert.Contains("multiple enabled test points", preflightSource, StringComparison.OrdinalIgnoreCase); - Assert.Contains("Validate_RejectsDuplicateEnabledLiveReference", preflightTests, StringComparison.Ordinal); - } - private static string FindRepoFile(string relativePath) => Path.Combine(FindRepoRoot(), relativePath); diff --git a/tests/ARSAS.Tests/ProductionFatM2PermanentHostRegressionTests.cs b/tests/ARSAS.Tests/ProductionFatM2PermanentHostRegressionTests.cs index 5f113b828..4583336da 100644 --- a/tests/ARSAS.Tests/ProductionFatM2PermanentHostRegressionTests.cs +++ b/tests/ARSAS.Tests/ProductionFatM2PermanentHostRegressionTests.cs @@ -16,36 +16,26 @@ public void FatTab_IsPermanentHost_NotLauncherOrAlternateOpenSclWorkflow() } [Fact] - public void EngineeringStaticDataSet_FatBootstrapIsNavigationGated() + public void NormalFatEntry_HasNoAutomaticEngineeringBootstrapOwner() { - var bootstrap = Read("MainWindow.ProductionFatEngineeringBootstrap.cs"); - - Assert.DoesNotContain( - "window.QueueProductionFatEngineeringBootstrap();\n }\n\n private void ProductionFatEngineeringBootstrap_SelectionChanged", - bootstrap, - StringComparison.Ordinal); - Assert.Contains( - "e.PropertyName != nameof(SelectedDevice) || MainTabs.SelectedIndex != NativeFatWorkspaceIndex", - bootstrap, - StringComparison.Ordinal); - Assert.Contains( - "!_productionFatEngineeringBootstrapInstalled || MainTabs.SelectedIndex != NativeFatWorkspaceIndex", - bootstrap, - StringComparison.Ordinal); - Assert.Contains( - "_productionFatEngineeringBootstrapBusy ||\n MainTabs.SelectedIndex != NativeFatWorkspaceIndex", - bootstrap, - StringComparison.Ordinal); + var root = FindRepoRoot(); + var tab = Read("MainWindow.ProductionFatTab.cs"); + + Assert.False(File.Exists(Path.Combine(root, "MainWindow.ProductionFatEngineeringBootstrap.cs"))); + Assert.False(File.Exists(Path.Combine(root, "MainWindow.ProductionFatNoFlicker.cs"))); + Assert.False(File.Exists(Path.Combine(root, "Services", "IoTesting", "IoFatEngineeringWorkspaceProjectionService.cs"))); + + Assert.Contains("BuildNativeFatCanonicalWorkspace()", tab, StringComparison.Ordinal); + Assert.Contains("BindNativeFatCanonicalRows();", tab, StringComparison.Ordinal); + Assert.DoesNotContain("QueueProductionFatEngineeringBootstrap", tab, StringComparison.Ordinal); + Assert.DoesNotContain("ShowIoTestingWorkspaceAsync", tab, StringComparison.Ordinal); } [Fact] - public void AutomaticFatBootstrap_PreservesStaticReportOnlyAuthority() + public void ExplicitCompatibilityStaticReportOnlyAuthority_RemainsAvailableAtBoundary() { - var bootstrap = Read("MainWindow.ProductionFatEngineeringBootstrap.cs"); var authority = Read("MainWindow.SharedStaticDataSetAuthority.cs"); - Assert.Contains("PreserveSharedStaticDataSetAuthority(device);", bootstrap, StringComparison.Ordinal); - Assert.DoesNotContain("MarkSharedSelectionAuthority(device);", bootstrap, StringComparison.Ordinal); Assert.Contains("Iec61850MonitoringModeRegistry.UseStaticDataSetReportOnly(device);", authority, StringComparison.Ordinal); Assert.DoesNotContain("UseHybrid", authority, StringComparison.Ordinal); Assert.Contains("_sharedSclStaticDataSetAuthorityDeviceIds.Add(device.DeviceId);", authority, StringComparison.Ordinal); diff --git a/tests/ARSAS.Tests/ProductionFatM7CleanupRegressionTests.cs b/tests/ARSAS.Tests/ProductionFatM7CleanupRegressionTests.cs index f0e993000..1296e3633 100644 --- a/tests/ARSAS.Tests/ProductionFatM7CleanupRegressionTests.cs +++ b/tests/ARSAS.Tests/ProductionFatM7CleanupRegressionTests.cs @@ -9,24 +9,12 @@ public void ObsoleteNativeFatRuntime_IsNotASecondProductionAuthority() var bridge = File.ReadAllText(Path.Combine(repoRoot, "MainWindow.NativeFatWorkspace.cs")); var productionTab = File.ReadAllText(Path.Combine(repoRoot, "MainWindow.ProductionFatTab.cs")); - Assert.False( - File.Exists(Path.Combine(repoRoot, "MainWindow.NativeFatExport.cs")), - "The retired native FAT export must not return; production FAT owns report/export delivery."); - Assert.False( - File.Exists(Path.Combine(repoRoot, "MainWindow.NativeFatReportPreview.cs")), - "The retired native FAT side-panel preview must not return. Production FAT owns one in-place report preview."); - Assert.False( - File.Exists(Path.Combine(repoRoot, "MainWindow.NativeFatHistoryInspector.cs")), - "The retired side-panel history inspector must not return as a second FAT presentation stack."); - Assert.False( - File.Exists(Path.Combine(repoRoot, "MainWindow.NativeFatExplorerSync.cs")), - "The retired native FAT Explorer reconciler must not return; Engineering selection and production FAT own synchronization."); - Assert.False( - File.Exists(Path.Combine(repoRoot, "MainWindow.NativeFatPersistenceSafety.cs")), - "The retired native FAT persistence runtime must not return; production FAT storage remains the persistence authority."); + Assert.False(File.Exists(Path.Combine(repoRoot, "MainWindow.NativeFatExport.cs"))); + Assert.False(File.Exists(Path.Combine(repoRoot, "MainWindow.NativeFatReportPreview.cs"))); + Assert.False(File.Exists(Path.Combine(repoRoot, "MainWindow.NativeFatHistoryInspector.cs"))); + Assert.False(File.Exists(Path.Combine(repoRoot, "MainWindow.NativeFatExplorerSync.cs"))); + Assert.False(File.Exists(Path.Combine(repoRoot, "MainWindow.NativeFatPersistenceSafety.cs"))); - // MainWindow.NativeFatWorkspace.cs is now only a compatibility bridge for the - // canonical seventh shell slot. It must never regain its own FAT runtime/state. Assert.Contains("private const int NativeFatWorkspaceIndex = 6", bridge, StringComparison.Ordinal); Assert.Contains("QueueNativeFatNavigationGeometry", bridge, StringComparison.Ordinal); Assert.DoesNotContain("DataGrid", bridge, StringComparison.Ordinal); @@ -38,8 +26,6 @@ public void ObsoleteNativeFatRuntime_IsNotASecondProductionAuthority() Assert.DoesNotContain("BuildNativeFatWorkspaceContent", bridge, StringComparison.Ordinal); Assert.DoesNotContain("RegisterNativeFatWorkspace", bridge, StringComparison.Ordinal); - // Production FAT mounts directly into the canonical XAML slot. There must be no - // compatibility-field handshake with the retired native runtime. Assert.Contains("NativeFatTab.Content = BuildProductionFatPermanentHost();", productionTab, StringComparison.Ordinal); Assert.Contains("NativeFatTab.Content = surface;", productionTab, StringComparison.Ordinal); Assert.Contains("NavNativeFatButton.ToolTip", productionTab, StringComparison.Ordinal); @@ -52,7 +38,7 @@ public void ObsoleteNativeFatRuntime_IsNotASecondProductionAuthority() } [Fact] - public void ProductionReportPreviewAndExport_RemainSingleAuthority() + public void ExplicitCompatibilityReportPreviewAndExport_RemainAvailableAtBoundary() { var repoRoot = FindRepoRoot(); var productionPreview = File.ReadAllText(Path.Combine(repoRoot, "IoListTestingWindow.PrintPreview.cs")); @@ -67,13 +53,17 @@ public void ProductionReportPreviewAndExport_RemainSingleAuthority() } [Fact] - public void Cleanup_DoesNotRetireProductionCapturePreflightOrEngineeringAcquisitionAuthority() + public void Cleanup_RetiresAutomaticBootstrapButKeepsExplicitProductionPreflightAuthority() { - var bootstrap = File.ReadAllText(FindRepoFile("MainWindow.ProductionFatEngineeringBootstrap.cs")); + var repoRoot = FindRepoRoot(); var adapter = File.ReadAllText(FindRepoFile("Services/IoTesting/IoFatProductionControllerAdapter.cs")); var contract = File.ReadAllText(FindRepoFile("docs/FAT_ENGINEERING_WORKSTATION_CONTRACT.md")); - Assert.Contains("AdoptEngineeringRuntimeWorkspaces", bootstrap, StringComparison.Ordinal); + Assert.False(File.Exists(Path.Combine(repoRoot, "MainWindow.ProductionFatEngineeringBootstrap.cs"))); + Assert.False(File.Exists(Path.Combine(repoRoot, "MainWindow.ProductionFatNoFlicker.cs"))); + Assert.False(File.Exists(Path.Combine(repoRoot, "Services", "IoTesting", "IoFatEngineeringWorkspaceProjectionService.cs"))); + Assert.False(File.Exists(Path.Combine(repoRoot, "Services", "IoTesting", "IoFatCanonicalEvidenceMigrationService.cs"))); + Assert.Contains("IoTestSessionPreflight.Validate", adapter, StringComparison.Ordinal); Assert.Contains("IoFatProductionControllerAdapter", adapter, StringComparison.Ordinal); Assert.Contains("production FAT", contract, StringComparison.OrdinalIgnoreCase); diff --git a/tests/ARSAS.Tests/ProductionFatP0FieldRegressionTests.cs b/tests/ARSAS.Tests/ProductionFatP0FieldRegressionTests.cs index 9ea5e3463..a4d0d0dde 100644 --- a/tests/ARSAS.Tests/ProductionFatP0FieldRegressionTests.cs +++ b/tests/ARSAS.Tests/ProductionFatP0FieldRegressionTests.cs @@ -25,10 +25,6 @@ public void AutomaticStaticDataSetScope_RetiresManualAliasBeforeSessionPreflight primaryLiveLeaf); var ied = Ied(staticPoint, manualAlias); - - // Field regression: a persisted scl-manual-* row may be restored before live - // binding proves that both rows collapse to the same primary leaf. Start FAT must - // self-heal that stale overlay instead of presenting a scope-not-ready dialog. var ready = IoTestSessionPreflight.Validate(ied); Assert.True(ready.Succeeded, ready.Message); @@ -38,8 +34,6 @@ public void AutomaticStaticDataSetScope_RetiresManualAliasBeforeSessionPreflight Assert.True(manualAlias.IsIncludedInFat); Assert.Equal(primaryLiveLeaf, manualAlias.LiveSignalReference); - // The broad automatic-static cleanup is now idempotent because preflight already - // retired the exact live-leaf shadow alias without touching evidence/test state. var retired = IoFatEngineeringSelectionBridge.RetireManualWorkspaceRowsForStaticDataSetMode(ied); Assert.Equal(0, retired); } @@ -67,7 +61,6 @@ public void Preflight_LiveDuplicateGroup_RetiresRestoredManualIdEvenWhenLegacyBi primaryLiveLeaf); var ied = Ied(staticPoint, restoredManualAlias); - var ready = IoTestSessionPreflight.Validate(ied); Assert.True(ready.Succeeded, ready.Message); @@ -101,7 +94,6 @@ public void Preflight_TrueNonManualDuplicate_RemainsBlocked() primaryLiveLeaf); var ied = Ied(staticPoint, ambiguousLegacyPoint); - var blocked = IoTestSessionPreflight.Validate(ied); Assert.False(blocked.Succeeded); @@ -125,56 +117,6 @@ public void StaticDataSetModeCleanup_DoesNotTouchManualOnlyProjects() Assert.True(manual.IsIncludedInFat); } - [Fact] - public void EngineeringBootstrap_AppliesStaticCleanupBeforeProductionWindowIsShown() - { - var source = Read("MainWindow.ProductionFatEngineeringBootstrap.cs"); - var synchronize = source.IndexOf("SynchronizeImportedSclFatWithEngineering(launch.Project);", StringComparison.Ordinal); - var retire = source.IndexOf("RetireManualWorkspaceRowsForStaticDataSetMode", StringComparison.Ordinal); - var show = source.IndexOf("await ShowIoTestingWorkspaceAsync(launch, importWarningCount: 0);", StringComparison.Ordinal); - - Assert.True(synchronize >= 0, "Engineering/FAT synchronization call is missing."); - Assert.True(retire > synchronize, "Static DataSet cleanup must happen after shared selection synchronization so newly-created manual aliases are also retired."); - Assert.True(show > retire, "Static DataSet cleanup must complete before the production FAT workspace/session can be exposed."); - } - - [Fact] - public void EngineeringBootstrap_ReusesDescribedSourcesWhileStagingStillVerifiesSha256() - { - var projection = Read("Services/IoTesting/IoFatEngineeringWorkspaceProjectionService.cs"); - var bootstrap = Read("MainWindow.ProductionFatEngineeringBootstrap.cs"); - var bootstrapService = Read("Services/IoTesting/IoTestWorkspaceBootstrapService.cs"); - var persistence = Read("Services/IoTesting/IoTestProjectPersistenceService.cs"); - var sourceWorkspace = Read("Services/IoTesting/IoFatSourceWorkspaceService.cs"); - - Assert.Contains("IReadOnlyList DescribedSources", projection, StringComparison.Ordinal); - Assert.Equal( - 1, - projection.Split("IoFatSourceWorkspaceService.DescribeAsync", StringSplitOptions.None).Length - 1); - Assert.Contains("projection.DescribedSources", bootstrap, StringComparison.Ordinal); - Assert.Contains("IoTestWorkspaceBootstrapService.OpenDescribedSourcesAsync", bootstrap, StringComparison.Ordinal); - Assert.Contains("OpenDescribedSourcesAsync", bootstrapService, StringComparison.Ordinal); - Assert.Contains("IoTestWorkspacePersistence.OpenDescribedSourcesAsync", bootstrapService, StringComparison.Ordinal); - Assert.Contains("StageDescribedAsync", persistence, StringComparison.Ordinal); - Assert.Contains("CopyVerifiedAsync", sourceWorkspace, StringComparison.Ordinal); - Assert.Contains("IsVerifiedStagedCopyAsync", sourceWorkspace, StringComparison.Ordinal); - Assert.Contains("SHA256.HashDataAsync(stream", sourceWorkspace, StringComparison.Ordinal); - Assert.Contains("VerifyHash(bytes, expectedSha256", sourceWorkspace, StringComparison.Ordinal); - } - - [Fact] - public void EmbeddedAutomaticBootstrap_NeverHidesEngineeringWindow() - { - var source = Read("MainWindow.ProductionFatNoFlicker.cs"); - - Assert.Contains("public new void Hide()", source, StringComparison.Ordinal); - Assert.Contains("ShouldKeepEngineeringVisibleDuringProductionFatBootstrap", source, StringComparison.Ordinal); - Assert.Contains("_productionFatEngineeringBootstrapBusy", source, StringComparison.Ordinal); - Assert.Contains("ProductionFatTabReady", source, StringComparison.Ordinal); - Assert.DoesNotContain("MainTabs.SelectedIndex == NativeFatWorkspaceIndex", source, StringComparison.Ordinal); - Assert.Contains("base.Hide();", source, StringComparison.Ordinal); - } - private static IoTestPointPlan StaticPoint(string staticReference) => new() { @@ -237,22 +179,4 @@ private static IoTestIedPlan Ied(params IoTestPointPlan[] points) IpAddress = "192.168.81.103", TestPoints = points.ToList() }; - - private static string Read(string relativePath) - => File.ReadAllText(FindRepoFile(relativePath)).Replace("\r\n", "\n", StringComparison.Ordinal); - - private static string FindRepoFile(string relativePath) - { - DirectoryInfo? directory = new(AppContext.BaseDirectory); - while (directory != null) - { - var candidate = Path.Combine(directory.FullName, relativePath); - if (File.Exists(candidate)) - return candidate; - directory = directory.Parent; - } - - throw new FileNotFoundException( - $"Could not locate repository file '{relativePath}' from '{AppContext.BaseDirectory}'."); - } } diff --git a/tests/ARSAS.Tests/ProductionFatP1CanonicalGridRegressionTests.cs b/tests/ARSAS.Tests/ProductionFatP1CanonicalGridRegressionTests.cs new file mode 100644 index 000000000..dbb7a33bc --- /dev/null +++ b/tests/ARSAS.Tests/ProductionFatP1CanonicalGridRegressionTests.cs @@ -0,0 +1,90 @@ +namespace ARSAS.Tests; + +public sealed class ProductionFatP1CanonicalGridRegressionTests +{ + [Fact] + public void P1A_NormalFatEntryBindsExactEngineeringPointCollection() + { + var gridSource = File.ReadAllText(FindRepoFile("MainWindow.NativeFatCanonicalGrid.cs")); + var tabSource = File.ReadAllText(FindRepoFile("MainWindow.ProductionFatTab.cs")); + + Assert.Contains("_nativeFatCanonicalGrid.ItemsSource = device?.Points;", gridSource, StringComparison.Ordinal); + Assert.Contains("BuildNativeFatCanonicalWorkspace", tabSource, StringComparison.Ordinal); + Assert.Contains("BindNativeFatCanonicalRows();", tabSource, StringComparison.Ordinal); + + Assert.DoesNotContain("IoFatEngineeringWorkspaceProjectionService", gridSource, StringComparison.Ordinal); + Assert.DoesNotContain("new IoTestPointPlan", gridSource, StringComparison.Ordinal); + Assert.DoesNotContain("QueueProductionFatEngineeringBootstrap();", tabSource, StringComparison.Ordinal); + Assert.DoesNotContain("OpenDescribedSourcesAsync", tabSource, StringComparison.Ordinal); + } + + [Fact] + public void P1B_EvidenceColumnsRemainSparseOverlayNotRowWrappers() + { + var gridSource = File.ReadAllText(FindRepoFile("MainWindow.NativeFatCanonicalGrid.cs")); + var columnContract = File.ReadAllText(FindRepoFile("MainWindow.NativeFatP4CColumnContract.cs")); + var overlaySource = File.ReadAllText(FindRepoFile("Services/IoTesting/NativeFatCanonicalEvidenceOverlay.cs")); + + Assert.Contains("NativeFatEvidenceField.Value1", columnContract, StringComparison.Ordinal); + Assert.Contains("NativeFatEvidenceField.Value1Timestamp", columnContract, StringComparison.Ordinal); + Assert.Contains("NativeFatEvidenceField.Value2", columnContract, StringComparison.Ordinal); + Assert.Contains("NativeFatEvidenceField.Value2Timestamp", columnContract, StringComparison.Ordinal); + Assert.Contains("NativeFatEvidenceField.Result", columnContract, StringComparison.Ordinal); + Assert.Contains("NativeFatIedSessionCacheState", gridSource, StringComparison.Ordinal); + Assert.Contains("TryBuildRowKey(point.DeviceName, point.IecTelegram", overlaySource, StringComparison.Ordinal); + Assert.Contains("cache.EvidenceByRow.Remove(key)", overlaySource, StringComparison.Ordinal); + + Assert.DoesNotContain("ObservableCollection", gridSource, StringComparison.Ordinal); + Assert.DoesNotContain("new Iec61850MonitorPoint", gridSource, StringComparison.Ordinal); + } + + [Fact] + public void P1C_NativeFatUsesEngineeringGridStyleTemplateAndVirtualizationContract() + { + var gridSource = File.ReadAllText(FindRepoFile("MainWindow.NativeFatCanonicalGrid.cs")); + var columnContract = File.ReadAllText(FindRepoFile("MainWindow.NativeFatP4CColumnContract.cs")); + var engineeringXaml = File.ReadAllText(FindRepoFile("MainWindow.xaml")); + var appXaml = File.ReadAllText(FindRepoFile("App.xaml")); + + Assert.Contains("x:Key=\"ModernDataGrid\"", appXaml, StringComparison.Ordinal); + Assert.Contains("", appXaml, StringComparison.Ordinal); + Assert.Contains("", appXaml, StringComparison.Ordinal); + + Assert.Contains("Style=\"{StaticResource ModernDataGrid}\" FrozenColumnCount=\"2\"", engineeringXaml, StringComparison.Ordinal); + Assert.Contains("CellTemplate=\"{StaticResource ProcessValueBadgeTemplate}\"", engineeringXaml, StringComparison.Ordinal); + Assert.Contains("VirtualizingPanel.VirtualizationMode=\"Recycling\"", engineeringXaml, StringComparison.Ordinal); + + Assert.Contains("FindResource(\"ModernDataGrid\") as Style", gridSource, StringComparison.Ordinal); + Assert.Contains("ApplyNativeFatP4CColumnContract();", gridSource, StringComparison.Ordinal); + Assert.Contains("AddCanonicalTemplateColumn(\"Live Value\", \"ProcessValueBadgeTemplate\", 150);", columnContract, StringComparison.Ordinal); + Assert.Contains("new NativeFatEvidenceBindingColumn(\"V1 Timestamp\", NativeFatEvidenceField.Value1Timestamp, 185, ReadNativeFatEvidence)", columnContract, StringComparison.Ordinal); + Assert.Contains("new NativeFatEvidenceBindingColumn(\"V2 Timestamp\", NativeFatEvidenceField.Value2Timestamp, 185, ReadNativeFatEvidence)", columnContract, StringComparison.Ordinal); + Assert.Contains("VirtualizingPanel.SetVirtualizationMode(_nativeFatCanonicalGrid, VirtualizationMode.Recycling);", gridSource, StringComparison.Ordinal); + Assert.Contains("RowStyle = BuildEngineeringLiveRowStyle()", gridSource, StringComparison.Ordinal); + Assert.Contains("CellStyle = BuildEngineeringLiveCellStyle()", gridSource, StringComparison.Ordinal); + + Assert.DoesNotContain("RowHeight = 40", gridSource, StringComparison.Ordinal); + Assert.DoesNotContain("MinHeight = 40", gridSource, StringComparison.Ordinal); + } + + private static string FindRepoFile(string relativePath) + => Path.Combine(FindRepoRoot(), relativePath); + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + if (File.Exists(Path.Combine(directory.FullName, "MainWindow.xaml")) && + Directory.Exists(Path.Combine(directory.FullName, "tests", "ARSAS.Tests"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException( + $"Could not locate repository root from '{AppContext.BaseDirectory}'."); + } +} diff --git a/tests/ARSAS.Tests/WorkspaceModeSwitchTests.cs b/tests/ARSAS.Tests/WorkspaceModeSwitchTests.cs index f61a16422..fd76fc1a6 100644 --- a/tests/ARSAS.Tests/WorkspaceModeSwitchTests.cs +++ b/tests/ARSAS.Tests/WorkspaceModeSwitchTests.cs @@ -3,26 +3,21 @@ namespace ARSAS.Tests; public sealed class WorkspaceModeSwitchTests { [Fact] - public void MainWindow_AlwaysExposesEngineeringAndPersistentIoFatWorkspaceModes() + public void MainWindow_DoesNotInstallTheObsoleteEngineeringIoFatSwitcher() { var source = File.ReadAllText(FindRepoFile("MainWindow.WorkspaceModeSwitch.cs")); - Assert.Contains("ENGINEERING", source, StringComparison.Ordinal); - Assert.Contains("IO LIST FAT", source, StringComparison.Ordinal); - Assert.Contains("IO LIST FAT · LOADED", source, StringComparison.Ordinal); + Assert.DoesNotContain("WorkspaceModeSwitchTag", source, StringComparison.Ordinal); + Assert.DoesNotContain("RegisterWorkspaceModeSwitch", source, StringComparison.Ordinal); + Assert.DoesNotContain("InstallWorkspaceModeSwitch", source, StringComparison.Ordinal); + Assert.DoesNotContain("_workspaceFatButton", source, StringComparison.Ordinal); + Assert.DoesNotContain("OpenOrResumeIoFatWorkspace_Click", source, StringComparison.Ordinal); + Assert.DoesNotContain("OpenIoFatWorkspaceMenu", source, StringComparison.Ordinal); Assert.Contains("_loadedIoFatWindow", source, StringComparison.Ordinal); - Assert.Contains("ShowLoadedIoFatWorkspace", source, StringComparison.Ordinal); - Assert.Contains("CurrentEngineeringSclSourcePaths", source, StringComparison.Ordinal); - Assert.Contains("OpenSclFatSourcesAsync(sharedSources, selectionMode: null)", source, StringComparison.Ordinal); - Assert.Contains("Continue loaded FAT project", source, StringComparison.Ordinal); - Assert.Contains("Import SCL / CID files", source, StringComparison.Ordinal); - Assert.Contains("Add SCL / CID to loaded FAT workspace", source, StringComparison.Ordinal); - Assert.Contains("OpenSclForLoadedFatAppendAsync(loaded)", source, StringComparison.Ordinal); - Assert.Contains("OpenSclFatTesting_Click", source, StringComparison.Ordinal); - Assert.Contains("Import another IO List Excel workbook", source, StringComparison.Ordinal); - Assert.Contains("Open another portable .arsas project", source, StringComparison.Ordinal); + Assert.Contains("RegisterLoadedIoFatWindow", source, StringComparison.Ordinal); + Assert.Contains("ShowEngineeringWorkspaceFromFat", source, StringComparison.Ordinal); Assert.Contains("QueueIoFatWorkspaceReplacement", source, StringComparison.Ordinal); - Assert.Contains("FrameworkElement.LoadedEvent", source, StringComparison.Ordinal); + Assert.Contains("LoadedIoFatWindow_Closed", source, StringComparison.Ordinal); } [Fact] @@ -98,12 +93,8 @@ public void LoadingAnotherFatProject_IsExplicitWhileLoadedSclImportIsAdditive() Assert.Contains("loaded.Close();", source, StringComparison.Ordinal); Assert.Contains("Dispatcher.BeginInvoke(openReplacement", source, StringComparison.Ordinal); - // P0.4 keeps SCL additive while workbook/project opens remain explicit replacement. - Assert.Contains("OpenSclForLoadedFatAppendAsync(loaded)", source, StringComparison.Ordinal); - Assert.DoesNotContain( - "QueueIoFatWorkspaceReplacement(\n () => OpenSclFatTesting_Click", - source, - StringComparison.Ordinal); + // Workbook/project opens remain explicit replacement. SCL import is owned by the + // canonical MainWindow workflow now that the obsolete header switcher is gone. Assert.Contains("QueueIoFatWorkspaceReplacement(() => OpenIoListTesting_Click", hostSource, StringComparison.Ordinal); Assert.Contains("QueueIoFatWorkspaceReplacement(() => OpenIoListPackage_Click", hostSource, StringComparison.Ordinal); }