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