diff --git a/.release/windows.json b/.release/windows.json index 3b1736961..6b76e5ffe 100644 --- a/.release/windows.json +++ b/.release/windows.json @@ -1,7 +1,7 @@ { - "version": "1.6.32", + "version": "1.6.33", "channel": "stable", "platform": "windows-x64", "primaryAsset": "ARSAS-Windows-x64-Setup.exe", - "publicationRequest": 20 + "publicationRequest": 21 } diff --git a/App.WindowsIdentity.cs b/App.WindowsIdentity.cs new file mode 100644 index 000000000..50c97c60a --- /dev/null +++ b/App.WindowsIdentity.cs @@ -0,0 +1,21 @@ +using System.Runtime.InteropServices; + +namespace ArIED61850Tester; + +internal static class WindowsApplicationIdentity +{ + private const string AppUserModelId = "masarray.ARSAS.IEC61850"; + + internal static void Apply() + { + if (!OperatingSystem.IsWindows()) + return; + + // A stable explicit identity makes Windows use the ARSAS executable icon for + // taskbar grouping even when the app was launched by dotnet or a portable host. + _ = SetCurrentProcessExplicitAppUserModelID(AppUserModelId); + } + + [DllImport("shell32.dll", CharSet = CharSet.Unicode)] + private static extern int SetCurrentProcessExplicitAppUserModelID(string appId); +} diff --git a/App.xaml.cs b/App.xaml.cs index 0b6326892..7a0d16197 100644 --- a/App.xaml.cs +++ b/App.xaml.cs @@ -19,6 +19,7 @@ public partial class App : Application protected override void OnStartup(StartupEventArgs e) { + WindowsApplicationIdentity.Apply(); base.OnStartup(e); // P2 installs one calm industrial visual system before StartupUri materializes. diff --git a/ArIED61850Tester.csproj b/ArIED61850Tester.csproj index 3000ca469..56f69afae 100644 --- a/ArIED61850Tester.csproj +++ b/ArIED61850Tester.csproj @@ -15,9 +15,9 @@ ARSAS ARSAS - IEC 61850 Engineering Workstation Open-source Windows IEC 61850 engineering workstation for MMS model discovery, reporting, independent multi-IED monitoring, GOOSE subscription, fault-record file transfer, Sampled Values engineering and evidence export, SCL workflows, diagnostics, sequence of events, and guarded control validation. - 1.6.32 - 1.6.32.0 - 1.6.32.0 + 1.6.33 + 1.6.33.0 + 1.6.33.0 https://github.com/masarray/arsas https://github.com/masarray/arsas git diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f6f1dc4c..ddb08a1fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ Notable public changes to ARSAS are recorded here. Application releases must ide ## Unreleased +## 1.6.33 — 2026-09-02 + +### Added + +- FAT workspaces can import additional CID/SCD/ICD/IID sources without replacing the current project, including while another IED evidence session remains active. +- Engineering and FAT workspaces now share imported SCL signal selection, so operator selection remains consistent when switching modes. + +### Fixed + +- Direct-SCL FAT connection now reuses the imported model and returns after the MMS association and bounded point setup instead of rediscovering or waiting through the legacy settle path. +- Structured THD current/voltage aggregates and demand-energy values retain exact parent and phase identities and use authoritative MMS projections instead of remaining `Unknown`. +- FAT live updates are coalesced below operator input priority; steady telemetry no longer refreshes the whole collection or schedules autosave for every sample, restoring smooth scrolling and reliable Capture clicks. +- The Add IED action is independent from the active evidence-session edit lock while preserving the running IED's immutable capture mapping. +- Windows receives a stable ARSAS AppUserModelID in addition to the embedded executable icon, fixing generic taskbar grouping for portable and `dotnet`-hosted launches. + ## 1.6.31 — 2026-08-15 ### Fixed diff --git a/Directory.Build.props b/Directory.Build.props index 5e40f1dbc..2defd62c4 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,8 +1,8 @@ - 1.6.32 - 1.6.32.0 - 1.6.32.0 - 1.6.32 + 1.6.33 + 1.6.33.0 + 1.6.33.0 + 1.6.33 diff --git a/IoListTestingWindow.AddIed.cs b/IoListTestingWindow.AddIed.cs new file mode 100644 index 000000000..2e17069e7 --- /dev/null +++ b/IoListTestingWindow.AddIed.cs @@ -0,0 +1,82 @@ +using System.IO; +using System.Windows; +using ArIED61850Tester.Models.IoTesting; +using ArIED61850Tester.Services.IoTesting; +using Microsoft.Win32; + +namespace ArIED61850Tester; + +public partial class IoListTestingWindow +{ + private async void AddFatIedFromScl_Click(object sender, RoutedEventArgs e) + { + if (!CanAddFatIed || Owner is not MainWindow engineeringWindow) + return; + + var dialog = new OpenFileDialog + { + Title = "Add IEC 61850 IED to current FAT workspace", + Filter = "IEC 61850 SCL (*.scd;*.cid;*.icd;*.iid;*.ssd)|*.scd;*.cid;*.icd;*.iid;*.ssd|XML SCL (*.xml)|*.xml|All files (*.*)|*.*", + CheckFileExists = true, + Multiselect = true + }; + if (dialog.ShowDialog(this) != true) + return; + + var result = await ImportAdditionalSclSourcesAsync(engineeringWindow, dialog.FileNames); + if (!result.Succeeded) + ShowActionResult(result, "IED could not be added"); + } + + internal async Task ImportAdditionalSclSourcesAsync( + MainWindow engineeringWindow, + IReadOnlyCollection sclPaths) + { + ArgumentNullException.ThrowIfNull(engineeringWindow); + ArgumentNullException.ThrowIfNull(sclPaths); + if (_isAddingFatIeds) + return IoTestSessionActionResult.Failure("Another SCL append is already being prepared for this FAT workspace."); + + SetAddingFatIeds(true); + PreparationStatusText = $"Importing {sclPaths.Count} SCL source(s) into the current FAT workspace…"; + try + { + var result = await engineeringWindow.AppendSclIedsToLoadedFatAsync(this, sclPaths); + PreparationStatusText = result.Message; + return result; + } + catch (OperationCanceledException) + { + const string message = "SCL append was cancelled."; + PreparationStatusText = message; + return IoTestSessionActionResult.Failure(message); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or InvalidOperationException or ArgumentException) + { + PreparationStatusText = ex.Message; + return IoTestSessionActionResult.Failure(ex.Message); + } + finally + { + SetAddingFatIeds(false); + } + } + + internal void RegisterAddedIeds(IReadOnlyCollection addedIeds) + { + Project.InitializeRuntimeNotifications(); + foreach (var ied in addedIeds) + { + if (_selectedIedContextInstalled) + ied.PropertyChanged += ContextIed_PropertyChanged; + Storage?.TrackAddedIed(ied); + } + + FatIedList.Items.Refresh(); + SelectedIed = addedIeds.FirstOrDefault() ?? SelectedIed; + Raise(nameof(ProjectSummary)); + RaiseStatusProperties(); + RaiseSelectedIedContextProperties(); + Storage?.ScheduleSave(); + } +} diff --git a/IoListTestingWindow.ContextUx.cs b/IoListTestingWindow.ContextUx.cs index 2c63e3982..7e948b52c 100644 --- a/IoListTestingWindow.ContextUx.cs +++ b/IoListTestingWindow.ContextUx.cs @@ -12,6 +12,8 @@ namespace ArIED61850Tester; public partial class IoListTestingWindow { private bool _selectedIedContextInstalled; + private bool _contextRefreshScheduled; + private bool _contextRowRefreshScheduled; public bool SelectedCanStartWorkflow => SelectedIed != null && !SelectedIed.IsPreparing && Session.CanStart; @@ -168,19 +170,10 @@ private async void ConnectIed_Click(object sender, RoutedEventArgs e) if (targetIed == null || targetIed.IsPreparing) return; - var enabledReady = targetIed.TestPoints - .Where(point => point.IsIncludedInFat && point.TestEnabled && point.ImportReady) - .ToList(); - if (enabledReady.Count == 0) - { - ShowActionResult( - IoTestSessionActionResult.Failure("No import-ready operator-selected signal is enabled in the active FAT scope for this IED."), - "IED connection scope is not ready"); - return; - } - - IReadOnlyCollection connectionScope = enabledReady; - + // P1: Connect owns endpoint/MMS/live-acquisition state, never FAT evidence + // selection. The preparation path receives no checkbox scope here and therefore + // acquires every shared-workspace-selected included import-ready SCL row for this + // IED. TEST remains authoritative only when Start FAT builds captureScope below. if (ReferenceEquals(SelectedIed, targetIed)) PreparationStatusText = $"Connecting {targetIed.IedName} · {targetIed.IpAddress}:102"; RaisePreparationProperties(); @@ -202,8 +195,7 @@ private async void ConnectIed_Click(object sender, RoutedEventArgs e) var preparation = await PrepareIndependentIedConnectionAsync( engineeringWindow, targetIed, - progress, - connectionScope); + progress); RaiseStatusProperties(); RaiseSelectedIedContextProperties(); @@ -259,8 +251,11 @@ private async void StartSelectedIedSafely_Click(object sender, RoutedEventArgs e return; } + // TEST is evidence authority only. Keep this selected capture scope for the + // evidence controller, while connection/live acquisition below remains the full + // shared-workspace-selected included import-ready IED scope. var captureScope = selectedIed.TestPoints - .Where(point => point.IsIncludedInFat && point.TestEnabled && point.ImportReady) + .Where(point => point.WorkspaceSelected && point.IsIncludedInFat && point.TestEnabled && point.ImportReady) .ToList(); try @@ -277,8 +272,7 @@ private async void StartSelectedIedSafely_Click(object sender, RoutedEventArgs e var preparation = await engineeringWindow.PrepareIoTestIedForFatAsync( Project, selectedIed, - progress, - captureScope); + progress); RaiseStatusProperties(); RaiseSelectedIedContextProperties(); if (!preparation.Succeeded) @@ -291,7 +285,7 @@ private async void StartSelectedIedSafely_Click(object sender, RoutedEventArgs e await CaptureTimeSyncEvidenceAfterPreparationAsync(engineeringWindow, selectedIed); } - // Operator selection remains authoritative. A selected static DataSet row that + // Operator selection remains authoritative. A selected SCL workspace row that // has no unique live point stays checked/included and visible, but it cannot be // allowed to manufacture evidence. Arm only the currently proven live subset. // Legacy source-contract spelling retained only as documentation: @@ -347,13 +341,11 @@ private async void StartSelectedIedSafely_Click(object sender, RoutedEventArgs e private Task PrepareIndependentIedConnectionAsync( MainWindow engineeringWindow, IoTestIedPlan targetIed, - IProgress progress, - IReadOnlyCollection connectionScope) + IProgress progress) => engineeringWindow.PrepareIoTestIedForFatAsync( Project, targetIed, - progress, - connectionScope); + progress); private void ContextWindow_PropertyChanged(object? sender, PropertyChangedEventArgs e) { @@ -361,14 +353,15 @@ private void ContextWindow_PropertyChanged(object? sender, PropertyChangedEventA { RaiseSelectedIedContextProperties(); if (e.PropertyName == nameof(SelectedIed)) - Dispatcher.BeginInvoke(new Action(RefreshFatV2WorkspaceUx), DispatcherPriority.DataBind); + Dispatcher.BeginInvoke( + new Action(() => RefreshFatV2WorkspaceUx(refreshRows: true)), + DispatcherPriority.Background); } } private void ContextSession_PropertyChanged(object? sender, PropertyChangedEventArgs e) { - RaiseSelectedIedContextProperties(); - RefreshFatV2WorkspaceUx(); + ScheduleContextRefresh(); } private void ContextIed_PropertyChanged(object? sender, PropertyChangedEventArgs e) @@ -376,12 +369,29 @@ private void ContextIed_PropertyChanged(object? sender, PropertyChangedEventArgs if (e.PropertyName == nameof(IoTestIedPlan.IsPreparing)) RaisePreparationProperties(); - RefreshFatV2WorkspaceUx(); - if (!ReferenceEquals(sender, SelectedIed)) - return; + ScheduleContextRefresh(e.PropertyName is + nameof(IoTestIedPlan.EnabledCount) or + nameof(IoTestIedPlan.RemovedCount)); + } - Raise(nameof(SelectedIedSummary)); - RaiseSelectedIedContextProperties(); + private void ScheduleContextRefresh(bool refreshRows = false) + { + _contextRowRefreshScheduled |= refreshRows; + if (_contextRefreshScheduled) + return; + _contextRefreshScheduled = true; + Dispatcher.BeginInvoke(new Action(() => + { + _contextRefreshScheduled = false; + var refreshFilteredRows = _contextRowRefreshScheduled; + _contextRowRefreshScheduled = false; + // Session/runtime notifications update summaries and visible cells through + // normal bindings. Re-filter only when shared selection/disposition changes; + // live value traffic must never interrupt scrolling or Capture clicks. + RefreshFatV2WorkspaceUx(refreshFilteredRows); + Raise(nameof(SelectedIedSummary)); + RaiseSelectedIedContextProperties(); + }), DispatcherPriority.Background); } private void RaiseSelectedIedContextProperties() @@ -409,7 +419,9 @@ private void ContextWindow_Closed(object? sender, EventArgs e) } private static List EnabledPoints(IoTestIedPlan ied) - => ied.TestPoints.Where(point => point.IsIncludedInFat && point.TestEnabled).ToList(); + => ied.TestPoints + .Where(point => point.WorkspaceSelected && point.IsIncludedInFat && point.TestEnabled) + .ToList(); } public sealed class IoFatAllPassedVisibilityConverter : IMultiValueConverter diff --git a/IoListTestingWindow.FatV2Ux.cs b/IoListTestingWindow.FatV2Ux.cs index 3e23c1c49..8f73887a1 100644 --- a/IoListTestingWindow.FatV2Ux.cs +++ b/IoListTestingWindow.FatV2Ux.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Windows; using System.Windows.Controls; using System.Windows.Data; @@ -11,8 +12,27 @@ public partial class IoListTestingWindow { private DataGrid? _fatSignalsGrid; private Button? _removedSignalsButton; + private ICollectionView? _fatSignalsView; + private IoTestPointPlan? _fatContextPoint; private bool _fatV2UxInstalled; private readonly BooleanToVisibilityConverter _fatBooleanVisibility = new(); + private readonly IoFatSelectedIedPlanEditabilityConverter _fatPlanEditabilityConverter = new(); + + // P0.3/P2: plan ownership is per IED. Connection preparation and evidence sessions + // lock only the owning IED; sibling IEDs remain independently editable. + public bool SelectedCanEditPlan => SelectedIed is not null && CanEditIedPlan(SelectedIed); + + private bool CanEditIedPlan(IoTestIedPlan ied) + => !ied.IsPreparing && !Session.IsIedSessionActive(ied); + + private bool CanEditPointPlan(IoTestPointPlan point) + { + var owner = Project.Ieds.FirstOrDefault(ied => ied.TestPoints.Contains(point)); + return owner is not null && CanEditIedPlan(owner); + } + + private bool CanRestoreAnyRemovedSignal => Project.Ieds.Any(ied => + CanEditIedPlan(ied) && ied.TestPoints.Any(point => !point.IsIncludedInFat)); private void InstallFatV2WorkspaceUx() { @@ -21,6 +41,10 @@ private void InstallFatV2WorkspaceUx() _fatSignalsGrid = FindVisualDescendant(this); if (_fatSignalsGrid != null) { + // P3 desktop selection contract: Ctrl+Click selects disjoint rows, + // Shift+Click selects ranges, and Recapture consumes SelectedItems. + _fatSignalsGrid.SelectionMode = DataGridSelectionMode.Extended; + _fatSignalsGrid.SelectionUnit = DataGridSelectionUnit.FullRow; ConfigureFatV2Columns(_fatSignalsGrid); _fatSignalsGrid.PreviewMouseRightButtonDown += FatSignalsGrid_PreviewMouseRightButtonDown; _fatSignalsGrid.ContextMenu = BuildFatContextMenu(); @@ -31,19 +55,32 @@ private void InstallFatV2WorkspaceUx() _fatV2UxInstalled = true; } - RefreshFatV2WorkspaceUx(); + RefreshFatV2WorkspaceUx(refreshRows: true); } - private void RefreshFatV2WorkspaceUx() + private void RefreshFatV2WorkspaceUx(bool refreshRows = false) { + // Selected/session/preparation notifications all converge here through the selected + // IED context refresh. Re-evaluate the per-IED edit contract without touching rows. + Raise(nameof(SelectedCanEditPlan)); + Raise(nameof(CanRestoreAnyRemovedSignal)); + if (_fatSignalsGrid != null) { var view = CollectionViewSource.GetDefaultView(_fatSignalsGrid.ItemsSource); - if (view != null) + if (view != null && !ReferenceEquals(view, _fatSignalsView)) { - view.Filter = item => item is IoTestPointPlan point && point.IsIncludedInFat; - view.Refresh(); + _fatSignalsView = view; + // FAT projects the shared Engineering workspace selection. TEST is a FAT-only + // evidence-scope toggle and must never hide a shared signal. Remove/Restore is + // a second, orthogonal FAT-only disposition overlay. + view.Filter = item => item is IoTestPointPlan point && + point.IsIncludedInFat && + point.WorkspaceSelected; + refreshRows = true; } + if (refreshRows) + view?.Refresh(); } if (_removedSignalsButton != null) @@ -51,7 +88,7 @@ private void RefreshFatV2WorkspaceUx() _removedSignalsButton.Content = Project.RemovedSignalCount == 0 ? "Removed Signals" : $"Removed Signals ({Project.RemovedSignalCount})"; - _removedSignalsButton.IsEnabled = CanEditPlan && Project.RemovedSignalCount > 0; + _removedSignalsButton.IsEnabled = CanRestoreAnyRemovedSignal && Project.RemovedSignalCount > 0; } Raise(nameof(ProjectSummary)); @@ -62,6 +99,8 @@ private void RefreshFatV2WorkspaceUx() private void ConfigureFatV2Columns(DataGrid grid) { + grid.SelectionMode = DataGridSelectionMode.Extended; + grid.SelectionUnit = DataGridSelectionUnit.FullRow; grid.Columns.Clear(); var enabledFactory = new FrameworkElementFactory(typeof(CheckBox)); @@ -70,10 +109,28 @@ private void ConfigureFatV2Columns(DataGrid grid) Mode = BindingMode.TwoWay, UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged }); - enabledFactory.SetBinding(UIElement.IsEnabledProperty, new Binding("DataContext.CanEditPlan") + + // Bind directly to selected-IED/session/preparation sources so the active IED locks + // synchronously when Session.Start or IsPreparing changes. This avoids a dispatcher + // timing window where a stale global CanEditPlan value could accept one more click. + var editability = new MultiBinding { Converter = _fatPlanEditabilityConverter }; + editability.Bindings.Add(new Binding("DataContext.SelectedIed") + { + RelativeSource = new RelativeSource(RelativeSourceMode.FindAncestor, typeof(DataGrid), 1) + }); + editability.Bindings.Add(new Binding("DataContext.Session.ActiveIed") { RelativeSource = new RelativeSource(RelativeSourceMode.FindAncestor, typeof(DataGrid), 1) }); + editability.Bindings.Add(new Binding("DataContext.Session.IsSessionActive") + { + RelativeSource = new RelativeSource(RelativeSourceMode.FindAncestor, typeof(DataGrid), 1) + }); + editability.Bindings.Add(new Binding("DataContext.SelectedIed.IsPreparing") + { + RelativeSource = new RelativeSource(RelativeSourceMode.FindAncestor, typeof(DataGrid), 1) + }); + enabledFactory.SetBinding(UIElement.IsEnabledProperty, editability); enabledFactory.SetValue(FrameworkElement.HorizontalAlignmentProperty, HorizontalAlignment.Center); enabledFactory.SetValue(FrameworkElement.VerticalAlignmentProperty, VerticalAlignment.Center); grid.Columns.Add(new DataGridTemplateColumn @@ -227,42 +284,118 @@ private void CaptureOperatorValue(object sender, FatValueSlot slot) private ContextMenu BuildFatContextMenu() { var menu = new ContextMenu(); + + var recapture = new MenuItem { Header = "Recapture" }; + var value1 = new MenuItem { Header = "Value 1" }; + value1.Click += RecaptureValue1_Click; + var value2 = new MenuItem { Header = "Value 2" }; + value2.Click += RecaptureValue2_Click; + var both = new MenuItem + { + Header = "Value 1 & Value 2", + ToolTip = "Stage Value 1 now, change the test condition, then use Recapture → Value 2 to commit the new pair" + }; + both.Click += BeginPairRecapture_Click; + var cancelPair = new MenuItem { Header = "Cancel staged Value 1 & Value 2" }; + cancelPair.Click += CancelPairRecapture_Click; + recapture.Items.Add(value1); + recapture.Items.Add(value2); + recapture.Items.Add(both); + recapture.Items.Add(new Separator()); + recapture.Items.Add(cancelPair); + menu.Items.Add(recapture); + menu.Items.Add(new Separator()); + + // Remove remains a deliberate one-row disposition action. Multi-selection is for + // Recapture in P3; right-click remembers the exact context row so preserving a + // broader selection never removes an arbitrary anchor row. var remove = new MenuItem { Header = "Remove from FAT" }; remove.Click += RemoveSelectedFromFat_Click; menu.Items.Add(remove); + menu.Opened += (_, _) => + { + var count = SelectedFatRows().Count; + recapture.Header = count <= 1 ? "Recapture" : $"Recapture ({count} selected)"; + }; return menu; } + private IReadOnlyList SelectedFatRows() + { + if (_fatSignalsGrid == null) + return Array.Empty(); + return _fatSignalsGrid.SelectedItems + .OfType() + .Distinct() + .ToArray(); + } + + private void RecaptureValue1_Click(object sender, RoutedEventArgs e) + => ApplyBulkRecapture(Session.RecaptureValues(SelectedFatRows(), FatValueSlot.Value1), "Value 1 Recapture failed"); + + private void RecaptureValue2_Click(object sender, RoutedEventArgs e) + => ApplyBulkRecapture(Session.RecaptureValues(SelectedFatRows(), FatValueSlot.Value2), "Value 2 Recapture failed"); + + private void BeginPairRecapture_Click(object sender, RoutedEventArgs e) + => ApplyBulkRecapture(Session.BeginPairRecapture(SelectedFatRows()), "Value 1 & Value 2 Recapture could not be staged"); + + private void CancelPairRecapture_Click(object sender, RoutedEventArgs e) + => ApplyBulkRecapture(Session.CancelPairRecapture(SelectedFatRows()), "Staged Value 1 & Value 2 Recapture could not be cancelled"); + + private void ApplyBulkRecapture(IoTestSessionActionResult result, string failureTitle) + { + if (!result.Succeeded) + { + ShowActionResult(result, failureTitle); + return; + } + + // Success feedback stays compact in the selected IED footer through Session.StatusText. + // Never display one modal popup per row for a batch operation. + Storage?.ScheduleSave(); + RaiseSelectedIedContextProperties(); + RefreshFatV2WorkspaceUx(); + } + private void FatSignalsGrid_PreviewMouseRightButtonDown(object sender, MouseButtonEventArgs e) { if (_fatSignalsGrid == null) return; var row = FindVisualAncestor(e.OriginalSource as DependencyObject); - if (row?.Item is IoTestPointPlan point) - { - _fatSignalsGrid.SelectedItem = point; - row.IsSelected = true; - } + if (row?.Item is not IoTestPointPlan point) + return; + + _fatContextPoint = point; + if (row.IsSelected) + return; + + // Desktop convention: right-clicking outside the current set makes that row the + // sole target. Right-clicking inside an existing Ctrl/Shift selection preserves it. + _fatSignalsGrid.SelectedItems.Clear(); + row.IsSelected = true; + _fatSignalsGrid.SelectedItem = point; } private void RemoveSelectedFromFat_Click(object sender, RoutedEventArgs e) { - if (_fatSignalsGrid?.SelectedItem is not IoTestPointPlan point) + var point = _fatContextPoint ?? _fatSignalsGrid?.SelectedItem as IoTestPointPlan; + if (point == null) return; - if (!CanEditPlan) + if (!CanEditPointPlan(point)) { MessageBox.Show( this, - "Stop the active FAT session or connection preparation before changing FAT scope.", - "FAT scope is locked", + "This IED's FAT scope is locked while its connection preparation or evidence session is active. Other IEDs remain editable.", + "IED FAT scope is locked", MessageBoxButton.OK, MessageBoxImage.Information); return; } point.RemoveFromFat(); + _fatContextPoint = null; Storage?.ScheduleSave(); - RefreshFatV2WorkspaceUx(); + RefreshFatV2WorkspaceUx(refreshRows: true); RaiseSelectedIedContextProperties(); } @@ -290,15 +423,23 @@ private void InstallRemovedSignalsButton() private void RemovedSignals_Click(object sender, RoutedEventArgs e) { - if (!CanEditPlan) + if (!CanRestoreAnyRemovedSignal) + { + MessageBox.Show( + this, + "Removed signals currently belong only to an IED whose connection preparation or FAT evidence session owns an immutable scope.", + "Removed Signals are locked", + MessageBoxButton.OK, + MessageBoxImage.Information); return; + } - var window = new RemovedFatSignalsWindow(Project) { Owner = this }; + var window = new RemovedFatSignalsWindow(Project, CanEditPointPlan) { Owner = this }; if (window.ShowDialog() != true || window.RestoredCount == 0) return; Storage?.ScheduleSave(); - RefreshFatV2WorkspaceUx(); + RefreshFatV2WorkspaceUx(refreshRows: true); RaiseSelectedIedContextProperties(); } @@ -309,7 +450,7 @@ private void UpdateFatWorkspaceLabels() if (text.Text == "IO LIST FAT") text.Text = "IEC 61850 FAT"; else if (text.Text == "Workbook scope · report-first acquisition · relay-timestamped evidence") - text.Text = "Static DataSet scope · Value 1 / Value 2 · immutable evidence history"; + text.Text = "Shared SCL scope · Value 1 / Value 2 · immutable evidence history"; else if (text.Text == "Workbook devices") text.Text = "FAT source IEDs"; } @@ -353,4 +494,21 @@ private static IEnumerable FindVisualDescendants(DependencyObject root) wh } return null; } -} \ No newline at end of file +} + +public sealed class IoFatSelectedIedPlanEditabilityConverter : IMultiValueConverter +{ + public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) + { + if (values.Length < 4 || values[0] is not IoTestIedPlan selectedIed) + return false; + + var activeIed = values[1] as IoTestIedPlan; + var sessionActive = values[2] is bool active && active; + var selectedPreparing = values[3] is bool preparing && preparing; + return !selectedPreparing && (!sessionActive || !ReferenceEquals(activeIed, selectedIed)); + } + + public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) + => targetTypes.Select(_ => Binding.DoNothing).ToArray(); +} diff --git a/IoListTestingWindow.SharedWorkspaceProjection.cs b/IoListTestingWindow.SharedWorkspaceProjection.cs new file mode 100644 index 000000000..760bff84f --- /dev/null +++ b/IoListTestingWindow.SharedWorkspaceProjection.cs @@ -0,0 +1,19 @@ +using ArIED61850Tester.Models.IoTesting; + +namespace ArIED61850Tester; + +public partial class IoListTestingWindow +{ + internal void NotifySharedWorkspacePointsChanged(IoTestIedPlan ied) + { + ArgumentNullException.ThrowIfNull(ied); + + // TestPoints is intentionally persisted as a List. Manual SCL selection can add a + // row while FAT is hidden in Engineering, so force the existing collection view to + // rebuild when the operator returns instead of requiring a second import/window. + RefreshFatV2WorkspaceUx(refreshRows: true); + Raise(nameof(ProjectSummary)); + Raise(nameof(SelectedIedSummary)); + RaiseSelectedIedContextProperties(); + } +} diff --git a/IoListTestingWindow.xaml b/IoListTestingWindow.xaml index f62ba6396..48b1a7bb5 100644 --- a/IoListTestingWindow.xaml +++ b/IoListTestingWindow.xaml @@ -123,7 +123,12 @@ - + + diff --git a/IoListTestingWindow.xaml.cs b/IoListTestingWindow.xaml.cs index 0012e2f25..84f37fb94 100644 --- a/IoListTestingWindow.xaml.cs +++ b/IoListTestingWindow.xaml.cs @@ -11,6 +11,8 @@ public partial class IoListTestingWindow : Window, INotifyPropertyChanged { private IoTestIedPlan? _selectedIed; private string _preparationStatusText = string.Empty; + private bool _isAddingFatIeds; + private bool _parallelSessionsAttached; public IoListTestingWindow() : this(CreateEmptyProject(), CreateEmptyController(), null) @@ -23,17 +25,20 @@ public IoListTestingWindow( IoTestWorkspacePersistence? persistence) { Project = project ?? throw new ArgumentNullException(nameof(project)); - Session = session ?? throw new ArgumentNullException(nameof(session)); + Session = new IoTestMultiSessionCoordinator( + Project, + session ?? throw new ArgumentNullException(nameof(session))); Storage = persistence; Project.InitializeRuntimeNotifications(); Session.PropertyChanged += Session_PropertyChanged; InitializeComponent(); DataContext = this; SelectedIed = Project.Ieds.FirstOrDefault(); + Loaded += Window_LoadedForParallelEvidenceSessions; } public IoTestProject Project { get; } - public IoTestSessionController Session { get; } + public IoTestMultiSessionCoordinator Session { get; } public IoTestWorkspacePersistence? Storage { get; } public IoTestIedPlan? SelectedIed @@ -44,14 +49,15 @@ public IoTestIedPlan? SelectedIed if (ReferenceEquals(_selectedIed, value)) return; _selectedIed = value; + Session.SelectContext(_selectedIed); Raise(); Raise(nameof(SelectedIedSummary)); RaisePreparationProperties(); } } - // Compatibility aggregate used for close/edit protection. It no longer blocks - // another IED from starting its own independent connection workflow. + // Compatibility aggregate used for close protection. It no longer blocks another + // IED from starting its own independent connection or evidence workflow. public bool IsPreparingIed => Project.Ieds.Any(ied => ied.IsPreparing); public string PreparationStatusText @@ -74,14 +80,26 @@ private set public bool CanStartWorkflow => SelectedIed != null && !SelectedIed.IsPreparing && Session.CanStart; - // Explorer navigation stays available while one or more IEDs are connecting or a - // FAT evidence session is running. Each IED card owns its own connection progress. + // Explorer navigation stays available while one or more IEDs are connecting or FAT + // evidence sessions are running. Each IED owns its own connection and evidence state. public bool CanSelectIed => true; - // Keep plan mutation frozen while any network preparation is consuming the selected - // FAT scope, or while the evidence controller owns a session. + // P2 edit lock is selected-IED-local: an IED whose evidence session is active is + // immutable, while another selected IED remains editable even when its sibling is live. public bool CanEditPlan => - !IsPreparingIed && Session.CanEditPlan; + SelectedIed?.IsPreparing != true && Session.CanEditPlan; + + // Adding another SCL is a workspace-level append operation, independent of per-IED + // connection preparation and P2 evidence sessions. Only another append locks it. + public bool CanAddFatIed => !_isAddingFatIeds; + + private void SetAddingFatIeds(bool value) + { + if (_isAddingFatIeds == value) + return; + _isAddingFatIeds = value; + Raise(nameof(CanAddFatIed)); + } public string StartWorkflowText => SelectedIed?.IsPreparing == true ? $"Connecting {SelectedIed.IedName}…" : "Connect & Start IED"; @@ -310,7 +328,7 @@ private async void ExportHandover_Click(object sender, RoutedEventArgs e) IsEnabled = false; var exportedPath = await IoFatProjectPackageService.ExportAsync( Storage, - Session, + Session.PrimaryController, dialog.FileName); MessageBox.Show( this, @@ -331,13 +349,13 @@ private async void ExportHandover_Click(object sender, RoutedEventArgs e) private bool EnsureSessionSealedForExport(string outputName) { - if (!Session.IsSessionActive) + if (!Session.HasActiveSessions) return true; MessageBox.Show( this, - $"Stop the active IED session before exporting the {outputName}. This seals and verifies the current evidence journal first.", - "Stop session before export", + $"Stop all active IED evidence sessions before exporting the {outputName}. Every per-IED evidence journal must be sealed and verified first.", + "Stop sessions before export", MessageBoxButton.OK, MessageBoxImage.Information); return false; @@ -361,12 +379,13 @@ private void Window_Closing(object? sender, CancelEventArgs e) return; } - if (Session.IsSessionActive) + if (Session.HasActiveSessions) { + var activeCount = Session.ActiveSessionCount; var answer = MessageBox.Show( this, - "A FAT session is active. Returning to Engineering will stop the session, seal the evidence journal, and save the current project progress.\n\nStop the session and return?", - "Stop active FAT session", + $"{activeCount} FAT evidence session(s) are active. Returning to Engineering will stop every active IED session, seal each independent evidence journal, and save the current project progress.\n\nStop all sessions and return?", + "Stop active FAT sessions", MessageBoxButton.YesNo, MessageBoxImage.Warning, MessageBoxResult.No); @@ -376,7 +395,13 @@ private void Window_Closing(object? sender, CancelEventArgs e) return; } - Session.Stop("Workspace closed by operator; evidence journal sealed."); + var stopAll = Session.StopAll("Workspace closed by operator; per-IED evidence journal sealed."); + if (!stopAll.Succeeded) + { + MessageBox.Show(this, stopAll.Message, "Evidence journals could not be sealed", MessageBoxButton.OK, MessageBoxImage.Error); + e.Cancel = true; + return; + } } try @@ -397,9 +422,35 @@ private void Window_Closing(object? sender, CancelEventArgs e) } } + private void Window_LoadedForParallelEvidenceSessions(object sender, RoutedEventArgs e) + { + if (_parallelSessionsAttached || Owner is not MainWindow engineeringWindow) + return; + + var evidenceRoot = Storage?.EvidenceProjectDirectory; + if (string.IsNullOrWhiteSpace(evidenceRoot)) + { + evidenceRoot = Path.Combine( + Path.GetTempPath(), + "ARSAS", + "IO Testing Evidence", + string.IsNullOrWhiteSpace(Project.ProjectId) ? "UNNAMED" : Project.ProjectId); + } + + engineeringWindow.AttachIoTestParallelEvidenceSessions(Session, evidenceRoot); + _parallelSessionsAttached = true; + } + protected override void OnClosed(EventArgs e) { + Loaded -= Window_LoadedForParallelEvidenceSessions; Session.PropertyChanged -= Session_PropertyChanged; + if (_parallelSessionsAttached && Owner is MainWindow engineeringWindow) + { + engineeringWindow.DetachIoTestParallelEvidenceSessions(Session); + _parallelSessionsAttached = false; + } + Session.Dispose(); base.OnClosed(e); } @@ -422,6 +473,7 @@ private void RaisePreparationProperties() Raise(nameof(CanStartWorkflow)); Raise(nameof(CanSelectIed)); Raise(nameof(CanEditPlan)); + Raise(nameof(CanAddFatIed)); Raise(nameof(StartWorkflowText)); Raise(nameof(FooterStatusText)); } diff --git a/MainWindow.IoTesting.AutoConnect.cs b/MainWindow.IoTesting.AutoConnect.cs index 17f76b407..7ace02f5f 100644 --- a/MainWindow.IoTesting.AutoConnect.cs +++ b/MainWindow.IoTesting.AutoConnect.cs @@ -26,12 +26,25 @@ internal async Task PrepareIoTestIedForFatAsync( if (ied.IsPreparing) return IoTestSessionActionResult.Failure($"{ied.IedName} is already being prepared for FAT acquisition."); + // P1: connection/acquisition scope and FAT evidence selection are different + // authorities. A normal Connect prepares every shared-workspace-selected, + // included, import-ready row whether its TEST checkbox is on or off. The optional + // override is retained for callers that explicitly ask for narrower evidence scope. var requestedPoints = (requestedPointsOverride ?? ied.TestPoints) - .Where(point => point.TestEnabled && point.ImportReady) + .Where(point => + point.WorkspaceSelected && + point.IsIncludedInFat && + point.ImportReady && + (requestedPointsOverride is null || point.TestEnabled)) .Distinct() .ToList(); if (requestedPoints.Count == 0) - return IoTestSessionActionResult.Failure("No import-ready FAT signal is enabled for this IED."); + { + return IoTestSessionActionResult.Failure( + requestedPointsOverride is null + ? "No shared-workspace-selected import-ready FAT signal is available for this IED." + : "No operator-selected import-ready FAT signal is available for this IED."); + } void ReportProgress(string message) { @@ -166,8 +179,20 @@ void ReportProgress(string message) // Reconciliation belongs to the connection/discovery lifecycle, before local // FAT row selection. SCL-backed fast association keeps the design authority // intact; absence is never inferred merely because full live discovery was skipped. - ReportProgress("Reconciling FAT source with authoritative IED model"); - await IoTestReconciliationCache.RefreshAsync(device, _applicationCancellation.Token); + if (hasSclRuntimeAuthority) + { + // The imported ARIEC SCL workspace already owns the complete static + // DataSet identity. Connected reconciliation can perform a large exact-read + // pass and used to hold this SIPROTEC FAT preparation for about one minute. + // Explicit Re-scan remains the design-versus-live comparison action. + IoTestReconciliationCache.Invalidate(device); + ReportProgress("Using authoritative SCL workspace identity · starting live acquisition"); + } + else + { + ReportProgress("Reconciling FAT source with authoritative IED model"); + await IoTestReconciliationCache.RefreshAsync(device, _applicationCancellation.Token); + } // Never let a runtime anchor from an earlier model silently decide a fresh // FAT preparation. Re-prove every requested row against the current model; @@ -195,10 +220,10 @@ void ReportProgress(string message) device); // Legacy workbook rows may still request one fresh discovery when a saved live - // model is stale. An SCL-backed FAT row already has engine-owned static DataSet - // authority; a local match miss must not trigger a 50-second rediscovery loop. + // model is stale. Direct SCL rows already own imported workspace identity; a + // local match miss must not trigger a 50-second rediscovery loop. var hasBlockingNonSclMiss = selection.MissingPoints.Any(point => - !IoTestSignalSelectionService.IsSclDataSetAuthority(point)); + !IoTestSignalSelectionService.IsDirectSclAuthority(point)); if (!selection.Succeeded && selection.CanRetryWithFreshDiscovery && hasBlockingNonSclMiss) { ReportProgress("Refreshing live model once · saved model missed legacy FAT points"); @@ -245,7 +270,7 @@ void ReportProgress(string message) var mayProceedWithPartialSclSelection = hasSclRuntimeAuthority && selection.Matches.Count > 0 && - unresolvedSelectionPoints.All(IoTestSignalSelectionService.IsSclDataSetAuthority); + unresolvedSelectionPoints.All(IoTestSignalSelectionService.IsDirectSclAuthority); if (!selection.Succeeded && !mayProceedWithPartialSclSelection) { @@ -260,11 +285,22 @@ void ReportProgress(string message) { unresolved.ApplyLiveBinding( IoTestLiveBindingState.NotEvaluated, - "Static DataSet membership remains selected, but no unique runtime point is safe to bind yet. Other live FAT rows remain usable.", + "Selected SCL workspace identity remains authoritative, but no unique runtime point is safe to bind yet. Other live FAT rows remain usable.", device.DeviceId); } - var selectionChanged = false; + // P1 acquisition scope is the safe model match set, not the operator TEST + // selection. Keep Engineering selection and FAT TEST untouched while arming + // all proven shared-workspace-selected rows for this isolated IED runtime. + var acquisitionSignals = selection.Matches + .Select(match => match.Signal) + .Where(signal => signal.CanPublishToRuntime) + .GroupBy( + signal => IoTestLiveBindingService.NormalizeReference(signal.ObjectReference), + StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) + .ToList(); + foreach (var match in selection.Matches) { // Preserve the unique reference proven by the preparation pass. This is @@ -279,20 +315,19 @@ void ReportProgress(string message) : "FAT preparation matched an exact engine-owned IEC 61850 model identity.", device.DeviceId, match.Signal.ObjectReference); - - if (match.Signal.IsSelected) - continue; - match.Signal.IsSelected = true; - selectionChanged = true; } // Time synchronization is device-level FAT evidence rather than an ON/OFF // test point. If the live model exposes an explicit status (for example // SIPROTEC TimeSynchrnz or MiCOM LLN0.SyncSt), arm that one extra read-only // signal so the FAT window can capture the real IED value automatically. - var timeSyncArmed = IoFatSupplementalEvidenceService.EnsureTimeSyncSignalSelected(device); - if (timeSyncArmed) - selectionChanged = true; + IoFatSupplementalEvidenceService.EnsureTimeSyncSignalSelected(device); + var timeSyncSignal = IoFatSupplementalEvidenceService.FindTimeSyncSignal(device); + if (timeSyncSignal?.CanPublishToRuntime == true && + acquisitionSignals.All(signal => !ReferenceEquals(signal, timeSyncSignal))) + { + acquisitionSignals.Add(timeSyncSignal); + } device.RecountSelectedSignals(); device.RefreshComputed(); @@ -301,20 +336,33 @@ void ReportProgress(string message) _ioTestLiveBindingService.BindIed(ied, Devices); var fatPollingNotActive = device.IsMonitoring && device.Points.Any(point => point.PollingIntervalMs > 500); - - // P5.3: unresolved selected rows are not a reason to tear down an otherwise - // healthy monitor. Restart only when the selected live model changed or the FAT - // polling cadence itself needs correction. - if (device.IsMonitoring && (selectionChanged || fatPollingNotActive)) + var requestedAcquisitionReferences = acquisitionSignals + .Select(signal => IoTestLiveBindingService.NormalizeReference(signal.ObjectReference)) + .Where(reference => reference.Length > 0) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var activeAcquisitionReferences = device.Points + .Select(point => IoTestLiveBindingService.NormalizeReference(point.IecReference)) + .Where(reference => reference.Length > 0) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var acquisitionScopeChanged = + !requestedAcquisitionReferences.SetEquals(activeAcquisitionReferences); + + // Each IED monitor is isolated. Refresh only this device when its acquisition + // scope or FAT polling cadence changed; another connected/monitoring IED is + // never stopped or rebound by this preparation. + if (device.IsMonitoring && (acquisitionScopeChanged || fatPollingNotActive)) { - ReportProgress("Refreshing FAT acquisition · deterministic fast digital polling"); + ReportProgress("Refreshing this IED's FAT acquisition · other IED monitors remain active"); await StopDeviceMonitorAsync(device); } if (!device.IsMonitoring) { - ReportProgress("Starting FAT live acquisition · fast MMS verification"); - if (!await StartDeviceMonitorAsync(device, navigateToExplorer: false)) + ReportProgress("Starting independent FAT live acquisition · fast MMS verification"); + // Compatibility note: the pre-P1 source contract called StartDeviceMonitorAsync + // here. P1 routes through StartIoFatDeviceMonitorAsync so acquisition scope can + // be armed without mutating/persisting the operator TEST selection. + if (!await StartIoFatDeviceMonitorAsync(device, acquisitionSignals)) { _ioTestLiveBindingService.BindIed(ied, Devices); return IoTestSessionActionResult.Failure( @@ -322,16 +370,13 @@ void ReportProgress(string message) } } - // Do not hold the operator behind an 8–18 second report-settling phase. FAT - // readiness is the first usable live image; report planning for non-fast points - // can continue in the monitor loop after the card is already responsive. - var acquisition = await SettleIoFatReportPriorityAsync( - ied, - requestedPoints, - device, - ReportProgress); - + // The monitor points now exist and MMS polling has already been scheduled. + // Return control to FAT immediately: waiting here for values/report setup made + // a sub-second SCL association look like a 30-50 second connection whenever + // the UI dispatcher was busy with the first report burst. Values and report + // optimization continue through the shared Engineering runtime. var binding = _ioTestLiveBindingService.BindIed(ied, Devices); + var acquisition = ReadIoFatAcquisitionSummary(requestedPoints, device); var liveCount = requestedPoints.Count(point => point.LiveBindingState == IoTestLiveBindingState.LivePointReady); if (liveCount == 0) @@ -343,6 +388,8 @@ void ReportProgress(string message) $"{ied.IedName} is connected and monitoring, but none of the {requestedPoints.Count} requested FAT signal(s) has a unique live monitor point. Unresolved: {string.Join(", ", unresolved)}."); } + // The temporary acquisition arm has already been restored by + // StartIoFatDeviceMonitorAsync. Persist only the real operator selection. SaveSignalSelectionMemory(device); var modelText = hasSclRuntimeAuthority ? "imported SCL model" @@ -356,13 +403,13 @@ void ReportProgress(string message) var unresolvedCount = requestedPoints.Count - liveCount; var partialText = unresolvedCount == 0 ? string.Empty - : $" · {unresolvedCount} selected row(s) waiting for safe live binding"; + : $" · {unresolvedCount} FAT row(s) waiting for safe live binding"; var message = $"{ied.IedName} · {liveCount}/{requestedPoints.Count} live · {acquisitionText}{timeSyncText}{partialText}"; SetStatus(message); AddLog( unresolvedCount == 0 ? "INFO" : "WARN", "IO Testing", - $"{message}. Live rows are usable immediately; unresolved rows stay operator-selected and visible but are excluded from the active evidence scope until a unique live point exists. No checkbox or FAT disposition is changed by the engine. IED live-bound={binding.LivePointCount}; model={modelText}; mode={device.AcquisitionMode}."); + $"{message}. Live rows are usable immediately; unresolved rows stay visible but are excluded from the active evidence scope until a unique live point exists. No checkbox or FAT disposition is changed by the engine. IED live-bound={binding.LivePointCount}; model={modelText}; mode={device.AcquisitionMode}."); ReportProgress(message); return IoTestSessionActionResult.Success(message); } @@ -404,26 +451,111 @@ private bool AttachIoFatSclRuntimeAuthority( device.SclAccessPointName = workspace.AccessPointName; device.IdentitySource = "FAT SCL · ARIEC workspace"; - if (!device.HasDiscoveryCache) + if (device.Signals.Count == 0) { var projected = SclWorkspaceSignalMapper.BuildSignals(workspace); + DetachSignalHandlers(device.Signals); device.Signals.Clear(); device.Signals.AddRange(projected); } else { - // An existing Engineering Workspace keeps its richer live model. Merge only - // engine-authoritative static DataSet inventory; never replace discovered rows. + // Existing Engineering signals are the live shared selection authority even + // when they came from offline SCL rather than a discovery cache. Preserve those + // SignalDefinition instances/checks and merge only missing mandatory DataSet + // descriptors; never recreate the catalog during an Engineering -> FAT switch. Iec61850DataSetSignalInventoryService.EnsureMandatorySignals( device.Signals, workspace.DesignModel); } + AttachIoFatSignalHandlers(device); + device.RecountSelectedSignals(); device.RefreshComputed(); return device.Signals.Count > 0; } + private int SynchronizeImportedSclFatWithEngineering(IoTestProject project) + => SynchronizeImportedSclFatWithEngineering(project, project.Ieds); + + private int SynchronizeImportedSclFatWithEngineering( + IoTestProject project, + IEnumerable ieds) + { + ArgumentNullException.ThrowIfNull(project); + ArgumentNullException.ThrowIfNull(ieds); + var synchronized = 0; + foreach (var ied in ieds) + { + var device = ResolveIoTestDevice(ied.LiveDeviceId) + ?? ResolveIoTestDevice(ied.IpAddress) + ?? ResolveIoTestDevice(ied.IedName); + var existed = device is not null; + if (device is null) + { + device = new Iec61850MonitorDevice + { + Name = ied.IedName, + SclIedName = ied.IedName, + IdentitySource = "FAT SCL · shared Engineering workspace", + IpAddress = ied.IpAddress, + Port = 102, + AllowDynamicDataSetWrites = true, + Status = "SCL model ready", + Detail = "Engineering and FAT share this imported SCL model and signal selection." + }; + Devices.Add(device); + } + + var preserveEngineeringSelection = existed && + (_sharedSclSelectionAuthorityDeviceIds.Contains(device.DeviceId) || + device.Signals.Any(signal => signal.IsSelected)); + if (!AttachIoFatSclRuntimeAuthority(ied, device)) + continue; + + synchronized += IoFatEngineeringSelectionBridge.Initialize( + ied, + device, + preserveEngineeringSelection); + + // When Engineering already owns this workspace, project every selected SCL + // signal into FAT. This materializes manually selected non-DataSet signals and + // keeps intentionally empty manual selections empty without changing TEST. + if (preserveEngineeringSelection) + { + foreach (var signal in device.Signals) + { + if (IoFatEngineeringSelectionBridge.ApplyEngineeringSignalSelection( + signal, + signal.IsSelected, + ied, + device)) + { + synchronized++; + } + } + } + + _ioTestLiveBindingService.BindIed(ied, Devices); + SaveSignalSelectionMemory(device); + } + + RaiseWorkspaceCounts(); + return synchronized; + } + + private void AttachIoFatSignalHandlers(Iec61850MonitorDevice device) + { + foreach (var signal in device.Signals) + { + if (_signalOwners.ContainsKey(signal)) + continue; + signal.PropertyChanged += Signal_PropertyChanged; + _signalOwners[signal] = device; + } + } + private async Task ConnectIoFatUsingPreparedSclAsync(Iec61850MonitorDevice device) { // ConnectUsingSavedModelAsync and the runtime predate the SCL fast-connect UI and @@ -444,68 +576,6 @@ private async Task ConnectIoFatUsingPreparedSclAsync(Iec61850MonitorDevice } } - private async Task SettleIoFatReportPriorityAsync( - IoTestIedPlan ied, - IReadOnlyCollection requestedPoints, - Iec61850MonitorDevice device, - Action reportProgress) - { - return await ObserveIoFatAcquisitionAsync( - ied, - requestedPoints, - device, - reportProgress, - TimeSpan.FromMilliseconds(2500)); - } - - private async Task ObserveIoFatAcquisitionAsync( - IoTestIedPlan ied, - IReadOnlyCollection requestedPoints, - Iec61850MonitorDevice device, - Action reportProgress, - TimeSpan timeout) - { - var deadline = DateTime.UtcNow + timeout; - var last = ReadIoFatAcquisitionSummary(requestedPoints, device); - var announced = false; - var nextBindingRefreshUtc = DateTime.MinValue; - - while (DateTime.UtcNow < deadline && device.IsMonitoring) - { - await Task.Delay(100); - var nowUtc = DateTime.UtcNow; - if (nowUtc >= nextBindingRefreshUtc) - { - _ioTestLiveBindingService.BindIed(ied, Devices); - nextBindingRefreshUtc = nowUtc.AddMilliseconds(250); - } - last = ReadIoFatAcquisitionSummary(requestedPoints, device); - - if (!announced) - { - reportProgress("Waiting for first live FAT image · report optimization continues in background"); - announced = true; - } - - var livePoints = requestedPoints - .Where(point => point.LiveBindingState == IoTestLiveBindingState.LivePointReady) - .ToList(); - if (livePoints.Count > 0 && livePoints.All(HasUsableFatLiveValue)) - break; - } - - _ioTestLiveBindingService.BindIed(ied, Devices); - return ReadIoFatAcquisitionSummary(requestedPoints, device); - } - - private static bool HasUsableFatLiveValue(IoTestPointPlan point) - { - var value = (point.Runtime.CurrentValue ?? string.Empty).Trim(); - return value.Length > 0 && value != "-" && value != "—" && - !value.Equals("Unknown", StringComparison.OrdinalIgnoreCase) && - !value.Contains("pending", StringComparison.OrdinalIgnoreCase); - } - private static IoFatAcquisitionSummary ReadIoFatAcquisitionSummary( IEnumerable requestedPoints, Iec61850MonitorDevice device) diff --git a/MainWindow.IoTesting.MultiIedMonitor.cs b/MainWindow.IoTesting.MultiIedMonitor.cs new file mode 100644 index 000000000..c6b405911 --- /dev/null +++ b/MainWindow.IoTesting.MultiIedMonitor.cs @@ -0,0 +1,93 @@ +using ArIED61850Tester.Models; +using ArIED61850Tester.Services.IoTesting; + +namespace ArIED61850Tester; + +public partial class MainWindow +{ + /// + /// Starts one IED's FAT monitor from an explicit acquisition scope without changing + /// the operator's Engineering/TEST selection. Iec61850MonitorRuntime snapshots the + /// selected signal set when StartMonitoringAsync is called, so P1 temporarily arms + /// only the acquisition signals under the device's bulk-selection guard and restores + /// the operator selection immediately after the runtime point definitions are built. + /// + private async Task StartIoFatDeviceMonitorAsync( + Iec61850MonitorDevice device, + IReadOnlyCollection acquisitionSignals) + { + ArgumentNullException.ThrowIfNull(device); + ArgumentNullException.ThrowIfNull(acquisitionSignals); + + var acquisition = acquisitionSignals + .Where(signal => signal.CanPublishToRuntime) + .GroupBy( + signal => IoTestLiveBindingService.NormalizeReference(signal.ObjectReference), + StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) + .ToArray(); + if (acquisition.Length == 0) + { + SetStatus($"{device.Name}: no readable FAT acquisition point is available."); + return false; + } + + var acquisitionSet = acquisition.ToHashSet(); + var originalSelection = device.Signals + .ToDictionary(signal => signal, signal => signal.IsSelected); + + device.BeginBulkSignalSelection(); + try + { + // Runtime acquisition and operator selection are intentionally different + // authorities in P1. The bulk guard prevents Signal_PropertyChanged from + // reflecting these temporary acquisition flags back into TEST checkboxes. + foreach (var signal in device.Signals) + signal.IsSelected = acquisitionSet.Contains(signal); + + device.HasReportStream = false; + device.ReportPulseActive = false; + _reportPulseUntil.Remove(device.DeviceId); + RemoveDeviceHighlights(device.DeviceId); + RemoveDevicePoints(device.DeviceId); + device.Points.Clear(); + + SetStatus($"{device.Name}: starting independent FAT live acquisition…"); + var points = await _runtime.StartMonitoringAsync( + device, + acquisition, + PollingIntervalMs, + _applicationCancellation.Token); + + device.Points.AddRange(points); + GlobalPoints.AddRange(points); + RebuildControlFeedbackIndex(device); + foreach (var point in points) + _pointIndex[point.PointKey] = point; + device.RefreshComputed(); + RaiseWorkspaceCounts(); + SetStatus($"{device.Name}: FAT monitoring {points.Count} point(s). {device.AcquisitionMode}"); + return true; + } + catch (OperationCanceledException) + { + SetStatus($"{device.Name}: FAT monitor start cancelled."); + return false; + } + catch (Exception ex) + { + AddLog("ERROR", device.Name, $"FAT monitor start failed: {ex.Message}"); + SetStatus($"{device.Name}: FAT monitor start failed. Diagnostics is marked with !."); + MarkDiagnosticAlert(); + return false; + } + finally + { + foreach (var (signal, wasSelected) in originalSelection) + signal.IsSelected = wasSelected; + device.EndBulkSignalSelection(); + device.RefreshComputed(); + RaiseWorkspaceCounts(); + } + } +} diff --git a/MainWindow.IoTesting.MultiSessionEvidence.cs b/MainWindow.IoTesting.MultiSessionEvidence.cs new file mode 100644 index 000000000..05c385b92 --- /dev/null +++ b/MainWindow.IoTesting.MultiSessionEvidence.cs @@ -0,0 +1,63 @@ +using ArIED61850Tester.Models; +using ArIED61850Tester.Services.IoTesting; + +namespace ArIED61850Tester; + +public partial class MainWindow +{ + private IoTestMultiSessionCoordinator? _activeIoTestMultiSessionCoordinator; + + /// + /// P2 attaches the FAT window's additional per-IED evidence leaves to the same + /// Engineering IEC 61850 runtime. The existing primary controller keeps its legacy + /// Runtime_IoTestPointUpdated route; only sibling leaves use the additional route so a + /// primary event can never be journaled twice. + /// + internal void AttachIoTestParallelEvidenceSessions( + IoTestMultiSessionCoordinator coordinator, + string evidenceRoot) + { + ArgumentNullException.ThrowIfNull(coordinator); + ArgumentException.ThrowIfNullOrWhiteSpace(evidenceRoot); + + coordinator.ConfigureSiblingFactory( + () => CreateIoTestSession(coordinator.Project, evidenceRoot)); + _activeIoTestMultiSessionCoordinator = coordinator; + _runtime.PointUpdated -= Runtime_IoTestAdditionalPointUpdated; + _runtime.PointUpdated += Runtime_IoTestAdditionalPointUpdated; + } + + internal void DetachIoTestParallelEvidenceSessions(IoTestMultiSessionCoordinator coordinator) + { + if (coordinator == null) + return; + if (ReferenceEquals(_activeIoTestMultiSessionCoordinator, coordinator)) + _activeIoTestMultiSessionCoordinator = null; + _runtime.PointUpdated -= Runtime_IoTestAdditionalPointUpdated; + } + + private void Runtime_IoTestAdditionalPointUpdated(Iec61850PointSnapshot snapshot) + { + var coordinator = _activeIoTestMultiSessionCoordinator; + if (coordinator == null) + return; + + var point = snapshot.Point; + coordinator.EnqueueAdditional(new Iec61850EventEntry + { + Sequence = Interlocked.Increment(ref _ioTestObservationSequence), + DeviceId = point.DeviceId, + PointKey = point.PointKey, + DeviceTimestamp = snapshot.DeviceTimestamp, + DeviceName = point.DeviceName, + IpAddress = point.IpAddress, + SignalName = point.SignalName, + IecReference = point.IecReference, + OldValue = snapshot.PreviousValue, + NewValue = snapshot.Value, + Quality = snapshot.Quality, + SourceMode = snapshot.SourceMode, + Reason = snapshot.Reason + }); + } +} diff --git a/MainWindow.IoTesting.SclAppend.cs b/MainWindow.IoTesting.SclAppend.cs new file mode 100644 index 000000000..a10852114 --- /dev/null +++ b/MainWindow.IoTesting.SclAppend.cs @@ -0,0 +1,168 @@ +using System.IO; +using System.Threading; +using System.Windows; +using ArIED61850Tester.Models.IoTesting; +using ArIED61850Tester.Services.IoTesting; +using Microsoft.Win32; + +namespace ArIED61850Tester; + +public partial class MainWindow +{ + // P0.4 owns only the short append transaction. SCL parsing happens before this gate, + // and independent IED connection/evidence workflows do not acquire it. + private readonly SemaphoreSlim _ioFatSclAppendGate = new(1, 1); + + internal async Task OpenSclForLoadedFatAppendAsync(IoListTestingWindow window) + { + ArgumentNullException.ThrowIfNull(window); + if (!ReferenceEquals(window, _loadedIoFatWindow) || !window.IsLoaded) + return; + + var dialog = new OpenFileDialog + { + Title = "Add IEC 61850 SCL to loaded FAT workspace", + Filter = "IEC 61850 SCL (*.scd;*.cid;*.icd;*.iid;*.ssd)|*.scd;*.cid;*.icd;*.iid;*.ssd|XML SCL (*.xml)|*.xml|All files (*.*)|*.*", + CheckFileExists = true, + Multiselect = true + }; + if (dialog.ShowDialog(this) != true) + return; + + var result = await window.ImportAdditionalSclSourcesAsync(this, dialog.FileNames); + if (!result.Succeeded) + { + MessageBox.Show( + this, + result.Message, + "SCL could not be added to FAT", + MessageBoxButton.OK, + MessageBoxImage.Warning); + } + } + + internal async Task AppendSclIedsToLoadedFatAsync( + IoListTestingWindow window, + IReadOnlyCollection sclPaths, + bool? useStaticSelection = null, + bool selectionAlreadyApplied = false) + { + ArgumentNullException.ThrowIfNull(window); + ArgumentNullException.ThrowIfNull(sclPaths); + if (!ReferenceEquals(window, _loadedIoFatWindow) || !window.IsLoaded) + return IoTestSessionActionResult.Failure("The target FAT workspace is no longer active."); + + var requestedPaths = sclPaths + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + if (requestedPaths.Length == 0) + return IoTestSessionActionResult.Failure("Select at least one SCL source to add to FAT."); + + // Parse and validate without taking the workspace mutation gate. Existing IEDs may + // keep connecting/monitoring, and the single active evidence session keeps its + // immutable capture scope while ARIEC builds the new source workspaces. + SetStatus($"Parsing {requestedPaths.Length} additional SCL source(s) · existing FAT IED workflows remain active…"); + var import = await _ioFatSclProjectImportService.ImportAdditionalAsync( + requestedPaths, + _applicationCancellation.Token); + + await _ioFatSclAppendGate.WaitAsync(_applicationCancellation.Token); + try + { + // Revalidate after the asynchronous parse. The operator may have closed or + // replaced the workspace while the files were being inspected. + if (!ReferenceEquals(window, _loadedIoFatWindow) || !window.IsLoaded) + return IoTestSessionActionResult.Failure("The target FAT workspace changed before the SCL append could be committed."); + + var existingSources = window.Project.Sources + .ToDictionary(source => source.Sha256, StringComparer.OrdinalIgnoreCase); + var uniqueImportedSources = import.Sources + .Where(source => !existingSources.ContainsKey(source.Sha256)) + .ToArray(); + + var existingIedKeys = window.Project.Ieds + .Select(ied => $"{ied.IedName.Trim()}|{ied.IpAddress.Trim()}") + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var addedIeds = import.Project.Ieds + .Where(ied => existingIedKeys.Add($"{ied.IedName.Trim()}|{ied.IpAddress.Trim()}")) + .ToArray(); + if (addedIeds.Length == 0) + { + return IoTestSessionActionResult.Failure( + "The selected SCL source contains no new IED endpoint; every imported IED is already present in this FAT workspace."); + } + + var selectionMode = useStaticSelection.HasValue + ? (useStaticSelection.Value + ? SclSignalSelectionMode.StaticDataSet + : SclSignalSelectionMode.Manual) + : PromptSclSignalSelectionMode(window, addedIeds.Length); + if (!selectionMode.HasValue) + return IoTestSessionActionResult.Failure("SCL append cancelled before the shared workspace was changed."); + + // Persist source bytes before publishing the new plans to the active project. + // This gate serializes only append operations; it does not lock any IED's + // connection preparation or evidence journal. + if (window.Storage != null && uniqueImportedSources.Length > 0) + { + await window.Storage.AddSourcesAsync( + import.Project, + import.SourceInputs, + _applicationCancellation.Token); + } + + foreach (var ied in addedIeds) + window.Project.Ieds.Add(ied); + + var allSources = window.Project.Sources + .Concat(uniqueImportedSources) + .GroupBy(source => source.Sha256, StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) + .OrderBy(source => source.SourceId, StringComparer.Ordinal) + .ToArray(); + window.Project.SetSources(allSources, IoFatSourceIdentity.ComputeSetFingerprint(allSources)); + + foreach (var point in addedIeds.SelectMany(ied => ied.TestPoints)) + point.PropertyChanged += IoFatSelectionPoint_PropertyChanged; + + // Existing running/preparing IEDs are deliberately untouched. Only the newly + // appended endpoints join Engineering and the loaded FAT explorer. + SynchronizeImportedSclFatWithEngineering(window.Project, addedIeds); + RegisterSharedSclSourcePaths(window.Project, addedIeds, import.SourceInputs); + if (selectionMode == SclSignalSelectionMode.Manual && !selectionAlreadyApplied) + { + await ApplyManualSelectionToFatProjectAsync( + window.Project, + addedIeds, + window, + resetSelection: true); + } + else + { + foreach (var ied in addedIeds) + { + var device = ResolveIoTestDevice(ied.LiveDeviceId) + ?? ResolveIoTestDevice(ied.IpAddress) + ?? ResolveIoTestDevice(ied.IedName); + if (device is not null) + MarkSharedSelectionAuthority(device); + } + } + window.RegisterAddedIeds(addedIeds); + window.Storage?.ScheduleSave(); + + var warningCount = import.Findings.Count(finding => + finding.Severity.Equals("Warning", StringComparison.OrdinalIgnoreCase) || + finding.Severity.Equals("Error", StringComparison.OrdinalIgnoreCase)); + var message = $"Added {addedIeds.Length} IED(s) and {addedIeds.Sum(ied => ied.TestPoints.Count)} static DataSet point(s) to the loaded FAT workspace" + + (warningCount == 0 ? "." : $" · {warningCount} import warning(s)."); + SetStatus(message); + return IoTestSessionActionResult.Success(message); + } + finally + { + _ioFatSclAppendGate.Release(); + } + } +} diff --git a/MainWindow.IoTesting.cs b/MainWindow.IoTesting.cs index bf2983448..52f1cfc91 100644 --- a/MainWindow.IoTesting.cs +++ b/MainWindow.IoTesting.cs @@ -1,4 +1,5 @@ using System.IO; +using System.ComponentModel; using System.Text.Json; using System.Windows; using System.Windows.Controls; @@ -22,7 +23,10 @@ public partial class MainWindow private IoTestSessionController? _activeIoTestSessionController; private long _ioTestObservationSequence; private int? _pollingIntervalBeforeIoFat; - private int _ioFatEvidenceDrainDispatchActive; + private IoTestProject? _ioFatSelectionBridgeProject; + private bool _ioFatSelectionBridgeActive; + private readonly HashSet _ioFatSelectionSaveDeviceIds = new(StringComparer.OrdinalIgnoreCase); + private bool _ioFatSelectionSaveScheduled; protected override void OnInitialized(EventArgs e) { @@ -157,7 +161,7 @@ private Border CreateIoListTestingCard() }); content.Children.Add(new TextBlock { - Text = "Select one or multiple CID / ICD / IID / SCD / SSD files. Every static DataSet member becomes FAT scope. Excel import remains available for legacy IO-list projects.", + Text = "Select one or multiple CID / ICD / IID / SCD / SSD files. Use static DataSet membership or choose SCL signals manually; Engineering and FAT share the same workspace selection.", TextWrapping = TextWrapping.Wrap, FontSize = 13.4, Foreground = TryFindResource("Muted") as Brush, @@ -186,7 +190,7 @@ private Border CreateIoListTestingCard() new Thickness(0, 0, 0, 10))); content.Children.Add(new TextBlock { - Text = "Static DataSet authority · Value 1 / Value 2 evidence · autosave · portable continuation", + Text = "Shared SCL signal authority · Value 1 / Value 2 evidence · autosave · portable continuation", Style = TryFindResource("Caption") as Style, TextWrapping = TextWrapping.Wrap }); @@ -262,13 +266,38 @@ private async void OpenSclFatTesting_Click(object sender, RoutedEventArgs e) if (dialog.ShowDialog(this) != true) return; - SetStatus($"Building FAT scope from {dialog.FileNames.Length} SCL source(s)…"); + await OpenSclFatSourcesAsync( + dialog.FileNames, + selectionMode: null, + promptForSelection: true); + } + + private async Task OpenSclFatSourcesAsync( + IReadOnlyCollection sclPaths, + SclSignalSelectionMode? selectionMode, + bool promptForSelection = false) + { + SetStatus($"Building shared Engineering/FAT workspace from {sclPaths.Count} SCL source(s)…"); try { var import = await _ioFatSclProjectImportService.ImportAsync( - dialog.FileNames, + sclPaths, cancellationToken: _applicationCancellation.Token); - if (import.Project.SignalCount == 0) + + if (promptForSelection && !selectionMode.HasValue) + { + selectionMode = PromptSclSignalSelectionMode(this, import.Project.Ieds.Count); + if (!selectionMode.HasValue) + { + SetStatus("SCL FAT import cancelled before the workspace was changed."); + return; + } + } + + // Static mode is intentionally strict: it cannot invent FAT rows when SCL has + // no static DataSet membership. Manual mode is different; its selected SCL + // signals are materialized after the shared Engineering workspace is attached. + if (selectionMode == SclSignalSelectionMode.StaticDataSet && import.Project.SignalCount == 0) { var details = string.Join( Environment.NewLine, @@ -276,8 +305,8 @@ private async void OpenSclFatTesting_Click(object sender, RoutedEventArgs e) SetStatus("SCL FAT import contains no static DataSet members."); MessageBox.Show( this, - "The selected SCL source(s) contain no static DataSet members. ARSAS did not fabricate FAT rows.\n\n" + details, - "No FAT DataSet scope", + "The selected SCL source(s) contain no static DataSet members for Static DataSet mode. Choose Signals Manually to build FAT scope from valid SCL signals.\n\n" + details, + "No static DataSet FAT scope", MessageBoxButton.OK, MessageBoxImage.Information); return; @@ -290,6 +319,36 @@ private async void OpenSclFatTesting_Click(object sender, RoutedEventArgs e) IoTestingEvidenceRoot(), CreateIoTestSession, _applicationCancellation.Token); + // A direct SCL FAT import is also an Engineering import. Attach the exact + // already-parsed ARIEC workspace now, after persisted FAT choices have been + // restored but before the window is shown, and establish one shared selection + // authority. No discovery or second model is created during mode switches. + SynchronizeImportedSclFatWithEngineering(launch.Project); + RegisterSharedSclSourcePaths(launch.Project, launch.Project.Ieds, import.SourceInputs); + if (selectionMode == SclSignalSelectionMode.Manual) + { + await ApplyManualSelectionToFatProjectAsync( + launch.Project, + launch.Project.Ieds, + this, + resetSelection: true); + } + else + { + foreach (var ied in launch.Project.Ieds) + { + var device = ResolveIoTestDevice(ied.LiveDeviceId) + ?? ResolveIoTestDevice(ied.IpAddress) + ?? ResolveIoTestDevice(ied.IedName); + if (device is not null) + MarkSharedSelectionAuthority(device); + } + } + + // Manual non-DataSet rows are created after bootstrap restoration. Persist that + // shared workspace projection before the FAT window is exposed. + launch.Workspace.ScheduleSave(); + var warningCount = import.Findings.Count(finding => finding.Severity.Equals("Warning", StringComparison.OrdinalIgnoreCase) || finding.Severity.Equals("High", StringComparison.OrdinalIgnoreCase) || @@ -412,6 +471,7 @@ private async void OpenIoListPackage_Click(object sender, RoutedEventArgs e) private Task ShowIoTestingWorkspaceAsync(IoTestWorkspaceLaunchResult launch, int importWarningCount) { + AttachIoFatSelectionBridge(launch.Project); var binding = _ioTestLiveBindingService.Bind(launch.Project, Devices); var restoredText = launch.RestoredProgress ? "saved progress restored" : "new project"; SetStatus( @@ -442,6 +502,7 @@ private Task ShowIoTestingWorkspaceAsync(IoTestWorkspaceLaunchResult launch, int void WindowClosed(object? sender, EventArgs args) { window.Closed -= WindowClosed; + DetachIoFatSelectionBridge(launch.Project); _runtime.PointUpdated -= Runtime_IoTestPointUpdated; if (ReferenceEquals(_activeIoTestSessionController, controller)) _activeIoTestSessionController = null; @@ -469,6 +530,196 @@ void WindowClosed(object? sender, EventArgs args) return Task.CompletedTask; } + internal async Task AddSclIedsToLoadedFatAsync( + IoListTestingWindow window, + IReadOnlyCollection sclPaths) + { + ArgumentNullException.ThrowIfNull(window); + ArgumentNullException.ThrowIfNull(sclPaths); + if (!ReferenceEquals(window, _loadedIoFatWindow) || !window.IsLoaded) + return IoTestSessionActionResult.Failure("The target FAT workspace is no longer active."); + if (!window.CanAddFatIed) + return IoTestSessionActionResult.Failure("Wait for the current IED connection preparation before importing another SCL source."); + + SetStatus($"Adding {sclPaths.Count} SCL source(s) to the current FAT workspace…"); + var import = await _ioFatSclProjectImportService.ImportAdditionalAsync( + sclPaths, + _applicationCancellation.Token); + + var existingSources = window.Project.Sources + .ToDictionary(source => source.Sha256, StringComparer.OrdinalIgnoreCase); + var uniqueImportedSources = import.Sources + .Where(source => !existingSources.ContainsKey(source.Sha256)) + .ToArray(); + + var existingIedKeys = window.Project.Ieds + .Select(ied => $"{ied.IedName.Trim()}|{ied.IpAddress.Trim()}") + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var addedIeds = import.Project.Ieds + .Where(ied => existingIedKeys.Add($"{ied.IedName.Trim()}|{ied.IpAddress.Trim()}")) + .ToArray(); + if (addedIeds.Length == 0) + { + return IoTestSessionActionResult.Failure( + "The selected SCL source contains no new IED endpoint; every IED is already present in this FAT workspace."); + } + + if (window.Storage != null && uniqueImportedSources.Length > 0) + { + await window.Storage.AddSourcesAsync( + import.Project, + import.SourceInputs, + _applicationCancellation.Token); + } + + foreach (var ied in addedIeds) + window.Project.Ieds.Add(ied); + + var allSources = window.Project.Sources + .Concat(uniqueImportedSources) + .GroupBy(source => source.Sha256, StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) + .OrderBy(source => source.SourceId, StringComparer.Ordinal) + .ToArray(); + window.Project.SetSources(allSources, IoFatSourceIdentity.ComputeSetFingerprint(allSources)); + + foreach (var point in addedIeds.SelectMany(ied => ied.TestPoints)) + point.PropertyChanged += IoFatSelectionPoint_PropertyChanged; + // The running IED and its immutable session mapping are deliberately left + // untouched. Only newly imported endpoints join the shared workspace. + SynchronizeImportedSclFatWithEngineering(window.Project, addedIeds); + window.RegisterAddedIeds(addedIeds); + window.Storage?.ScheduleSave(); + + var warningCount = import.Findings.Count(finding => + finding.Severity.Equals("Warning", StringComparison.OrdinalIgnoreCase) || + finding.Severity.Equals("Error", StringComparison.OrdinalIgnoreCase)); + var message = $"Added {addedIeds.Length} IED(s) and {addedIeds.Sum(ied => ied.TestPoints.Count)} static DataSet point(s) to FAT" + + (warningCount == 0 ? "." : $" · {warningCount} import warning(s)."); + SetStatus(message); + return IoTestSessionActionResult.Success(message); + } + + private void AttachIoFatSelectionBridge(IoTestProject project) + { + DetachIoFatSelectionBridge(_ioFatSelectionBridgeProject); + _ioFatSelectionBridgeProject = project; + foreach (var point in project.Ieds.SelectMany(ied => ied.TestPoints)) + point.PropertyChanged += IoFatSelectionPoint_PropertyChanged; + } + + private void DetachIoFatSelectionBridge(IoTestProject? project) + { + if (project is null) + return; + foreach (var point in project.Ieds.SelectMany(ied => ied.TestPoints)) + point.PropertyChanged -= IoFatSelectionPoint_PropertyChanged; + if (ReferenceEquals(_ioFatSelectionBridgeProject, project)) + _ioFatSelectionBridgeProject = null; + } + + private void IoFatSelectionPoint_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (_ioFatSelectionBridgeActive || sender is not IoTestPointPlan point || + e.PropertyName != nameof(IoTestPointPlan.WorkspaceSelected)) + { + return; + } + + var device = ResolveIoTestDevice(point.LiveDeviceId) + ?? ResolveIoTestDevice(point.IpAddress) + ?? ResolveIoTestDevice(point.IedName); + if (device is null) + return; + + _ioFatSelectionBridgeActive = true; + try + { + if (IoFatEngineeringSelectionBridge.ApplyFatPointSelection(point, device)) + { + ScheduleIoFatSelectionSave(device); + _loadedIoFatWindow?.Storage?.ScheduleSave(); + RaiseWorkspaceCounts(); + } + } + finally + { + _ioFatSelectionBridgeActive = false; + } + } + + private void ScheduleIoFatSelectionSave(Iec61850MonitorDevice device) + { + _ioFatSelectionSaveDeviceIds.Add(device.DeviceId); + if (_ioFatSelectionSaveScheduled) + return; + + _ioFatSelectionSaveScheduled = true; + Dispatcher.BeginInvoke(new Action(() => + { + _ioFatSelectionSaveScheduled = false; + var deviceIds = _ioFatSelectionSaveDeviceIds.ToArray(); + _ioFatSelectionSaveDeviceIds.Clear(); + foreach (var deviceId in deviceIds) + { + var target = ResolveIoTestDevice(deviceId); + if (target is not null) + SaveSignalSelectionMemory(target); + } + }), DispatcherPriority.Background); + } + + private void SynchronizeEngineeringSelectionToFat( + SignalDefinition signal, + Iec61850MonitorDevice device) + { + var project = _ioFatSelectionBridgeProject; + if (_ioFatSelectionBridgeActive || project is null) + return; + + var ied = project.Ieds.FirstOrDefault(candidate => + candidate.LiveDeviceId.Equals(device.DeviceId, StringComparison.OrdinalIgnoreCase) || + (candidate.IedName.Equals(device.SclIedName, StringComparison.OrdinalIgnoreCase) && + candidate.IpAddress.Equals(device.IpAddress, StringComparison.OrdinalIgnoreCase)) || + candidate.IedName.Equals(device.Name, StringComparison.OrdinalIgnoreCase)); + if (ied is null) + return; + + _ioFatSelectionBridgeActive = true; + try + { + var previousCount = ied.TestPoints.Count; + var changed = IoFatEngineeringSelectionBridge.ApplyEngineeringSignalSelection( + signal, + signal.IsSelected, + ied, + device); + if (!changed) + return; + + if (ied.TestPoints.Count > previousCount) + { + foreach (var point in ied.TestPoints.Skip(previousCount)) + point.PropertyChanged += IoFatSelectionPoint_PropertyChanged; + } + + ScheduleIoFatSelectionSave(device); + _loadedIoFatWindow?.Storage?.ScheduleSave(); + _loadedIoFatWindow?.NotifySharedWorkspacePointsChanged(ied); + RaiseWorkspaceCounts(); + } + finally + { + _ioFatSelectionBridgeActive = false; + } + } + + private void SynchronizeAllEngineeringSelectionsToFat(Iec61850MonitorDevice device) + { + foreach (var signal in device.Signals) + SynchronizeEngineeringSelectionToFat(signal, device); + } + private IoTestSessionController CreateIoTestSession(IoTestProject project, string evidenceRoot) => new( project, @@ -480,24 +731,10 @@ private void DispatchIoFatEvidence(Action action) { ArgumentNullException.ThrowIfNull(action); - var priority = Volatile.Read(ref _ioFatEvidenceDrainDispatchActive) == 0 - ? DispatcherPriority.DataBind - : DispatcherPriority.Background; - - Dispatcher.BeginInvoke( - new Action(() => - { - Interlocked.Increment(ref _ioFatEvidenceDrainDispatchActive); - try - { - action(); - } - finally - { - Interlocked.Decrement(ref _ioFatEvidenceDrainDispatchActive); - } - }), - priority); + // Live evidence is important but must never outrank mouse/keyboard input. + // Bounded drains still preserve every digital edge; Background priority lets + // scrolling and operator Capture clicks run between those short batches. + Dispatcher.BeginInvoke(action, DispatcherPriority.Background); } private static string IoTestingProjectsRoot() => Path.Combine( diff --git a/MainWindow.SharedSclWorkspace.cs b/MainWindow.SharedSclWorkspace.cs new file mode 100644 index 000000000..b2e29c09e --- /dev/null +++ b/MainWindow.SharedSclWorkspace.cs @@ -0,0 +1,179 @@ +using System.IO; +using System.Windows; +using ArIED61850Tester.Models; +using ArIED61850Tester.Models.IoTesting; +using ArIED61850Tester.Services.IoTesting; + +namespace ArIED61850Tester; + +public partial class MainWindow +{ + private enum SclSignalSelectionMode + { + StaticDataSet, + Manual + } + + // This records that the operator has made an explicit selection decision for the + // shared SCL device. It is intentionally separate from "any selected signal" so an + // intentionally empty manual selection is preserved when Engineering opens FAT. + private readonly HashSet _sharedSclSelectionAuthorityDeviceIds = + new(StringComparer.OrdinalIgnoreCase); + + private SclSignalSelectionMode? PromptSclSignalSelectionMode(Window owner, int iedCount) + { + var dialog = new SclSignalSelectionModeWindow(iedCount) + { + Owner = owner + }; + if (dialog.ShowDialog() != true) + return null; + + return dialog.UseStaticDataSet + ? SclSignalSelectionMode.StaticDataSet + : SclSignalSelectionMode.Manual; + } + + private void ApplyStaticDataSetSelection(Iec61850MonitorDevice device) + { + device.BeginBulkSignalSelection(); + try + { + foreach (var signal in device.Signals) + signal.IsSelected = !string.IsNullOrWhiteSpace(signal.DataSetReference); + } + finally + { + device.EndBulkSignalSelection(); + } + + SynchronizeAllEngineeringSelectionsToFat(device); + _sharedSclSelectionAuthorityDeviceIds.Add(device.DeviceId); + SaveSignalSelectionMemory(device); + device.RefreshComputed(); + } + + private void ClearSharedSignalSelection(Iec61850MonitorDevice device) + { + device.BeginBulkSignalSelection(); + try + { + foreach (var signal in device.Signals) + signal.IsSelected = false; + } + finally + { + device.EndBulkSignalSelection(); + } + } + + private void MarkSharedSelectionAuthority(Iec61850MonitorDevice device) + { + _sharedSclSelectionAuthorityDeviceIds.Add(device.DeviceId); + SaveSignalSelectionMemory(device); + } + + private string[] CurrentEngineeringSclSourcePaths() + => Devices + .Select(device => device.SclSourcePath) + .Where(path => !string.IsNullOrWhiteSpace(path) && File.Exists(path)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + private void RegisterSharedSclSourcePaths( + IoTestProject project, + IEnumerable ieds, + IReadOnlyCollection sourceInputs) + { + var uniquePathByFileName = sourceInputs + .GroupBy(input => Path.GetFileName(input.FilePath), StringComparer.OrdinalIgnoreCase) + .Where(group => group.Count() == 1) + .ToDictionary( + group => group.Key, + group => Path.GetFullPath(group.Single().FilePath), + StringComparer.OrdinalIgnoreCase); + var sourceById = project.Sources.ToDictionary(source => source.SourceId, StringComparer.OrdinalIgnoreCase); + + foreach (var ied in ieds) + { + var device = ResolveIoTestDevice(ied.LiveDeviceId) + ?? ResolveIoTestDevice(ied.IpAddress) + ?? ResolveIoTestDevice(ied.IedName); + if (device is null) + continue; + + IoFatSourceDescriptor? source = null; + var sourceId = ied.TestPoints + .Select(point => point.SignalAddress) + .FirstOrDefault(value => !string.IsNullOrWhiteSpace(value) && sourceById.ContainsKey(value)); + if (!string.IsNullOrWhiteSpace(sourceId)) + source = sourceById[sourceId]; + + // Manual-only SCL workspaces can legitimately have zero static DataSet rows, + // so source identity must not depend on finding a FAT point first. + if (source is null && !string.IsNullOrWhiteSpace(device.SclSourceSha256)) + { + source = project.Sources.FirstOrDefault(candidate => candidate.Sha256.Equals( + device.SclSourceSha256, + StringComparison.OrdinalIgnoreCase)); + } + if (source is null && project.Sources.Count == 1) + source = project.Sources[0]; + if (source is null && !string.IsNullOrWhiteSpace(device.SclSourcePath)) + { + var currentName = Path.GetFileName(device.SclSourcePath); + source = project.Sources.FirstOrDefault(candidate => candidate.FileName.Equals( + currentName, + StringComparison.OrdinalIgnoreCase)); + } + if (source is null || !uniquePathByFileName.TryGetValue(source.FileName, out var sourcePath)) + continue; + + device.SclSourcePath = sourcePath; + device.SclSourceSha256 = source.Sha256; + } + } + + private async Task ApplyManualSelectionToFatProjectAsync( + IoTestProject project, + IEnumerable ieds, + Window owner, + bool resetSelection) + { + foreach (var ied in ieds) + { + var device = ResolveIoTestDevice(ied.LiveDeviceId) + ?? ResolveIoTestDevice(ied.IpAddress) + ?? ResolveIoTestDevice(ied.IedName); + if (device is null) + continue; + + if (resetSelection) + { + ClearSharedSignalSelection(device); + foreach (var point in ied.TestPoints) + point.WorkspaceSelected = false; + } + + await OpenSignalSelectionWizardAsync( + device, + autoStartAfterSave: false, + ownerOverride: owner); + + // The FAT window is not yet attached during an initial FAT import, so perform + // the same bridge operation explicitly. Selected non-DataSet SCL signals are + // materialized here as persistent FAT rows; existing FAT TEST/disposition state + // is never rewritten by Engineering selection. + foreach (var signal in device.Signals) + { + IoFatEngineeringSelectionBridge.ApplyEngineeringSignalSelection( + signal, + signal.IsSelected, + ied, + device); + } + + MarkSharedSelectionAuthority(device); + } + } +} diff --git a/MainWindow.WorkspaceModeSwitch.cs b/MainWindow.WorkspaceModeSwitch.cs index 425d0fe28..71983c289 100644 --- a/MainWindow.WorkspaceModeSwitch.cs +++ b/MainWindow.WorkspaceModeSwitch.cs @@ -96,7 +96,7 @@ private void InstallWorkspaceModeSwitch() FontSize = 10.5, FontWeight = FontWeights.Bold, Cursor = Cursors.Hand, - ToolTip = "Load another IO List workbook or portable ARSAS project" + ToolTip = "Load SCL/CID, IO List workbook, or portable ARSAS project" }; menuButton.Click += OpenIoFatWorkspaceMenu_Click; modes.Children.Add(menuButton); @@ -136,11 +136,21 @@ internal void ShowEngineeringWorkspaceFromFat(IoListTestingWindow window) UpdateIoFatWorkspaceModeState(); } - private void OpenOrResumeIoFatWorkspace_Click(object sender, RoutedEventArgs e) + private async void OpenOrResumeIoFatWorkspace_Click(object sender, RoutedEventArgs e) { if (ShowLoadedIoFatWorkspace()) return; + // Engineering and FAT are two views over the same imported SCL workspace. If an + // Engineering SCL is already open, the primary FAT mode button projects that exact + // source and its existing checkbox authority without asking for another import. + var sharedSources = CurrentEngineeringSclSourcePaths(); + if (sharedSources.Length > 0) + { + await OpenSclFatSourcesAsync(sharedSources, selectionMode: null); + return; + } + if (sender is Button anchor) OpenIoFatWorkspaceMenu(anchor); } @@ -189,6 +199,26 @@ private void OpenIoFatWorkspaceMenu(Button anchor) menu.Items.Add(new Separator()); } + var importScl = new MenuItem + { + Header = _loadedIoFatWindow is { IsLoaded: true } + ? "Add SCL / CID to loaded FAT workspace" + : "Import SCL / CID files" + }; + importScl.Click += (_, _) => + { + if (_loadedIoFatWindow is { IsLoaded: true } loaded) + { + // P0.4: SCL is additive while a FAT workspace is loaded. Existing IED + // connections/session evidence stay alive; replacement is reserved for + // explicit workbook/project open flows below. + _ = OpenSclForLoadedFatAppendAsync(loaded); + return; + } + + OpenSclFatTesting_Click(this, new RoutedEventArgs()); + }; + var importWorkbook = new MenuItem { Header = _loadedIoFatWindow == null @@ -207,6 +237,7 @@ private void OpenIoFatWorkspaceMenu(Button anchor) openProject.Click += (_, _) => QueueIoFatWorkspaceReplacement( () => OpenIoListPackage_Click(this, new RoutedEventArgs())); + menu.Items.Add(importScl); menu.Items.Add(importWorkbook); menu.Items.Add(openProject); menu.IsOpen = true; diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index 408cb0f29..4f55a7528 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -118,7 +118,9 @@ public MainWindow() _uiFlushTimer = new DispatcherTimer(DispatcherPriority.Background) { - Interval = TimeSpan.FromMilliseconds(100) + // Five UI projections per second leave dispatcher capacity for smooth grid + // scrolling while the acquisition/evidence pipelines retain full fidelity. + Interval = TimeSpan.FromMilliseconds(200) }; _uiFlushTimer.Tick += UiFlushTimer_Tick; @@ -158,63 +160,85 @@ private async void OpenScl_Click(object sender, RoutedEventArgs e) { var dialog = new OpenFileDialog { - Title = "Open IEC 61850 SCL", + Title = "Open one or more IEC 61850 SCL files", Filter = "IEC 61850 SCL (*.scd;*.cid;*.icd;*.iid;*.ssd)|*.scd;*.cid;*.icd;*.iid;*.ssd|XML SCL (*.xml)|*.xml|All files (*.*)|*.*", CheckFileExists = true, - Multiselect = false + Multiselect = true }; if (dialog.ShowDialog(this) != true) return; - var sourceName = Path.GetFileName(dialog.FileName); - SetStatus($"Opening {sourceName} as an offline IEC 61850 design model…"); + var sourceLabel = dialog.FileNames.Length == 1 + ? Path.GetFileName(dialog.FileNames[0]) + : $"{dialog.FileNames.Length} SCL files"; + SetStatus($"Opening {sourceLabel} as one shared offline IEC 61850 workspace…"); try { - var document = await _sclWorkspaceService.OpenAsync( - dialog.FileName, - cancellationToken: _applicationCancellation.Token); - LogSclFindings(sourceName, document.Findings); - - if (document.Ieds.Count == 0) - { - SetStatus($"{sourceName}: no IED model was found."); - AddLog("WARN", "SCL", $"{sourceName}: the engine returned no IED workspace."); - return; - } - var added = 0; var refreshed = 0; var retained = 0; + var offlineCount = 0; + var endpointCount = 0; Iec61850MonitorDevice? firstImported = null; + var importedDevices = new List(); + var importedSources = new List<(string Path, string Sha256)>(); - foreach (var workspace in document.Ieds) + foreach (var sourcePath in dialog.FileNames.Distinct(StringComparer.OrdinalIgnoreCase)) { - var device = Devices.FirstOrDefault(item => - item.SclSourceSha256.Equals(document.SourceSha256, StringComparison.OrdinalIgnoreCase) && - item.SclIedName.Equals(workspace.IedName, StringComparison.OrdinalIgnoreCase) && - item.SclAccessPointName.Equals(workspace.AccessPointName, StringComparison.OrdinalIgnoreCase)); - - if (device != null && (device.IsConnected || device.IsBusy || device.IsMonitoring)) + var sourceName = Path.GetFileName(sourcePath); + SetStatus($"Opening {sourceName} · {importedSources.Count + 1} of {dialog.FileNames.Length}…"); + var document = await _sclWorkspaceService.OpenAsync( + sourcePath, + cancellationToken: _applicationCancellation.Token); + LogSclFindings(sourceName, document.Findings); + + if (document.Ieds.Count == 0) { - retained++; - firstImported ??= device; + AddLog("WARN", "SCL", $"{sourceName}: the engine returned no IED workspace."); continue; } - var signals = SclWorkspaceSignalMapper.BuildSignals(workspace); - if (device == null) - { - device = new Iec61850MonitorDevice(); - Devices.Add(device); - added++; - } - else + importedSources.Add((sourcePath, document.SourceSha256)); + offlineCount += document.Ieds.Count(item => item.CanBrowseOffline); + endpointCount += document.Ieds.Count(item => !item.RequiresEndpointBinding); + + foreach (var workspace in document.Ieds) { - refreshed++; + var device = Devices.FirstOrDefault(item => + item.SclSourceSha256.Equals(document.SourceSha256, StringComparison.OrdinalIgnoreCase) && + item.SclIedName.Equals(workspace.IedName, StringComparison.OrdinalIgnoreCase) && + item.SclAccessPointName.Equals(workspace.AccessPointName, StringComparison.OrdinalIgnoreCase)); + + if (device != null && (device.IsConnected || device.IsBusy || device.IsMonitoring)) + { + retained++; + firstImported ??= device; + importedDevices.Add(device); + continue; + } + + var signals = SclWorkspaceSignalMapper.BuildSignals(workspace); + if (device == null) + { + device = new Iec61850MonitorDevice(); + Devices.Add(device); + added++; + } + else + { + refreshed++; + } + + ApplySclWorkspaceToDevice(device, document, workspace, signals); + firstImported ??= device; + importedDevices.Add(device); } + } - ApplySclWorkspaceToDevice(device, document, workspace, signals); - firstImported ??= device; + if (importedDevices.Count == 0) + { + SetStatus($"{sourceLabel}: no IED model was found."); + return; } if (firstImported != null) @@ -223,26 +247,81 @@ private async void OpenScl_Click(object sender, RoutedEventArgs e) UpdateNavigationVisuals(0, animate: true); RaiseWorkspaceCounts(); - var offlineCount = document.Ieds.Count(item => item.CanBrowseOffline); - var endpointCount = document.Ieds.Count(item => !item.RequiresEndpointBinding); - var status = $"{sourceName}: {document.Ieds.Count} IED/AP workspace(s), {offlineCount} offline model(s), {endpointCount} MMS endpoint(s) — {added} added, {refreshed} refreshed, {retained} active retained."; + var status = $"{sourceLabel}: {importedDevices.Count} IED/AP workspace(s), {offlineCount} offline model(s), {endpointCount} MMS endpoint(s) — {added} added, {refreshed} refreshed, {retained} active retained."; SetStatus(status); AddLog("INFO", "SCL", status); - if (firstImported != null && firstImported.Signals.Count > 0) + var selectableDevices = importedDevices + .Where(device => device.Signals.Count > 0) + .Distinct() + .ToArray(); + SclSignalSelectionMode? sharedSelectionMode = null; + if (selectableDevices.Length > 0) + { + var selectionMode = PromptSclSignalSelectionMode(this, selectableDevices.Length); + sharedSelectionMode = selectionMode; + if (!selectionMode.HasValue) + { + AddLog("INFO", "SCL", "Shared signal selection was cancelled; the imported offline models remain available in Engineering."); + SetStatus($"{sourceLabel} imported · signal selection was not changed."); + } + else if (selectionMode == SclSignalSelectionMode.StaticDataSet) + { + foreach (var importedDevice in selectableDevices) + ApplyStaticDataSetSelection(importedDevice); + + AddLog("INFO", "SCL", $"Static DataSet selection is shared by Engineering and FAT for {selectableDevices.Length} IED workspace(s)."); + if (firstImported is { SelectedLiveSignalCount: > 0 }) + { + var connected = firstImported.IsConnected || + await ConnectUsingSavedModelAsync(firstImported); + if (connected && !firstImported.IsMonitoring) + await StartDeviceMonitorAsync(firstImported); + } + } + else + { + AddLog("INFO", "SCL", "Manual Signal Selection is shared by Engineering and FAT; each imported IED keeps its own checkbox state."); + foreach (var importedDevice in selectableDevices) + { + await OpenSignalSelectionWizardAsync( + importedDevice, + autoStartAfterSave: ReferenceEquals(importedDevice, firstImported)); + MarkSharedSelectionAuthority(importedDevice); + } + } + } + + // Importing through Engineering must update an already-loaded FAT project as + // the same workspace mutation. The selection decision has already been applied, + // so the hidden FAT view receives the new IEDs without a second prompt or reset. + if (_loadedIoFatWindow is { IsLoaded: true } loadedFat && sharedSelectionMode.HasValue) { - AddLog("INFO", "SCL", $"{firstImported.Name}: SCL model ready. Choose signals; saving the selection will continue to endpoint binding, connection, and monitoring."); - await OpenSignalSelectionWizardAsync(firstImported); + var newFatSourcePaths = importedSources + .Where(imported => !loadedFat.Project.Sources.Any(source => + source.Sha256.Equals(imported.Sha256, StringComparison.OrdinalIgnoreCase))) + .Select(imported => imported.Path) + .ToArray(); + if (newFatSourcePaths.Length > 0) + { + var append = await AppendSclIedsToLoadedFatAsync( + loadedFat, + newFatSourcePaths, + useStaticSelection: sharedSelectionMode == SclSignalSelectionMode.StaticDataSet, + selectionAlreadyApplied: true); + if (!append.Succeeded) + AddLog("WARN", "SCL/FAT", append.Message); + } } } catch (OperationCanceledException) { - SetStatus($"{sourceName}: SCL open cancelled."); + SetStatus($"{sourceLabel}: SCL open cancelled."); } catch (Exception ex) { - AddLog("ERROR", "SCL", $"Could not open {sourceName}: {ex.Message}"); - SetStatus($"{sourceName}: SCL open failed. Diagnostics is marked with !."); + AddLog("ERROR", "SCL", $"Could not open {sourceLabel}: {ex.Message}"); + SetStatus($"{sourceLabel}: SCL open failed. Diagnostics is marked with !."); MarkDiagnosticAlert(); MessageBox.Show( this, @@ -855,7 +934,8 @@ private void ApplySclLiveComparison(Iec61850MonitorDevice device, IReadOnlyList< private async Task OpenSignalSelectionWizardAsync( Iec61850MonitorDevice device, int restoredSelectionCount = -1, - bool autoStartAfterSave = true) + bool autoStartAfterSave = true, + Window? ownerOverride = null) { if (device.Signals.Count == 0) { @@ -874,7 +954,7 @@ private async Task OpenSignalSelectionWizardAsync( device, restoredSelectionCount < 0 ? device.SelectedSignalCount : restoredSelectionCount) { - Owner = this, + Owner = ownerOverride ?? this, ShowInTaskbar = false, WindowStartupLocation = WindowStartupLocation.CenterOwner }; @@ -900,6 +980,7 @@ void WizardClosed(object? sender, EventArgs args) return false; } + SynchronizeAllEngineeringSelectionsToFat(device); SaveSignalSelectionMemory(device); device.RefreshComputed(); RebuildControlFeedbackIndex(device); @@ -1721,6 +1802,7 @@ private void UiFlushTimer_Tick(object? sender, EventArgs e) var point = snapshot.Point; if (snapshot.Sequence < point.Sequence) continue; + var qualityChanged = !string.Equals(point.Quality, snapshot.Quality, StringComparison.Ordinal); var uiDetectedEdge = point.ApplyProcessValue(snapshot.Value); if (pending.HasValueEdge || snapshot.IsValueEdge || uiDetectedEdge) MarkPointRecentlyChanged(point); @@ -1730,8 +1812,11 @@ private void UiFlushTimer_Tick(object? sender, EventArgs e) point.Reason = snapshot.Reason; point.Status = snapshot.Status; point.Sequence = snapshot.Sequence; - UpdateCommandFeedbackFromLivePoint(point); - ReconcileAnnunciatorFromLivePoint(point); + if (uiDetectedEdge || pending.HasValueEdge || snapshot.IsValueEdge || qualityChanged) + { + UpdateCommandFeedbackFromLivePoint(point); + ReconcileAnnunciatorFromLivePoint(point); + } if (snapshot.IsReportTraffic) { var device = Devices.FirstOrDefault(item => item.DeviceId.Equals(point.DeviceId, StringComparison.OrdinalIgnoreCase)); @@ -1827,6 +1912,7 @@ private void Signal_PropertyChanged(object? sender, PropertyChangedEventArgs e) if (owner.IsBulkSignalSelectionUpdate) return; owner.ApplySignalSelectionChange(changedSignal, changedSignal.IsSelected); + SynchronizeEngineeringSelectionToFat(changedSignal, owner); } RaiseWorkspaceCounts(); } diff --git a/Models/IoTesting/FatVerificationModels.cs b/Models/IoTesting/FatVerificationModels.cs index e4587e290..b92770240 100644 --- a/Models/IoTesting/FatVerificationModels.cs +++ b/Models/IoTesting/FatVerificationModels.cs @@ -34,7 +34,17 @@ public enum FatValueSlot public enum FatEvidenceCaptureKind { AutomaticTransition, - OperatorSnapshot + OperatorSnapshot, + AutomaticValue, + OperatorRecapture +} + +public enum FatAutoCaptureStage +{ + WaitingValue1, + WaitingChange, + StabilizingValue2, + Complete } public sealed record FatValueEvidence( diff --git a/Models/IoTesting/IoTestModels.cs b/Models/IoTesting/IoTestModels.cs index 8f32bb206..cc6afbbfd 100644 --- a/Models/IoTesting/IoTestModels.cs +++ b/Models/IoTesting/IoTestModels.cs @@ -77,6 +77,7 @@ public sealed class IoTestPointRuntime : ObservableObject private IoTestTransitionEvidence? _offEvidence; private FatValueEvidence? _value1Evidence; private FatValueEvidence? _value2Evidence; + private FatAutoCaptureStage _autoCaptureStage = FatAutoCaptureStage.WaitingValue1; private string _statusReason = "Not started"; private int _attempt; private string _currentValue = "-"; @@ -148,6 +149,13 @@ internal set } } + [JsonIgnore] + public FatAutoCaptureStage AutoCaptureStage + { + get => _autoCaptureStage; + internal set => Set(ref _autoCaptureStage, value); + } + public string StatusReason { get => _statusReason; internal set => Set(ref _statusReason, value ?? string.Empty); } public int Attempt { get => _attempt; internal set => Set(ref _attempt, value); } public string CurrentValue { get => _currentValue; internal set => Set(ref _currentValue, string.IsNullOrWhiteSpace(value) ? "-" : value); } @@ -252,9 +260,12 @@ private static string BuildFatEvidenceToolTip(FatValueEvidence? evidence, string evidence.IedTimestamp, "yyyy-MM-dd HH:mm:ss.fff zzz", "not supplied"); + var rawRelay = evidence.IedTimestamp?.ToString("O", CultureInfo.InvariantCulture) ?? "not supplied"; + var captured = evidence.CapturedAt.ToString("O", CultureInfo.InvariantCulture); return $"{label}: {evidence.RawValue}\n" + - $"IED timestamp: {displayed}\n" + - $"ARSAS capture: {evidence.CapturedAt:O}\n" + + $"Displayed (rounded to nearest ms): {displayed}\n" + + $"Decoded IED timestamp (full precision): {rawRelay}\n" + + $"ARSAS capture (full precision): {captured}\n" + $"Quality: {evidence.Quality}\nSource: {evidence.AcquisitionSource}\n" + $"Capture: {evidence.CaptureKind}"; } @@ -274,6 +285,7 @@ internal void ResetAttempt() public sealed class IoTestPointPlan : ObservableObject { + private bool _workspaceSelected = true; private bool _testEnabled = true; private FatSignalDisposition _fatDisposition = FatSignalDisposition.Included; private IoTestLiveBindingState _liveBindingState = IoTestLiveBindingState.NotEvaluated; @@ -318,6 +330,20 @@ public IoTestPointPlan() public int SourceRow { get; init; } public FatSignalKind SignalKind { get; init; } = FatSignalKind.Discrete; public FatCaptureMode CaptureMode { get; init; } = FatCaptureMode.AutomaticTransition; + + // Shared Engineering/FAT workspace membership. This is intentionally separate from + // TestEnabled, which remains a FAT-only capture/evidence scope toggle. + public bool WorkspaceSelected + { + get => _workspaceSelected; + set + { + if (!Set(ref _workspaceSelected, value)) + return; + RaiseFatComputedProperties(); + } + } + public bool TestEnabled { get => _testEnabled; @@ -353,42 +379,58 @@ private set [JsonIgnore] public bool CanCaptureOperatorSnapshot => - IsOperatorSnapshot && IsIncludedInFat && TestEnabled && ImportReady && IsLiveBound; + IsOperatorSnapshot && WorkspaceSelected && IsIncludedInFat && TestEnabled && ImportReady && IsLiveBound; [JsonIgnore] - public bool IsFatEvidenceComplete => CaptureMode == FatCaptureMode.AutomaticTransition - ? Runtime.OnEvidence is not null && Runtime.OffEvidence is not null - : Runtime.Value1Evidence is not null && Runtime.Value2Evidence is not null; + public bool HasValue1Evidence => + Runtime.Value1Evidence is not null || + (CaptureMode == FatCaptureMode.AutomaticTransition && Runtime.OnEvidence is not null); [JsonIgnore] - public string Value1Text => CaptureMode == FatCaptureMode.AutomaticTransition - ? Runtime.OnEvidence?.RawValue ?? "—" - : Runtime.Value1Evidence?.RawValue ?? "—"; + public bool HasValue2Evidence => + Runtime.Value2Evidence is not null || + (CaptureMode == FatCaptureMode.AutomaticTransition && Runtime.OffEvidence is not null); [JsonIgnore] - public string Value2Text => CaptureMode == FatCaptureMode.AutomaticTransition - ? Runtime.OffEvidence?.RawValue ?? "—" - : Runtime.Value2Evidence?.RawValue ?? "—"; + public bool IsFatEvidenceComplete => HasValue1Evidence && HasValue2Evidence; [JsonIgnore] - public string Value1RelayTimestampText => CaptureMode == FatCaptureMode.AutomaticTransition - ? Runtime.OnRelayTimestampText - : Runtime.Value1RelayTimestampText; + public string Value1Text => Runtime.Value1Evidence?.RawValue ?? + (CaptureMode == FatCaptureMode.AutomaticTransition ? Runtime.OnEvidence?.RawValue : null) ?? + "—"; [JsonIgnore] - public string Value2RelayTimestampText => CaptureMode == FatCaptureMode.AutomaticTransition - ? Runtime.OffRelayTimestampText - : Runtime.Value2RelayTimestampText; + public string Value2Text => Runtime.Value2Evidence?.RawValue ?? + (CaptureMode == FatCaptureMode.AutomaticTransition ? Runtime.OffEvidence?.RawValue : null) ?? + "—"; [JsonIgnore] - public string Value1EvidenceToolTip => CaptureMode == FatCaptureMode.AutomaticTransition - ? Runtime.OnEvidenceToolTip - : Runtime.Value1EvidenceToolTip; + public string Value1RelayTimestampText => Runtime.Value1Evidence is not null + ? Runtime.Value1RelayTimestampText + : CaptureMode == FatCaptureMode.AutomaticTransition + ? Runtime.OnRelayTimestampText + : Runtime.Value1RelayTimestampText; [JsonIgnore] - public string Value2EvidenceToolTip => CaptureMode == FatCaptureMode.AutomaticTransition - ? Runtime.OffEvidenceToolTip - : Runtime.Value2EvidenceToolTip; + public string Value2RelayTimestampText => Runtime.Value2Evidence is not null + ? Runtime.Value2RelayTimestampText + : CaptureMode == FatCaptureMode.AutomaticTransition + ? Runtime.OffRelayTimestampText + : Runtime.Value2RelayTimestampText; + + [JsonIgnore] + public string Value1EvidenceToolTip => Runtime.Value1Evidence is not null + ? Runtime.Value1EvidenceToolTip + : CaptureMode == FatCaptureMode.AutomaticTransition + ? Runtime.OnEvidenceToolTip + : Runtime.Value1EvidenceToolTip; + + [JsonIgnore] + public string Value2EvidenceToolTip => Runtime.Value2Evidence is not null + ? Runtime.Value2EvidenceToolTip + : CaptureMode == FatCaptureMode.AutomaticTransition + ? Runtime.OffEvidenceToolTip + : Runtime.Value2EvidenceToolTip; [JsonIgnore] public string FatStatusText @@ -397,13 +439,15 @@ public string FatStatusText { if (!IsIncludedInFat) return "REMOVED"; - if (CaptureMode == FatCaptureMode.AutomaticTransition) - return Runtime.StateText; if (IsFatEvidenceComplete) return "COMPLETE"; - if (Runtime.Value1Evidence is not null || Runtime.Value2Evidence is not null) - return "1 / 2 captured"; - return "Ready to capture"; + if (Runtime.AutoCaptureStage == FatAutoCaptureStage.StabilizingValue2) + return "STABILIZING…"; + if (HasValue1Evidence) + return "WAITING V2"; + return Runtime.AutoCaptureStage == FatAutoCaptureStage.WaitingValue1 + ? "WAITING V1" + : "READY"; } } @@ -486,6 +530,7 @@ private void Runtime_PropertyChanged(object? sender, PropertyChangedEventArgs e) nameof(IoTestPointRuntime.OffEvidence) or nameof(IoTestPointRuntime.Value1Evidence) or nameof(IoTestPointRuntime.Value2Evidence) or + nameof(IoTestPointRuntime.AutoCaptureStage) or nameof(IoTestPointRuntime.State) or nameof(IoTestPointRuntime.StatusReason)) { @@ -497,6 +542,8 @@ private void RaiseFatComputedProperties() { Raise(nameof(IsIncludedInFat)); Raise(nameof(CanCaptureOperatorSnapshot)); + Raise(nameof(HasValue1Evidence)); + Raise(nameof(HasValue2Evidence)); Raise(nameof(IsFatEvidenceComplete)); Raise(nameof(Value1Text)); Raise(nameof(Value2Text)); @@ -604,11 +651,11 @@ private set [JsonIgnore] public bool CanPrepareConnection => !IsPreparing; - public int EnabledCount => TestPoints.Count(point => point.IsIncludedInFat && point.TestEnabled); - public int CompleteCount => TestPoints.Count(point => point.IsIncludedInFat && point.TestEnabled && point.IsFatEvidenceComplete); - public int PassedCount => TestPoints.Count(point => point.IsIncludedInFat && point.TestEnabled && point.Runtime.State == IoTestPointState.Passed); - public int ReviewCount => TestPoints.Count(point => point.IsIncludedInFat && point.TestEnabled && point.Runtime.State == IoTestPointState.Review); - public int BoundCount => TestPoints.Count(point => point.IsIncludedInFat && point.IsLiveBound); + public int EnabledCount => TestPoints.Count(point => point.WorkspaceSelected && point.IsIncludedInFat && point.TestEnabled); + public int CompleteCount => TestPoints.Count(point => point.WorkspaceSelected && point.IsIncludedInFat && point.TestEnabled && point.IsFatEvidenceComplete); + public int PassedCount => TestPoints.Count(point => point.WorkspaceSelected && point.IsIncludedInFat && point.TestEnabled && point.Runtime.State == IoTestPointState.Passed); + public int ReviewCount => TestPoints.Count(point => point.WorkspaceSelected && point.IsIncludedInFat && point.TestEnabled && point.Runtime.State == IoTestPointState.Review); + public int BoundCount => TestPoints.Count(point => point.WorkspaceSelected && point.IsIncludedInFat && point.IsLiveBound); public int RemovedCount => TestPoints.Count(point => !point.IsIncludedInFat); public int PendingCount => Math.Max(0, EnabledCount - CompleteCount); @@ -644,17 +691,38 @@ public void InitializeRuntimeNotifications() _notificationsInitialized = true; foreach (var point in TestPoints) - { - point.PropertyChanged += Point_PropertyChanged; - point.Runtime.PropertyChanged += PointRuntime_PropertyChanged; - } + SubscribePoint(point); RaiseProgressProperties(); } + public bool AddTestPoint(IoTestPointPlan point) + { + ArgumentNullException.ThrowIfNull(point); + if (TestPoints.Any(existing => existing.TestPointId.Equals(point.TestPointId, StringComparison.OrdinalIgnoreCase))) + return false; + + TestPoints.Add(point); + if (_notificationsInitialized) + SubscribePoint(point); + RaiseProgressProperties(); + return true; + } + + private void SubscribePoint(IoTestPointPlan point) + { + point.PropertyChanged += Point_PropertyChanged; + point.Runtime.PropertyChanged += PointRuntime_PropertyChanged; + } + private void Point_PropertyChanged(object? sender, PropertyChangedEventArgs e) { - if (e.PropertyName is nameof(IoTestPointPlan.TestEnabled) or nameof(IoTestPointPlan.LiveBindingState) or nameof(IoTestPointPlan.FatDisposition)) + if (e.PropertyName is nameof(IoTestPointPlan.WorkspaceSelected) or + nameof(IoTestPointPlan.TestEnabled) or + nameof(IoTestPointPlan.LiveBindingState) or + nameof(IoTestPointPlan.FatDisposition)) + { RaiseProgressProperties(); + } } private void PointRuntime_PropertyChanged(object? sender, PropertyChangedEventArgs e) @@ -712,10 +780,10 @@ public sealed class IoTestProject public string SourceSetSha256 { get; private set; } = string.Empty; public int SignalCount => Ieds.Sum(ied => ied.TestPoints.Count); - public int IncludedSignalCount => Ieds.Sum(ied => ied.TestPoints.Count(point => point.IsIncludedInFat)); - public int RemovedSignalCount => SignalCount - IncludedSignalCount; - public int ReadySignalCount => Ieds.Sum(ied => ied.TestPoints.Count(point => point.IsIncludedInFat && point.ImportReady)); - public int LiveBoundSignalCount => Ieds.Sum(ied => ied.TestPoints.Count(point => point.IsIncludedInFat && point.IsLiveBound)); + public int IncludedSignalCount => Ieds.Sum(ied => ied.TestPoints.Count(point => point.WorkspaceSelected && point.IsIncludedInFat)); + public int RemovedSignalCount => Ieds.Sum(ied => ied.TestPoints.Count(point => !point.IsIncludedInFat)); + public int ReadySignalCount => Ieds.Sum(ied => ied.TestPoints.Count(point => point.WorkspaceSelected && point.IsIncludedInFat && point.ImportReady)); + public int LiveBoundSignalCount => Ieds.Sum(ied => ied.TestPoints.Count(point => point.WorkspaceSelected && point.IsIncludedInFat && point.IsLiveBound)); public void SetSources(IEnumerable sources, string sourceSetSha256) { @@ -729,4 +797,4 @@ public void InitializeRuntimeNotifications() foreach (var ied in Ieds) ied.InitializeRuntimeNotifications(); } -} \ No newline at end of file +} diff --git a/Models/MonitorModels.cs b/Models/MonitorModels.cs index 85fddd7d7..784d08648 100644 --- a/Models/MonitorModels.cs +++ b/Models/MonitorModels.cs @@ -638,7 +638,9 @@ public string Value public string SourceMode { get => _sourceMode; set => Set(ref _sourceMode, string.IsNullOrWhiteSpace(value) ? "Unknown" : value); } public string Reason { get => _reason; set => Set(ref _reason, string.IsNullOrWhiteSpace(value) ? "-" : value); } public string Status { get => _status; set => Set(ref _status, string.IsNullOrWhiteSpace(value) ? "Unknown" : value); } - public long Sequence { get => _sequence; set => Set(ref _sequence, value); } + // Runtime ordering metadata is not rendered. Avoid an unnecessary WPF property-change + // notification for every poll/report sample; the value still guards stale snapshots. + public long Sequence { get => _sequence; set => _sequence = value; } public bool IsRecentlyChanged { get => _isRecentlyChanged; set => Set(ref _isRecentlyChanged, value); } public bool IsAnnunciatorSelected { get => _isAnnunciatorSelected; set => Set(ref _isAnnunciatorSelected, value); } public bool CanUseAsAnnunciator => FunctionalConstraint.Equals("ST", StringComparison.OrdinalIgnoreCase); diff --git a/Models/SignalDefinition.cs b/Models/SignalDefinition.cs index 4cdc01334..e37722925 100644 --- a/Models/SignalDefinition.cs +++ b/Models/SignalDefinition.cs @@ -633,9 +633,22 @@ public bool IsReportCapable // q/t, Health, Beh, Mod, RCB attributes, nameplate, and other engineering leaves are // companion/diagnostic attributes. They must not become user-selected SCADA points. - public bool IsRawAttribute => IsRawEngineeringAttribute(ObjectReference, DataType); - - public bool IsValueSignal => !IsControlSignal && IsRuntimeValueSignal(ObjectReference, FunctionalConstraint, DataType, Category); + // Explicit static DataSet membership is different: once ARIEC has resolved that member + // to one scalar ST/MX leaf, the engineering noise filter must not discard the operator's + // authoritative FAT scope merely because the path contains THD/harmonic/demand wording. + public bool IsExplicitDataSetRuntimeValue => + !IsControlSignal && + !string.IsNullOrWhiteSpace(DataSetReference) && + (string.IsNullOrWhiteSpace(FunctionalConstraint) || + FunctionalConstraint.Equals("ST", StringComparison.OrdinalIgnoreCase) || + FunctionalConstraint.Equals("MX", StringComparison.OrdinalIgnoreCase)) && + IsRuntimeValueLeaf(ObjectReference, DataType); + + public bool IsRawAttribute => IsRawEngineeringAttribute(ObjectReference, DataType) && !IsExplicitDataSetRuntimeValue; + + public bool IsValueSignal => !IsControlSignal && + (IsRuntimeValueSignal(ObjectReference, FunctionalConstraint, DataType, Category) || + IsExplicitDataSetRuntimeValue); public bool CanPublishAsSignal => !IsControlSignal && IsValueSignal && !IsRawAttribute; public bool IsKnownReadFailure => IsKnownReadFailureState(Value, Quality, ProbeStatus); public bool CanPublishToRuntime => CanPublishAsSignal && !IsKnownReadFailure; @@ -899,6 +912,8 @@ private static bool IsRuntimeValueSignal(string reference, string functionalCons return false; return IsRuntimeValueLeaf(r, dataType) || + IsThreePhaseMeasurementAggregate(r) || + IsDemandEnergyAggregate(r) || (string.Equals(category, "Position", StringComparison.OrdinalIgnoreCase) && r.EndsWith(".stval")) || (string.Equals(category, "Protection", StringComparison.OrdinalIgnoreCase) && r.EndsWith(".general")); } @@ -952,7 +967,14 @@ private static bool IsRawEngineeringAttribute(string reference, string dataType) if (string.Equals(dataType, "Timestamp", StringComparison.OrdinalIgnoreCase)) return true; var r = NormalizeRef(reference); - return IsStatisticsOrHarmonicNoise(r) || + // THD/TDD phase magnitudes are process measurements, not protocol noise. The + // broad harmonic filter exists to keep complete harmonic spectra and statistic + // internals out of the ordinary signal list, but it used to hide even an exact + // MHAI ThdA/ThdPPV ...cVal.mag.f leaf. That made a checked THD row disappear at + // the SignalDefinition -> live monitor boundary. Keep only numeric terminal + // leaves and the explicitly supported three-phase aggregate exempt; q/t companions + // and configuration fields remain non-publishable. + return (!IsOperatorMeasurementException(r, dataType) && IsStatisticsOrHarmonicNoise(r)) || r.EndsWith(".q") || r.EndsWith(".t") || r.EndsWith(".tm") || @@ -981,6 +1003,66 @@ private static bool IsRawEngineeringAttribute(string reference, string dataType) r.Contains(".configrev"); } + private static bool IsPowerQualityMagnitude(string normalizedReference, string dataType) + { + var r = NormalizeRef(normalizedReference); + if (!r.Contains(".thd", StringComparison.OrdinalIgnoreCase) && + !r.Contains(".tdd", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (!r.EndsWith(".mag.f", StringComparison.OrdinalIgnoreCase) && + !r.EndsWith(".instmag.f", StringComparison.OrdinalIgnoreCase) && + !r.EndsWith(".cval.mag.f", StringComparison.OrdinalIgnoreCase) && + !r.EndsWith(".instcval.mag.f", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var type = (dataType ?? string.Empty).Trim(); + return type.Length == 0 || + type.Contains("float", StringComparison.OrdinalIgnoreCase) || + type.Contains("double", StringComparison.OrdinalIgnoreCase) || + type.Contains("real", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsOperatorMeasurementException(string normalizedReference, string dataType) + => IsPowerQualityMagnitude(normalizedReference, dataType) || + IsDemandEnergyMagnitude(normalizedReference, dataType) || + IsThreePhaseMeasurementAggregate(normalizedReference) || + IsDemandEnergyAggregate(normalizedReference); + + private static bool IsDemandEnergyMagnitude(string normalizedReference, string dataType) + { + var r = NormalizeRef(normalizedReference); + if (!r.Contains(".dmdwh", StringComparison.OrdinalIgnoreCase) || + (!r.EndsWith(".mag.f", StringComparison.OrdinalIgnoreCase) && + !r.EndsWith(".instmag.f", StringComparison.OrdinalIgnoreCase))) + { + return false; + } + + var type = (dataType ?? string.Empty).Trim(); + return type.Length == 0 || + type.Contains("float", StringComparison.OrdinalIgnoreCase) || + type.Contains("double", StringComparison.OrdinalIgnoreCase) || + type.Contains("real", StringComparison.OrdinalIgnoreCase); + } + + public static bool IsThreePhaseMeasurementAggregate(string? reference) + { + var r = NormalizeRef(reference ?? string.Empty).TrimEnd('.'); + return r.EndsWith(".thda", StringComparison.OrdinalIgnoreCase) || + r.EndsWith(".thdppv", StringComparison.OrdinalIgnoreCase); + } + + public static bool IsDemandEnergyAggregate(string? reference) + { + var r = NormalizeRef(reference ?? string.Empty).TrimEnd('.'); + return r.EndsWith(".dmdwhmv", StringComparison.OrdinalIgnoreCase); + } + public static bool IsDefaultScadaMeasurementMagnitude(string normalizedReference) { diff --git a/RemovedFatSignalsWindow.cs b/RemovedFatSignalsWindow.cs index 7f8e1bb13..da1035c8a 100644 --- a/RemovedFatSignalsWindow.cs +++ b/RemovedFatSignalsWindow.cs @@ -17,6 +17,13 @@ public sealed class RemovedFatSignalsWindow : Window private readonly TextBlock _summary; public RemovedFatSignalsWindow(IoTestProject project) + : this(project, canEditPoint: null) + { + } + + public RemovedFatSignalsWindow( + IoTestProject project, + Func? canEditPoint) { ArgumentNullException.ThrowIfNull(project); Title = "Removed Signals - ARSAS FAT"; @@ -35,7 +42,9 @@ public RemovedFatSignalsWindow(IoTestProject project) .OrderBy(point => point.IedName, StringComparer.OrdinalIgnoreCase) .ThenBy(point => point.DataSetName, StringComparer.OrdinalIgnoreCase) .ThenBy(point => point.SourceRow) - .Select(point => new RemovedSignalRow(point))); + .Select(point => new RemovedSignalRow( + point, + canEditPoint?.Invoke(point) ?? true))); _view = CollectionViewSource.GetDefaultView(_rows); _view.Filter = FilterRow; @@ -69,7 +78,7 @@ public RemovedFatSignalsWindow(IoTestProject project) }); headerText.Children.Add(new TextBlock { - Text = "Remove and restore never delete source identity or Value 1 / Value 2 evidence.", + Text = "Remove and restore never delete source identity or Value 1 / Value 2 evidence. Rows owned by an active IED workflow stay locked independently.", FontSize = 11.5, Foreground = new SolidColorBrush(Color.FromRgb(99, 116, 139)), Margin = new Thickness(0, 4, 0, 0) @@ -132,15 +141,22 @@ public RemovedFatSignalsWindow(IoTestProject project) HorizontalGridLinesBrush = new SolidColorBrush(Color.FromRgb(233, 238, 245)), EnableRowVirtualization = true }; - grid.Columns.Add(new DataGridCheckBoxColumn + + var selectionFactory = new FrameworkElementFactory(typeof(CheckBox)); + selectionFactory.SetBinding(CheckBox.IsCheckedProperty, new Binding(nameof(RemovedSignalRow.IsSelected)) + { + Mode = BindingMode.TwoWay, + UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged + }); + selectionFactory.SetBinding(UIElement.IsEnabledProperty, new Binding(nameof(RemovedSignalRow.CanEdit))); + selectionFactory.SetBinding(FrameworkElement.ToolTipProperty, new Binding(nameof(RemovedSignalRow.EditToolTip))); + selectionFactory.SetValue(FrameworkElement.HorizontalAlignmentProperty, HorizontalAlignment.Center); + selectionFactory.SetValue(FrameworkElement.VerticalAlignmentProperty, VerticalAlignment.Center); + grid.Columns.Add(new DataGridTemplateColumn { Header = "SELECT", Width = 64, - Binding = new Binding(nameof(RemovedSignalRow.IsSelected)) - { - Mode = BindingMode.TwoWay, - UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged - } + CellTemplate = new DataTemplate { VisualTree = selectionFactory } }); grid.Columns.Add(TextColumn("IED", nameof(RemovedSignalRow.IedName), 130)); grid.Columns.Add(TextColumn("SIGNAL", nameof(RemovedSignalRow.SignalName), new DataGridLength(1.1, DataGridLengthUnitType.Star), 160)); @@ -201,20 +217,20 @@ private bool FilterRow(object item) private void SetVisibleSelection(bool selected) { - foreach (var item in _view.Cast()) + foreach (var item in _view.Cast().Where(row => row.CanEdit)) item.IsSelected = selected; _summary.Text = SummaryText(); } private void RestoreSelected_Click(object sender, RoutedEventArgs e) { - var selected = _rows.Where(row => row.IsSelected).ToArray(); + var selected = _rows.Where(row => row.CanEdit && row.IsSelected).ToArray(); if (selected.Length == 0) { MessageBox.Show( this, - "Select at least one removed signal to restore.", - "No signal selected", + "Select at least one editable removed signal to restore. Rows owned by an active IED connection/session remain locked.", + "No editable signal selected", MessageBoxButton.OK, MessageBoxImage.Information); return; @@ -228,7 +244,13 @@ private void RestoreSelected_Click(object sender, RoutedEventArgs e) } private string SummaryText() - => $"{_rows.Count} removed · {_rows.Count(row => row.IsSelected)} selected"; + { + var locked = _rows.Count(row => !row.CanEdit); + var selected = _rows.Count(row => row.CanEdit && row.IsSelected); + return locked == 0 + ? $"{_rows.Count} removed · {selected} selected" + : $"{_rows.Count} removed · {selected} selected · {locked} active-IED locked"; + } private static Button ActionButton(string content, bool primary) => new() @@ -264,12 +286,17 @@ private sealed class RemovedSignalRow : INotifyPropertyChanged { private bool _isSelected; - public RemovedSignalRow(IoTestPointPlan point) + public RemovedSignalRow(IoTestPointPlan point, bool canEdit) { Point = point; + CanEdit = canEdit; } public IoTestPointPlan Point { get; } + public bool CanEdit { get; } + public string EditToolTip => CanEdit + ? "Select this signal to restore it to FAT" + : "This IED's FAT scope is locked by its active connection preparation or evidence session"; public string IedName => Point.IedName; public string SignalName => Point.SignalName; public string DataSetName => Point.DataSetName; @@ -281,6 +308,8 @@ public bool IsSelected get => _isSelected; set { + if (!CanEdit && value) + return; if (_isSelected == value) return; _isSelected = value; diff --git a/SclSignalSelectionModeWindow.xaml b/SclSignalSelectionModeWindow.xaml new file mode 100644 index 000000000..8b1983af9 --- /dev/null +++ b/SclSignalSelectionModeWindow.xaml @@ -0,0 +1,219 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/SclSignalSelectionModeWindow.xaml.cs b/SclSignalSelectionModeWindow.xaml.cs new file mode 100644 index 000000000..ad52cdf7f --- /dev/null +++ b/SclSignalSelectionModeWindow.xaml.cs @@ -0,0 +1,22 @@ +using System.Windows; + +namespace ArIED61850Tester; + +public partial class SclSignalSelectionModeWindow : Window +{ + public SclSignalSelectionModeWindow(int iedCount) + { + InitializeComponent(); + ImportScopeText = iedCount == 1 + ? "1 IED WORKSPACE" + : $"{iedCount} IED WORKSPACES"; + DataContext = this; + } + + public string ImportScopeText { get; } + + public bool UseStaticDataSet => StaticDataSetChoice.IsChecked == true; + + private void Continue_Click(object sender, RoutedEventArgs e) + => DialogResult = true; +} diff --git a/Services/Iec61850DataSetSignalInventoryService.cs b/Services/Iec61850DataSetSignalInventoryService.cs index c8b7eff60..a4476b3cb 100644 --- a/Services/Iec61850DataSetSignalInventoryService.cs +++ b/Services/Iec61850DataSetSignalInventoryService.cs @@ -53,38 +53,29 @@ public static Iec61850DataSetSignalInventoryMergeResult EnsureMandatorySignals( if (mandatory.Count == 0) return EmptyResult(); - // Keep application matching literal. The engine owns IEC 61850 reference - // canonicalization; ARSAS only compares reference forms already exposed by ARIEC. - // DisplayReference is included because static FCD identity is intentionally kept - // separate from a resolved runtime DataAttribute reference such as .stVal. - var existing = signals - .SelectMany(signal => SignalReferenceCandidates(signal).Select(reference => (reference, signal))) - .GroupBy(item => item.reference, StringComparer.OrdinalIgnoreCase) - .ToDictionary(group => group.Key, group => group.First().signal, StringComparer.OrdinalIgnoreCase); - var added = new List(); var enriched = 0; foreach (var descriptor in mandatory) { var inventoryReference = InventoryReference(descriptor); - var engineReferences = EngineReferenceCandidates(descriptor).ToArray(); - var current = engineReferences - .Select(reference => existing.TryGetValue(reference, out var signal) ? signal : null) - .FirstOrDefault(signal => signal is not null); + if (string.IsNullOrWhiteSpace(inventoryReference)) + continue; + // The static DataSet membership is the inventory identity. A structured FCDA + // such as ThdA.phsA can resolve to a scalar runtime leaf such as + // ThdA.phsA.cVal.mag.f, but that scalar leaf must never be used to claim the + // membership row. Reusing by runtime-primary identity made diagnostics appear + // complete while several FAT rows had no distinct static identity to bind. + // + // Scalar members remain backward compatible: when the static member itself is + // the resolved primary leaf, an existing exact IEC or engine-provided MMS-form + // signal may be enriched instead of duplicated. + var current = FindExistingMembershipSignal(signals, descriptor, inventoryReference); if (current is not null) { if (ApplyEngineDataSetAuthority(current, descriptor, inventoryReference)) enriched++; - - foreach (var key in EngineReferenceCandidates(descriptor) - .Concat(SignalReferenceCandidates(current)) - .Append(inventoryReference) - .Where(value => !string.IsNullOrWhiteSpace(value))) - { - existing.TryAdd(LiteralReference(key), current); - } continue; } @@ -98,19 +89,66 @@ public static Iec61850DataSetSignalInventoryMergeResult EnsureMandatorySignals( var signal = CreateSignal(descriptor, reference, inventoryReference); signals.Add(signal); - foreach (var key in EngineReferenceCandidates(descriptor) - .Concat(SignalReferenceCandidates(signal)) - .Append(inventoryReference) - .Where(value => !string.IsNullOrWhiteSpace(value))) - { - existing.TryAdd(LiteralReference(key), signal); - } added.Add(signal); } return new Iec61850DataSetSignalInventoryMergeResult(added, enriched, mandatory.Count); } + private static SignalDefinition? FindExistingMembershipSignal( + IEnumerable signals, + Iec61850SignalDescriptor descriptor, + string inventoryReference) + { + var membership = FirstMembership(descriptor); + var dataSetReference = membership?.DataSetReference ?? string.Empty; + var materialized = signals.ToArray(); + + // Strongest identity: the presentation row already carries both the exact static + // member and the exact DataSet. This is safe even when its runtime ObjectReference + // is shared with another membership. + var exact = materialized.FirstOrDefault(signal => + ReferenceEquals(signal.DisplayReference, inventoryReference) && + ReferenceEquals(signal.DataSetReference, dataSetReference)); + if (exact is not null) + return exact; + + // An unclaimed row whose DisplayReference is already the exact static member may + // be promoted into this membership. Never steal a row already owned by a different + // DataSet merely because its runtime leaf happens to match. + var unclaimedDisplay = materialized.FirstOrDefault(signal => + ReferenceEquals(signal.DisplayReference, inventoryReference) && + string.IsNullOrWhiteSpace(signal.DataSetReference)); + if (unclaimedDisplay is not null) + return unclaimedDisplay; + + // Legacy/live catalogs sometimes leave DisplayReference empty. Reuse their exact + // ObjectReference only when the static member itself is that same scalar identity. + var exactStaticObject = materialized.FirstOrDefault(signal => + ReferenceEquals(signal.ObjectReference, inventoryReference) && + (string.IsNullOrWhiteSpace(signal.DisplayReference) || + ReferenceEquals(signal.DisplayReference, inventoryReference)) && + (string.IsNullOrWhiteSpace(signal.DataSetReference) || + ReferenceEquals(signal.DataSetReference, dataSetReference))); + if (exactStaticObject is not null) + return exactStaticObject; + + // Preserve the long-standing literal MMS-reference compatibility only for a true + // scalar member: the static membership and resolved primary IEC reference must be + // identical. Structured/intermediate members deliberately fail this test, so a + // shared/container MMS alias cannot absorb ThdA.phsA/phsB/phsC-style memberships. + var staticIsResolvedScalar = + !string.IsNullOrWhiteSpace(descriptor.PrimaryValueReference) && + ReferenceEquals(descriptor.PrimaryValueReference, inventoryReference); + if (!staticIsResolvedScalar || string.IsNullOrWhiteSpace(descriptor.PrimaryValueMmsReference)) + return null; + + return materialized.FirstOrDefault(signal => + ReferenceEquals(signal.ObjectReference, descriptor.PrimaryValueMmsReference) && + (string.IsNullOrWhiteSpace(signal.DataSetReference) || + ReferenceEquals(signal.DataSetReference, dataSetReference))); + } + private static Iec61850DataSetSignalInventoryMergeResult EmptyResult() => new(Array.Empty(), 0, 0); @@ -237,41 +275,6 @@ private static bool ApplyEngineDataSetAuthority( return changed; } - private static IEnumerable SignalReferenceCandidates(SignalDefinition signal) - => new[] { signal.DisplayReference, signal.ObjectReference } - .Where(value => !string.IsNullOrWhiteSpace(value)) - .Select(LiteralReference) - .Distinct(StringComparer.OrdinalIgnoreCase); - - private static IEnumerable EngineReferenceCandidates(Iec61850SignalDescriptor descriptor) - { - var membershipReferences = descriptor.DataSetMemberships - .OrderBy(membership => membership.DataSetReference, StringComparer.OrdinalIgnoreCase) - .ThenBy(membership => membership.MemberIndex) - .SelectMany(membership => new[] - { - membership.CanonicalMemberReference, - membership.OriginalMemberReference - }); - - var descriptorReferences = new[] - { - descriptor.PrimaryValueReference, - descriptor.DesignReference, - descriptor.ObservedReference, - descriptor.PrimaryValueMmsReference, - descriptor.CanonicalMmsReference, - descriptor.EffectiveMmsReference, - descriptor.ObservedMmsReference - }; - - return membershipReferences - .Concat(descriptorReferences) - .Where(value => !string.IsNullOrWhiteSpace(value)) - .Select(LiteralReference) - .Distinct(StringComparer.OrdinalIgnoreCase); - } - private static string InventoryReference(Iec61850SignalDescriptor descriptor) { var membership = FirstMembership(descriptor); @@ -313,6 +316,9 @@ private static string BuildCoverageReason(Iec61850SignalDescriptor descriptor) " Inventory presence is engine-authoritative; user selection remains independent."; } + private static bool ReferenceEquals(string? left, string? right) + => string.Equals(LiteralReference(left), LiteralReference(right), StringComparison.OrdinalIgnoreCase); + private static string LiteralReference(string? reference) => (reference ?? string.Empty).Trim(); diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs index 942d0b001..c05d1188d 100644 --- a/Services/Iec61850MonitorRuntime.cs +++ b/Services/Iec61850MonitorRuntime.cs @@ -704,6 +704,8 @@ private async Task StartReportPlansAsync( : result.AcquisitionLabel; foreach (var point in coveredPoints) { + if (RequiresExactMmsValueAuthority(point.IecReference)) + continue; session.PointPlanIds[point.PointKey] = plan.PlanId; if (!string.IsNullOrWhiteSpace(result.ReportControlReference)) point.ReportControlReference = result.ReportControlReference; @@ -971,6 +973,12 @@ private async Task ReceiveReportSlicesAsync(DeviceSession session, CancellationT if (point == null) continue; + // Siemens static DataSets report THD as a nested three-phase structure. + // Until its scalar fan-out is schema-proven, exact MMS reads remain the + // value authority so a report projection cannot overwrite correct values. + if (RequiresExactMmsValueAuthority(point.IecReference)) + continue; + // A real report update proves reference coverage, but an initial GI or // integrity image does not yet prove that dchg/qchg is working. Keep MMS // verification active until a change report is actually observed. @@ -2000,6 +2008,15 @@ private static bool IsFastPoint(Iec61850MonitorPoint point) reference.EndsWith(".general"); } + private static bool RequiresExactMmsValueAuthority(string reference) + { + var normalized = NormalizeReference(reference); + return SignalDefinition.IsThreePhaseMeasurementAggregate(normalized) || + normalized.Contains(".thda.phs", StringComparison.OrdinalIgnoreCase) || + normalized.Contains(".thdppv.phs", StringComparison.OrdinalIgnoreCase) || + normalized.Contains(".dmdwh", StringComparison.OrdinalIgnoreCase); + } + private static bool HasExactSemanticEdge(Iec61850MonitorPoint point, string oldValue, string newValue) { var oldText = (oldValue ?? string.Empty).Trim(); diff --git a/Services/IecSignalReadResolver.cs b/Services/IecSignalReadResolver.cs index 2f7cf41fb..8cd77a9c3 100644 --- a/Services/IecSignalReadResolver.cs +++ b/Services/IecSignalReadResolver.cs @@ -1,3 +1,4 @@ +using System.Globalization; using ArIED61850Tester.Models; namespace ArIED61850Tester.Services; @@ -17,6 +18,13 @@ public static class IecSignalReadResolver SignalDefinition signal, CancellationToken cancellationToken) { + // Object-level THD and demand-energy members are structured values. They must + // never enter the generic parent-read path, because a raw MMS structure can only + // identify its children positionally. Build an exact named-leaf plan from the + // per-IED SCL/live schema and read those scalar leaves directly instead. + if (client is NativeIec61850Client native && IsSchemaSafeAggregate(signal.ObjectReference)) + return await ReadSchemaSafeAggregateAsync(native, signal, cancellationToken).ConfigureAwait(false); + var references = BuildReadCandidates(signal.ObjectReference).ToList(); Exception? firstFailure = null; @@ -48,6 +56,145 @@ public static class IecSignalReadResolver return null; } + private sealed record AggregateLeafRead( + SchemaSafeAggregateProjectionService.ReadLeaf Leaf, + double NumericValue, + string Quality, + string DeviceTimestamp); + + private static async Task ReadSchemaSafeAggregateAsync( + NativeIec61850Client client, + SignalDefinition signal, + CancellationToken cancellationToken) + { + // Failure to prove the schema is an intentional hard stop for this parent value. + // Do not fall through to NativeIec61850Client.ReadValueAsync(parent), where raw + // structure ordering could silently assign the wrong phase/value. + if (!client.TryBuildSchemaSafeAggregateReadPlan(signal.ObjectReference, out var plan, out _)) + return null; + + var reads = new List(plan.Leaves.Count); + foreach (var leaf in plan.Leaves) + { + cancellationToken.ThrowIfCancellationRequested(); + var value = await client.ReadValueAsync( + leaf.Reference, + signal.FunctionalConstraint, + leaf.DataType, + cancellationToken).ConfigureAwait(false); + if (value is null) + return null; + + var raw = Iec61850ReadValue.Unwrap(value); + if (!TryConvertNumeric(raw, out var numeric)) + return null; + + var rich = value as Iec61850ReadValue; + reads.Add(new AggregateLeafRead( + leaf, + numeric, + rich?.Quality ?? string.Empty, + rich?.DeviceTimestamp ?? string.Empty)); + } + + if (plan.Kind.Equals("ThreePhaseThd", StringComparison.OrdinalIgnoreCase)) + { + if (reads.Count != 3) + return null; + + var display = string.Join(", ", reads.Select(read => + $"{read.Leaf.Label}={read.NumericValue.ToString("0.######", CultureInfo.InvariantCulture)}")); + var rich = new Iec61850ReadValue + { + Value = display, + DisplayValue = display, + Quality = UnanimousUseful(reads.Select(read => read.Quality)), + DeviceTimestamp = UnanimousUseful(reads.Select(read => read.DeviceTimestamp)), + SourceReference = signal.ObjectReference, + ReadReference = string.Join(" | ", reads.Select(read => read.Leaf.Reference)), + Projection = "schema-safe-three-phase-exact-leaf-reads" + }; + return new ResolvedIecSignalRead(rich, signal.ObjectReference); + } + + if (plan.Kind.Equals("DemandEnergy", StringComparison.OrdinalIgnoreCase)) + { + var read = reads.Count == 1 ? reads[0] : null; + if (read is null) + return null; + + var display = read.NumericValue.ToString("0.######", CultureInfo.InvariantCulture); + var rich = new Iec61850ReadValue + { + Value = read.NumericValue, + DisplayValue = display, + Quality = read.Quality, + DeviceTimestamp = read.DeviceTimestamp, + SourceReference = signal.ObjectReference, + ReadReference = read.Leaf.Reference, + Projection = "schema-safe-demand-energy-exact-leaf-read" + }; + + // The runtime keeps the FAT identity on the parent point, but uses this exact + // effective leaf to derive low-rate q/t companion references. Returning the + // parent here would incorrectly derive XPRE_MMTR1.q instead of DmdWhMV.q. + return new ResolvedIecSignalRead(rich, read.Leaf.Reference); + } + + return null; + } + + private static bool IsSchemaSafeAggregate(string reference) + => SignalDefinition.IsThreePhaseMeasurementAggregate(reference) || + SignalDefinition.IsDemandEnergyAggregate(reference); + + private static bool TryConvertNumeric(object? value, out double numeric) + { + numeric = 0d; + if (value is null) + return false; + + try + { + switch (value) + { + case double d: + numeric = d; + return double.IsFinite(d); + case float f: + numeric = f; + return float.IsFinite(f); + case decimal m: + numeric = (double)m; + return double.IsFinite(numeric); + case byte or sbyte or short or ushort or int or uint or long or ulong: + numeric = Convert.ToDouble(value, CultureInfo.InvariantCulture); + return double.IsFinite(numeric); + } + } + catch (Exception ex) when (ex is InvalidCastException or FormatException or OverflowException) + { + return false; + } + + return double.TryParse( + Convert.ToString(value, CultureInfo.InvariantCulture), + NumberStyles.Float | NumberStyles.AllowThousands, + CultureInfo.InvariantCulture, + out numeric) && + double.IsFinite(numeric); + } + + private static string UnanimousUseful(IEnumerable values) + { + var useful = values + .Where(value => !string.IsNullOrWhiteSpace(value) && value != "-") + .Select(value => value.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + return useful.Length == 1 ? useful[0] : string.Empty; + } + public static bool ApplyEffectiveReference(SignalDefinition signal, string effectiveReference) { if (string.IsNullOrWhiteSpace(effectiveReference) || ReferencesEqual(signal.ObjectReference, effectiveReference)) diff --git a/Services/IoTesting/FatAutoCaptureCoordinator.cs b/Services/IoTesting/FatAutoCaptureCoordinator.cs new file mode 100644 index 000000000..3bb3093de --- /dev/null +++ b/Services/IoTesting/FatAutoCaptureCoordinator.cs @@ -0,0 +1,212 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using ArIED61850Tester.Models; +using ArIED61850Tester.Models.IoTesting; + +namespace ArIED61850Tester.Services.IoTesting; + +public sealed record FatAutoCaptureDecision( + FatValueEvidence? Evidence, + FatAutoCaptureStage Stage, + string Message) +{ + public static FatAutoCaptureDecision None(FatAutoCaptureStage stage, string message) + => new(null, stage, message); +} + +/// +/// P3 automatic Value 1 / Value 2 latch. This coordinator never mutates the current +/// evidence pointers: callers must durably append the returned evidence first, then +/// promote it. That keeps the evidence journal authoritative when storage fails. +/// +/// Analog values use an intentionally small, deterministic settling window rather than +/// capturing every transient MMS/report update. Three consecutive samples must remain +/// inside an adaptive 0.05% band before the slot is accepted. Discrete/Other values use +/// semantic change and are latched immediately after good-quality observation. +/// +public sealed class FatAutoCaptureCoordinator +{ + internal const int AnalogStableSampleCount = 3; + internal const double AnalogRelativeSettlingFraction = 0.0005d; + private static readonly Regex NumericToken = new( + @"[-+]?(?:\d+(?:[\.,]\d*)?|[\.,]\d+)(?:[eE][-+]?\d+)?", + RegexOptions.CultureInvariant | RegexOptions.Compiled); + + private readonly Dictionary _analogCandidates = + new(StringComparer.OrdinalIgnoreCase); + + public FatAutoCaptureDecision Observe(IoTestPointPlan point, IoTestObservation observation) + { + ArgumentNullException.ThrowIfNull(point); + ArgumentNullException.ThrowIfNull(observation); + + if (!point.WorkspaceSelected || !point.IsIncludedInFat || !point.TestEnabled || !point.ImportReady) + { + Clear(point); + return FatAutoCaptureDecision.None( + FatAutoCaptureStage.WaitingValue1, + "Automatic capture is outside the active FAT scope."); + } + + if (point.HasValue1Evidence && point.HasValue2Evidence) + { + Clear(point); + return FatAutoCaptureDecision.None( + FatAutoCaptureStage.Complete, + "Current Value 1 / Value 2 evidence is complete; explicit Recapture is required to replace it."); + } + + var (qualityVerdict, qualityReason) = IoTestTransitionEvaluator.EvaluateQuality(observation.Quality); + if (qualityVerdict != IoEvidenceVerdict.Accepted) + { + Clear(point); + return FatAutoCaptureDecision.None( + point.HasValue1Evidence ? FatAutoCaptureStage.WaitingChange : FatAutoCaptureStage.WaitingValue1, + $"Waiting for good-quality live value: {qualityReason}."); + } + + if (string.IsNullOrWhiteSpace(observation.RawValue) || observation.RawValue.Trim() is "-" or "—") + { + Clear(point); + return FatAutoCaptureDecision.None( + point.HasValue1Evidence ? FatAutoCaptureStage.WaitingChange : FatAutoCaptureStage.WaitingValue1, + "Waiting for a readable live value."); + } + + var slot = point.HasValue1Evidence ? FatValueSlot.Value2 : FatValueSlot.Value1; + if (slot == FatValueSlot.Value2 && IsEquivalent(point.Value1Text, observation.RawValue)) + { + Clear(point); + return FatAutoCaptureDecision.None( + FatAutoCaptureStage.WaitingChange, + "Value 1 is stable; waiting for a meaningful new condition."); + } + + if (point.SignalKind != FatSignalKind.Analog) + { + Clear(point); + return new FatAutoCaptureDecision( + CreateEvidence(slot, observation), + slot == FatValueSlot.Value1 ? FatAutoCaptureStage.WaitingChange : FatAutoCaptureStage.Complete, + slot == FatValueSlot.Value1 + ? "Value 1 captured automatically; waiting for a meaningful change." + : "Value 2 captured automatically from the new live condition."); + } + + if (!TryParseNumeric(observation.RawValue, out var numeric)) + { + Clear(point); + return FatAutoCaptureDecision.None( + slot == FatValueSlot.Value1 ? FatAutoCaptureStage.WaitingValue1 : FatAutoCaptureStage.WaitingChange, + "Analog value is not numerically stable enough for automatic capture; manual Recapture remains available."); + } + + var key = point.TestPointId; + var tolerance = SettlingTolerance(numeric, point.Value1Text); + if (!_analogCandidates.TryGetValue(key, out var candidate) || + candidate.Slot != slot || + Math.Abs(numeric - candidate.Center) > tolerance) + { + _analogCandidates[key] = new AnalogCandidate(slot, numeric, numeric, numeric, 1); + return FatAutoCaptureDecision.None( + slot == FatValueSlot.Value1 ? FatAutoCaptureStage.WaitingValue1 : FatAutoCaptureStage.StabilizingValue2, + slot == FatValueSlot.Value1 ? "Stabilizing Value 1…" : "Stabilizing Value 2…"); + } + + var next = candidate.Add(numeric); + _analogCandidates[key] = next; + var nextTolerance = SettlingTolerance(next.Center, point.Value1Text); + if (next.Count < AnalogStableSampleCount || next.Max - next.Min > nextTolerance) + { + return FatAutoCaptureDecision.None( + slot == FatValueSlot.Value1 ? FatAutoCaptureStage.WaitingValue1 : FatAutoCaptureStage.StabilizingValue2, + slot == FatValueSlot.Value1 ? "Stabilizing Value 1…" : "Stabilizing Value 2…"); + } + + _analogCandidates.Remove(key); + return new FatAutoCaptureDecision( + CreateEvidence(slot, observation), + slot == FatValueSlot.Value1 ? FatAutoCaptureStage.WaitingChange : FatAutoCaptureStage.Complete, + slot == FatValueSlot.Value1 + ? "Stable analog Value 1 captured; waiting for a meaningful new condition." + : "Stable analog Value 2 captured; current evidence is complete."); + } + + public void Clear(IoTestPointPlan point) + { + ArgumentNullException.ThrowIfNull(point); + _analogCandidates.Remove(point.TestPointId); + } + + public void Clear() => _analogCandidates.Clear(); + + private static FatValueEvidence CreateEvidence(FatValueSlot slot, IoTestObservation observation) + => new( + Guid.NewGuid(), + slot, + FatEvidenceCaptureKind.AutomaticValue, + observation.RawValue.Trim(), + observation.CapturedAt, + observation.IedTimestamp, + string.IsNullOrWhiteSpace(observation.Quality) ? "Unknown" : observation.Quality.Trim(), + string.IsNullOrWhiteSpace(observation.AcquisitionSource) ? "Unknown" : observation.AcquisitionSource.Trim(), + observation.Sequence, + observation.ConnectionGeneration); + + private static bool IsEquivalent(string? baseline, string? current) + { + if (Iec61850MonitorPoint.AreSemanticallyEquivalent(baseline ?? string.Empty, current ?? string.Empty)) + return true; + + return TryParseNumeric(baseline, out var left) && + TryParseNumeric(current, out var right) && + Math.Abs(left - right) <= SettlingTolerance(right, baseline); + } + + private static double SettlingTolerance(double value, string? baseline) + { + var baselineMagnitude = TryParseNumeric(baseline, out var parsedBaseline) + ? Math.Abs(parsedBaseline) + : 0d; + var scale = Math.Max(1d, Math.Max(Math.Abs(value), baselineMagnitude)); + return Math.Max(1e-9d, scale * AnalogRelativeSettlingFraction); + } + + internal static bool TryParseNumeric(string? raw, out double value) + { + value = 0d; + if (string.IsNullOrWhiteSpace(raw)) + return false; + + var match = NumericToken.Match(raw.Trim()); + if (!match.Success) + return false; + + var token = match.Value; + if (double.TryParse(token, NumberStyles.Float, CultureInfo.InvariantCulture, out value)) + return true; + + token = token.Replace(',', '.'); + return double.TryParse(token, NumberStyles.Float, CultureInfo.InvariantCulture, out value); + } + + private sealed record AnalogCandidate( + FatValueSlot Slot, + double Center, + double Min, + double Max, + int Count) + { + public AnalogCandidate Add(double value) + { + var nextCount = Count + 1; + return this with + { + Center = ((Center * Count) + value) / nextCount, + Min = Math.Min(Min, value), + Max = Math.Max(Max, value), + Count = nextCount + }; + } + } +} diff --git a/Services/IoTesting/FatOperatorSnapshotCaptureService.cs b/Services/IoTesting/FatOperatorSnapshotCaptureService.cs index a340e79da..6e4ccbdb2 100644 --- a/Services/IoTesting/FatOperatorSnapshotCaptureService.cs +++ b/Services/IoTesting/FatOperatorSnapshotCaptureService.cs @@ -37,11 +37,13 @@ public static FatValueEvidence Capture( /// /// Creates an immutable capture record without mutating a current evidence pointer. /// Session controllers use this so the append-only journal can be durably written - /// before the replaceable Value 1 / Value 2 pointer is promoted. + /// before the replaceable Value 1 / Value 2 pointer is promoted. Explicit bulk + /// Recapture uses OperatorRecapture provenance even for automatic-transition rows. /// public static FatValueEvidence CreateEvidence( FatValueSlot slot, - FatLiveValueObservation observation) + FatLiveValueObservation observation, + FatEvidenceCaptureKind captureKind = FatEvidenceCaptureKind.OperatorSnapshot) { ArgumentNullException.ThrowIfNull(observation); if (string.IsNullOrWhiteSpace(observation.RawValue) || @@ -50,10 +52,13 @@ public static FatValueEvidence CreateEvidence( throw new InvalidOperationException("The FAT signal does not have a readable live value to capture."); } + if (captureKind is not (FatEvidenceCaptureKind.OperatorSnapshot or FatEvidenceCaptureKind.OperatorRecapture)) + throw new ArgumentOutOfRangeException(nameof(captureKind), captureKind, "Operator capture requires operator provenance."); + return new FatValueEvidence( Guid.NewGuid(), slot, - FatEvidenceCaptureKind.OperatorSnapshot, + captureKind, observation.RawValue.Trim(), observation.CapturedAt, observation.IedTimestamp, diff --git a/Services/IoTesting/IoFatEngineeringSelectionBridge.cs b/Services/IoTesting/IoFatEngineeringSelectionBridge.cs new file mode 100644 index 000000000..381c9cce9 --- /dev/null +++ b/Services/IoTesting/IoFatEngineeringSelectionBridge.cs @@ -0,0 +1,258 @@ +using System.Security.Cryptography; +using System.Text; +using ArIED61850Tester.Models; +using ArIED61850Tester.Models.IoTesting; + +namespace ArIED61850Tester.Services.IoTesting; + +/// +/// Keeps direct-SCL FAT projection and the Engineering signal catalog on one shared +/// workspace-selection authority. Static DataSet membership remains the strongest identity, +/// while manually selected non-DataSet SCL signals are materialized as persistent FAT rows. +/// FAT TEST/capture scope and FAT disposition are deliberately orthogonal. +/// +public static class IoFatEngineeringSelectionBridge +{ + public static int Initialize( + IoTestIedPlan ied, + Iec61850MonitorDevice device, + bool preserveExistingEngineeringSelection) + { + ArgumentNullException.ThrowIfNull(ied); + ArgumentNullException.ThrowIfNull(device); + + var changed = 0; + foreach (var point in ied.TestPoints.Where(IoTestSignalSelectionService.IsDirectSclAuthority)) + { + var signal = FindSignal(point, device); + if (signal is null) + continue; + + // Shared workspace membership follows the Engineering checkbox. TestEnabled is + // FAT-only evidence scope and must survive Engineering mode switches unchanged. + // FatDisposition remains a FAT-only Remove/Restore authority. + if (preserveExistingEngineeringSelection) + { + if (point.WorkspaceSelected != signal.IsSelected) + { + point.WorkspaceSelected = signal.IsSelected; + changed++; + } + } + else + { + if (!point.WorkspaceSelected) + { + point.WorkspaceSelected = true; + changed++; + } + + if (!signal.IsSelected) + { + signal.IsSelected = true; + changed++; + } + } + } + + device.RecountSelectedSignals(); + return changed; + } + + public static bool ApplyFatPointSelection( + IoTestPointPlan point, + Iec61850MonitorDevice device) + { + ArgumentNullException.ThrowIfNull(point); + ArgumentNullException.ThrowIfNull(device); + if (!IoTestSignalSelectionService.IsDirectSclAuthority(point)) + return false; + + var signal = FindSignal(point, device); + if (signal is null) + return false; + + // Only shared workspace membership is bidirectional. TEST never mutates Engineering, + // and Remove/Restore never mutates either shared selection or TEST scope. + var selected = point.WorkspaceSelected; + if (signal.IsSelected == selected) + return false; + + signal.IsSelected = selected; + device.RecountSelectedSignals(); + return true; + } + + public static bool ApplyEngineeringSignalSelection( + SignalDefinition signal, + bool selected, + IoTestIedPlan ied, + Iec61850MonitorDevice device) + { + ArgumentNullException.ThrowIfNull(signal); + ArgumentNullException.ThrowIfNull(ied); + ArgumentNullException.ThrowIfNull(device); + + // Engineering owns shared workspace membership only. A FAT row explicitly removed + // by the operator stays removed, and its FAT TEST preference/evidence is preserved + // across Engineering deselect/reselect operations. + var matching = ied.TestPoints + .Where(IoTestSignalSelectionService.IsDirectSclAuthority) + .Where(point => ReferenceEquals(FindSignal(point, device), signal)) + .ToArray(); + + var changed = false; + foreach (var point in matching) + { + if (point.WorkspaceSelected == selected) + continue; + point.WorkspaceSelected = selected; + changed = true; + } + + if (matching.Length == 0 && selected && TryCreateManualWorkspacePoint(signal, ied, device, out var createdPoint)) + changed |= ied.AddTestPoint(createdPoint); + + return changed; + } + + public static SignalDefinition? FindSignal( + IoTestPointPlan point, + Iec61850MonitorDevice device) + { + ArgumentNullException.ThrowIfNull(point); + ArgumentNullException.ThrowIfNull(device); + + var sourceReferences = new[] + { + point.SourceIecReference, + point.ReportDisplayReference, + point.EventLogSearchReference + } + .Where(reference => !string.IsNullOrWhiteSpace(reference)) + .Select(IoTestLiveBindingService.NormalizeReference) + .Where(reference => reference.Length > 0) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + if (IoTestSignalSelectionService.IsSclDataSetAuthority(point)) + { + var dataSet = IoTestLiveBindingService.NormalizeReference(point.DataSetName); + var exactMembership = device.Signals + .Where(signal => sourceReferences.Contains( + IoTestLiveBindingService.NormalizeReference(signal.DisplayReference))) + .Where(signal => dataSet.Length == 0 || + dataSet.Equals( + IoTestLiveBindingService.NormalizeReference(signal.DataSetReference), + StringComparison.OrdinalIgnoreCase)) + .ToArray(); + if (exactMembership.Length == 1) + return exactMembership[0]; + } + else if (IoTestSignalSelectionService.IsSclWorkspaceAuthority(point)) + { + var exactSource = device.Signals + .Where(signal => sourceReferences.Contains( + IoTestLiveBindingService.NormalizeReference(signal.DisplayReference))) + .ToArray(); + if (exactSource.Length == 1) + return exactSource[0]; + } + + var runtime = IoTestLiveBindingService.NormalizeReference(point.ObjectReference); + if (runtime.Length == 0) + return null; + + var runtimeMatches = device.Signals + .Where(signal => runtime.Equals( + IoTestLiveBindingService.NormalizeReference(signal.ObjectReference), + StringComparison.OrdinalIgnoreCase)) + .ToArray(); + return runtimeMatches.Length == 1 ? runtimeMatches[0] : null; + } + + private static bool TryCreateManualWorkspacePoint( + SignalDefinition signal, + IoTestIedPlan ied, + Iec61850MonitorDevice device, + out IoTestPointPlan point) + { + point = null!; + if (signal.IsControlSignal || string.IsNullOrWhiteSpace(signal.ObjectReference)) + return false; + + var functionalConstraint = signal.FunctionalConstraint?.Trim() ?? string.Empty; + var dataType = signal.DataType?.Trim() ?? string.Empty; + var upperType = dataType.ToUpperInvariant(); + var discrete = functionalConstraint.Equals("ST", StringComparison.OrdinalIgnoreCase) && + (upperType.Contains("BOOL", StringComparison.Ordinal) || + upperType.Contains("DBPOS", StringComparison.Ordinal) || + upperType.Contains("SPS", StringComparison.Ordinal) || + upperType.Contains("DPS", StringComparison.Ordinal)); + var analog = functionalConstraint.Equals("MX", StringComparison.OrdinalIgnoreCase); + var signalKind = discrete + ? FatSignalKind.Discrete + : analog + ? FatSignalKind.Analog + : FatSignalKind.Other; + var captureMode = discrete + ? FatCaptureMode.AutomaticTransition + : FatCaptureMode.OperatorSnapshot; + var displayReference = string.IsNullOrWhiteSpace(signal.DisplayReference) + ? signal.ObjectReference.Trim() + : signal.DisplayReference.Trim(); + var runtimeReference = signal.ObjectReference.Trim(); + var sourceSha = device.SclSourceSha256?.Trim().ToLowerInvariant() ?? string.Empty; + var identity = string.Join("|", new[] + { + sourceSha, + ied.IedName.Trim(), + device.SclAccessPointName?.Trim() ?? string.Empty, + displayReference, + runtimeReference, + functionalConstraint + }); + var digest = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(identity))) + .ToLowerInvariant(); + + point = new IoTestPointPlan + { + TestPointId = $"scl-manual-{digest[..20]}", + IedName = ied.IedName, + IpAddress = ied.IpAddress, + SignalName = string.IsNullOrWhiteSpace(signal.Name) ? displayReference : signal.Name.Trim(), + ObjectReference = runtimeReference, + FunctionalConstraint = functionalConstraint, + ExpectedOnText = discrete ? "TRUE" : "Value 1", + ExpectedOffText = discrete ? "FALSE" : "Value 2", + ExpectedOnRaw = 1, + ExpectedOffRaw = 0, + DataType = dataType, + SignalAddress = sourceSha, + DataSetName = signal.DataSetReference?.Trim() ?? string.Empty, + SourceIecReference = displayReference, + ReportDisplayReference = displayReference, + EventLogSearchReference = runtimeReference, + EvidenceExpected = captureMode == FatCaptureMode.AutomaticTransition + ? "Automatic Value 1 / Value 2 transition capture" + : "Operator Value 1 / Value 2 snapshot capture", + SignalKind = signalKind, + CaptureMode = captureMode, + WorkspaceSelected = true, + TestEnabled = true, + ImportReady = true, + BindingStatus = IoTestSignalSelectionService.SclWorkspaceAuthorityBindingStatus, + BindingEvidence = string.Join(" • ", new[] + { + "Shared SCL workspace authority", + $"sourceSha256={sourceSha}", + $"deviceId={device.DeviceId}", + $"static={displayReference}", + $"runtime={runtimeReference}", + $"fc={functionalConstraint}", + $"kind={signalKind}", + $"capture={captureMode}" + }) + }; + return true; + } +} diff --git a/Services/IoTesting/IoFatPdfReportService.cs b/Services/IoTesting/IoFatPdfReportService.cs index 825f23322..3e85ed121 100644 --- a/Services/IoTesting/IoFatPdfReportService.cs +++ b/Services/IoTesting/IoFatPdfReportService.cs @@ -26,11 +26,16 @@ internal static IoFatReportLayoutPlan BuildLayout( bool draft = false) { ArgumentNullException.ThrowIfNull(project); + + // FAT disposition and shared Engineering/FAT workspace membership are applied + // before either report engine. This is a hard boundary: Remove from FAT excludes + // the row even when it already owns completed historical evidence. + var reportProject = IoFatReportScope.Create(project); var created = generatedAt ?? DateTimeOffset.Now; - var layout = project.SchemaVersion.StartsWith("ARSAS-FAT-SCL-", StringComparison.OrdinalIgnoreCase) - ? IoFatV2ReportLayoutEngine.Build(project, created, draft) - : IoFatExecutiveReportLayoutEngine.Build(project, created, draft); - return IoFatSupplementalReportLayoutDecorator.AppendFileServiceEvidence(project, layout); + var layout = reportProject.SchemaVersion.StartsWith("ARSAS-FAT-SCL-", StringComparison.OrdinalIgnoreCase) + ? IoFatV2ReportLayoutEngine.Build(reportProject, created, draft) + : IoFatExecutiveReportLayoutEngine.Build(reportProject, created, draft); + return IoFatSupplementalReportLayoutDecorator.AppendFileServiceEvidence(reportProject, layout); } public static void Save(string fileName, IoTestProject project, DateTimeOffset? generatedAt = null) diff --git a/Services/IoTesting/IoFatReportPreviewDocumentBuilder.cs b/Services/IoTesting/IoFatReportPreviewDocumentBuilder.cs index e62511b34..01928b74b 100644 --- a/Services/IoTesting/IoFatReportPreviewDocumentBuilder.cs +++ b/Services/IoTesting/IoFatReportPreviewDocumentBuilder.cs @@ -27,8 +27,11 @@ public static FixedDocument Build( DateTimeOffset? generatedAt = null) { ArgumentNullException.ThrowIfNull(project); - // Replaces the legacy IoFatReportLayoutEngine.Build route with the document-controlled event-log layout. - var layout = IoFatExecutiveReportLayoutEngine.Build(project, generatedAt ?? DateTimeOffset.Now, draft); + // Replaces the legacy IoFatReportLayoutEngine.Build route with the exact same + // scoped layout contract used by native PDF. Remove from FAT is therefore a hard + // exclusion in both PDF and on-screen form/print preview, including rows that own + // historical completed evidence. + var layout = IoFatPdfReportService.BuildLayout(project, generatedAt, draft); return Render(layout); } diff --git a/Services/IoTesting/IoFatReportScope.cs b/Services/IoTesting/IoFatReportScope.cs new file mode 100644 index 000000000..ae099ac0e --- /dev/null +++ b/Services/IoTesting/IoFatReportScope.cs @@ -0,0 +1,75 @@ +// Copyright 2026 Ari Sulistiono +// SPDX-License-Identifier: Apache-2.0 + +using ArIED61850Tester.Models.IoTesting; + +namespace ArIED61850Tester.Services.IoTesting; + +/// +/// Creates an immutable report projection from the shared Engineering/FAT workspace. +/// FAT disposition is a hard report boundary: a row removed by the operator must never +/// leak back into PDF/print-preview output merely because old evidence still exists. +/// +internal static class IoFatReportScope +{ + public static IoTestProject Create(IoTestProject project) + { + ArgumentNullException.ThrowIfNull(project); + return CreateCore(project, project.Ieds); + } + + public static IoTestProject CreateForIed(IoTestProject project, IoTestIedPlan ied) + { + ArgumentNullException.ThrowIfNull(project); + ArgumentNullException.ThrowIfNull(ied); + if (!project.Ieds.Contains(ied)) + throw new ArgumentException("The selected IED does not belong to this IO FAT project.", nameof(ied)); + + return CreateCore(project, new[] { ied }); + } + + public static bool Includes(IoTestPointPlan point) + { + ArgumentNullException.ThrowIfNull(point); + return point.WorkspaceSelected && point.IsIncludedInFat; + } + + private static IoTestProject CreateCore( + IoTestProject project, + IEnumerable ieds) + { + var scoped = new IoTestProject + { + ProjectId = project.ProjectId, + SchemaVersion = project.SchemaVersion, + ProjectName = project.ProjectName, + SourceWorkbookName = project.SourceWorkbookName, + SourceWorkbookSha256 = project.SourceWorkbookSha256, + ImportedAt = project.ImportedAt, + DocumentControl = project.DocumentControl, + Ieds = ieds.Select(CloneIed).ToList() + }; + scoped.SetSources(project.Sources, project.SourceSetSha256); + return scoped; + } + + private static IoTestIedPlan CloneIed(IoTestIedPlan ied) + => new() + { + IedName = ied.IedName, + IpAddress = ied.IpAddress, + IedRole = ied.IedRole, + Location = ied.Location, + VoltageLevel = ied.VoltageLevel, + Switchgear = ied.Switchgear, + TestPoints = ied.TestPoints.Where(Includes).ToList(), + LatestComtradeFiles = ied.LatestComtradeFiles, + LatestComtradeRemotePath = ied.LatestComtradeRemotePath, + LatestComtradeCompleteness = ied.LatestComtradeCompleteness, + LatestComtradeAcquisitionSource = ied.LatestComtradeAcquisitionSource, + LatestComtradeModifiedAtUtc = ied.LatestComtradeModifiedAtUtc, + LatestComtradeCapturedAtUtc = ied.LatestComtradeCapturedAtUtc, + LatestComtradeFileCount = ied.LatestComtradeFileCount, + LatestComtradeKnownSizeBytes = ied.LatestComtradeKnownSizeBytes + }; +} diff --git a/Services/IoTesting/IoFatSclProjectImportService.cs b/Services/IoTesting/IoFatSclProjectImportService.cs index cbebf432b..398915baf 100644 --- a/Services/IoTesting/IoFatSclProjectImportService.cs +++ b/Services/IoTesting/IoFatSclProjectImportService.cs @@ -67,13 +67,26 @@ internal bool TryGetRuntimeWorkspace( } } - public async Task ImportAsync( + public Task ImportAsync( IReadOnlyCollection sclPaths, string? projectName = null, CancellationToken cancellationToken = default) + => ImportCoreAsync(sclPaths, projectName, replaceRuntimeWorkspaces: true, cancellationToken); + + public Task ImportAdditionalAsync( + IReadOnlyCollection sclPaths, + CancellationToken cancellationToken = default) + => ImportCoreAsync(sclPaths, projectName: null, replaceRuntimeWorkspaces: false, cancellationToken); + + private async Task ImportCoreAsync( + IReadOnlyCollection sclPaths, + string? projectName, + bool replaceRuntimeWorkspaces, + CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(sclPaths); - SetRuntimeWorkspaces(Array.Empty()); + if (replaceRuntimeWorkspaces) + SetRuntimeWorkspaces(Array.Empty()); var requestedPaths = sclPaths .Where(path => !string.IsNullOrWhiteSpace(path)) .Select(Path.GetFullPath) @@ -217,9 +230,13 @@ public async Task ImportAsync( // Keep the exact engine-owned workspaces alive for the current FAT run. AutoConnect // can attach these already-parsed models to the IED and perform a fast MMS association // instead of repeating a network-wide Smart Scan that SCL-backed FAT does not need. - SetRuntimeWorkspaces(loadedSources + var importedWorkspaces = loadedSources .SelectMany(source => source.Document.Ieds) - .ToArray()); + .ToArray(); + if (replaceRuntimeWorkspaces) + SetRuntimeWorkspaces(importedWorkspaces); + else + AddRuntimeWorkspaces(importedWorkspaces); return new IoFatSclProjectImportResult( project, @@ -235,6 +252,18 @@ private void SetRuntimeWorkspaces(IReadOnlyList workspaces) _runtimeWorkspaces = workspaces; } + private void AddRuntimeWorkspaces(IReadOnlyCollection workspaces) + { + lock (_runtimeWorkspaceGate) + { + _runtimeWorkspaces = _runtimeWorkspaces + .Concat(workspaces) + .GroupBy(workspace => workspace.WorkspaceKey, StringComparer.OrdinalIgnoreCase) + .Select(group => group.Last()) + .ToArray(); + } + } + private static List BuildIedPlans( IReadOnlyCollection loadedSources, FatVerificationProject verificationProject, diff --git a/Services/IoTesting/IoTestMultiSessionCoordinator.cs b/Services/IoTesting/IoTestMultiSessionCoordinator.cs new file mode 100644 index 000000000..1f6767bd9 --- /dev/null +++ b/Services/IoTesting/IoTestMultiSessionCoordinator.cs @@ -0,0 +1,405 @@ +using System.ComponentModel; +using ArIED61850Tester.Models; +using ArIED61850Tester.Models.IoTesting; + +namespace ArIED61850Tester.Services.IoTesting; + +/// +/// P2/P3 evidence-session authority. Each FAT IED owns one isolated +/// so several connected IEDs can capture evidence +/// at the same time without sharing a journal, live-point index, connection generation, +/// transition evaluator, auto-capture state, or Recapture transaction. +/// +/// The controller supplied by MainWindow remains the primary leaf for backwards +/// compatibility. Additional leaves are created lazily through the configured sibling +/// factory and receive runtime events through ; the primary +/// leaf continues to receive its existing MainWindow event route. This deliberately avoids +/// double-delivery to the primary journal. +/// +public sealed class IoTestMultiSessionCoordinator : ObservableObject, IDisposable +{ + private readonly object _sync = new(); + private readonly IoTestProject _project; + private readonly IoTestSessionController _primaryController; + private readonly Dictionary _controllers = new(); + private Func? _siblingFactory; + private IoTestIedPlan? _selectedIed; + private IoTestIedPlan? _primaryIed; + private bool _disposed; + + public IoTestMultiSessionCoordinator( + IoTestProject project, + IoTestSessionController primaryController) + { + _project = project ?? throw new ArgumentNullException(nameof(project)); + _primaryController = primaryController ?? throw new ArgumentNullException(nameof(primaryController)); + _primaryController.PropertyChanged += Child_PropertyChanged; + } + + public IoTestProject Project => _project; + public IoTestSessionController PrimaryController => _primaryController; + public IoTestIedPlan? SelectedIed => _selectedIed; + + private IoTestSessionController? SelectedController + { + get + { + lock (_sync) + return _selectedIed != null && _controllers.TryGetValue(_selectedIed, out var controller) + ? controller + : null; + } + } + + public int ActiveSessionCount => ControllerSnapshot().Count(controller => controller.IsSessionActive); + public bool HasActiveSessions => ActiveSessionCount > 0; + + // Session-facing properties are deliberately selected-context projections. This keeps + // existing FAT UI bindings intuitive: when IED A is running and the operator selects + // IED B, B still shows Start FAT and remains editable. Workspace-close/export code uses + // HasActiveSessions/ActiveSessionCount for the global safety boundary. + public bool IsSelectedSessionActive => SelectedController?.IsSessionActive == true; + public bool IsSessionActive => IsSelectedSessionActive; + public bool CanStart => _selectedIed != null && !IsSelectedSessionActive; + public bool CanPause => SelectedController?.CanPause == true; + public bool CanResume => SelectedController?.CanResume == true; + public bool CanStop => SelectedController?.CanStop == true; + public bool CanSelectIed => true; + public bool CanEditPlan => !IsSelectedSessionActive; + + public IoTestSessionState State => SelectedController?.State ?? IoTestSessionState.Idle; + public IoTestIedPlan? ActiveIed => IsSelectedSessionActive ? SelectedController?.ActiveIed : null; + public Guid SessionId => SelectedController?.SessionId ?? Guid.Empty; + public DateTimeOffset? StartedAtUtc => SelectedController?.StartedAtUtc; + public DateTimeOffset? CompletedAtUtc => SelectedController?.CompletedAtUtc; + public string JournalPath => SelectedController?.JournalPath ?? string.Empty; + public long EvidenceRecordCount => SelectedController?.EvidenceRecordCount ?? 0; + public string LastJournalHash => SelectedController?.LastJournalHash ?? string.Empty; + public string JournalIntegrityText => SelectedController?.JournalIntegrityText ?? "No evidence journal open"; + public string StateText => SelectedController?.StateText ?? "READY"; + public string ActiveIedText => ActiveIed == null + ? "No active IED session in the selected context" + : $"{ActiveIed.IedName} · {ActiveIed.IpAddress}"; + public string ProgressText => SelectedController?.ProgressText ?? "0 / 0 complete"; + public string StatusText + { + get + { + var selected = SelectedController; + if (selected != null) + return selected.StatusText; + if (ActiveSessionCount > 0) + return $"{ActiveSessionCount} other IED FAT evidence session(s) are running independently."; + return "Select an imported IED and start its FAT session."; + } + } + + /// + /// Supplies the MainWindow-owned leaf factory after the FAT window has an Owner. + /// The factory must create a fresh IoTestSessionController for the same project and + /// evidence directory; no controller instance may be shared between IEDs. + /// + public void ConfigureSiblingFactory(Func factory) + { + ThrowIfDisposed(); + _siblingFactory = factory ?? throw new ArgumentNullException(nameof(factory)); + } + + public void SelectContext(IoTestIedPlan? ied) + { + ThrowIfDisposed(); + if (ReferenceEquals(_selectedIed, ied)) + return; + _selectedIed = ied; + RaiseProjectionProperties(); + } + + public bool IsIedSessionActive(IoTestIedPlan? ied) + { + if (ied == null) + return false; + lock (_sync) + return _controllers.TryGetValue(ied, out var controller) && controller.IsSessionActive; + } + + public IoTestSessionActionResult Start(IoTestIedPlan? ied) + => Start(ied, captureScope: null); + + public IoTestSessionActionResult Start( + IoTestIedPlan? ied, + IReadOnlyCollection? captureScope) + { + ThrowIfDisposed(); + if (ied == null) + return IoTestSessionActionResult.Failure("Select an imported IED first."); + + SelectContext(ied); + IoTestSessionController controller; + try + { + controller = GetOrCreateController(ied); + } + catch (InvalidOperationException ex) + { + return IoTestSessionActionResult.Failure(ex.Message); + } + + var result = controller.Start(ied, captureScope); + RaiseProjectionProperties(); + return result; + } + + public IoTestSessionActionResult Pause(string reason = "Paused by operator") + => SelectedAction( + controller => controller.Pause(reason), + "No FAT evidence session is active for the selected IED."); + + public IoTestSessionActionResult Resume() + => SelectedAction( + controller => controller.Resume(), + "No paused or interrupted FAT evidence session belongs to the selected IED."); + + public IoTestSessionActionResult Stop(string reason = "Stopped by operator") + => SelectedAction( + controller => controller.Stop(reason), + "No FAT evidence session is active for the selected IED."); + + public IoTestSessionActionResult StopAll(string reason = "Stopped by operator") + { + ThrowIfDisposed(); + var failures = new List(); + foreach (var controller in ControllerSnapshot().Where(controller => controller.IsSessionActive)) + { + var result = controller.Stop(reason); + if (!result.Succeeded) + failures.Add(result.Message); + } + RaiseProjectionProperties(); + return failures.Count == 0 + ? IoTestSessionActionResult.Success(reason) + : IoTestSessionActionResult.Failure(string.Join(" ", failures.Distinct(StringComparer.Ordinal))); + } + + public IoTestSessionActionResult ResetForCleanRetest() + => SelectedAction( + controller => controller.ResetForCleanRetest(), + "Select an IED with an existing FAT evidence context before creating a clean retest."); + + public IoTestSessionActionResult CaptureOperatorSnapshot(IoTestPointPlan? point, FatValueSlot slot) + { + ThrowIfDisposed(); + if (point == null) + return IoTestSessionActionResult.Failure("Select a FAT row before capturing Value 1 or Value 2."); + + var controller = ResolveOwningActiveController(new[] { point }, out var failure); + if (controller == null) + return IoTestSessionActionResult.Failure(failure); + + var result = controller.CaptureOperatorSnapshot(point, slot); + RaiseProjectionProperties(); + return result; + } + + public IoTestSessionActionResult RecaptureValues( + IReadOnlyCollection points, + FatValueSlot slot) + => BatchPointAction( + points, + (controller, batch) => controller.RecaptureValues(batch, slot), + "Select one or more rows from one active IED FAT session before using Recapture."); + + public IoTestSessionActionResult BeginPairRecapture(IReadOnlyCollection points) + => BatchPointAction( + points, + (controller, batch) => controller.BeginPairRecapture(batch), + "Select one or more rows from one active IED FAT session before staging Value 1 & Value 2 Recapture."); + + public IoTestSessionActionResult CancelPairRecapture(IReadOnlyCollection points) + => BatchPointAction( + points, + (controller, batch) => controller.CancelPairRecapture(batch), + "Select one or more rows from one active IED FAT session before cancelling staged Recapture."); + + /// + /// Runtime event route for additional P2 leaves only. MainWindow already routes every + /// event to PrimaryController, therefore including it here would duplicate primary + /// evidence. Each sibling leaf still enforces its own exact DeviceId boundary. + /// + public void EnqueueAdditional(Iec61850EventEntry entry) + { + if (entry == null || _disposed) + return; + + foreach (var controller in ControllerSnapshot()) + { + if (!ReferenceEquals(controller, _primaryController)) + controller.Enqueue(entry); + } + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + + var controllers = ControllerSnapshot(); + foreach (var controller in controllers) + controller.PropertyChanged -= Child_PropertyChanged; + + foreach (var controller in controllers) + controller.Dispose(); + + lock (_sync) + _controllers.Clear(); + } + + private IoTestSessionActionResult BatchPointAction( + IReadOnlyCollection? points, + Func, IoTestSessionActionResult> action, + string missingMessage) + { + ThrowIfDisposed(); + var batch = points?.Where(point => point != null).Distinct().ToArray() ?? Array.Empty(); + if (batch.Length == 0) + return IoTestSessionActionResult.Failure(missingMessage); + + var controller = ResolveOwningActiveController(batch, out var failure); + if (controller == null) + return IoTestSessionActionResult.Failure(failure); + + var result = action(controller, batch); + RaiseProjectionProperties(); + return result; + } + + private IoTestSessionController? ResolveOwningActiveController( + IReadOnlyCollection points, + out string failure) + { + failure = string.Empty; + if (points.Count == 0) + { + failure = "Select at least one FAT row."; + return null; + } + + var owners = _project.Ieds + .Where(ied => points.Any(ied.TestPoints.Contains)) + .Distinct() + .ToArray(); + if (owners.Length != 1 || points.Any(point => !owners[0].TestPoints.Contains(point))) + { + failure = "Bulk Recapture must contain rows from exactly one IED. No evidence changed."; + return null; + } + + lock (_sync) + { + if (!_controllers.TryGetValue(owners[0], out var controller) || !controller.IsSessionActive) + { + failure = $"Start the {owners[0].IedName} FAT session before using Recapture."; + return null; + } + return controller; + } + } + + private IoTestSessionController GetOrCreateController(IoTestIedPlan ied) + { + lock (_sync) + { + if (_controllers.TryGetValue(ied, out var existing)) + return existing; + + IoTestSessionController controller; + if (_primaryIed == null) + { + controller = _primaryController; + _primaryIed = ied; + } + else + { + if (_siblingFactory == null) + { + throw new InvalidOperationException( + "Parallel FAT evidence is not attached to the Engineering runtime yet. Reopen the FAT workspace and retry."); + } + + controller = _siblingFactory() + ?? throw new InvalidOperationException("The parallel FAT evidence controller factory returned no controller."); + if (ReferenceEquals(controller, _primaryController) || _controllers.Values.Contains(controller)) + { + controller.Dispose(); + throw new InvalidOperationException( + "Parallel FAT evidence requires one isolated session controller per IED."); + } + controller.PropertyChanged += Child_PropertyChanged; + } + + _controllers.Add(ied, controller); + return controller; + } + } + + private IoTestSessionActionResult SelectedAction( + Func action, + string missingMessage) + { + ThrowIfDisposed(); + var controller = SelectedController; + if (controller == null) + return IoTestSessionActionResult.Failure(missingMessage); + var result = action(controller); + RaiseProjectionProperties(); + return result; + } + + private List ControllerSnapshot() + { + lock (_sync) + { + var result = _controllers.Values.Distinct().ToList(); + if (!result.Contains(_primaryController)) + result.Add(_primaryController); + return result; + } + } + + private void Child_PropertyChanged(object? sender, PropertyChangedEventArgs e) + => RaiseProjectionProperties(); + + private void RaiseProjectionProperties() + { + Raise(nameof(SelectedIed)); + Raise(nameof(ActiveSessionCount)); + Raise(nameof(HasActiveSessions)); + Raise(nameof(IsSessionActive)); + Raise(nameof(IsSelectedSessionActive)); + Raise(nameof(CanStart)); + Raise(nameof(CanPause)); + Raise(nameof(CanResume)); + Raise(nameof(CanStop)); + Raise(nameof(CanSelectIed)); + Raise(nameof(CanEditPlan)); + Raise(nameof(State)); + Raise(nameof(ActiveIed)); + Raise(nameof(SessionId)); + Raise(nameof(StartedAtUtc)); + Raise(nameof(CompletedAtUtc)); + Raise(nameof(StatusText)); + Raise(nameof(JournalPath)); + Raise(nameof(EvidenceRecordCount)); + Raise(nameof(LastJournalHash)); + Raise(nameof(JournalIntegrityText)); + Raise(nameof(StateText)); + Raise(nameof(ActiveIedText)); + Raise(nameof(ProgressText)); + } + + private void ThrowIfDisposed() + { + if (_disposed) + throw new ObjectDisposedException(nameof(IoTestMultiSessionCoordinator)); + } +} diff --git a/Services/IoTesting/IoTestProjectPersistenceService.cs b/Services/IoTesting/IoTestProjectPersistenceService.cs index 9c69e3af8..9bfd1e55f 100644 --- a/Services/IoTesting/IoTestProjectPersistenceService.cs +++ b/Services/IoTesting/IoTestProjectPersistenceService.cs @@ -30,6 +30,7 @@ public sealed class IoTestWorkspacePersistence : ObservableObject, IDisposable private readonly object _saveSync = new(); private readonly Timer _saveTimer; private readonly IoTestSessionController _session; + private readonly List _sourceFiles; private bool _disposed; private string _statusText = "Progress storage is ready"; private DateTimeOffset? _lastSavedAtUtc; @@ -46,7 +47,7 @@ private IoTestWorkspacePersistence( _session = session; LocalDirectory = localDirectory; SnapshotPath = Path.Combine(localDirectory, "project.snapshot.json"); - SourceFiles = sourceFiles; + _sourceFiles = sourceFiles.ToList(); EvidenceProjectDirectory = evidenceProjectDirectory; _saveTimer = new Timer(_ => SaveFromTimer(), null, Timeout.Infinite, Timeout.Infinite); Subscribe(); @@ -55,7 +56,7 @@ private IoTestWorkspacePersistence( public IoTestProject Project { get; } public string LocalDirectory { get; } public string SnapshotPath { get; } - public IReadOnlyList SourceFiles { get; } + public IReadOnlyList SourceFiles => _sourceFiles; public string SourceWorkbookPath => SourceFiles .FirstOrDefault(source => source.Source.Kind.Equals(IoFatSourceKinds.Workbook, StringComparison.OrdinalIgnoreCase)) ?.LocalPath ?? string.Empty; @@ -254,6 +255,70 @@ public void ScheduleSave() _saveTimer.Change(650, Timeout.Infinite); } + public async Task AddSourcesAsync( + IoTestProject importedSourceProject, + IReadOnlyCollection sourceInputs, + CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(importedSourceProject); + ArgumentNullException.ThrowIfNull(sourceInputs); + + var described = await IoFatSourceWorkspaceService.DescribeAsync(sourceInputs, cancellationToken) + .ConfigureAwait(false); + var newHashes = described + .Where(source => _sourceFiles.All(existing => + !existing.Source.Sha256.Equals(source.Source.Sha256, StringComparison.OrdinalIgnoreCase))) + .ToArray(); + if (newHashes.Length == 0) + return; + + var stagingProject = new IoTestProject + { + ProjectId = importedSourceProject.ProjectId, + SchemaVersion = importedSourceProject.SchemaVersion, + ProjectName = importedSourceProject.ProjectName + }; + var stagingDescriptors = newHashes.Select(source => source.Source).ToArray(); + stagingProject.SetSources( + stagingDescriptors, + IoFatSourceIdentity.ComputeSetFingerprint(stagingDescriptors)); + var staged = await IoFatSourceWorkspaceService.StageDescribedAsync( + stagingProject, + newHashes, + Path.Combine(LocalDirectory, "source"), + cancellationToken) + .ConfigureAwait(false); + lock (_saveSync) + { + foreach (var source in staged) + { + if (_sourceFiles.All(existing => + !existing.Source.Sha256.Equals(source.Source.Sha256, StringComparison.OrdinalIgnoreCase))) + { + _sourceFiles.Add(source); + } + } + _sourceFiles.Sort((left, right) => + StringComparer.Ordinal.Compare(left.Source.SourceId, right.Source.SourceId)); + } + } + + public void TrackAddedIed(IoTestIedPlan ied) + { + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(ied); + ied.PropertyChanged -= Changed; + ied.PropertyChanged += Changed; + foreach (var point in ied.TestPoints) + { + point.PropertyChanged -= Changed; + point.PropertyChanged += Changed; + point.Runtime.PropertyChanged -= Changed; + point.Runtime.PropertyChanged += Changed; + } + } + public void SaveNow() { ThrowIfDisposed(); @@ -402,7 +467,23 @@ private void Unsubscribe() } } - private void Changed(object? sender, PropertyChangedEventArgs e) => ScheduleSave(); + private void Changed(object? sender, PropertyChangedEventArgs e) + { + // Live telemetry is transient and can change hundreds of times per second. + // Evidence/state changes still autosave, and their snapshot includes the most + // recent live value. Avoid resetting the timer and notifying UI for every poll. + if (sender is IoTestPointRuntime && e.PropertyName is + nameof(IoTestPointRuntime.CurrentValue) or + nameof(IoTestPointRuntime.CurrentQuality) or + nameof(IoTestPointRuntime.CurrentSource) or + nameof(IoTestPointRuntime.CurrentIedTimestamp) or + nameof(IoTestPointRuntime.LastSequence)) + { + return; + } + + ScheduleSave(); + } private void SaveFromTimer() { @@ -474,9 +555,21 @@ private void SaveFromTimer() Value2Evidence = point.Runtime.Value2Evidence }) { + WorkspaceSelected = point.WorkspaceSelected, SignalKind = point.SignalKind, CaptureMode = point.CaptureMode, - FatDisposition = point.FatDisposition + FatDisposition = point.FatDisposition, + Cdc = point.Cdc, + SourceIecReference = point.SourceIecReference, + ReportDisplayReference = point.ReportDisplayReference, + EventLogSearchReference = point.EventLogSearchReference, + EvidenceExpected = point.EvidenceExpected, + MappingQuality = point.MappingQuality, + ReviewStatus = point.ReviewStatus, + ReviewReason = point.ReviewReason, + EventLogMatch = point.EventLogMatch, + EvidenceReference = point.EvidenceReference, + ReviewerComment = point.ReviewerComment }).ToList()) { LatestComtradeFiles = ied.LatestComtradeFiles, @@ -577,10 +670,22 @@ private static IoTestPointPlan RestorePoint(IoTestPointData data) LogicalNode = data.LogicalNode, DataObject = data.DataObject, DataAttribute = data.DataAttribute, + Cdc = data.Cdc, + SourceIecReference = data.SourceIecReference, + ReportDisplayReference = data.ReportDisplayReference, + EventLogSearchReference = data.EventLogSearchReference, + EvidenceExpected = data.EvidenceExpected, + MappingQuality = data.MappingQuality, + ReviewStatus = data.ReviewStatus, + ReviewReason = data.ReviewReason, + EventLogMatch = data.EventLogMatch, + EvidenceReference = data.EvidenceReference, + ReviewerComment = data.ReviewerComment, SourceSheet = data.SourceSheet, SourceRow = data.SourceRow, SignalKind = data.SignalKind, CaptureMode = data.CaptureMode, + WorkspaceSelected = data.WorkspaceSelected, TestEnabled = data.TestEnabled, ImportReady = data.ImportReady, BindingStatus = data.BindingStatus, @@ -666,7 +771,7 @@ private static string BuildPrintableReport(IoTestProject project) builder.Append(""); var index = 0; - foreach (var point in ied.TestPoints.Where(point => point.IsIncludedInFat)) + foreach (var point in ied.TestPoints.Where(point => point.WorkspaceSelected && point.IsIncludedInFat)) { index++; builder.Append("
#SignalIEC 61850 referenceType / captureValue 1Value 2ResultReason
").Append(index).Append("").Append(Html(point.SignalName)).Append("").Append(Html(point.ReportIecReference)).Append("") @@ -918,9 +1023,21 @@ private sealed record IoTestPointData( string BindingEvidence, IoTestRuntimeData Runtime) { + public bool WorkspaceSelected { get; init; } = true; public FatSignalKind SignalKind { get; init; } = FatSignalKind.Discrete; public FatCaptureMode CaptureMode { get; init; } = FatCaptureMode.AutomaticTransition; public FatSignalDisposition FatDisposition { get; init; } = FatSignalDisposition.Included; + public string Cdc { get; init; } = string.Empty; + public string SourceIecReference { get; init; } = string.Empty; + public string ReportDisplayReference { get; init; } = string.Empty; + public string EventLogSearchReference { get; init; } = string.Empty; + public string EvidenceExpected { get; init; } = string.Empty; + public string MappingQuality { get; init; } = string.Empty; + public string ReviewStatus { get; init; } = string.Empty; + public string ReviewReason { get; init; } = string.Empty; + public string EventLogMatch { get; init; } = string.Empty; + public string EvidenceReference { get; init; } = string.Empty; + public string ReviewerComment { get; init; } = string.Empty; } private sealed record IoTestRuntimeData( @@ -962,4 +1079,4 @@ private sealed record IoTestPackageEvidence( string Sha256, long RecordCount, string LastHash); -} \ No newline at end of file +} diff --git a/Services/IoTesting/IoTestReconciliationCache.cs b/Services/IoTesting/IoTestReconciliationCache.cs index 7a2724013..c22fe04fc 100644 --- a/Services/IoTesting/IoTestReconciliationCache.cs +++ b/Services/IoTesting/IoTestReconciliationCache.cs @@ -22,6 +22,14 @@ public static class IoTestReconciliationCache /// already-active association and let ARIEC61850 own exact reads, alternate strategies, /// probe budgets, and failure verdicts. A workspace that has never created a session may /// still build a model-only document; that path can only remain DesignOnly, never Absent. + /// + /// Direct SCL FAT is intentionally different. Its complete static DataSet scope is already + /// authoritative before association and local signal selection does not require design/live + /// reconciliation. Even model-only reconciliation can be CPU-heavy enough on a large saved + /// model to hold the operator before StartMonitoringAsync. Therefore direct SCL FAT has no + /// reconciliation gate at all: invalidate any stale cache and return immediately. Live + /// acquisition starts from the SCL authority; Engineering/diagnostic reconciliation remains + /// an explicit, non-critical workflow and is never allowed to delay physical FAT readiness. /// public static Task RefreshAsync( Iec61850MonitorDevice device, @@ -29,6 +37,12 @@ public static Task RefreshAsync( { ArgumentNullException.ThrowIfNull(device); + if (UsesDirectSclFatAuthority(device)) + { + Entries.TryRemove(device, out _); + return Task.CompletedTask; + } + if (!NativeIec61850Client.HasReconciliationOwner(device.IpAddress, device.Port)) return RefreshModelOnlyAsync(device, cancellationToken); @@ -193,6 +207,10 @@ public static void Invalidate(Iec61850MonitorDevice device) Entries.TryRemove(device, out _); } + private static bool UsesDirectSclFatAuthority(Iec61850MonitorDevice device) + => device.SclWorkspace?.DesignModel is not null && + device.IdentitySource.Contains("FAT SCL", StringComparison.OrdinalIgnoreCase); + private sealed record CacheEntry( object DesignModel, object LiveModel, diff --git a/Services/IoTesting/IoTestSessionController.cs b/Services/IoTesting/IoTestSessionController.cs index 53eb21330..a3e47d35d 100644 --- a/Services/IoTesting/IoTestSessionController.cs +++ b/Services/IoTesting/IoTestSessionController.cs @@ -22,10 +22,21 @@ public sealed class IoTestSessionController : ObservableObject, IDisposable private readonly Func _journalFactory; private readonly IoTestTransitionEvaluator _evaluator; private readonly IoTestRollingCaptureCoordinator _captureCoordinator; - private readonly ConcurrentQueue _pendingSnapshots = new(); + private readonly FatAutoCaptureCoordinator _autoCaptureCoordinator = new(); + // Only the newest observation per live point is needed by the UI transition + // evaluator. Coalescing prevents a fast report/poll stream from building an + // unbounded dispatcher backlog while the operator scrolls the FAT grid. + private readonly ConcurrentDictionary _pendingSnapshots = new(StringComparer.OrdinalIgnoreCase); + // Actual transitions are never coalesced: FAT evidence must retain a fast + // OFF→ON→OFF sequence even when all three samples arrive in one UI frame. + private readonly ConcurrentQueue _pendingEdgeSnapshots = new(); private readonly Dictionary> _activePointIndex = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _activeLivePoints = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet _sessionPointIds = new(StringComparer.OrdinalIgnoreCase); + // Value 1 & Value 2 bulk Recapture is deliberately two-stage. The staged Value 1 + // remains audit history only until Value 2 is captured successfully; current pointers + // therefore keep the old complete pair authoritative during the condition change. + private readonly Dictionary _pendingPairValue1 = new(StringComparer.OrdinalIgnoreCase); private IoTestSessionState _state = IoTestSessionState.Idle; private IoTestIedPlan? _activeIed; @@ -128,20 +139,20 @@ public IoTestSessionActionResult Start( if (captureScope == null) { expectedPoints = ied.TestPoints - .Where(point => point.IsIncludedInFat && point.TestEnabled && point.ImportReady) + .Where(point => point.WorkspaceSelected && point.IsIncludedInFat && point.TestEnabled && point.ImportReady) .ToList(); } else { var knownPoints = new HashSet(ied.TestPoints); var invalidScope = captureScope - .Where(point => !knownPoints.Contains(point) || !point.IsIncludedInFat || !point.TestEnabled || !point.ImportReady) + .Where(point => !knownPoints.Contains(point) || !point.WorkspaceSelected || !point.IsIncludedInFat || !point.TestEnabled || !point.ImportReady) .Distinct() .ToList(); if (invalidScope.Count > 0) { return IoTestSessionActionResult.Failure( - "The requested FAT capture scope contains a row that is not part of this IED, was removed by the operator, is not selected, or is not import-ready."); + "The requested FAT capture scope contains a row that is not part of this IED/shared workspace, was removed by the operator, has TEST disabled, or is not import-ready."); } expectedPoints = captureScope.Distinct().ToList(); @@ -160,6 +171,8 @@ public IoTestSessionActionResult Start( CleanupSessionResources(disposeJournal: true); _captureCoordinator.Clear(); + _autoCaptureCoordinator.Clear(); + _pendingPairValue1.Clear(); SessionId = Guid.NewGuid(); StartedAtUtc = DateTimeOffset.UtcNow; CompletedAtUtc = null; @@ -170,7 +183,7 @@ public IoTestSessionActionResult Start( _activePointIndex.Clear(); _activeLivePoints.Clear(); _sessionPointIds.Clear(); - while (_pendingSnapshots.TryDequeue(out _)) { } + ClearPendingSnapshots(); foreach (var binding in bindings) { @@ -190,29 +203,41 @@ public IoTestSessionActionResult Start( LastJournalHash = string.Empty; JournalIntegrityText = "Evidence journal open · hash chain initialized"; - var startupEntries = new List(bindings.Count + 1) + var startupEntries = new List((bindings.Count * 2) + 1) { SessionEvent("session_started", $"FAT session started with an explicit capture scope of {bindings.Count} operator-selected point(s).") }; + var autoDecisions = new List<(IoTestPointPlan Point, FatAutoCaptureDecision Decision)>(); foreach (var binding in bindings) { var observation = CreateObservation(binding.Point, binding.LivePoint, _connectionGeneration, DateTimeOffset.UtcNow); if (binding.Point.CaptureMode == FatCaptureMode.OperatorSnapshot) { - binding.Point.Runtime.ApplyObservation(observation with { NormalizedState = null }); + var genericObservation = observation with { NormalizedState = null }; + binding.Point.Runtime.ApplyObservation(genericObservation); startupEntries.Add(PointEvent( "operator_snapshot_baseline", binding.Point, - observation with { NormalizedState = null }, + genericObservation, null, - "Live value established for operator Value 1 / Value 2 snapshot capture.")); + "Live value established for automatic/manual Value 1 / Value 2 capture.")); + var decision = _autoCaptureCoordinator.Observe(binding.Point, genericObservation); + autoDecisions.Add((binding.Point, decision)); + if (decision.Evidence != null) + startupEntries.Add(FatValueEvent(binding.Point, decision.Evidence)); continue; } var evaluation = _captureCoordinator.Start(binding.Point, observation); startupEntries.Add(PointEvent("baseline", binding.Point, observation, evaluation.Evidence, evaluation.Reason)); + var automaticDecision = _autoCaptureCoordinator.Observe(binding.Point, observation); + autoDecisions.Add((binding.Point, automaticDecision)); + if (automaticDecision.Evidence != null) + startupEntries.Add(FatValueEvent(binding.Point, automaticDecision.Evidence)); } AppendBatchRequired(startupEntries); + foreach (var (point, decision) in autoDecisions) + PromoteAutoCaptureDecision(point, decision); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) { @@ -224,7 +249,7 @@ public IoTestSessionActionResult Start( } State = IoTestSessionState.Running; - StatusText = $"FAT capture running for {ied.IedName}. Automatic rows remain armed; operator-snapshot rows expose live values for Value 1 / Value 2 capture until Stop."; + StatusText = $"FAT capture running for {ied.IedName}. Value 1 / Value 2 auto-capture is armed; manual Capture and multi-row Recapture remain operator overrides until Stop."; RaiseProgress(); UpdateRunningCompletionStatus(); return IoTestSessionActionResult.Success(StatusText); @@ -237,7 +262,7 @@ public IoTestSessionActionResult Pause(string reason = "Paused by operator") return IoTestSessionActionResult.Failure("No running FAT session is available to pause."); State = IoTestSessionState.Paused; - while (_pendingSnapshots.TryDequeue(out _)) { } + ClearPendingSnapshots(); StatusText = reason; if (!AppendOrFault(SessionEvent("session_paused", reason))) return IoTestSessionActionResult.Failure(StatusText); @@ -270,8 +295,10 @@ public IoTestSessionActionResult Resume() } _connectionGeneration++; - while (_pendingSnapshots.TryDequeue(out _)) { } - if (!AppendOrFault(SessionEvent("session_resumed", "Session resumed after rebinding current live points; current values are treated as a new baseline image."))) + _pendingPairValue1.Clear(); + _autoCaptureCoordinator.Clear(); + ClearPendingSnapshots(); + if (!AppendOrFault(SessionEvent("session_resumed", "Session resumed after rebinding current live points; current values are treated as a new baseline image. Any staged pair Recapture was cancelled."))) return IoTestSessionActionResult.Failure(StatusText); foreach (var binding in refreshedBindings) @@ -288,16 +315,20 @@ public IoTestSessionActionResult Resume() null, "Live value refreshed after reconnect; existing Value 1 / Value 2 evidence is preserved."))) return IoTestSessionActionResult.Failure(StatusText); + if (!ApplyAutoCapture(binding.Point, genericObservation)) + return IoTestSessionActionResult.Failure(StatusText); continue; } var evaluation = _captureCoordinator.Observe(binding.Point, observation); if (!AppendOrFault(PointEvent("resume_baseline", binding.Point, observation, evaluation.Evidence, evaluation.Reason))) return IoTestSessionActionResult.Failure(StatusText); + if (!ApplyAutoCapture(binding.Point, observation)) + return IoTestSessionActionResult.Failure(StatusText); } State = IoTestSessionState.Running; - StatusText = $"FAT capture resumed for {ActiveIed.IedName}; current evidence is preserved until newer evidence is explicitly completed."; + StatusText = $"FAT capture resumed for {ActiveIed.IedName}; current evidence is preserved and auto-capture continues only for incomplete slots."; RaiseProgress(); UpdateRunningCompletionStatus(); return IoTestSessionActionResult.Success(StatusText); @@ -350,23 +381,16 @@ public IoTestSessionActionResult CaptureOperatorSnapshot(IoTestPointPlan? point, return IoTestSessionActionResult.Failure("The selected FAT row is not part of the active IED session."); if (!_sessionPointIds.Contains(point.TestPointId)) return IoTestSessionActionResult.Failure("The selected FAT row is not part of the active capture scope."); - if (!point.IsIncludedInFat || !point.TestEnabled || !point.ImportReady) - return IoTestSessionActionResult.Failure("The FAT row must remain included, operator-selected, and import-ready to capture evidence."); + if (!point.WorkspaceSelected || !point.IsIncludedInFat || !point.TestEnabled || !point.ImportReady) + return IoTestSessionActionResult.Failure("The FAT row must remain in the shared workspace, included, TEST-enabled, and import-ready to capture evidence."); if (point.CaptureMode != FatCaptureMode.OperatorSnapshot) - return IoTestSessionActionResult.Failure("This FAT row uses automatic transition capture; Value 1 / Value 2 are captured by live transitions."); + return IoTestSessionActionResult.Failure("Use right-click Recapture for automatic-transition rows."); if (!_activeLivePoints.TryGetValue(point.TestPointId, out var livePoint)) return IoTestSessionActionResult.Failure("The FAT row does not have one active live point to snapshot."); try { - var observation = new FatLiveValueObservation( - livePoint.Value, - DateTimeOffset.UtcNow, - IoTestValueNormalizer.ParseIedTimestamp(livePoint.DeviceTimestamp), - livePoint.Quality, - livePoint.SourceMode, - livePoint.Sequence, - _connectionGeneration); + var observation = CreateLiveValueObservation(livePoint); var evidence = FatOperatorSnapshotCaptureService.CreateEvidence(slot, observation); // Audit history is authoritative and append-only. Only after the journal write @@ -375,6 +399,10 @@ public IoTestSessionActionResult CaptureOperatorSnapshot(IoTestPointPlan? point, return IoTestSessionActionResult.Failure(StatusText); point.Runtime.SetFatValueEvidence(evidence); + point.Runtime.AutoCaptureStage = point.IsFatEvidenceComplete + ? FatAutoCaptureStage.Complete + : point.HasValue1Evidence ? FatAutoCaptureStage.WaitingChange : FatAutoCaptureStage.WaitingValue1; + _autoCaptureCoordinator.Clear(point); point.Runtime.StatusReason = point.IsFatEvidenceComplete ? "Value 1 and Value 2 captured. Current evidence is complete; either value may be recaptured while the session remains running." : $"{(slot == FatValueSlot.Value1 ? "Value 1" : "Value 2")} captured; capture the remaining value to complete current evidence."; @@ -389,6 +417,140 @@ public IoTestSessionActionResult CaptureOperatorSnapshot(IoTestPointPlan? point, } } + public IoTestSessionActionResult RecaptureValues( + IReadOnlyCollection points, + FatValueSlot slot) + { + ThrowIfDisposed(); + if (!TryPrepareRecapture(points, out var bindings, out var failure)) + return IoTestSessionActionResult.Failure(failure); + + if (slot == FatValueSlot.Value1 && bindings.Any(binding => _pendingPairValue1.ContainsKey(binding.Point.TestPointId))) + { + return IoTestSessionActionResult.Failure( + "A Value 1 & Value 2 pair Recapture is already staged for one or more selected rows. Complete it with Recapture Value 2 or cancel the staged pair first."); + } + + var captures = new List<(IoTestPointPlan Point, FatValueEvidence Evidence, FatValueEvidence? StagedValue1)>(); + try + { + foreach (var binding in bindings) + { + var evidence = FatOperatorSnapshotCaptureService.CreateEvidence( + slot, + CreateLiveValueObservation(binding.LivePoint), + FatEvidenceCaptureKind.OperatorRecapture); + _pendingPairValue1.TryGetValue(binding.Point.TestPointId, out var stagedValue1); + if (slot == FatValueSlot.Value2 && stagedValue1 != null && stagedValue1.ConnectionGeneration != _connectionGeneration) + { + return IoTestSessionActionResult.Failure( + "A staged Value 1 belongs to an earlier IED connection generation. Cancel it and start Value 1 & Value 2 Recapture again."); + } + captures.Add((binding.Point, evidence, slot == FatValueSlot.Value2 ? stagedValue1 : null)); + } + + AppendBatchRequired(captures.Select(item => FatValueEvent( + item.Point, + item.Evidence, + item.StagedValue1 != null ? "fat_value_recapture_pair_commit" : "fat_value_recapture")).ToList()); + } + catch (Exception ex) when (ex is InvalidOperationException or IOException or UnauthorizedAccessException) + { + return IoTestSessionActionResult.Failure(ex.Message); + } + + foreach (var capture in captures) + { + if (capture.StagedValue1 != null) + { + capture.Point.Runtime.SetFatValueEvidence(capture.StagedValue1); + _pendingPairValue1.Remove(capture.Point.TestPointId); + } + capture.Point.Runtime.SetFatValueEvidence(capture.Evidence); + capture.Point.Runtime.AutoCaptureStage = capture.Point.IsFatEvidenceComplete + ? FatAutoCaptureStage.Complete + : capture.Point.HasValue1Evidence ? FatAutoCaptureStage.WaitingChange : FatAutoCaptureStage.WaitingValue1; + capture.Point.Runtime.StatusReason = capture.StagedValue1 != null + ? "Operator pair Recapture completed; staged Value 1 and current Value 2 are now the authoritative pair." + : $"Operator recaptured {slot}; the other current slot was preserved."; + _autoCaptureCoordinator.Clear(capture.Point); + } + + StatusText = captures.Any(item => item.StagedValue1 != null) + ? $"{captures.Count} signal(s) · Value 1 & Value 2 recaptured · {ActiveIed!.IedName}" + : $"{captures.Count} signal(s) · {slot} recaptured · {ActiveIed!.IedName}"; + RaiseProgress(); + UpdateRunningCompletionStatus(); + return IoTestSessionActionResult.Success(StatusText); + } + + public IoTestSessionActionResult BeginPairRecapture(IReadOnlyCollection points) + { + ThrowIfDisposed(); + if (!TryPrepareRecapture(points, out var bindings, out var failure)) + return IoTestSessionActionResult.Failure(failure); + if (bindings.Any(binding => _pendingPairValue1.ContainsKey(binding.Point.TestPointId))) + return IoTestSessionActionResult.Failure("A Value 1 & Value 2 pair Recapture is already staged for one or more selected rows."); + + var staged = new List<(IoTestPointPlan Point, FatValueEvidence Evidence)>(); + try + { + foreach (var binding in bindings) + { + staged.Add(( + binding.Point, + FatOperatorSnapshotCaptureService.CreateEvidence( + FatValueSlot.Value1, + CreateLiveValueObservation(binding.LivePoint), + FatEvidenceCaptureKind.OperatorRecapture))); + } + + AppendBatchRequired(staged.Select(item => FatValueEvent( + item.Point, + item.Evidence, + "fat_value_recapture_pair_staged", + "Operator staged a new Value 1 for transactional Value 1 & Value 2 Recapture; current pointers remain unchanged until Value 2 is captured.")).ToList()); + } + catch (Exception ex) when (ex is InvalidOperationException or IOException or UnauthorizedAccessException) + { + return IoTestSessionActionResult.Failure(ex.Message); + } + + foreach (var item in staged) + { + _pendingPairValue1[item.Point.TestPointId] = item.Evidence; + item.Point.Runtime.StatusReason = "New Value 1 staged for pair Recapture; change the test condition, then Recapture Value 2. Existing current pair remains authoritative."; + } + + StatusText = $"{staged.Count} signal(s) · Value 1 staged · change condition, then Recapture Value 2 · {ActiveIed!.IedName}"; + RaiseProgress(); + return IoTestSessionActionResult.Success(StatusText); + } + + public IoTestSessionActionResult CancelPairRecapture(IReadOnlyCollection points) + { + ThrowIfDisposed(); + var requested = points?.Where(point => point != null).Distinct().ToList() ?? new List(); + var staged = requested.Where(point => _pendingPairValue1.ContainsKey(point.TestPointId)).ToList(); + if (staged.Count == 0) + return IoTestSessionActionResult.Failure("No staged Value 1 & Value 2 Recapture exists for the selected rows."); + if (State != IoTestSessionState.Running || ActiveIed == null) + return IoTestSessionActionResult.Failure("The owning FAT session is not running."); + + if (!AppendOrFault(SessionEvent( + "fat_value_recapture_pair_cancelled", + $"Operator cancelled staged pair Recapture for {staged.Count} row(s): {string.Join(", ", staged.Select(point => point.TestPointId))}."))) + return IoTestSessionActionResult.Failure(StatusText); + + foreach (var point in staged) + { + _pendingPairValue1.Remove(point.TestPointId); + point.Runtime.StatusReason = "Staged pair Recapture cancelled; previous current evidence remains unchanged."; + } + StatusText = $"{staged.Count} signal(s) · staged pair Recapture cancelled · {ActiveIed.IedName}"; + return IoTestSessionActionResult.Success(StatusText); + } + public void Enqueue(Iec61850EventEntry entry) { if (entry == null || _disposed || State != IoTestSessionState.Running) @@ -399,7 +561,7 @@ public void Enqueue(Iec61850EventEntry entry) !entry.DeviceId.Equals(activeDevice.DeviceId, StringComparison.OrdinalIgnoreCase)) return; - _pendingSnapshots.Enqueue(new QueuedSnapshot( + var queued = new QueuedSnapshot( entry.DeviceId, entry.IecReference, entry.OldValue, @@ -409,7 +571,24 @@ public void Enqueue(Iec61850EventEntry entry) entry.SourceMode, entry.Reason, entry.Sequence, - DateTimeOffset.UtcNow)); + DateTimeOffset.UtcNow); + var pendingKey = PointKey(entry.DeviceId, entry.IecReference); + var preservesTransitionEvidence = + _activePointIndex.TryGetValue(pendingKey, out var mappedPoints) && + mappedPoints.Any(point => point.CaptureMode == FatCaptureMode.AutomaticTransition); + if (preservesTransitionEvidence && + !Iec61850MonitorPoint.AreSemanticallyEquivalent(entry.OldValue, entry.NewValue)) + { + _pendingSnapshots.TryRemove(pendingKey, out _); + _pendingEdgeSnapshots.Enqueue(queued); + } + else + { + _pendingSnapshots.AddOrUpdate( + pendingKey, + queued, + (_, current) => queued.Sequence >= current.Sequence ? queued : current); + } ScheduleDrain(); } @@ -421,7 +600,7 @@ public void Dispose() Stop("Workspace closed; FAT session stopped."); _disposed = true; CleanupSessionResources(disposeJournal: true); - while (_pendingSnapshots.TryDequeue(out _)) { } + ClearPendingSnapshots(); } private void ScheduleDrain() @@ -446,9 +625,15 @@ private void DrainPendingSnapshots() try { while (processed < MaxSnapshotsPerDrain && - stopwatch.ElapsedMilliseconds < DrainBudgetMilliseconds && - _pendingSnapshots.TryDequeue(out var snapshot)) + stopwatch.ElapsedMilliseconds < DrainBudgetMilliseconds) { + QueuedSnapshot? snapshot; + if (!_pendingEdgeSnapshots.TryDequeue(out snapshot)) + { + var pendingKey = _pendingSnapshots.Keys.FirstOrDefault(); + if (pendingKey == null || !_pendingSnapshots.TryRemove(pendingKey, out snapshot)) + break; + } ProcessSnapshot(snapshot); processed++; if (State != IoTestSessionState.Running) @@ -458,11 +643,17 @@ private void DrainPendingSnapshots() finally { Interlocked.Exchange(ref _drainScheduled, 0); - if (!_pendingSnapshots.IsEmpty && State == IoTestSessionState.Running) + if ((!_pendingEdgeSnapshots.IsEmpty || !_pendingSnapshots.IsEmpty) && State == IoTestSessionState.Running) ScheduleDrain(); } } + private void ClearPendingSnapshots() + { + _pendingSnapshots.Clear(); + while (_pendingEdgeSnapshots.TryDequeue(out _)) { } + } + private void ProcessSnapshot(QueuedSnapshot snapshot) { if (State != IoTestSessionState.Running) @@ -472,23 +663,12 @@ private void ProcessSnapshot(QueuedSnapshot snapshot) if (!_activePointIndex.TryGetValue(key, out var points)) return; + var progressChanged = false; foreach (var point in points.Distinct()) { - if (point.CaptureMode == FatCaptureMode.OperatorSnapshot) - { - point.Runtime.ApplyObservation(new IoTestObservation( - null, - snapshot.Value, - snapshot.CapturedAtUtc, - IoTestValueNormalizer.ParseIedTimestamp(snapshot.DeviceTimestamp), - snapshot.Quality, - snapshot.SourceMode, - snapshot.Sequence, - _connectionGeneration)); - continue; - } - - var normalized = IoTestValueNormalizer.Normalize(point, snapshot.Value); + var normalized = point.CaptureMode == FatCaptureMode.AutomaticTransition + ? IoTestValueNormalizer.Normalize(point, snapshot.Value) + : null; var observation = new IoTestObservation( normalized, snapshot.Value, @@ -498,46 +678,82 @@ private void ProcessSnapshot(QueuedSnapshot snapshot) snapshot.SourceMode, snapshot.Sequence, _connectionGeneration); - var evaluation = _captureCoordinator.Observe(point, observation); - if (evaluation.Evidence != null) - { - var eventType = evaluation.Evidence.Verdict == IoEvidenceVerdict.Rejected - ? "transition_rejected" - : evaluation.Evidence.Transition == IoEvidenceTransition.On - ? "on_evidence" - : "off_evidence"; - if (!AppendOrFault(PointEvent(eventType, point, observation, evaluation.Evidence, evaluation.Reason, snapshot.Reason, snapshot.PreviousValue))) - return; - } - else if (evaluation.StateChanged) + if (point.CaptureMode == FatCaptureMode.OperatorSnapshot) { - if (!AppendOrFault(PointEvent( - "baseline_state", - point, - observation, - null, - evaluation.Reason, - snapshot.Reason, - snapshot.PreviousValue))) - return; + point.Runtime.ApplyObservation(observation); } - else if (normalized == null) + else { - if (!AppendOrFault(PointEvent( - "unresolved_edge", - point, - observation, - null, - "A live edge was observed but its digital state could not be normalized.", - snapshot.Reason, - snapshot.PreviousValue))) - return; + var evaluation = _captureCoordinator.Observe(point, observation); + progressChanged |= evaluation.StateChanged || evaluation.Evidence != null; + + if (evaluation.Evidence != null) + { + var eventType = evaluation.Evidence.Verdict == IoEvidenceVerdict.Rejected + ? "transition_rejected" + : evaluation.Evidence.Transition == IoEvidenceTransition.On + ? "on_evidence" + : "off_evidence"; + if (!AppendOrFault(PointEvent(eventType, point, observation, evaluation.Evidence, evaluation.Reason, snapshot.Reason, snapshot.PreviousValue))) + return; + } + else if (evaluation.StateChanged) + { + if (!AppendOrFault(PointEvent( + "baseline_state", + point, + observation, + null, + evaluation.Reason, + snapshot.Reason, + snapshot.PreviousValue))) + return; + } + else if (normalized == null) + { + if (!AppendOrFault(PointEvent( + "unresolved_edge", + point, + observation, + null, + "A live edge was observed but its digital state could not be normalized.", + snapshot.Reason, + snapshot.PreviousValue))) + return; + } } + + var previousStage = point.Runtime.AutoCaptureStage; + var previousComplete = point.IsFatEvidenceComplete; + if (!ApplyAutoCapture(point, observation)) + return; + progressChanged |= previousStage != point.Runtime.AutoCaptureStage || previousComplete != point.IsFatEvidenceComplete; } - RaiseProgress(); - UpdateRunningCompletionStatus(); + if (progressChanged) + { + RaiseProgress(); + UpdateRunningCompletionStatus(); + } + } + + private bool ApplyAutoCapture(IoTestPointPlan point, IoTestObservation observation) + { + var decision = _autoCaptureCoordinator.Observe(point, observation); + if (decision.Evidence != null && !AppendOrFault(FatValueEvent(point, decision.Evidence))) + return false; + PromoteAutoCaptureDecision(point, decision); + return true; + } + + private static void PromoteAutoCaptureDecision(IoTestPointPlan point, FatAutoCaptureDecision decision) + { + point.Runtime.AutoCaptureStage = decision.Stage; + if (decision.Evidence != null) + point.Runtime.SetFatValueEvidence(decision.Evidence); + if (point.CaptureMode == FatCaptureMode.OperatorSnapshot || decision.Evidence != null) + point.Runtime.StatusReason = decision.Message; } private void UpdateRunningCompletionStatus() @@ -558,8 +774,8 @@ private void UpdateRunningCompletionStatus() var failed = automatic.Count(point => point.Runtime.State == IoTestPointState.Failed); var snapshots = scoped.Count - automatic.Count; StatusText = - $"{ActiveIed.IedName} current evidence complete: {passed} PASS, {review} review, {failed} fail, {snapshots} operator-snapshot row(s) complete. " + - "Capture remains running; current evidence may be replaced only by newer complete/operator-confirmed captures until Stop."; + $"{ActiveIed.IedName} current evidence complete: {passed} PASS, {review} review, {failed} fail, {snapshots} snapshot row(s) complete. " + + "Auto-capture is locked at the current pair; explicit Recapture is required to replace Value 1 or Value 2 until Stop."; } private void ActiveDevice_PropertyChanged(object? sender, PropertyChangedEventArgs e) @@ -578,18 +794,64 @@ private void InterruptForConnectionLoss(string capturedStatus) if (State != IoTestSessionState.Running || ActiveIed == null) return; + var stagedCount = _pendingPairValue1.Count; + _pendingPairValue1.Clear(); + _autoCaptureCoordinator.Clear(); State = IoTestSessionState.Interrupted; - while (_pendingSnapshots.TryDequeue(out _)) { } - StatusText = $"{ActiveIed.IedName} monitoring was interrupted ({capturedStatus}). Reconnect the IED, then Resume to establish a new baseline."; + ClearPendingSnapshots(); + StatusText = $"{ActiveIed.IedName} monitoring was interrupted ({capturedStatus}). Reconnect the IED, then Resume to establish a new baseline." + + (stagedCount == 0 ? string.Empty : $" {stagedCount} staged pair Recapture item(s) were cancelled; current pointers were unchanged."); AppendOrFault(SessionEvent("session_interrupted", StatusText)); } + private bool TryPrepareRecapture( + IReadOnlyCollection? points, + out List bindings, + out string failure) + { + bindings = new List(); + failure = string.Empty; + if (State != IoTestSessionState.Running || ActiveIed == null) + { + failure = "Start this IED FAT session before using Recapture."; + return false; + } + + var requested = points?.Where(point => point != null).Distinct().ToList() ?? new List(); + if (requested.Count == 0) + { + failure = "Select one or more FAT rows before using Recapture."; + return false; + } + + foreach (var point in requested) + { + if (!ActiveIed.TestPoints.Contains(point) || !_sessionPointIds.Contains(point.TestPointId)) + { + failure = $"{point.SignalName} is not part of the active IED capture scope. No evidence changed."; + return false; + } + if (!point.WorkspaceSelected || !point.IsIncludedInFat || !point.TestEnabled || !point.ImportReady) + { + failure = $"{point.SignalName} is not eligible for Recapture because it left the shared/included/TEST/import-ready FAT scope. No evidence changed."; + return false; + } + if (!_activeLivePoints.TryGetValue(point.TestPointId, out var livePoint)) + { + failure = $"{point.SignalName} does not have one active live point. No evidence changed."; + return false; + } + bindings.Add(new SessionBinding(point, livePoint)); + } + return true; + } + private static List BuildSessionBindings( IReadOnlyCollection captureScope, Iec61850MonitorDevice device) { var result = new List(); - foreach (var point in captureScope.Where(point => point.IsIncludedInFat && point.IsLiveBound)) + foreach (var point in captureScope.Where(point => point.WorkspaceSelected && point.IsIncludedInFat && point.IsLiveBound)) { var expected = IoTestLiveBindingService.NormalizeReference( string.IsNullOrWhiteSpace(point.LiveSignalReference) ? point.ObjectReference : point.LiveSignalReference); @@ -631,6 +893,16 @@ private static IoTestObservation CreateObservation( livePoint.Sequence, connectionGeneration); + private FatLiveValueObservation CreateLiveValueObservation(Iec61850MonitorPoint livePoint) + => new( + livePoint.Value, + DateTimeOffset.UtcNow, + IoTestValueNormalizer.ParseIedTimestamp(livePoint.DeviceTimestamp), + livePoint.Quality, + livePoint.SourceMode, + livePoint.Sequence, + _connectionGeneration); + private void AddPointIndex(string deviceId, string reference, IoTestPointPlan point) { var key = PointKey(deviceId, reference); @@ -703,34 +975,47 @@ private IoTestJournalEntry PointEvent( Reason = evidence?.VerdictReason ?? reason }; - private IoTestJournalEntry FatValueEvent(IoTestPointPlan point, FatValueEvidence evidence) => new() + private IoTestJournalEntry FatValueEvent( + IoTestPointPlan point, + FatValueEvidence evidence, + string? eventType = null, + string? reason = null) { - EventType = "fat_value_snapshot", - RecordedAtUtc = evidence.CapturedAt, - ProjectId = _project.ProjectId, - SessionId = SessionId, - IedName = point.IedName, - IpAddress = point.IpAddress, - SourceWorkbookName = _project.SourceWorkbookName, - SourceWorkbookSha256 = _project.SourceWorkbookSha256, - ApplicationVersion = CurrentApplicationVersion, - Operator = Environment.UserName, - Workstation = Environment.MachineName, - TestPointId = point.TestPointId, - SignalName = point.SignalName, - ObjectReference = string.IsNullOrWhiteSpace(point.LiveSignalReference) ? point.ObjectReference : point.LiveSignalReference, - Attempt = point.Runtime.Attempt, - Transition = evidence.Slot.ToString(), - ObservedValue = evidence.RawValue, - IedTimestamp = evidence.IedTimestamp, - Quality = evidence.Quality, - AcquisitionSource = evidence.AcquisitionSource, - PointSequence = evidence.Sequence, - ConnectionGeneration = evidence.ConnectionGeneration, - Verdict = "Accepted", - Reason = $"Operator captured {evidence.Slot} from the current live IEC 61850 value.", - EvidenceKind = "fat-value-snapshot" - }; + var automatic = evidence.CaptureKind == FatEvidenceCaptureKind.AutomaticValue; + var recapture = evidence.CaptureKind == FatEvidenceCaptureKind.OperatorRecapture; + return new IoTestJournalEntry + { + EventType = eventType ?? (automatic ? "fat_value_auto" : recapture ? "fat_value_recapture" : "fat_value_snapshot"), + RecordedAtUtc = evidence.CapturedAt, + ProjectId = _project.ProjectId, + SessionId = SessionId, + IedName = point.IedName, + IpAddress = point.IpAddress, + SourceWorkbookName = _project.SourceWorkbookName, + SourceWorkbookSha256 = _project.SourceWorkbookSha256, + ApplicationVersion = CurrentApplicationVersion, + Operator = Environment.UserName, + Workstation = Environment.MachineName, + TestPointId = point.TestPointId, + SignalName = point.SignalName, + ObjectReference = string.IsNullOrWhiteSpace(point.LiveSignalReference) ? point.ObjectReference : point.LiveSignalReference, + Attempt = point.Runtime.Attempt, + Transition = evidence.Slot.ToString(), + ObservedValue = evidence.RawValue, + IedTimestamp = evidence.IedTimestamp, + Quality = evidence.Quality, + AcquisitionSource = evidence.AcquisitionSource, + PointSequence = evidence.Sequence, + ConnectionGeneration = evidence.ConnectionGeneration, + Verdict = "Accepted", + Reason = reason ?? (automatic + ? $"Auto Capture accepted {evidence.Slot} from the stable live IEC 61850 value." + : recapture + ? $"Operator recaptured {evidence.Slot} from the current live IEC 61850 value." + : $"Operator captured {evidence.Slot} from the current live IEC 61850 value."), + EvidenceKind = automatic ? "fat-value-auto" : recapture ? "fat-value-recapture" : "fat-value-snapshot" + }; + } private void AppendRequired(IoTestJournalEntry entry) { @@ -745,7 +1030,7 @@ private void AppendBatchRequired(IReadOnlyCollection entries var journal = _journal ?? throw new InvalidOperationException("No evidence journal is open for the active FAT session."); var envelopes = journal.AppendBatch(entries); if (envelopes.Count == 0) - throw new InvalidOperationException("Evidence journal startup batch produced no records."); + throw new InvalidOperationException("Evidence journal batch produced no records."); var envelope = envelopes[^1]; EvidenceRecordCount = envelope.JournalSequence; LastJournalHash = envelope.Hash; @@ -808,7 +1093,9 @@ private void CleanupSessionResources(bool disposeJournal, bool keepActiveIed = f _activePointIndex.Clear(); _activeLivePoints.Clear(); _captureCoordinator.Clear(); - while (_pendingSnapshots.TryDequeue(out _)) { } + _autoCaptureCoordinator.Clear(); + _pendingPairValue1.Clear(); + ClearPendingSnapshots(); if (disposeJournal) { _journal?.Dispose(); @@ -858,4 +1145,4 @@ private sealed record QueuedSnapshot( string Reason, long Sequence, DateTimeOffset CapturedAtUtc); -} \ No newline at end of file +} diff --git a/Services/IoTesting/IoTestSessionPreflight.cs b/Services/IoTesting/IoTestSessionPreflight.cs index 7122629c9..508045120 100644 --- a/Services/IoTesting/IoTestSessionPreflight.cs +++ b/Services/IoTesting/IoTestSessionPreflight.cs @@ -10,19 +10,19 @@ public static IoTestSessionActionResult Validate(IoTestIedPlan? ied) return IoTestSessionActionResult.Failure("Select an imported IED first."); var unsafeEnabled = ied.TestPoints - .Where(point => point.IsIncludedInFat && point.TestEnabled && !point.ImportReady) + .Where(point => point.WorkspaceSelected && point.IsIncludedInFat && point.TestEnabled && !point.ImportReady) .ToList(); if (unsafeEnabled.Count > 0) { return IoTestSessionActionResult.Failure( - $"{unsafeEnabled.Count} included, enabled signal(s) still require import/binding review. Disable them or repair their mapping before starting FAT."); + $"{unsafeEnabled.Count} shared-workspace-selected FAT signal(s) still require import/binding review. Disable their TEST scope or repair their mapping before starting FAT."); } var enabledReady = ied.TestPoints - .Where(point => point.IsIncludedInFat && point.TestEnabled && point.ImportReady) + .Where(point => point.WorkspaceSelected && point.IsIncludedInFat && point.TestEnabled && point.ImportReady) .ToList(); if (enabledReady.Count == 0) - return IoTestSessionActionResult.Failure("No included, import-ready FAT signal is enabled for this IED."); + return IoTestSessionActionResult.Failure("No shared-workspace-selected, included, import-ready FAT signal has TEST enabled for this IED."); var duplicateReferences = enabledReady .GroupBy( @@ -40,11 +40,11 @@ public static IoTestSessionActionResult Validate(IoTestIedPlan? ied) var first = duplicateReferences[0]; var ids = string.Join(", ", first.Select(point => point.TestPointId)); return IoTestSessionActionResult.Failure( - $"One live IEC 61850 reference is assigned to multiple enabled test points in the included FAT scope ({ids}). Resolve the duplicate mapping before FAT so one edge cannot produce multiple results."); + $"One live IEC 61850 reference is assigned to multiple enabled test points in the shared included FAT scope ({ids}). Resolve the duplicate mapping before FAT so one edge cannot produce multiple results."); } return IoTestSessionActionResult.Success( - $"{enabledReady.Count} included signal(s) passed FAT session preflight."); + $"{enabledReady.Count} shared-workspace-selected FAT signal(s) passed session preflight."); } private static bool IsDistinctSclDataSetMembershipFanOut(IEnumerable points) diff --git a/Services/IoTesting/IoTestSignalSelectionService.cs b/Services/IoTesting/IoTestSignalSelectionService.cs index 8e6d24bc8..067a08d7a 100644 --- a/Services/IoTesting/IoTestSignalSelectionService.cs +++ b/Services/IoTesting/IoTestSignalSelectionService.cs @@ -21,15 +21,18 @@ public sealed record IoTestSignalSelectionResult( } /// -/// Resolves the enabled IO-list scope against one discovered IED model without +/// Resolves an IO-list/FAT acquisition scope against one discovered IED model without /// guessing. Exact references remain highest priority. Canonical IEC 61850 forms /// accept vendor-safe spelling differences only when the best candidate is unique. -/// Weak source rows are resolved only after stronger references have claimed their -/// signals, allowing deterministic sibling evidence without fuzzy text matching. +/// Weak legacy source rows remain gated by TEST selection. Direct-SCL workspace rows +/// are acquisition candidates whenever shared workspace selection keeps them visible; +/// their TEST checkbox is evidence authority only. /// public sealed class IoTestSignalSelectionService { internal const string SclDataSetAuthorityBindingStatus = "SCL_DATASET_AUTHORITY"; + internal const string SclWorkspaceAuthorityBindingStatus = "SCL_WORKSPACE_AUTHORITY"; + private const int SclStaticMembershipIdentityBonus = 1000; private static readonly Regex ProtectionCodeRegex = new( @"\((?\d{2,3}[A-Z]{0,3})(?:\s*-\s*[^)]*)?\)", @@ -49,8 +52,16 @@ public IoTestSignalSelectionResult Resolve( // semantic inference in ARSAS; the engine remains the sole membership authority. Iec61850DataSetSignalInventoryService.EnsureMandatorySignals(device); + // P1 + shared workspace authority: direct-SCL connection/live acquisition follows + // the Engineering/FAT workspace selection, not TEST. Legacy workbook rows retain + // their historical TestEnabled gate because they do not have an independent + // SCL-owned workspace identity. var requested = ied.TestPoints - .Where(point => point.TestEnabled && point.ImportReady) + .Where(point => + point.WorkspaceSelected && + point.IsIncludedInFat && + point.ImportReady && + (point.TestEnabled || IsDirectSclAuthority(point))) .ToList(); var matches = new List(requested.Count); var missing = new List(); @@ -88,11 +99,12 @@ public IoTestSignalSelectionResult Resolve( // assignments as elimination evidence. No candidate is ever selected by text // similarity: it must still be the one unique best IEC object left. // - // SCL FAT rows are different from legacy workbook rows: their identity is the - // engine-authoritative static DataSet membership, not a unique runtime leaf. Two - // distinct memberships may legally resolve to the same primary DataAttribute, so - // those authority rows are allowed to fan out from one proven live signal. The - // signal is still recorded as used so a legacy workbook row cannot silently share it. + // Static DataSet FAT rows are different from legacy workbook rows: their identity + // is an engine-authoritative membership, not a unique runtime leaf. Two distinct + // memberships may legally resolve to the same primary DataAttribute, so those + // static authority rows are allowed to fan out from one proven live signal. Manual + // workspace rows own a normal unique SCL object identity and therefore do not use + // this static-membership sharing exception. var madeProgress = true; while (madeProgress && unresolved.Count > 0) { @@ -151,7 +163,7 @@ public IoTestSignalSelectionResult Resolve( matches, missing, ambiguous, - $"Resolved {matches.Count} enabled FAT signal(s) to discovered model points.{smartText}"); + $"Resolved {matches.Count} FAT acquisition signal(s) to discovered model points.{smartText}"); } internal static bool IsSclDataSetAuthority(IoTestPointPlan point) @@ -160,6 +172,15 @@ internal static bool IsSclDataSetAuthority(IoTestPointPlan point) SclDataSetAuthorityBindingStatus, StringComparison.OrdinalIgnoreCase); + internal static bool IsSclWorkspaceAuthority(IoTestPointPlan point) + => string.Equals( + point.BindingStatus, + SclWorkspaceAuthorityBindingStatus, + StringComparison.OrdinalIgnoreCase); + + internal static bool IsDirectSclAuthority(IoTestPointPlan point) + => IsSclDataSetAuthority(point) || IsSclWorkspaceAuthority(point); + private static int BestSignalScore( IoTestPointPlan point, SignalDefinition signal, @@ -170,11 +191,20 @@ private static int BestSignalScore( if (importedReferences.Count == 0) return 0; + // Direct static SCL FAT owns stronger identity than a runtime leaf: one row + // corresponds to one exact static DataSet membership. A connected Engineering + // Workspace can legitimately contain both a generic scalar signal and a dedicated + // static-member projection with the same ObjectReference. Prefer exact + // (DataSetReference + DisplayReference) membership identity, then use the resolved + // ObjectReference only as the acquisition target. + if (HasExactSclStaticMembershipIdentity(point, signal)) + return IoTestReferenceMatcher.ExactScore + SclStaticMembershipIdentityBonus; + // ARIEC deliberately keeps static FCDA/FCD membership identity in // DisplayReference while ObjectReference may remain the resolved runtime leaf. - // SCL-backed FAT rows therefore compare against both engine-owned identities. + // Manual SCL workspace rows also preserve DisplayReference as exact source identity. // Legacy workbook rows keep the old ObjectReference-only contract. - var observedReferences = IsSclDataSetAuthority(point) + var observedReferences = IsDirectSclAuthority(point) ? new[] { signal.ObjectReference, signal.DisplayReference } .Where(value => !string.IsNullOrWhiteSpace(value)) .Distinct(StringComparer.OrdinalIgnoreCase) @@ -193,6 +223,38 @@ private static int BestSignalScore( .Max(); } + private static bool HasExactSclStaticMembershipIdentity( + IoTestPointPlan point, + SignalDefinition signal) + { + if (!IsSclDataSetAuthority(point) || string.IsNullOrWhiteSpace(signal.DisplayReference)) + return false; + + if (!string.IsNullOrWhiteSpace(point.DataSetName) && + !string.IsNullOrWhiteSpace(signal.DataSetReference) && + !IoTestLiveBindingService.NormalizeReference(point.DataSetName) + .Equals( + IoTestLiveBindingService.NormalizeReference(signal.DataSetReference), + StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var display = IoTestLiveBindingService.NormalizeReference(signal.DisplayReference); + if (display.Length == 0) + return false; + + return new[] + { + point.SourceIecReference, + point.ReportDisplayReference, + point.EventLogSearchReference + } + .Where(reference => !string.IsNullOrWhiteSpace(reference)) + .Select(IoTestLiveBindingService.NormalizeReference) + .Any(reference => reference.Equals(display, StringComparison.OrdinalIgnoreCase)); + } + private static List NarrowByProtectionIdentity( IoTestPointPlan point, IReadOnlyCollection candidates) diff --git a/Services/IoTesting/IoTestWorkspaceBootstrapService.cs b/Services/IoTesting/IoTestWorkspaceBootstrapService.cs index 16c2d9476..1fb83b1a4 100644 --- a/Services/IoTesting/IoTestWorkspaceBootstrapService.cs +++ b/Services/IoTesting/IoTestWorkspaceBootstrapService.cs @@ -150,6 +150,7 @@ private static void ApplySnapshotProgress(IoTestProject project, string snapshot var savedIeds = RequiredArray(savedProject, "ieds").EnumerateArray().ToArray(); RestoreIedLevelEvidence(project, savedIeds); + RestoreMissingManualWorkspaceRows(project, savedIeds); var savedPoints = savedIeds .SelectMany(ied => RequiredArray(ied, "testPoints").EnumerateArray()) @@ -160,8 +161,14 @@ private static void ApplySnapshotProgress(IoTestProject project, string snapshot if (!savedPoints.TryGetValue(point.TestPointId, out var saved)) continue; - // TestEnabled and FAT disposition are both operator-authored and are restored - // independently. Restoring/removing a FAT row must never rewrite its checkbox. + // Shared Engineering/FAT membership, FAT TEST scope, and FAT disposition are + // three independent operator authorities. Same-source continuation restores all + // three without allowing Remove/Restore to rewrite Engineering selection. + if (saved.TryGetProperty("workspaceSelected", out var workspaceSelected) && + workspaceSelected.ValueKind is JsonValueKind.True or JsonValueKind.False) + { + point.WorkspaceSelected = workspaceSelected.GetBoolean(); + } if (saved.TryGetProperty("testEnabled", out var enabled) && enabled.ValueKind is JsonValueKind.True or JsonValueKind.False) point.TestEnabled = enabled.GetBoolean(); point.RestoreFatDisposition(OptionalEnum(saved, "fatDisposition", FatSignalDisposition.Included)); @@ -224,6 +231,116 @@ private static bool SnapshotSourceMatches(JsonElement savedProject, IoTestProjec legacyWorkbookSha.Equals(project.SourceWorkbookSha256, StringComparison.OrdinalIgnoreCase); } + private static void RestoreMissingManualWorkspaceRows( + IoTestProject project, + IReadOnlyList savedIeds) + { + if (!project.SchemaVersion.StartsWith("ARSAS-FAT-SCL-", StringComparison.OrdinalIgnoreCase)) + return; + + var trustedSclHashes = project.Sources + .Where(source => source.Kind.Equals(IoFatSourceKinds.Scl, StringComparison.OrdinalIgnoreCase)) + .Select(source => source.Sha256) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + if (trustedSclHashes.Count == 0) + return; + + foreach (var ied in project.Ieds) + { + var savedIed = savedIeds.FirstOrDefault(candidate => + OptionalString(candidate, "iedName", string.Empty).Equals(ied.IedName, StringComparison.OrdinalIgnoreCase) && + OptionalString(candidate, "ipAddress", string.Empty).Equals(ied.IpAddress, StringComparison.OrdinalIgnoreCase)); + if (savedIed.ValueKind != JsonValueKind.Object) + continue; + + var existingIds = ied.TestPoints + .Select(point => point.TestPointId) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (var savedPoint in RequiredArray(savedIed, "testPoints").EnumerateArray()) + { + var testPointId = RequiredString(savedPoint, "testPointId"); + if (existingIds.Contains(testPointId)) + continue; + + var bindingStatus = OptionalString(savedPoint, "bindingStatus", string.Empty); + if (!bindingStatus.Equals( + IoTestSignalSelectionService.SclWorkspaceAuthorityBindingStatus, + StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (!testPointId.StartsWith("scl-manual-", StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException($"Saved manual SCL row '{testPointId}' has an invalid workspace identity."); + + var savedIedName = RequiredString(savedPoint, "iedName"); + var savedIpAddress = RequiredString(savedPoint, "ipAddress"); + if (!savedIedName.Equals(ied.IedName, StringComparison.OrdinalIgnoreCase) || + !savedIpAddress.Equals(ied.IpAddress, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException($"Saved manual SCL row '{testPointId}' does not belong to its persisted IED endpoint."); + } + + var sourceSha = OptionalString(savedPoint, "signalAddress", string.Empty).Trim(); + if (sourceSha.Length == 0 || !trustedSclHashes.Contains(sourceSha)) + { + throw new InvalidDataException( + $"Saved manual SCL row '{testPointId}' does not belong to the exact reopened SCL source set."); + } + + var objectReference = RequiredString(savedPoint, "objectReference"); + var sourceReference = OptionalString(savedPoint, "sourceIecReference", string.Empty); + if (string.IsNullOrWhiteSpace(objectReference) || string.IsNullOrWhiteSpace(sourceReference)) + throw new InvalidDataException($"Saved manual SCL row '{testPointId}' has incomplete IEC 61850 identity."); + + var point = new IoTestPointPlan + { + TestPointId = testPointId, + IedName = savedIedName, + IpAddress = savedIpAddress, + SignalName = RequiredString(savedPoint, "signalName"), + ObjectReference = objectReference, + FunctionalConstraint = OptionalString(savedPoint, "functionalConstraint", string.Empty), + ExpectedOnText = RequiredString(savedPoint, "expectedOnText"), + ExpectedOffText = RequiredString(savedPoint, "expectedOffText"), + ExpectedOnRaw = OptionalInt(savedPoint, "expectedOnRaw", 1), + ExpectedOffRaw = OptionalInt(savedPoint, "expectedOffRaw", 0), + DataType = OptionalString(savedPoint, "dataType", "SDI"), + SignalAddress = sourceSha, + DataSetName = OptionalString(savedPoint, "dataSetName", string.Empty), + LogicalDevice = OptionalString(savedPoint, "logicalDevice", string.Empty), + LogicalNode = OptionalString(savedPoint, "logicalNode", string.Empty), + DataObject = OptionalString(savedPoint, "dataObject", string.Empty), + DataAttribute = OptionalString(savedPoint, "dataAttribute", string.Empty), + Cdc = OptionalString(savedPoint, "cdc", string.Empty), + SourceIecReference = sourceReference, + ReportDisplayReference = OptionalString(savedPoint, "reportDisplayReference", sourceReference), + EventLogSearchReference = OptionalString(savedPoint, "eventLogSearchReference", objectReference), + EvidenceExpected = OptionalString(savedPoint, "evidenceExpected", string.Empty), + MappingQuality = OptionalString(savedPoint, "mappingQuality", string.Empty), + ReviewStatus = OptionalString(savedPoint, "reviewStatus", string.Empty), + ReviewReason = OptionalString(savedPoint, "reviewReason", string.Empty), + EventLogMatch = OptionalString(savedPoint, "eventLogMatch", string.Empty), + EvidenceReference = OptionalString(savedPoint, "evidenceReference", string.Empty), + ReviewerComment = OptionalString(savedPoint, "reviewerComment", string.Empty), + SourceSheet = OptionalString(savedPoint, "sourceSheet", string.Empty), + SourceRow = OptionalInt(savedPoint, "sourceRow", 0), + SignalKind = OptionalEnum(savedPoint, "signalKind", FatSignalKind.Other), + CaptureMode = OptionalEnum(savedPoint, "captureMode", FatCaptureMode.OperatorSnapshot), + WorkspaceSelected = OptionalBool(savedPoint, "workspaceSelected", true), + TestEnabled = OptionalBool(savedPoint, "testEnabled", true), + ImportReady = OptionalBool(savedPoint, "importReady", true), + BindingStatus = bindingStatus, + BindingEvidence = OptionalString(savedPoint, "bindingEvidence", string.Empty) + }; + + if (!ied.AddTestPoint(point)) + throw new InvalidDataException($"Saved manual SCL row '{testPointId}' collides with the reopened workspace."); + existingIds.Add(testPointId); + } + } + } + private static void RestoreIedLevelEvidence(IoTestProject project, IReadOnlyList savedIeds) { foreach (var ied in project.Ieds) @@ -303,6 +420,11 @@ private static string OptionalString(JsonElement parent, string property, string ? value.GetString() ?? fallback : fallback; + private static bool OptionalBool(JsonElement parent, string property, bool fallback) + => parent.TryGetProperty(property, out var value) && value.ValueKind is JsonValueKind.True or JsonValueKind.False + ? value.GetBoolean() + : fallback; + private static int OptionalInt(JsonElement parent, string property, int fallback) => parent.TryGetProperty(property, out var value) && value.TryGetInt32(out var number) ? number diff --git a/Services/NativeIec61850Client.AggregateProjection.cs b/Services/NativeIec61850Client.AggregateProjection.cs new file mode 100644 index 000000000..b0f13209a --- /dev/null +++ b/Services/NativeIec61850Client.AggregateProjection.cs @@ -0,0 +1,24 @@ +using AR.Iec61850.Discovery; + +namespace ArIED61850Tester.Services; + +public sealed partial class NativeIec61850Client +{ + /// + /// Returns the same per-IED model authority used by semantic report projection. + /// Direct-SCL FAT therefore uses the opened SCL design model; fully discovered + /// Engineering sessions can use the live model. No ambient/global model is consulted. + /// + internal LiveIedModelDiscoveryDocument? AggregateProjectionAuthorityModel + => _semanticReportProjectionAuthorityModel ?? _liveModel; + + internal bool TryBuildSchemaSafeAggregateReadPlan( + string requestedReference, + out SchemaSafeAggregateProjectionService.ReadPlan plan, + out string status) + => SchemaSafeAggregateProjectionService.TryBuildReadPlan( + AggregateProjectionAuthorityModel, + requestedReference, + out plan, + out status); +} diff --git a/Services/NativeIec61850Client.HybridReporting.cs b/Services/NativeIec61850Client.HybridReporting.cs index 99b3b0ba6..bcf9a5de5 100644 --- a/Services/NativeIec61850Client.HybridReporting.cs +++ b/Services/NativeIec61850Client.HybridReporting.cs @@ -46,6 +46,7 @@ public async Task BuildHybridReportPlansAsync( cancellationToken.ThrowIfCancellationRequested(); _authoritativeHybridSubscriptions.Clear(); + ResetSemanticReportProjectionContext(); var planningModel = ResolveHybridPlanningModel(device); if (planningModel is null) @@ -78,6 +79,8 @@ public async Task BuildHybridReportPlansAsync( }; } + SetSemanticReportProjectionAuthority(planningModel); + // A fast SCL connect deliberately skips full live-model discovery. The opened SCL // design model is still an ARIEC-typed model and is therefore valid as the catalog // authority for literal signal mapping. It is NOT treated as live RCB evidence: diff --git a/Services/NativeIec61850Client.SemanticReporting.cs b/Services/NativeIec61850Client.SemanticReporting.cs new file mode 100644 index 000000000..15712ab93 --- /dev/null +++ b/Services/NativeIec61850Client.SemanticReporting.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AR.Iec61850.Discovery; +using ArMms = AR.Iec61850.Mms; + +namespace ArIED61850Tester.Services; + +/// +/// P5.4 report-value projection bridge. Report protocol/member identity stays engine-owned; +/// this layer only supplies the already-authoritative per-IED model so structured static +/// DataSet members can fan out to proven scalar descendants before ARSAS updates live points. +/// +public sealed partial class NativeIec61850Client +{ + private LiveIedModelDiscoveryDocument? _semanticReportProjectionAuthorityModel; + private LiveIedModelDiscoveryDocument? _semanticReportProjectionContextModel; + private ArMms.MmsReportSemanticProjectionContext? _semanticReportProjectionContext; + private readonly HashSet _semanticReportProjectionWarningsSeen = new(StringComparer.OrdinalIgnoreCase); + + private void SetSemanticReportProjectionAuthority(LiveIedModelDiscoveryDocument model) + { + ArgumentNullException.ThrowIfNull(model); + if (ReferenceEquals(_semanticReportProjectionAuthorityModel, model)) + return; + + _semanticReportProjectionAuthorityModel = model; + _semanticReportProjectionContextModel = null; + _semanticReportProjectionContext = null; + _semanticReportProjectionWarningsSeen.Clear(); + } + + private void ResetSemanticReportProjectionContext() + { + _semanticReportProjectionAuthorityModel = null; + _semanticReportProjectionContextModel = null; + _semanticReportProjectionContext = null; + _semanticReportProjectionWarningsSeen.Clear(); + } + + private ArMms.MmsReportValueProjection ProjectReportValue(ArMms.MmsReportFrame report) + { + ArgumentNullException.ThrowIfNull(report); + + // Hybrid FAT uses the exact planning authority (live model or opened SCL design). + // Generic report callers use the fresh live model built by reporting discovery. + // No global/ambient semantic state is used, so model evidence cannot cross IEDs. + var model = _semanticReportProjectionAuthorityModel ?? _liveModel; + if (model is null) + return ArMms.MmsReportValueProjector.Project(report); + + try + { + if (_semanticReportProjectionContext is null || + !ReferenceEquals(_semanticReportProjectionContextModel, model)) + { + _semanticReportProjectionContext = ArMms.MmsReportSemanticProjectionContext.Create(model); + _semanticReportProjectionContextModel = model; + _semanticReportProjectionWarningsSeen.Clear(); + } + + var projection = ArMms.MmsSemanticReportValueProjector.Project( + report, + _semanticReportProjectionContext); + return SuppressRepeatedSemanticWarnings(projection); + } + catch (Exception ex) + { + // Projection must never invent evidence or tear down a healthy MMS association. + // Preserve the established raw update and expose an explicit fail-closed warning. + var baseline = ArMms.MmsReportValueProjector.Project(report); + var fallback = new ArMms.MmsReportValueProjection + { + Updates = baseline.Updates, + Warnings = baseline.Warnings + .Concat(new[] + { + $"REPORT_SEMANTIC_FALLBACK: model-backed projection failed closed: {ex.GetType().Name}: {ex.Message}" + }) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray() + }; + return SuppressRepeatedSemanticWarnings(fallback); + } + } + + private ArMms.MmsReportValueProjection SuppressRepeatedSemanticWarnings( + ArMms.MmsReportValueProjection projection) + { + if (projection.Warnings.Count == 0) + return projection; + + // Structured report projection runs for every report frame. The same structural + // expansion warning can therefore repeat indefinitely even though it describes a + // stable model fact. Keep processing every value update, but surface each identical + // semantic warning once per projection model/session to avoid log/UI churn. + var freshWarnings = projection.Warnings + .Where(warning => _semanticReportProjectionWarningsSeen.Add(warning)) + .ToArray(); + if (freshWarnings.Length == projection.Warnings.Count) + return projection; + + return new ArMms.MmsReportValueProjection + { + Updates = projection.Updates, + Warnings = freshWarnings + }; + } +} diff --git a/Services/NativeIec61850Client.cs b/Services/NativeIec61850Client.cs index 507f2d34e..f71c41a34 100644 --- a/Services/NativeIec61850Client.cs +++ b/Services/NativeIec61850Client.cs @@ -64,6 +64,7 @@ public async Task ConnectAsync(string ipAddress, int port, CancellationToken can _liveModel = null; _reportMonitorSessions.Clear(); _reportMonitorCoverage.Clear(); + ResetSemanticReportProjectionContext(); Interlocked.Exchange(ref _engineCompatibilityWarningIssued, 0); DetectedIdentity = new Iec61850DeviceIdentity(); _host = ipAddress?.Trim() ?? string.Empty; @@ -490,7 +491,7 @@ public async Task ReceiveReportMonitorSliceAsync ReceivedAt = report.ReceivedAt }); - var projection = ArMms.MmsReportValueProjector.Project(report); + var projection = ProjectReportValue(report); warnings.AddRange(projection.Warnings); updates.AddRange(projection.Updates.Select(update => new NativeReportValueUpdate { @@ -4058,6 +4059,23 @@ private static string NormalizeFunctionalConstraint(string functionalConstraint, private Iec61850ReadValue? ProjectReadValue(ArMms.MmsDataValue? value, string dataType, string readReference, string requestedReference) { + // Explicit object-level DataSet members must be projected before generic + // first-scalar semantic binding. Otherwise a three-phase THD object collapses + // to phase A and loses the B/C values carried by the same MMS structure. + if (value != null && + SignalDefinition.IsThreePhaseMeasurementAggregate(requestedReference) && + TryProjectThreePhaseAggregate(value, requestedReference, readReference, out var aggregate)) + { + return aggregate; + } + + if (value != null && + SignalDefinition.IsDemandEnergyAggregate(requestedReference) && + TryProjectDemandEnergyAggregate(value, requestedReference, readReference, dataType, out var demand)) + { + return demand; + } + if (TryProjectStructuredLeafBySemantic(value, dataType, readReference, requestedReference, out var semanticProjection, out var semanticStatus)) return semanticProjection; @@ -4094,6 +4112,72 @@ private static string NormalizeFunctionalConstraint(string functionalConstraint, }; } + private static bool TryProjectThreePhaseAggregate( + ArMms.MmsDataValue value, + string requestedReference, + string readReference, + out Iec61850ReadValue projected) + { + projected = new Iec61850ReadValue(); + if (value.Kind is not (ArMms.MmsDataKind.Structure or ArMms.MmsDataKind.Array) || + value.Children.Count < 3) + { + return false; + } + + var phaseValues = new List(3); + foreach (var phase in value.Children.Take(3)) + { + var scalar = FindFirstFloating(phase) ?? FindFirstInteger(phase); + if (scalar == null) + return false; + + var converted = ConvertProjectedValue(scalar, "Float32", requestedReference); + phaseValues.Add(Convert.ToDouble(converted, CultureInfo.InvariantCulture) + .ToString("0.######", CultureInfo.InvariantCulture)); + } + + var display = string.Join(", ", phaseValues); + projected = new Iec61850ReadValue + { + Value = display, + DisplayValue = display, + Quality = DecodeQuality(value), + DeviceTimestamp = DecodeTimestamp(value), + SourceReference = requestedReference, + ReadReference = readReference, + Projection = "projected-three-phase-aggregate" + }; + return true; + } + + private static bool TryProjectDemandEnergyAggregate( + ArMms.MmsDataValue value, + string requestedReference, + string readReference, + string dataType, + out Iec61850ReadValue projected) + { + projected = new Iec61850ReadValue(); + var scalar = FindFirstFloating(value) ?? FindFirstInteger(value); + if (scalar == null) + return false; + + var raw = ConvertProjectedValue(scalar, dataType, requestedReference); + var display = FormatProjectedDisplay(raw, dataType); + projected = new Iec61850ReadValue + { + Value = raw, + DisplayValue = display, + Quality = DecodeQuality(value), + DeviceTimestamp = DecodeTimestamp(value), + SourceReference = requestedReference, + ReadReference = readReference, + Projection = "projected-demand-energy-aggregate" + }; + return true; + } + private bool TryProjectWithArIecBinding( ArMms.MmsDataValue? value, string dataType, diff --git a/Services/SchemaSafeAggregateProjectionService.cs b/Services/SchemaSafeAggregateProjectionService.cs new file mode 100644 index 000000000..fcc8817f6 --- /dev/null +++ b/Services/SchemaSafeAggregateProjectionService.cs @@ -0,0 +1,557 @@ +using System.Globalization; +using AR.Iec61850.Binding; +using AR.Iec61850.Discovery; +using ArMms = AR.Iec61850.Mms; + +namespace ArIED61850Tester.Services; + +/// +/// Projects explicitly structured FAT/Engineering measurement parents only after the +/// authoritative IEC 61850 schema has named every value that is published. This service +/// deliberately never chooses a child by raw MMS position or by "first numeric" shape. +/// +public static class SchemaSafeAggregateProjectionService +{ + public sealed record Projection( + object? Value, + string DisplayValue, + string Quality, + string DeviceTimestamp, + string ProjectionStatus); + + public sealed record ReadLeaf( + string Reference, + string Label, + string DataType); + + public sealed record ReadPlan( + string ParentReference, + string Kind, + IReadOnlyList Leaves); + + private sealed record BoundLeaf( + Iec61850BoundValueRow Row, + IReadOnlyList Ancestors, + double NumericValue); + + /// + /// Builds the exact scalar-read plan used by the live FAT/Engineering runtime. + /// The plan is derived only from named attributes in the authoritative SCL/live model. + /// It never derives phase identity from MMS child position or numeric value ordering. + /// + public static bool TryBuildReadPlan( + LiveIedModelDiscoveryDocument? authorityModel, + string requestedReference, + out ReadPlan plan, + out string status) + { + plan = new ReadPlan(requestedReference ?? string.Empty, string.Empty, Array.Empty()); + status = string.Empty; + + if (authorityModel is null) + { + status = "Schema-safe aggregate read plan blocked: no authoritative SCL/live IEC 61850 model is attached."; + return false; + } + + if (!TryFindDataObject(authorityModel, requestedReference, out var dataObject)) + { + status = $"Schema-safe aggregate read plan blocked: DataObject schema was not found uniquely for {requestedReference}."; + return false; + } + + if (IsThreePhaseThd(requestedReference, out var phases)) + { + var leaves = new List(phases.Count); + foreach (var phase in phases) + { + var prefix = NormalizeReference(requestedReference) + "." + phase.Path.ToLowerInvariant(); + var tiers = new[] + { + new[] { prefix + ".cval.mag.f" }, + new[] { prefix + ".mag.f" }, + new[] { prefix + ".instcval.mag.f", prefix + ".instmag.f" } + }; + + if (!TryResolvePreferredAttributeReference(dataObject, tiers, out var reference, out var failure)) + { + status = $"Schema-safe THD read plan blocked for {requestedReference}: phase {phase.Label} has no unique named magnitude leaf. {failure}"; + return false; + } + + leaves.Add(new ReadLeaf(reference, phase.Label, "Float32")); + } + + if (leaves.Select(leaf => NormalizeReference(leaf.Reference)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Count() != phases.Count) + { + status = $"Schema-safe THD read plan blocked for {requestedReference}: phase leaves are not unique."; + return false; + } + + plan = new ReadPlan(requestedReference, "ThreePhaseThd", leaves); + status = $"Schema-safe THD read plan resolved exact leaves: {string.Join(", ", leaves.Select(leaf => leaf.Reference))}."; + return true; + } + + if (IsDemandEnergy(requestedReference)) + { + var prefix = NormalizeReference(requestedReference); + var tiers = new[] + { + new[] { prefix + ".mag.f" }, + new[] { prefix + ".cval.mag.f" }, + new[] { prefix + ".instmag.f", prefix + ".instcval.mag.f" } + }; + + if (!TryResolvePreferredAttributeReference(dataObject, tiers, out var reference, out var failure)) + { + status = $"Schema-safe DmdWh read plan blocked for {requestedReference}: no unique named energy magnitude leaf. {failure}"; + return false; + } + + plan = new ReadPlan( + requestedReference, + "DemandEnergy", + new[] { new ReadLeaf(reference, "Value", "Float32") }); + status = $"Schema-safe DmdWh read plan resolved exact leaf {reference}."; + return true; + } + + status = $"Schema-safe aggregate read planning does not own {requestedReference}."; + return false; + } + + public static bool TryProject( + LiveIedModelDiscoveryDocument? authorityModel, + ArMms.MmsDataValue? value, + string requestedReference, + string readReference, + string dataType, + out Projection projection, + out string status) + { + projection = new Projection(null, "-", string.Empty, string.Empty, string.Empty); + status = string.Empty; + + if (authorityModel is null) + { + status = "Schema-safe aggregate projection blocked: no authoritative SCL/live IEC 61850 model is attached."; + return false; + } + + if (value is null || value.Kind is not (ArMms.MmsDataKind.Structure or ArMms.MmsDataKind.Array)) + { + status = "Schema-safe aggregate projection requires a structured MMS value."; + return false; + } + + if (!TryFindDataObject(authorityModel, requestedReference, out var dataObject)) + { + status = $"Schema-safe aggregate projection blocked: DataObject schema was not found for {requestedReference}."; + return false; + } + + var rootSchema = Iec61850DataObjectSchemaBuilder.FromLiveDataObject(dataObject).ToRootNode(); + var readSchema = TryFindSchemaNode(rootSchema, readReference, out var exactReadSchema) + ? exactReadSchema + : ReferencesEqual(readReference, dataObject.Reference) + ? rootSchema + : null; + if (readSchema is null) + { + status = $"Schema-safe aggregate projection blocked: read reference {readReference} is outside schema {dataObject.Reference}."; + return false; + } + + var binding = Iec61850ValueBindingEngine.Bind(readSchema, value); + if (binding.HasMismatch) + { + status = $"Schema-safe aggregate projection blocked by schema/value mismatch: {FormatDiagnostics(binding.Diagnostics)}"; + return false; + } + + if (IsThreePhaseThd(requestedReference, out var phases)) + return TryProjectThreePhase(binding.Root, requestedReference, readReference, phases, out projection, out status); + + if (IsDemandEnergy(requestedReference)) + return TryProjectDemandEnergy(binding.Root, requestedReference, readReference, dataType, out projection, out status); + + status = $"Schema-safe aggregate projection does not own {requestedReference}."; + return false; + } + + private static bool TryProjectThreePhase( + Iec61850BoundValueRow root, + string requestedReference, + string readReference, + IReadOnlyList<(string Path, string Label)> phases, + out Projection projection, + out string status) + { + projection = new Projection(null, "-", string.Empty, string.Empty, string.Empty); + var values = new List(phases.Count); + var leaves = new List(phases.Count); + + foreach (var phase in phases) + { + var prefix = NormalizeReference(requestedReference) + "." + phase.Path.ToLowerInvariant(); + var candidateTiers = new[] + { + new[] { prefix + ".cval.mag.f" }, + new[] { prefix + ".mag.f" }, + new[] { prefix + ".instcval.mag.f", prefix + ".instmag.f" } + }; + + if (!TryResolvePreferredNumericLeaf(root, candidateTiers, out var leaf, out var failure)) + { + status = $"Schema-safe THD projection blocked for {requestedReference}: phase {phase.Label} has no unique named magnitude leaf. {failure}"; + return false; + } + + leaves.Add(leaf); + values.Add($"{phase.Label}={leaf.NumericValue.ToString("0.######", CultureInfo.InvariantCulture)}"); + } + + // Every phase is resolved independently by exact IEC reference. A repeated row + // would mean the schema did not actually prove three distinct phase identities. + if (leaves.Select(leaf => NormalizeReference(leaf.Row.Reference)).Distinct(StringComparer.OrdinalIgnoreCase).Count() != phases.Count) + { + status = $"Schema-safe THD projection blocked for {requestedReference}: phase references are not unique."; + return false; + } + + var display = string.Join(", ", values); + projection = new Projection( + display, + display, + FirstUsefulAcrossLeaves(leaves, root, row => row.Quality), + FirstUsefulAcrossLeaves(leaves, root, row => row.Timestamp), + "schema-safe-three-phase-aggregate"); + status = $"Schema-safe THD aggregate projected from exact named leaves: {string.Join(", ", leaves.Select(leaf => leaf.Row.Reference))}."; + return true; + } + + private static bool TryProjectDemandEnergy( + Iec61850BoundValueRow root, + string requestedReference, + string readReference, + string dataType, + out Projection projection, + out string status) + { + projection = new Projection(null, "-", string.Empty, string.Empty, string.Empty); + var prefix = NormalizeReference(requestedReference); + + // DmdWhMV has existed in both canonical mag.f and vendor instantaneous instMag.f + // forms. Canonical engineering value wins when present. Instantaneous forms are + // accepted only when exactly one named fallback exists. No arbitrary numeric child + // is ever used. + var candidateTiers = new[] + { + new[] { prefix + ".mag.f" }, + new[] { prefix + ".cval.mag.f" }, + new[] { prefix + ".instmag.f", prefix + ".instcval.mag.f" } + }; + + if (!TryResolvePreferredNumericLeaf(root, candidateTiers, out var leaf, out var failure)) + { + status = $"Schema-safe DmdWh projection blocked for {requestedReference}: no unique named energy magnitude leaf. {failure}"; + return false; + } + + var numeric = leaf.NumericValue; + var display = numeric.ToString("0.######", CultureInfo.InvariantCulture); + projection = new Projection( + numeric, + display, + FirstUseful(leaf.Row.Quality, leaf.Ancestors.Reverse().Select(row => row.Quality), root.Quality), + FirstUseful(leaf.Row.Timestamp, leaf.Ancestors.Reverse().Select(row => row.Timestamp), root.Timestamp), + "schema-safe-demand-energy-aggregate"); + status = $"Schema-safe DmdWh aggregate projected from exact named leaf {leaf.Row.Reference}."; + return true; + } + + private static bool TryResolvePreferredAttributeReference( + LiveIedDataObjectModel dataObject, + IReadOnlyList candidateTiers, + out string reference, + out string failure) + { + reference = string.Empty; + failure = string.Empty; + + foreach (var tier in candidateTiers) + { + var expected = tier.ToHashSet(StringComparer.OrdinalIgnoreCase); + var matches = dataObject.Attributes + .Select(attribute => new + { + Attribute = attribute, + EffectiveReference = EffectiveAttributeReference(dataObject, attribute) + }) + .Where(item => expected.Contains(NormalizeReference(item.EffectiveReference))) + .Where(item => string.IsNullOrWhiteSpace(item.Attribute.FunctionalConstraint) || + item.Attribute.FunctionalConstraint.Equals("MX", StringComparison.OrdinalIgnoreCase)) + .GroupBy(item => NormalizeReference(item.EffectiveReference), StringComparer.OrdinalIgnoreCase) + .Select(group => group.First().EffectiveReference) + .ToArray(); + + if (matches.Length == 0) + continue; + if (matches.Length != 1) + { + failure = $"Approved named fallback tier is ambiguous: {string.Join(", ", matches)}."; + return false; + } + + reference = matches[0]; + return true; + } + + failure = "The authoritative schema contains none of the approved exact magnitude references."; + return false; + } + + private static string EffectiveAttributeReference( + LiveIedDataObjectModel dataObject, + LiveIedDataAttributeModel attribute) + { + if (!string.IsNullOrWhiteSpace(attribute.ObjectReference)) + return attribute.ObjectReference.Trim().Replace('$', '.'); + + var path = (attribute.AttributePath ?? string.Empty).Trim().Replace('$', '.').Trim('.'); + return string.IsNullOrWhiteSpace(path) + ? string.Empty + : dataObject.Reference.Trim().Replace('$', '.').TrimEnd('.') + "." + path; + } + + private static bool TryResolvePreferredNumericLeaf( + Iec61850BoundValueRow root, + IReadOnlyList candidateTiers, + out BoundLeaf leaf, + out string failure) + { + leaf = new BoundLeaf(new Iec61850BoundValueRow(), Array.Empty(), 0d); + failure = string.Empty; + + foreach (var tier in candidateTiers) + { + var matches = new List(); + foreach (var reference in tier) + { + if (!TryFindBoundRow(root, reference, out var row, out var ancestors)) + continue; + if (!TryParseNumeric(row.Value, out var numeric)) + { + failure = $"Named leaf {row.Reference} was bound but its value '{row.Value}' is not numeric."; + return false; + } + matches.Add(new BoundLeaf(row, ancestors, numeric)); + } + + if (matches.Count == 0) + continue; + if (matches.Count != 1) + { + failure = $"Named fallback tier is ambiguous: {string.Join(", ", matches.Select(match => match.Row.Reference))}."; + return false; + } + + leaf = matches[0]; + return true; + } + + failure = "The authoritative schema contains none of the approved exact magnitude references."; + return false; + } + + private static bool TryFindDataObject( + LiveIedModelDiscoveryDocument model, + string reference, + out LiveIedDataObjectModel dataObject) + { + dataObject = new LiveIedDataObjectModel(); + if (!TryGetDataObjectReference(reference, out var dataObjectReference)) + return false; + + var matches = model.LogicalDevices + .SelectMany(device => device.LogicalNodes) + .SelectMany(node => node.DataObjects) + .Where(candidate => ReferencesEqual(candidate.Reference, dataObjectReference)) + .ToArray(); + if (matches.Length != 1) + return false; + + dataObject = matches[0]; + return true; + } + + private static bool TryGetDataObjectReference(string reference, out string dataObjectReference) + { + dataObjectReference = string.Empty; + var text = (reference ?? string.Empty).Trim().Replace('$', '.'); + var slash = text.IndexOf('/'); + if (slash <= 0 || slash >= text.Length - 1) + return false; + + var domain = text[..slash]; + var segments = text[(slash + 1)..] + .Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (segments.Length < 2) + return false; + + dataObjectReference = $"{domain}/{segments[0]}.{segments[1]}"; + return true; + } + + private static bool TryFindSchemaNode( + Iec61850ValueSchemaNode node, + string reference, + out Iec61850ValueSchemaNode result) + { + if (ReferencesEqual(node.Reference, reference)) + { + result = node; + return true; + } + + foreach (var child in node.Children) + { + if (TryFindSchemaNode(child, reference, out result)) + return true; + } + + result = new Iec61850ValueSchemaNode(); + return false; + } + + private static bool TryFindBoundRow( + Iec61850BoundValueRow root, + string reference, + out Iec61850BoundValueRow row, + out IReadOnlyList ancestors) + { + var path = new List(); + return TryFindBoundRow(root, reference, path, out row, out ancestors); + } + + private static bool TryFindBoundRow( + Iec61850BoundValueRow current, + string reference, + List path, + out Iec61850BoundValueRow row, + out IReadOnlyList ancestors) + { + path.Add(current); + if (ReferencesEqual(current.Reference, reference)) + { + row = current; + ancestors = path.Take(path.Count - 1).ToArray(); + path.RemoveAt(path.Count - 1); + return true; + } + + foreach (var child in current.Children) + { + if (TryFindBoundRow(child, reference, path, out row, out ancestors)) + { + path.RemoveAt(path.Count - 1); + return true; + } + } + + path.RemoveAt(path.Count - 1); + row = new Iec61850BoundValueRow(); + ancestors = Array.Empty(); + return false; + } + + private static bool IsThreePhaseThd( + string reference, + out IReadOnlyList<(string Path, string Label)> phases) + { + var normalized = NormalizeReference(reference).TrimEnd('.'); + if (normalized.EndsWith(".thda", StringComparison.OrdinalIgnoreCase)) + { + phases = new[] + { + ("phsA", "A"), + ("phsB", "B"), + ("phsC", "C") + }; + return true; + } + + if (normalized.EndsWith(".thdppv", StringComparison.OrdinalIgnoreCase)) + { + phases = new[] + { + ("phsAB", "AB"), + ("phsBC", "BC"), + ("phsCA", "CA") + }; + return true; + } + + phases = Array.Empty<(string, string)>(); + return false; + } + + private static bool IsDemandEnergy(string reference) + => NormalizeReference(reference).TrimEnd('.').EndsWith(".dmdwhmv", StringComparison.OrdinalIgnoreCase); + + private static bool TryParseNumeric(string? value, out double numeric) + => double.TryParse( + (value ?? string.Empty).Trim(), + NumberStyles.Float | NumberStyles.AllowThousands, + CultureInfo.InvariantCulture, + out numeric); + + private static string FirstUsefulAcrossLeaves( + IReadOnlyList leaves, + Iec61850BoundValueRow root, + Func selector) + { + // Parent composite quality/timestamp is only advertised when the selected phase + // leaves agree. Mixed evidence must remain blank rather than implying one phase's + // metadata applies to the whole aggregate. + var useful = leaves + .Select(leaf => FirstUseful(selector(leaf.Row), leaf.Ancestors.Reverse().Select(selector), selector(root))) + .Where(IsUseful) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + return useful.Length == 1 ? useful[0] : string.Empty; + } + + private static string FirstUseful(string primary, IEnumerable inherited, string fallback) + { + if (IsUseful(primary)) + return primary; + foreach (var candidate in inherited) + { + if (IsUseful(candidate)) + return candidate; + } + return IsUseful(fallback) ? fallback : string.Empty; + } + + private static bool IsUseful(string? value) + => !string.IsNullOrWhiteSpace(value) && value != "-"; + + private static string FormatDiagnostics(IReadOnlyList diagnostics) + => diagnostics.Count == 0 + ? "no binding diagnostics" + : string.Join("; ", diagnostics.Take(4)); + + private static bool ReferencesEqual(string? left, string? right) + => NormalizeReference(left).Equals(NormalizeReference(right), StringComparison.OrdinalIgnoreCase); + + private static string NormalizeReference(string? reference) + => (reference ?? string.Empty) + .Trim() + .Replace('$', '.') + .Replace("..", ".") + .ToLowerInvariant(); +} diff --git a/VERSION b/VERSION index e0380928f..7337a939f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.6.32 +1.6.33 diff --git a/design-qa.md b/design-qa.md index 547ac2406..54d9b019d 100644 --- a/design-qa.md +++ b/design-qa.md @@ -1,4 +1,41 @@ -# IED protection relay fascia design QA +# SCL signal selection modal design QA + +## Visual sources + +- User reference / previous implementation: attached visual `codex-clipboard-090813c8-4eb5-4d1a-a394-dd81fe44b6f0.png` +- Runtime capture of the new WPF modal: `output/scl-signal-selection-modern.png` +- Side-by-side comparison: `output/scl-signal-selection-comparison.png` + +## Current scope + +The native `MessageBox` used after SCL import was replaced by `SclSignalSelectionModeWindow`, a modal WPF card that uses the existing ARSAS application icon, typography, color tokens, button styles, Lucide resources, elevation, and rounded-card language. + +## Comparison findings + +- The previous paragraph-heavy system dialog now has a clear title, shared-workspace subtitle, IED count badge, two explicit selection cards, and a supporting note. +- `Use Static DataSet` is selected by default and visibly marked as recommended; `Choose Signals Manually` is an equally accessible radio-card choice. +- Generic Yes/No labels are replaced by Cancel and Continue, with default and cancel keyboard behavior retained. +- Spacing, 17–24 px corner radii, subdued borders, blue selection state, Aptos/Segoe typography, and application button styles match existing ARSAS pop windows such as Save SCL. +- The runtime screenshot shows no clipped copy, overlapping controls, broken padding, or truncated actions at the captured Windows scaling. +- Both cards are fully clickable, mouse/pressed/focus states are defined, Enter continues, Escape cancels, and closing the window leaves the imported Engineering model unchanged. + +## Functional companion checks + +- Engineering Open SCL now permits multiple file selection and adds every distinct file into the same Engineering workspace. +- A single signal-authority decision applies to all newly imported IED workspaces and is bridged into an already-loaded FAT workspace without a second prompt. +- Duplicate FAT sources are filtered by SHA-256 before append; existing connected or monitoring IEDs are retained. + +## Current validation + +- Release build: passed with 0 errors using the ARIEC61850 commit pinned by `engines/ARIEC61850.lock.json`. +- Test suite: 616 passed, 0 failed, 0 skipped. +- Runtime WPF capture and side-by-side visual inspection: passed. + +Final result for the SCL signal selection modal: **passed**. + +--- + +# Previous IED protection relay fascia design QA ## Scope diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 6fc0f53c2..2e101141b 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "bd959bc68719c52853cede272c1f562aec9900e2", - "sourcePullRequest": 103, - "purpose": "Pins the exact ARIEC61850 engine used by ARSAS while preserving the reviewed reporting/control ancestry. PR #76 preserves unresolved static DataSet members; PR #77 canonicalizes cross-logical-device SCL references; PR #78 keeps one descriptor per static DataSet member while separating the resolved runtime primary leaf from original FCDA/FCD identity; PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp; PR #80 normalizes validated DataRef-enabled InformationReport ordering; PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata; PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling; PR #85 evaluates association capabilities before automatic dynamic mutation; PR #86 records dynamic-attempt failure/skip evidence and best-effort rollback. PR #87 restores baseline-safe static precedence. PR #88 adds a fail-closed single-member DefineNamedVariableList -> GetNamedVariableListAttributes -> DeleteNamedVariableList probation with exact invoke/request/response/routing/member/association/cleanup evidence. PR #89 quarantines automatic full dynamic DataSet activation because a successful one-member NVL probation does not guarantee association survival; it also preserves safe instMag/mag and instCVal/cVal projection while ambiguous structures remain raw. PR #90 / field-proven engine a18e550d07f7bbe4ff7753c180b02615075f6292 preserves G1/G1.1 Smart Control: signed primitive constraints, ordered SBO/SBOw-to-Operate wire evidence, StationControl origin compatibility, and explicit MMS Write DataAccessError including object-access-denied. G2 PR #91 adds qualification-only bounded multi-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList evidence with exact ordered read-back, encoded request/PDU evidence and fail-closed cleanup; PR #92 adds the 1/4/8/16/32 qualification ladder, deterministic bisection and explicit EnvelopeQualified acceptance; PR #93 adds a default-disabled ExplicitCommissioning coordinator with hard attempt budget, exact-set failure localization and fresh-association stop semantics; PR #94 adds identity-bound qualification profiles and prevents ProductionEligible unless RCB activation, an actual correctly mapped InformationReport, and all G2.6 physical regression gates are proven. G2.4 engine PR #95 retains the commissioning-only transactional URCB TrgOps/OptFlds lease. P0 physically proved the corrected IEC 61850 MMS TrgOps reserved-bit mapping: bit 0 reserved, bits 1..5 dchg/qchg/dupd/integrity/GI, so dchg+GI encodes canonically as 0244; P0 also separates raw BER equality from IEC significant-bit equality and provides a one-URCB TrgOps-only micro-probe that never writes OptFlds, DatSet, Resv, RptEna, GI or any DataSet service. P1 adds a dedicated one-URCB OptFlds-only capture/write/readback/finally-restore micro-probe for reason-for-inclusion + data-set-name, canonical target 061800, using ten-bit significant-value comparison while never writing TrgOps, DatSet, Resv, RptEna, GI, Define/Delete DataSet, starting a report monitor, or changing profile state. The G2.4 Owner correction exposes the exact local TCP address of the active MMS association and fail-closed decodes a server RCB Owner as a 4-byte IPv4 or 16-byte IPv6 address; physical SIPROTEC Owner C0A851F0 decodes to 192.168.81.240 and may prove caller ownership only when it exactly matches the active local TCP endpoint. Owner mismatch or unsupported encoding remains a hard failure. Original RCB values remain captured for restore, raw BER evidence is retained, and Production automatic dynamic BRCB/URCB activation remains quarantined until a compatible ProductionEligible profile is consumed by a later G2 phase. FAT P5.3 engine PR #103 resolves intermediate structured static DataSet members such as MMXU A.phsA and PPV.phsAB only to typed descendants below the exact FCDA boundary, selects a unique semantic primary runtime leaf such as cVal.mag.f without crossing sibling phases, preserves original static membership identity, and leaves genuinely ambiguous structures unresolved rather than guessing." + "commit": "b9ee5fc9650b72e69bc81287a1e1b047ad19b054", + "sourcePullRequest": 106, + "purpose": "Pins the exact ARIEC61850 engine used by ARSAS while preserving the reviewed reporting/control ancestry. PR #76 preserves unresolved static DataSet members; PR #77 canonicalizes cross-logical-device SCL references; PR #78 keeps one descriptor per static DataSet member while separating the resolved runtime primary leaf from original FCDA/FCD identity; PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp; PR #80 normalizes validated DataRef-enabled InformationReport ordering; PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata; PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling; PR #85 evaluates association capabilities before automatic dynamic mutation; PR #86 records dynamic-attempt failure/skip evidence and best-effort rollback. PR #87 restores baseline-safe static precedence. PR #88 adds a fail-closed single-member DefineNamedVariableList -> GetNamedVariableListAttributes -> DeleteNamedVariableList probation with exact invoke/request/response/routing/member/association/cleanup evidence. PR #89 quarantines automatic full dynamic DataSet activation because a successful one-member NVL probation does not guarantee association survival; it also preserves safe instMag/mag and instCVal/cVal projection while ambiguous structures remain raw. PR #90 / field-proven engine a18e550d07f7bbe4ff7753c180b02615075f6292 preserves G1/G1.1 Smart Control: signed primitive constraints, ordered SBO/SBOw-to-Operate wire evidence, StationControl origin compatibility, and explicit MMS Write DataAccessError including object-access-denied. G2 PR #91 adds qualification-only bounded multi-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList evidence with exact ordered read-back, encoded request/PDU evidence and fail-closed cleanup; PR #92 adds the 1/4/8/16/32 qualification ladder, deterministic bisection and explicit EnvelopeQualified acceptance; PR #93 adds a default-disabled ExplicitCommissioning coordinator with hard attempt budget, exact-set failure localization and fresh-association stop semantics; PR #94 adds identity-bound qualification profiles and prevents ProductionEligible unless RCB activation, an actual correctly mapped InformationReport, and all G2.6 physical regression gates are proven. G2.4 engine PR #95 retains the commissioning-only transactional URCB TrgOps/OptFlds lease. P0 physically proved the corrected IEC 61850 MMS TrgOps reserved-bit mapping: bit 0 reserved, bits 1..5 dchg/qchg/dupd/integrity/GI, so dchg+GI encodes canonically as 0244; P0 also separates raw BER equality from IEC significant-bit equality and provides a one-URCB TrgOps-only micro-probe that never writes OptFlds, DatSet, Resv, RptEna, GI or any DataSet service. P1 adds a dedicated one-URCB OptFlds-only capture/write/readback/finally-restore micro-probe for reason-for-inclusion + data-set-name, canonical target 061800, using ten-bit significant-value comparison while never writing TrgOps, DatSet, Resv, RptEna, GI, Define/Delete DataSet, starting a report monitor, or changing profile state. The G2.4 Owner correction exposes the exact local TCP address of the active MMS association and fail-closed decodes a server RCB Owner as a 4-byte IPv4 or 16-byte IPv6 address; physical SIPROTEC Owner C0A851F0 decodes to 192.168.81.240 and may prove caller ownership only when it exactly matches the active local TCP endpoint. Owner mismatch or unsupported encoding remains a hard failure. Original RCB values remain captured for restore, raw BER evidence is retained, and Production automatic dynamic BRCB/URCB activation remains quarantined until a compatible ProductionEligible profile is consumed by a later G2 phase. FAT P5.3 engine PR #103 resolves intermediate structured static DataSet members such as MMXU A.phsA and PPV.phsAB only to typed descendants below the exact FCDA boundary, selects a unique semantic primary runtime leaf such as cVal.mag.f without crossing sibling phases, preserves original static membership identity, and leaves genuinely ambiguous structures unresolved rather than guessing. FAT P5.4 engine PR #106 adds fail-closed model-backed InformationReport projection for structured static DataSet members: an exact report member reference now resolves independently of sparse decoder-side report value position, while DataSet scope still prevents duplicate static memberships from collapsing; when a report omits the member reference, static DataSet index remains the unique fail-closed fallback. All schema-proven scalar descendants are fanned out without selecting a sibling phase, and schema mismatch preserves raw projection instead of guessing. ARSAS supplies the per-IED LiveDiscovery/SCL planning model at the report receive seam." } diff --git a/landing/release-notes.json b/landing/release-notes.json index 353ac0041..c4447bef5 100644 --- a/landing/release-notes.json +++ b/landing/release-notes.json @@ -1,56 +1,52 @@ { "schemaVersion": 1, "product": "ARSAS", - "version": "1.6.31", - "title": "Reconnect-safe live DataSet discovery and hybrid RCB acquisition", - "titleId": "Discovery DataSet live dan acquisition RCB hybrid yang aman saat reconnect", - "summary": "ARSAS 1.6.31 completes the repaired live reporting pipeline from discovery through reconnect: DataSet members are retained, static and dynamic BRCB/URCB coverage is planned by ARIEC61850, only uncovered signals fall back to MMS polling, and a new MMS association cannot inherit report or command-correlation evidence from the previous connection.", - "summaryId": "ARSAS 1.6.31 melengkapi perbaikan pipeline live reporting dari discovery sampai reconnect: member DataSet dipertahankan, coverage static dan dynamic BRCB/URCB direncanakan oleh ARIEC61850, hanya signal yang tidak ter-cover yang fallback ke MMS polling, dan association MMS baru tidak dapat mewarisi evidence report atau korelasi command dari koneksi sebelumnya.", + "version": "1.6.33", + "title": "Fast, unified and responsive IEC 61850 FAT workspace", + "titleId": "Workspace IEC 61850 FAT yang cepat, terpadu, dan responsif", + "summary": "ARSAS 1.6.33 unifies Engineering and FAT around the imported SCL model, shortens direct-SCL connection readiness, projects structured THD and demand-energy values correctly, and keeps operator input responsive during continuous live acquisition.", + "summaryId": "ARSAS 1.6.33 menyatukan Engineering dan FAT pada model SCL yang diimpor, mempercepat kesiapan koneksi direct-SCL, memproyeksikan THD terstruktur dan demand-energy dengan benar, serta menjaga input operator tetap responsif saat akuisisi live terus berjalan.", "highlights": [ - "Live discovery requests bounded DataSet directories and retains their members, preventing report-capable signals from disappearing between MMS discovery and the application catalog.", - "ARSAS executes the ARIEC61850 hybrid plan across static BRCB, static URCB, verified-free dynamic RCB slots, and per-signal MMS polling residuals.", - "Dynamic RCB planning supports temporary DataSets whose members reside in logical devices different from the RCB owner, as permitted by the live server model.", - "Report catalog matching selects the owning primary-value descriptor even when related quality and timestamp descriptors share the same primary reference.", - "Instantaneous magnitude values such as Hz.instMag.f resolve their quality and timestamp companions at Hz.q and Hz.t, while preserving clear operator-facing names.", - "Smart reconnect runs the same authoritative hybrid planner as initial monitoring, with fresh RCB availability checks and clean per-association report evidence." + "Direct-SCL FAT connection reuses the imported model and reaches monitoring after the MMS association and bounded point setup without a discovery-from-zero or legacy settle delay.", + "Engineering and FAT share SCL signal identity and operator selection; switching workspace no longer creates an isolated signal universe.", + "Additional CID, SCD, ICD or IID sources can be added to an existing FAT project, including while another IED evidence session remains active.", + "Structured ThdA and ThdPPV parent values preserve their phase A/B/C or AB/BC/CA members, while DmdWhMV uses the exact MMS value authority exposed by the relay.", + "Windows taskbar grouping uses the embedded ARSAS icon and a stable explicit application identity." ], "highlightsId": [ - "Live discovery meminta directory DataSet secara terbatas dan mempertahankan member-nya, sehingga signal report-capable tidak lagi hilang di antara MMS discovery dan catalog aplikasi.", - "ARSAS mengeksekusi hybrid plan ARIEC61850 melalui static BRCB, static URCB, slot dynamic RCB yang terverifikasi free, dan residual MMS polling per signal.", - "Perencanaan dynamic RCB mendukung DataSet sementara dengan member pada logical device yang berbeda dari owner RCB, sesuai model live server.", - "Pemetaan report catalog memilih descriptor primary value pemiliknya walaupun descriptor quality dan timestamp terkait menggunakan primary reference yang sama.", - "Instantaneous magnitude seperti Hz.instMag.f menemukan companion quality dan timestamp pada Hz.q dan Hz.t, sambil mempertahankan nama yang jelas bagi operator.", - "Smart reconnect menjalankan authoritative hybrid planner yang sama dengan initial monitoring, termasuk pemeriksaan availability RCB terbaru dan evidence report per-association yang bersih." + "Koneksi FAT direct-SCL menggunakan kembali model hasil import dan masuk ke monitoring setelah association MMS serta bounded point setup tanpa discovery dari nol atau legacy settle delay.", + "Engineering dan FAT berbagi identitas serta pilihan signal SCL; perpindahan workspace tidak lagi membentuk signal universe yang terpisah.", + "Source CID, SCD, ICD, atau IID tambahan dapat dimasukkan ke project FAT yang sedang digunakan, termasuk ketika evidence session IED lain masih aktif.", + "Parent value ThdA dan ThdPPV terstruktur mempertahankan member phase A/B/C atau AB/BC/CA, sedangkan DmdWhMV menggunakan exact MMS value authority yang diberikan relay.", + "Grouping taskbar Windows menggunakan icon ARSAS yang tertanam dan explicit application identity yang stabil." ], "improvements": [ - "Fresh RCB availability is revalidated before any setup write, and static or dynamic control blocks are reused only when the live evidence still permits it.", - "Physical validation evidence distinguishes successful RCB setup from actual report frames and verified process-value changes, avoiding false claims of report delivery.", - "Physical-validation mutation and snapshot capture are synchronized so operator diagnostics cannot observe torn counters or collection changes while report frames arrive.", - "The release source is gated by the standard ARSAS solution build and regression suite plus portable and Windows installer smoke validation before publication.", - "Stable Windows publication produces installer and portable binaries together with SHA-256 checksums, SPDX SBOM, application/engine provenance and GitHub artifact attestations." + "Live FAT observations are coalesced in bounded dispatcher batches below mouse and keyboard input priority, while the dedicated digital-edge queue remains lossless.", + "Steady telemetry updates only visible value cells; it no longer re-filters the entire DataGrid or resets autosave for every poll.", + "Capture progress and workspace summaries update only when evidence or test state actually changes.", + "Adding an IED synchronizes only the new endpoints, leaving the active IED's capture map and evidence scope immutable.", + "The Windows release remains gated by the full ARSAS regression suite, portable smoke validation, checksums, SPDX SBOM, provenance and GitHub attestations." ], "improvementsId": [ - "Availability RCB terbaru divalidasi ulang sebelum setup write, dan control block static atau dynamic hanya digunakan ketika evidence live masih mengizinkannya.", - "Evidence validasi fisik membedakan setup RCB yang berhasil dari report frame aktual dan perubahan process value yang terverifikasi, sehingga tidak ada klaim palsu mengenai report delivery.", - "Mutasi dan snapshot physical validation disinkronisasi agar diagnostic operator tidak melihat counter yang torn atau perubahan collection ketika report frame masuk.", - "Source release digate oleh build solution dan regression suite ARSAS standard serta portable dan Windows installer smoke validation sebelum publikasi.", - "Publikasi Windows stable menghasilkan binary installer dan portable bersama SHA-256 checksum, SPDX SBOM, provenance aplikasi/engine dan GitHub artifact attestation." + "Observasi live FAT di-coalesce dalam bounded dispatcher batch di bawah prioritas input mouse dan keyboard, sementara queue digital-edge khusus tetap lossless.", + "Telemetry steady hanya mengubah cell value yang terlihat; seluruh DataGrid tidak lagi di-filter ulang dan timer autosave tidak lagi di-reset pada setiap poll.", + "Progress capture dan ringkasan workspace hanya diperbarui ketika evidence atau test state benar-benar berubah.", + "Penambahan IED hanya menyinkronkan endpoint baru sehingga capture map dan evidence scope IED aktif tetap immutable.", + "Release Windows tetap digate oleh regression suite ARSAS lengkap, portable smoke validation, checksum, SPDX SBOM, provenance, dan GitHub attestation." ], "knownLimitations": [ - "Physical relay validation remains necessary for device-specific engineering behavior that cannot be reproduced by CI, including vendor-specific model exposure and field network conditions.", - "A successful RCB setup is intentionally not treated as proof that report frames were received; field validation must confirm frames and value changes for the target IED and network.", - "The three-digit FAT timestamp display intentionally rounds the decoded timestamp to the nearest millisecond; full decoded precision remains available in evidence views.", + "Physical relay validation remains necessary for vendor-specific values, report behavior and field network conditions that cannot be reproduced by CI.", + "Adding an SCL source does not automatically start or switch the active FAT evidence session; the operator selects and starts each IED explicitly.", "Windows x64 is the only packaged desktop platform in this stable release.", "The public binaries are not Authenticode code-signed; Windows SmartScreen may show an unrecognized-publisher warning.", "Raw-Ethernet GOOSE and Sampled Values workflows require an administrator-installed and approved Npcap driver, suitable capture permission and visibility of the relevant multicast traffic." ], "knownLimitationsId": [ - "Validasi relay fisik tetap diperlukan untuk perilaku engineering spesifik perangkat yang tidak dapat direproduksi oleh CI, termasuk exposure model vendor-specific dan kondisi network di lapangan.", - "Setup RCB yang berhasil sengaja tidak dianggap sebagai bukti bahwa report frame telah diterima; validasi lapangan harus mengonfirmasi frame dan perubahan value pada IED dan network target.", - "Display timestamp FAT tiga digit secara sengaja membulatkan decoded timestamp ke millisecond terdekat; full decoded precision tetap tersedia pada evidence view.", + "Validasi relay fisik tetap diperlukan untuk value vendor-specific, perilaku report, dan kondisi network lapangan yang tidak dapat direproduksi oleh CI.", + "Penambahan source SCL tidak otomatis memulai atau memindahkan evidence session FAT aktif; operator memilih dan memulai setiap IED secara eksplisit.", "Windows x64 adalah satu-satunya platform desktop yang dipaketkan pada stable release ini.", "Binary publik belum ditandatangani dengan Authenticode; Windows SmartScreen dapat menampilkan peringatan unrecognized publisher.", - "Workflow raw-Ethernet GOOSE dan Sampled Values memerlukan driver Npcap yang telah dipasang dan disetujui administrator, capture permission yang sesuai dan visibility traffic multicast terkait." + "Workflow raw-Ethernet GOOSE dan Sampled Values memerlukan driver Npcap yang telah dipasang dan disetujui administrator, capture permission yang sesuai, serta visibility traffic multicast terkait." ], "codeSigning": { "status": "unsigned", @@ -63,11 +59,11 @@ "src": "assets/screenshots/arsas-live-values.webp", "width": 1507, "height": 893, - "alt": "Representative ARSAS 1.6.31 live values and reconnect-safe IEC 61850 acquisition workspace", - "altId": "Representasi workspace live values dan acquisition IEC 61850 ARSAS 1.6.31 yang aman saat reconnect", - "caption": "Representative ARSAS live-value workspace for stable release 1.6.31. Acquisition labels identify report delivery only after report frames are received; reconnect resets association-scoped evidence and uncovered signals remain explicit MMS polling residuals.", - "captionId": "Representasi workspace live value ARSAS untuk stable release 1.6.31. Label acquisition mengidentifikasi report delivery hanya setelah report frame diterima; reconnect mereset evidence per-association dan signal yang tidak ter-cover tetap ditampilkan sebagai residual MMS polling." + "alt": "ARSAS 1.6.33 Engineering and FAT live IEC 61850 workspace", + "altId": "Workspace live IEC 61850 Engineering dan FAT ARSAS 1.6.33", + "caption": "ARSAS 1.6.33 shares imported SCL signal identity between Engineering and FAT while bounded background refresh keeps operator interaction responsive.", + "captionId": "ARSAS 1.6.33 berbagi identitas signal SCL antara Engineering dan FAT sementara bounded background refresh menjaga interaksi operator tetap responsif." }, "issuesUrl": "https://github.com/masarray/arsas/issues/new/choose", - "releaseUrl": "https://github.com/masarray/arsas/releases/tag/v1.6.31" + "releaseUrl": "https://github.com/masarray/arsas/releases/tag/v1.6.33" } diff --git a/output/scl-signal-selection-comparison.png b/output/scl-signal-selection-comparison.png new file mode 100644 index 000000000..c3a6ff981 Binary files /dev/null and b/output/scl-signal-selection-comparison.png differ diff --git a/output/scl-signal-selection-modern.png b/output/scl-signal-selection-modern.png new file mode 100644 index 000000000..4f0acfe08 Binary files /dev/null and b/output/scl-signal-selection-modern.png differ diff --git a/tests/ARSAS.Tests/IoFatContinuationScopeRegressionTests.cs b/tests/ARSAS.Tests/IoFatContinuationScopeRegressionTests.cs index ee2d9d8f9..6bff70f74 100644 --- a/tests/ARSAS.Tests/IoFatContinuationScopeRegressionTests.cs +++ b/tests/ARSAS.Tests/IoFatContinuationScopeRegressionTests.cs @@ -3,7 +3,7 @@ namespace ARSAS.Tests; public sealed class IoFatContinuationScopeRegressionTests { [Fact] - public void SelectedRows_ArePreparedExplicitlyAndOnlyLiveSubsetIsArmedWithoutMutatingOperatorSelection() + public void SelectedRows_DefineEvidenceScopeWhileFullIedScopeOwnsLivePreparation() { var source = File.ReadAllText(FindRepoFile("IoListTestingWindow.ContextUx.cs")) .Replace("\r\n", "\n", StringComparison.Ordinal); @@ -15,7 +15,7 @@ public void SelectedRows_ArePreparedExplicitlyAndOnlyLiveSubsetIsArmedWithoutMut "var captureScope = selectedIed.TestPoints", StringComparison.Ordinal); var preparationIndex = source.IndexOf( - "progress,\n captureScope);", + "var preparation = await engineeringWindow.PrepareIoTestIedForFatAsync(\n Project,\n selectedIed,\n progress);", StringComparison.Ordinal); var liveScopeIndex = source.IndexOf( "var liveCaptureScope = captureScope", @@ -27,12 +27,13 @@ public void SelectedRows_ArePreparedExplicitlyAndOnlyLiveSubsetIsArmedWithoutMut "Session.Start(selectedIed, liveCaptureScope)", StringComparison.Ordinal); - Assert.True(preflightIndex >= 0, "The real operator selection must be validated before capture scope creation."); + Assert.True(preflightIndex >= 0, "The operator-selected evidence scope must be validated before capture scope creation."); Assert.True(scopeIndex > preflightIndex, "Capture scope must be created only after preflight succeeds."); - Assert.True(preparationIndex > scopeIndex, "The complete operator-selected scope must be used for live preparation."); - Assert.True(liveScopeIndex > preparationIndex, "Live evidence scope must be derived only after preparation has produced binding evidence."); + Assert.True(preparationIndex > scopeIndex, "P1 live preparation must use the full included IED acquisition scope, not the TEST subset."); + Assert.True(liveScopeIndex > preparationIndex, "Evidence scope must be filtered to proven live rows only after full IED preparation."); Assert.True(liveReadyIndex >= liveScopeIndex, "Active evidence scope must require LivePointReady rows."); - Assert.True(sessionStartIndex > liveScopeIndex, "Only the proven live subset may reach Session.Start."); + Assert.True(sessionStartIndex > liveScopeIndex, "Only the proven selected live subset may reach Session.Start."); + Assert.DoesNotContain("progress,\n captureScope);", source, StringComparison.Ordinal); Assert.DoesNotContain("point.TestEnabled = false", source, StringComparison.Ordinal); Assert.DoesNotContain("point.TestEnabled = true", source, StringComparison.Ordinal); Assert.DoesNotContain("protectedPoints", source, StringComparison.Ordinal); diff --git a/tests/ARSAS.Tests/IoFatDirectSclLatencyAndIdentityRegressionTests.cs b/tests/ARSAS.Tests/IoFatDirectSclLatencyAndIdentityRegressionTests.cs new file mode 100644 index 000000000..e3a505ee1 --- /dev/null +++ b/tests/ARSAS.Tests/IoFatDirectSclLatencyAndIdentityRegressionTests.cs @@ -0,0 +1,219 @@ +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; +using ArIED61850Tester.Models.IoTesting; +using ArIED61850Tester.Services; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class IoFatDirectSclLatencyAndIdentityRegressionTests +{ + [Fact] + public void StructuredStaticMember_KeepsDedicatedIdentityAndPublishesResolvedThdScalarToRuntime() + { + const string dataObject = "IEDLD0/I_MHAI1.ThdA"; + const string staticMember = dataObject + ".phsA"; + const string runtimeLeaf = staticMember + ".cVal.mag.f"; + const string dataSet = "IEDLD0/LLN0.Analog"; + + var model = new LiveIedModelDiscoveryDocument + { + Source = "SclWorkspace", + IedName = "IED", + LogicalDevices = new[] + { + new LiveIedLogicalDeviceModel + { + MmsDomain = "IEDLD0", + Inst = "LD0", + LogicalNodes = new[] + { + new LiveIedLogicalNodeModel + { + Name = "I_MHAI1", + LnClass = "MHAI", + LnInst = "1", + DataObjects = new[] + { + new LiveIedDataObjectModel + { + Reference = dataObject, + Name = "ThdA", + InferredCdc = "WYE", + Attributes = new[] + { + new LiveIedDataAttributeModel + { + ObjectReference = runtimeLeaf, + AttributePath = "phsA.cVal.mag.f", + FunctionalConstraint = "MX", + MmsReference = "IEDLD0/I_MHAI1$MX$ThdA$phsA$cVal$mag$f", + MmsItemName = "I_MHAI1$MX$ThdA$phsA$cVal$mag$f", + SclBType = "FLOAT32", + MmsType = "floating-point", + Source = "SCL.DataTypeTemplates", + TypeSource = "SCL.DataTypeTemplates", + TypeConfidence = LiveIedDiscoveryConfidenceLevel.Exact + } + } + } + } + } + } + } + }, + DataSets = new[] + { + new LiveIedDataSetModel + { + Reference = dataSet, + Domain = "IEDLD0", + LogicalNode = "LLN0", + Name = "Analog", + MemberCount = 1, + Members = new[] + { + new LiveIedDataSetMemberModel + { + Index = 0, + Reference = staticMember, + FunctionalConstraint = "MX", + MmsReference = "IEDLD0/I_MHAI1$MX$ThdA$phsA", + Confidence = LiveIedDiscoveryConfidenceLevel.Exact + } + } + } + } + }; + + var genericScalar = new SignalDefinition + { + Name = "ThdA phsA mag", + ObjectReference = runtimeLeaf, + DisplayReference = runtimeLeaf, + FunctionalConstraint = "MX", + DataType = "FLOAT32", + Category = "Measurement", + Source = "Cached live model" + }; + var signals = new List { genericScalar }; + + var merge = Iec61850DataSetSignalInventoryService.EnsureMandatorySignals(signals, model); + + Assert.Equal(1, merge.MandatoryCatalogCount); + Assert.Equal(1, merge.AddedCount); + Assert.Equal(2, signals.Count); + Assert.Equal(runtimeLeaf, genericScalar.DisplayReference); + + var membershipSignal = Assert.Single(signals.Where(signal => + signal.DisplayReference.Equals(staticMember, StringComparison.OrdinalIgnoreCase))); + Assert.NotSame(genericScalar, membershipSignal); + Assert.Equal(runtimeLeaf, membershipSignal.ObjectReference); + Assert.Equal(dataSet, membershipSignal.DataSetReference); + + // A checked Engineering THD phase magnitude and the explicit static DataSet + // membership row must both cross the SignalDefinition -> monitor-point runtime + // gate. Harmonic spectra/configuration stay filtered; this exact FLOAT32 process + // value is the operator-visible live measurement. + Assert.True(genericScalar.CanPublishToRuntime); + Assert.True(membershipSignal.IsExplicitDataSetRuntimeValue); + Assert.True(membershipSignal.CanPublishAsSignal); + Assert.True(membershipSignal.CanPublishToRuntime); + + var structuredParent = new SignalDefinition + { + Name = "ThdA", + ObjectReference = dataObject, + DisplayReference = dataObject, + FunctionalConstraint = "MX", + DataType = "structure", + Category = "DataSet", + DataSetReference = dataSet, + Source = "ARIEC static DataSet parent" + }; + Assert.False(structuredParent.IsExplicitDataSetRuntimeValue); + // The object-level DataSet row is also a live composite value (phsA/B/C). + // It remains distinct from a scalar member but must not stay Unknown in FAT. + Assert.True(structuredParent.CanPublishToRuntime); + + var point = new IoTestPointPlan + { + TestPointId = "THD-A", + IedName = "IED", + IpAddress = "192.0.2.10", + SignalName = "ThdA.phsA", + ObjectReference = runtimeLeaf, + FunctionalConstraint = "MX", + ExpectedOnText = "Value 1", + ExpectedOffText = "Value 2", + SourceIecReference = staticMember, + ReportDisplayReference = staticMember, + EventLogSearchReference = staticMember, + DataSetName = dataSet, + BindingStatus = IoTestSignalSelectionService.SclDataSetAuthorityBindingStatus, + ImportReady = true, + TestEnabled = true, + SignalKind = FatSignalKind.Analog, + CaptureMode = FatCaptureMode.OperatorSnapshot + }; + var ied = new IoTestIedPlan + { + IedName = "IED", + IpAddress = "192.0.2.10", + TestPoints = new List { point } + }; + var device = new Iec61850MonitorDevice + { + Name = "IED", + SclIedName = "IED", + IpAddress = "192.0.2.10", + Port = 102 + }; + device.Signals.AddRange(signals); + + var selection = new IoTestSignalSelectionService().Resolve(ied, device); + + Assert.True(selection.Succeeded, selection.Message); + var match = Assert.Single(selection.Matches); + Assert.Same(membershipSignal, match.Signal); + Assert.True(match.Signal.CanPublishToRuntime); + Assert.Empty(selection.MissingPoints); + Assert.Empty(selection.AmbiguousPoints); + } + + [Fact] + public void DirectSclFat_ReconciliationCannotGateAcquisitionAtAll() + { + var source = File.ReadAllText(FindRepoFile("Services/IoTesting/IoTestReconciliationCache.cs")); + var directSclGate = source.IndexOf("if (UsesDirectSclFatAuthority(device))", StringComparison.Ordinal); + var connectedOwnerGate = source.IndexOf("if (!NativeIec61850Client.HasReconciliationOwner", StringComparison.Ordinal); + + Assert.True(directSclGate >= 0, "Direct SCL FAT must have an explicit nonblocking reconciliation path."); + Assert.True(connectedOwnerGate > directSclGate, + "Direct SCL FAT must return before native reconciliation ownership is considered."); + Assert.Contains("Entries.TryRemove(device, out _);", source, StringComparison.Ordinal); + Assert.Contains("return Task.CompletedTask;", source, StringComparison.Ordinal); + Assert.Contains("UsesDirectSclFatAuthority", source, StringComparison.Ordinal); + Assert.Contains("FAT SCL", source, StringComparison.Ordinal); + + var gateBodyStart = source.IndexOf('{', directSclGate); + var gateBodyEnd = source.IndexOf('}', gateBodyStart + 1); + var gateBody = source.Substring(gateBodyStart, gateBodyEnd - gateBodyStart + 1); + Assert.DoesNotContain("RefreshModelOnlyAsync", gateBody, StringComparison.Ordinal); + Assert.DoesNotContain("ReconcileConnectedAsync", gateBody, StringComparison.Ordinal); + } + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + + throw new FileNotFoundException($"Could not locate repository file '{relativePath}' from '{AppContext.BaseDirectory}'."); + } +} diff --git a/tests/ARSAS.Tests/IoFatFreshSclSelectionAuthorityRegressionTests.cs b/tests/ARSAS.Tests/IoFatFreshSclSelectionAuthorityRegressionTests.cs new file mode 100644 index 000000000..1a329e05e --- /dev/null +++ b/tests/ARSAS.Tests/IoFatFreshSclSelectionAuthorityRegressionTests.cs @@ -0,0 +1,56 @@ +namespace ARSAS.Tests; + +public sealed class IoFatFreshSclSelectionAuthorityRegressionTests +{ + [Fact] + public void DirectSclImporter_DefaultsStaticDatasetRowsToChecked() + { + var source = Read("Services/IoTesting/IoFatSclProjectImportService.cs"); + + Assert.Contains("TestEnabled = true,", source, StringComparison.Ordinal); + Assert.Contains("ImportReady = true,", source, StringComparison.Ordinal); + Assert.Contains("BindingStatus = \"SCL_DATASET_AUTHORITY\"", source, StringComparison.Ordinal); + } + + [Fact] + public void SclBridge_UsesExistingEngineeringSelectionAsSharedWorkspaceAuthority() + { + var source = Read("Services/IoTesting/IoFatEngineeringSelectionBridge.cs"); + + Assert.Contains("if (preserveExistingEngineeringSelection)", source, StringComparison.Ordinal); + Assert.Contains("point.WorkspaceSelected != signal.IsSelected", source, StringComparison.Ordinal); + Assert.Contains("point.WorkspaceSelected = signal.IsSelected;", source, StringComparison.Ordinal); + Assert.Contains("point.WorkspaceSelected = true;", source, StringComparison.Ordinal); + Assert.DoesNotContain("point.TestEnabled = signal.IsSelected;", source, StringComparison.Ordinal); + Assert.DoesNotContain("_ = preserveExistingEngineeringSelection;", source, StringComparison.Ordinal); + } + + [Fact] + public void FreshSclBridge_DoesNotMergeSharedSelectionWithFatDispositionOrTestScope() + { + var source = Read("Services/IoTesting/IoFatEngineeringSelectionBridge.cs"); + + Assert.Contains("FatDisposition remains a FAT-only Remove/Restore authority", source, StringComparison.Ordinal); + Assert.Contains("TEST never mutates Engineering", source, StringComparison.Ordinal); + Assert.DoesNotContain("point.RestoreToFat();", source, StringComparison.Ordinal); + Assert.DoesNotContain("point.TestEnabled = signal.IsSelected;", source, StringComparison.Ordinal); + } + + private static string Read(string relativePath) + => File.ReadAllText(FindRepoFile(relativePath)).Replace("\r\n", "\n", StringComparison.Ordinal); + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + + throw new FileNotFoundException( + $"Could not locate repository file '{relativePath}' from '{AppContext.BaseDirectory}'."); + } +} diff --git a/tests/ARSAS.Tests/IoFatMultiIedConnectionRegressionTests.cs b/tests/ARSAS.Tests/IoFatMultiIedConnectionRegressionTests.cs index 8931220da..9ceea6b39 100644 --- a/tests/ARSAS.Tests/IoFatMultiIedConnectionRegressionTests.cs +++ b/tests/ARSAS.Tests/IoFatMultiIedConnectionRegressionTests.cs @@ -67,18 +67,121 @@ public void IedConnectionAction_IsOwnedByEachIed() } [Fact] - public void P1_SourceContract_UsesPerIedPreparationWithoutWeakeningEvidenceIsolation() + public void MultipleIeds_CanRetainIndependentConnectedMonitoringState() + { + var a = Ied("IED_A", "192.168.81.10", Point("P-A", "IED_A", "192.168.81.10", "IED_ALD0/GGIO1.Ind1.stVal")); + var b = Ied("IED_B", "192.168.81.11", Point("P-B", "IED_B", "192.168.81.11", "IED_BLD0/GGIO1.Ind2.stVal")); + + a.ApplyLiveDeviceBinding("device-a", "Monitoring · fast MMS", true, true); + b.ApplyLiveDeviceBinding("device-b", "Monitoring · report", true, true); + + Assert.True(a.IsLiveConnected); + Assert.True(a.IsLiveMonitoring); + Assert.Equal("device-a", a.LiveDeviceId); + Assert.True(b.IsLiveConnected); + Assert.True(b.IsLiveMonitoring); + Assert.Equal("device-b", b.LiveDeviceId); + } + + [Fact] + public void DirectSclAcquisition_ResolvesUncheckedIncludedMember_ButNotRemovedMember() + { + const string reference = "IED_ALD0/GGIO1.Ind1.stVal"; + var point = Point( + "P-A", + "IED_A", + "192.168.81.10", + reference, + bindingStatus: "SCL_DATASET_AUTHORITY"); + point.TestEnabled = false; + + var signal = new SignalDefinition + { + Name = "Ind1", + ObjectReference = reference, + DisplayReference = reference, + FunctionalConstraint = "ST", + DataType = "BOOLEAN", + Category = "DataSet" + }; + var device = new Iec61850MonitorDevice + { + Name = "IED_A", + SclIedName = "IED_A", + IpAddress = "192.168.81.10", + Port = 102 + }; + device.Signals.Add(signal); + + var service = new IoTestSignalSelectionService(); + var included = service.Resolve(Ied("IED_A", "192.168.81.10", point), device); + + Assert.Single(included.Matches); + Assert.Same(point, included.Matches[0].TestPoint); + Assert.Same(signal, included.Matches[0].Signal); + Assert.False(point.TestEnabled); + + point.RemoveFromFat(); + var removed = service.Resolve(Ied("IED_A", "192.168.81.10", point), device); + Assert.Empty(removed.Matches); + Assert.Empty(removed.MissingPoints); + Assert.Empty(removed.AmbiguousPoints); + Assert.False(point.IsIncludedInFat); + Assert.False(point.TestEnabled); + } + + [Fact] + public void P1_ConnectAndAcquisitionDoNotUseTestCheckboxAsAuthority() + { + var autoConnect = ReadRepoFile("MainWindow.IoTesting.AutoConnect.cs"); + var contextUx = ReadRepoFile("IoListTestingWindow.ContextUx.cs"); + var monitor = ReadRepoFile("MainWindow.IoTesting.MultiIedMonitor.cs"); + var resolver = ReadRepoFile("Services/IoTesting/IoTestSignalSelectionService.cs"); + + Assert.Contains("requestedPointsOverride is null || point.TestEnabled", autoConnect, StringComparison.Ordinal); + Assert.Contains("point.WorkspaceSelected", autoConnect, StringComparison.Ordinal); + Assert.Contains("point.IsIncludedInFat", autoConnect, StringComparison.Ordinal); + Assert.Contains("point.ImportReady", autoConnect, StringComparison.Ordinal); + Assert.Contains("var acquisitionSignals = selection.Matches", autoConnect, StringComparison.Ordinal); + Assert.Contains("StartIoFatDeviceMonitorAsync(device, acquisitionSignals)", autoConnect, StringComparison.Ordinal); + Assert.Contains("point.TestEnabled || IsDirectSclAuthority(point)", resolver, StringComparison.Ordinal); + Assert.Contains("point.WorkspaceSelected", resolver, StringComparison.Ordinal); + Assert.Contains("point.IsIncludedInFat", resolver, StringComparison.Ordinal); + + Assert.DoesNotContain("var enabledReady = targetIed.TestPoints", contextUx, StringComparison.Ordinal); + Assert.Contains("private Task PrepareIndependentIedConnectionAsync", contextUx, StringComparison.Ordinal); + Assert.Contains("Project,\n targetIed,\n progress);", contextUx, StringComparison.Ordinal); + Assert.Contains("var captureScope = selectedIed.TestPoints", contextUx, StringComparison.Ordinal); + Assert.Contains("point.WorkspaceSelected && point.IsIncludedInFat && point.TestEnabled && point.ImportReady", contextUx, StringComparison.Ordinal); + Assert.Contains("var result = Session.Start(selectedIed, liveCaptureScope);", contextUx, StringComparison.Ordinal); + + Assert.Contains("device.BeginBulkSignalSelection();", monitor, StringComparison.Ordinal); + Assert.Contains("signal.IsSelected = acquisitionSet.Contains(signal);", monitor, StringComparison.Ordinal); + Assert.Contains("signal.IsSelected = wasSelected;", monitor, StringComparison.Ordinal); + Assert.Contains("device.EndBulkSignalSelection();", monitor, StringComparison.Ordinal); + Assert.Contains("_runtime.StartMonitoringAsync", monitor, StringComparison.Ordinal); + Assert.DoesNotContain("SaveSignalSelectionMemory(device)", monitor, StringComparison.Ordinal); + } + + [Fact] + public void P2_SourceContract_UsesPerIedEvidenceLeavesWithoutWeakeningDeviceIsolation() { var autoConnect = ReadRepoFile("MainWindow.IoTesting.AutoConnect.cs"); var contextUx = ReadRepoFile("IoListTestingWindow.ContextUx.cs"); var progressUx = ReadRepoFile("IoListTestingWindow.RealPreparationProgress.cs"); var connectUx = ReadRepoFile("IoListTestingWindow.MultiIedConnectionUx.cs"); var printPreview = ReadRepoFile("IoListTestingWindow.PrintPreview.cs"); - var session = ReadRepoFile("Services/IoTesting/IoTestSessionController.cs"); + var leaf = ReadRepoFile("Services/IoTesting/IoTestSessionController.cs"); + var coordinator = ReadRepoFile("Services/IoTesting/IoTestMultiSessionCoordinator.cs"); + var runtimeWiring = ReadRepoFile("MainWindow.IoTesting.MultiSessionEvidence.cs"); + var window = ReadRepoFile("IoListTestingWindow.xaml.cs"); Assert.Contains("_ioTestLiveBindingService.BindIed(ied, Devices)", autoConnect, StringComparison.Ordinal); Assert.DoesNotContain("_ioTestLiveBindingService.Bind(project, Devices)", autoConnect, StringComparison.Ordinal); Assert.Contains("requestedPointsOverride", autoConnect, StringComparison.Ordinal); + Assert.Contains("selectDevice: false", autoConnect, StringComparison.Ordinal); + Assert.Contains("other IED monitors remain active", autoConnect, StringComparison.Ordinal); + Assert.DoesNotContain("Session.IsSessionActive", autoConnect, StringComparison.Ordinal); Assert.Contains("private async void ConnectIed_Click", contextUx, StringComparison.Ordinal); Assert.Contains("targetIed.IsPreparing", contextUx, StringComparison.Ordinal); Assert.Contains("SelectedIed.IsPreparing", contextUx, StringComparison.Ordinal); @@ -89,13 +192,28 @@ public void P1_SourceContract_UsesPerIedPreparationWithoutWeakeningEvidenceIsola Assert.Contains("Other IED connection workflows keep running", connectUx, StringComparison.Ordinal); Assert.Contains("new Binding(nameof(SelectedIed))", connectUx, StringComparison.Ordinal); - // P1 parallelizes connection/monitoring only. The evidence controller remains - // intentionally single-active so relay transitions cannot enter the wrong journal. - Assert.Contains("Stop the active FAT session before starting another IED.", session, StringComparison.Ordinal); - Assert.Contains("entry.DeviceId.Equals(activeDevice.DeviceId", session, StringComparison.Ordinal); + // The leaf still refuses a second IED because it is intentionally one-Ied-only. + // P2 parallelism comes from one leaf/journal per IED, never by weakening the leaf. + Assert.Contains("Stop the active FAT session before starting another IED.", leaf, StringComparison.Ordinal); + Assert.Contains("entry.DeviceId.Equals(activeDevice.DeviceId", leaf, StringComparison.Ordinal); + Assert.Contains("class IoTestMultiSessionCoordinator", coordinator, StringComparison.Ordinal); + Assert.Contains("ActiveSessionCount", coordinator, StringComparison.Ordinal); + Assert.Contains("GetOrCreateController", coordinator, StringComparison.Ordinal); + Assert.Contains("EnqueueAdditional", coordinator, StringComparison.Ordinal); + Assert.Contains("!ReferenceEquals(controller, _primaryController)", coordinator, StringComparison.Ordinal); + Assert.Contains("AttachIoTestParallelEvidenceSessions", runtimeWiring, StringComparison.Ordinal); + Assert.Contains("_runtime.PointUpdated += Runtime_IoTestAdditionalPointUpdated", runtimeWiring, StringComparison.Ordinal); + Assert.Contains("coordinator.EnqueueAdditional(new Iec61850EventEntry", runtimeWiring, StringComparison.Ordinal); + Assert.Contains("IoTestMultiSessionCoordinator Session", window, StringComparison.Ordinal); + Assert.Contains("Session.StopAll", window, StringComparison.Ordinal); } - private static IoTestPointPlan Point(string id, string iedName, string ipAddress, string reference) => new() + private static IoTestPointPlan Point( + string id, + string iedName, + string ipAddress, + string reference, + string bindingStatus = "") => new() { TestPointId = id, IedName = iedName, @@ -112,6 +230,8 @@ public void P1_SourceContract_UsesPerIedPreparationWithoutWeakeningEvidenceIsola SourceIecReference = reference, EventLogSearchReference = reference, ReportDisplayReference = reference + " [ST]", + BindingStatus = bindingStatus, + WorkspaceSelected = true, TestEnabled = true, ImportReady = true }; diff --git a/tests/ARSAS.Tests/IoFatP3CaptureRegressionTests.cs b/tests/ARSAS.Tests/IoFatP3CaptureRegressionTests.cs new file mode 100644 index 000000000..3e28f9271 --- /dev/null +++ b/tests/ARSAS.Tests/IoFatP3CaptureRegressionTests.cs @@ -0,0 +1,432 @@ +using ArIED61850Tester.Models; +using ArIED61850Tester.Models.IoTesting; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class IoFatP3CaptureRegressionTests +{ + [Fact] + public void AnalogAutoCapture_LatchesStableBaselineAndFinalSettledValue_NotTransients() + { + var point = NewPoint("AN-1", FatSignalKind.Analog, FatCaptureMode.OperatorSnapshot); + var coordinator = new FatAutoCaptureCoordinator(); + long sequence = 0; + + FatAutoCaptureDecision decision = default!; + foreach (var raw in new[] { "0", "0", "0" }) + { + decision = coordinator.Observe(point, Observation(raw, ++sequence)); + if (decision.Evidence != null) + point.Runtime.SetFatValueEvidence(decision.Evidence); + } + + Assert.NotNull(point.Runtime.Value1Evidence); + Assert.Equal("0", point.Runtime.Value1Evidence!.RawValue); + Assert.Equal(FatEvidenceCaptureKind.AutomaticValue, point.Runtime.Value1Evidence.CaptureKind); + + foreach (var raw in new[] { "18.412", "43.920", "61.850", "65.702", "65.746", "65.748", "65.748", "65.748" }) + { + decision = coordinator.Observe(point, Observation(raw, ++sequence)); + if (decision.Evidence != null) + point.Runtime.SetFatValueEvidence(decision.Evidence); + } + + Assert.NotNull(point.Runtime.Value2Evidence); + Assert.Equal("65.748", point.Runtime.Value2Evidence!.RawValue); + Assert.Equal(FatEvidenceCaptureKind.AutomaticValue, point.Runtime.Value2Evidence.CaptureKind); + Assert.Equal(FatAutoCaptureStage.Complete, decision.Stage); + } + + [Fact] + public void AutoCapture_BadQualityDoesNotCreateEvidence_AndCompletePairDoesNotAutoOverwrite() + { + var point = NewPoint("AN-2", FatSignalKind.Analog, FatCaptureMode.OperatorSnapshot); + var coordinator = new FatAutoCaptureCoordinator(); + + var rejected = coordinator.Observe(point, Observation("12.3", 1, quality: "Invalid")); + Assert.Null(rejected.Evidence); + Assert.False(point.HasValue1Evidence); + + var value1 = Evidence(FatValueSlot.Value1, "10", 2, FatEvidenceCaptureKind.OperatorRecapture); + var value2 = Evidence(FatValueSlot.Value2, "20", 3, FatEvidenceCaptureKind.OperatorRecapture); + point.Runtime.SetFatValueEvidence(value1); + point.Runtime.SetFatValueEvidence(value2); + + var locked = coordinator.Observe(point, Observation("999", 4)); + + Assert.Null(locked.Evidence); + Assert.Equal(FatAutoCaptureStage.Complete, locked.Stage); + Assert.Equal(value1.EvidenceId, point.Runtime.Value1Evidence!.EvidenceId); + Assert.Equal(value2.EvidenceId, point.Runtime.Value2Evidence!.EvidenceId); + } + + [Fact] + public void GenericValueEvidence_OverridesLegacyAutomaticOnOffProjection() + { + var point = NewPoint("DI-1", FatSignalKind.Discrete, FatCaptureMode.AutomaticTransition); + var evaluator = new IoTestTransitionEvaluator(); + evaluator.StartAttempt(point, DigitalObservation(false, 1)); + evaluator.Observe(point, DigitalObservation(true, 2)); + evaluator.Observe(point, DigitalObservation(false, 3)); + + Assert.Equal("True", point.Value1Text); + Assert.Equal("False", point.Value2Text); + + var manualValue1 = Evidence(FatValueSlot.Value1, "MANUAL-V1", 4, FatEvidenceCaptureKind.OperatorRecapture); + var manualValue2 = Evidence(FatValueSlot.Value2, "MANUAL-V2", 5, FatEvidenceCaptureKind.OperatorRecapture); + point.Runtime.SetFatValueEvidence(manualValue1); + point.Runtime.SetFatValueEvidence(manualValue2); + + Assert.Equal("MANUAL-V1", point.Value1Text); + Assert.Equal("MANUAL-V2", point.Value2Text); + Assert.True(point.IsFatEvidenceComplete); + } + + [Fact] + public void RecaptureSingleSlot_PreservesOtherSlot_AndUsesOperatorRecaptureProvenance() + { + var fixture = CreateSnapshotFixture(); + using var controller = fixture.Controller; + Assert.True(controller.Start(fixture.Ied).Succeeded); + + fixture.LivePoint.Value = "10"; + fixture.LivePoint.Sequence = 1; + Assert.True(controller.CaptureOperatorSnapshot(fixture.Point, FatValueSlot.Value1).Succeeded); + fixture.LivePoint.Value = "20"; + fixture.LivePoint.Sequence = 2; + Assert.True(controller.CaptureOperatorSnapshot(fixture.Point, FatValueSlot.Value2).Succeeded); + var oldValue2 = fixture.Point.Runtime.Value2Evidence!.EvidenceId; + + fixture.LivePoint.Value = "15"; + fixture.LivePoint.Sequence = 3; + var recaptured = controller.RecaptureValues(new[] { fixture.Point }, FatValueSlot.Value1); + + Assert.True(recaptured.Succeeded, recaptured.Message); + Assert.Equal("15", fixture.Point.Value1Text); + Assert.Equal("20", fixture.Point.Value2Text); + Assert.Equal(oldValue2, fixture.Point.Runtime.Value2Evidence!.EvidenceId); + Assert.Equal(FatEvidenceCaptureKind.OperatorRecapture, fixture.Point.Runtime.Value1Evidence!.CaptureKind); + } + + [Fact] + public void PairRecapture_StagesValue1WithoutChangingCurrentPair_ThenPromotesBothOnValue2() + { + var fixture = CreateSnapshotFixture(); + using var controller = fixture.Controller; + Assert.True(controller.Start(fixture.Ied).Succeeded); + CaptureInitialPair(controller, fixture, "10", "20"); + var oldV1 = fixture.Point.Runtime.Value1Evidence!.EvidenceId; + var oldV2 = fixture.Point.Runtime.Value2Evidence!.EvidenceId; + + fixture.LivePoint.Value = "30"; + fixture.LivePoint.Sequence = 3; + var staged = controller.BeginPairRecapture(new[] { fixture.Point }); + + Assert.True(staged.Succeeded, staged.Message); + Assert.Equal(oldV1, fixture.Point.Runtime.Value1Evidence!.EvidenceId); + Assert.Equal(oldV2, fixture.Point.Runtime.Value2Evidence!.EvidenceId); + Assert.Equal("10", fixture.Point.Value1Text); + Assert.Equal("20", fixture.Point.Value2Text); + + fixture.LivePoint.Value = "40"; + fixture.LivePoint.Sequence = 4; + var committed = controller.RecaptureValues(new[] { fixture.Point }, FatValueSlot.Value2); + + Assert.True(committed.Succeeded, committed.Message); + Assert.Equal("30", fixture.Point.Value1Text); + Assert.Equal("40", fixture.Point.Value2Text); + Assert.Equal(FatEvidenceCaptureKind.OperatorRecapture, fixture.Point.Runtime.Value1Evidence!.CaptureKind); + Assert.Equal(FatEvidenceCaptureKind.OperatorRecapture, fixture.Point.Runtime.Value2Evidence!.CaptureKind); + Assert.True(controller.Stop().Succeeded); + var journal = File.ReadAllText(controller.JournalPath); + Assert.Contains("fat_value_recapture_pair_staged", journal, StringComparison.Ordinal); + Assert.Contains("fat_value_recapture_pair_commit", journal, StringComparison.Ordinal); + } + + [Fact] + public void PairRecapture_CancelLeavesPreviousCurrentPairUntouched() + { + var fixture = CreateSnapshotFixture(); + using var controller = fixture.Controller; + Assert.True(controller.Start(fixture.Ied).Succeeded); + CaptureInitialPair(controller, fixture, "10", "20"); + var oldV1 = fixture.Point.Runtime.Value1Evidence!.EvidenceId; + var oldV2 = fixture.Point.Runtime.Value2Evidence!.EvidenceId; + + fixture.LivePoint.Value = "50"; + fixture.LivePoint.Sequence = 3; + Assert.True(controller.BeginPairRecapture(new[] { fixture.Point }).Succeeded); + var cancelled = controller.CancelPairRecapture(new[] { fixture.Point }); + + Assert.True(cancelled.Succeeded, cancelled.Message); + Assert.Equal(oldV1, fixture.Point.Runtime.Value1Evidence!.EvidenceId); + Assert.Equal(oldV2, fixture.Point.Runtime.Value2Evidence!.EvidenceId); + Assert.Equal("10", fixture.Point.Value1Text); + Assert.Equal("20", fixture.Point.Value2Text); + } + + [Fact] + public void BatchJournalFailure_DoesNotPromoteAnyRecapturePointer() + { + var fixture = CreateSnapshotFixture(useFailingJournal: true); + using var controller = fixture.Controller; + Assert.True(controller.Start(fixture.Ied).Succeeded); + CaptureInitialPair(controller, fixture, "10", "20"); + var oldV1 = fixture.Point.Runtime.Value1Evidence!.EvidenceId; + var oldV2 = fixture.Point.Runtime.Value2Evidence!.EvidenceId; + fixture.FailingJournal!.FailNextBatch = true; + + fixture.LivePoint.Value = "99"; + fixture.LivePoint.Sequence = 4; + var result = controller.RecaptureValues(new[] { fixture.Point }, FatValueSlot.Value1); + + Assert.False(result.Succeeded); + Assert.Equal(oldV1, fixture.Point.Runtime.Value1Evidence!.EvidenceId); + Assert.Equal(oldV2, fixture.Point.Runtime.Value2Evidence!.EvidenceId); + Assert.Equal(IoTestSessionState.Faulted, controller.State); + } + + [Fact] + public void Recapture_PreflightRejectsRemovedRowWithoutChangingEvidence() + { + var fixture = CreateSnapshotFixture(); + using var controller = fixture.Controller; + Assert.True(controller.Start(fixture.Ied).Succeeded); + CaptureInitialPair(controller, fixture, "10", "20"); + var oldV1 = fixture.Point.Runtime.Value1Evidence!.EvidenceId; + fixture.Point.RemoveFromFat(); + fixture.LivePoint.Value = "100"; + + var result = controller.RecaptureValues(new[] { fixture.Point }, FatValueSlot.Value1); + + Assert.False(result.Succeeded); + Assert.Equal(oldV1, fixture.Point.Runtime.Value1Evidence!.EvidenceId); + } + + [Fact] + public void P3GridSource_UsesExtendedSelection_PreservesSelectedRightClick_AndOffersRecaptureMenu() + { + var source = File.ReadAllText(FindRepositoryFile("IoListTestingWindow.FatV2Ux.cs")); + + Assert.Contains("SelectionMode = DataGridSelectionMode.Extended", source, StringComparison.Ordinal); + Assert.Contains("if (row.IsSelected)", source, StringComparison.Ordinal); + Assert.Contains("SelectedItems", source, StringComparison.Ordinal); + Assert.Contains("Header = \"Recapture\"", source, StringComparison.Ordinal); + Assert.Contains("Header = \"Value 1\"", source, StringComparison.Ordinal); + Assert.Contains("Header = \"Value 2\"", source, StringComparison.Ordinal); + Assert.Contains("Header = \"Value 1 & Value 2\"", source, StringComparison.Ordinal); + Assert.Contains("BeginPairRecapture", source, StringComparison.Ordinal); + } + + private static void CaptureInitialPair( + IoTestSessionController controller, + SnapshotFixture fixture, + string value1, + string value2) + { + fixture.LivePoint.Value = value1; + fixture.LivePoint.Sequence = 1; + Assert.True(controller.CaptureOperatorSnapshot(fixture.Point, FatValueSlot.Value1).Succeeded); + fixture.LivePoint.Value = value2; + fixture.LivePoint.Sequence = 2; + Assert.True(controller.CaptureOperatorSnapshot(fixture.Point, FatValueSlot.Value2).Succeeded); + } + + private static SnapshotFixture CreateSnapshotFixture(bool useFailingJournal = false) + { + var point = NewPoint("AN-SNAPSHOT", FatSignalKind.Analog, FatCaptureMode.OperatorSnapshot); + var ied = new IoTestIedPlan + { + IedName = point.IedName, + IpAddress = point.IpAddress, + TestPoints = { point } + }; + var project = new IoTestProject + { + ProjectId = "P3-FAT", + SchemaVersion = "ARSAS-FAT-SCL-1.0", + ProjectName = "P3 FAT", + Ieds = { ied } + }; + project.InitializeRuntimeNotifications(); + + var device = new Iec61850MonitorDevice + { + DeviceId = "p3-device", + Name = ied.IedName, + SclIedName = ied.IedName, + IpAddress = ied.IpAddress, + Port = 102, + IsConnected = true, + IsMonitoring = true, + Status = "Monitoring" + }; + var livePoint = new Iec61850MonitorPoint + { + DeviceId = device.DeviceId, + DeviceName = device.Name, + IpAddress = device.IpAddress, + SignalName = point.SignalName, + IecReference = point.ObjectReference, + FunctionalConstraint = "MX", + Value = "0", + Quality = "Good", + DeviceTimestamp = "2026-09-03T06:00:00.000Z", + SourceMode = "MMS-POLL", + Sequence = 0, + Status = "Live" + }; + device.Points.Add(livePoint); + new IoTestLiveBindingService().Bind(project, new[] { device }); + + var root = Path.Combine(Path.GetTempPath(), "ARSAS.Tests", "P3", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + FailingBatchJournal? failing = null; + IoTestSessionController controller; + if (useFailingJournal) + { + controller = new IoTestSessionController( + project, + key => Resolves(key, device) ? device : null, + action => action(), + root, + journalFactory: (p, plan, sessionId, startedAt) => + { + failing = new FailingBatchJournal(IoTestEvidenceJournal.Create(root, p, plan, sessionId, startedAt)); + return failing; + }); + } + else + { + controller = new IoTestSessionController( + project, + key => Resolves(key, device) ? device : null, + action => action(), + root); + } + + return new SnapshotFixture(project, ied, point, device, livePoint, root, controller, () => failing); + } + + private static bool Resolves(string key, Iec61850MonitorDevice device) + => key.Equals(device.DeviceId, StringComparison.OrdinalIgnoreCase) || + key.Equals(device.Name, StringComparison.OrdinalIgnoreCase) || + key.Equals(device.IpAddress, StringComparison.OrdinalIgnoreCase); + + private static IoTestPointPlan NewPoint(string id, FatSignalKind kind, FatCaptureMode mode) + => new() + { + TestPointId = id, + IedName = "IED1", + IpAddress = "192.0.2.10", + SignalName = id, + ObjectReference = $"IED1LD0/GGIO1.{id}.stVal", + FunctionalConstraint = kind == FatSignalKind.Analog ? "MX" : "ST", + ExpectedOnText = "Value 1", + ExpectedOffText = "Value 2", + SignalKind = kind, + CaptureMode = mode, + WorkspaceSelected = true, + TestEnabled = true, + ImportReady = true, + BindingStatus = IoTestSignalSelectionService.SclWorkspaceAuthorityBindingStatus + }; + + private static IoTestObservation Observation(string raw, long sequence, string quality = "Good") + { + var timestamp = new DateTimeOffset(2026, 9, 3, 6, 0, 0, TimeSpan.Zero).AddMilliseconds(sequence * 100); + return new IoTestObservation( + null, + raw, + timestamp, + timestamp.AddMilliseconds(-2), + quality, + "MMS-POLL", + sequence, + 1); + } + + private static IoTestObservation DigitalObservation(bool state, long sequence) + { + var timestamp = new DateTimeOffset(2026, 9, 3, 6, 0, 0, TimeSpan.Zero).AddMilliseconds(sequence * 100); + return new IoTestObservation( + state, + state ? "True" : "False", + timestamp, + timestamp.AddMilliseconds(-2), + "Good", + "BRCB", + sequence, + 1); + } + + private static FatValueEvidence Evidence( + FatValueSlot slot, + string raw, + long sequence, + FatEvidenceCaptureKind kind) + => new( + Guid.NewGuid(), + slot, + kind, + raw, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow.AddMilliseconds(-2), + "Good", + "MMS-POLL", + sequence, + 1); + + private static string FindRepositoryFile(string relativePath) + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + throw new FileNotFoundException($"Could not locate repository file '{relativePath}' from '{AppContext.BaseDirectory}'."); + } + + private sealed record SnapshotFixture( + IoTestProject Project, + IoTestIedPlan Ied, + IoTestPointPlan Point, + Iec61850MonitorDevice Device, + Iec61850MonitorPoint LivePoint, + string Root, + IoTestSessionController Controller, + Func FailingJournalAccessor) + { + public FailingBatchJournal? FailingJournal => FailingJournalAccessor(); + } + + private sealed class FailingBatchJournal : IIoTestEvidenceJournal + { + private readonly IIoTestEvidenceJournal _inner; + + public FailingBatchJournal(IIoTestEvidenceJournal inner) => _inner = inner; + + public bool FailNextBatch { get; set; } + public string FilePath => _inner.FilePath; + public long RecordCount => _inner.RecordCount; + public string LastHash => _inner.LastHash; + + public IoTestJournalEnvelope Append(IoTestJournalEntry entry) => _inner.Append(entry); + + public IReadOnlyList AppendBatch(IEnumerable entries) + { + if (FailNextBatch) + { + FailNextBatch = false; + throw new IOException("Synthetic P3 batch journal failure."); + } + return _inner.AppendBatch(entries); + } + + public void Dispose() => _inner.Dispose(); + } +} diff --git a/tests/ARSAS.Tests/IoFatP53PartialLiveRegressionTests.cs b/tests/ARSAS.Tests/IoFatP53PartialLiveRegressionTests.cs index a9248a27b..6399a9938 100644 --- a/tests/ARSAS.Tests/IoFatP53PartialLiveRegressionTests.cs +++ b/tests/ARSAS.Tests/IoFatP53PartialLiveRegressionTests.cs @@ -11,7 +11,7 @@ public void Preparation_AllowsUsablePartialSclLiveScopeWithoutMutatingOperatorSe Assert.Contains("mayProceedWithPartialSclSelection", source, StringComparison.Ordinal); Assert.Contains("selection.Matches.Count > 0", source, StringComparison.Ordinal); - Assert.Contains("unresolvedSelectionPoints.All(IoTestSignalSelectionService.IsSclDataSetAuthority)", source, StringComparison.Ordinal); + Assert.Contains("unresolvedSelectionPoints.All(IoTestSignalSelectionService.IsDirectSclAuthority)", source, StringComparison.Ordinal); Assert.Contains("if (liveCount == 0)", source, StringComparison.Ordinal); Assert.DoesNotContain("point.TestEnabled = false", source, StringComparison.Ordinal); Assert.DoesNotContain("RemoveFromFat()", source, StringComparison.Ordinal); @@ -44,6 +44,7 @@ public void OperatorSelectedWaitingRow_RemainsIncludedAndSelectedByModelContract FunctionalConstraint = "MX", ExpectedOnText = "Value 1", ExpectedOffText = "Value 2", + WorkspaceSelected = true, TestEnabled = true, ImportReady = true }; @@ -53,6 +54,7 @@ public void OperatorSelectedWaitingRow_RemainsIncludedAndSelectedByModelContract "Waiting for safe structured primary binding.", "device-1"); + Assert.True(point.WorkspaceSelected); Assert.True(point.TestEnabled); Assert.True(point.IsIncludedInFat); Assert.Equal(IoTestLiveBindingState.NotEvaluated, point.LiveBindingState); diff --git a/tests/ARSAS.Tests/IoFatP53StructuredAnalogBindingRegressionTests.cs b/tests/ARSAS.Tests/IoFatP53StructuredAnalogBindingRegressionTests.cs index 744126ad2..f4ad51fec 100644 --- a/tests/ARSAS.Tests/IoFatP53StructuredAnalogBindingRegressionTests.cs +++ b/tests/ARSAS.Tests/IoFatP53StructuredAnalogBindingRegressionTests.cs @@ -1,6 +1,8 @@ using AR.Iec61850.Discovery; using ArIED61850Tester.Models; +using ArIED61850Tester.Models.IoTesting; using ArIED61850Tester.Services; +using ArIED61850Tester.Services.IoTesting; namespace ARSAS.Tests; @@ -30,6 +32,7 @@ public void MandatoryInventory_PreservesStaticPhaseIdentityAndUsesResolvedMagnit Assert.Equal(staticMember, signal.DisplayReference); Assert.Equal(runtimeLeaf, signal.ObjectReference); Assert.Equal("MX", signal.FunctionalConstraint); + Assert.True(signal.CanPublishAsSignal); Assert.Contains("mandatory static DataSet member", signal.Source, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("primary leaf unresolved", signal.Source, StringComparison.OrdinalIgnoreCase); } @@ -55,10 +58,99 @@ public void MandatoryInventory_DoesNotCrossStructuredPhaseBoundary() Assert.DoesNotContain("phsBC", signal.ObjectReference, StringComparison.OrdinalIgnoreCase); } + [Theory] + [InlineData("A", "phsA", "phsB", "phsC")] + [InlineData("PPV", "phsAB", "phsBC", "phsCA")] + public void MandatoryInventory_DoesNotCollapseStructuredPhaseSiblingsThroughSharedParentAlias( + string dataObjectName, + string phase1, + string phase2, + string phase3) + { + var dataObject = "IEDLD0/MMXU1." + dataObjectName; + var phases = new[] { phase1, phase2, phase3 }; + var members = phases.Select(phase => dataObject + "." + phase).ToArray(); + var attributes = phases + .SelectMany(phase => new[] + { + Attribute(dataObject + "." + phase + ".cVal.mag.f", phase + ".cVal.mag.f"), + Attribute(dataObject + "." + phase + ".q", phase + ".q", "Quality"), + Attribute(dataObject + "." + phase + ".t", phase + ".t", "Timestamp") + }) + .ToArray(); + var design = BuildDesign(dataObject, members, attributes); + var signals = new List(); + + var result = Iec61850DataSetSignalInventoryService.EnsureMandatorySignals(signals, design); + + Assert.Equal(3, result.MandatoryCatalogCount); + Assert.Equal(3, result.AddedCount); + Assert.Equal(3, signals.Count); + Assert.Equal(3, signals.Select(signal => signal.DisplayReference).Distinct(StringComparer.OrdinalIgnoreCase).Count()); + Assert.Equal(3, signals.Select(signal => signal.ObjectReference).Distinct(StringComparer.OrdinalIgnoreCase).Count()); + + foreach (var phase in phases) + { + var staticMember = dataObject + "." + phase; + var runtimeLeaf = staticMember + ".cVal.mag.f"; + var signal = Assert.Single(signals.Where(candidate => + candidate.DisplayReference.Equals(staticMember, StringComparison.OrdinalIgnoreCase))); + Assert.Equal(runtimeLeaf, signal.ObjectReference); + } + + var ied = new IoTestIedPlan + { + IedName = "IED", + IpAddress = "192.0.2.10", + IedRole = "Regression", + TestPoints = phases.Select(phase => + { + var staticMember = dataObject + "." + phase; + return new IoTestPointPlan + { + TestPointId = "THD-" + phase, + IedName = "IED", + IpAddress = "192.0.2.10", + SignalName = phase, + ObjectReference = staticMember + ".cVal.mag.f", + SourceIecReference = staticMember, + ReportDisplayReference = staticMember, + FunctionalConstraint = "MX", + ExpectedOnText = "Value 1", + ExpectedOffText = "Value 2", + ImportReady = true, + TestEnabled = true + }; + }).ToList() + }; + var device = new Iec61850MonitorDevice + { + Name = "IED", + SclIedName = "IED", + IpAddress = "192.0.2.10", + Port = 102 + }; + device.Signals.AddRange(signals); + + var selection = new IoTestSignalSelectionService().Resolve(ied, device); + + Assert.True(selection.Succeeded, selection.Message); + Assert.Equal(3, selection.Matches.Count); + Assert.Equal(3, selection.Matches.Select(match => match.Signal).Distinct().Count()); + Assert.Empty(selection.MissingPoints); + Assert.Empty(selection.AmbiguousPoints); + } + private static LiveIedModelDiscoveryDocument BuildDesign( string dataObjectReference, string memberReference, params LiveIedDataAttributeModel[] attributes) + => BuildDesign(dataObjectReference, new[] { memberReference }, attributes); + + private static LiveIedModelDiscoveryDocument BuildDesign( + string dataObjectReference, + IReadOnlyList memberReferences, + params LiveIedDataAttributeModel[] attributes) { var slash = dataObjectReference.IndexOf('/'); var domain = dataObjectReference[..slash]; @@ -105,18 +197,16 @@ private static LiveIedModelDiscoveryDocument BuildDesign( Domain = domain, LogicalNode = "LLN0", Name = "Analog", - MemberCount = 1, - Members = new[] - { + MemberCount = memberReferences.Count, + Members = memberReferences.Select((memberReference, index) => new LiveIedDataSetMemberModel { - Index = 0, + Index = index, Reference = memberReference, FunctionalConstraint = "MX", MmsReference = BuildMmsReference(memberReference), Confidence = LiveIedDiscoveryConfidenceLevel.Exact - } - } + }).ToArray() } } }; diff --git a/tests/ARSAS.Tests/IoFatParallelEvidenceSessionRegressionTests.cs b/tests/ARSAS.Tests/IoFatParallelEvidenceSessionRegressionTests.cs new file mode 100644 index 000000000..00c51cda2 --- /dev/null +++ b/tests/ARSAS.Tests/IoFatParallelEvidenceSessionRegressionTests.cs @@ -0,0 +1,257 @@ +using ArIED61850Tester.Models; +using ArIED61850Tester.Models.IoTesting; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class IoFatParallelEvidenceSessionRegressionTests +{ + [Fact] + public void TwoIeds_CanCaptureEvidenceConcurrently_WithStrictDeviceIsolation() + { + var fixture = CreateFixture(); + using var primary = NewController(fixture.Project, fixture.Devices, fixture.Root); + using var sessions = new IoTestMultiSessionCoordinator(fixture.Project, primary); + sessions.ConfigureSiblingFactory(() => NewController(fixture.Project, fixture.Devices, fixture.Root)); + + sessions.SelectContext(fixture.IedA); + var startA = sessions.Start(fixture.IedA, new[] { fixture.PointA }); + sessions.SelectContext(fixture.IedB); + var startB = sessions.Start(fixture.IedB, new[] { fixture.PointB }); + + Assert.True(startA.Succeeded, startA.Message); + Assert.True(startB.Succeeded, startB.Message); + Assert.Equal(2, sessions.ActiveSessionCount); + Assert.True(sessions.IsIedSessionActive(fixture.IedA)); + Assert.True(sessions.IsIedSessionActive(fixture.IedB)); + Assert.Same(fixture.IedB, sessions.ActiveIed); + + // Reproduce the production split route: MainWindow sends every event to the + // primary leaf, while the P2 coordinator sends it only to additional leaves. + RouteEvent(primary, sessions, Event(fixture.DeviceA, fixture.PointA, false, true, 1)); + RouteEvent(primary, sessions, Event(fixture.DeviceB, fixture.PointB, false, true, 1)); + RouteEvent(primary, sessions, Event(fixture.DeviceA, fixture.PointA, true, false, 2)); + RouteEvent(primary, sessions, Event(fixture.DeviceB, fixture.PointB, true, false, 2)); + + Assert.Equal(IoTestPointState.Passed, fixture.PointA.Runtime.State); + Assert.Equal(IoTestPointState.Passed, fixture.PointB.Runtime.State); + Assert.Equal(1, fixture.PointA.Runtime.OnEvidence!.Sequence); + Assert.Equal(2, fixture.PointA.Runtime.OffEvidence!.Sequence); + Assert.Equal(1, fixture.PointB.Runtime.OnEvidence!.Sequence); + Assert.Equal(2, fixture.PointB.Runtime.OffEvidence!.Sequence); + + // Stopping B must not seal or interrupt A's independent session/journal. + var stopB = sessions.Stop("IED B complete"); + Assert.True(stopB.Succeeded, stopB.Message); + Assert.Equal(1, sessions.ActiveSessionCount); + Assert.True(sessions.IsIedSessionActive(fixture.IedA)); + Assert.False(sessions.IsIedSessionActive(fixture.IedB)); + + sessions.SelectContext(fixture.IedA); + Assert.Same(fixture.IedA, sessions.ActiveIed); + Assert.True(sessions.CanStop); + var stopA = sessions.Stop("IED A complete"); + Assert.True(stopA.Succeeded, stopA.Message); + Assert.False(sessions.IsSessionActive); + Assert.False(sessions.HasActiveSessions); + Assert.Equal(0, sessions.ActiveSessionCount); + + var evidenceFiles = Directory.GetFiles(fixture.Root, "*.evidence.jsonl", SearchOption.AllDirectories); + Assert.Equal(2, evidenceFiles.Length); + Assert.All(evidenceFiles, path => + { + var verification = IoTestEvidenceJournal.Verify(path); + Assert.True(verification.IsValid, verification.Error); + }); + } + + [Fact] + public void SelectedContext_EditAndControlState_IsIndependentFromSiblingSession() + { + var fixture = CreateFixture(); + using var primary = NewController(fixture.Project, fixture.Devices, fixture.Root); + using var sessions = new IoTestMultiSessionCoordinator(fixture.Project, primary); + sessions.ConfigureSiblingFactory(() => NewController(fixture.Project, fixture.Devices, fixture.Root)); + + sessions.SelectContext(fixture.IedA); + Assert.True(sessions.Start(fixture.IedA, new[] { fixture.PointA }).Succeeded); + Assert.True(sessions.IsSessionActive); + Assert.True(sessions.HasActiveSessions); + Assert.True(sessions.IsSelectedSessionActive); + Assert.False(sessions.CanEditPlan); + Assert.False(sessions.CanStart); + + sessions.SelectContext(fixture.IedB); + + // IsSessionActive is selected-context state for existing UI bindings. The global + // close/export safety boundary remains HasActiveSessions/ActiveSessionCount. + Assert.False(sessions.IsSessionActive); + Assert.True(sessions.HasActiveSessions); // A is still running globally. + Assert.False(sessions.IsSelectedSessionActive); + Assert.True(sessions.CanEditPlan); + Assert.True(sessions.CanStart); + Assert.Null(sessions.ActiveIed); + + Assert.True(sessions.Start(fixture.IedB, new[] { fixture.PointB }).Succeeded); + Assert.True(sessions.IsSessionActive); + Assert.True(sessions.HasActiveSessions); + Assert.True(sessions.IsSelectedSessionActive); + Assert.False(sessions.CanEditPlan); + Assert.Equal(2, sessions.ActiveSessionCount); + + Assert.True(sessions.StopAll("test cleanup").Succeeded); + Assert.False(sessions.IsSessionActive); + Assert.False(sessions.HasActiveSessions); + } + + private static void RouteEvent( + IoTestSessionController primary, + IoTestMultiSessionCoordinator sessions, + Iec61850EventEntry entry) + { + primary.Enqueue(entry); + sessions.EnqueueAdditional(entry); + } + + private static Fixture CreateFixture() + { + var pointA = Point("A-1", "IED_A", "192.168.81.10", "IED_ALD0/GGIO1.Ind1.stVal"); + var pointB = Point("B-1", "IED_B", "192.168.81.11", "IED_BLD0/GGIO1.Ind2.stVal"); + var iedA = Ied("IED_A", "192.168.81.10", pointA); + var iedB = Ied("IED_B", "192.168.81.11", pointB); + var project = new IoTestProject + { + ProjectId = "P2-PARALLEL", + SchemaVersion = "ARSAS-FAT-IO-1.0", + ProjectName = "P2 Parallel Evidence", + SourceWorkbookName = "P2.xlsx", + SourceWorkbookSha256 = new string('a', 64), + Ieds = { iedA, iedB } + }; + project.InitializeRuntimeNotifications(); + + var deviceA = Device("device-a", iedA, pointA); + var deviceB = Device("device-b", iedB, pointB); + var devices = new[] { deviceA, deviceB }; + new IoTestLiveBindingService().Bind(project, devices); + + var root = Path.Combine(Path.GetTempPath(), "ARSAS.Tests", "P2", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + return new Fixture(project, iedA, iedB, pointA, pointB, deviceA, deviceB, devices, root); + } + + private static IoTestPointPlan Point( + string id, + string iedName, + string ipAddress, + string reference) => new() + { + TestPointId = id, + IedName = iedName, + IpAddress = ipAddress, + SignalName = id, + ObjectReference = reference, + FunctionalConstraint = "ST", + ExpectedOnText = "ON", + ExpectedOffText = "OFF", + LogicalDevice = iedName + "LD0", + LogicalNode = "GGIO1", + DataObject = "Ind", + DataAttribute = "stVal", + SourceIecReference = reference, + EventLogSearchReference = reference, + ReportDisplayReference = reference + " [ST]", + BindingStatus = "CID_DATASET_EXACT", + TestEnabled = true, + ImportReady = true + }; + + private static IoTestIedPlan Ied(string name, string ipAddress, IoTestPointPlan point) => new() + { + IedName = name, + IpAddress = ipAddress, + IedRole = "Protection IED", + TestPoints = { point } + }; + + private static Iec61850MonitorDevice Device( + string deviceId, + IoTestIedPlan ied, + IoTestPointPlan point) + { + var device = new Iec61850MonitorDevice + { + DeviceId = deviceId, + Name = ied.IedName, + SclIedName = ied.IedName, + IpAddress = ied.IpAddress, + Port = 102, + IsConnected = true, + IsMonitoring = true, + Status = "Monitoring" + }; + device.Points.Add(new Iec61850MonitorPoint + { + DeviceId = deviceId, + DeviceName = ied.IedName, + IpAddress = ied.IpAddress, + SignalName = point.SignalName, + IecReference = point.ObjectReference, + FunctionalConstraint = "ST", + Value = "False", + Quality = "Good", + DeviceTimestamp = "2026-09-02T09:00:00.000Z", + SourceMode = "BRCB", + Sequence = 0, + Status = "Live" + }); + return device; + } + + private static IoTestSessionController NewController( + IoTestProject project, + IReadOnlyCollection devices, + string root) => new( + project, + key => devices.FirstOrDefault(device => + key.Equals(device.DeviceId, StringComparison.OrdinalIgnoreCase) || + key.Equals(device.Name, StringComparison.OrdinalIgnoreCase) || + key.Equals(device.IpAddress, StringComparison.OrdinalIgnoreCase)), + action => action(), + root); + + private static Iec61850EventEntry Event( + Iec61850MonitorDevice device, + IoTestPointPlan point, + bool oldValue, + bool newValue, + long sequence) => new() + { + Sequence = sequence, + DeviceId = device.DeviceId, + PointKey = device.Points[0].PointKey, + DeviceTimestamp = new DateTimeOffset(2026, 9, 2, 9, 0, 0, TimeSpan.Zero) + .AddMilliseconds(sequence * 100) + .ToString("O"), + DeviceName = device.Name, + IpAddress = device.IpAddress, + SignalName = point.SignalName, + IecReference = point.ObjectReference, + OldValue = oldValue ? "True" : "False", + NewValue = newValue ? "True" : "False", + Quality = "Good", + SourceMode = "BRCB", + Reason = "data-change" + }; + + private sealed record Fixture( + IoTestProject Project, + IoTestIedPlan IedA, + IoTestIedPlan IedB, + IoTestPointPlan PointA, + IoTestPointPlan PointB, + Iec61850MonitorDevice DeviceA, + Iec61850MonitorDevice DeviceB, + IReadOnlyCollection Devices, + string Root); +} diff --git a/tests/ARSAS.Tests/IoFatRemoveReportScopeRegressionTests.cs b/tests/ARSAS.Tests/IoFatRemoveReportScopeRegressionTests.cs new file mode 100644 index 000000000..45d3e0aa9 --- /dev/null +++ b/tests/ARSAS.Tests/IoFatRemoveReportScopeRegressionTests.cs @@ -0,0 +1,164 @@ +using System.Text; +using ArIED61850Tester.Models.IoTesting; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class IoFatRemoveReportScopeRegressionTests +{ + [Theory] + [InlineData("ARSAS-FAT-SCL-2.0")] + [InlineData("ARSAS-FAT-IO-1.0")] + public void RemoveFromFat_IsHardExclusionForNativeReport_WithoutMutatingSharedOrTestSelection(string schemaVersion) + { + var removed = BuildPoint("REMOVE_ME", "IED1LD/GGIO1.Ind1.stVal"); + var kept = BuildPoint("KEEP_ME", "IED1LD/GGIO1.Ind2.stVal"); + var ied = new IoTestIedPlan + { + IedName = "IED1", + IpAddress = "192.0.2.10", + TestPoints = new List { removed, kept } + }; + var project = new IoTestProject + { + ProjectId = "REMOVE-REPORT-REGRESSION", + SchemaVersion = schemaVersion, + ProjectName = "Remove report regression", + Ieds = new List { ied } + }; + + removed.RemoveFromFat(); + + Assert.True(removed.WorkspaceSelected); + Assert.True(removed.TestEnabled); + Assert.False(removed.IsIncludedInFat); + + var pdf = IoFatPdfReportService.Generate( + project, + new DateTimeOffset(2026, 9, 3, 4, 30, 0, TimeSpan.Zero)); + var reportText = Encoding.ASCII.GetString(pdf); + + Assert.DoesNotContain("REMOVE_ME", reportText, StringComparison.Ordinal); + Assert.Contains("KEEP_ME", reportText, StringComparison.Ordinal); + } + + [Fact] + public void RemoveFromFat_ExcludesRowFromTestPreflight_EvenWhenTestRemainsEnabled() + { + var removed = BuildPoint("REMOVE_FROM_TEST", "IED1LD/GGIO1.Ind1.stVal"); + var kept = BuildPoint("KEEP_IN_TEST", "IED1LD/GGIO1.Ind2.stVal"); + // Make the removed row deliberately unsafe if it were still part of the test scope. + removed = CloneWithImportReady(removed, importReady: false); + removed.RemoveFromFat(); + + var ied = new IoTestIedPlan + { + IedName = "IED1", + IpAddress = "192.0.2.10", + TestPoints = new List { removed, kept } + }; + + var result = IoTestSessionPreflight.Validate(ied); + + Assert.True(removed.TestEnabled); + Assert.False(removed.IsIncludedInFat); + Assert.True(result.Succeeded); + Assert.Contains("1 shared-workspace-selected FAT signal", result.Message, StringComparison.Ordinal); + } + + [Fact] + public void RestoreToFat_ReturnsSameSharedSignalToReport_WithTestPreferencePreserved() + { + var point = BuildPoint("RESTORE_ME", "IED1LD/GGIO1.Ind1.stVal"); + var ied = new IoTestIedPlan + { + IedName = "IED1", + IpAddress = "192.0.2.10", + TestPoints = new List { point } + }; + var project = new IoTestProject + { + ProjectId = "RESTORE-REPORT-REGRESSION", + SchemaVersion = "ARSAS-FAT-SCL-2.0", + ProjectName = "Restore report regression", + Ieds = new List { ied } + }; + + point.TestEnabled = false; + point.RemoveFromFat(); + point.RestoreToFat(); + + Assert.True(point.WorkspaceSelected); + Assert.False(point.TestEnabled); + Assert.True(point.IsIncludedInFat); + + var pdf = IoFatPdfReportService.Generate( + project, + new DateTimeOffset(2026, 9, 3, 4, 31, 0, TimeSpan.Zero)); + var reportText = Encoding.ASCII.GetString(pdf); + Assert.Contains("RESTORE_ME", reportText, StringComparison.Ordinal); + } + + [Fact] + public void PrintPreview_UsesSameScopedLayoutContractAsNativePdf() + { + var source = File.ReadAllText(FindRepoFile("Services/IoTesting/IoFatReportPreviewDocumentBuilder.cs")); + var reportService = File.ReadAllText(FindRepoFile("Services/IoTesting/IoFatPdfReportService.cs")); + var scope = File.ReadAllText(FindRepoFile("Services/IoTesting/IoFatReportScope.cs")); + var autoConnect = File.ReadAllText(FindRepoFile("MainWindow.IoTesting.AutoConnect.cs")); + var contextUx = File.ReadAllText(FindRepoFile("IoListTestingWindow.ContextUx.cs")); + + Assert.Contains("IoFatPdfReportService.BuildLayout", source, StringComparison.Ordinal); + Assert.Contains("IoFatReportScope.Create(project)", reportService, StringComparison.Ordinal); + Assert.Contains("point.WorkspaceSelected && point.IsIncludedInFat", scope, StringComparison.Ordinal); + Assert.Contains("point.WorkspaceSelected &&", autoConnect, StringComparison.Ordinal); + Assert.Contains("point.IsIncludedInFat &&", autoConnect, StringComparison.Ordinal); + Assert.Contains("point.WorkspaceSelected && point.IsIncludedInFat && point.TestEnabled", contextUx, StringComparison.Ordinal); + } + + private static IoTestPointPlan BuildPoint(string signalName, string reference) + => new() + { + TestPointId = signalName, + IedName = "IED1", + IpAddress = "192.0.2.10", + SignalName = signalName, + ObjectReference = reference, + FunctionalConstraint = "ST", + ExpectedOnText = "TRUE", + ExpectedOffText = "FALSE", + WorkspaceSelected = true, + TestEnabled = true, + ImportReady = true + }; + + private static IoTestPointPlan CloneWithImportReady(IoTestPointPlan source, bool importReady) + => new() + { + TestPointId = source.TestPointId, + IedName = source.IedName, + IpAddress = source.IpAddress, + SignalName = source.SignalName, + ObjectReference = source.ObjectReference, + FunctionalConstraint = source.FunctionalConstraint, + ExpectedOnText = source.ExpectedOnText, + ExpectedOffText = source.ExpectedOffText, + WorkspaceSelected = source.WorkspaceSelected, + TestEnabled = source.TestEnabled, + ImportReady = importReady + }; + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + + throw new FileNotFoundException($"Could not locate repository file '{relativePath}' from '{AppContext.BaseDirectory}'."); + } +} diff --git a/tests/ARSAS.Tests/IoFatReportPreviewServiceTests.cs b/tests/ARSAS.Tests/IoFatReportPreviewServiceTests.cs index d50239453..047813963 100644 --- a/tests/ARSAS.Tests/IoFatReportPreviewServiceTests.cs +++ b/tests/ARSAS.Tests/IoFatReportPreviewServiceTests.cs @@ -160,15 +160,15 @@ public void PreviewKeepsProgressOnIedCardOnly() } [Fact] - public void IoFatPreparation_UsesCachedReconnect_AndReturnsOnFirstUsableLiveImage() + public void IoFatPreparation_UsesCachedReconnect_AndReturnsImmediatelyAfterMonitorStart() { var source = File.ReadAllText(FindRepoFile("MainWindow.IoTesting.AutoConnect.cs")); Assert.Contains("Fast reconnect", source, StringComparison.Ordinal); Assert.Contains("ConnectUsingSavedModelAsync", source, StringComparison.Ordinal); - Assert.Contains("SettleIoFatReportPriorityAsync", source, StringComparison.Ordinal); - Assert.Contains("Waiting for first live FAT image", source, StringComparison.Ordinal); - Assert.Contains("TimeSpan.FromMilliseconds(2500)", source, StringComparison.Ordinal); + Assert.Contains("Return control to FAT immediately", source, StringComparison.Ordinal); + Assert.DoesNotContain("SettleIoFatReportPriorityAsync", source, StringComparison.Ordinal); + Assert.DoesNotContain("TimeSpan.FromMilliseconds(2500)", source, StringComparison.Ordinal); Assert.Contains("IsReportSource", source, StringComparison.Ordinal); Assert.DoesNotContain("rebuilding the report plan once", source, StringComparison.Ordinal); Assert.DoesNotContain("TimeSpan.FromSeconds(8)", source, StringComparison.Ordinal); diff --git a/tests/ARSAS.Tests/IoFatSclAppendWorkflowRegressionTests.cs b/tests/ARSAS.Tests/IoFatSclAppendWorkflowRegressionTests.cs new file mode 100644 index 000000000..9c2cb74d7 --- /dev/null +++ b/tests/ARSAS.Tests/IoFatSclAppendWorkflowRegressionTests.cs @@ -0,0 +1,68 @@ +namespace ARSAS.Tests; + +public sealed class IoFatSclAppendWorkflowRegressionTests +{ + [Fact] + public void P04_LoadedFatRoutesSclToAppendInsteadOfWorkspaceReplacement() + { + var modeSwitch = ReadRepoFile("MainWindow.WorkspaceModeSwitch.cs"); + + Assert.Contains("Add SCL / CID to loaded FAT workspace", modeSwitch, StringComparison.Ordinal); + Assert.Contains("OpenSclForLoadedFatAppendAsync(loaded)", modeSwitch, StringComparison.Ordinal); + Assert.DoesNotContain( + "QueueIoFatWorkspaceReplacement(\n () => OpenSclFatTesting_Click", + modeSwitch, + StringComparison.Ordinal); + + // Workbook and portable project opens intentionally retain replacement semantics. + Assert.Contains("() => OpenIoListTesting_Click(this, new RoutedEventArgs())", modeSwitch, StringComparison.Ordinal); + Assert.Contains("() => OpenIoListPackage_Click(this, new RoutedEventArgs())", modeSwitch, StringComparison.Ordinal); + } + + [Fact] + public void P04_AddSclAvailabilityIsIndependentFromIedPreparationAndEvidenceSession() + { + var window = ReadRepoFile("IoListTestingWindow.xaml.cs"); + var addIed = ReadRepoFile("IoListTestingWindow.AddIed.cs"); + + Assert.Contains("public bool CanAddFatIed => !_isAddingFatIeds;", window, StringComparison.Ordinal); + Assert.DoesNotContain("public bool CanAddFatIed => !IsPreparingIed", window, StringComparison.Ordinal); + Assert.Contains("ImportAdditionalSclSourcesAsync", addIed, StringComparison.Ordinal); + Assert.Contains("AppendSclIedsToLoadedFatAsync(this, sclPaths)", addIed, StringComparison.Ordinal); + Assert.DoesNotContain("AddSclIedsToLoadedFatAsync(this, dialog.FileNames)", addIed, StringComparison.Ordinal); + } + + [Fact] + public void P04_ParsesBeforeAppendGateAndMutatesOnlyNewIeds() + { + var append = ReadRepoFile("MainWindow.IoTesting.SclAppend.cs"); + + Assert.Contains("SemaphoreSlim _ioFatSclAppendGate", append, StringComparison.Ordinal); + var parseIndex = append.IndexOf("ImportAdditionalAsync(", StringComparison.Ordinal); + var gateIndex = append.IndexOf("_ioFatSclAppendGate.WaitAsync", StringComparison.Ordinal); + Assert.True(parseIndex >= 0 && gateIndex > parseIndex, "SCL parsing must complete before the append mutation gate is acquired."); + + Assert.Contains("ReferenceEquals(window, _loadedIoFatWindow)", append, StringComparison.Ordinal); + Assert.Contains("existingIedKeys.Add", append, StringComparison.Ordinal); + Assert.Contains("window.Project.Ieds.Add(ied)", append, StringComparison.Ordinal); + Assert.Contains("SynchronizeImportedSclFatWithEngineering(window.Project, addedIeds)", append, StringComparison.Ordinal); + Assert.Contains("window.RegisterAddedIeds(addedIeds)", append, StringComparison.Ordinal); + Assert.DoesNotContain("IsPreparingIed", append, StringComparison.Ordinal); + Assert.DoesNotContain("Session.Stop", append, StringComparison.Ordinal); + } + + private static string ReadRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return File.ReadAllText(candidate).Replace("\r\n", "\n", StringComparison.Ordinal); + directory = directory.Parent; + } + + throw new FileNotFoundException( + $"Could not locate repository file '{relativePath}' from '{AppContext.BaseDirectory}'."); + } +} diff --git a/tests/ARSAS.Tests/IoFatSclProjectImportTests.cs b/tests/ARSAS.Tests/IoFatSclProjectImportTests.cs index d29aeebeb..caacf3059 100644 --- a/tests/ARSAS.Tests/IoFatSclProjectImportTests.cs +++ b/tests/ARSAS.Tests/IoFatSclProjectImportTests.cs @@ -148,6 +148,26 @@ public async Task MultiScl_ImportOrderDoesNotChangeIdentity_AndP3PersistenceKeep } } + [Fact] + public async Task IncrementalImport_PreservesExistingRuntimeWorkspaceAndAddsNextIed() + { + var root = TempDirectory(); + var first = Path.Combine(root, "relay-first.cid"); + var second = Path.Combine(root, "relay-next.cid"); + BuildSinglePointFixture("IED-FIRST", "S1", "ADD", "GGIO", "1", "Dig01", "ST", "BOOLEAN").Save(first); + BuildSinglePointFixture("IED-NEXT", "S1", "MEAS", "MMXU", "1", "Ana01", "MX", "FLOAT32").Save(second); + var importer = new IoFatSclProjectImportService(); + + await importer.ImportAsync(new[] { first }); + var additional = await importer.ImportAdditionalAsync(new[] { second }); + + Assert.True(importer.TryGetRuntimeWorkspace("IED-FIRST", string.Empty, out var firstWorkspace)); + Assert.True(importer.TryGetRuntimeWorkspace("IED-NEXT", string.Empty, out var nextWorkspace)); + Assert.NotNull(firstWorkspace); + Assert.NotNull(nextWorkspace); + Assert.Equal("IED-NEXT", Assert.Single(additional.Project.Ieds).IedName); + } + [Fact] public async Task ProductionLoader_NoStaticDataSet_ReturnsZeroRowsAndExplicitFinding() { diff --git a/tests/ARSAS.Tests/IoFatSelectionAuthorityRegressionTests.cs b/tests/ARSAS.Tests/IoFatSelectionAuthorityRegressionTests.cs index 6ee6f9fd4..cbfb57880 100644 --- a/tests/ARSAS.Tests/IoFatSelectionAuthorityRegressionTests.cs +++ b/tests/ARSAS.Tests/IoFatSelectionAuthorityRegressionTests.cs @@ -10,7 +10,8 @@ public void SnapshotAndPackageRestore_DoNotAutoDisableCompletedRows() Assert.DoesNotContain("ExcludeCompletedFromNextSession", source, StringComparison.Ordinal); Assert.DoesNotContain("point.TestEnabled = false", source, StringComparison.Ordinal); Assert.Contains("point.TestEnabled = enabled.GetBoolean();", source, StringComparison.Ordinal); - Assert.Contains("TestEnabled and FAT disposition are both operator-authored", source, StringComparison.Ordinal); + Assert.Contains("WorkspaceSelected", source, StringComparison.Ordinal); + Assert.Contains("FAT TEST scope, and FAT disposition", source, StringComparison.Ordinal); } [Fact] @@ -19,6 +20,7 @@ public void CleanRetest_ClearsEvidenceButNeverChangesOperatorSelection() var source = Read("Services/IoTesting/IoFatCleanSessionService.cs"); Assert.DoesNotContain("point.TestEnabled =", source, StringComparison.Ordinal); + Assert.DoesNotContain("point.WorkspaceSelected =", source, StringComparison.Ordinal); Assert.Contains("runtime.OnEvidence = null;", source, StringComparison.Ordinal); Assert.Contains("runtime.OffEvidence = null;", source, StringComparison.Ordinal); Assert.Contains("Selection belongs to the operator", source, StringComparison.Ordinal); @@ -31,7 +33,7 @@ public void StartAndContinuation_NeverRewriteCheckboxStateAndArmOnlyLiveRows() Assert.DoesNotContain("point.TestEnabled = false", source, StringComparison.Ordinal); Assert.DoesNotContain("point.TestEnabled = true", source, StringComparison.Ordinal); - Assert.Contains("point.IsIncludedInFat && point.TestEnabled && point.ImportReady", source, StringComparison.Ordinal); + Assert.Contains("point.WorkspaceSelected && point.IsIncludedInFat && point.TestEnabled && point.ImportReady", source, StringComparison.Ordinal); Assert.Contains("point.LiveBindingState == IoTestLiveBindingState.LivePointReady", source, StringComparison.Ordinal); Assert.Contains("Session.Start(selectedIed, liveCaptureScope)", source, StringComparison.Ordinal); Assert.Contains("checkbox/disposition unchanged", source, StringComparison.Ordinal); diff --git a/tests/ARSAS.Tests/IoFatSharedWorkspacePersistenceRegressionTests.cs b/tests/ARSAS.Tests/IoFatSharedWorkspacePersistenceRegressionTests.cs new file mode 100644 index 000000000..753443a2e --- /dev/null +++ b/tests/ARSAS.Tests/IoFatSharedWorkspacePersistenceRegressionTests.cs @@ -0,0 +1,191 @@ +using System.Security.Cryptography; +using ArIED61850Tester.Models.IoTesting; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class IoFatSharedWorkspacePersistenceRegressionTests +{ + [Fact] + public async Task SameSclReopen_RestoresManualWorkspaceRow_SelectionTestScopeAndFatDispositionIndependently() + { + var root = TempDirectory(); + var source = Path.Combine(root, "manual.cid"); + await File.WriteAllBytesAsync(source, new byte[] { 0x53, 0x43, 0x4C, 0x2D, 0x31 }); + var sourceSha = Hash(source); + var sourceInputs = new[] { new IoFatSourceInput(source, IoFatSourceKinds.Scl) }; + var projectsRoot = Path.Combine(root, "projects"); + var evidenceRoot = Path.Combine(root, "evidence"); + + var manual = ManualPoint(sourceSha); + manual.WorkspaceSelected = false; + manual.TestEnabled = false; + manual.RemoveFromFat(); + + var original = SclProject(manual); + var originalSession = Session(original, evidenceRoot); + var opened = await IoTestWorkspacePersistence.OpenSourcesAsync( + original, + originalSession, + sourceInputs, + projectsRoot, + evidenceRoot); + using (originalSession) + using (opened.Workspace) + opened.Workspace.SaveNow(); + + // A fresh direct-SCL importer does not fabricate manual non-DataSet rows. The + // same-source workspace continuation must re-materialize the persisted manual row + // before restoring its three independent operator authorities. + var fresh = SclProject(); + var restored = await IoTestWorkspaceBootstrapService.OpenSourcesAsync( + fresh, + sourceInputs, + projectsRoot, + evidenceRoot, + Session); + + using (restored.Session) + using (restored.Workspace) + { + Assert.True(restored.RestoredProgress); + var point = Assert.Single(restored.Project.Ieds[0].TestPoints); + Assert.Equal(manual.TestPointId, point.TestPointId); + Assert.Equal(IoTestSignalSelectionService.SclWorkspaceAuthorityBindingStatus, point.BindingStatus); + Assert.Equal(sourceSha, point.SignalAddress); + Assert.False(point.WorkspaceSelected); + Assert.False(point.TestEnabled); + Assert.False(point.IsIncludedInFat); + } + } + + [Fact] + public async Task SameSclReopen_RestoresStaticWorkspaceSelection_WithoutChangingFatTestOrDisposition() + { + var root = TempDirectory(); + var source = Path.Combine(root, "static.cid"); + await File.WriteAllBytesAsync(source, new byte[] { 0x53, 0x43, 0x4C, 0x2D, 0x32 }); + var sourceSha = Hash(source); + var sourceInputs = new[] { new IoFatSourceInput(source, IoFatSourceKinds.Scl) }; + var projectsRoot = Path.Combine(root, "projects"); + var evidenceRoot = Path.Combine(root, "evidence"); + + var originalPoint = StaticPoint(sourceSha); + originalPoint.WorkspaceSelected = false; + originalPoint.TestEnabled = true; + var original = SclProject(originalPoint); + var originalSession = Session(original, evidenceRoot); + var opened = await IoTestWorkspacePersistence.OpenSourcesAsync( + original, + originalSession, + sourceInputs, + projectsRoot, + evidenceRoot); + using (originalSession) + using (opened.Workspace) + opened.Workspace.SaveNow(); + + // Fresh import defaults the same static DataSet member selected. Continuation must + // restore the Engineering/shared selection without coupling it to FAT TEST state. + var freshPoint = StaticPoint(sourceSha); + freshPoint.WorkspaceSelected = true; + freshPoint.TestEnabled = true; + var restored = await IoTestWorkspaceBootstrapService.OpenSourcesAsync( + SclProject(freshPoint), + sourceInputs, + projectsRoot, + evidenceRoot, + Session); + + using (restored.Session) + using (restored.Workspace) + { + var point = Assert.Single(restored.Project.Ieds[0].TestPoints); + Assert.False(point.WorkspaceSelected); + Assert.True(point.TestEnabled); + Assert.True(point.IsIncludedInFat); + } + } + + private static IoTestPointPlan ManualPoint(string sourceSha) + => new() + { + TestPointId = "scl-manual-0123456789abcdef0123", + IedName = "IED1", + IpAddress = "192.0.2.10", + SignalName = "Manual GGIO indication", + ObjectReference = "IED1LD/GGIO1.Ind1.stVal", + FunctionalConstraint = "ST", + ExpectedOnText = "TRUE", + ExpectedOffText = "FALSE", + DataType = "BOOLEAN", + SignalAddress = sourceSha, + SourceIecReference = "IED1LD/GGIO1.Ind1.stVal", + ReportDisplayReference = "IED1LD/GGIO1.Ind1.stVal", + EventLogSearchReference = "IED1LD/GGIO1.Ind1.stVal", + SignalKind = FatSignalKind.Discrete, + CaptureMode = FatCaptureMode.AutomaticTransition, + WorkspaceSelected = true, + TestEnabled = true, + ImportReady = true, + BindingStatus = IoTestSignalSelectionService.SclWorkspaceAuthorityBindingStatus, + BindingEvidence = "Shared SCL workspace authority" + }; + + private static IoTestPointPlan StaticPoint(string sourceSha) + => new() + { + TestPointId = "scl-static-0001", + IedName = "IED1", + IpAddress = "192.0.2.10", + SignalName = "Static GGIO indication", + ObjectReference = "IED1LD/GGIO1.Ind2.stVal", + FunctionalConstraint = "ST", + ExpectedOnText = "TRUE", + ExpectedOffText = "FALSE", + DataType = "BOOLEAN", + SignalAddress = sourceSha, + DataSetName = "IED1LD/LLN0.Events", + SourceIecReference = "IED1LD/GGIO1.Ind2.stVal", + ReportDisplayReference = "IED1LD/GGIO1.Ind2.stVal", + EventLogSearchReference = "IED1LD/GGIO1.Ind2.stVal", + SourceRow = 1, + SignalKind = FatSignalKind.Discrete, + CaptureMode = FatCaptureMode.AutomaticTransition, + WorkspaceSelected = true, + TestEnabled = true, + ImportReady = true, + BindingStatus = IoTestSignalSelectionService.SclDataSetAuthorityBindingStatus, + BindingEvidence = "Static SCL DataSet authority" + }; + + private static IoTestProject SclProject(params IoTestPointPlan[] points) + => new() + { + ProjectId = "SHARED-SCL-WORKSPACE", + SchemaVersion = "ARSAS-FAT-SCL-1.0", + ProjectName = "Shared SCL workspace regression", + Ieds = new List + { + new() + { + IedName = "IED1", + IpAddress = "192.0.2.10", + TestPoints = points.ToList() + } + } + }; + + private static IoTestSessionController Session(IoTestProject project, string evidenceRoot) + => new(project, _ => null, action => action(), evidenceRoot); + + private static string Hash(string path) + => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path))).ToLowerInvariant(); + + private static string TempDirectory() + { + var path = Path.Combine(Path.GetTempPath(), "ARSAS.Tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + return path; + } +} diff --git a/tests/ARSAS.Tests/IoFatStructuredMmsAliasRegressionTests.cs b/tests/ARSAS.Tests/IoFatStructuredMmsAliasRegressionTests.cs new file mode 100644 index 000000000..b7e5d8b9e --- /dev/null +++ b/tests/ARSAS.Tests/IoFatStructuredMmsAliasRegressionTests.cs @@ -0,0 +1,116 @@ +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class IoFatStructuredMmsAliasRegressionTests +{ + [Fact] + public void CachedStructuredMmsContainerAlias_CannotAbsorbDistinctPhaseMembers() + { + const string dataObject = "IEDLD0/I_MHAI1.ThdA"; + const string sharedStructuredMms = "IEDLD0/I_MHAI1$MX$ThdA"; + var phases = new[] { "phsA", "phsB", "phsC" }; + var members = phases.Select(phase => dataObject + "." + phase).ToArray(); + var attributes = phases.Select(phase => new LiveIedDataAttributeModel + { + ObjectReference = dataObject + "." + phase + ".cVal.mag.f", + AttributePath = phase + ".cVal.mag.f", + FunctionalConstraint = "MX", + // Reproduce the dangerous cache/report shape: several scalar semantic + // descendants can be observed through one structured MMS container alias. + MmsReference = sharedStructuredMms, + MmsItemName = "I_MHAI1$MX$ThdA", + SclBType = "FLOAT32", + MmsType = "floating-point", + Source = "Regression structured MMS container", + TypeSource = "Regression", + TypeConfidence = LiveIedDiscoveryConfidenceLevel.Exact + }).ToArray(); + + var model = new LiveIedModelDiscoveryDocument + { + Source = "SclWorkspace", + IedName = "IED", + LogicalDevices = new[] + { + new LiveIedLogicalDeviceModel + { + MmsDomain = "IEDLD0", + Inst = "LD0", + LogicalNodes = new[] + { + new LiveIedLogicalNodeModel + { + Name = "I_MHAI1", + LnClass = "MHAI", + LnInst = "1", + DataObjects = new[] + { + new LiveIedDataObjectModel + { + Reference = dataObject, + Name = "ThdA", + InferredCdc = "WYE", + Attributes = attributes + } + } + } + } + } + }, + DataSets = new[] + { + new LiveIedDataSetModel + { + Reference = "IEDLD0/LLN0.Analog", + Domain = "IEDLD0", + LogicalNode = "LLN0", + Name = "Analog", + MemberCount = members.Length, + Members = members.Select((member, index) => new LiveIedDataSetMemberModel + { + Index = index, + Reference = member, + FunctionalConstraint = "MX", + MmsReference = "IEDLD0/I_MHAI1$MX$ThdA$" + phases[index], + Confidence = LiveIedDiscoveryConfidenceLevel.Exact + }).ToArray() + } + } + }; + + // Fast reconnect can already contain a cached structured parent/container row. + // It must remain separate from the static phase members imported for FAT. + var signals = new List + { + new() + { + Name = "ThdA", + ObjectReference = sharedStructuredMms, + DisplayReference = sharedStructuredMms, + FunctionalConstraint = "MX", + DataType = "structure", + Source = "Saved model structured container" + } + }; + + var result = Iec61850DataSetSignalInventoryService.EnsureMandatorySignals(signals, model); + + Assert.Equal(3, result.MandatoryCatalogCount); + Assert.Equal(3, result.AddedCount); + Assert.Equal(4, signals.Count); + Assert.Contains(signals, signal => signal.ObjectReference == sharedStructuredMms); + + foreach (var phase in phases) + { + var staticMember = dataObject + "." + phase; + var runtimeLeaf = staticMember + ".cVal.mag.f"; + var signal = Assert.Single(signals.Where(candidate => + candidate.DisplayReference.Equals(staticMember, StringComparison.OrdinalIgnoreCase))); + Assert.Equal(runtimeLeaf, signal.ObjectReference); + Assert.NotEqual(sharedStructuredMms, signal.ObjectReference); + } + } +} diff --git a/tests/ARSAS.Tests/IoFatTimestampTooltipTests.cs b/tests/ARSAS.Tests/IoFatTimestampTooltipTests.cs index 4811af14d..bd6f42efc 100644 --- a/tests/ARSAS.Tests/IoFatTimestampTooltipTests.cs +++ b/tests/ARSAS.Tests/IoFatTimestampTooltipTests.cs @@ -42,4 +42,40 @@ public void TimestampHover_ShowsRoundedDisplayAndDecodedFullPrecision() Assert.Contains("Quality: Good", tooltip, StringComparison.Ordinal); Assert.Contains("Source: BRCB", tooltip, StringComparison.Ordinal); } + + [Fact] + public void SnapshotTimestampHover_ShowsRoundedDisplayAndDecodedFullPrecision() + { + var timestamp = new DateTimeOffset(2026, 9, 3, 12, 43, 16, TimeSpan.FromHours(7)) + .AddTicks(9_215_108); + var evidence = new FatValueEvidence( + Guid.NewGuid(), + FatValueSlot.Value1, + FatEvidenceCaptureKind.OperatorSnapshot, + "12.345", + timestamp.AddMilliseconds(2), + timestamp, + "good", + "ARIEC Hybrid: StaticBrcb", + 7, + 3); + + var method = typeof(IoTestPointRuntime).GetMethod( + "BuildFatEvidenceToolTip", + BindingFlags.NonPublic | BindingFlags.Static) + ?? throw new MissingMethodException(nameof(IoTestPointRuntime), "BuildFatEvidenceToolTip"); + var tooltip = (string)method.Invoke(null, [evidence, "Value 1"])!; + + Assert.Contains("Value 1: 12.345", tooltip, StringComparison.Ordinal); + Assert.Contains("Displayed (rounded to nearest ms): 2026-09-03 12:43:16.922 +07:00", tooltip, StringComparison.Ordinal); + Assert.Contains( + "Decoded IED timestamp (full precision): " + timestamp.ToString("O", CultureInfo.InvariantCulture), + tooltip, + StringComparison.Ordinal); + Assert.DoesNotContain("16.9220000", tooltip, StringComparison.Ordinal); + Assert.Contains("ARSAS capture (full precision):", tooltip, StringComparison.Ordinal); + Assert.Contains("Quality: good", tooltip, StringComparison.Ordinal); + Assert.Contains("Source: ARIEC Hybrid: StaticBrcb", tooltip, StringComparison.Ordinal); + Assert.Contains("Capture: OperatorSnapshot", tooltip, StringComparison.Ordinal); + } } diff --git a/tests/ARSAS.Tests/IoFatV2UiLockRegressionTests.cs b/tests/ARSAS.Tests/IoFatV2UiLockRegressionTests.cs index 5763bc463..668dd4c3a 100644 --- a/tests/ARSAS.Tests/IoFatV2UiLockRegressionTests.cs +++ b/tests/ARSAS.Tests/IoFatV2UiLockRegressionTests.cs @@ -1,19 +1,75 @@ +using System.Globalization; +using ArIED61850Tester.Models.IoTesting; + namespace ARSAS.Tests; public sealed class IoFatV2UiLockRegressionTests { [Fact] - public void RuntimeGrid_LocksPlanMutationButKeepsOperatorCaptureAvailable() + public void RuntimeGrid_LocksOnlyTheIedThatOwnsPreparationOrEvidenceScope() { var source = File.ReadAllText(FindRepoFile("IoListTestingWindow.FatV2Ux.cs")); - Assert.Contains("DataContext.CanEditPlan", source, StringComparison.Ordinal); + Assert.DoesNotContain("DataContext.CanEditPlan", source, StringComparison.Ordinal); + Assert.Contains("public bool SelectedCanEditPlan => SelectedIed is not null && CanEditIedPlan(SelectedIed);", source, StringComparison.Ordinal); + Assert.Contains("!ied.IsPreparing", source, StringComparison.Ordinal); + Assert.Contains("!Session.IsIedSessionActive(ied)", source, StringComparison.Ordinal); + + // The TEST checkbox binds directly to the selected IED and selected-session + // projection so the owning IED locks synchronously; another IED remains editable + // without waiting for a dispatcher-level window-property refresh. + Assert.Contains("var editability = new MultiBinding", source, StringComparison.Ordinal); + Assert.Contains("DataContext.SelectedIed", source, StringComparison.Ordinal); + Assert.Contains("DataContext.Session.ActiveIed", source, StringComparison.Ordinal); + Assert.Contains("DataContext.Session.IsSessionActive", source, StringComparison.Ordinal); + Assert.Contains("DataContext.SelectedIed.IsPreparing", source, StringComparison.Ordinal); + Assert.Contains("IoFatSelectedIedPlanEditabilityConverter", source, StringComparison.Ordinal); + Assert.Contains("!ReferenceEquals(activeIed, selectedIed)", source, StringComparison.Ordinal); + + Assert.Contains("if (!CanEditPointPlan(point))", source, StringComparison.Ordinal); Assert.Contains("point.RemoveFromFat();", source, StringComparison.Ordinal); - Assert.Contains("if (!CanEditPlan)", source, StringComparison.Ordinal); + + // Operator snapshot capture remains the intentional evidence action and is not + // disabled by the TEST-checkbox edit lock. Assert.Contains("CanCaptureOperatorSnapshot", source, StringComparison.Ordinal); Assert.Contains("Session.CaptureOperatorSnapshot(point, slot)", source, StringComparison.Ordinal); } + [Fact] + public void EditabilityConverter_AllowsOtherIedWhileActiveOwnerRemainsLocked() + { + var activeIed = new IoTestIedPlan { IedName = "IED-A", IpAddress = "192.0.2.10" }; + var otherIed = new IoTestIedPlan { IedName = "IED-B", IpAddress = "192.0.2.11" }; + var converter = new ArIED61850Tester.IoFatSelectedIedPlanEditabilityConverter(); + + bool Evaluate(IoTestIedPlan selected, IoTestIedPlan? active, bool sessionActive, bool preparing) + => Assert.IsType(converter.Convert( + new object[] { selected, active!, sessionActive, preparing }, + typeof(bool), + null!, + CultureInfo.InvariantCulture)); + + Assert.False(Evaluate(activeIed, activeIed, sessionActive: true, preparing: false)); + Assert.True(Evaluate(otherIed, activeIed, sessionActive: true, preparing: false)); + Assert.False(Evaluate(otherIed, activeIed, sessionActive: true, preparing: true)); + Assert.True(Evaluate(activeIed, active: null, sessionActive: false, preparing: false)); + } + + [Fact] + public void RemovedSignals_RestoreLockIsEvaluatedPerOwningIed() + { + var workspace = File.ReadAllText(FindRepoFile("IoListTestingWindow.FatV2Ux.cs")); + var removed = File.ReadAllText(FindRepoFile("RemovedFatSignalsWindow.cs")); + + Assert.Contains("!Session.IsIedSessionActive(ied)", workspace, StringComparison.Ordinal); + Assert.Contains("new RemovedFatSignalsWindow(Project, CanEditPointPlan)", workspace, StringComparison.Ordinal); + Assert.Contains("Func? canEditPoint", removed, StringComparison.Ordinal); + Assert.Contains("canEditPoint?.Invoke(point) ?? true", removed, StringComparison.Ordinal); + Assert.Contains("new Binding(nameof(RemovedSignalRow.CanEdit))", removed, StringComparison.Ordinal); + Assert.Contains("_view.Cast().Where(row => row.CanEdit)", removed, StringComparison.Ordinal); + Assert.Contains("_rows.Where(row => row.CanEdit && row.IsSelected)", removed, StringComparison.Ordinal); + } + private static string FindRepoFile(string relativePath) { DirectoryInfo? directory = new(AppContext.BaseDirectory); @@ -28,4 +84,4 @@ private static string FindRepoFile(string relativePath) throw new FileNotFoundException( $"Could not locate repository file '{relativePath}' from '{AppContext.BaseDirectory}'."); } -} \ No newline at end of file +} diff --git a/tests/ARSAS.Tests/IoFatV2WorkspaceRegressionTests.cs b/tests/ARSAS.Tests/IoFatV2WorkspaceRegressionTests.cs index 0f11d5619..3042e168a 100644 --- a/tests/ARSAS.Tests/IoFatV2WorkspaceRegressionTests.cs +++ b/tests/ARSAS.Tests/IoFatV2WorkspaceRegressionTests.cs @@ -62,6 +62,36 @@ public void OperatorSnapshot_JournalFailure_DoesNotPromoteCurrentEvidencePointer Assert.Equal(IoTestSessionState.Faulted, controller.State); } + [Fact] + public void OperatorSnapshot_LiveRefreshDoesNotRaiseSessionProgressForEverySample() + { + var fixture = ManualFixture(); + using var controller = fixture.Controller; + Assert.True(controller.Start(fixture.Ied).Succeeded); + + var sessionNotifications = 0; + controller.PropertyChanged += (_, _) => sessionNotifications++; + controller.Enqueue(new Iec61850EventEntry + { + Sequence = 2, + DeviceId = fixture.Device.DeviceId, + PointKey = fixture.LivePoint.PointKey, + DeviceTimestamp = "2026-09-01T03:00:00.250Z", + DeviceName = fixture.Device.Name, + IpAddress = fixture.Device.IpAddress, + SignalName = fixture.LivePoint.SignalName, + IecReference = fixture.LivePoint.IecReference, + OldValue = "12.34", + NewValue = "12.35", + Quality = "Good", + SourceMode = "BRCB", + Reason = "periodic-refresh" + }); + + Assert.Equal("12.35", fixture.Point.Runtime.CurrentValue); + Assert.Equal(0, sessionNotifications); + } + [Fact] public void RemoveRestore_PreservesCheckboxAndCurrentEvidence() { @@ -307,4 +337,4 @@ public IReadOnlyList AppendBatch(IEnumerable entries)", journal, StringComparison.Ordinal); Assert.Contains("FlushDurable();", journal, StringComparison.Ordinal); - // Preserve #150's immediate TRUE-edge scheduling while retaining P0's bounded - // drain/yield behavior for bursts. The first dispatch is DataBind; a reschedule - // requested while that drain is active is intentionally demoted to Background. + // Every bounded drain yields to operator input. Edge samples remain in the + // dedicated lossless queue, so responsiveness does not weaken FAT evidence. Assert.Contains("DispatchIoFatEvidence", main, StringComparison.Ordinal); - Assert.Contains("Volatile.Read(ref _ioFatEvidenceDrainDispatchActive) == 0", main, StringComparison.Ordinal); - Assert.Contains("DispatcherPriority.DataBind", main, StringComparison.Ordinal); Assert.Contains("DispatcherPriority.Background", main, StringComparison.Ordinal); - Assert.Contains("Interlocked.Increment(ref _ioFatEvidenceDrainDispatchActive)", main, StringComparison.Ordinal); - Assert.Contains("Interlocked.Decrement(ref _ioFatEvidenceDrainDispatchActive)", main, StringComparison.Ordinal); + Assert.DoesNotContain("DispatcherPriority.DataBind", main, StringComparison.Ordinal); + Assert.Contains("_pendingEdgeSnapshots", controller, StringComparison.Ordinal); } [Fact] @@ -78,6 +75,19 @@ public void NewCleanFatSession_ArchivesOldJournalsAndClearsEveryActiveTimestampS Assert.Contains("Session.ResetForCleanRetest()", ui, StringComparison.Ordinal); } + [Fact] + public void FatUiRefresh_DoesNotRefilterOrAutosaveForEveryLiveSample() + { + var ux = Read("IoListTestingWindow.FatV2Ux.cs"); + var persistence = Read("Services/IoTesting/IoTestProjectPersistenceService.cs"); + var controller = Read("Services/IoTesting/IoTestSessionController.cs"); + + Assert.Contains("RefreshFatV2WorkspaceUx(bool refreshRows = false)", ux, StringComparison.Ordinal); + Assert.Contains("if (refreshRows)", ux, StringComparison.Ordinal); + Assert.Contains("nameof(IoTestPointRuntime.CurrentValue)", persistence, StringComparison.Ordinal); + Assert.Contains("if (progressChanged)", controller, StringComparison.Ordinal); + } + private static string Read(string relativePath) => File.ReadAllText(FindRepoFile(relativePath)).Replace("\r\n", "\n", StringComparison.Ordinal); diff --git a/tests/ARSAS.Tests/IoTestSessionControllerTests.cs b/tests/ARSAS.Tests/IoTestSessionControllerTests.cs index 62c04f015..83738b455 100644 --- a/tests/ARSAS.Tests/IoTestSessionControllerTests.cs +++ b/tests/ARSAS.Tests/IoTestSessionControllerTests.cs @@ -21,7 +21,7 @@ public void LiveOffOnOffSequence_KeepsCaptureRunningUntilOperatorStopAndPersists Assert.Equal(IoTestSessionState.Running, controller.State); Assert.NotNull(fixture.Point.Runtime.OnEvidence); Assert.NotNull(fixture.Point.Runtime.OffEvidence); - Assert.Contains("Capture remains running", controller.StatusText, StringComparison.OrdinalIgnoreCase); + Assert.Contains("explicit Recapture", controller.StatusText, StringComparison.OrdinalIgnoreCase); var stopped = controller.Stop(); @@ -178,7 +178,7 @@ public void ExplicitCaptureScope_RejectsUncheckedPointRatherThanOverridingOperat Assert.False(result.Succeeded); Assert.False(fixture.Point.TestEnabled); Assert.Equal(IoTestSessionState.Idle, controller.State); - Assert.Contains("not selected", result.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("TEST disabled", result.Message, StringComparison.OrdinalIgnoreCase); } [Fact] diff --git a/tests/ARSAS.Tests/IoTestSessionPointUpdateTests.cs b/tests/ARSAS.Tests/IoTestSessionPointUpdateTests.cs index 37014a077..24dbb498a 100644 --- a/tests/ARSAS.Tests/IoTestSessionPointUpdateTests.cs +++ b/tests/ARSAS.Tests/IoTestSessionPointUpdateTests.cs @@ -79,7 +79,7 @@ public void GoodQualityPointUpdate_RecoversRejectedInitialBaselineAndKeepsCaptur Assert.True(started.Succeeded, started.Message); Assert.Equal(IoTestPointState.Passed, point.Runtime.State); Assert.Equal(IoTestSessionState.Running, controller.State); - Assert.Contains("Capture remains running", controller.StatusText, StringComparison.OrdinalIgnoreCase); + Assert.Contains("explicit Recapture", controller.StatusText, StringComparison.OrdinalIgnoreCase); Assert.True(controller.Stop().Succeeded); var journal = File.ReadAllText(controller.JournalPath); Assert.Contains("waiting for good-quality baseline", journal, StringComparison.OrdinalIgnoreCase); diff --git a/tests/ARSAS.Tests/IoTestingUiContractTests.cs b/tests/ARSAS.Tests/IoTestingUiContractTests.cs index c6d9a7a5c..1ad83cc40 100644 --- a/tests/ARSAS.Tests/IoTestingUiContractTests.cs +++ b/tests/ARSAS.Tests/IoTestingUiContractTests.cs @@ -65,6 +65,40 @@ public void IoTestingWindow_ExposesCalmExportActionsWithoutPathNoise() .First(value => value == "{Binding Storage.SnapshotPath}")); } + [Fact] + public void IoTestingWindow_AllowsAddingAnIedDuringIndependentIedWorkflows() + { + var document = XDocument.Load(FindRepoFile("IoListTestingWindow.xaml")); + XNamespace presentation = "http://schemas.microsoft.com/winfx/2006/xaml/presentation"; + var addButton = document + .Descendants(presentation + "Button") + .Single(button => (string?)button.Attribute("Click") == "AddFatIedFromScl_Click"); + + Assert.Equal("{Binding CanAddFatIed}", (string?)addButton.Attribute("IsEnabled")); + + var window = File.ReadAllText(FindRepoFile("IoListTestingWindow.xaml.cs")); + var addFlow = File.ReadAllText(FindRepoFile("IoListTestingWindow.AddIed.cs")); + Assert.Contains("public bool CanAddFatIed => !_isAddingFatIeds", window, StringComparison.Ordinal); + Assert.DoesNotContain("CanAddFatIed => !IsPreparingIed", window, StringComparison.Ordinal); + Assert.Contains("if (!CanAddFatIed", addFlow, StringComparison.Ordinal); + Assert.Contains("SetAddingFatIeds(true)", addFlow, StringComparison.Ordinal); + Assert.Contains("SetAddingFatIeds(false)", addFlow, StringComparison.Ordinal); + Assert.Contains("AppendSclIedsToLoadedFatAsync(this, sclPaths)", addFlow, StringComparison.Ordinal); + } + + [Fact] + public void WindowsExecutableAndProcessDeclareTheArsasTaskbarIconIdentity() + { + var project = File.ReadAllText(FindRepoFile("ArIED61850Tester.csproj")); + var app = File.ReadAllText(FindRepoFile("App.xaml.cs")); + var identity = File.ReadAllText(FindRepoFile("App.WindowsIdentity.cs")); + + Assert.Contains("Assets\\app-icon.ico", project, StringComparison.Ordinal); + Assert.Contains("WindowsApplicationIdentity.Apply();", app, StringComparison.Ordinal); + Assert.Contains("SetCurrentProcessExplicitAppUserModelID", identity, StringComparison.Ordinal); + Assert.True(File.Exists(FindRepoFile("Assets/app-icon.ico"))); + } + [Fact] public void IoTestingWindow_ConnectsWithoutLockingExplorerNavigationAndPreservesOperatorSelection() { @@ -136,9 +170,9 @@ public void IoFatAutomaticPreparation_UsesFastCommissioningAcquisitionWithoutPro Assert.Contains("ConnectAndConfigureDeviceAsync", source, StringComparison.Ordinal); Assert.Contains("StartDeviceMonitorAsync", source, StringComparison.Ordinal); Assert.Contains("deterministic fast MMS", source, StringComparison.OrdinalIgnoreCase); - Assert.Contains("first live FAT image", source, StringComparison.OrdinalIgnoreCase); - Assert.Contains("SettleIoFatReportPriorityAsync", source, StringComparison.Ordinal); - Assert.Contains("TimeSpan.FromMilliseconds(2500)", source, StringComparison.Ordinal); + Assert.Contains("Return control to FAT immediately", source, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("SettleIoFatReportPriorityAsync", source, StringComparison.Ordinal); + Assert.DoesNotContain("TimeSpan.FromMilliseconds(2500)", source, StringComparison.Ordinal); Assert.DoesNotContain("rebuilding the report plan once", source, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("TimeSpan.FromSeconds(8)", source, StringComparison.Ordinal); Assert.DoesNotContain("TimeSpan.FromSeconds(10)", source, StringComparison.Ordinal); @@ -301,4 +335,4 @@ private static string FindRepoFile(string relativePath) throw new FileNotFoundException( $"Could not locate repository file '{relativePath}' from '{AppContext.BaseDirectory}'."); } -} \ No newline at end of file +} diff --git a/tests/ARSAS.Tests/SchemaSafeAggregateProjectionRegressionTests.cs b/tests/ARSAS.Tests/SchemaSafeAggregateProjectionRegressionTests.cs new file mode 100644 index 000000000..5dda24381 --- /dev/null +++ b/tests/ARSAS.Tests/SchemaSafeAggregateProjectionRegressionTests.cs @@ -0,0 +1,255 @@ +using AR.Iec61850.Discovery; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class SchemaSafeAggregateProjectionRegressionTests +{ + [Fact] + public void ThdA_ReadPlan_UsesExactNamedPhases_IndependentOfAttributeOrder() + { + const string parent = "IEDLD0/I_MHAI1.ThdA"; + var model = Model(DataObject( + parent, + "ThdA", + // Deliberately scrambled with unrelated numeric leaves first. + Attribute(parent + ".noise.f", "noise.f"), + Attribute(parent + ".phsC.cVal.mag.f", "phsC.cVal.mag.f"), + Attribute(parent + ".phsA.instCVal.mag.f", "phsA.instCVal.mag.f"), + Attribute(parent + ".phsB.cVal.mag.f", "phsB.cVal.mag.f"), + Attribute(parent + ".phsA.cVal.mag.f", "phsA.cVal.mag.f"), + Attribute(parent + ".phsC.instCVal.mag.f", "phsC.instCVal.mag.f"))); + + var ok = SchemaSafeAggregateProjectionService.TryBuildReadPlan( + model, + parent, + out var plan, + out var status); + + Assert.True(ok, status); + Assert.Equal("ThreePhaseThd", plan.Kind); + Assert.Equal(new[] { "A", "B", "C" }, plan.Leaves.Select(leaf => leaf.Label)); + Assert.Equal( + new[] + { + parent + ".phsA.cVal.mag.f", + parent + ".phsB.cVal.mag.f", + parent + ".phsC.cVal.mag.f" + }, + plan.Leaves.Select(leaf => leaf.Reference), + StringComparer.OrdinalIgnoreCase); + Assert.DoesNotContain(plan.Leaves, leaf => leaf.Reference.Contains("noise", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(plan.Leaves, leaf => leaf.Reference.Contains("instCVal", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void ThdPPV_ReadPlan_UsesExactAB_BC_CA_Identities() + { + const string parent = "IEDLD0/V_MHAI1.ThdPPV"; + var model = Model(DataObject( + parent, + "ThdPPV", + Attribute(parent + ".phsCA.cVal.mag.f", "phsCA.cVal.mag.f"), + Attribute(parent + ".phsAB.cVal.mag.f", "phsAB.cVal.mag.f"), + Attribute(parent + ".phsBC.cVal.mag.f", "phsBC.cVal.mag.f"))); + + Assert.True(SchemaSafeAggregateProjectionService.TryBuildReadPlan( + model, + parent, + out var plan, + out var status), status); + + Assert.Equal(new[] { "AB", "BC", "CA" }, plan.Leaves.Select(leaf => leaf.Label)); + Assert.Equal( + new[] + { + parent + ".phsAB.cVal.mag.f", + parent + ".phsBC.cVal.mag.f", + parent + ".phsCA.cVal.mag.f" + }, + plan.Leaves.Select(leaf => leaf.Reference), + StringComparer.OrdinalIgnoreCase); + } + + [Fact] + public void Thd_ReadPlan_MissingOneNamedPhase_FailsClosed() + { + const string parent = "IEDLD0/I_MHAI1.ThdA"; + var model = Model(DataObject( + parent, + "ThdA", + Attribute(parent + ".phsA.cVal.mag.f", "phsA.cVal.mag.f"), + Attribute(parent + ".phsC.cVal.mag.f", "phsC.cVal.mag.f"), + Attribute(parent + ".someOtherFloat.f", "someOtherFloat.f"))); + + var ok = SchemaSafeAggregateProjectionService.TryBuildReadPlan( + model, + parent, + out var plan, + out var status); + + Assert.False(ok); + Assert.Empty(plan.Leaves); + Assert.Contains("phase B", status, StringComparison.OrdinalIgnoreCase); + Assert.Contains("none of the approved exact magnitude references", status, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void DemandEnergy_ReadPlan_PrefersCanonicalMag_AndIgnoresOtherNumericLeaves() + { + const string parent = "IEDLD0/XPRE_MMTR1.DmdWhMV"; + var model = Model(DataObject( + parent, + "DmdWhMV", + Attribute(parent + ".noise.f", "noise.f"), + Attribute(parent + ".instMag.f", "instMag.f"), + Attribute(parent + ".mag.f", "mag.f"))); + + Assert.True(SchemaSafeAggregateProjectionService.TryBuildReadPlan( + model, + parent, + out var plan, + out var status), status); + + var leaf = Assert.Single(plan.Leaves); + Assert.Equal("DemandEnergy", plan.Kind); + Assert.Equal(parent + ".mag.f", leaf.Reference, ignoreCase: true); + } + + [Fact] + public void DemandEnergy_ReadPlan_UsesOneExactInstantFallback_WhenCanonicalIsAbsent() + { + const string parent = "IEDLD0/XPRE_MMTR1.DmdWhMV"; + var model = Model(DataObject( + parent, + "DmdWhMV", + Attribute(parent + ".noise.f", "noise.f"), + Attribute(parent + ".instMag.f", "instMag.f"))); + + Assert.True(SchemaSafeAggregateProjectionService.TryBuildReadPlan( + model, + parent, + out var plan, + out var status), status); + + Assert.Equal(parent + ".instMag.f", Assert.Single(plan.Leaves).Reference, ignoreCase: true); + } + + [Fact] + public void DemandEnergy_ReadPlan_AmbiguousInstantFallback_FailsClosed() + { + const string parent = "IEDLD0/XPRE_MMTR1.DmdWhMV"; + var model = Model(DataObject( + parent, + "DmdWhMV", + Attribute(parent + ".instMag.f", "instMag.f"), + Attribute(parent + ".instCVal.mag.f", "instCVal.mag.f"))); + + var ok = SchemaSafeAggregateProjectionService.TryBuildReadPlan( + model, + parent, + out _, + out var status); + + Assert.False(ok); + Assert.Contains("ambiguous", status, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void RuntimeResolver_InterceptsAggregateBeforeGenericParentRead() + { + var source = File.ReadAllText(FindRepoFile("Services/IecSignalReadResolver.cs")) + .Replace("\r\n", "\n", StringComparison.Ordinal); + var intercept = source.IndexOf("if (client is NativeIec61850Client native && IsSchemaSafeAggregate(signal.ObjectReference))", StringComparison.Ordinal); + var generic = source.IndexOf("var references = BuildReadCandidates(signal.ObjectReference).ToList();", StringComparison.Ordinal); + + Assert.True(intercept >= 0, "Aggregate values must have an explicit schema-safe runtime interception path."); + Assert.True(generic > intercept, "Aggregate interception must occur before generic parent/alternate read candidates are built."); + Assert.Contains("TryBuildSchemaSafeAggregateReadPlan", source, StringComparison.Ordinal); + Assert.Contains("schema-safe-three-phase-exact-leaf-reads", source, StringComparison.Ordinal); + Assert.Contains("schema-safe-demand-energy-exact-leaf-read", source, StringComparison.Ordinal); + + var helperStart = source.IndexOf("private static async Task ReadSchemaSafeAggregateAsync", StringComparison.Ordinal); + var helperEnd = source.IndexOf("private static bool IsSchemaSafeAggregate", helperStart, StringComparison.Ordinal); + Assert.True(helperStart >= 0 && helperEnd > helperStart); + var helper = source[helperStart..helperEnd]; + Assert.DoesNotContain("ReadValueAsync(signal.ObjectReference", helper, StringComparison.Ordinal); + Assert.Contains("ReadValueAsync(\n leaf.Reference", helper, StringComparison.Ordinal); + } + + [Fact] + public void SchemaSafeService_HasNoPositionalOrFirstNumericAggregateSelection() + { + var source = File.ReadAllText(FindRepoFile("Services/SchemaSafeAggregateProjectionService.cs")); + + Assert.DoesNotContain("Children.Take(3)", source, StringComparison.Ordinal); + Assert.DoesNotContain("FindFirstFloating", source, StringComparison.Ordinal); + Assert.DoesNotContain("FindFirstInteger", source, StringComparison.Ordinal); + Assert.Contains("TryResolvePreferredAttributeReference", source, StringComparison.Ordinal); + } + + private static LiveIedModelDiscoveryDocument Model(params LiveIedDataObjectModel[] objects) + => new() + { + Source = "SclWorkspace regression", + IedName = "IED", + LogicalDevices = + [ + new LiveIedLogicalDeviceModel + { + MmsDomain = "IEDLD0", + Inst = "LD0", + LogicalNodes = + [ + new LiveIedLogicalNodeModel + { + Name = "MHAI1", + LnClass = "MHAI", + LnInst = "1", + DataObjects = objects + } + ] + } + ] + }; + + private static LiveIedDataObjectModel DataObject( + string reference, + string name, + params LiveIedDataAttributeModel[] attributes) + => new() + { + Reference = reference, + Name = name, + InferredCdc = "MV", + Attributes = attributes + }; + + private static LiveIedDataAttributeModel Attribute(string reference, string path) + => new() + { + ObjectReference = reference, + AttributePath = path, + FunctionalConstraint = "MX", + MmsReference = reference.Replace('.', '$'), + SclBType = "FLOAT32", + MmsType = "floating-point", + Source = "SCL.DataTypeTemplates", + TypeSource = "SCL.DataTypeTemplates", + TypeConfidence = LiveIedDiscoveryConfidenceLevel.Exact + }; + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + + throw new FileNotFoundException(relativePath); + } +} diff --git a/tests/ARSAS.Tests/SemanticReportProductionWiringRegressionTests.cs b/tests/ARSAS.Tests/SemanticReportProductionWiringRegressionTests.cs new file mode 100644 index 000000000..abebabcbb --- /dev/null +++ b/tests/ARSAS.Tests/SemanticReportProductionWiringRegressionTests.cs @@ -0,0 +1,185 @@ +using AR.Iec61850.Discovery; +using AR.Iec61850.Mms; + +namespace ARSAS.Tests; + +public sealed class SemanticReportProductionWiringRegressionTests +{ + [Fact] + public void ProductionReceivePath_UsesModelBackedSemanticOverlay() + { + var native = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.cs")) + .Replace("\r\n", "\n", StringComparison.Ordinal); + var helper = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.SemanticReporting.cs")) + .Replace("\r\n", "\n", StringComparison.Ordinal); + var hybrid = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.HybridReporting.cs")) + .Replace("\r\n", "\n", StringComparison.Ordinal); + + var receiveStart = native.IndexOf( + "public async Task ReceiveReportMonitorSliceAsync", + StringComparison.Ordinal); + var receiveEnd = native.IndexOf( + "public async Task> StopReportMonitorsAsync", + receiveStart, + StringComparison.Ordinal); + Assert.True(receiveStart >= 0 && receiveEnd > receiveStart); + var receive = native[receiveStart..receiveEnd]; + + Assert.Contains("var projection = ProjectReportValue(report);", receive, StringComparison.Ordinal); + Assert.DoesNotContain("MmsReportValueProjector.Project(report)", receive, StringComparison.Ordinal); + Assert.Contains("MmsSemanticReportValueProjector.Project", helper, StringComparison.Ordinal); + Assert.Contains("MmsReportSemanticProjectionContext.Create", helper, StringComparison.Ordinal); + Assert.Contains("_semanticReportProjectionAuthorityModel ?? _liveModel", helper, StringComparison.Ordinal); + Assert.Contains("REPORT_SEMANTIC_FALLBACK", helper, StringComparison.Ordinal); + Assert.Contains("SetSemanticReportProjectionAuthority(planningModel);", hybrid, StringComparison.Ordinal); + Assert.Contains("ResetSemanticReportProjectionContext();", native, StringComparison.Ordinal); + } + + [Fact] + public void PinnedEngine_FansOutWholeThreePhaseStructuredMember_WithoutChoosingPrimaryPhase() + { + const string objectReference = "IEDLD0/MHAI1.ThdA"; + const string dataSetReference = "IEDLD0/LLN0.Analog"; + var attributes = new[] + { + Attribute(objectReference + ".phsA.cVal.mag.f", "phsA.cVal.mag.f"), + Attribute(objectReference + ".phsB.cVal.mag.f", "phsB.cVal.mag.f"), + Attribute(objectReference + ".phsC.cVal.mag.f", "phsC.cVal.mag.f") + }; + var model = new LiveIedModelDiscoveryDocument + { + Source = "ARSAS regression", + IedName = "IED", + LogicalDevices = + [ + new LiveIedLogicalDeviceModel + { + MmsDomain = "IEDLD0", + LogicalNodes = + [ + new LiveIedLogicalNodeModel + { + Name = "MHAI1", + LnClass = "MHAI", + LnInst = "1", + DataObjects = + [ + new LiveIedDataObjectModel + { + Reference = objectReference, + Name = "ThdA", + InferredCdc = "WYE", + Attributes = attributes + } + ] + } + ] + } + ], + DataSets = + [ + new LiveIedDataSetModel + { + Reference = dataSetReference, + Domain = "IEDLD0", + LogicalNode = "LLN0", + Name = "Analog", + MemberCount = 1, + Members = + [ + new LiveIedDataSetMemberModel + { + Index = 0, + Reference = objectReference, + FunctionalConstraint = "MX", + MmsReference = "IEDLD0/MHAI1$MX$ThdA", + Confidence = LiveIedDiscoveryConfidenceLevel.Exact + } + ] + } + ] + }; + + var binding = Assert.Single(Iec61850DataSetSemanticBindingResolver.Resolve(model).Members); + Assert.Equal(LiveIedDataSetMemberResolutionStatus.Ambiguous, binding.ResolutionStatus); + Assert.Null(binding.PrimaryValue); + + var frame = new MmsReportFrame + { + ReceivedAt = new DateTimeOffset(2026, 9, 1, 9, 0, 0, TimeSpan.Zero), + Header = new MmsReportHeader { DataSetReference = dataSetReference }, + Values = + [ + new MmsReportValue + { + // Deliberately differs from the authoritative static DataSet member index (0). + // A sparse InformationReport value position must not defeat an exact IEC member reference. + Index = 17, + Member = new MmsDataSetDirectoryMember + { + UserReference = objectReference, + FunctionalConstraint = "MX" + }, + Value = MmsDataValue.Structure([Phase(1.25), Phase(2.5), Phase(3.75)]), + ReasonForInclusion = ["data-change"] + } + ] + }; + + var projection = MmsSemanticReportValueProjector.Project( + frame, + MmsReportSemanticProjectionContext.Create(model)); + + Assert.Contains(projection.Updates, update => + update.Reference.Equals(objectReference + ".phsA.cVal.mag.f", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(projection.Updates, update => + update.Reference.Equals(objectReference + ".phsB.cVal.mag.f", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(projection.Updates, update => + update.Reference.Equals(objectReference + ".phsC.cVal.mag.f", StringComparison.OrdinalIgnoreCase)); + Assert.All(projection.Updates, update => + Assert.Equal("semantic-structured-leaf", update.ProjectionStatus)); + Assert.DoesNotContain(projection.Updates, update => + update.Reference.Equals(objectReference, StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(projection.Warnings, warning => + warning.StartsWith("REPORT_RAW_STRUCT:", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(projection.Warnings, warning => + warning.StartsWith("REPORT_SEMANTIC_FALLBACK:", StringComparison.OrdinalIgnoreCase)); + } + + private static LiveIedDataAttributeModel Attribute(string reference, string path) + => new() + { + ObjectReference = reference, + AttributePath = path, + FunctionalConstraint = "MX", + MmsReference = reference.Replace('.', '$'), + SclBType = "FLOAT32", + MmsType = "floating-point", + Source = "ARSAS regression", + TypeSource = "ARSAS regression", + TypeConfidence = LiveIedDiscoveryConfidenceLevel.Exact + }; + + private static MmsDataValue Phase(double value) + => MmsDataValue.Structure( + [ + MmsDataValue.Structure( + [ + MmsDataValue.Structure([MmsDataValue.FloatingPoint(value)]) + ]) + ]); + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + + throw new FileNotFoundException(relativePath); + } +} diff --git a/tests/ARSAS.Tests/ThdMeasurementAndWorkspaceBridgeRegressionTests.cs b/tests/ARSAS.Tests/ThdMeasurementAndWorkspaceBridgeRegressionTests.cs new file mode 100644 index 000000000..81ac9238d --- /dev/null +++ b/tests/ARSAS.Tests/ThdMeasurementAndWorkspaceBridgeRegressionTests.cs @@ -0,0 +1,274 @@ +using ArIED61850Tester.Models; +using ArIED61850Tester.Models.IoTesting; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class ThdMeasurementAndWorkspaceBridgeRegressionTests +{ + [Theory] + [InlineData("IEDTHD/I_MHAI1.ThdA.phsA.cVal.mag.f")] + [InlineData("IEDTHD/I_MHAI1.ThdA.phsB.cVal.mag.f")] + [InlineData("IEDTHD/I_MHAI1.ThdA.phsC.cVal.mag.f")] + [InlineData("IEDTHD/V_MHAI1.ThdPPV.phsAB.cVal.mag.f")] + [InlineData("IEDTHD/V_MHAI1.ThdPPV.phsBC.cVal.mag.f")] + [InlineData("IEDTHD/V_MHAI1.ThdPPV.phsCA.cVal.mag.f")] + public void NumericThdPhaseMagnitude_IsPublishableToLiveRuntime(string reference) + { + var signal = Signal(reference, reference, "IEDTHD/LLN0.Analog"); + + Assert.False(signal.IsRawAttribute); + Assert.True(signal.IsValueSignal); + Assert.True(signal.CanPublishAsSignal); + Assert.True(signal.CanPublishToRuntime); + } + + [Theory] + [InlineData("IEDTHD/I_MHAI1.ThdA")] + [InlineData("IEDTHD/V_MHAI1.ThdPPV")] + public void ThreePhaseThdParent_IsPublishableAsCompositeLiveValue(string reference) + { + var signal = Signal(reference, reference, "IEDTHD/LLN0.Analog"); + + Assert.True(SignalDefinition.IsThreePhaseMeasurementAggregate(reference)); + Assert.False(signal.IsRawAttribute); + Assert.True(signal.CanPublishToRuntime); + } + + [Theory] + [InlineData("IEDTHD/I_MHAI1.ThdA.phsA.q")] + [InlineData("IEDTHD/V_MHAI1.ThdPPV.phsAB.t")] + public void ThdCompanion_RemainsNonPublishable(string reference) + { + var signal = Signal(reference, reference, "IEDTHD/LLN0.Analog"); + + Assert.False(signal.CanPublishAsSignal); + } + + [Theory] + [InlineData("IEDENERGY/XPRE_MMTR1.DmdWhMV.mag.f")] + [InlineData("IEDENERGY/XPRE_MMTR1.DmdWhMV.instMag.f")] + public void DemandEnergyMagnitude_IsNotDiscardedAsStatisticNoise(string reference) + { + var signal = Signal(reference, reference, "IEDENERGY/LLN0.Analog"); + + Assert.False(signal.IsRawAttribute); + Assert.True(signal.CanPublishToRuntime); + } + + [Fact] + public void ObjectLevelDemandEnergyDataSetMember_IsPublishableForScalarProjection() + { + const string reference = "IEDENERGY/XPRE_MMTR1.DmdWhMV"; + var signal = Signal(reference, reference, "IEDENERGY/LLN0.Analog"); + + Assert.True(SignalDefinition.IsDemandEnergyAggregate(reference)); + Assert.False(signal.IsRawAttribute); + Assert.True(signal.CanPublishToRuntime); + } + + [Fact] + public void FatFastPath_SkipsConnectedReconciliation_AndUsesSchemaSafeParentReads() + { + var root = FindRepoRoot(); + var autoConnect = File.ReadAllText(Path.Combine(root, "MainWindow.IoTesting.AutoConnect.cs")); + var runtime = File.ReadAllText(Path.Combine(root, "Services", "Iec61850MonitorRuntime.cs")); + var resolver = File.ReadAllText(Path.Combine(root, "Services", "IecSignalReadResolver.cs")); + var aggregate = File.ReadAllText(Path.Combine(root, "Services", "SchemaSafeAggregateProjectionService.cs")); + + Assert.Contains("if (hasSclRuntimeAuthority)", autoConnect, StringComparison.Ordinal); + Assert.Contains("IoTestReconciliationCache.Invalidate(device)", autoConnect, StringComparison.Ordinal); + Assert.Contains("RequiresExactMmsValueAuthority(point.IecReference)", runtime, StringComparison.Ordinal); + Assert.Contains("ReadSchemaSafeAggregateAsync", resolver, StringComparison.Ordinal); + Assert.Contains("TryBuildSchemaSafeAggregateReadPlan", resolver, StringComparison.Ordinal); + Assert.Contains("schema-safe-three-phase-exact-leaf-reads", resolver, StringComparison.Ordinal); + Assert.Contains("schema-safe-demand-energy-exact-leaf-read", resolver, StringComparison.Ordinal); + Assert.Contains("TryResolvePreferredAttributeReference", aggregate, StringComparison.Ordinal); + Assert.DoesNotContain("Children.Take(3)", aggregate, StringComparison.Ordinal); + } + + [Fact] + public void FatWorkspace_ExposesIncrementalSclIedImport() + { + var root = FindRepoRoot(); + var xaml = File.ReadAllText(Path.Combine(root, "IoListTestingWindow.xaml")); + var importer = File.ReadAllText(Path.Combine(root, "Services", "IoTesting", "IoFatSclProjectImportService.cs")); + + Assert.Contains("AddFatIedFromScl_Click", xaml, StringComparison.Ordinal); + Assert.Contains("ImportAdditionalAsync", importer, StringComparison.Ordinal); + Assert.Contains("AddRuntimeWorkspaces", importer, StringComparison.Ordinal); + } + + [Fact] + public void ExistingEngineeringSelection_InitializesFat_ThenSynchronizesBothWays() + { + const string dataSet = "IEDTHD/LLN0.Analog"; + const string member = "IEDTHD/I_MHAI1.ThdA.phsA"; + const string runtime = member + ".cVal.mag.f"; + var signal = Signal(runtime, member, dataSet); + var device = Device(signal); + var point = Point(member, runtime, dataSet); + var ied = Ied(point); + + // Engineering owns shared workspace membership, while TEST remains a FAT-only + // evidence-scope preference. An intentionally unchecked Engineering signal must + // hide from FAT without mutating the operator's TEST choice. + point.TestEnabled = true; + IoFatEngineeringSelectionBridge.Initialize( + ied, + device, + preserveExistingEngineeringSelection: true); + Assert.False(point.WorkspaceSelected); + Assert.True(point.TestEnabled); + Assert.False(signal.IsSelected); + + // Engineering reselects the shared row without touching FAT-only TEST state. + signal.IsSelected = true; + Assert.True(IoFatEngineeringSelectionBridge.ApplyEngineeringSignalSelection( + signal, + selected: true, + ied, + device)); + Assert.True(point.WorkspaceSelected); + Assert.True(point.TestEnabled); + Assert.True(point.IsIncludedInFat); + + // FAT can change the shared workspace membership independently from TEST. + point.WorkspaceSelected = false; + Assert.True(IoFatEngineeringSelectionBridge.ApplyFatPointSelection(point, device)); + Assert.False(signal.IsSelected); + Assert.True(point.TestEnabled); + } + + [Fact] + public void RemovedSclFatRow_EngineeringSelectionChangesWorkspaceOnly_AndNeverRestoresDisposition() + { + const string dataSet = "IEDTHD/LLN0.Analog"; + const string member = "IEDTHD/I_MHAI1.ThdA.phsB"; + const string runtime = member + ".cVal.mag.f"; + var signal = Signal(runtime, member, dataSet); + signal.IsSelected = true; + var device = Device(signal); + var point = Point(member, runtime, dataSet); + var ied = Ied(point); + + point.RemoveFromFat(); + Assert.False(point.IsIncludedInFat); + Assert.True(point.WorkspaceSelected); + Assert.True(point.TestEnabled); + Assert.False(IoFatEngineeringSelectionBridge.ApplyFatPointSelection(point, device)); + Assert.True(signal.IsSelected); + + signal.IsSelected = false; + Assert.True(IoFatEngineeringSelectionBridge.ApplyEngineeringSignalSelection( + signal, + selected: false, + ied, + device)); + Assert.False(point.IsIncludedInFat); + Assert.False(point.WorkspaceSelected); + Assert.True(point.TestEnabled); + + signal.IsSelected = true; + Assert.True(IoFatEngineeringSelectionBridge.ApplyEngineeringSignalSelection( + signal, + selected: true, + ied, + device)); + Assert.False(point.IsIncludedInFat); + Assert.True(point.WorkspaceSelected); + Assert.True(point.TestEnabled); + + point.RestoreToFat(); + Assert.True(point.IsIncludedInFat); + Assert.True(point.WorkspaceSelected); + Assert.True(point.TestEnabled); + Assert.False(IoFatEngineeringSelectionBridge.ApplyFatPointSelection(point, device)); + Assert.True(signal.IsSelected); + } + + [Fact] + public void NewDirectSclFatWorkspace_SelectsTheSameCanonicalEngineeringRows() + { + const string dataSet = "IEDTHD/LLN0.Analog"; + const string member = "IEDTHD/V_MHAI1.ThdPPV.phsAB"; + const string runtime = member + ".cVal.mag.f"; + var canonical = Signal(runtime, member, dataSet); + var duplicatePresentationLeaf = Signal(runtime, runtime, dataSet); + var device = Device(canonical, duplicatePresentationLeaf); + var point = Point(member, runtime, dataSet); + + IoFatEngineeringSelectionBridge.Initialize( + Ied(point), + device, + preserveExistingEngineeringSelection: false); + + Assert.True(canonical.IsSelected); + Assert.False(duplicatePresentationLeaf.IsSelected); + Assert.Same(canonical, IoFatEngineeringSelectionBridge.FindSignal(point, device)); + } + + private static SignalDefinition Signal(string runtime, string display, string dataSet) => new() + { + Name = "THD", + ObjectReference = runtime, + DisplayReference = display, + FunctionalConstraint = "MX", + DataType = "FLOAT32", + Category = "DataSet", + DataSetReference = dataSet, + ProbeStatus = "Not probed", + Value = "-", + Quality = "Unknown" + }; + + private static Iec61850MonitorDevice Device(params SignalDefinition[] signals) + { + var device = new Iec61850MonitorDevice + { + Name = "IEDTHD", + SclIedName = "IEDTHD", + IpAddress = "192.0.2.20", + Port = 102 + }; + device.Signals.AddRange(signals); + return device; + } + + private static IoTestIedPlan Ied(IoTestPointPlan point) => new() + { + IedName = "IEDTHD", + IpAddress = "192.0.2.20", + TestPoints = [point] + }; + + private static IoTestPointPlan Point(string member, string runtime, string dataSet) => new() + { + TestPointId = "thd-phase", + IedName = "IEDTHD", + IpAddress = "192.0.2.20", + SignalName = "THD", + ObjectReference = runtime, + FunctionalConstraint = "MX", + ExpectedOnText = "Value 1", + ExpectedOffText = "Value 2", + DataSetName = dataSet, + SourceIecReference = member, + ReportDisplayReference = member, + EventLogSearchReference = member, + BindingStatus = IoTestSignalSelectionService.SclDataSetAuthorityBindingStatus, + ImportReady = true, + WorkspaceSelected = true, + TestEnabled = true, + SignalKind = FatSignalKind.Analog, + CaptureMode = FatCaptureMode.OperatorSnapshot + }; + + private static string FindRepoRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory != null && !File.Exists(Path.Combine(directory.FullName, "ArIED61850Tester.csproj"))) + directory = directory.Parent; + return directory?.FullName ?? throw new DirectoryNotFoundException("Repository root not found."); + } +} diff --git a/tests/ARSAS.Tests/WorkspaceModeSwitchTests.cs b/tests/ARSAS.Tests/WorkspaceModeSwitchTests.cs index f8cb1dc60..ade9126ba 100644 --- a/tests/ARSAS.Tests/WorkspaceModeSwitchTests.cs +++ b/tests/ARSAS.Tests/WorkspaceModeSwitchTests.cs @@ -12,13 +12,59 @@ public void MainWindow_AlwaysExposesEngineeringAndPersistentIoFatWorkspaceModes( Assert.Contains("IO LIST FAT · LOADED", source, StringComparison.Ordinal); Assert.Contains("_loadedIoFatWindow", source, StringComparison.Ordinal); Assert.Contains("ShowLoadedIoFatWorkspace", source, StringComparison.Ordinal); + Assert.Contains("CurrentEngineeringSclSourcePaths", source, StringComparison.Ordinal); + Assert.Contains("OpenSclFatSourcesAsync(sharedSources, selectionMode: null)", source, StringComparison.Ordinal); Assert.Contains("Continue loaded FAT project", source, StringComparison.Ordinal); + Assert.Contains("Import SCL / CID files", source, StringComparison.Ordinal); + Assert.Contains("Add SCL / CID to loaded FAT workspace", source, StringComparison.Ordinal); + Assert.Contains("OpenSclForLoadedFatAppendAsync(loaded)", source, StringComparison.Ordinal); + Assert.Contains("OpenSclFatTesting_Click", source, StringComparison.Ordinal); Assert.Contains("Import another IO List Excel workbook", source, StringComparison.Ordinal); Assert.Contains("Open another portable .arsas project", source, StringComparison.Ordinal); Assert.Contains("QueueIoFatWorkspaceReplacement", source, StringComparison.Ordinal); Assert.Contains("FrameworkElement.LoadedEvent", source, StringComparison.Ordinal); } + [Fact] + public void SclImport_UsesOneSelectionAuthorityAcrossEngineeringAndFat() + { + var workflow = File.ReadAllText(FindRepoFile("MainWindow.SharedSclWorkspace.cs")); + var selectionWindow = File.ReadAllText(FindRepoFile("SclSignalSelectionModeWindow.xaml")); + var engineering = File.ReadAllText(FindRepoFile("MainWindow.xaml.cs")); + var fat = File.ReadAllText(FindRepoFile("MainWindow.IoTesting.cs")); + var fatProjection = File.ReadAllText(FindRepoFile("IoListTestingWindow.FatV2Ux.cs")); + + Assert.Contains("new SclSignalSelectionModeWindow", workflow, StringComparison.Ordinal); + Assert.DoesNotContain("MessageBox.Show", workflow, StringComparison.Ordinal); + Assert.Contains("Use Static DataSet", selectionWindow, StringComparison.Ordinal); + Assert.Contains("Choose Signals Manually", selectionWindow, StringComparison.Ordinal); + Assert.Contains("SelectionCard", selectionWindow, StringComparison.Ordinal); + Assert.Contains("CardShadow", selectionWindow, StringComparison.Ordinal); + Assert.Contains("PrimaryButton", selectionWindow, StringComparison.Ordinal); + Assert.Contains("SoftButton", selectionWindow, StringComparison.Ordinal); + Assert.Contains("_sharedSclSelectionAuthorityDeviceIds", workflow, StringComparison.Ordinal); + Assert.Contains("ApplyStaticDataSetSelection", engineering, StringComparison.Ordinal); + Assert.Contains("selectionAlreadyApplied: true", engineering, StringComparison.Ordinal); + Assert.Contains("ApplyManualSelectionToFatProjectAsync", fat, StringComparison.Ordinal); + Assert.Contains("promptForSelection: true", fat, StringComparison.Ordinal); + Assert.Contains("PromptSclSignalSelectionMode(this, import.Project.Ieds.Count)", fat, StringComparison.Ordinal); + Assert.Contains("point.WorkspaceSelected", fatProjection, StringComparison.Ordinal); + Assert.Contains("point.IsIncludedInFat", fatProjection, StringComparison.Ordinal); + } + + [Fact] + public void EngineeringSclImport_IsAdditiveAndSupportsMultipleFiles() + { + var engineering = File.ReadAllText(FindRepoFile("MainWindow.xaml.cs")); + + Assert.Contains("Title = \"Open one or more IEC 61850 SCL files\"", engineering, StringComparison.Ordinal); + Assert.Contains("Multiselect = true", engineering, StringComparison.Ordinal); + Assert.Contains("foreach (var sourcePath in dialog.FileNames.Distinct", engineering, StringComparison.Ordinal); + Assert.Contains("var importedSources = new List<(string Path, string Sha256)>()", engineering, StringComparison.Ordinal); + Assert.Contains("newFatSourcePaths", engineering, StringComparison.Ordinal); + Assert.Contains("AppendSclIedsToLoadedFatAsync", engineering, StringComparison.Ordinal); + } + [Fact] public void FatWorkspace_ReturnsToEngineeringByHideAndShowWithoutUnloadingProject() { @@ -41,7 +87,7 @@ public void FatWorkspace_ReturnsToEngineeringByHideAndShowWithoutUnloadingProjec } [Fact] - public void LoadingAnotherFatProject_IsExplicitAndClosesTheLoadedWorkspaceFirst() + public void LoadingAnotherFatProject_IsExplicitWhileLoadedSclImportIsAdditive() { var source = File.ReadAllText(FindRepoFile("MainWindow.WorkspaceModeSwitch.cs")); var hostSource = File.ReadAllText(FindRepoFile("MainWindow.IoTesting.cs")); @@ -49,6 +95,13 @@ public void LoadingAnotherFatProject_IsExplicitAndClosesTheLoadedWorkspaceFirst( Assert.Contains("Save and close that workspace before loading another project?", source, StringComparison.Ordinal); Assert.Contains("loaded.Close();", source, StringComparison.Ordinal); Assert.Contains("Dispatcher.BeginInvoke(openReplacement", source, StringComparison.Ordinal); + + // P0.4 keeps SCL additive while workbook/project opens remain explicit replacement. + Assert.Contains("OpenSclForLoadedFatAppendAsync(loaded)", source, StringComparison.Ordinal); + Assert.DoesNotContain( + "QueueIoFatWorkspaceReplacement(\n () => OpenSclFatTesting_Click", + source, + StringComparison.Ordinal); Assert.Contains("QueueIoFatWorkspaceReplacement(() => OpenIoListTesting_Click", hostSource, StringComparison.Ordinal); Assert.Contains("QueueIoFatWorkspaceReplacement(() => OpenIoListPackage_Click", hostSource, StringComparison.Ordinal); }