From 42c7cfe1364faaeeee64180bd82e5dbef4f7355a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 09:30:35 +0700 Subject: [PATCH] integration(checkpoint): forward-port field-accepted Write leaf readback onto newer SSOT V2 baseline without SMV changes Preserve exact 10 field-proven Write/readback/test blobs from checkpoint 04350e15 and all other files from newer SSOT responsive-static PR #724 fb4932d. Trial ONLY, no upstream merge; do not repin native engine or revert SMV UI. User accepted Write popup close and IED Browser leaf MM S Read/SSOT; freeze existing code. For integration qualification only. --- BrowserBatchWriteWindow.xaml.cs | 85 +++---------- BrowserDataWriteWindow.xaml.cs | 80 +++---------- ControlModelWriteWindow.xaml.cs | 31 ++--- IedBrowserWriteWindow.xaml.cs | 11 +- MainWindow.IedBrowser.TypedDataWrite.cs | 53 ++++++++ MainWindow.IedBrowser.Write.cs | 7 +- MainWindow.IedBrowser.cs | 72 ++++++++++- Models/IedBrowserPostWriteReadRequest.cs | 59 +++++++++ .../IedBrowserPostWriteSsotRegressionTests.cs | 113 ++++++++++++++++++ .../TypedWriteEditorUxRegressionTests.cs | 6 +- 10 files changed, 358 insertions(+), 159 deletions(-) create mode 100644 Models/IedBrowserPostWriteReadRequest.cs create mode 100644 tests/ARSAS.Tests/IedBrowserPostWriteSsotRegressionTests.cs diff --git a/BrowserBatchWriteWindow.xaml.cs b/BrowserBatchWriteWindow.xaml.cs index 0fc210430..991c87e94 100644 --- a/BrowserBatchWriteWindow.xaml.cs +++ b/BrowserBatchWriteWindow.xaml.cs @@ -24,6 +24,10 @@ public partial class BrowserBatchWriteWindow : Window private readonly CancellationTokenSource _lifetime = new(); private bool _busy; private bool _prepared; + public long WriteAssociation { get; private set; } + public IReadOnlyList<(string Reference, string Fc)> AttemptedLeaves { get; private set; } = + Array.Empty<(string Reference, string Fc)>(); + public string WriteStatus { get; private set; } = ""; public ObservableCollection Rows { get; } = new(); @@ -325,7 +329,11 @@ private async void Write_Click(object sender, RoutedEventArgs e) if (!CurrentAssociation) throw new InvalidOperationException("IED association changed; no Write sent."); - StatusLabel.Text = $"Writing {steps.Count} changed DA(s) and verifying readback…"; + WriteAssociation = _association; + AttemptedLeaves = steps.Select(step => ( + step.Reference.Domain + "/" + step.Reference.Item, + step.Reference.FunctionalConstraint)).ToArray(); + StatusLabel.Text = $"Writing {steps.Count} changed DA(s)…"; // Native typed admission checks every GVA/old-value/association; // sequential, stop-on-failure, no automatic retry or rollback. var result = await _runtime.WriteBrowserDataAttributesAsync( @@ -341,83 +349,26 @@ private async void Write_Click(object sender, RoutedEventArgs e) } } - // Refresh every selected DA regardless of success/rejection. - // Verify the newly displayed value against the staged exact MMS - // bytes; an independent mismatch can never earn a green toast. - var readbackMismatch = false; - for (var i = 0; i < selected.Length; i++) - { - var row = selected[i]; - if (!CurrentAssociation) - { - row.Status += " · connection changed"; - readbackMismatch = true; - break; - } - try - { - var actual = await _runtime.PreviewBrowserDataAttributeWriteAsync( - _device, row.Reference!.Value, _lifetime.Token); - if (!CurrentAssociation) - { - readbackMismatch = true; - break; - } - if (actual is { IsAvailable: true, OldValue: not null }) - { - var matches = MmsDataCodec.Encode(actual.OldValue).AsSpan() - .SequenceEqual(MmsDataCodec.Encode(steps[i].ProposedValue)); - row.Old = actual.CurrentText; - row.Preview = actual; - row.ResetToReadback(actual.CurrentText); - if (!matches) - { - row.Status += " · Readback differs: " + actual.CurrentText; - readbackMismatch = true; - } - row.NotifyEditorChanged(); - } - else - { - row.Status += " · Readback unavailable"; - readbackMismatch = true; - } - } - catch (Exception ex) when (ex is not OutOfMemoryException) - { - row.Status += " · Readback unavailable: " + ex.Message; - readbackMismatch = true; - } - } - var anyFailure = readbackMismatch || - result.Rejected > 0 || result.Unknown > 0 || - result.Unverified > 0 || result.Unattempted > 0; - var shortReason = result.Rows.LastOrDefault()?.Message ?? "IED refused the requested value."; - if (shortReason.Length > 110) shortReason = shortReason[..107] + "…"; - StatusLabel.Text = anyFailure - ? $"Write ditolak · {result.Verified} verified, {result.Rejected} rejected, " + - $"{result.Unknown} unknown · {shortReason}" - : $"Write verified · {result.Verified} DA(s) · values refreshed from IED."; - ResultBanner.Background = new System.Windows.Media.SolidColorBrush( - anyFailure ? System.Windows.Media.Color.FromRgb(255, 242, 228) - : System.Windows.Media.Color.FromRgb(232, 248, 237)); + WriteStatus = result.Rejected > 0 || result.Unknown > 0 || + result.Unverified > 0 || result.Unattempted > 0 + ? $"Write ditolak / unverified: {result.Rows.LastOrDefault()?.Message ?? "IED refused"}" + : $"Write verified · {result.Verified} DA(s)"; } catch (OperationCanceledException) { - StatusLabel.Text = "Write uncertain; read the actual IED state before manual retry."; - ResultBanner.Background = new System.Windows.Media.SolidColorBrush( - System.Windows.Media.Color.FromRgb(255, 242, 228)); + WriteStatus = "Write uncertain: connection interrupted."; } catch (Exception ex) { - StatusLabel.Text = "Write ditolak / unresolved: " + ex.Message; - ResultBanner.Background = new System.Windows.Media.SolidColorBrush( - System.Windows.Media.Color.FromRgb(255, 242, 228)); + WriteStatus = "Write ditolak / unresolved: " + ex.Message; } finally { _prepared = false; SetBusy(false); + // Never read/write Browser state from the dialog. Let the owner + // close the popup and run canonical exact-leaf Read + SSOT ingress. + if (AttemptedLeaves.Count > 0 && IsLoaded) Close(); } } diff --git a/BrowserDataWriteWindow.xaml.cs b/BrowserDataWriteWindow.xaml.cs index d5218e933..8fd5f8454 100644 --- a/BrowserDataWriteWindow.xaml.cs +++ b/BrowserDataWriteWindow.xaml.cs @@ -26,6 +26,11 @@ public partial class BrowserDataWriteWindow : Window !_lifetime.IsCancellationRequested; private IedBrowserNativeWritePreview? _preview; private bool _busy; + public bool WriteAttempted { get; private set; } + public long WriteAssociation { get; private set; } + public string WriteStatus { get; private set; } = ""; + public (string Reference, string Fc) AttemptedLeaf => + (_reference.Domain + "/" + _reference.Item, _reference.FunctionalConstraint); public BrowserDataWriteWindow( Iec61850MonitorRuntime runtime, @@ -175,23 +180,23 @@ private async void Write_Click(object sender, RoutedEventArgs e) NewValueBox.IsEnabled = false; BoolPicker.IsEnabled = false; StatusLabel.Text = "Writing…"; - string feedback = "Write not verified."; - bool accepted = false; - bool readbackVerified = false; + bool verified = false; + string feedback = "IED did not verify Write."; + WriteAttempted = true; + WriteAssociation = _association; try { var step = new IedBrowserTypedWriteStep( _reference, p.GvaSignature, p.OldValue, proposed); var outcome = await _runtime.WriteBrowserDataAttributesAsync( _device, new[] { step }, true, _lifetime.Token); - if (!CurrentAssociation) return; - var result = outcome.Rows.Single(); - accepted = outcome.Verified == 1; - feedback = accepted ? "Write accepted" : "Write ditolak: " + result.Message; + verified = outcome.Verified == 1; + feedback = verified ? "Write verified" : + "Write ditolak: " + (outcome.Rows.FirstOrDefault()?.Message ?? "IED refused"); } catch (OperationCanceledException) { - feedback = "Write uncertain: connection interrupted."; + feedback = "Write outcome unknown: connection interrupted."; } catch (Exception ex) { @@ -199,62 +204,11 @@ private async void Write_Click(object sender, RoutedEventArgs e) } finally { - // Independent live GVA/Read after every attempted Write, even - // when the IED rejected it. Never send an automatic retry. - _preview = null; - if (CurrentAssociation) - { - try - { - var readback = await _runtime.PreviewBrowserDataAttributeWriteAsync( - _device, _reference, _lifetime.Token); - if (CurrentAssociation && readback is - { IsAvailable: true, OldValue: not null, Type: not null } live) - { - CurrentLabel.Text = "Current: " + live.CurrentText; - TypeLabel.Text = live.Type.MmsType; - _preview = live; - if (live.Type.MmsType == "boolean") - { - BoolPicker.Visibility = Visibility.Visible; - NewValueBox.Visibility = Visibility.Collapsed; - BoolPicker.SelectedIndex = live.CurrentText.Equals( - "true", StringComparison.OrdinalIgnoreCase) ? 0 : - live.CurrentText.Equals("false", StringComparison.OrdinalIgnoreCase) ? 1 : -1; - BoolPicker.IsEnabled = BoolPicker.SelectedIndex >= 0; - } - else - { - BoolPicker.Visibility = Visibility.Collapsed; - NewValueBox.Visibility = Visibility.Visible; - NewValueBox.Text = live.CurrentText; - NewValueBox.IsEnabled = true; - } - var matches = MmsDataCodec.Encode(live.OldValue).AsSpan() - .SequenceEqual(MmsDataCodec.Encode(proposed)); - readbackVerified = accepted && matches; - feedback = readbackVerified - ? "Write verified · Readback: " + live.CurrentText - : accepted ? "Write not verified: readback differs (" + live.CurrentText + ")" - : feedback + " · Current: " + live.CurrentText; - } - else - { - feedback += " · Readback unavailable."; - } - } - catch (Exception ex) when (ex is not OutOfMemoryException) - { - feedback += " · Readback unavailable: " + ex.Message; - } - } + WriteStatus = feedback; _busy = false; - RefreshInput(); - ResultBanner.Background = new System.Windows.Media.SolidColorBrush( - readbackVerified - ? System.Windows.Media.Color.FromRgb(232, 248, 237) - : System.Windows.Media.Color.FromRgb(255, 242, 228)); - StatusLabel.Text = feedback.Length > 165 ? feedback[..162] + "…" : feedback; + // No duplicate read of the value inside this popup. Browser will + // issue its own exact live MMS Read and publish only into SSOT. + if (IsLoaded) Close(); } } diff --git a/ControlModelWriteWindow.xaml.cs b/ControlModelWriteWindow.xaml.cs index 18082eef7..e21b50b07 100644 --- a/ControlModelWriteWindow.xaml.cs +++ b/ControlModelWriteWindow.xaml.cs @@ -21,6 +21,9 @@ public sealed record ModelChoice(Iec61850ControlModelKind Model, string Label); private readonly CancellationTokenSource _cancellation = new(); private bool _busy; public bool VerifiedWriteOccurred { get; private set; } + public bool WriteAttempted { get; private set; } + public string WriteStatus { get; private set; } = ""; + public long WriteAssociation { get; private set; } private bool _liveModelReady; private Iec61850ControlModelKind _liveModel = Iec61850ControlModelKind.Unknown; // Read/GVA evidence belongs to one live MMS association and one immutable @@ -151,10 +154,12 @@ private async void Write_Click(object sender, RoutedEventArgs e) _liveModelReady = false; ModelPicker.IsEnabled = false; UpdateWriteButton(); - StatusLabel.Text = "Writing and reading back ctlModel…"; + StatusLabel.Text = "Writing ctlModel…"; ResultBanner.Background = System.Windows.Media.Brushes.Transparent; string? failure = null; bool verified = false; + WriteAttempted = true; + WriteAssociation = _inspectedAssociation; try { // The pinned native dispatcher checks expected-old value and @@ -180,24 +185,14 @@ private async void Write_Click(object sender, RoutedEventArgs e) } finally { - // Always refresh the displayed DA from the live IED, including - // rejected and uncertain Writes. Read only; never retry Write. - if (!_cancellation.IsCancellationRequested && IsLoaded) - { - await ReadModelAsync(); - if (verified && _liveModelReady && _liveModel == selected.Model) - { - StatusLabel.Text = $"Write verified · Readback: {(int)_liveModel} ({selected.Label})"; - ResultBanner.Background = new System.Windows.Media.SolidColorBrush( - System.Windows.Media.Color.FromRgb(232, 248, 237)); - } - else - { - ShowWriteFailure(failure ?? "Readback did not confirm the requested value."); - } - } + WriteStatus = verified + ? "ctlModel Write verified; refreshing Data Model…" + : "ctlModel Write ditolak / unknown: " + + (string.IsNullOrWhiteSpace(failure) ? "IED did not verify Write." : failure); _busy = false; - UpdateWriteButton(); + // Browser owns the post-Write MMS Read and SSOT publication. + // Close the editor immediately, including on rejected Writes. + if (IsLoaded) Close(); } } diff --git a/IedBrowserWriteWindow.xaml.cs b/IedBrowserWriteWindow.xaml.cs index 3aba80995..3221de18d 100644 --- a/IedBrowserWriteWindow.xaml.cs +++ b/IedBrowserWriteWindow.xaml.cs @@ -26,6 +26,9 @@ public partial class IedBrowserWriteWindow : Window // Invalidate stale Command Dock permission on modal close in that case. public HashSet PotentiallyChangedControlModels { get; } = new(StringComparer.OrdinalIgnoreCase); + public long WriteAssociation => _association; + public List<(string Reference, string Fc)> AttemptedLeaves { get; } = new(); + public string WriteStatus { get; private set; } = ""; public ObservableCollection Rows { get; } public IedBrowserWriteWindow( @@ -203,6 +206,7 @@ private async void Write_Click(object sender, RoutedEventArgs e) row.ExpireLiveRead(); // each attempt requires a NEW explicit Read if (row.IsControlModel) PotentiallyChangedControlModels.Add(row.Reference[..^".ctlModel".Length]); + AttemptedLeaves.Add((row.Reference, row.FunctionalConstraint)); var result = await _runtime.WriteBrowserConfigurationAsync( _device.DeviceId, _model, _association, row, requested, _lifetime.Token); row.Current = result.ReadbackVerified ? result.After : result.Before; @@ -234,7 +238,12 @@ private async void Write_Click(object sender, RoutedEventArgs e) ResultBanner.Background = new System.Windows.Media.SolidColorBrush( System.Windows.Media.Color.FromRgb(255, 242, 228)); } - finally { SetBusy(false); } + finally + { + WriteStatus = StatusText.Text; + SetBusy(false); + if (AttemptedLeaves.Count > 0 && IsLoaded) Close(); + } } private void Close_Click(object sender, RoutedEventArgs e) => Close(); diff --git a/MainWindow.IedBrowser.TypedDataWrite.cs b/MainWindow.IedBrowser.TypedDataWrite.cs index f9c5236c8..d755b9216 100644 --- a/MainWindow.IedBrowser.TypedDataWrite.cs +++ b/MainWindow.IedBrowser.TypedDataWrite.cs @@ -175,6 +175,10 @@ private void IedBrowserWrite_Click(object sender, RoutedEventArgs e) Owner = this }; dialog.ShowDialog(); + if (dialog.WriteAttempted && device.LiveDiscoveryModel is { } model) + QueueIedBrowserPostWriteRead(device, model, dialog.WriteAssociation, + new[] { (Reference: modelSignal.ObjectReference + ".ctlModel", Fc: "CF") }, + dialog.WriteStatus); if (dialog.VerifiedWriteOccurred && device.IsConnected) _ = RefreshControlValuesAsync(device, force: true); } @@ -185,6 +189,9 @@ private void IedBrowserWrite_Click(object sender, RoutedEventArgs e) Owner = this }; dialog.ShowDialog(); + if (dialog.WriteAttempted && device.LiveDiscoveryModel is { } model) + QueueIedBrowserPostWriteRead(device, model, dialog.WriteAssociation, + new[] { dialog.AttemptedLeaf }, dialog.WriteStatus); } else if (owner is not null && device.LiveDiscoveryModel is { } model) { @@ -193,6 +200,9 @@ private void IedBrowserWrite_Click(object sender, RoutedEventArgs e) Owner = this }; dialog.ShowDialog(); + if (dialog.AttemptedLeaves.Count > 0) + QueueIedBrowserPostWriteRead(device, model, dialog.WriteAssociation, + dialog.AttemptedLeaves, dialog.WriteStatus); } RefreshIedBrowserWriteButton(); // A successful MMS Write is NOT process feedback. An explicit Read or @@ -214,7 +224,50 @@ private void IedBrowserWriteDo_Click(object sender, RoutedEventArgs e) Owner = this }; dialog.ShowDialog(); + if (dialog.AttemptedLeaves.Count > 0) + QueueIedBrowserPostWriteRead(device, model, dialog.WriteAssociation, + dialog.AttemptedLeaves, dialog.WriteStatus); RefreshIedBrowserWriteButton(); } + private async void QueueIedBrowserPostWriteRead( + Iec61850MonitorDevice device, LiveIedModelDiscoveryDocument model, + long association, IReadOnlyCollection<(string Reference, string Fc)> attempted, + string status) + { + // Never push the popup's preview into Data Model. Resolve exact, + // uniquely declared live DA identities, then enter the SAME typed MMS + // Read → SSOT → visible-row pipeline as the Browser Read button. + var request = IedBrowserPostWriteReadRequest.Create( + device, model, association, attempted, status); + if (request is null || !ReferenceEquals(SelectedDevice, device)) + { + IedBrowserDetailStatus.Text = + "Post-Write Read unavailable: IED association/model changed."; + return; + } + IedBrowserDetailStatus.Text = string.IsNullOrWhiteSpace(status) + ? "Reading written DA from IED into Data Model…" + : status; + // A prior automatic Browser Read may still be draining when the + // modal closes. Do not silently discard the post-Write refresh or + // cancel that reader. Wait briefly for its bounded flight to finish. + for (var checks = 0; + (_iedBrowserReadOperation is not null || device.IsBusy) && checks < 200; + checks++) + { + await Task.Delay(100); + if (!request.Matches(device) || !ReferenceEquals(SelectedDevice, device)) + return; // reconnect or navigation changed: no stale publication + } + if (!request.Matches(device) || !ReferenceEquals(SelectedDevice, device) || + _iedBrowserReadOperation is not null || device.IsBusy) + { + IedBrowserDetailStatus.Text = + "Post-Write Read not started; previous Browser Read still active."; + return; + } + IedBrowserReadValues_Click(new Button { Tag = request }, new RoutedEventArgs()); + } + } diff --git a/MainWindow.IedBrowser.Write.cs b/MainWindow.IedBrowser.Write.cs index 1967943b0..e0f039aad 100644 --- a/MainWindow.IedBrowser.Write.cs +++ b/MainWindow.IedBrowser.Write.cs @@ -33,6 +33,9 @@ private void IedBrowserWriteValues_Click(object sender, RoutedEventArgs e) Owner = this }; editor.ShowDialog(); + if (editor.AttemptedLeaves.Count > 0) + QueueIedBrowserPostWriteRead(device, liveModel, editor.WriteAssociation, + editor.AttemptedLeaves, editor.WriteStatus); if (editor.PotentiallyChangedControlModels.Count > 0 && device.IsConnected && ReferenceEquals(device.LiveDiscoveryModel, liveModel)) { @@ -52,7 +55,7 @@ private void IedBrowserWriteValues_Click(object sender, RoutedEventArgs e) ResetControlPreloadAttempts(device); _ = PreloadControlModelsAsync(); } - IedBrowserDetailStatus.Text = - "MMS configuration editor closed. Use explicit Read to refresh visible live values."; + if (editor.AttemptedLeaves.Count == 0) + IedBrowserDetailStatus.Text = "Configuration editor closed; no Write sent."; } } diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index 8b5247d36..d96d7e9d0 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -381,6 +381,12 @@ private async void IedBrowserReadValues_Click(object sender, RoutedEventArgs e) // A dedicated read-only MMS association is used if monitoring is active. // Its bounded reads never interfere with the reporting session. + var postWriteRequest = (sender as Button)?.Tag as IedBrowserPostWriteReadRequest; + if (postWriteRequest is not null && !postWriteRequest.Matches(device)) + { + IedBrowserDetailStatus.Text = "IED association changed; post-Write Read cancelled."; + return; + } var configRequest = (sender as Button)?.Tag as IedBrowserVisibleConfigurationReadRequest; var companionRequest = (sender as Button)?.Tag as IedBrowserExactCompanionReadRequest; // A selected Report's CF unit hydration is a READ OF THE REFERENCED @@ -388,9 +394,11 @@ private async void IedBrowserReadValues_Click(object sender, RoutedEventArgs e) // ordinary typed Read → association-fenced LiveValues pipeline. var reportUnitRequest = sender is Button { Tag: "ReportUnitAuto" }; var automatic = sender is Button { Tag: "Auto" } || - configRequest is not null || companionRequest is not null || reportUnitRequest; + configRequest is not null || companionRequest is not null || + reportUnitRequest || postWriteRequest is not null; var readAll = sender is Button { Tag: "All" }; - if (!readAll && !reportUnitRequest && companionRequest is null) + if (!readAll && !reportUnitRequest && companionRequest is null && + postWriteRequest is null) { switch (IedBrowserReadPlan.SelectionKind(_iedBrowserNavigation.Current)) { @@ -425,7 +433,14 @@ private async void IedBrowserReadValues_Click(object sender, RoutedEventArgs e) targets = IedBrowserReadPlan.PrioritizeExplicitLn(targets); if (automatic) { - if (reportUnitRequest) + if (postWriteRequest is not null) + { + // Exactly the DA(s) the operator attempted, regardless of + // tree selection or the current LN page. The ordinary MMS + // Read result inbox is the ONLY publisher to LiveValues. + targets = postWriteRequest.Targets; + } + else if (reportUnitRequest) { if (currentPage is not { Kind: "RCB", Payload: RcbExportRow }) return; @@ -514,7 +529,7 @@ _iedBrowserReadFailureEvidence is { } previousFailures && // Admission is claimed only after a concrete, nonempty verified plan // reaches the read handler. A queued Dispatcher callback is NOT a read. // Rejected/busy/cancelled preflight cannot poison a future visit. - var automaticKey = automatic && currentPage is not null + var automaticKey = automatic && postWriteRequest is null && currentPage is not null ? IedBrowserFocusedAdmission.PageKey( currentPage.Key, reportUnitRequest ? "|unit-CF" : companionRequest is not null @@ -535,7 +550,13 @@ companionRequest is not null // connection and reject a previously planned schema. IedBrowserReadBindingIndex? bindingIndex = null; var cts = CancellationTokenSource.CreateLinkedTokenSource(_applicationCancellation.Token); - if (automatic) cts.CancelAfter(TimeSpan.FromSeconds(15)); + if (automatic) + { + if (postWriteRequest is null) + cts.CancelAfter(TimeSpan.FromSeconds(15)); + else + cts.CancelAfter(TimeSpan.FromSeconds(30)); + } // Read All may include thousands of attributes. It is cancellable by the // operator, never a new continuous acquisition/polling process. _iedBrowserReadOperation = cts; @@ -544,6 +565,9 @@ companionRequest is not null if (!automatic || currentPage?.Kind != "SGCB") _iedBrowserReadFailureEvidence = null; var sourceDevice = device; + // A post-Write refresh must prove NEW exact association-bound MMS evidence; + // a cached pre-Write observation is never a successful readback. + var postWriteBaselineRevision = sourceDevice.LiveValues.Version; var successes = 0; var failures = 0; var unverifiedReadTargets = 0; @@ -756,6 +780,35 @@ bool HasTerminalFailure(IedBrowserReadTarget target) : "\nFirst unavailable attributes (no scalar was published):\n" + string.Join("\n", failureExamples)); RefreshIedBrowserVisibleValues(); + if (postWriteRequest is not null) + { + // The Browser owns this fresh Read and the visible grid. + // Verify all attempted exact reference/FC keys obtained + // a new MMS Read since the post-Write sweep started. + var verified = 0; + foreach (var target in postWriteRequest.Targets) + { + if (sourceDevice.LiveValues.TryGet(target.Reference, + target.FunctionalConstraint, out var observed, + preferReport: false) && + !observed.IsStale && + observed.Association == readAssociation && + observed.Revision > postWriteBaselineRevision && + string.Equals(observed.Source, "MMS Read", + StringComparison.Ordinal)) + verified++; + } + var writeOutcome = string.IsNullOrWhiteSpace(postWriteRequest.WriteStatus) + ? "Write attempted" : postWriteRequest.WriteStatus; + var readOutcome = verified == postWriteRequest.Targets.Count + ? $"Data Model refreshed from IED · {verified}/{postWriteRequest.Targets.Count} DA MMS Read verified." + : $"Data Model Read incomplete · {verified}/{postWriteRequest.Targets.Count} DA refreshed; " + + $"{failures} Read failed. Check the leaf before another Write."; + // A successful Read of the actual old value never upgrades + // a rejected Write into an apparent Write success. + IedBrowserDetailStatus.Text = writeOutcome + " · " + readOutcome; + IedBrowserDetailStatus.ToolTip = IedBrowserDetailStatus.Text; + } // A few permanently refused SG leaves must not block the next // finite SGCB chunk. Per-page auto admission still caps attempts // to three; this never becomes Read All or cyclic polling. @@ -767,7 +820,13 @@ bool HasTerminalFailure(IedBrowserReadTarget target) { DrainReadResults(); // retain observations completed before cancellation if (ReferenceEquals(SelectedDevice, sourceDevice)) + { IedBrowserSubtitle.Text = $"Read cancelled · {successes:N0} value(s) received; no settings changed."; + if (postWriteRequest is not null) + IedBrowserDetailStatus.Text = + postWriteRequest.WriteStatus + + " · Data Model Read interrupted · verify the exact DA before another Write."; + } } catch (Exception ex) { @@ -776,6 +835,9 @@ bool HasTerminalFailure(IedBrowserReadTarget target) { device.MarkDiscoveryFailed(ex.Message); IedBrowserSubtitle.Text = "Read failed: " + ex.Message; + if (postWriteRequest is not null) + IedBrowserDetailStatus.Text = + postWriteRequest.WriteStatus + " · Data Model not refreshed · MMS Read failed: " + ex.Message; } } finally diff --git a/Models/IedBrowserPostWriteReadRequest.cs b/Models/IedBrowserPostWriteReadRequest.cs new file mode 100644 index 000000000..5b71afa7d --- /dev/null +++ b/Models/IedBrowserPostWriteReadRequest.cs @@ -0,0 +1,59 @@ +using AR.Iec61850.Discovery; + +namespace ArIED61850Tester.Models; + +/// +/// One-shot, exact-leaf MMS Read after an operator Write. Resolves targets +/// from the same live discovery model and association as the completed dialog. +/// Nothing is published to the Browser until the normal typed Read pipeline +/// independently returns evidence from the IED. +/// +public sealed record IedBrowserPostWriteReadRequest( + string DeviceId, long Association, LiveIedModelDiscoveryDocument Model, + IReadOnlyList Targets, string WriteStatus = "") +{ + public static IedBrowserPostWriteReadRequest? Create( + Iec61850MonitorDevice device, + LiveIedModelDiscoveryDocument? model, + long association, + IReadOnlyCollection<(string Reference, string Fc)> attempted, + string writeStatus = "") + { + if (model is null || !device.IsConnected || device.IsDemo || + device.LiveValues.IsOffline || device.LiveValues.Association != association || + !ReferenceEquals(device.LiveDiscoveryModel, model) || + attempted.Count is < 1 or > 64) + return null; + + var attributes = model.LogicalDevices.SelectMany(ld => ld.LogicalNodes) + .SelectMany(ln => ln.DataObjects) + .SelectMany(obj => obj.Attributes) + .Where(attr => attr.FunctionalConstraint is "CF" or "DC" or "SP" or "SG" or "SE") + .ToLookup(attr => IedBrowserLiveValues.CanonicalReference(attr.MmsReference) + + "|" + attr.FunctionalConstraint.ToUpperInvariant(), + StringComparer.Ordinal); + var targets = new List(attempted.Count); + var seen = new HashSet(StringComparer.Ordinal); + foreach (var (reference, fc) in attempted) + { + if (string.IsNullOrWhiteSpace(reference) || + fc is not ("CF" or "DC" or "SP" or "SG" or "SE")) return null; + var key = IedBrowserLiveValues.CanonicalReference(reference) + "|" + fc; + if (!seen.Add(key)) continue; + var group = attributes[key].Take(2).ToArray(); + if (group.Length != 1) return null; // never borrow another DA or ambiguous FC + var attr = group[0]; + targets.Add(new IedBrowserReadTarget(attr.MmsReference, + attr.FunctionalConstraint, + string.IsNullOrWhiteSpace(attr.SclBType) ? attr.MmsType : attr.SclBType)); + } + return targets.Count == 0 ? null : + new IedBrowserPostWriteReadRequest(device.DeviceId, association, model, targets, + writeStatus); + } + + public bool Matches(Iec61850MonitorDevice device) + => device.DeviceId == DeviceId && device.IsConnected && !device.IsDemo && + !device.LiveValues.IsOffline && device.LiveValues.Association == Association && + ReferenceEquals(device.LiveDiscoveryModel, Model); +} diff --git a/tests/ARSAS.Tests/IedBrowserPostWriteSsotRegressionTests.cs b/tests/ARSAS.Tests/IedBrowserPostWriteSsotRegressionTests.cs new file mode 100644 index 000000000..b888fa7c8 --- /dev/null +++ b/tests/ARSAS.Tests/IedBrowserPostWriteSsotRegressionTests.cs @@ -0,0 +1,113 @@ +using System.Xml.Linq; + +namespace ARSAS.Tests; + +/// +/// Locks the operator-visible post-Write contract: editor closes, Browser +/// independently reads exact live DA(s), and only canonical SSOT projects +/// the result into the Data Model. Never copy popup previews into UI rows. +/// +public sealed class IedBrowserPostWriteSsotRegressionTests +{ + private static string Source(string file) + { + for (DirectoryInfo? dir = new(AppContext.BaseDirectory); dir is not null; dir = dir.Parent) + { + var path = Path.Combine(dir.FullName, file); + if (File.Exists(path)) return File.ReadAllText(path); + } + throw new FileNotFoundException(file); + } + + [Fact] + public void AllWriteEditors_AutoCloseAndHandOffAttemptedExactLeafIdentities() + { + var single = Source("BrowserDataWriteWindow.xaml.cs"); + var ctl = Source("ControlModelWriteWindow.xaml.cs"); + var batch = Source("BrowserBatchWriteWindow.xaml.cs"); + var legacy = Source("IedBrowserWriteWindow.xaml.cs"); + Assert.Contains("public bool WriteAttempted", single); + Assert.Contains("WriteAssociation = _association", single); + Assert.Contains("if (IsLoaded) Close();", single); + Assert.Contains("public bool WriteAttempted", ctl); + Assert.Contains("WriteAssociation = _inspectedAssociation", ctl); + Assert.Contains("if (IsLoaded) Close();", ctl); + Assert.Contains("AttemptedLeaves = steps.Select(", batch); + Assert.Contains("if (AttemptedLeaves.Count > 0 && IsLoaded) Close();", batch); + Assert.Contains("AttemptedLeaves.Add((row.Reference, row.FunctionalConstraint))", legacy); + Assert.Contains("if (AttemptedLeaves.Count > 0 && IsLoaded) Close();", legacy); + Assert.DoesNotContain("await ReadModelAsync();", ctl.Split("private async void Write_Click(")[1]); + Assert.DoesNotContain("PreviewBrowserDataAttributeWriteAsync(", + single.Split("private async void Write_Click(")[1]); + Assert.DoesNotContain("PreviewBrowserDataAttributeWriteAsync(", + batch.Split("private async void Write_Click(")[1].Split("private void Close_Click")[0] + .Split("var result = await _runtime.WriteBrowserDataAttributesAsync(")[1]); + } + + [Fact] + public void BrowserOwner_QueuesScopedReadAfterModalNotAnotherPopupPreview() + { + var toolbar = Source("MainWindow.IedBrowser.TypedDataWrite.cs"); + Assert.Contains("QueueIedBrowserPostWriteRead(device, model, dialog.WriteAssociation", toolbar); + Assert.Contains("new[] { dialog.AttemptedLeaf }", toolbar); + Assert.Contains("dialog.AttemptedLeaves, dialog.WriteStatus", toolbar); + Assert.Contains("IedBrowserReadValues_Click(new Button { Tag = request }", toolbar); + Assert.Contains("_iedBrowserReadOperation is not null || device.IsBusy", toolbar); + Assert.Contains("await Task.Delay(100);", toolbar); + Assert.DoesNotContain("device.LiveValues.Publish(", toolbar); + var legacy = Source("MainWindow.IedBrowser.Write.cs"); + Assert.Contains("editor.AttemptedLeaves, editor.WriteStatus", legacy); + var pipeline = Source("MainWindow.IedBrowser.cs"); + Assert.Contains("var postWriteRequest =", pipeline); + Assert.Contains("postWriteRequest.Matches(device)", pipeline); + Assert.Contains("targets = postWriteRequest.Targets;", pipeline); + Assert.Contains("postWriteRequest is null && currentPage is not null", pipeline); + Assert.Contains("sourceDevice.LiveValues.PublishForAssociation(", pipeline); + Assert.Contains("RefreshIedBrowserVisibleValues();", pipeline); + } + + [Fact] + public void PostWriteBrowserRead_DemandsNewExactSsotEvidence_NotOldPopupOrCachedData() + { + var pipeline = Source("MainWindow.IedBrowser.cs"); + Assert.Contains("var postWriteBaselineRevision = sourceDevice.LiveValues.Version;", pipeline); + Assert.Contains("observed.Revision > postWriteBaselineRevision", pipeline); + Assert.Contains("observed.Association == readAssociation", pipeline); + Assert.Contains("preferReport: false", pipeline); + Assert.Contains("string.Equals(observed.Source, \"MMS Read\"", pipeline); + Assert.Contains("postWriteRequest.Targets", pipeline); + Assert.Contains("RefreshIedBrowserVisibleValues();", pipeline); + Assert.Contains("Data Model refreshed from IED", pipeline); + Assert.Contains("Data Model Read incomplete", pipeline); + Assert.Contains("Data Model not refreshed", pipeline); + Assert.Contains("Data Model Read interrupted", pipeline); + Assert.Contains("postWriteRequest.WriteStatus", pipeline); + Assert.Contains("writeOutcome + \" · \" + readOutcome", pipeline); + Assert.Contains("IedBrowserDetailStatus.ToolTip", pipeline); + Assert.Contains("device, model, association, attempted, status", Source("MainWindow.IedBrowser.TypedDataWrite.cs")); + Assert.Contains("string WriteStatus = \"\"", Source("Models/IedBrowserPostWriteReadRequest.cs")); + Assert.Contains("TimeSpan.FromSeconds(30)", pipeline); + Assert.DoesNotContain("postWriteRequest.Targets.First().Value", pipeline); + } + + [Fact] + public void PostWriteReadRequest_FailsClosedAcrossReconnectOrAmbiguousDataModel() + { + var source = Source("Models/IedBrowserPostWriteReadRequest.cs"); + Assert.Contains("ReferenceEquals(device.LiveDiscoveryModel, model)", source); + Assert.Contains("device.LiveValues.Association != association", source); + Assert.Contains("attempted.Count is < 1 or > 64", source); + Assert.Contains("if (group.Length != 1) return null;", source); + Assert.Contains("IedBrowserLiveValues.CanonicalReference", source); + Assert.Contains("new IedBrowserReadTarget(attr.MmsReference", source); + Assert.Contains("or \"SG\" or \"SE\"", source); + Assert.DoesNotContain("PublishForAssociation(", source); + Assert.DoesNotContain("WriteBrowserDataAttributesAsync(", source); + + foreach (var file in new[] + { + "BrowserDataWriteWindow.xaml", "BrowserBatchWriteWindow.xaml", + "ControlModelWriteWindow.xaml", "IedBrowserWriteWindow.xaml" + }) XDocument.Parse(Source(file)); + } +} diff --git a/tests/ARSAS.Tests/TypedWriteEditorUxRegressionTests.cs b/tests/ARSAS.Tests/TypedWriteEditorUxRegressionTests.cs index b27b653d1..d86ea1031 100644 --- a/tests/ARSAS.Tests/TypedWriteEditorUxRegressionTests.cs +++ b/tests/ARSAS.Tests/TypedWriteEditorUxRegressionTests.cs @@ -103,9 +103,9 @@ public void BrowserWrite_ProvidesSingleAndMultiLeafWithoutSecondModalConfirmatio Assert.Contains("PreviewBrowserDataAttributeWriteAsync(", Source("BrowserDataWriteWindow.xaml.cs")); var batch = Source("BrowserBatchWriteWindow.xaml.cs"); Assert.Contains("await PrimeLiveEditorsAsync();", batch); - Assert.Contains("row.ResetToReadback(actual.CurrentText);", batch); - Assert.Contains("var readbackMismatch = false;", batch); - Assert.Contains("MmsDataCodec.Encode(steps[i].ProposedValue)", batch); + Assert.Contains("AttemptedLeaves = steps.Select(", batch); + Assert.Contains("if (AttemptedLeaves.Count > 0 && IsLoaded) Close();", batch); + Assert.DoesNotContain("row.ResetToReadback(actual.CurrentText);", batch); Assert.Contains("baseline.GvaSignature, preview.GvaSignature", batch); Assert.Contains("MmsDataCodec.Encode(baseline.OldValue)", batch); Assert.Contains("WriteBrowserDataAttributesAsync(", batch);