diff --git a/FatVerificationWindow.cs b/FatVerificationWindow.cs new file mode 100644 index 000000000..1147b02fd --- /dev/null +++ b/FatVerificationWindow.cs @@ -0,0 +1,579 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Input; +using System.Windows.Media; +using ArIED61850Tester.Models.IoTesting; +using ArIED61850Tester.Services.IoTesting; + +namespace ArIED61850Tester; + +/// +/// Additive FAT v2 workspace. It deliberately does not reuse the legacy ON/OFF grid because +/// digital, analog, and fallback DataSet members all retain generic Value 1 / Value 2 semantics. +/// +public sealed class FatVerificationWindow : Window +{ + private readonly FatSclWorkspaceLaunchResult _launch; + private readonly FatLiveCaptureCoordinator _capture; + private readonly Dictionary _rowsById; + private readonly TextBox _searchBox = new(); + private readonly TextBlock _summary = new(); + private readonly Button _removedButton = new(); + private readonly DataGrid _grid = new(); + + public FatVerificationWindow(FatSclWorkspaceLaunchResult launch) + { + _launch = launch ?? throw new ArgumentNullException(nameof(launch)); + _capture = new FatLiveCaptureCoordinator(launch.Project); + _rowsById = launch.Project.Signals.ToDictionary( + signal => signal.SignalId, + signal => new FatVerificationRow(signal, _capture, ShowCaptureError), + StringComparer.OrdinalIgnoreCase); + + Title = "ARSAS FAT v2 — DataSet Verification"; + Width = 1440; + Height = 800; + MinWidth = 1040; + MinHeight = 620; + WindowStartupLocation = WindowStartupLocation.CenterOwner; + Background = Brush("#F4F7FB"); + Content = BuildLayout(); + RefreshRows(); + } + + public long EvidenceHistoryCount => _capture.History.Count; + + /// + /// Receives the existing monitor-runtime image. No second polling loop is created for FAT. + /// Exact runtime reference plus IED identity controls fan-out to static membership rows. + /// + public void ApplyLiveObservation( + string runtimeReference, + IEnumerable iedAliases, + string previousRawValue, + bool isValueEdge, + FatLiveValueObservation observation) + { + var aliases = iedAliases?.ToArray() ?? Array.Empty(); + if (!Dispatcher.CheckAccess()) + { + Dispatcher.BeginInvoke(new Action(() => ApplyLiveObservation( + runtimeReference, + aliases, + previousRawValue, + isValueEdge, + observation))); + return; + } + + var matched = _capture.Observe( + runtimeReference, + aliases, + previousRawValue, + isValueEdge, + observation); + foreach (var signal in matched) + { + if (_rowsById.TryGetValue(signal.SignalId, out var row)) + row.Refresh(); + } + + if (matched.Count > 0) + RefreshSummary(); + } + + private UIElement BuildLayout() + { + var root = new Grid { Margin = new Thickness(20) }; + root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + root.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); + + var heading = new Grid { Margin = new Thickness(0, 0, 0, 14) }; + heading.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + heading.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + var title = new StackPanel(); + title.Children.Add(new TextBlock + { + Text = "FAT v2 · STATIC DATASET VERIFICATION", + FontSize = 11, + FontWeight = FontWeights.SemiBold, + Foreground = Brush("#2563EB") + }); + title.Children.Add(new TextBlock + { + Text = "Value 1 / Value 2 evidence workspace", + FontSize = 24, + FontWeight = FontWeights.SemiBold, + Foreground = Brush("#172033"), + Margin = new Thickness(0, 3, 0, 3) + }); + title.Children.Add(_summary); + Grid.SetColumn(title, 0); + heading.Children.Add(title); + + var sourceBadge = new Border + { + Padding = new Thickness(12, 8, 12, 8), + CornerRadius = new CornerRadius(10), + Background = Brushes.White, + BorderBrush = Brush("#D9E2EF"), + BorderThickness = new Thickness(1), + Child = new TextBlock + { + Text = $"{_launch.SourceFiles.Count} SCL source(s) · {_launch.SourceSetSha256[..12]}", + FontSize = 12, + Foreground = Brush("#4B5D73") + } + }; + Grid.SetColumn(sourceBadge, 1); + heading.Children.Add(sourceBadge); + Grid.SetRow(heading, 0); + root.Children.Add(heading); + + var toolbar = new Grid { Margin = new Thickness(0, 0, 0, 10) }; + toolbar.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + toolbar.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + _searchBox.Height = 34; + _searchBox.MaxWidth = 560; + _searchBox.HorizontalAlignment = HorizontalAlignment.Left; + _searchBox.VerticalContentAlignment = VerticalAlignment.Center; + _searchBox.Padding = new Thickness(10, 0, 10, 0); + _searchBox.ToolTip = "Search IED, DataSet, signal, FC, type, or current value"; + _searchBox.TextChanged += (_, _) => RefreshRows(); + Grid.SetColumn(_searchBox, 0); + toolbar.Children.Add(_searchBox); + + _removedButton.MinWidth = 150; + _removedButton.Height = 34; + _removedButton.Padding = new Thickness(12, 0, 12, 0); + _removedButton.Click += RemovedSignals_Click; + Grid.SetColumn(_removedButton, 1); + toolbar.Children.Add(_removedButton); + Grid.SetRow(toolbar, 1); + root.Children.Add(toolbar); + + _grid.AutoGenerateColumns = false; + _grid.IsReadOnly = true; + _grid.CanUserAddRows = false; + _grid.CanUserDeleteRows = false; + _grid.SelectionMode = DataGridSelectionMode.Single; + _grid.SelectionUnit = DataGridSelectionUnit.FullRow; + _grid.HeadersVisibility = DataGridHeadersVisibility.Column; + _grid.GridLinesVisibility = DataGridGridLinesVisibility.Horizontal; + _grid.BorderBrush = Brush("#D9E2EF"); + _grid.BorderThickness = new Thickness(1); + _grid.Background = Brushes.White; + _grid.RowBackground = Brushes.White; + _grid.AlternatingRowBackground = Brush("#FAFCFF"); + _grid.RowHeight = 36; + _grid.ColumnHeaderHeight = 36; + _grid.PreviewMouseRightButtonDown += Grid_PreviewMouseRightButtonDown; + _grid.Columns.Add(TextColumn("IED", nameof(FatVerificationRow.IedName), 115)); + _grid.Columns.Add(TextColumn("DataSet", nameof(FatVerificationRow.DataSetReference), 190)); + _grid.Columns.Add(TextColumn("#", nameof(FatVerificationRow.DataSetMemberIndex), 42)); + _grid.Columns.Add(TextColumn("Signal", nameof(FatVerificationRow.StaticMemberReference), 300)); + _grid.Columns.Add(TextColumn("FC", nameof(FatVerificationRow.FunctionalConstraint), 48)); + _grid.Columns.Add(TextColumn("Kind", nameof(FatVerificationRow.SignalKind), 75)); + _grid.Columns.Add(TextColumn("Current", nameof(FatVerificationRow.CurrentValue), 120)); + _grid.Columns.Add(EvidenceColumn( + "Value 1", + nameof(FatVerificationRow.Value1Display), + nameof(FatVerificationRow.CaptureValue1Command), + 155)); + _grid.Columns.Add(EvidenceColumn( + "Value 2", + nameof(FatVerificationRow.Value2Display), + nameof(FatVerificationRow.CaptureValue2Command), + 155)); + var remove = new MenuItem { Header = "Remove from FAT" }; + remove.Click += RemoveSelected_Click; + _grid.ContextMenu = new ContextMenu { Items = { remove } }; + + Grid.SetRow(_grid, 2); + root.Children.Add(_grid); + return root; + } + + private static DataGridTextColumn TextColumn(string header, string path, double width) + => new() + { + Header = header, + Binding = new Binding(path), + Width = new DataGridLength(width) + }; + + private static DataGridTemplateColumn EvidenceColumn( + string header, + string valuePath, + string commandPath, + double width) + { + var panel = new FrameworkElementFactory(typeof(StackPanel)); + panel.SetValue(StackPanel.OrientationProperty, Orientation.Horizontal); + panel.SetValue(FrameworkElement.VerticalAlignmentProperty, VerticalAlignment.Center); + + var value = new FrameworkElementFactory(typeof(TextBlock)); + value.SetValue(TextBlock.VerticalAlignmentProperty, VerticalAlignment.Center); + value.SetValue(TextBlock.MinWidthProperty, 84d); + value.SetBinding(TextBlock.TextProperty, new Binding(valuePath)); + panel.AppendChild(value); + + var capture = new FrameworkElementFactory(typeof(Button)); + capture.SetValue(ContentControl.ContentProperty, "✓"); + capture.SetValue(FrameworkElement.WidthProperty, 28d); + capture.SetValue(FrameworkElement.HeightProperty, 24d); + capture.SetValue(FrameworkElement.MarginProperty, new Thickness(5, 0, 0, 0)); + capture.SetValue(FrameworkElement.ToolTipProperty, $"Capture current live reading as {header}"); + capture.SetBinding(Button.CommandProperty, new Binding(commandPath)); + capture.SetBinding(UIElement.VisibilityProperty, new Binding(nameof(FatVerificationRow.ManualCaptureVisibility))); + panel.AppendChild(capture); + + return new DataGridTemplateColumn + { + Header = header, + Width = new DataGridLength(width), + CellTemplate = new DataTemplate { VisualTree = panel } + }; + } + + private void RefreshRows() + { + foreach (var row in _rowsById.Values) + row.Refresh(); + + var query = _searchBox.Text.Trim(); + IEnumerable rows = _rowsById.Values + .Where(row => row.Signal.IsIncludedInFat); + if (query.Length > 0) + { + rows = rows.Where(row => + Contains(row.IedName, query) || + Contains(row.AccessPointName, query) || + Contains(row.DataSetReference, query) || + Contains(row.StaticMemberReference, query) || + Contains(row.RuntimeReference, query) || + Contains(row.FunctionalConstraint, query) || + Contains(row.DataType, query) || + Contains(row.SignalKind.ToString(), query) || + Contains(row.CurrentValue, query)); + } + + _grid.ItemsSource = rows + .OrderBy(row => row.DataSetReference, StringComparer.OrdinalIgnoreCase) + .ThenBy(row => row.DataSetMemberIndex) + .ToArray(); + RefreshSummary(); + } + + private void RefreshSummary() + { + var all = _launch.Project.Signals; + var live = all.Count(signal => _capture.GetLatestObservation(signal.SignalId) != null); + _summary.Text = $"{_launch.Project.IncludedSignals.Count} included · {_launch.Project.RemovedSignals.Count} removed · " + + $"{all.Count(signal => signal.SignalKind == FatSignalKind.Discrete)} digital · " + + $"{all.Count(signal => signal.SignalKind == FatSignalKind.Analog)} analog · " + + $"{all.Count(signal => signal.SignalKind == FatSignalKind.Other)} other · " + + $"{live} live · {_capture.History.Count} evidence history"; + _summary.FontSize = 12.5; + _summary.Foreground = Brush("#64748B"); + _removedButton.Content = $"Removed Signals ({_launch.Project.RemovedSignals.Count})"; + } + + private void RemoveSelected_Click(object sender, RoutedEventArgs e) + { + if (_grid.SelectedItem is not FatVerificationRow row) + return; + _launch.Project.RemoveSignal(row.Signal.SignalId); + row.Refresh(); + RefreshRows(); + } + + private void RemovedSignals_Click(object sender, RoutedEventArgs e) + { + var dialog = new FatRemovedSignalsWindow(_launch.Project) { Owner = this }; + dialog.ShowDialog(); + RefreshRows(); + } + + private void ShowCaptureError(string message) + { + MessageBox.Show( + this, + message, + "FAT capture unavailable", + MessageBoxButton.OK, + MessageBoxImage.Information); + } + + private void Grid_PreviewMouseRightButtonDown(object sender, MouseButtonEventArgs e) + { + var row = FindAncestor(e.OriginalSource as DependencyObject); + if (row != null) + _grid.SelectedItem = row.Item; + } + + private static T? FindAncestor(DependencyObject? current) where T : DependencyObject + { + while (current != null) + { + if (current is T result) + return result; + current = VisualTreeHelper.GetParent(current); + } + return null; + } + + private static bool Contains(string? value, string query) + => (value ?? string.Empty).Contains(query, StringComparison.OrdinalIgnoreCase); + + private static Brush Brush(string value) + => new SolidColorBrush((Color)ColorConverter.ConvertFromString(value)); + + private sealed class FatVerificationRow : INotifyPropertyChanged + { + private readonly FatLiveCaptureCoordinator _capture; + private readonly Action _showError; + private readonly RelayCommand _captureValue1; + private readonly RelayCommand _captureValue2; + + public FatVerificationRow( + FatVerificationSignal signal, + FatLiveCaptureCoordinator capture, + Action showError) + { + Signal = signal; + _capture = capture; + _showError = showError; + _captureValue1 = new RelayCommand( + () => Capture(FatValueSlot.Value1), + CanCapture); + _captureValue2 = new RelayCommand( + () => Capture(FatValueSlot.Value2), + CanCapture); + } + + public event PropertyChangedEventHandler? PropertyChanged; + + public FatVerificationSignal Signal { get; } + public string IedName => Signal.IedName; + public string AccessPointName => Signal.AccessPointName; + public string DataSetReference => Signal.DataSetReference; + public int DataSetMemberIndex => Signal.DataSetMemberIndex; + public string StaticMemberReference => Signal.StaticMemberReference; + public string RuntimeReference => Signal.RuntimeReference; + public string FunctionalConstraint => Signal.FunctionalConstraint; + public string DataType => Signal.DataType; + public FatSignalKind SignalKind => Signal.SignalKind; + public string CurrentValue => _capture.GetLatestObservation(Signal.SignalId)?.RawValue ?? "—"; + public string Value1Display => Signal.Value1Evidence?.RawValue ?? "—"; + public string Value2Display => Signal.Value2Evidence?.RawValue ?? "—"; + public Visibility ManualCaptureVisibility => + Signal.CaptureMode == FatCaptureMode.OperatorSnapshot + ? Visibility.Visible + : Visibility.Collapsed; + public ICommand CaptureValue1Command => _captureValue1; + public ICommand CaptureValue2Command => _captureValue2; + + public void Refresh() + { + Raise(nameof(CurrentValue)); + Raise(nameof(Value1Display)); + Raise(nameof(Value2Display)); + Raise(nameof(ManualCaptureVisibility)); + _captureValue1.RaiseCanExecuteChanged(); + _captureValue2.RaiseCanExecuteChanged(); + } + + private bool CanCapture() + => Signal.IsIncludedInFat && + Signal.CaptureMode == FatCaptureMode.OperatorSnapshot && + _capture.GetLatestObservation(Signal.SignalId) != null; + + private void Capture(FatValueSlot slot) + { + try + { + _capture.CaptureOperatorSnapshot(Signal.SignalId, slot); + Refresh(); + } + catch (Exception ex) when (ex is InvalidOperationException or KeyNotFoundException) + { + _showError(ex.Message); + } + } + + private void Raise([CallerMemberName] string? propertyName = null) + => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + private sealed class RelayCommand : ICommand + { + private readonly Action _execute; + private readonly Func _canExecute; + + public RelayCommand(Action execute, Func canExecute) + { + _execute = execute ?? throw new ArgumentNullException(nameof(execute)); + _canExecute = canExecute ?? throw new ArgumentNullException(nameof(canExecute)); + } + + public event EventHandler? CanExecuteChanged; + public bool CanExecute(object? parameter) => _canExecute(); + public void Execute(object? parameter) => _execute(); + public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty); + } +} + +internal sealed class FatRemovedSignalsWindow : Window +{ + private sealed class RemovedSignalRow + { + public required FatVerificationSignal Signal { get; init; } + public bool IsSelected { get; set; } + public string IedName => Signal.IedName; + public string DataSetReference => Signal.DataSetReference; + public int DataSetMemberIndex => Signal.DataSetMemberIndex; + public string StaticMemberReference => Signal.StaticMemberReference; + public FatSignalKind SignalKind => Signal.SignalKind; + public string SignalId => Signal.SignalId; + } + + private readonly FatVerificationProject _project; + private readonly List _rows; + private readonly TextBox _search = new(); + private readonly DataGrid _grid = new(); + + public FatRemovedSignalsWindow(FatVerificationProject project) + { + _project = project ?? throw new ArgumentNullException(nameof(project)); + _rows = project.RemovedSignals.Select(signal => new RemovedSignalRow { Signal = signal }).ToList(); + Title = "Removed Signals"; + Width = 940; + Height = 560; + MinWidth = 760; + MinHeight = 460; + WindowStartupLocation = WindowStartupLocation.CenterOwner; + Content = BuildLayout(); + RefreshRows(); + } + + private UIElement BuildLayout() + { + var root = new Grid { Margin = new Thickness(18) }; + root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + root.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); + root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + + var top = new DockPanel { Margin = new Thickness(0, 0, 0, 10) }; + _search.Width = 380; + _search.Height = 32; + _search.VerticalContentAlignment = VerticalAlignment.Center; + _search.Padding = new Thickness(8, 0, 8, 0); + _search.ToolTip = "Search removed signals"; + _search.TextChanged += (_, _) => RefreshRows(); + DockPanel.SetDock(_search, Dock.Left); + top.Children.Add(_search); + Grid.SetRow(top, 0); + root.Children.Add(top); + + _grid.AutoGenerateColumns = false; + _grid.CanUserAddRows = false; + _grid.CanUserDeleteRows = false; + _grid.SelectionMode = DataGridSelectionMode.Single; + _grid.SelectionUnit = DataGridSelectionUnit.FullRow; + _grid.Columns.Add(new DataGridCheckBoxColumn + { + Header = "✓", + Binding = new Binding(nameof(RemovedSignalRow.IsSelected)) { Mode = BindingMode.TwoWay }, + Width = 42 + }); + _grid.Columns.Add(Column("IED", nameof(RemovedSignalRow.IedName), 120)); + _grid.Columns.Add(Column("DataSet", nameof(RemovedSignalRow.DataSetReference), 210)); + _grid.Columns.Add(Column("#", nameof(RemovedSignalRow.DataSetMemberIndex), 45)); + _grid.Columns.Add(Column("Signal", nameof(RemovedSignalRow.StaticMemberReference), 360)); + _grid.Columns.Add(Column("Kind", nameof(RemovedSignalRow.SignalKind), 80)); + var restoreOne = new MenuItem { Header = "Restore to FAT" }; + restoreOne.Click += RestoreOne_Click; + _grid.ContextMenu = new ContextMenu { Items = { restoreOne } }; + Grid.SetRow(_grid, 1); + root.Children.Add(_grid); + + var actions = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Right, + Margin = new Thickness(0, 12, 0, 0) + }; + actions.Children.Add(Button("Select All", (_, _) => SetVisibleSelection(true))); + actions.Children.Add(Button("Deselect All", (_, _) => SetVisibleSelection(false))); + actions.Children.Add(Button("Restore Selected", RestoreSelected_Click, true)); + actions.Children.Add(Button("Cancel", (_, _) => Close())); + Grid.SetRow(actions, 2); + root.Children.Add(actions); + return root; + } + + private static DataGridTextColumn Column(string header, string path, double width) + => new() { Header = header, Binding = new Binding(path), Width = new DataGridLength(width) }; + + private static Button Button(string text, RoutedEventHandler click, bool primary = false) + { + var button = new Button + { + Content = text, + MinWidth = 100, + Height = 32, + Padding = new Thickness(10, 0, 10, 0), + Margin = new Thickness(8, 0, 0, 0), + FontWeight = primary ? FontWeights.SemiBold : FontWeights.Normal + }; + button.Click += click; + return button; + } + + private IEnumerable VisibleRows() + { + var query = _search.Text.Trim(); + return query.Length == 0 + ? _rows + : _rows.Where(row => + Contains(row.IedName, query) || + Contains(row.DataSetReference, query) || + Contains(row.StaticMemberReference, query) || + Contains(row.SignalKind.ToString(), query)); + } + + private void RefreshRows() => _grid.ItemsSource = VisibleRows().ToArray(); + + private void SetVisibleSelection(bool selected) + { + foreach (var row in VisibleRows()) + row.IsSelected = selected; + _grid.Items.Refresh(); + } + + private void RestoreSelected_Click(object sender, RoutedEventArgs e) + { + var ids = _rows.Where(row => row.IsSelected).Select(row => row.SignalId).ToArray(); + if (ids.Length == 0) + return; + _project.RestoreSignals(ids); + DialogResult = true; + } + + private void RestoreOne_Click(object sender, RoutedEventArgs e) + { + if (_grid.SelectedItem is not RemovedSignalRow row) + return; + _project.RestoreSignal(row.SignalId); + _rows.Remove(row); + RefreshRows(); + } + + private static bool Contains(string? value, string query) + => (value ?? string.Empty).Contains(query, StringComparison.OrdinalIgnoreCase); +} diff --git a/MainWindow.FatV2.cs b/MainWindow.FatV2.cs new file mode 100644 index 000000000..22f346faa --- /dev/null +++ b/MainWindow.FatV2.cs @@ -0,0 +1,228 @@ +using System.Globalization; +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; +using Microsoft.Win32; + +namespace ArIED61850Tester; + +public partial class MainWindow +{ + private FatVerificationWindow? _fatV2Window; + + private void ScheduleFatV2LauncherInstall() + { + // P4 remains additive to the proven workbook FAT path. Install the SCL FAT command + // after the existing first-run card has been constructed instead of rewriting the + // legacy launcher or its session lifecycle. + Dispatcher.BeginInvoke( + new Action(InstallFatV2Launcher), + DispatcherPriority.Background); + } + + private void InstallFatV2Launcher() + { + if (_ioListTestingLauncherCard is not Border { Child: StackPanel content }) + return; + if (content.Children.OfType