diff --git a/MainWindow.IoTesting.SclAppend.cs b/MainWindow.IoTesting.SclAppend.cs index d59696ff6..af799c5a7 100644 --- a/MainWindow.IoTesting.SclAppend.cs +++ b/MainWindow.IoTesting.SclAppend.cs @@ -159,7 +159,15 @@ await ApplyManualSelectionToFatProjectAsync( if (device is null) continue; - ApplyStaticDataSetSelection(device); + device.BeginReportingProgress("Preparing Static DataSet FAT selection…"); + try + { + await ApplyStaticDataSetSelection(device); + } + finally + { + device.CompleteReportingProgress(); + } // The new FAT IED has deliberately not been published yet, so project-wide // selection projection cannot see it. Project the authoritative Engineering diff --git a/MainWindow.SclQuickActions.cs b/MainWindow.SclQuickActions.cs index a23c0d484..827538509 100644 --- a/MainWindow.SclQuickActions.cs +++ b/MainWindow.SclQuickActions.cs @@ -79,7 +79,7 @@ internal void OpenSclComtradeDownload(Iec61850MonitorDevice device) internal async Task OpenIedWorkspaceActionsAsync(Iec61850MonitorDevice device) { ArgumentNullException.ThrowIfNull(device); - if (device.IsBusy) + if (device.IsBusy || device.IsReportingProgressActive) { SetStatus($"{device.Name}: wait for the current IED task to finish before opening IED Actions."); return; @@ -112,37 +112,45 @@ internal async Task OpenIedWorkspaceActionsAsync(Iec61850MonitorDevice device) if (dialog.Selection == IedWorkspaceTaskChoice.StaticDataSet) { - if (device.IsMonitoring) - await StopDeviceMonitorAsync(device); - - ApplyStaticDataSetSelection(device); - if (device.SelectedLiveSignalCount == 0) + device.BeginReportingProgress("Preparing Static DataSet reporting…"); + try { - SetStatus($"{device.Name}: Static DataSet selected, but no report-authoritative process leaf is available to monitor."); - return; - } + if (device.IsMonitoring) + await StopDeviceMonitorAsync(device); - if (!device.IsConnected) - { - var connected = device.HasDiscoveryCache && device.Signals.Count > 0 - ? await ConnectUsingSavedModelAsync(device) - : await ConnectAndConfigureDeviceAsync(device, openWizard: false); - if (!connected) + if (device.IsReportingProgressCanceled) return; + await ApplyStaticDataSetSelection(device); + if (device.IsReportingProgressCanceled) return; + + if (device.SelectedLiveSignalCount == 0) + { + SetStatus($"{device.Name}: Static DataSet selected, but no report-authoritative process leaf is available to monitor."); return; + } + + if (!device.IsConnected) + { + var connected = device.HasDiscoveryCache && device.Signals.Count > 0 + ? await ConnectUsingSavedModelAsync(device) + : await ConnectAndConfigureDeviceAsync(device, openWizard: false); + if (!connected || device.IsReportingProgressCanceled) + return; + } + + device.UpdateReportingProgress(80, "Verifying measurement engineering units…"); + SetStatus($"{device.Name}: verifying engineering units for selected Static DataSet measurements…"); + await _runtime.EnrichSelectedStaticDataSetUnitsAsync( + device, _applicationCancellation.Token); + device.RefreshComputed(); + if (device.IsReportingProgressCanceled) return; + + if (!device.IsMonitoring) + await StartDeviceMonitorAsync(device); + } + finally + { + device.CompleteReportingProgress(); } - - // Unit metadata belongs to the same source-neutral Static DataSet workflow. - // Resolve only selected measurement owners on the active MMS session, before - // any RCB is armed, so discovery and opened-SCL paths expose the same verified - // engineering-unit behavior without disturbing active reporting. - SetStatus($"{device.Name}: verifying engineering units for selected Static DataSet measurements…"); - await _runtime.EnrichSelectedStaticDataSetUnitsAsync( - device, - _applicationCancellation.Token); - device.RefreshComputed(); - - if (!device.IsMonitoring) - await StartDeviceMonitorAsync(device); return; } diff --git a/MainWindow.SharedSclWorkspace.cs b/MainWindow.SharedSclWorkspace.cs index ee6f75be6..f6378f53a 100644 --- a/MainWindow.SharedSclWorkspace.cs +++ b/MainWindow.SharedSclWorkspace.cs @@ -63,29 +63,64 @@ private bool IsSharedStaticDataSetAuthority(Iec61850MonitorDevice device) return mode; } - private void ApplyStaticDataSetSelection(Iec61850MonitorDevice device) + private async Task ApplyStaticDataSetSelection(Iec61850MonitorDevice device) { - // Static DataSet remains the protocol authority established by the report-only - // baseline. Materialize every ARIEC-owned member first, then select exactly one - // presentation/runtime row per literal static membership. A browsed alias carrying - // DataSetReference is not enough authority and must not inflate the live plan. + // WPF-owned SignalDefinition collections stay on their Dispatcher. + // Cooperative slices keep input/render live; matching is indexed by + // exact ARIEC DataSet owner, not a background thread over live bindings. + var clock = System.Diagnostics.Stopwatch.StartNew(); + device.UpdateReportingProgress(4, "Preparing ARIEC Static DataSet members…"); + await System.Windows.Threading.Dispatcher.Yield(System.Windows.Threading.DispatcherPriority.Background); var merge = Iec61850DataSetSignalInventoryService.EnsureMandatorySignals(device); RegisterRecoveredDataSetSignals(device, merge); + var inventoryMs = clock.Elapsed.TotalMilliseconds; + device.UpdateReportingProgress(20, "Matching exact DataSet authority…"); + await System.Windows.Threading.Dispatcher.Yield(System.Windows.Threading.DispatcherPriority.Background); + var authoritativeSignals = Iec61850StaticDataSetAuthoritySelection.Build(device); + var authorityMs = clock.Elapsed.TotalMilliseconds; Iec61850MonitoringModeRegistry.UseStaticDataSetReportOnly(device); + device.UpdateReportingProgress(30, "Selecting verified DataSet member rows…"); + await System.Windows.Threading.Dispatcher.Yield(System.Windows.Threading.DispatcherPriority.Background); + // Snapshot object identities before yielding: even a pre-emptive Stop + // cannot invalidate a live WPF collection enumerator mid-operation. + var signalRows = device.Signals.ToArray(); device.BeginBulkSignalSelection(); try { - foreach (var signal in device.Signals) + var index = 0; + var total = signalRows.Length; + foreach (var signal in signalRows) + { signal.IsSelected = authoritativeSignals.Contains(signal); + index++; + if ((index & 127) == 0 && index < total) + { + device.UpdateReportingProgress(30d + 30d * index / Math.Max(1, total), + $"Selecting Static DataSet members · {index:N0}/{total:N0}…"); + await System.Windows.Threading.Dispatcher.Yield(System.Windows.Threading.DispatcherPriority.Background); + } + } } finally { device.EndBulkSignalSelection(); } + var selectionMs = clock.Elapsed.TotalMilliseconds; + device.UpdateReportingProgress(63, "Synchronizing FAT signal selection…"); + await System.Windows.Threading.Dispatcher.Yield(System.Windows.Threading.DispatcherPriority.Background); - SynchronizeAllEngineeringSelectionsToFat(device); + if (_ioFatSelectionBridgeProject is not null) + { + var index = 0; + foreach (var signal in signalRows) + { + SynchronizeEngineeringSelectionToFat(signal, device); + if ((++index & 127) == 0) + await System.Windows.Threading.Dispatcher.Yield(System.Windows.Threading.DispatcherPriority.Background); + } + } _sharedSclSelectionAuthorityDeviceIds.Add(device.DeviceId); _sharedSclStaticDataSetAuthorityDeviceIds.Add(device.DeviceId); SaveSignalSelectionMemory(device); @@ -93,20 +128,14 @@ private void ApplyStaticDataSetSelection(Iec61850MonitorDevice device) if (_pendingSharedStaticSelectionAssignments > 0) _pendingSharedStaticSelectionAssignments--; - AddLog( - "INFO", - device.Name, + AddLog("INFO", device.Name, $"Static DataSet report-only authority selected: {device.SelectedLiveSignalCount} exact runtime member row(s) from {merge.MandatoryCatalogCount} ARIEC static membership descriptor(s); cyclic MMS process polling and dynamic DataSet writes remain disabled."); + AddLog("INFO", device.Name, + $"P0_STATIC_UI_WORK inventoryMs={inventoryMs:0.###} exactAuthorityMs={authorityMs - inventoryMs:0.###} selectionMs={selectionMs - authorityMs:0.###} totalMs={clock.Elapsed.TotalMilliseconds:0.###} signalRows={device.Signals.Count} selected={authoritativeSignals.Count}"); - // Make feasibility and first-report proof visible from the initial Engineering - // workflow rather than waiting until FAT is opened. The observer waits for the - // shared monitor to start and never changes acquisition method. + device.UpdateReportingProgress(75, "Checking configured RCB reporting coverage…"); LogStaticDataSetReportFeasibility(device); _ = ObserveInitialStaticReportEvidenceAsync(device); - - // P5 native FAT is a thin view over SelectedDevice.Points. Re-synchronize after - // static DataSet authority refresh so same-IED SCL refreshes are visible without - // reviving the retired Engineering -> legacy IoTest bootstrap. SynchronizeProductionFatSelectedIed(); } @@ -131,7 +160,31 @@ private void MarkSharedSelectionAuthority(Iec61850MonitorDevice device) // report-only authority instead of silently demoting it to Hybrid. if (_pendingSharedStaticSelectionAssignments > 0) { - ApplyStaticDataSetSelection(device); + // Legacy synchronous FAT hook retains exact membership semantics. + // Card/SCL actions use the cooperative responsive path above. + var merge = Iec61850DataSetSignalInventoryService.EnsureMandatorySignals(device); + RegisterRecoveredDataSetSignals(device, merge); + var selected = Iec61850StaticDataSetAuthoritySelection.Build(device); + Iec61850MonitoringModeRegistry.UseStaticDataSetReportOnly(device); + device.BeginBulkSignalSelection(); + try + { + foreach (var signal in device.Signals) + signal.IsSelected = selected.Contains(signal); + } + finally + { + device.EndBulkSignalSelection(); + } + SynchronizeAllEngineeringSelectionsToFat(device); + _sharedSclSelectionAuthorityDeviceIds.Add(device.DeviceId); + _sharedSclStaticDataSetAuthorityDeviceIds.Add(device.DeviceId); + SaveSignalSelectionMemory(device); + device.RefreshComputed(); + _pendingSharedStaticSelectionAssignments--; + LogStaticDataSetReportFeasibility(device); + _ = ObserveInitialStaticReportEvidenceAsync(device); + SynchronizeProductionFatSelectedIed(); return; } diff --git a/MainWindow.xaml b/MainWindow.xaml index 7e18da2bc..d7bd31224 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -463,6 +463,10 @@ + + + + diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index 4f6ff9459..cc80e0d0e 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -346,22 +346,42 @@ private async void OpenScl_Click(object sender, RoutedEventArgs e) } 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 staged = new List(); + try { - var connected = firstImported.IsConnected || - await ConnectUsingSavedModelAsync(firstImported); - if (connected && !firstImported.IsMonitoring) + foreach (var importedDevice in selectableDevices) { - await _runtime.EnrichSelectedStaticDataSetUnitsAsync( - firstImported, - _applicationCancellation.Token); - firstImported.RefreshComputed(); - await StartDeviceMonitorAsync(firstImported); + importedDevice.BeginReportingProgress("Preparing Static DataSet reporting…"); + staged.Add(importedDevice); + await ApplyStaticDataSetSelection(importedDevice); + if (importedDevice.IsReportingProgressCanceled) + return; // preempted by operator Stop + if (!ReferenceEquals(importedDevice, firstImported)) + importedDevice.CompleteReportingProgress(); } + + AddLog("INFO", "SCL", $"Static DataSet selection is shared by Engineering and FAT for {selectableDevices.Length} IED workspace(s)."); + if (firstImported is { SelectedLiveSignalCount: > 0 } && + !firstImported.IsReportingProgressCanceled) + { + var connected = firstImported.IsConnected || + await ConnectUsingSavedModelAsync(firstImported); + if (connected && !firstImported.IsReportingProgressCanceled && + !firstImported.IsMonitoring) + { + firstImported.UpdateReportingProgress(80, "Verifying engineering units…"); + await _runtime.EnrichSelectedStaticDataSetUnitsAsync( + firstImported, _applicationCancellation.Token); + firstImported.RefreshComputed(); + if (!firstImported.IsReportingProgressCanceled) + await StartDeviceMonitorAsync(firstImported); + } + } + } + finally + { + foreach (var stagedDevice in staged) + stagedDevice.CompleteReportingProgress(); } } else if (selectionMode == SclSignalSelectionMode.BrowseIed) @@ -1387,6 +1407,8 @@ private async void IedStopAction_Click(object sender, RoutedEventArgs e) // Stop is a preemptive safety lane even while Connect/Start is busy. if (!TryGetDeviceFromButton(sender, out var device) || device.IsStopping) return; SelectedDevice = device; + if (device.IsReportingProgressActive) + device.CancelReportingProgress(); // Stop preempts report setup. if (device.IsDemo) { StopDemoDevice(device); @@ -2405,6 +2427,7 @@ private async Task StartDeviceMonitorAsync(Iec61850MonitorDevice device, b RemoveDevicePoints(device.DeviceId); device.Points.Clear(); + device.UpdateReportingProgress(84, "Arming configured BRCB/URCB reports…"); SetStatus($"{device.Name}: arming static/dynamic IEC 61850 reporting…"); var points = await _runtime.StartMonitoringAsync( device, @@ -2425,6 +2448,7 @@ private async Task StartDeviceMonitorAsync(Iec61850MonitorDevice device, b foreach (var point in points) signalProjection.ProjectReportedSignals(device.LiveValues, projectionAssociation, point.IecReference, point.FunctionalConstraint); + device.UpdateReportingProgress(96, "Applying initial report-backed signal grid…"); device.RefreshComputed(); RaiseWorkspaceCounts(); if (navigateToExplorer) diff --git a/Models/MonitorModels.cs b/Models/MonitorModels.cs index 4f9ed5b73..4d9b1ee3b 100644 --- a/Models/MonitorModels.cs +++ b/Models/MonitorModels.cs @@ -21,6 +21,9 @@ public sealed class Iec61850MonitorDevice : ObservableObject private bool _isMonitoring; private bool _isBrowserReading; private bool _discoveryProgressActive; + private bool _reportingProgressActive; + private bool _reportingProgressCanceled; + private double _reportingProgressPercent; private double _browserReadProgressPercent; private bool _isActive; private bool _hasReportStream; @@ -430,16 +433,68 @@ private set } } - // The fixed under-IP line is one presentation of TWO existing progress - // authorities, not another progress counter or independent timer. - // Discovery wins while in flight; Browser Read resumes otherwise. + // The permanent IED-card progress line is reused for discovery, report + // arming, and Browser Read, without a second visual indicator or ledger. [JsonIgnore] public bool IsDiscoveryProgressActive => _discoveryProgressActive && IsBusy; + [JsonIgnore] + public bool IsReportingProgressActive => _reportingProgressActive; + + [JsonIgnore] + public bool IsReportingProgressCanceled => _reportingProgressCanceled; + [JsonIgnore] public double IedCardProgressPercent => IsDiscoveryProgressActive ? DiscoveryProgressPercent - : BrowserReadProgressPercent; + : IsReportingProgressActive + ? _reportingProgressPercent + : BrowserReadProgressPercent; + + // Work-flow completion, NOT percentage of InformationReport traffic. + // Green 100% means arming finished, never proves buffered SOE continuity. + public void BeginReportingProgress(string stage) + { + _reportingProgressCanceled = false; + _reportingProgressActive = true; + _reportingProgressPercent = 0; + BusyStage = stage; + Raise(nameof(IsReportingProgressActive)); + Raise(nameof(IedCardProgressPercent)); + RefreshComputed(); + } + + public void UpdateReportingProgress(double percent, string stage) + { + if (!_reportingProgressActive) return; + BusyStage = stage; + var next = Math.Clamp(percent, 0d, 99d); + if (next <= _reportingProgressPercent) return; + _reportingProgressPercent = next; + Raise(nameof(IedCardProgressPercent)); + } + + public void CompleteReportingProgress() + { + if (!_reportingProgressActive) return; + _reportingProgressPercent = 100d; + Raise(nameof(IedCardProgressPercent)); + _reportingProgressActive = false; + Raise(nameof(IsReportingProgressActive)); + RefreshComputed(); + } + + // Stop preempts the selection/arming workflow without a second competing + // MMS request. The caller checks this before moving to the next stage. + public void CancelReportingProgress() + { + if (!_reportingProgressActive) return; + _reportingProgressCanceled = true; + _reportingProgressActive = false; + Raise(nameof(IsReportingProgressActive)); + Raise(nameof(IedCardProgressPercent)); + RefreshComputed(); + } public bool IsMonitoring { @@ -621,16 +676,16 @@ public void MarkDiscoveryFailed(string message) public int SelectedControlSignalCount => _selectedControlSignalCount; public bool IsBulkSignalSelectionUpdate => _isBulkSignalSelectionUpdate; public int PointCount => Points.Count; - public bool IsActionEnabled => !IsBusy; - public bool CanEditSignals => SignalCount > 0 && !IsBusy && !IsMonitoring && !IsDemo; - public bool CanStartMonitor => IsConnected && SelectedLiveSignalCount > 0 && !IsBusy; - public bool CanStartOrStopMonitor => !IsBusy && (IsMonitoring || SelectedLiveSignalCount > 0); - public bool CanPlayAction => !IsStopping && !IsBusy && (!IsConnected || (!IsMonitoring && SelectedLiveSignalCount > 0)); + public bool IsActionEnabled => !IsBusy && !IsReportingProgressActive; + public bool CanEditSignals => SignalCount > 0 && !IsBusy && !IsReportingProgressActive && !IsMonitoring && !IsDemo; + public bool CanStartMonitor => IsConnected && SelectedLiveSignalCount > 0 && !IsBusy && !IsReportingProgressActive; + public bool CanStartOrStopMonitor => !IsBusy && !IsReportingProgressActive && (IsMonitoring || SelectedLiveSignalCount > 0); + public bool CanPlayAction => !IsStopping && !IsBusy && !IsReportingProgressActive && (!IsConnected || (!IsMonitoring && SelectedLiveSignalCount > 0)); // Stop is intentionally pre-emptive: a hung Connect/Start must not // disable the only safe operator exit from an in-flight MMS operation. - public bool CanStopAction => !IsStopping && (IsBusy || IsConnected || IsMonitoring); - public bool CanRescan => !IsBusy && !IsMonitoring && !IsDemo; - public bool CanSaveScl => !IsBusy && !IsDemo && (SclWorkspace != null || LiveDiscoveryModel != null); + public bool CanStopAction => !IsStopping && (IsBusy || IsConnected || IsMonitoring || IsReportingProgressActive); + public bool CanRescan => !IsBusy && !IsReportingProgressActive && !IsMonitoring && !IsDemo; + public bool CanSaveScl => !IsBusy && !IsReportingProgressActive && !IsDemo && (SclWorkspace != null || LiveDiscoveryModel != null); public string SaveSclToolTip => SclWorkspace != null ? $"Save {Name} from the opened SCL design model" : LiveDiscoveryModel != null diff --git a/Services/Iec61850StaticDataSetAuthoritySelection.cs b/Services/Iec61850StaticDataSetAuthoritySelection.cs index eb94e455f..8e9eaf4e5 100644 --- a/Services/Iec61850StaticDataSetAuthoritySelection.cs +++ b/Services/Iec61850StaticDataSetAuthoritySelection.cs @@ -36,7 +36,11 @@ public static IReadOnlySet Build(Iec61850MonitorDevice device) var mandatory = Iec61850DataSetSignalInventoryProjection.GetMandatorySignals(authorityModel); var selected = new HashSet(ReferenceEqualityComparer.Instance); - var signals = device.Signals.ToArray(); + // Index exact DataSet ownership once instead of rescanning the entire + // SG7 signal catalog for every FCDA. This never infers membership. + var signalsByDataSet = device.Signals.ToLookup( + signal => NormalizeLiteral(signal.DataSetReference), + StringComparer.OrdinalIgnoreCase); foreach (var descriptor in mandatory) { @@ -60,13 +64,14 @@ public static IReadOnlySet Build(Iec61850MonitorDevice device) continue; } + var datasetSignals = signalsByDataSet[NormalizeLiteral(membership.DataSetReference)]; + // Command retention is independent from scalar process projection. The // projection service has already rooted these companions in exact SCL/ARIEC // CDC + DataObject authority. Selecting them here enables ctlModel inspection // and Command Panel projection even when PrimaryValueReference is unresolved. - foreach (var control in signals + foreach (var control in datasetSignals .Where(signal => signal.IsControlSignal && signal.IsValidControlObject) - .Where(signal => LiteralEquals(signal.DataSetReference, membership.DataSetReference)) .Where(signal => LiteralEquals(signal.DisplayReference, memberReference))) { selected.Add(control); @@ -78,9 +83,8 @@ public static IReadOnlySet Build(Iec61850MonitorDevice device) foreach (var leaf in Iec61850StaticPhaseLeafProjection.Resolve( authorityModel, memberReference, descriptor.FunctionalConstraint)) { - var matches = signals + var matches = datasetSignals .Where(signal => !signal.IsControlSignal && signal.CanPublishToRuntime) - .Where(signal => LiteralEquals(signal.DataSetReference, membership.DataSetReference)) .Where(signal => LiteralEquals(signal.ObjectReference, leaf.Reference)) .Where(signal => LiteralEquals(signal.DisplayReference, leaf.Reference)) .ToArray(); @@ -88,9 +92,8 @@ public static IReadOnlySet Build(Iec61850MonitorDevice device) selected.Add(matches[0]); } - var candidates = signals + var candidates = datasetSignals .Where(signal => !signal.IsControlSignal && signal.CanPublishToRuntime) - .Where(signal => LiteralEquals(signal.DataSetReference, membership.DataSetReference)) .Where(signal => LiteralEquals(signal.DisplayReference, memberReference)) .ToArray(); if (candidates.Length == 0) diff --git a/tests/ARSAS.Tests/P0Build1888RecoveryRegressionTests.cs b/tests/ARSAS.Tests/P0Build1888RecoveryRegressionTests.cs index d4a1abeee..aca6ccdb9 100644 --- a/tests/ARSAS.Tests/P0Build1888RecoveryRegressionTests.cs +++ b/tests/ARSAS.Tests/P0Build1888RecoveryRegressionTests.cs @@ -52,7 +52,9 @@ public void P0_SclFromFat_ReusesEngineeringModel_AndPreservesStaticDataSetAuthor Assert.Contains("device.HasDiscoveryCache = true", recovery, StringComparison.Ordinal); Assert.Contains("_pendingSharedStaticSelectionAssignments", shared, StringComparison.Ordinal); - Assert.Contains("ApplyStaticDataSetSelection(device);", shared, StringComparison.Ordinal); + Assert.Contains("private async Task ApplyStaticDataSetSelection(Iec61850MonitorDevice device)", shared, StringComparison.Ordinal); + Assert.Contains("Iec61850StaticDataSetAuthoritySelection.Build(device)", shared, StringComparison.Ordinal); + Assert.Contains("signal.IsSelected = authoritativeSignals.Contains(signal)", shared, StringComparison.Ordinal); Assert.Contains("ApplyStaticDataSetSelection(device);", append, StringComparison.Ordinal); Assert.Contains("Iec61850MonitoringModeRegistry.UseStaticDataSetReportOnly(device)", shared, StringComparison.Ordinal); diff --git a/tests/ARSAS.Tests/P0ResponsiveStaticReportingUiTests.cs b/tests/ARSAS.Tests/P0ResponsiveStaticReportingUiTests.cs new file mode 100644 index 000000000..da25572af --- /dev/null +++ b/tests/ARSAS.Tests/P0ResponsiveStaticReportingUiTests.cs @@ -0,0 +1,101 @@ +using System.Xml.Linq; +using ArIED61850Tester.Models; +using Xunit; + +namespace ARSAS.Tests; + +public sealed class P0ResponsiveStaticReportingUiTests +{ + [Fact] + public void ReportingUsesSameIedCardProgressAuthorityWithNoFalseReportSuccess() + { + var device = new Iec61850MonitorDevice(); + device.BeginReportingProgress("Preparing exact DataSet authority…"); + Assert.True(device.IsReportingProgressActive); + Assert.Equal(0d, device.IedCardProgressPercent); + Assert.Contains("DataSet", device.BusyStage); + + device.UpdateReportingProgress(63d, "Selecting static FCDA rows"); + Assert.Equal(63d, device.IedCardProgressPercent); + device.UpdateReportingProgress(20d, "Stale stage"); + Assert.Equal(63d, device.IedCardProgressPercent); // monotonic + device.UpdateReportingProgress(100d, "Arming RCB"); + Assert.Equal(99d, device.IedCardProgressPercent); // never claim complete pre-finish + + device.CompleteReportingProgress(); + Assert.False(device.IsReportingProgressActive); + // Green progress is an arming-workflow indicator, not a live RCB + // traffic gate, SCD parity verdict, or SOE completeness claim. + Assert.False(device.HasReportStream); + } + + [Fact] + public void StopPreemptsReportingAndNextOperationGetsFreshProgressScope() + { + var device = new Iec61850MonitorDevice { IsConnected = true }; + device.BeginReportingProgress("Preparing…"); + Assert.False(device.CanPlayAction); + Assert.False(device.CanEditSignals); + Assert.True(device.CanStopAction); // operator exit is never disabled + device.CancelReportingProgress(); + Assert.True(device.IsReportingProgressCanceled); + Assert.False(device.IsReportingProgressActive); + device.CompleteReportingProgress(); // late finally cannot reassert success + Assert.True(device.IsReportingProgressCanceled); + + device.BeginReportingProgress("Fresh start…"); + Assert.False(device.IsReportingProgressCanceled); + Assert.Equal(0d, device.IedCardProgressPercent); + device.CompleteReportingProgress(); + } + + [Fact] + public void ExistingIedCardProgressLineIsReusedAndTurnsGreenDuringReporting() + { + var xaml = Read("MainWindow.xaml"); + var xml = XDocument.Parse(xaml); + XNamespace wpf = "http://schemas.microsoft.com/winfx/2006/xaml/presentation"; + XNamespace x = "http://schemas.microsoft.com/winfx/2006/xaml"; + var bar = xml.Descendants(wpf + "ProgressBar").Single(e => + (string?)e.Attribute(x + "Name") == "BrowserReadProgressLine"); + Assert.Equal("{Binding IedCardProgressPercent, Mode=OneWay}", + (string?)bar.Attribute("Value")); + var template = bar.Ancestors(wpf + "DataTemplate").First(); + var trigger = template.Descendants(wpf + "DataTrigger").Single(t => + (string?)t.Attribute("Binding") == "{Binding IsReportingProgressActive}"); + Assert.Contains(trigger.Elements(wpf + "Setter"), setter => + (string?)setter.Attribute("TargetName") == "BrowserReadProgressLine" && + (string?)setter.Attribute("Property") == "Foreground" && + (string?)setter.Attribute("Value") == "#16A34A"); + } + + [Fact] + public void ResponsiveSelectionKeepsWpfBoundObjectsOnDispatcherAndDoesNotPoll() + { + var workflow = Read("MainWindow.SharedSclWorkspace.cs"); + Assert.Contains("ToLookup(", Read("Services/Iec61850StaticDataSetAuthoritySelection.cs")); + Assert.Contains("Dispatcher.Yield", workflow); + Assert.Contains("device.BeginBulkSignalSelection()", workflow); + Assert.Contains("device.EndBulkSignalSelection()", workflow); + Assert.Contains("P0_STATIC_UI_WORK", workflow); + Assert.DoesNotContain("Task.Run(", workflow); + Assert.Contains("await ApplyStaticDataSetSelection(device);", + Read("MainWindow.SclQuickActions.cs")); + Assert.Contains("device.CompleteReportingProgress();", + Read("MainWindow.SclQuickActions.cs")); + Assert.Contains("device.UpdateReportingProgress(84", + Read("MainWindow.xaml.cs")); + } + + private static string Read(string path) + { + DirectoryInfo? dir = new(AppContext.BaseDirectory); + while (dir is not null) + { + var file = Path.Combine(dir.FullName, path); + if (File.Exists(file)) return File.ReadAllText(file); + dir = dir.Parent; + } + throw new FileNotFoundException(path); + } +} diff --git a/tests/ARSAS.Tests/StaticDataSetDualRoleControlRegressionTests.cs b/tests/ARSAS.Tests/StaticDataSetDualRoleControlRegressionTests.cs index ecc12faf0..2d5f62674 100644 --- a/tests/ARSAS.Tests/StaticDataSetDualRoleControlRegressionTests.cs +++ b/tests/ARSAS.Tests/StaticDataSetDualRoleControlRegressionTests.cs @@ -126,7 +126,7 @@ public void StaticControlProjection_RetainsControlWhenScalarFeedbackIsUnresolved ".Where(signal => signal.IsControlSignal && signal.IsValidControlObject)", StringComparison.Ordinal); var runtimeCandidates = authority.IndexOf( - "var candidates = signals", + "var candidates = datasetSignals", StringComparison.Ordinal); var noRuntimeCandidate = authority.IndexOf( "if (candidates.Length == 0)", diff --git a/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs b/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs index 38b4a4205..bb56d251e 100644 --- a/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs +++ b/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs @@ -61,7 +61,11 @@ public void SharedSclStaticSelection_UsesOnlyExactRcbBackedAriecMembershipRows() Assert.Contains("AddReportBackedDataSets(configurationModel, result)", authority, StringComparison.Ordinal); Assert.DoesNotContain("AddReportBackedDataSets(device.SclWorkspace?.DesignModel, result)", authority, StringComparison.Ordinal); Assert.DoesNotContain("AddReportBackedDataSets(device.LiveDiscoveryModel, result)", authority, StringComparison.Ordinal); - Assert.Contains("LiteralEquals(signal.DataSetReference, membership.DataSetReference)", authority, StringComparison.Ordinal); + // Exact DataSet membership is now enforced once by a case-insensitive, + // Trim-normalized lookup, not one full-catalog LINQ filter per member. + Assert.Contains("device.Signals.ToLookup(", authority, StringComparison.Ordinal); + Assert.Contains("signal => NormalizeLiteral(signal.DataSetReference)", authority, StringComparison.Ordinal); + Assert.Contains("signalsByDataSet[NormalizeLiteral(membership.DataSetReference)]", authority, StringComparison.Ordinal); Assert.Contains("LiteralEquals(signal.DisplayReference, memberReference)", authority, StringComparison.Ordinal); Assert.DoesNotContain("StartsWith(memberReference", authority, StringComparison.Ordinal); Assert.DoesNotContain("Contains(memberReference", authority, StringComparison.Ordinal);