From 85a4fbac3ea9749d9f7e6a09e5a1951022589da3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 20:51:03 +0700 Subject: [PATCH 1/8] feat(write): compact ctlModel Name/Value picker with pinned Material actions --- ControlModelWriteWindow.xaml | 124 +++++++++++++++++++++++++---------- 1 file changed, 90 insertions(+), 34 deletions(-) diff --git a/ControlModelWriteWindow.xaml b/ControlModelWriteWindow.xaml index 77171121e..f706f9010 100644 --- a/ControlModelWriteWindow.xaml +++ b/ControlModelWriteWindow.xaml @@ -1,53 +1,109 @@ - + - + - + - + TextTrimming="CharacterEllipsis" + ToolTip="{Binding Text, RelativeSource={RelativeSource Self}}" + Margin="2,0,2,10"/> + + + - + - + - - + + + + + + + + + - - - - - - - + + From 5ca60e680ed1294b1e4db71447ec60e060f93c10 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 20:51:52 +0700 Subject: [PATCH 2/8] fix(write): fence live ctlModel inspection across reconnect and require explicit one-shot confirmation --- ControlModelWriteWindow.xaml.cs | 57 ++++++++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/ControlModelWriteWindow.xaml.cs b/ControlModelWriteWindow.xaml.cs index bf11df221..2a64bb95d 100644 --- a/ControlModelWriteWindow.xaml.cs +++ b/ControlModelWriteWindow.xaml.cs @@ -22,6 +22,16 @@ public sealed record ModelChoice(Iec61850ControlModelKind Model, string Label); private bool _busy; private bool _liveModelReady; private Iec61850ControlModelKind _liveModel = Iec61850ControlModelKind.Unknown; + // Read/GVA evidence belongs to one live MMS association and one immutable + // model instance. A reconnect cannot keep a previously enabled Write valid. + private long _inspectedAssociation; + private object? _inspectedLiveModel; + + private bool InspectionIsCurrent => + _inspectedLiveModel is not null && + ReferenceEquals(_device.LiveDiscoveryModel, _inspectedLiveModel) && + _device.LiveValues.Association == _inspectedAssociation && + _device.IsConnected && !_cancellation.IsCancellationRequested; public ObservableCollection Choices { get; } = new() { @@ -52,7 +62,7 @@ private void ModelPicker_SelectionChanged(object sender, SelectionChangedEventAr private void UpdateWriteButton() { ReadButton.IsEnabled = !_busy; - WriteButton.IsEnabled = !_busy && _device.IsConnected && _liveModelReady && + WriteButton.IsEnabled = !_busy && _liveModelReady && InspectionIsCurrent && ModelPicker.SelectedItem is ModelChoice choice && choice.Model != _liveModel; } @@ -60,15 +70,30 @@ private async Task ReadModelAsync() { _busy = true; _liveModelReady = false; + _inspectedLiveModel = null; ModelPicker.IsEnabled = false; UpdateWriteButton(); StatusLabel.Text = "Reading current ctlModel…"; + var association = _device.LiveValues.Association; + var model = _device.LiveDiscoveryModel; FailureDetails.Visibility = Visibility.Collapsed; FailureDetails.Text = string.Empty; try { + if (!_device.IsConnected || model is null) + throw new InvalidOperationException("A connected IED with verified live model is required."); var capabilities = await _runtime.InspectControlAsync( _device.DeviceId, _signal, _cancellation.Token); + if (_cancellation.IsCancellationRequested || !IsLoaded) + return; + if (!_device.IsConnected || _device.LiveValues.Association != association || + !ReferenceEquals(_device.LiveDiscoveryModel, model)) + { + StatusLabel.Text = "IED connection or live model changed. Read again before Write."; + return; + } + _inspectedAssociation = association; + _inspectedLiveModel = model; _liveModel = capabilities.ControlModel; _liveModelReady = _liveModel != Iec61850ControlModelKind.Unknown && capabilities.EngineControlServiceAvailable; @@ -105,12 +130,36 @@ private async void Read_Click(object sender, RoutedEventArgs e) private async void Write_Click(object sender, RoutedEventArgs e) { - if (_busy || !_liveModelReady || !_device.IsConnected || + if (_busy || !_liveModelReady || !InspectionIsCurrent || ModelPicker.SelectedItem is not ModelChoice selected || selected.Model == _liveModel) return; - // Exact expected value is confirmed again by ARIEC immediately before - // the one permitted CF Write. No automatic mode trials. + // Explicit operator confirmation: changing ctlModel affects later + // command admission and must never be a single accidental click. var expected = _liveModel; + var decision = MessageBox.Show( + this, + $"IED: {_device.Name}\n" + + $"DA: {_signal.ObjectReference}.ctlModel [CF]\n" + + $"Current: {(int)expected}\n" + + $"Requested: {selected.Label}\n\n" + + "Send one configuration Write and verify its exact readback?\n" + + "No Operate or automatic retry will be sent.", + "Confirm IEC 61850 ctlModel Write", + MessageBoxButton.OKCancel, MessageBoxImage.Warning, MessageBoxResult.Cancel); + if (decision != MessageBoxResult.OK) + return; + // An operator may spend minutes in the confirmation dialog. Recheck + // live identity AFTER confirmation and BEFORE entering the native call. + if (!InspectionIsCurrent || ModelPicker.SelectedItem is not ModelChoice current || + current.Model != selected.Model || _liveModel != expected) + { + _liveModelReady = false; + UpdateWriteButton(); + ShowWriteFailure("IED association or selected ctlModel changed. Read again; no Write was sent."); + return; + } + // Native ARIEC will additionally compare expected ctlModel, dispatch + // at most one CF Write, and independently verify MMS readback. _busy = true; _liveModelReady = false; ModelPicker.IsEnabled = false; From 133bd67646e7cd85f60a5ce1208a85d5a116fe73 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 20:52:37 +0700 Subject: [PATCH 3/8] test(write): guard compact CF picker, Material icons, live-association lease and confirmed native dispatch --- .../ControlModelWriteAcceptanceTests.cs | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 tests/ARSAS.Tests/ControlModelWriteAcceptanceTests.cs diff --git a/tests/ARSAS.Tests/ControlModelWriteAcceptanceTests.cs b/tests/ARSAS.Tests/ControlModelWriteAcceptanceTests.cs new file mode 100644 index 000000000..595b8f51b --- /dev/null +++ b/tests/ARSAS.Tests/ControlModelWriteAcceptanceTests.cs @@ -0,0 +1,91 @@ +using System.Xml.Linq; + +namespace ARSAS.Tests; + +/// +/// Regression contracts for the dedicated CF ctlModel editor. The native +/// semantic gate is tested separately by the control service suite; these +/// tests defend operator affordances and reconnect safety at the WPF seam. +/// +public sealed class ControlModelWriteAcceptanceTests +{ + private static readonly XNamespace P = "http://schemas.microsoft.com/winfx/2006/xaml/presentation"; + + private static string Source(string path) + { + for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir != null; dir = dir.Parent) + { + var file = Path.Combine(dir.FullName, path); + if (File.Exists(file)) return File.ReadAllText(file); + } + throw new FileNotFoundException(path); + } + + [Fact] + public void SingleCfEditorHasOneInlineDropdownAndPersistentMaterialActionFooter() + { + var window = XDocument.Parse(Source("ControlModelWriteWindow.xaml")); + var root = Assert.Single(window.Elements(P + "Window")); + Assert.Equal("Write Data to IED", (string?)root.Attribute("Title")); + Assert.Equal("CanResize", (string?)root.Attribute("ResizeMode")); + Assert.Equal("{StaticResource AppFontFamily}", (string?)root.Attribute("FontFamily")); + Assert.Equal("450", (string?)root.Attribute("MaxHeight")); + + var picker = Assert.Single(window.Descendants(P + "ComboBox")); + Assert.Equal("ModelPicker", (string?)picker.Attribute(XName.Get("Name", + "http://schemas.microsoft.com/winfx/2006/xaml"))); + Assert.Equal("{Binding Choices}", (string?)picker.Attribute("ItemsSource")); + Assert.Equal("False", (string?)picker.Attribute("IsEnabled")); + Assert.Contains(window.Descendants(P + "TextBlock"), x => (string?)x.Attribute("Text") == "Name"); + Assert.Contains(window.Descendants(P + "TextBlock"), x => (string?)x.Attribute("Text") == "Value"); + Assert.Contains(window.Descendants(P + "TextBlock"), x => (string?)x.Attribute("Text") == "ctlModel [CF]"); + Assert.All(window.Descendants(P + "TextBox"), x => + Assert.Equal("True", (string?)x.Attribute("IsReadOnly"))); + + var button = Assert.Single(window.Descendants(P + "Button").Where(x => + (string?)x.Attribute("Click") == "Write_Click")); + Assert.Equal("94", (string?)button.Attribute("Width")); + Assert.Equal("3", (string?)button.Attribute("Grid.Row")); + Assert.Contains(button.Descendants(P + "TextBlock"), x => + (string?)x.Attribute("Text") == "Write"); + Assert.Contains(button.Descendants(P + "Path"), x => + (string?)x.Attribute("Data") == "{StaticResource MaterialEditIcon}"); + Assert.Contains(window.Descendants(P + "Path"), x => + (string?)x.Attribute("Data") == "{StaticResource MaterialRefreshIcon}"); + Assert.Contains(window.Descendants(P + "Path"), x => + (string?)x.Attribute("Data") == "{StaticResource MaterialCloseIcon}"); + + var scrolling = Assert.Single(window.Descendants(P + "ScrollViewer")); + Assert.Equal("2", (string?)scrolling.Attribute("Grid.Row")); + Assert.Equal("Auto", (string?)scrolling.Attribute("VerticalScrollBarVisibility")); + var resources = Source("App.xaml"); + Assert.Contains("x:Key=\"MaterialEditIcon\"", resources); + Assert.Contains("x:Key=\"MaterialRefreshIcon\"", resources); + Assert.Contains("x:Key=\"MaterialCloseIcon\"", resources); + } + + [Fact] + public void ControlModelWriteRequiresVerifiedLiveAssociationAndAnExplicitPostConfirmationCheck() + { + var source = Source("ControlModelWriteWindow.xaml.cs"); + var requireRead = source.IndexOf("_inspectedAssociation = association", StringComparison.Ordinal); + var requireModel = source.IndexOf("_inspectedLiveModel = model", StringComparison.Ordinal); + var confirmation = source.IndexOf("MessageBox.Show(", StringComparison.Ordinal); + var afterConfirm = source.IndexOf("if (!InspectionIsCurrent", confirmation, StringComparison.Ordinal); + var dispatch = source.IndexOf("ConfigureExactModelAsync(expected, selected.Model", StringComparison.Ordinal); + Assert.True(requireRead >= 0 && requireModel >= 0); + Assert.True(confirmation > requireModel); + Assert.True(afterConfirm > confirmation); + Assert.True(dispatch > afterConfirm); + Assert.Contains("ReferenceEquals(_device.LiveDiscoveryModel, _inspectedLiveModel)", source); + Assert.Contains("_device.LiveValues.Association == _inspectedAssociation", source); + Assert.Contains("MessageBoxButton.OKCancel, MessageBoxImage.Warning, MessageBoxResult.Cancel", source); + Assert.Contains("No Write was sent.", source); + Assert.DoesNotContain("OperateAsync(", source); + Assert.DoesNotContain("WriteDataAttributeAsync(", source); + + var bridge = Source("Services/Iec61850ControlModelWriteDispatcher.cs"); + Assert.Contains("runtime.ConfigureControlModelAsync", bridge); + Assert.DoesNotContain("WriteDataAttributeAsync(", bridge); + } +} From 423d976dc8e1e8f2b4d547b99b1dff88c1580333 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 20:53:07 +0700 Subject: [PATCH 4/8] test(write): correct XAML document root assertion and exact fail-closed message contract --- tests/ARSAS.Tests/ControlModelWriteAcceptanceTests.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/ARSAS.Tests/ControlModelWriteAcceptanceTests.cs b/tests/ARSAS.Tests/ControlModelWriteAcceptanceTests.cs index 595b8f51b..d174f2507 100644 --- a/tests/ARSAS.Tests/ControlModelWriteAcceptanceTests.cs +++ b/tests/ARSAS.Tests/ControlModelWriteAcceptanceTests.cs @@ -25,7 +25,8 @@ private static string Source(string path) public void SingleCfEditorHasOneInlineDropdownAndPersistentMaterialActionFooter() { var window = XDocument.Parse(Source("ControlModelWriteWindow.xaml")); - var root = Assert.Single(window.Elements(P + "Window")); + var root = Assert.IsType(window.Root); + Assert.Equal(P + "Window", root.Name); Assert.Equal("Write Data to IED", (string?)root.Attribute("Title")); Assert.Equal("CanResize", (string?)root.Attribute("ResizeMode")); Assert.Equal("{StaticResource AppFontFamily}", (string?)root.Attribute("FontFamily")); @@ -80,7 +81,7 @@ public void ControlModelWriteRequiresVerifiedLiveAssociationAndAnExplicitPostCon Assert.Contains("ReferenceEquals(_device.LiveDiscoveryModel, _inspectedLiveModel)", source); Assert.Contains("_device.LiveValues.Association == _inspectedAssociation", source); Assert.Contains("MessageBoxButton.OKCancel, MessageBoxImage.Warning, MessageBoxResult.Cancel", source); - Assert.Contains("No Write was sent.", source); + Assert.Contains("no Write was sent.", source); Assert.DoesNotContain("OperateAsync(", source); Assert.DoesNotContain("WriteDataAttributeAsync(", source); From 05acb4c876b88212547a8e75fdd032997e13af9d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 20:54:30 +0700 Subject: [PATCH 5/8] test(write): align legacy commissioning UI regression with Material icon and pinned Write caption --- .../P109QCommandDockCommissioningRegressionTests.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/ARSAS.Tests/P109QCommandDockCommissioningRegressionTests.cs b/tests/ARSAS.Tests/P109QCommandDockCommissioningRegressionTests.cs index 679a87aab..b6fc8360a 100644 --- a/tests/ARSAS.Tests/P109QCommandDockCommissioningRegressionTests.cs +++ b/tests/ARSAS.Tests/P109QCommandDockCommissioningRegressionTests.cs @@ -146,7 +146,8 @@ public void ModelButtonOpensSimplePickAndWriteDialog() var ui = Read("ControlModelWriteWindow.xaml"); var handler = Read("ControlModelWriteWindow.xaml.cs"); Assert.Contains("new ControlModelWriteWindow(", code); - Assert.Contains("Content=\"Write\"", ui); + Assert.Contains("", ui); + Assert.Contains("Data=\"{StaticResource MaterialEditIcon}\"", ui); Assert.Contains("ItemsSource=\"{Binding Choices}\"", ui); Assert.DoesNotContain("Type the exact object reference", ui); Assert.DoesNotContain("I am authorized to change", ui); @@ -163,7 +164,8 @@ public void ModelButtonOpensSimplePickAndWriteDialog() Assert.Contains("public Task ConfigureControlModelAsync(", facade); Assert.Contains("_inner.ConfigureControlModelAsync(deviceId, signal, expected, requested, token)", facade); Assert.Contains("RunDeviceOperationAsync(", facade); - Assert.Contains("Background=\"#F4F7FB\"", ui); + Assert.Contains("Background=\"#F5F7FB\"", ui); + Assert.Contains("FontFamily=\"{StaticResource AppFontFamily}\"", ui); Assert.DoesNotContain("new ControlCommandWindow(", code); } From 2eb5ccea30c3d3fb72152d368d4d4f40b35a1c66 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 20:56:36 +0700 Subject: [PATCH 6/8] fix(ci): keep reference-application names out of clean-room XAML source --- ControlModelWriteWindow.xaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ControlModelWriteWindow.xaml b/ControlModelWriteWindow.xaml index f706f9010..6fc89b9f2 100644 --- a/ControlModelWriteWindow.xaml +++ b/ControlModelWriteWindow.xaml @@ -21,7 +21,7 @@ ToolTip="{Binding Text, RelativeSource={RelativeSource Self}}" Margin="2,0,2,10"/> - + From 54a5696fd06b16986ea7c5429a7648a95df51f17 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 21:01:35 +0700 Subject: [PATCH 7/8] test(write): assert pinned footer row on parent Grid rather than its child button --- tests/ARSAS.Tests/ControlModelWriteAcceptanceTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/ControlModelWriteAcceptanceTests.cs b/tests/ARSAS.Tests/ControlModelWriteAcceptanceTests.cs index d174f2507..7cb8ddb5a 100644 --- a/tests/ARSAS.Tests/ControlModelWriteAcceptanceTests.cs +++ b/tests/ARSAS.Tests/ControlModelWriteAcceptanceTests.cs @@ -46,7 +46,7 @@ public void SingleCfEditorHasOneInlineDropdownAndPersistentMaterialActionFooter( var button = Assert.Single(window.Descendants(P + "Button").Where(x => (string?)x.Attribute("Click") == "Write_Click")); Assert.Equal("94", (string?)button.Attribute("Width")); - Assert.Equal("3", (string?)button.Attribute("Grid.Row")); + Assert.Equal("3", (string?)button.Parent?.Attribute("Grid.Row")); Assert.Contains(button.Descendants(P + "TextBlock"), x => (string?)x.Attribute("Text") == "Write"); Assert.Contains(button.Descendants(P + "Path"), x => From e9b290488e01c3779b9a53b3226108cc9da9202d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 21:03:41 +0700 Subject: [PATCH 8/8] test(control): assert final typed CDC plus exact DPC feedback gate before UI authority --- .../P109WCommandDockLifecycleArchitectureTests.cs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/tests/ARSAS.Tests/P109WCommandDockLifecycleArchitectureTests.cs b/tests/ARSAS.Tests/P109WCommandDockLifecycleArchitectureTests.cs index 6d62041c0..22c53319f 100644 --- a/tests/ARSAS.Tests/P109WCommandDockLifecycleArchitectureTests.cs +++ b/tests/ARSAS.Tests/P109WCommandDockLifecycleArchitectureTests.cs @@ -152,9 +152,18 @@ public void InspectionPublishesTypedAuthorizationOnlyAfterFinalMmsReads() var inspect = client[start..stop]; var invalidate = inspect.IndexOf("await InvalidateCachedControlSessionAsync(", StringComparison.Ordinal); var typed = inspect.IndexOf("var semantic = ArControl.Iec61850ControlSemanticResolver.Resolve(", StringComparison.Ordinal); - var finalCommit = inspect.LastIndexOf("signal.ClearLiveControlAuthority();", StringComparison.Ordinal); - var authorize = inspect.IndexOf("signal.ControlSemanticVerified = semantic.IsCommandSafe && descriptor.IsOperationallyReady;", StringComparison.Ordinal); - Assert.True(invalidate >= 0 && typed > invalidate && finalCommit > typed && authorize > finalCommit); + // Current implementation composes all three independent safety inputs + // BEFORE it can publish authority. A mere semantic CDC match must not + // bypass the consumer's exact Boolean DPC status-reference fence. + var proof = inspect.IndexOf("var fullyVerified = semantic.IsCommandSafe &&", StringComparison.Ordinal); + var identity = inspect.IndexOf("descriptor.IsOperationallyReady && exactDpcBooleanFeedback;", StringComparison.Ordinal); + var revoke = inspect.LastIndexOf("signal.ClearLiveControlAuthority();", StringComparison.Ordinal); + var authorize = inspect.IndexOf("signal.ControlSemanticVerified = fullyVerified;", StringComparison.Ordinal); + Assert.True(invalidate >= 0 && typed > invalidate && proof > typed && + identity > proof && revoke > identity && authorize > revoke); + Assert.Contains("if (!fullyVerified ||", inspect, StringComparison.Ordinal); + Assert.Contains("if (fullyVerified)", inspect, StringComparison.Ordinal); + Assert.Contains("signal.AcceptVerifiedLiveControlCdc(effectiveCdc);", inspect, StringComparison.Ordinal); Assert.Contains("signal.ControlModelText = probe.Decoded", inspect, StringComparison.Ordinal); Assert.Contains("signal.ControlModelText = modelText;", inspect, StringComparison.Ordinal); Assert.DoesNotContain("signal.ClearLiveControlAuthority();\n signal.ControlModelText = \"Auto-detect\";\n await Invalidate", inspect, StringComparison.Ordinal);