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);