diff --git a/ControlModelWriteWindow.xaml b/ControlModelWriteWindow.xaml index 77171121e..6fc89b9f2 100644 --- a/ControlModelWriteWindow.xaml +++ b/ControlModelWriteWindow.xaml @@ -1,53 +1,109 @@ - + - + - + - + TextTrimming="CharacterEllipsis" + ToolTip="{Binding Text, RelativeSource={RelativeSource Self}}" + Margin="2,0,2,10"/> + + + - + - + - - + + + + + + + + + - - - - - - - + + 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; diff --git a/tests/ARSAS.Tests/ControlModelWriteAcceptanceTests.cs b/tests/ARSAS.Tests/ControlModelWriteAcceptanceTests.cs new file mode 100644 index 000000000..7cb8ddb5a --- /dev/null +++ b/tests/ARSAS.Tests/ControlModelWriteAcceptanceTests.cs @@ -0,0 +1,92 @@ +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.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")); + 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.Parent?.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); + } +} 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); }