diff --git a/FaultRecordWindow.DownloadedSelectionBinding.cs b/FaultRecordWindow.DownloadedSelectionBinding.cs new file mode 100644 index 000000000..faba9efb5 --- /dev/null +++ b/FaultRecordWindow.DownloadedSelectionBinding.cs @@ -0,0 +1,199 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Data; +using System.Windows.Threading; + +namespace ArIED61850Tester; + +/// +/// Single visual/state authority for downloaded COMTRADE re-download selection. +/// The legacy row model intentionally rejects IsSelected for already-downloaded records, +/// so a notifying proxy owns the native WPF CheckBox while the existing staged-overwrite +/// HashSet remains the transfer authority. No synthetic glyph or manual IsChecked paint is used. +/// +public partial class FaultRecordWindow +{ + private readonly Dictionary _downloadedSelectionProxies = + new(StringComparer.OrdinalIgnoreCase); + private bool _downloadedSelectionBindingInstalled; + + [ModuleInitializer] + internal static void RegisterDownloadedSelectionBindingAuthority() + { + EventManager.RegisterClassHandler( + typeof(FaultRecordWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(DownloadedSelectionBinding_WindowLoaded), + handledEventsToo: true); + } + + private static void DownloadedSelectionBinding_WindowLoaded(object sender, RoutedEventArgs e) + { + if (sender is not FaultRecordWindow window || window._downloadedSelectionBindingInstalled) + return; + + // Install after the normal window Loaded path has completed. This intentionally + // removes the old manual PreviewMouseDown checkbox toggle and lets WPF TwoWay binding + // own the native check mark from this point forward. + window.Dispatcher.BeginInvoke( + DispatcherPriority.ContextIdle, + new Action(window.InstallDownloadedSelectionBindingAuthority)); + } + + private void InstallDownloadedSelectionBindingAuthority() + { + if (_downloadedSelectionBindingInstalled) + return; + + _downloadedSelectionBindingInstalled = true; + FaultRecordsGrid.PreviewMouseLeftButtonDown -= RedownloadGrid_PreviewMouseLeftButtonDown; + FaultRecordsGrid.LoadingRow += DownloadedSelectionBinding_LoadingRow; + Closed += DownloadedSelectionBinding_Closed; + + RebindVisibleDownloadedSelectionRows(); + } + + private void DownloadedSelectionBinding_Closed(object? sender, EventArgs e) + { + FaultRecordsGrid.LoadingRow -= DownloadedSelectionBinding_LoadingRow; + Closed -= DownloadedSelectionBinding_Closed; + _downloadedSelectionProxies.Clear(); + } + + private void DownloadedSelectionBinding_LoadingRow(object? sender, DataGridRowEventArgs e) + { + // RedownloadUx.LoadingRow was registered first and performs the legacy setup at + // DispatcherPriority.Loaded. Re-apply the authoritative binding one turn later. + Dispatcher.BeginInvoke( + DispatcherPriority.ContextIdle, + new Action(() => BindDownloadedSelectionRow(e.Row))); + } + + private void RebindVisibleDownloadedSelectionRows() + { + FaultRecordsGrid.UpdateLayout(); + foreach (var row in Records) + { + if (FaultRecordsGrid.ItemContainerGenerator.ContainerFromItem(row) is DataGridRow gridRow) + BindDownloadedSelectionRow(gridRow); + } + } + + private void BindDownloadedSelectionRow(DataGridRow gridRow) + { + if (gridRow.DataContext is not FaultRecordRow row || + row.LocalState != FaultRecordLocalState.Downloaded) + { + return; + } + + var checkBox = FindVisualDescendants(gridRow).FirstOrDefault(); + if (checkBox == null) + return; + + var proxy = GetDownloadedSelectionProxy(row); + + // Remove every legacy manual checkbox writer. The native CheckBox gets one TwoWay + // binding and therefore its visual tick can no longer diverge from transfer state. + checkBox.Click -= DownloadedRecordCheckBox_Click; + BindingOperations.ClearBinding(checkBox, ToggleButton.IsCheckedProperty); + BindingOperations.ClearBinding(checkBox, UIElement.IsEnabledProperty); + BindingOperations.SetBinding( + checkBox, + ToggleButton.IsCheckedProperty, + new Binding(nameof(DownloadedSelectionProxy.IsSelected)) + { + Source = proxy, + Mode = BindingMode.TwoWay, + UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged + }); + checkBox.IsEnabled = row.Record.Files.Count > 0 && !IsBusy; + checkBox.ToolTip = "Already downloaded. Check to download again and safely overwrite the existing local copy."; + } + + private DownloadedSelectionProxy GetDownloadedSelectionProxy(FaultRecordRow row) + { + var recordId = row.Record.RecordId; + if (_downloadedSelectionProxies.TryGetValue(recordId, out var existing)) + return existing; + + var proxy = new DownloadedSelectionProxy( + _redownloadSelections.Contains(recordId), + selected => DownloadedSelectionProxy_Changed(row, selected)); + _downloadedSelectionProxies[recordId] = proxy; + return proxy; + } + + private void DownloadedSelectionProxy_Changed(FaultRecordRow row, bool selected) + { + if (selected) + _redownloadSelections.Add(row.Record.RecordId); + else + _redownloadSelections.Remove(row.Record.RecordId); + + UpdateSmartSelectionUi(); + RefreshFaultRecordHeaderSelection(); + } + + private bool IsDownloadedTransferSelected(FaultRecordRow row) + => _downloadedSelectionProxies.TryGetValue(row.Record.RecordId, out var proxy) + ? proxy.IsSelected + : _redownloadSelections.Contains(row.Record.RecordId); + + private void SetDownloadedTransferSelection(FaultRecordRow row, bool selected) + { + if (row.LocalState != FaultRecordLocalState.Downloaded) + return; + + var proxy = GetDownloadedSelectionProxy(row); + proxy.IsSelected = selected; + + // If the state was already equal, the proxy intentionally emits no notification; + // still synchronize the backing set because scan/local-state transitions can rebuild it. + if (selected) + _redownloadSelections.Add(row.Record.RecordId); + else + _redownloadSelections.Remove(row.Record.RecordId); + + Dispatcher.BeginInvoke( + DispatcherPriority.ContextIdle, + new Action(() => + { + ConfigureRecordRow(row); // preserve legacy enabled/tooltip semantics + if (FaultRecordsGrid.ItemContainerGenerator.ContainerFromItem(row) is DataGridRow gridRow) + BindDownloadedSelectionRow(gridRow); // then restore the one authoritative binding + UpdateSmartSelectionUi(); + RefreshFaultRecordHeaderSelection(); + })); + } + + private sealed class DownloadedSelectionProxy : INotifyPropertyChanged + { + private readonly Action _changed; + private bool _isSelected; + + public DownloadedSelectionProxy(bool isSelected, Action changed) + { + _isSelected = isSelected; + _changed = changed; + } + + public bool IsSelected + { + get => _isSelected; + set + { + if (_isSelected == value) + return; + _isSelected = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsSelected))); + _changed(value); + } + } + + public event PropertyChangedEventHandler? PropertyChanged; + } +} diff --git a/FaultRecordWindow.HeaderSelection.cs b/FaultRecordWindow.HeaderSelection.cs index 1540a6857..286949aa4 100644 --- a/FaultRecordWindow.HeaderSelection.cs +++ b/FaultRecordWindow.HeaderSelection.cs @@ -9,9 +9,10 @@ namespace ArIED61850Tester; /// -/// Adds a tri-state select-all checkbox to the fault-record Get column. Selecting all -/// affects only rows that are currently eligible for download; clearing always clears -/// every row so stale disabled selections cannot remain hidden. +/// Adds a tri-state select-all checkbox to the fault-record Get column. A record with relay +/// files is selectable whether it is a first download or an intentional re-download. Native +/// first-download rows use FaultRecordRow.IsSelected; downloaded rows use the notifying +/// re-download selection authority so the native WPF checkbox and transfer state cannot diverge. /// public partial class FaultRecordWindow { @@ -75,7 +76,7 @@ private void EnsureFaultRecordHeaderSelection() VerticalAlignment = VerticalAlignment.Center, Cursor = Cursors.Hand, Focusable = true, - ToolTip = "Check / uncheck all downloadable fault records" + ToolTip = "Check / uncheck all downloadable or re-downloadable fault records" }; AutomationProperties.SetName(headerCheckBox, "Toggle all downloadable fault records"); headerCheckBox.Click += FaultRecordHeaderSelectionCheckBox_Click; @@ -105,10 +106,26 @@ private void FaultRecordHeaderSelectionCheckBox_Click(object sender, RoutedEvent { foreach (var row in Records) { - if (target) - row.IsSelected = row.CanSelectForDownload; - else + if (!HasTransferableFiles(row)) + { + row.IsSelected = false; + if (row.LocalState == FaultRecordLocalState.Downloaded) + SetDownloadedTransferSelection(row, false); + else + _redownloadSelections.Remove(row.Record.RecordId); + continue; + } + + if (row.LocalState == FaultRecordLocalState.Downloaded) + { row.IsSelected = false; + SetDownloadedTransferSelection(row, target); + } + else + { + _redownloadSelections.Remove(row.Record.RecordId); + row.IsSelected = target && row.CanSelectForDownload; + } } } finally @@ -117,6 +134,7 @@ private void FaultRecordHeaderSelectionCheckBox_Click(object sender, RoutedEvent } RaiseSelectionState(); + UpdateSmartSelectionUi(); RefreshFaultRecordHeaderSelection(); } @@ -161,18 +179,18 @@ private void RefreshFaultRecordHeaderSelection() if (header == null) return; - var eligibleCount = Records.Count(row => row.CanSelectForDownload); + var eligibleCount = Records.Count(HasTransferableFiles); header.IsEnabled = !IsBusy && eligibleCount > 0; header.IsChecked = GetFaultRecordHeaderSelectionState(); } private bool? GetFaultRecordHeaderSelectionState() { - var eligible = Records.Where(row => row.CanSelectForDownload).ToArray(); + var eligible = Records.Where(HasTransferableFiles).ToArray(); if (eligible.Length == 0) return false; - var selected = eligible.Count(row => row.IsSelected); + var selected = eligible.Count(IsSelectedForTransfer); if (selected == 0) return false; if (selected == eligible.Length) @@ -180,6 +198,14 @@ private void RefreshFaultRecordHeaderSelection() return null; } + private static bool HasTransferableFiles(FaultRecordRow row) + => row.Record.Files.Count > 0; + + private bool IsSelectedForTransfer(FaultRecordRow row) + => row.LocalState == FaultRecordLocalState.Downloaded + ? IsDownloadedTransferSelected(row) + : row.IsSelected; + private void FaultRecordHeaderSelectionWindow_Closed(object? sender, EventArgs e) { PropertyChanged -= FaultRecordHeaderSelectionWindow_PropertyChanged; diff --git a/FaultRecordWindow.RedownloadSelectionAuthority.cs b/FaultRecordWindow.RedownloadSelectionAuthority.cs new file mode 100644 index 000000000..aa27c49b9 --- /dev/null +++ b/FaultRecordWindow.RedownloadSelectionAuthority.cs @@ -0,0 +1,98 @@ +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; + +namespace ArIED61850Tester; + +/// +/// Row-body selection authority for the fault-record grid. Native WPF CheckBoxes own checkbox +/// clicks. This class handles only clicks on the rest of a transferable row and routes downloaded +/// rows through the same notifying selection proxy used by the checkbox and header selection. +/// +public partial class FaultRecordWindow +{ + [ModuleInitializer] + internal static void RegisterRedownloadSelectionAuthority() + { + EventManager.RegisterClassHandler( + typeof(DataGrid), + UIElement.PreviewMouseLeftButtonDownEvent, + new MouseButtonEventHandler(RedownloadSelectionAuthority_Down), + handledEventsToo: true); + } + + private static void RedownloadSelectionAuthority_Down(object sender, MouseButtonEventArgs e) + { + if (sender is not DataGrid grid || + Window.GetWindow(grid) is not FaultRecordWindow window || + !ReferenceEquals(grid, window.FaultRecordsGrid) || + window.IsBusy || + e.ChangedButton != MouseButton.Left || + e.OriginalSource is not DependencyObject source) + { + return; + } + + // Native checkbox binding owns checkbox clicks. Row-body clicks are a fast-workflow + // convenience and must not create a second toggle for the same pointer action. + if (FindRedownloadSelectionAncestor(source) != null) + return; + + if (!TryResolveTransferRow(source, out var row) || row.Record.Files.Count == 0) + return; + + if (row.LocalState == FaultRecordLocalState.Downloaded) + { + window.SetDownloadedTransferSelection(row, !window.IsDownloadedTransferSelected(row)); + } + else + { + if (!row.CanSelectForDownload) + return; + row.IsSelected = !row.IsSelected; + } + + e.Handled = true; + window.UpdateSmartSelectionUi(); + window.RefreshFaultRecordHeaderSelection(); + } + + private static bool TryResolveTransferRow(DependencyObject? source, out FaultRecordRow row) + { + row = null!; + var dataGridRow = FindRedownloadSelectionAncestor(source); + if (dataGridRow?.DataContext is not FaultRecordRow candidate) + return false; + + row = candidate; + return true; + } + + private static T? FindRedownloadSelectionAncestor(DependencyObject? source) + where T : DependencyObject + { + var current = source; + while (current != null) + { + if (current is T match) + return match; + + DependencyObject? parent = null; + try + { + parent = VisualTreeHelper.GetParent(current); + } + catch (InvalidOperationException) + { + } + + if (parent == null && current is FrameworkElement element) + parent = element.Parent; + current = parent; + } + + return null; + } +} diff --git a/FaultRecordWindow.RedownloadUx.cs b/FaultRecordWindow.RedownloadUx.cs index 1a386e9ec..b4ed3291b 100644 --- a/FaultRecordWindow.RedownloadUx.cs +++ b/FaultRecordWindow.RedownloadUx.cs @@ -15,7 +15,7 @@ namespace ArIED61850Tester; /// /// Adds an explicit re-download workflow without weakening the persistent local-state /// indication. A green row remains selectable, the fresh package is downloaded into a -/// separate complete directory first, and only then replaces the known-good local copy. +/// separate complete staging directory first, and only then replaces the known-good copy. /// public partial class FaultRecordWindow { @@ -40,6 +40,10 @@ private void InstallRedownloadUx() ToastHost.Margin = new Thickness(0); FaultRecordsGrid.LoadingRow += RedownloadUx_LoadingRow; + // A downloaded row is disabled by the legacy row model before the visual overlay is + // installed. Handle the tunnelling mouse event at the grid itself so re-download is + // reliable even with virtualization/recycled rows and even on the very first click. + FaultRecordsGrid.PreviewMouseLeftButtonDown += RedownloadGrid_PreviewMouseLeftButtonDown; Records.CollectionChanged += RedownloadUx_RecordsChanged; PropertyChanged += RedownloadUx_WindowPropertyChanged; Closed += RedownloadUx_Closed; @@ -60,6 +64,7 @@ private void InstallRedownloadUx() private void RedownloadUx_Closed(object? sender, EventArgs e) { FaultRecordsGrid.LoadingRow -= RedownloadUx_LoadingRow; + FaultRecordsGrid.PreviewMouseLeftButtonDown -= RedownloadGrid_PreviewMouseLeftButtonDown; Records.CollectionChanged -= RedownloadUx_RecordsChanged; PropertyChanged -= RedownloadUx_WindowPropertyChanged; @@ -74,6 +79,31 @@ private void RedownloadUx_Closed(object? sender, EventArgs e) _redownloadRowHandlers.Clear(); } + private void RedownloadGrid_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + if (IsBusy || e.ChangedButton != MouseButton.Left || + e.OriginalSource is not DependencyObject source) + { + return; + } + + var checkBox = FindVisualAncestor(source); + if (checkBox?.DataContext is not FaultRecordRow row || + row.LocalState != FaultRecordLocalState.Downloaded || + row.Record.Files.Count == 0) + { + return; + } + + e.Handled = true; + var recordId = row.Record.RecordId; + if (!_redownloadSelections.Add(recordId)) + _redownloadSelections.Remove(recordId); + + ConfigureRecordRow(row); + UpdateSmartSelectionUi(); + } + private void RedownloadUx_WindowPropertyChanged(object? sender, PropertyChangedEventArgs e) { if (e.PropertyName is nameof(IsBusy) or nameof(CanDownload) or nameof(SelectionSummary)) @@ -187,9 +217,9 @@ private void ConfigureDataGridRow(DataGridRow gridRow) { BindingOperations.ClearBinding(checkBox, ToggleButton.IsCheckedProperty); BindingOperations.ClearBinding(checkBox, UIElement.IsEnabledProperty); - checkBox.IsEnabled = row.Record.Files.Count > 0; + checkBox.IsEnabled = row.Record.Files.Count > 0 && !IsBusy; checkBox.IsChecked = _redownloadSelections.Contains(row.Record.RecordId); - checkBox.ToolTip = "Already downloaded. Select to download again and overwrite the local copy."; + checkBox.ToolTip = "Already downloaded. Select to download again and overwrite the existing local copy."; checkBox.Click += DownloadedRecordCheckBox_Click; return; } @@ -339,11 +369,14 @@ private async Task RunSmartDownloadAsync() var overwriteExisting = row.LocalState == FaultRecordLocalState.Downloaded && !string.IsNullOrWhiteSpace(previousDirectory) && Directory.Exists(previousDirectory); + var stagingRoot = overwriteExisting + ? Path.Combine(Path.GetFullPath(DestinationDirectory), $".arsas-redownload-{Guid.NewGuid():N}") + : DestinationDirectory; row.Status = "Downloading"; row.Detail = string.Empty; StatusText = overwriteExisting - ? $"Downloading a fresh copy of {row.RecordName} ({index + 1}/{selected.Length})…" + ? $"Downloading a fresh copy of {row.RecordName} before overwrite ({index + 1}/{selected.Length})…" : $"Downloading {row.RecordName} ({index + 1}/{selected.Length})…"; var recordIndex = index; @@ -358,14 +391,29 @@ private async Task RunSmartDownloadAsync() StatusText = $"{row.RecordName}: {FormatBytes(item.BytesTransferred)} transferred, file {item.CompletedFiles}/{item.TotalFiles}."; }); - var result = await _client.DownloadAsync( - row.Record, - DestinationDirectory, - progress, - _operationCancellation.Token); + Iec61850FaultRecordDownloadResult result; + try + { + if (overwriteExisting) + Directory.CreateDirectory(stagingRoot); + + result = await _client.DownloadAsync( + row.Record, + stagingRoot, + progress, + _operationCancellation.Token); + } + catch + { + if (overwriteExisting) + TryRemoveDirectory(stagingRoot); + throw; + } if (!result.IsSuccess) { + if (overwriteExisting) + TryRemoveDirectory(stagingRoot); failedRecords++; row.Status = "Failed"; row.Detail = result.Message; @@ -399,8 +447,13 @@ ArgumentException or failedRecords++; row.Status = "Failed"; row.Detail = - "The new relay copy downloaded successfully, but replacing the previous local record failed: " + - $"{ex.GetType().Name}: {ex.Message}. The fresh copy remains at '{result.DestinationDirectory}'."; + "The fresh relay copy downloaded successfully, but replacing the previous local record failed: " + + $"{ex.GetType().Name}: {ex.Message}. The previous local record was preserved whenever rollback succeeded."; + } + finally + { + if (overwriteExisting) + TryRemoveDirectory(stagingRoot); } ProgressValue = ((index + 1d) / selected.Length) * 100d; @@ -584,6 +637,19 @@ private void HideStartupScanToast() ToastHost.Opacity = 0; } + private static T? FindVisualAncestor(DependencyObject? child) + where T : DependencyObject + { + var current = child; + while (current != null) + { + if (current is T match) + return match; + current = VisualTreeHelper.GetParent(current); + } + return null; + } + private static IEnumerable FindVisualDescendants(DependencyObject root) where T : DependencyObject { diff --git a/IoListTestingWindow.ActiveFatView.cs b/IoListTestingWindow.ActiveFatView.cs new file mode 100644 index 000000000..8790b01f7 --- /dev/null +++ b/IoListTestingWindow.ActiveFatView.cs @@ -0,0 +1,126 @@ +using System.Collections; +using System.ComponentModel; +using System.Windows; +using System.Windows.Data; +using System.Windows.Threading; +using ArIED61850Tester.Models.IoTesting; + +namespace ArIED61850Tester; + +/// +/// Owns the active FAT grid projection. TestPoints remains the retained project/evidence +/// collection, while the visible grid contains only points whose FAT disposition is Included. +/// Remove/restore changes workspace membership only. TestEnabled is an operator-owned flag: +/// no background projection, refresh, reconnect or FAT lifecycle is allowed to toggle it. +/// +public partial class IoListTestingWindow +{ + private static readonly bool ActiveFatViewClassHandlerRegistered = RegisterActiveFatViewClassHandler(); + private readonly HashSet _activeFatViewPoints = new(); + private ListCollectionView? _activeFatView; + private IoTestIedPlan? _activeFatViewIed; + private bool _activeFatViewInstalled; + + private static bool RegisterActiveFatViewClassHandler() + { + EventManager.RegisterClassHandler( + typeof(IoListTestingWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(ActiveFatView_Loaded)); + return true; + } + + private static void ActiveFatView_Loaded(object sender, RoutedEventArgs e) + { + if (sender is not IoListTestingWindow window || window._activeFatViewInstalled) + return; + + window._activeFatViewInstalled = true; + window.PropertyChanged += window.ActiveFatView_WindowPropertyChanged; + window.Closed += window.ActiveFatView_Closed; + window.Dispatcher.BeginInvoke( + new Action(window.RefreshActiveFatView), + DispatcherPriority.ContextIdle); + } + + private void ActiveFatView_WindowPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName != nameof(SelectedIed)) + return; + + Dispatcher.BeginInvoke(new Action(RefreshActiveFatView), DispatcherPriority.DataBind); + } + + private void RefreshActiveFatView() + { + if (_fatSignalsGrid == null) + { + if (IsLoaded) + Dispatcher.BeginInvoke(new Action(RefreshActiveFatView), DispatcherPriority.ContextIdle); + return; + } + + DetachActiveFatViewPoints(); + _activeFatViewIed = SelectedIed; + + if (_activeFatViewIed == null) + { + _activeFatView = null; + _fatSignalsGrid.ItemsSource = null; + return; + } + + foreach (var point in _activeFatViewIed.TestPoints) + { + point.PropertyChanged += ActiveFatView_PointPropertyChanged; + _activeFatViewPoints.Add(point); + } + + _activeFatView = new ListCollectionView((IList)_activeFatViewIed.TestPoints) + { + Filter = item => item is IoTestPointPlan point && point.IsIncludedInFat + }; + _fatSignalsGrid.ItemsSource = _activeFatView; + } + + private void ActiveFatView_PointPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (sender is not IoTestPointPlan point || + e.PropertyName is not (nameof(IoTestPointPlan.FatDisposition) or nameof(IoTestPointPlan.IsIncludedInFat))) + { + return; + } + + void ApplyMembershipChange() + { + if (!_activeFatViewPoints.Contains(point)) + return; + + // Only the active projection changes here. TestEnabled must never be changed as a + // side effect: checked/unchecked state belongs exclusively to explicit operator input. + _activeFatView?.Refresh(); + } + + if (Dispatcher.CheckAccess()) + ApplyMembershipChange(); + else + Dispatcher.BeginInvoke(new Action(ApplyMembershipChange), DispatcherPriority.DataBind); + } + + private void DetachActiveFatViewPoints() + { + foreach (var point in _activeFatViewPoints) + point.PropertyChanged -= ActiveFatView_PointPropertyChanged; + _activeFatViewPoints.Clear(); + } + + private void ActiveFatView_Closed(object? sender, EventArgs e) + { + PropertyChanged -= ActiveFatView_WindowPropertyChanged; + Closed -= ActiveFatView_Closed; + DetachActiveFatViewPoints(); + _activeFatView = null; + _activeFatViewIed = null; + _activeFatViewInstalled = false; + } +} diff --git a/IoListTestingWindow.ColumnSizing.cs b/IoListTestingWindow.ColumnSizing.cs new file mode 100644 index 000000000..b80c1e15f --- /dev/null +++ b/IoListTestingWindow.ColumnSizing.cs @@ -0,0 +1,57 @@ +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Threading; + +namespace ArIED61850Tester; + +/// +/// Keeps the FAT IEC reference column operator-resizable. IEC object references are often +/// the longest and most important identification field in the FAT grid, so the column must +/// not be capped at the compact-layout width. +/// +public partial class IoListTestingWindow +{ + [ModuleInitializer] + internal static void RegisterFatColumnSizing() + { + EventManager.RegisterClassHandler( + typeof(IoListTestingWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(FatColumnSizing_Loaded)); + } + + private static void FatColumnSizing_Loaded(object sender, RoutedEventArgs e) + { + if (sender is not IoListTestingWindow window) + return; + + // FAT V2 rebuilds the runtime columns from ContentRendered. Apply this operator + // sizing contract after that rebuild rather than relying on the original XAML + // column instances, which would be replaced a moment later. + window.Dispatcher.BeginInvoke( + new Action(window.ApplyOperatorFatColumnSizing), + DispatcherPriority.ApplicationIdle); + } + + private void ApplyOperatorFatColumnSizing() + { + _fatSignalsGrid ??= FindVisualDescendant(this); + if (_fatSignalsGrid == null) + return; + + _fatSignalsGrid.CanUserResizeColumns = true; + foreach (var column in _fatSignalsGrid.Columns) + { + if (!string.Equals(column.Header?.ToString(), "IEC REFERENCE", StringComparison.OrdinalIgnoreCase)) + continue; + + // Preserve the compact initial width, but remove the old ~360 px ceiling. + // A finite generous cap avoids pathological accidental drags while still letting + // the operator expose an entire IEC 61850 telegram on wide monitors. + column.MinWidth = Math.Max(column.MinWidth, 250d); + column.MaxWidth = 4096d; + break; + } + } +} diff --git a/IoListTestingWindow.CommandPanel.cs b/IoListTestingWindow.CommandPanel.cs index 034fc1017..d08372f69 100644 --- a/IoListTestingWindow.CommandPanel.cs +++ b/IoListTestingWindow.CommandPanel.cs @@ -1,5 +1,6 @@ using System.Collections.Specialized; using System.ComponentModel; +using System.Globalization; using System.Windows; using System.Windows.Controls; using System.Windows.Controls.Primitives; @@ -12,11 +13,60 @@ namespace ArIED61850Tester; public partial class IoListTestingWindow { + private sealed class FatCommandRowView + { + public required Border Container { get; init; } + public required Grid Grid { get; init; } + public required FrameworkElement Actions { get; set; } + } + + private sealed class FatCommandTextConverter : IValueConverter + { + public static FatCommandTextConverter Instance { get; } = new(); + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + var text = value?.ToString()?.Trim() ?? string.Empty; + return string.IsNullOrWhiteSpace(text) ? "—" : text; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => Binding.DoNothing; + } + + private sealed class FatCommandModelConverter : IValueConverter + { + public static FatCommandModelConverter Instance { get; } = new(); + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + => FatCommandModelText(value?.ToString()); + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => Binding.DoNothing; + } + + private sealed class FatCommandCanOperateConverter : IMultiValueConverter + { + public static FatCommandCanOperateConverter Instance { get; } = new(); + + public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) + { + var supportsOperate = values.ElementAtOrDefault(0) is true; + var isBusy = values.ElementAtOrDefault(1) is true; + return supportsOperate && !isBusy; + } + + public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) + => targetTypes.Select(_ => Binding.DoNothing).ToArray(); + } + private Border? _fatCommandPanelShell; private StackPanel? _fatCommandRows; private TextBlock? _fatCommandSummary; + private FrameworkElement? _fatCommandEmptyState; private Iec61850MonitorDevice? _fatCommandDevice; private readonly HashSet _fatCommandSubscribedSignals = new(); + private readonly Dictionary _fatCommandRowViews = new(); private bool _fatCommandPanelLifecycleInstalled; // Register at class level rather than overriding OnInitialized. IoListTestingWindow @@ -146,13 +196,13 @@ private async Task RefreshFatCommandPanelAsync() { DetachFatCommandDevice(); _fatCommandSummary.Text = "Engineering owner unavailable; control is disabled fail-closed."; - RebuildFatCommandRows(); + SynchronizeFatCommandRows(); return; } var device = engineeringWindow.ResolveIoFatCommandDevice(SelectedIed); AttachFatCommandDevice(device); - RebuildFatCommandRows(); + SynchronizeFatCommandRows(); if (device == null) { _fatCommandSummary.Text = "No shared Engineering IED is bound to the selected FAT device."; @@ -165,12 +215,12 @@ private async Task RefreshFatCommandPanelAsync() return; } - _fatCommandSummary.Text = $"{device.Name} · validating live ctlModel and command values…"; + _fatCommandSummary.Text = $"{device.Name} · validating live ctlModel and shared process values…"; try { await engineeringWindow.RefreshIoFatCommandValuesAsync(device); AttachFatCommandDevice(device); - RebuildFatCommandRows(); + SynchronizeFatCommandRows(); } catch (OperationCanceledException) { @@ -182,15 +232,16 @@ private async Task RefreshFatCommandPanelAsync() } } - private void AttachFatCommandDevice(Iec61850MonitorDevice? device) + private bool AttachFatCommandDevice(Iec61850MonitorDevice? device) { if (ReferenceEquals(_fatCommandDevice, device)) - return; + return false; DetachFatCommandDevice(); _fatCommandDevice = device; if (_fatCommandDevice != null) _fatCommandDevice.CommandSignals.CollectionChanged += FatCommandSignals_CollectionChanged; + return true; } private void DetachFatCommandDevice() @@ -205,71 +256,113 @@ private void DetachFatCommandDevice() } private void FatCommandSignals_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) - => Dispatcher.BeginInvoke(new Action(RebuildFatCommandRows), DispatcherPriority.Background); + => Dispatcher.BeginInvoke(new Action(SynchronizeFatCommandRows), DispatcherPriority.Background); private void FatCommandSignal_PropertyChanged(object? sender, PropertyChangedEventArgs e) { - if (e.PropertyName is nameof(SignalDefinition.ControlSetPointText) - or nameof(SignalDefinition.ControlInterlockCheck) - or nameof(SignalDefinition.ControlSynchroCheck) - or nameof(SignalDefinition.ControlTestMode)) - { + if (sender is not SignalDefinition signal) return; - } - if (e.PropertyName is nameof(SignalDefinition.ControlCurrentValue) - or nameof(SignalDefinition.ControlLastResult) - or nameof(SignalDefinition.ControlConfirmationPending) - or nameof(SignalDefinition.ControlCommandBusy) - or nameof(SignalDefinition.ControlInspectionBusy) - or nameof(SignalDefinition.ControlModelText) + // LIVE VALUE, result text and busy/enabled state are WPF bindings on the existing + // row instance. Only a semantic action-layout transition needs to replace the small + // action cell; the row and the rest of the panel remain untouched. + if (e.PropertyName is nameof(SignalDefinition.ControlConfirmationPending) or nameof(SignalDefinition.ControlCdc) - or nameof(SignalDefinition.ControlSupportsOperate)) + or nameof(SignalDefinition.ControlModelText) + or nameof(SignalDefinition.ControlModelResolved)) { - Dispatcher.BeginInvoke(new Action(RebuildFatCommandRows), DispatcherPriority.Background); + Dispatcher.BeginInvoke( + new Action(() => RefreshFatCommandActions(signal)), + DispatcherPriority.Background); } } - private void RebuildFatCommandRows() + private void SynchronizeFatCommandRows() { if (_fatCommandRows == null || _fatCommandSummary == null) return; - foreach (var signal in _fatCommandSubscribedSignals) - signal.PropertyChanged -= FatCommandSignal_PropertyChanged; - _fatCommandSubscribedSignals.Clear(); - _fatCommandRows.Children.Clear(); - var device = _fatCommandDevice; - if (device == null) + var commands = device?.CommandSignals.ToArray() ?? Array.Empty(); + var commandSet = commands.ToHashSet(); + + foreach (var stale in _fatCommandRowViews.Keys.Where(signal => !commandSet.Contains(signal)).ToArray()) + RemoveFatCommandRow(stale); + + if (commands.Length == 0) { - _fatCommandRows.Children.Add(FatCommandEmptyText("No FAT command device selected.")); + _fatCommandSummary.Text = device == null + ? "No FAT command device selected." + : $"{device.Name} · no operable control is proven by live ctlModel. Status-only controls remain read-only."; + + EnsureFatCommandEmptyState(device == null + ? "No FAT command device selected." + : "No command action is available. Controls appear only after live ctlModel proves Direct/SBO operation; StatusOnly and unsupported generic types stay fail-closed."); return; } - var commands = device.CommandSignals.ToArray(); - _fatCommandSummary.Text = commands.Length == 0 - ? $"{device.Name} · no operable control is proven by live ctlModel. Status-only controls remain read-only." - : $"{device.Name} · {commands.Length} operable DataSet control(s) · shared Engineering command backend"; + RemoveFatCommandEmptyState(); + _fatCommandSummary.Text = $"{device!.Name} · {commands.Length} operable DataSet control(s) · shared Engineering command backend"; - if (commands.Length == 0) + for (var index = 0; index < commands.Length; index++) { - _fatCommandRows.Children.Add(FatCommandEmptyText( - "No command action is available. Controls appear only after live ctlModel proves Direct/SBO operation; StatusOnly and unsupported generic types stay fail-closed.")); - return; + var signal = commands[index]; + if (!_fatCommandRowViews.TryGetValue(signal, out var view)) + { + signal.PropertyChanged += FatCommandSignal_PropertyChanged; + _fatCommandSubscribedSignals.Add(signal); + view = BuildFatCommandRow(signal); + _fatCommandRowViews[signal] = view; + _fatCommandRows.Children.Insert(Math.Min(index, _fatCommandRows.Children.Count), view.Container); + } + + var currentIndex = _fatCommandRows.Children.IndexOf(view.Container); + if (currentIndex >= 0 && currentIndex != index) + { + _fatCommandRows.Children.RemoveAt(currentIndex); + _fatCommandRows.Children.Insert(Math.Min(index, _fatCommandRows.Children.Count), view.Container); + } } + } + + private void EnsureFatCommandEmptyState(string text) + { + if (_fatCommandRows == null) + return; - foreach (var signal in commands) + if (_fatCommandEmptyState is TextBlock existing) { - signal.PropertyChanged += FatCommandSignal_PropertyChanged; - _fatCommandSubscribedSignals.Add(signal); - _fatCommandRows.Children.Add(BuildFatCommandRow(signal)); + existing.Text = text; + if (!_fatCommandRows.Children.Contains(existing)) + _fatCommandRows.Children.Add(existing); + return; } + + _fatCommandEmptyState = FatCommandEmptyText(text); + _fatCommandRows.Children.Add(_fatCommandEmptyState); } - private FrameworkElement BuildFatCommandRow(SignalDefinition signal) + private void RemoveFatCommandEmptyState() { - var row = new Grid { MinWidth = 960 }; + if (_fatCommandRows == null || _fatCommandEmptyState == null) + return; + _fatCommandRows.Children.Remove(_fatCommandEmptyState); + _fatCommandEmptyState = null; + } + + private void RemoveFatCommandRow(SignalDefinition signal) + { + if (_fatCommandSubscribedSignals.Remove(signal)) + signal.PropertyChanged -= FatCommandSignal_PropertyChanged; + + if (!_fatCommandRowViews.Remove(signal, out var view) || _fatCommandRows == null) + return; + _fatCommandRows.Children.Remove(view.Container); + } + + private FatCommandRowView BuildFatCommandRow(SignalDefinition signal) + { + var row = new Grid { MinWidth = 960, DataContext = signal }; row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(2.25, GridUnitType.Star), MinWidth = 210 }); row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(0.8, GridUnitType.Star), MinWidth = 82 }); row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(0.72, GridUnitType.Star), MinWidth = 70 }); @@ -282,18 +375,48 @@ private FrameworkElement BuildFatCommandRow(SignalDefinition signal) reference.ToolTip = signal.ObjectReference; AddFatCommandCell(row, reference, 0); - var current = FatCommandText(signal.ControlCurrentValue, 11.0, FontWeights.SemiBold); - current.ToolTip = signal.ControlLastResult; + var current = FatCommandText("—", 11.0, FontWeights.SemiBold); + current.SetBinding(TextBlock.TextProperty, new Binding(nameof(SignalDefinition.ControlCurrentValue)) + { + Source = signal, + Mode = BindingMode.OneWay, + Converter = FatCommandTextConverter.Instance + }); + current.SetBinding(FrameworkElement.ToolTipProperty, new Binding(nameof(SignalDefinition.ControlLastResult)) + { + Source = signal, + Mode = BindingMode.OneWay + }); AddFatCommandCell(row, current, 1); - AddFatCommandCell(row, FatCommandText( - string.IsNullOrWhiteSpace(signal.ControlCdc) ? "—" : signal.ControlCdc, - 10.8, - FontWeights.SemiBold), 2); - AddFatCommandCell(row, FatCommandText(FatCommandModelText(signal.ControlModelText), 10.5, FontWeights.SemiBold), 3); + + var cdc = FatCommandText("—", 10.8, FontWeights.SemiBold); + cdc.SetBinding(TextBlock.TextProperty, new Binding(nameof(SignalDefinition.ControlCdc)) + { + Source = signal, + Mode = BindingMode.OneWay, + Converter = FatCommandTextConverter.Instance + }); + AddFatCommandCell(row, cdc, 2); + + var model = FatCommandText("Reading…", 10.5, FontWeights.SemiBold); + model.SetBinding(TextBlock.TextProperty, new Binding(nameof(SignalDefinition.ControlModelText)) + { + Source = signal, + Mode = BindingMode.OneWay, + Converter = FatCommandModelConverter.Instance + }); + model.SetBinding(FrameworkElement.ToolTipProperty, new Binding(nameof(SignalDefinition.ControlModelText)) + { + Source = signal, + Mode = BindingMode.OneWay + }); + AddFatCommandCell(row, model, 3); AddFatCommandCell(row, BuildFatCommandChecks(signal), 4); - AddFatCommandCell(row, BuildFatCommandActions(signal), 5); - return new Border + var actions = BuildFatCommandActions(signal); + AddFatCommandCell(row, actions, 5); + + var container = new Border { Background = Brushes.White, BorderBrush = FatCommandBrush("#E2E8F1"), @@ -303,6 +426,24 @@ private FrameworkElement BuildFatCommandRow(SignalDefinition signal) Margin = new Thickness(0, 0, 0, 6), Child = row }; + + return new FatCommandRowView + { + Container = container, + Grid = row, + Actions = actions + }; + } + + private void RefreshFatCommandActions(SignalDefinition signal) + { + if (!_fatCommandRowViews.TryGetValue(signal, out var view)) + return; + + var replacement = BuildFatCommandActions(signal); + view.Grid.Children.Remove(view.Actions); + AddFatCommandCell(view.Grid, replacement, 5); + view.Actions = replacement; } private FrameworkElement BuildFatCommandChecks(SignalDefinition signal) @@ -342,28 +483,25 @@ private FrameworkElement BuildFatCommandActions(SignalDefinition signal) if (signal.ControlConfirmationPending) { var confirm = FatCommandButton("Confirm", "PrimaryButton"); + BindFatCommandEnabled(confirm, signal); confirm.Click += async (_, _) => await ConfirmFatPositionControlAsync(signal); panel.Children.Add(confirm); var cancel = FatCommandButton("Cancel", "SoftButton"); cancel.Margin = new Thickness(6, 0, 0, 0); - cancel.Click += (_, _) => - { - signal.ClearControlConfirmation(); - RebuildFatCommandRows(); - }; + cancel.Click += (_, _) => signal.ClearControlConfirmation(); panel.Children.Add(cancel); } else { var open = FatCommandButton("Open", "CommandOpenButton"); - open.IsEnabled = signal.ControlSupportsOperate && !signal.ControlIsBusy; + BindFatCommandEnabled(open, signal); open.Click += (_, _) => StageFatPositionControl(signal, "Open [01]", "Open"); panel.Children.Add(open); var close = FatCommandButton("Close", "CommandCloseButton"); close.Margin = new Thickness(6, 0, 0, 0); - close.IsEnabled = signal.ControlSupportsOperate && !signal.ControlIsBusy; + BindFatCommandEnabled(close, signal); close.Click += (_, _) => StageFatPositionControl(signal, "Closed [10]", "Close"); panel.Children.Add(close); } @@ -415,7 +553,7 @@ private FrameworkElement BuildFatCommandActions(SignalDefinition signal) panel.Children.Add(target); var set = FatCommandButton("Set", "PrimaryButton"); - set.IsEnabled = signal.ControlSupportsOperate && !signal.ControlIsBusy; + BindFatCommandEnabled(set, signal); set.Click += async (_, _) => await ExecuteFatQuickControlAsync(signal, signal.ControlSetPointText, "Set"); panel.Children.Add(set); return panel; @@ -428,11 +566,31 @@ private FrameworkElement BuildFatCommandActions(SignalDefinition signal) private Button FatQuickCommandButton(SignalDefinition signal, string label, string requestedValue, string styleKey) { var button = FatCommandButton(label, styleKey); - button.IsEnabled = signal.ControlSupportsOperate && !signal.ControlIsBusy; + BindFatCommandEnabled(button, signal); button.Click += async (_, _) => await ExecuteFatQuickControlAsync(signal, requestedValue, label); return button; } + private static void BindFatCommandEnabled(Button button, SignalDefinition signal) + { + var binding = new MultiBinding + { + Converter = FatCommandCanOperateConverter.Instance, + Mode = BindingMode.OneWay + }; + binding.Bindings.Add(new Binding(nameof(SignalDefinition.ControlSupportsOperate)) + { + Source = signal, + Mode = BindingMode.OneWay + }); + binding.Bindings.Add(new Binding(nameof(SignalDefinition.ControlIsBusy)) + { + Source = signal, + Mode = BindingMode.OneWay + }); + BindingOperations.SetBinding(button, UIElement.IsEnabledProperty, binding); + } + private void StageFatPositionControl(SignalDefinition signal, string requestedValue, string actionLabel) { if (!signal.TryStageControlConfirmation(requestedValue, actionLabel, out var rejectionReason)) @@ -440,7 +598,8 @@ private void StageFatPositionControl(SignalDefinition signal, string requestedVa signal.ControlLastResult = $"Command rejected: {rejectionReason}."; return; } - RebuildFatCommandRows(); + + RefreshFatCommandActions(signal); } private async Task ConfirmFatPositionControlAsync(SignalDefinition signal) @@ -453,8 +612,9 @@ private async Task ConfirmFatPositionControlAsync(SignalDefinition signal) return; } + RefreshFatCommandActions(signal); await engineeringWindow.ExecuteIoFatControlClaimAsync(signal, claim); - RebuildFatCommandRows(); + RefreshFatCommandActions(signal); } private async Task ExecuteFatQuickControlAsync(SignalDefinition signal, string requestedValue, string actionLabel) @@ -468,7 +628,6 @@ private async Task ExecuteFatQuickControlAsync(SignalDefinition signal, string r } await engineeringWindow.ExecuteIoFatControlClaimAsync(signal, claim); - RebuildFatCommandRows(); } private Button FatCommandButton(string text, string styleKey) diff --git a/IoListTestingWindow.P0BenchUx.cs b/IoListTestingWindow.P0BenchUx.cs index 788f8420c..acac468e1 100644 --- a/IoListTestingWindow.P0BenchUx.cs +++ b/IoListTestingWindow.P0BenchUx.cs @@ -3,18 +3,14 @@ using System.Windows.Controls; using System.Windows.Data; using System.Windows.Media; -using System.Windows.Threading; using ArIED61850Tester.Models.IoTesting; namespace ArIED61850Tester; /// -/// Bench-facing P0 UX corrections that are intentionally presentation-only. -/// -/// - LIVE/VALUE1/VALUE2 always render canonical Boolean text without mutating raw evidence. -/// - Normal per-cell Capture buttons are removed from the template; Recapture remains the -/// explicit operator correction path while FatAutoCaptureCoordinator owns normal capture. -/// - Primary session actions and secondary evidence/status actions use two adaptive rows. +/// Bench-facing FAT UX corrections. FAT deliberately disables ToolTips so hover creation +/// cannot compete with report-backed updates on relay benches; Engineering ToolTips live in +/// MainWindow and are untouched. The FAT action strip is kept in one compact adaptive row. /// public partial class IoListTestingWindow { @@ -38,22 +34,19 @@ private static void P0BenchUxLoaded(object sender, RoutedEventArgs e) return; window._p0BenchUxInstalled = true; - window.ContentRendered += window.P0BenchUxContentRendered; window.Closed += window.P0BenchUxClosed; - window.Dispatcher.BeginInvoke( - new Action(window.ApplyP0BenchUx), - DispatcherPriority.ContextIdle); - } - private void P0BenchUxContentRendered(object? sender, EventArgs e) - => Dispatcher.BeginInvoke(new Action(ApplyP0BenchUx), DispatcherPriority.ContextIdle); + // FAT-only. Do not touch MainWindow/Engineering ToolTips. + ToolTipService.SetIsEnabled(window, false); - private void P0BenchUxClosed(object? sender, EventArgs e) - { - ContentRendered -= P0BenchUxContentRendered; - Closed -= P0BenchUxClosed; + // Final FAT V2 schema is installed before first visible render. + window.InstallFatV2WorkspaceUx(); + window.ApplyP0BenchUx(); } + private void P0BenchUxClosed(object? sender, EventArgs e) + => Closed -= P0BenchUxClosed; + private void ApplyP0BenchUx() { ConfigureP0StableFatColumns(); @@ -66,6 +59,22 @@ private void ConfigureP0StableFatColumns() if (_fatSignalsGrid == null) return; + // Removed signals remain in the immutable project/evidence model so they can be + // restored from the dedicated Removed Signals UX, but they are not active FAT rows. + // Collapse them at the row-container layer so a removed point consumes no grid space + // and reappears automatically as soon as IsIncludedInFat becomes true again. + var activeFatRowStyle = new Style(typeof(DataGridRow), _fatSignalsGrid.RowStyle); + activeFatRowStyle.Triggers.Add(new DataTrigger + { + Binding = new Binding(nameof(IoTestPointPlan.IsIncludedInFat)), + Value = false, + Setters = + { + new Setter(UIElement.VisibilityProperty, Visibility.Collapsed) + } + }); + _fatSignalsGrid.RowStyle = activeFatRowStyle; + foreach (var column in _fatSignalsGrid.Columns.OfType()) { var header = column.Header?.ToString()?.Trim() ?? string.Empty; @@ -129,9 +138,6 @@ private static DataTemplate BuildP0EvidenceValueTemplate(FatValueSlot slot) value.SetValue(TextBlock.FontWeightProperty, FontWeights.SemiBold); value.SetValue(TextBlock.FontSizeProperty, 11.4); value.SetValue(TextBlock.TextTrimmingProperty, TextTrimming.CharacterEllipsis); - value.SetBinding(FrameworkElement.ToolTipProperty, new Binding(isValue1 - ? nameof(IoTestPointPlan.Value1EvidenceToolTip) - : nameof(IoTestPointPlan.Value2EvidenceToolTip))); panel.AppendChild(value); var timestamp = new FrameworkElementFactory(typeof(TextBlock)); @@ -143,8 +149,6 @@ private static DataTemplate BuildP0EvidenceValueTemplate(FatValueSlot slot) timestamp.SetValue(TextBlock.ForegroundProperty, new SolidColorBrush(Color.FromRgb(112, 126, 145))); panel.AppendChild(timestamp); - // Intentionally no normal Capture button. Automatic capture is the normal path; - // multi-row/context Recapture remains available for explicit evidence correction. return new DataTemplate { VisualTree = panel }; } @@ -166,36 +170,26 @@ private void ConfigureP0AdaptiveHeaderActions() { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right, - VerticalAlignment = VerticalAlignment.Center + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0) }; + + // Retained as an empty compatibility panel because P2 helpers reference it. All FAT + // actions/status now share the one visible compact row instead of forcing a second row. _p0SecondaryHeaderActions = new WrapPanel { Orientation = Orientation.Horizontal, - HorizontalAlignment = HorizontalAlignment.Right, - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 5, 0, 0) + Visibility = Visibility.Collapsed, + Margin = new Thickness(0) }; foreach (var child in children) - { - if (IsP0SecondaryHeaderAction(child)) - _p0SecondaryHeaderActions.Children.Add(child); - else - _p0PrimaryHeaderActions.Children.Add(child); - } + _p0PrimaryHeaderActions.Children.Add(child); actionPanel.Children.Add(_p0PrimaryHeaderActions); - if (_p0SecondaryHeaderActions.Children.Count > 0) - actionPanel.Children.Add(_p0SecondaryHeaderActions); } - private bool IsP0SecondaryHeaderAction(UIElement element) - => ReferenceEquals(element, WorkspacePreviewToggle) || - ReferenceEquals(element, _timeSyncEvidenceButton) || - ReferenceEquals(element, _comtradeEvidenceButton) || - ReferenceEquals(element, _cleanSessionButton) || - ReferenceEquals(element, _clockSyncGlobalStatusText) || - ReferenceEquals(element, _clockSyncEvidenceText); + private bool IsP0SecondaryHeaderAction(UIElement element) => false; } public sealed class P0FatCanonicalValueConverter : IValueConverter @@ -207,4 +201,4 @@ public object Convert(object value, Type targetType, object parameter, CultureIn public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => Binding.DoNothing; -} \ No newline at end of file +} diff --git a/IoListTestingWindow.P0Lifecycle.cs b/IoListTestingWindow.P0Lifecycle.cs index 398757140..ac76e6299 100644 --- a/IoListTestingWindow.P0Lifecycle.cs +++ b/IoListTestingWindow.P0Lifecycle.cs @@ -2,6 +2,7 @@ using System.IO; using System.Windows; using System.Windows.Threading; +using ArIED61850Tester.Services.IoTesting; namespace ArIED61850Tester; @@ -9,9 +10,9 @@ namespace ArIED61850Tester; /// P0 responsiveness guard for the FAT workspace lifecycle. /// /// Confirmation and evidence-session state transitions remain on the WPF Dispatcher, but -/// the high-rate FAT live projection is quiesced before sealing/saving and durable workspace -/// persistence runs on a worker thread. This prevents a closing window from competing with -/// incoming report traffic while preserving UI-bound session state safety. +/// the high-rate FAT live projection is quiesced before sealing/saving. Close-only durable +/// journal flush/read-back verification and workspace persistence run on worker threads so +/// a closing window never blocks the Dispatcher on disk work. /// public partial class IoListTestingWindow { @@ -91,24 +92,37 @@ private async void P0Window_Closing(object? sender, CancelEventArgs e) engineeringWindow.SuspendIoFatRuntimeProjection(this); IsEnabled = false; - // Let the disabled/closing visual state paint before journal sealing. The actual - // session mutation remains on Dispatcher; only durable workspace I/O is offloaded. + // Let the disabled/closing visual state paint before journal sealing. Session state + // remains Dispatcher-owned; only the physical seal/read-back work is deferred. await Dispatcher.Yield(DispatcherPriority.Render); try { if (Session.HasActiveSessions) { - // Session.StopAll mutates controller/project state and raises UI-bound - // PropertyChanged notifications. Keep that state transition on Dispatcher; - // offloading the whole coordinator would create a cross-thread WPF defect. - var stopAll = Session.StopAll( - "Workspace closed by operator; per-IED evidence journal sealed."); - if (!stopAll.Succeeded) + var stopSucceeded = false; + var stopMessage = string.Empty; + // StopAll still performs controller/project state mutation and UI-bound + // PropertyChanged notifications synchronously on this Dispatcher. The scope + // changes only production journal Dispose(): its durable flush + read-back + // verification are queued to a worker and awaited immediately afterwards. + using (IoTestEvidenceJournal.BeginDeferredSealScope()) + { + var stopAll = Session.StopAll( + "Workspace closed by operator; per-IED evidence journal sealed."); + stopSucceeded = stopAll.Succeeded; + stopMessage = stopAll.Message; + } + + // Do not allow project save or window close until every queued journal has + // completed its physical disk barrier and full hash-chain read-back. + await IoTestEvidenceJournal.AwaitDeferredSealsAsync(); + + if (!stopSucceeded) { MessageBox.Show( this, - stopAll.Message, + stopMessage, "Evidence journals could not be sealed", MessageBoxButton.OK, MessageBoxImage.Error); @@ -116,8 +130,8 @@ private async void P0Window_Closing(object? sender, CancelEventArgs e) } } - // Persistence is pure durable I/O after the session state is sealed and is the - // portion that must not occupy the WPF Dispatcher. + // Persistence is pure durable I/O after every session journal is actually sealed + // and verified, so it also stays away from the WPF Dispatcher. if (Storage != null) await Task.Run(Storage.SaveNow); @@ -128,8 +142,8 @@ private async void P0Window_Closing(object? sender, CancelEventArgs e) { var answer = MessageBox.Show( this, - $"ARSAS could not save the latest IO FAT progress.\n\n{ex.Message}\n\nClose the workspace anyway?", - "Progress save failed", + $"ARSAS could not seal the FAT evidence or save the latest IO FAT progress.\n\n{ex.Message}\n\nClose the workspace anyway?", + "FAT close failed", MessageBoxButton.YesNo, MessageBoxImage.Error, MessageBoxResult.No); diff --git a/IoListTestingWindow.P0RelayBenchHotPath.cs b/IoListTestingWindow.P0RelayBenchHotPath.cs new file mode 100644 index 000000000..dd2d50ce9 --- /dev/null +++ b/IoListTestingWindow.P0RelayBenchHotPath.cs @@ -0,0 +1,306 @@ +using System.Diagnostics; +using System.IO; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using System.Windows.Threading; +using ArIED61850Tester.Models; +using ArIED61850Tester.Models.IoTesting; +using ArIED61850Tester.Services.IoTesting; + +namespace ArIED61850Tester; + +/// +/// Relay-bench hot-path guard. This class handler runs before ordinary Button.Click handlers +/// and owns only the FAT operations that must never enter the legacy synchronous lifecycle. +/// It keeps an already-running Engineering acquisition session untouched and treats FAT as +/// a lightweight evidence consumer. +/// +public partial class IoListTestingWindow +{ + private static readonly bool P0RelayBenchButtonGuardRegistered = RegisterP0RelayBenchButtonGuard(); + + private bool _p0HotPathStartRunning; + private bool _p0HotPathResumeRunning; + private bool _p0HotPathStopRunning; + private bool _p0HotPathCommandRunning; + + private static bool RegisterP0RelayBenchButtonGuard() + { + EventManager.RegisterClassHandler( + typeof(Button), + Button.ClickEvent, + new RoutedEventHandler(P0RelayBenchButton_Click), + handledEventsToo: true); + return true; + } + + private static void P0RelayBenchButton_Click(object sender, RoutedEventArgs e) + { + if (sender is not Button button || FindOwningFatWindow(button) is not IoListTestingWindow window) + return; + + var text = ButtonText(button); + + if (IsFastStartLabel(text) && + window.SelectedIed?.IsLiveMonitoring == true && + window.Session.CanStart) + { + e.Handled = true; + _ = window.StartFatFromSharedLiveSessionAsync(button); + return; + } + + if (text.Equals("Resume", StringComparison.OrdinalIgnoreCase) && window.Session.CanResume) + { + e.Handled = true; + _ = window.ResumeFatWithoutBlockingDispatcherAsync(button); + return; + } + + if (text.Equals("Stop", StringComparison.OrdinalIgnoreCase) && window.Session.CanStop) + { + e.Handled = true; + _ = window.StopFatWithoutBlockingDispatcherAsync(button); + return; + } + + // FAT position Confirm buttons inherit SignalDefinition as DataContext from the row. + // Intercept only those buttons; Engineering command controls are untouched. + if (text.Equals("Confirm", StringComparison.OrdinalIgnoreCase) && + button.DataContext is SignalDefinition signal && + signal.IsPositionControl && + signal.ControlConfirmationPending) + { + e.Handled = true; + _ = window.ConfirmFatPositionWithoutUiBlockAsync(button, signal); + } + } + + private async Task StartFatFromSharedLiveSessionAsync(Button button) + { + if (_p0HotPathStartRunning) + return; + + var ied = SelectedIed; + if (ied == null) + return; + + _p0HotPathStartRunning = true; + var originalOpacity = button.Opacity; + button.IsHitTestVisible = false; + button.Opacity = 0.68; + await Dispatcher.Yield(DispatcherPriority.Render); + + var stopwatch = Stopwatch.StartNew(); + try + { + var preflight = IoTestSessionPreflight.Validate(ied); + if (!preflight.Succeeded) + { + ShowActionResult(preflight, "FAT session scope is not ready"); + return; + } + + // Critical fast path: Engineering is already connected + monitoring. Do NOT run + // the legacy live-preparation routine again, do NOT reconcile/reselect/restart + // reports, and do NOT touch the acquisition cadence. FAT only arms evidence on + // the live rows already proven by the shared process image. + var requested = ied.TestPoints + .Where(point => + point.WorkspaceSelected && + point.IsIncludedInFat && + point.TestEnabled && + point.ImportReady) + .ToList(); + var live = requested + .Where(point => point.LiveBindingState == IoTestLiveBindingState.LivePointReady) + .ToList(); + + if (live.Count == 0) + { + var failure = IoTestSessionActionResult.Failure( + $"{ied.IedName} is monitoring, but none of the {requested.Count} selected FAT row(s) has a proven live point."); + ShowActionResult(failure, "FAT evidence session could not start"); + return; + } + + var result = Session.Start(ied, live); + ShowActionResult(result, "FAT evidence session could not start"); + if (result.Succeeded) + { + var waiting = requested.Count - live.Count; + PreparationStatusText = waiting == 0 + ? $"{ied.IedName} FAT active · attached directly to shared Engineering live data · {live.Count} row(s) armed" + : $"{ied.IedName} FAT active · {live.Count}/{requested.Count} proven live row(s) armed · {waiting} waiting for binding"; + Storage?.ScheduleSave(); + } + else + { + PreparationStatusText = result.Message; + } + + RaiseStatusProperties(); + RaiseSelectedIedContextProperties(); + Trace.WriteLine( + $"[IO FAT P0] shared-live Start/Continue completed in {stopwatch.ElapsedMilliseconds} ms; " + + $"ied={ied.IedName}; requested={requested.Count}; live={live.Count}; succeeded={result.Succeeded}."); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException or ArgumentException) + { + Trace.WriteLine($"[IO FAT P0] shared-live Start/Continue failed after {stopwatch.ElapsedMilliseconds} ms: {ex}"); + MessageBox.Show(this, ex.Message, "FAT session could not start", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + button.Opacity = originalOpacity; + button.IsHitTestVisible = true; + _p0HotPathStartRunning = false; + RaiseSelectedIedContextProperties(); + } + } + + private async Task ResumeFatWithoutBlockingDispatcherAsync(Button button) + { + if (_p0HotPathResumeRunning) + return; + + _p0HotPathResumeRunning = true; + var originalOpacity = button.Opacity; + button.IsHitTestVisible = false; + button.Opacity = 0.68; + await Dispatcher.Yield(DispatcherPriority.Render); + + var stopwatch = Stopwatch.StartNew(); + try + { + var result = Session.Resume(); + ShowActionResult(result, "FAT session could not resume"); + if (result.Succeeded) + Storage?.ScheduleSave(); + RaiseStatusProperties(); + RaiseSelectedIedContextProperties(); + Trace.WriteLine($"[IO FAT P0] Resume completed in {stopwatch.ElapsedMilliseconds} ms; succeeded={result.Succeeded}."); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) + { + MessageBox.Show(this, ex.Message, "FAT session could not resume", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + button.Opacity = originalOpacity; + button.IsHitTestVisible = true; + _p0HotPathResumeRunning = false; + } + } + + private async Task StopFatWithoutBlockingDispatcherAsync(Button button) + { + if (_p0HotPathStopRunning) + return; + + _p0HotPathStopRunning = true; + var originalOpacity = button.Opacity; + button.IsHitTestVisible = false; + button.Opacity = 0.68; + await Dispatcher.Yield(DispatcherPriority.Render); + + var stopwatch = Stopwatch.StartNew(); + try + { + IoTestSessionActionResult result; + using (IoTestEvidenceJournal.BeginDeferredSealScope()) + result = Session.Stop(); + + ShowActionResult(result, "FAT session could not stop"); + if (result.Succeeded) + { + // Queue drain, durable disk barrier and full hash-chain verification never + // run on WPF Dispatcher. Engineering/FAT remain repaintable while sealing. + await IoTestEvidenceJournal.AwaitDeferredSealsAsync(); + if (Storage != null) + await Task.Run(Storage.SaveNow); + } + + RaiseStatusProperties(); + RaiseSelectedIedContextProperties(); + Trace.WriteLine($"[IO FAT P0] Stop completed in {stopwatch.ElapsedMilliseconds} ms; succeeded={result.Succeeded}."); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) + { + Trace.WriteLine($"[IO FAT P0] Stop failed after {stopwatch.ElapsedMilliseconds} ms: {ex}"); + MessageBox.Show(this, ex.Message, "FAT evidence could not be sealed", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + button.Opacity = originalOpacity; + button.IsHitTestVisible = true; + _p0HotPathStopRunning = false; + RaiseSelectedIedContextProperties(); + } + } + + private async Task ConfirmFatPositionWithoutUiBlockAsync(Button button, SignalDefinition signal) + { + if (_p0HotPathCommandRunning || Owner is not MainWindow engineeringWindow) + return; + + if (!signal.TryClaimControlConfirmation(out var claim, out var rejectionReason) || claim == null) + { + signal.ControlLastResult = $"Command rejected: {rejectionReason}."; + return; + } + + _p0HotPathCommandRunning = true; + var originalOpacity = button.Opacity; + button.IsHitTestVisible = false; + button.Opacity = 0.68; + RefreshFatCommandActions(signal); + await Dispatcher.Yield(DispatcherPriority.Render); + + try + { + await engineeringWindow.ExecuteIoFatControlClaimAsync(signal, claim); + + // Command-service feedback is not allowed to overwrite the shared Engineering + // process image. Re-project the newest actual status point after command release, + // then once more after the CSWI stability guard has had time to publish. + engineeringWindow.ReconcileIoFatCommandValueFromSharedProcessImage(signal); + await Task.Delay(450); + engineeringWindow.ReconcileIoFatCommandValueFromSharedProcessImage(signal); + } + finally + { + RefreshFatCommandActions(signal); + button.Opacity = originalOpacity; + button.IsHitTestVisible = true; + _p0HotPathCommandRunning = false; + } + } + + private static bool IsFastStartLabel(string text) + => text.Equals("Start FAT", StringComparison.OrdinalIgnoreCase) || + text.Equals("Continue FAT", StringComparison.OrdinalIgnoreCase) || + text.Equals("Retest FAT", StringComparison.OrdinalIgnoreCase); + + private static string ButtonText(Button button) + => button.Content switch + { + string text => text.Trim(), + TextBlock textBlock => textBlock.Text?.Trim() ?? string.Empty, + _ => button.Content?.ToString()?.Trim() ?? string.Empty + }; + + private static IoListTestingWindow? FindOwningFatWindow(DependencyObject start) + { + DependencyObject? current = start; + while (current != null) + { + if (current is IoListTestingWindow window) + return window; + + current = VisualTreeHelper.GetParent(current) ?? LogicalTreeHelper.GetParent(current); + } + return null; + } +} diff --git a/IoListTestingWindow.P0RuntimeActions.cs b/IoListTestingWindow.P0RuntimeActions.cs new file mode 100644 index 000000000..1e44485be --- /dev/null +++ b/IoListTestingWindow.P0RuntimeActions.cs @@ -0,0 +1,316 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Media; +using System.Windows.Threading; +using ArIED61850Tester.Models.IoTesting; +using ArIED61850Tester.Services.IoTesting; + +namespace ArIED61850Tester; + +/// +/// P0 operator-action responsiveness for the FAT bench. +/// +/// Start/Continue paints a local busy state before the existing safe preparation workflow +/// begins. Resume can emit one evidence record per active point; production journal writes +/// remain ordered and hash-chained, but their visible StreamWriter flush is coalesced into +/// one flush for the complete rebaseline transaction. Stop detaches the evidence journal on +/// the Dispatcher and performs the expensive durable disk barrier + full read-back on a +/// worker. Only the pressed button is muted while work is in flight; the DataGrid, search, +/// IED explorer and window chrome remain interactive. +/// +public partial class IoListTestingWindow +{ + private static readonly bool P0RuntimeActionsRegistered = RegisterP0RuntimeActions(); + + private bool _p0RuntimeActionsInstalled; + private bool _p0StartInProgress; + private bool _p0ResumeInProgress; + private bool _p0StopInProgress; + private Button? _p0StartButton; + private Button? _p0ResumeButton; + private Button? _p0StopButton; + + private static bool RegisterP0RuntimeActions() + { + EventManager.RegisterClassHandler( + typeof(IoListTestingWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(P0RuntimeActions_Loaded)); + return true; + } + + private static void P0RuntimeActions_Loaded(object sender, RoutedEventArgs e) + { + if (sender is not IoListTestingWindow window || window._p0RuntimeActionsInstalled) + return; + + window._p0RuntimeActionsInstalled = true; + window.Dispatcher.BeginInvoke( + new Action(window.InstallP0RuntimeActionHandlers), + DispatcherPriority.Loaded); + } + + private void InstallP0RuntimeActionHandlers() + { + _p0StartButton = FindButtonByContentBindingPath(this, nameof(SelectedStartWorkflowText)); + if (_p0StartButton != null) + { + // Preserve the existing safe Start/Continue workflow, but insert one rendered + // frame before it begins. This avoids the Windows "not responding" impression + // even if the first preparation phase has synchronous setup before its await. + _p0StartButton.Click -= StartSelectedIedSafely_Click; + _p0StartButton.Click += P0StartSelectedIedSafely_Click; + } + + _p0ResumeButton = FindButtonByContent(this, "Resume"); + if (_p0ResumeButton != null) + { + // XAML attached the legacy synchronous handler during InitializeComponent. + // Replace only this edge and leave Session/controller ownership unchanged. + _p0ResumeButton.Click -= ResumeSession_Click; + _p0ResumeButton.Click += P0ResumeSession_Click; + } + + _p0StopButton = FindButtonByContent(this, "Stop"); + if (_p0StopButton != null) + { + _p0StopButton.Click -= StopSession_Click; + _p0StopButton.Click += P0StopSession_Click; + } + + Closed += P0RuntimeActions_Closed; + } + + private void P0RuntimeActions_Closed(object? sender, EventArgs e) + { + Closed -= P0RuntimeActions_Closed; + if (_p0StartButton != null) + { + _p0StartButton.Click -= P0StartSelectedIedSafely_Click; + _p0StartButton = null; + } + if (_p0ResumeButton != null) + { + _p0ResumeButton.Click -= P0ResumeSession_Click; + _p0ResumeButton = null; + } + if (_p0StopButton != null) + { + _p0StopButton.Click -= P0StopSession_Click; + _p0StopButton = null; + } + } + + private async void P0StartSelectedIedSafely_Click(object sender, RoutedEventArgs e) + { + if (_p0StartInProgress || sender is not Button button) + return; + + var targetIed = SelectedIed; + _p0StartInProgress = true; + var originalOpacity = button.Opacity; + button.IsHitTestVisible = false; + button.Opacity = 0.68; + + // Content stays bound to SelectedStartWorkflowText. SetPreparingIed in the existing + // workflow therefore changes the same button naturally to "Connecting …" without + // replacing/breaking its binding. + await Dispatcher.Yield(DispatcherPriority.Render); + var stopwatch = Stopwatch.StartNew(); + try + { + StartSelectedIedSafely_Click(sender, e); + await WaitForP0StartWorkflowCompletionAsync(targetIed); + Trace.WriteLine( + $"[IO FAT P0] Start/Continue workflow completed in {stopwatch.ElapsedMilliseconds} ms; " + + $"ied={targetIed?.IedName ?? ""}; state={Session.State}; active={Session.IsSessionActive}."); + } + finally + { + button.Opacity = originalOpacity; + button.IsHitTestVisible = true; + _p0StartInProgress = false; + RaiseSelectedIedContextProperties(); + } + } + + private async Task WaitForP0StartWorkflowCompletionAsync(IoTestIedPlan? targetIed) + { + // The legacy handler is async void. Yield once so it can enter SetPreparingIed and + // reach its first asynchronous acquisition await. If preflight returned early there + // is nothing to wait for. + await Dispatcher.Yield(DispatcherPriority.Background); + if (targetIed == null || !targetIed.IsPreparing) + return; + + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + PropertyChangedEventHandler? handler = null; + handler = (_, args) => + { + if (args.PropertyName == nameof(IoTestIedPlan.IsPreparing) && !targetIed.IsPreparing) + completion.TrySetResult(true); + }; + + targetIed.PropertyChanged += handler; + try + { + if (!targetIed.IsPreparing) + return; + await completion.Task; + } + finally + { + targetIed.PropertyChanged -= handler; + } + } + + private async void P0ResumeSession_Click(object sender, RoutedEventArgs e) + { + if (_p0ResumeInProgress || sender is not Button button) + return; + + _p0ResumeInProgress = true; + var originalContent = button.Content; + var originalOpacity = button.Opacity; + button.Content = "Continuing…"; + button.IsHitTestVisible = false; + button.Opacity = 0.68; + + // Paint the busy state before any controller/project PropertyChanged burst starts. + await Dispatcher.Yield(DispatcherPriority.Render); + var stopwatch = Stopwatch.StartNew(); + try + { + IoTestSessionActionResult result; + using (IoTestEvidenceJournal.BeginCoalescedVisibleFlushScope()) + result = Session.Resume(); + + ShowActionResult(result, "FAT session could not resume"); + if (result.Succeeded) + Storage?.ScheduleSave(); + + RaiseStatusProperties(); + RaiseSelectedIedContextProperties(); + Trace.WriteLine($"[IO FAT P0] Resume completed in {stopwatch.ElapsedMilliseconds} ms; succeeded={result.Succeeded}."); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) + { + Trace.WriteLine($"[IO FAT P0] Resume failed after {stopwatch.ElapsedMilliseconds} ms: {ex}"); + MessageBox.Show( + this, + ex.Message, + "FAT session could not resume", + MessageBoxButton.OK, + MessageBoxImage.Error); + } + finally + { + button.Content = originalContent; + button.Opacity = originalOpacity; + button.IsHitTestVisible = true; + _p0ResumeInProgress = false; + RaiseSelectedIedContextProperties(); + } + } + + private async void P0StopSession_Click(object sender, RoutedEventArgs e) + { + if (_p0StopInProgress || sender is not Button button) + return; + + _p0StopInProgress = true; + var originalContent = button.Content; + var originalOpacity = button.Opacity; + button.Content = "Stopping…"; + button.IsHitTestVisible = false; + button.Opacity = 0.68; + + // Paint immediately. Do not disable the Window: scrolling and inspection remain + // available while the detached evidence file is being durably sealed/read back. + await Dispatcher.Yield(DispatcherPriority.Render); + var stopwatch = Stopwatch.StartNew(); + try + { + IoTestSessionActionResult result; + using (IoTestEvidenceJournal.BeginDeferredSealScope()) + result = Session.Stop(); + + ShowActionResult(result, "FAT session could not stop"); + if (result.Succeeded) + { + // Stop() has already made the controller/session state immutable. Await the + // physical disk barrier and complete hash-chain read-back off Dispatcher. + await IoTestEvidenceJournal.AwaitDeferredSealsAsync(); + if (Storage != null) + await Task.Run(Storage.SaveNow); + } + + RaiseStatusProperties(); + RaiseSelectedIedContextProperties(); + Trace.WriteLine($"[IO FAT P0] Stop completed in {stopwatch.ElapsedMilliseconds} ms; succeeded={result.Succeeded}."); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) + { + Trace.WriteLine($"[IO FAT P0] Stop failed after {stopwatch.ElapsedMilliseconds} ms: {ex}"); + MessageBox.Show( + this, + ex.Message, + "FAT evidence could not be sealed", + MessageBoxButton.OK, + MessageBoxImage.Error); + } + finally + { + button.Content = originalContent; + button.Opacity = originalOpacity; + button.IsHitTestVisible = true; + _p0StopInProgress = false; + RaiseSelectedIedContextProperties(); + } + } + + private static Button? FindButtonByContentBindingPath(DependencyObject root, string path) + { + var childCount = VisualTreeHelper.GetChildrenCount(root); + for (var index = 0; index < childCount; index++) + { + var child = VisualTreeHelper.GetChild(root, index); + if (child is Button button) + { + var binding = BindingOperations.GetBinding(button, ContentControl.ContentProperty); + if (string.Equals(binding?.Path?.Path, path, StringComparison.Ordinal)) + return button; + } + + var nested = FindButtonByContentBindingPath(child, path); + if (nested != null) + return nested; + } + + return null; + } + + private static Button? FindButtonByContent(DependencyObject root, string content) + { + var childCount = VisualTreeHelper.GetChildrenCount(root); + for (var index = 0; index < childCount; index++) + { + var child = VisualTreeHelper.GetChild(root, index); + if (child is Button button && + string.Equals(button.Content?.ToString(), content, StringComparison.Ordinal)) + { + return button; + } + + var nested = FindButtonByContent(child, content); + if (nested != null) + return nested; + } + + return null; + } +} diff --git a/IoListTestingWindow.P1NotificationThrottle.cs b/IoListTestingWindow.P1NotificationThrottle.cs new file mode 100644 index 000000000..e3d2497c0 --- /dev/null +++ b/IoListTestingWindow.P1NotificationThrottle.cs @@ -0,0 +1,87 @@ +using System.ComponentModel; +using System.Threading; +using System.Windows; +using System.Windows.Threading; + +namespace ArIED61850Tester; + +/// +/// P1 FAT UI notification gate. +/// +/// The multi-session coordinator exposes fine-grained property changes, but the legacy FAT +/// window used to translate every one of those changes into a full window/property refresh. +/// During Start/Continue, automatic Value 1/Value 2 capture and Stop that multiplied a small +/// evidence update into thousands of WPF binding/layout invalidations. Coalesce those legacy +/// wrapper notifications to at most one refresh per Dispatcher turn. Direct bindings to +/// Session.* continue to receive their normal targeted PropertyChanged events. +/// +public partial class IoListTestingWindow +{ + private static readonly bool P1NotificationThrottleRegistered = RegisterP1NotificationThrottle(); + + private bool _p1NotificationThrottleInstalled; + private int _p1WindowRefreshScheduled; + + private static bool RegisterP1NotificationThrottle() + { + EventManager.RegisterClassHandler( + typeof(IoListTestingWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(P1NotificationThrottle_Loaded)); + return true; + } + + private static void P1NotificationThrottle_Loaded(object sender, RoutedEventArgs e) + { + if (sender is not IoListTestingWindow window || + window._p1NotificationThrottleInstalled || + !ReferenceEquals(e.OriginalSource, window)) + { + return; + } + + window._p1NotificationThrottleInstalled = true; + + // The constructor attached the compatibility handler before InitializeComponent. + // Replace only that fan-out edge. Session itself remains fully observable, so nested + // bindings such as Session.CanStop / Session.StateText are not delayed or hidden. + window.Session.PropertyChanged -= window.Session_PropertyChanged; + window.Session.PropertyChanged += window.P1Session_PropertyChanged; + window.Closed += window.P1NotificationThrottle_Closed; + } + + private void P1Session_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (Interlocked.Exchange(ref _p1WindowRefreshScheduled, 1) != 0) + return; + + try + { + Dispatcher.BeginInvoke( + new Action(() => + { + Interlocked.Exchange(ref _p1WindowRefreshScheduled, 0); + if (!IsLoaded) + return; + + // One coherent recompute is enough for the window-owned derived labels. + // Keep it below input/render priority so command buttons and scrolling + // never wait behind evidence metadata churn. + RaiseStatusProperties(); + RaiseSelectedIedContextProperties(); + }), + DispatcherPriority.Background); + } + catch (InvalidOperationException) + { + Interlocked.Exchange(ref _p1WindowRefreshScheduled, 0); + } + } + + private void P1NotificationThrottle_Closed(object? sender, EventArgs e) + { + Closed -= P1NotificationThrottle_Closed; + Session.PropertyChanged -= P1Session_PropertyChanged; + Interlocked.Exchange(ref _p1WindowRefreshScheduled, 0); + } +} diff --git a/IoListTestingWindow.P2CompactHeader.cs b/IoListTestingWindow.P2CompactHeader.cs index 9d899819c..9e75b1c9f 100644 --- a/IoListTestingWindow.P2CompactHeader.cs +++ b/IoListTestingWindow.P2CompactHeader.cs @@ -1,5 +1,9 @@ +using System.Globalization; using System.Windows; using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Media; +using System.Windows.Threading; namespace ArIED61850Tester; @@ -33,6 +37,60 @@ private void ConfigureP2CompactHeader() ApplyP2CompactStatusMetrics(_clockSyncGlobalStatusText, 118, FontWeights.Medium); ApplyP2CompactStatusMetrics(_clockSyncEvidenceText, 188, FontWeights.Normal); + + // The selected-IED subtitle used to render the verbose LiveStatusText and was + // routinely clipped by the operational buttons. Rebind only this presentation line + // to a compact status; keep the complete legacy summary as its tooltip. + Dispatcher.BeginInvoke( + DispatcherPriority.Loaded, + new Action(ConfigureP2SelectedIedSubtitle)); + } + + private void ConfigureP2SelectedIedSubtitle() + { + var text = FindBoundTextBlock(this, nameof(SelectedIedSummary)); + if (text == null) + return; + + var compact = new MultiBinding + { + Mode = BindingMode.OneWay, + Converter = CompactSelectedIedSummaryConverter.Instance + }; + compact.Bindings.Add(new Binding("SelectedIed.IpAddress") { Mode = BindingMode.OneWay }); + compact.Bindings.Add(new Binding("SelectedIed.EnabledCount") { Mode = BindingMode.OneWay }); + compact.Bindings.Add(new Binding("SelectedIed.LiveStatusText") { Mode = BindingMode.OneWay }); + BindingOperations.SetBinding(text, TextBlock.TextProperty, compact); + BindingOperations.SetBinding( + text, + FrameworkElement.ToolTipProperty, + new Binding(nameof(SelectedIedSummary)) { Mode = BindingMode.OneWay }); + + text.FontSize = 10.2; + text.TextWrapping = TextWrapping.NoWrap; + text.TextTrimming = TextTrimming.CharacterEllipsis; + text.MaxWidth = 330; + } + + private static TextBlock? FindBoundTextBlock(DependencyObject root, string bindingPath) + { + var count = VisualTreeHelper.GetChildrenCount(root); + for (var index = 0; index < count; index++) + { + var child = VisualTreeHelper.GetChild(root, index); + if (child is TextBlock text) + { + var binding = BindingOperations.GetBinding(text, TextBlock.TextProperty); + if (string.Equals(binding?.Path?.Path, bindingPath, StringComparison.Ordinal)) + return text; + } + + var nested = FindBoundTextBlock(child, bindingPath); + if (nested != null) + return nested; + } + + return null; } private static void ApplyP2CompactButtonMetrics(Button button, bool secondary) @@ -64,4 +122,54 @@ private static void ApplyP2CompactStatusMetrics( text.FontWeight = fontWeight; text.FontSize = 10.2; } -} \ No newline at end of file + + private sealed class CompactSelectedIedSummaryConverter : IMultiValueConverter + { + public static readonly CompactSelectedIedSummaryConverter Instance = new(); + + public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) + { + var ip = Value(values, 0); + var count = Value(values, 1); + var status = CompactLiveStatus(Value(values, 2)); + + if (string.IsNullOrWhiteSpace(ip)) + return "Select an imported IED"; + + return $"{ip} · {count} pts · {status}"; + } + + public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) + => throw new NotSupportedException(); + + private static string Value(object[] values, int index) + => index < values.Length && values[index] != DependencyProperty.UnsetValue + ? values[index]?.ToString()?.Trim() ?? string.Empty + : string.Empty; + + private static string CompactLiveStatus(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return "OFFLINE"; + + if (value.Contains("Monitoring", StringComparison.OrdinalIgnoreCase)) + { + if (value.Contains("Static", StringComparison.OrdinalIgnoreCase)) + return "MON · Static DS"; + if (value.Contains("Report", StringComparison.OrdinalIgnoreCase)) + return "MON · Report"; + return "MON"; + } + + if (value.Contains("Reconnect", StringComparison.OrdinalIgnoreCase)) + return "RECONNECT"; + if (value.Contains("Offline", StringComparison.OrdinalIgnoreCase) || + value.Contains("Disconnect", StringComparison.OrdinalIgnoreCase)) + return "OFFLINE"; + if (value.Contains("Connected", StringComparison.OrdinalIgnoreCase)) + return "CONNECTED"; + + return value.Length <= 18 ? value : value[..18] + "…"; + } + } +} diff --git a/IoListTestingWindow.ProfessionalReportUx.cs b/IoListTestingWindow.ProfessionalReportUx.cs new file mode 100644 index 000000000..a82ac9675 --- /dev/null +++ b/IoListTestingWindow.ProfessionalReportUx.cs @@ -0,0 +1,402 @@ +using System.ComponentModel; +using System.IO; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Data; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Shapes; +using System.Windows.Threading; +using ArIED61850Tester.Models.IoTesting; +using ArIED61850Tester.Services.IoTesting; +using Microsoft.Win32; + +namespace ArIED61850Tester; + +/// +/// Customer-facing FAT report polish layered on the existing relay-bench-safe workspace. +/// Keeps report actions per IED, replaces ambiguous Unicode preview glyphs with Lucide-style +/// vectors, and adds a targeted IED-card close action without disturbing sibling sessions. +/// +public partial class IoListTestingWindow +{ + private static readonly bool ProfessionalReportUxRegistered = RegisterProfessionalReportUx(); + private bool _professionalReportUxInstalled; + private int _professionalReportUxInstallAttempts; + private readonly HashSet _iedCardStopsInProgress = new(); + + private enum PreviewLucideIcon + { + Printer, + Copy, + Minus, + Plus, + Maximize2, + ChevronLeft, + ChevronRight, + RefreshCw, + Save, + X + } + + private static bool RegisterProfessionalReportUx() + { + EventManager.RegisterClassHandler( + typeof(IoListTestingWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(ProfessionalReportUx_Loaded)); + return true; + } + + private static void ProfessionalReportUx_Loaded(object sender, RoutedEventArgs e) + { + if (sender is not IoListTestingWindow window || window._professionalReportUxInstalled) + return; + + window.Dispatcher.BeginInvoke( + new Action(window.InstallProfessionalReportUx), + DispatcherPriority.ContextIdle); + } + + private void InstallProfessionalReportUx() + { + if (_professionalReportUxInstalled) + return; + if (_printPreviewHost == null) + { + if (++_professionalReportUxInstallAttempts < 8) + Dispatcher.BeginInvoke(new Action(InstallProfessionalReportUx), DispatcherPriority.ContextIdle); + return; + } + + _professionalReportUxInstalled = true; + PolishPreviewToolbar(); + ClarifyCombinedPdfAction(); + + FatIedList.ItemContainerGenerator.StatusChanged += IedCardGenerator_StatusChanged; + Session.PropertyChanged += ProfessionalReportUx_SessionPropertyChanged; + Closed += ProfessionalReportUx_Closed; + InstallIedCardCloseButtons(); + } + + private void PolishPreviewToolbar() + { + if (_printPreviewHost == null) + return; + + var iconByToolTip = new Dictionary(StringComparer.Ordinal) + { + ["Print native report"] = PreviewLucideIcon.Printer, + ["Copy selected report text"] = PreviewLucideIcon.Copy, + ["Zoom out"] = PreviewLucideIcon.Minus, + ["Zoom in"] = PreviewLucideIcon.Plus, + ["Fit report page to width"] = PreviewLucideIcon.Maximize2, + ["Previous page"] = PreviewLucideIcon.ChevronLeft, + ["Next page"] = PreviewLucideIcon.ChevronRight, + ["Refresh from current IED evidence"] = PreviewLucideIcon.RefreshCw + }; + + foreach (var button in VisualDescendants