diff --git a/FaultRecordUxBehavior.cs b/FaultRecordUxBehavior.cs index 4f29dbaa0..e16d42d3e 100644 --- a/FaultRecordUxBehavior.cs +++ b/FaultRecordUxBehavior.cs @@ -211,10 +211,10 @@ private static void RefreshCapabilityState( ApplyState( gooseButton, - hasGoose ? CapabilityState.Available : CapabilityState.Unavailable, + hasGoose ? CapabilityState.Available : CapabilityState.ProbeReady, hasGoose - ? $"Open GOOSE Subscriber for {device.Name}" - : $"{device.Name} has no configured or discovered GOOSE control block"); + ? $"Monitor IEC 61850 GOOSE frames for {device.Name}" + : $"Monitor raw GOOSE frames for {device.Name} (no GoCB model configured)"); ApplyState( smvButton, hasSmv ? CapabilityState.Available : CapabilityState.Unavailable, @@ -268,9 +268,9 @@ private static void ApplyState(Button button, CapabilityState state, string tool private static void OpenGooseWorkspace(MainWindow mainWindow, Iec61850MonitorDevice device) { - mainWindow.SelectedDevice = device; - if (mainWindow.FindName("MainTabs") is TabControl tabs) - tabs.SelectedIndex = 3; + // The visible GOOSE capability pill is the operator's CTA. Route through + // the same adapter-pick workflow as any other GOOSE shortcut. + mainWindow.OpenIedGooseSubscriber(device); mainWindow.Activate(); } diff --git a/MainWindow.GooseSubscriber.cs b/MainWindow.GooseSubscriber.cs index 91c026f2b..3a3312e91 100644 --- a/MainWindow.GooseSubscriber.cs +++ b/MainWindow.GooseSubscriber.cs @@ -21,6 +21,8 @@ public partial class MainWindow private readonly Dictionary _gooseStreamIndex = new(StringComparer.OrdinalIgnoreCase); private GooseBindingCatalog _gooseBindingCatalog = GooseBindingCatalog.Empty; private GooseAdapterOption? _selectedGooseAdapter; + // Armed only by an IED-card CTA. Direct GOOSE tab navigation remains manual. + private Iec61850MonitorDevice? _pendingIedGooseAutoStart; private GooseStreamRow? _selectedGooseStream; private bool _isGooseCapturing; private bool _gooseActionBusy; @@ -44,6 +46,7 @@ public GooseAdapterOption? SelectedGooseAdapter if (!Set(ref _selectedGooseAdapter, value)) return; Raise(nameof(CanStartGooseSubscriber)); Raise(nameof(SelectedGooseAdapterDetail)); + } } @@ -152,8 +155,24 @@ private void RefreshGooseAdapters_Click(object sender, RoutedEventArgs e) private void RefreshGooseModels_Click(object sender, RoutedEventArgs e) => RefreshGooseBindingPreview(); + /// + /// The IED-card intent is consumed only by an operator adapter choice. + /// Refresh or a programmatic ComboBox binding cannot auto-start capture. + /// + internal void ConfirmIedGooseAdapterSelection() + { + if (_pendingIedGooseAutoStart is null || SelectedGooseAdapter is null || + MainTabs.SelectedIndex != GooseSubscriberTabIndex || + IsGooseCapturing || GooseActionBusy) + return; + _pendingIedGooseAutoStart = null; + ResetGooseTimelineUi(); + StartGooseSubscriber_Click(this, new RoutedEventArgs()); + } + private async void StartGooseSubscriber_Click(object sender, RoutedEventArgs e) { + _pendingIedGooseAutoStart = null; if (SelectedGooseAdapter is null || GooseActionBusy || IsGooseCapturing) return; @@ -251,11 +270,15 @@ private void RefreshGooseAdapters() var previousName = SelectedGooseAdapter?.Name; var adapters = _gooseSubscriberRuntime.ListAdapters(); GooseAdapters.ReplaceAll(adapters); + var usableAdapters = adapters.Where(adapter => !LooksLikeLoopback(adapter)).ToArray(); + // Never silently choose the first of several network interfaces: an + // unrelated LAN/Wi-Fi adapter can miss all IEC 61850 GOOSE frames. SelectedGooseAdapter = adapters.FirstOrDefault(adapter => - adapter.Name.Equals(previousName, StringComparison.OrdinalIgnoreCase)) ?? adapters.FirstOrDefault(); + adapter.Name.Equals(previousName, StringComparison.OrdinalIgnoreCase)) + ?? (usableAdapters.Length == 1 ? usableAdapters[0] : null); GooseStatusText = adapters.Count == 0 - ? "No Npcap/WinPcap adapters were found. Install Npcap and restart ArIED." - : $"{adapters.Count:N0} capture adapter(s) available. Select the approved station/LAN interface."; + ? "No Npcap/WinPcap adapters found. Install Npcap, then refresh adapters." + : $"{adapters.Count:N0} capture adapter(s) available. Choose the station Ethernet adapter and press Start."; } catch (Exception ex) { diff --git a/MainWindow.GooseTimeline.cs b/MainWindow.GooseTimeline.cs index b1c48fe1f..0ed13c6e4 100644 --- a/MainWindow.GooseTimeline.cs +++ b/MainWindow.GooseTimeline.cs @@ -4,6 +4,7 @@ using System.Runtime.CompilerServices; using System.Windows; using System.Windows.Controls; +using AR.Iec61850.Mms; using ArIED61850Tester.Models; using ArIED61850Tester.Services; using ArIED61850Tester.Views; @@ -12,10 +13,18 @@ namespace ArIED61850Tester; public partial class MainWindow { - private const int MaxGooseTimelineEvents = 300; + private const int MaxGooseTimelineEvents = 500; private const int MaxPendingGooseTimelineEvents = 512; - private readonly ConcurrentQueue _pendingGooseTimeline = new(); + private readonly ConcurrentQueue<(GooseSubscriberFrameSnapshot Frame, GooseTimelineDecision Decision)> _pendingGooseTimeline = new(); + private readonly Dictionary _lastGooseTimelineSignature = new(StringComparer.OrdinalIgnoreCase); + private readonly object _gooseTimelineGate = new(); + private bool _showGooseRetransmissions; + public bool ShowGooseRetransmissions + { + get => _showGooseRetransmissions; + set => Set(ref _showGooseRetransmissions, value); + } private readonly Dictionary _lastGooseTimelineTimestamp = new(StringComparer.OrdinalIgnoreCase); private GooseEventRow? _selectedGooseEvent; private bool _goosePresentationInstalled; @@ -56,6 +65,10 @@ internal static void RegisterGoosePresentationIntegration() typeof(MainWindow), GooseSubscriberLiteView.RefreshModelsRequestedEvent, new RoutedEventHandler(OnRefreshGooseModelsRequested)); + EventManager.RegisterClassHandler( + typeof(MainWindow), + GooseSubscriberLiteView.AdapterConfirmedEvent, + new RoutedEventHandler(OnGooseAdapterConfirmed)); EventManager.RegisterClassHandler( typeof(MainWindow), GooseSubscriberLiteView.StartRequestedEvent, @@ -117,6 +130,14 @@ private static void OnRefreshGooseModelsRequested(object sender, RoutedEventArgs args.Handled = true; } + private static void OnGooseAdapterConfirmed(object sender, RoutedEventArgs args) + { + if (sender is not MainWindow window) + return; + window.ConfirmIedGooseAdapterSelection(); + args.Handled = true; + } + private static void OnStartGooseRequested(object sender, RoutedEventArgs args) { if (sender is not MainWindow window) @@ -143,26 +164,29 @@ private static void OnClearGooseRequested(object sender, RoutedEventArgs args) args.Handled = true; } - private void GooseTimeline_FrameReceived(GooseSubscriberFrameSnapshot snapshot) + private void GooseTimeline_FrameReceived(GooseSubscriberFrameSnapshot frame) { - if (!IsMeaningfulGooseTimelineEvent(snapshot)) - return; - - _pendingGooseTimeline.Enqueue(snapshot); - while (_pendingGooseTimeline.Count > MaxPendingGooseTimelineEvents && _pendingGooseTimeline.TryDequeue(out _)) + // Read-only capture still records every frame. Only operator messages + // are coalesced, using exact decoded payload evidence per publisher. + var values = frame.StreamEvent.GooseValues.Count > 0 + ? frame.StreamEvent.GooseValues.Select(x => x.DisplayValue) + : frame.Frame.Pdu.Values.Select(x => MmsDataValueRenderer.ToCompactString(x, string.Empty)); + var fingerprint = new GooseTimelineSignature( + string.Concat(values.Select(x => $"{x?.Length ?? 0}:{x}")), + frame.Frame.Pdu.StateNumber.ToString(CultureInfo.InvariantCulture), + string.Join(" • ", frame.StreamEvent.Diagnostics.OrderBy(x => x, StringComparer.Ordinal)), + frame.StreamEvent.GooseSequenceStatus.ToString()); + GooseTimelineDecision decision; + lock (_gooseTimelineGate) { + _lastGooseTimelineSignature.TryGetValue(frame.StreamKey, out var previous); + decision = GooseTimelineEventPolicy.Evaluate(previous, fingerprint, ShowGooseRetransmissions); + _lastGooseTimelineSignature[frame.StreamKey] = fingerprint; } - } - - private static bool IsMeaningfulGooseTimelineEvent(GooseSubscriberFrameSnapshot snapshot) - { - if (snapshot.PacketCount <= 1 || snapshot.StreamEvent.ChangedValueCount > 0 || snapshot.StreamEvent.Diagnostics.Count > 0) - return true; - - var status = snapshot.StreamEvent.GooseSequenceStatus.ToString(); - return status.Contains("State", StringComparison.OrdinalIgnoreCase) || - (!status.Contains("Retransmission", StringComparison.OrdinalIgnoreCase) && - !status.Contains("Normal", StringComparison.OrdinalIgnoreCase)); + if (!decision.Include) return; + _pendingGooseTimeline.Enqueue((frame, decision)); + while (_pendingGooseTimeline.Count > MaxPendingGooseTimelineEvents && + _pendingGooseTimeline.TryDequeue(out _)) { } } private void GooseTimelineUiFlushTimer_Tick(object? sender, EventArgs args) @@ -171,7 +195,7 @@ private void GooseTimelineUiFlushTimer_Tick(object? sender, EventArgs args) if (nowUtc >= _nextGooseHighlightExpiryCheckUtc) { ExpireGooseHighlights(nowUtc); - _nextGooseHighlightExpiryCheckUtc = nowUtc.AddSeconds(1); + _nextGooseHighlightExpiryCheckUtc = nowUtc.AddMilliseconds(250); } if (_pendingGooseTimeline.IsEmpty) @@ -180,8 +204,8 @@ private void GooseTimelineUiFlushTimer_Tick(object? sender, EventArgs args) var processed = 0; while (processed < 48 && _pendingGooseTimeline.TryDequeue(out var captured)) { - var stream = BuildGooseStreamSnapshot(captured, _gooseBindingCatalog); - var eventRow = BuildGooseEventRow(captured, stream); + var stream = BuildGooseStreamSnapshot(captured.Frame, _gooseBindingCatalog); + var eventRow = BuildGooseEventRow(captured.Frame, stream, captured.Decision); // Append in capture order. Tail insertion avoids shifting every realized row. GooseEvents.Add(eventRow); while (GooseEvents.Count > MaxGooseTimelineEvents) @@ -206,46 +230,34 @@ private void ExpireGooseHighlights(DateTimeOffset nowUtc) } } - private GooseEventRow BuildGooseEventRow( - GooseSubscriberFrameSnapshot captured, - GooseStreamSnapshot stream) + private GooseEventRow BuildGooseEventRow(GooseSubscriberFrameSnapshot captured, + GooseStreamSnapshot stream, GooseTimelineDecision decision) { - var status = stream.SequenceStatus; - var hasDiagnostics = !string.IsNullOrWhiteSpace(stream.DiagnosticsSummary); - var isNew = captured.PacketCount <= 1; - var isStateChange = stream.ChangedValueCount > 0 || status.Contains("State", StringComparison.OrdinalIgnoreCase); - - var eventText = isNew - ? "New" - : hasDiagnostics - ? "Warning" - : isStateChange - ? "State change" - : FriendlySequenceStatus(status); - var eventTone = hasDiagnostics - ? "Warning" - : isStateChange - ? "Change" - : "Info"; - + var hasNewWarning = !string.IsNullOrWhiteSpace(stream.DiagnosticsSummary) && + (decision.IsNew || decision.DiagnosticsChanged); + var valueAnomaly = decision.PayloadChanged && !decision.StateChanged; + var changed = decision.PayloadChanged || decision.StateChanged; + var eventText = decision.IsNew ? "New" : + valueAnomaly ? "Value changed" : + hasNewWarning || decision.NewSequenceAnomaly ? "Warning" : + changed ? "State change" : FriendlySequenceStatus(stream.SequenceStatus); + var tone = valueAnomaly || hasNewWarning || decision.NewSequenceAnomaly ? "Warning" : + changed ? "Change" : "Info"; var deltaText = "-"; - if (_lastGooseTimelineTimestamp.TryGetValue(captured.StreamKey, out var previousTimestamp)) - { - var delta = captured.CaptureTimestamp - previousTimestamp; - deltaText = FormatGooseDelta(delta); - } + if (_lastGooseTimelineTimestamp.TryGetValue(captured.StreamKey, out var prev)) + deltaText = FormatGooseDelta(captured.CaptureTimestamp - prev); _lastGooseTimelineTimestamp[captured.StreamKey] = captured.CaptureTimestamp; - return new GooseEventRow { StreamKey = captured.StreamKey, Timestamp = captured.CaptureTimestamp, DeltaText = deltaText, EventText = eventText, - EventTone = eventTone, + EventTone = tone, Publisher = BuildGoosePublisherName(stream), StateSequenceText = $"{stream.StateNumberText} / {stream.SequenceNumberText}", - Summary = BuildGooseEventSummary(stream, isNew, hasDiagnostics) + Summary = valueAnomaly ? "Payload changed without stNum increment — verify publisher." : + BuildGooseEventSummary(stream, decision.IsNew, hasNewWarning) }; } @@ -345,6 +357,7 @@ private void ResetGooseTimelineUi() { } _lastGooseTimelineTimestamp.Clear(); + lock (_gooseTimelineGate) _lastGooseTimelineSignature.Clear(); _nextGooseHighlightExpiryCheckUtc = DateTimeOffset.MinValue; GooseEvents.Clear(); SelectedGooseEvent = null; diff --git a/MainWindow.IedGooseQuickStart.cs b/MainWindow.IedGooseQuickStart.cs index b3366a0ae..0b8fc1c7b 100644 --- a/MainWindow.IedGooseQuickStart.cs +++ b/MainWindow.IedGooseQuickStart.cs @@ -17,6 +17,7 @@ namespace ArIED61850Tester; public partial class MainWindow { private const string IedGooseQuickStartTag = "ARSAS_IED_GOOSE_QUICK_START"; + private const int GooseSubscriberTabIndex = 4; private static readonly bool IedGooseQuickStartRegistered = RegisterIedGooseQuickStart(); private static bool RegisterIedGooseQuickStart() @@ -78,7 +79,7 @@ private void InstallIedGooseQuickStart(UniformGrid actionGrid, Iec61850MonitorDe Height = 27, Margin = new Thickness(0), Cursor = System.Windows.Input.Cursors.Hand, - ToolTip = "Open GOOSE Subscriber and start capture on the Ethernet adapter routed to this IED", + ToolTip = "Open GOOSE Subscriber, choose the station Ethernet adapter, then press Start", Content = new Viewbox { Width = 14, @@ -90,87 +91,48 @@ private void InstallIedGooseQuickStart(UniformGrid actionGrid, Iec61850MonitorDe actionGrid.Children.Add(button); } - private async void IedGooseQuickStart_Click(object sender, RoutedEventArgs e) + private void IedGooseQuickStart_Click(object sender, RoutedEventArgs e) { - if (sender is not Button { CommandParameter: Iec61850MonitorDevice device }) - return; - - SelectedDevice = device; - MainTabs.SelectedIndex = 3; - UpdateNavigationVisuals(3, animate: true); - ActivateGooseSubscriberWorkspace(); - await Dispatcher.Yield(DispatcherPriority.Loaded); - - if (GooseAdapters.Count == 0) - RefreshGooseAdapters(); - - var adapter = ResolveGooseAdapterForIed(device, out var routeDetail); - if (adapter is null) - { - GooseStatusText = - $"GOOSE workspace opened for {device.Name}, but ARSAS could not identify one unique capture adapter for route {device.IpAddress}. Select the station-LAN adapter and press Start."; - SetStatus($"GOOSE: adapter selection required for {device.Name}."); - AddLog("WARN", "GOOSE", $"Automatic adapter selection failed for {device.Name} ({device.IpAddress}). {routeDetail}"); - return; - } - - await StartGooseForIedAsync(device, adapter, routeDetail); + if (sender is Button { CommandParameter: Iec61850MonitorDevice device }) + OpenIedGooseSubscriber(device); } - private async Task StartGooseForIedAsync( - Iec61850MonitorDevice device, - GooseAdapterOption adapter, - string routeDetail) + /// + /// Shared entry point for the visible IED-card GOOSE capability pill and + /// the legacy icon. The UI must show GOOSE before touching optional Npcap. + /// The operator's next adapter selection starts capture exactly once. + /// Direct navigation to the GOOSE tab preserves its manual Start workflow. + /// + internal void OpenIedGooseSubscriber(Iec61850MonitorDevice device) { - if (GooseActionBusy) - return; - - if (IsGooseCapturing && - SelectedGooseAdapter is not null && - SelectedGooseAdapter.Name.Equals(adapter.Name, StringComparison.OrdinalIgnoreCase)) - { - SelectedGooseAdapter = adapter; - GooseStatusText = - $"LIVE on {adapter.DisplayText} · selected from {device.Name} route {device.IpAddress} · capture already running."; - SetStatus($"GOOSE Subscriber already live for {device.Name} on the routed Ethernet adapter."); - return; - } - - if (IsGooseCapturing) - await StopGooseSubscriberAsync(); - - SelectedGooseAdapter = adapter; - GooseActionBusy = true; - try - { - RefreshGooseBindingPreview(); - ResetGooseView(resetCounters: true); - await _gooseSubscriberRuntime.StartAsync( - adapter.Selector, - _gooseBindingCatalog.SclDocument, - GooseCaptureFilter, - _applicationCancellation.Token); - - IsGooseCapturing = true; - GooseStatusText = - $"Listening on {adapter.DisplayText} for {device.Name} ({device.IpAddress}). Waiting for GOOSE frames…"; - SetStatus($"GOOSE Subscriber started for {device.Name} on its routed Ethernet adapter."); - AddLog( - "INFO", - "GOOSE", - $"One-click subscriber started for {device.Name} ({device.IpAddress}) on adapter {adapter.Index}: {adapter.Description}. Route selection: {routeDetail}. Binding: {_gooseBindingCatalog.Summary}"); - } - catch (Exception ex) - { - IsGooseCapturing = false; - GooseStatusText = $"Could not start GOOSE subscriber for {device.Name}: {DescribeGooseFailure(ex)}"; - AddLog("ERROR", "GOOSE", GooseStatusText); - MarkDiagnosticAlert(); - } - finally + SelectedDevice = device; + _pendingIedGooseAutoStart = null; + MainTabs.SelectedIndex = GooseSubscriberTabIndex; + UpdateNavigationVisuals(GooseSubscriberTabIndex, animate: true); + ActivateGooseSubscriberWorkspace(); + Dispatcher.BeginInvoke(DispatcherPriority.Loaded, new Action(() => { - GooseActionBusy = false; - } + if (Dispatcher.HasShutdownStarted || MainTabs.SelectedIndex != GooseSubscriberTabIndex) + return; + if (IsGooseCapturing || GooseActionBusy) + { + GooseStatusText = IsGooseCapturing + ? "GOOSE monitoring is already running. Stop it before changing network adapters." + : "GOOSE capture is busy. Wait for the current operation."; + return; + } + if (GooseAdapters.Count == 0) + RefreshGooseAdapters(); + + // Never assume the unicast MMS route is the GOOSE L2 network. The + // Npcap adapter must be explicitly selected before capture begins. + SelectedGooseAdapter = null; + _pendingIedGooseAutoStart = device; + GooseStatusText = GooseAdapters.Count == 0 + ? "No Ethernet capture adapter found. Check Npcap and refresh the adapter list." + : $"Select the station network adapter for {device.Name}. GOOSE capture starts automatically after selection."; + SetStatus($"GOOSE: select network adapter for {device.Name}."); + })); } private GooseAdapterOption? ResolveGooseAdapterForIed( diff --git a/MainWindow.xaml b/MainWindow.xaml index 6482c8cfc..f3acaadf5 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -738,44 +738,14 @@ - + - - - - - - - - - - - - - - - - - - + ", StringComparison.Ordinal); PreviousValue = snapshot.PreviousValue; Value = snapshot.Value; BindingSource = snapshot.BindingSource; - IsChanged = snapshot.IsChanged; - if (snapshot.IsChanged) + IsChanged = snapshot.IsChanged || changedOnWire; + if (IsChanged) { - _highlightUntilUtc = DateTimeOffset.UtcNow.AddSeconds(5); + _highlightUntilUtc = DateTimeOffset.UtcNow.AddSeconds(3); IsHighlighted = true; } Raise(nameof(TypeText)); diff --git a/Services/GooseTimelineEventPolicy.cs b/Services/GooseTimelineEventPolicy.cs new file mode 100644 index 000000000..3313057ae --- /dev/null +++ b/Services/GooseTimelineEventPolicy.cs @@ -0,0 +1,22 @@ +namespace ArIED61850Tester.Services; + +public sealed record GooseTimelineSignature(string Payload, string State, string Diagnostics, string Sequence); +public readonly record struct GooseTimelineDecision(bool Include, bool IsNew, bool PayloadChanged, bool StateChanged, bool DiagnosticsChanged, bool NewSequenceAnomaly); + +/// Presentation-only filter. All packets still enter the read-only wire capture. +public static class GooseTimelineEventPolicy +{ + public static GooseTimelineDecision Evaluate(GooseTimelineSignature? previous, GooseTimelineSignature current, bool showRetransmissions) + { + ArgumentNullException.ThrowIfNull(current); + var first = previous is null; + var valueChanged = !first && previous!.Payload != current.Payload; + var stateChanged = !first && previous!.State != current.State; + var diagnosticChanged = !first && previous!.Diagnostics != current.Diagnostics; + var normal = current.Sequence.Contains("Retransmission", StringComparison.OrdinalIgnoreCase) || + current.Sequence.Contains("Normal", StringComparison.OrdinalIgnoreCase); + var newAnomaly = !first && previous!.Sequence != current.Sequence && !normal; + return new GooseTimelineDecision(first || valueChanged || stateChanged || diagnosticChanged || newAnomaly || showRetransmissions, + first, valueChanged, stateChanged, diagnosticChanged, newAnomaly); + } +} diff --git a/Views/GooseSubscriberLiteView.xaml b/Views/GooseSubscriberLiteView.xaml index 53bae9d2a..f3f5baa2a 100644 --- a/Views/GooseSubscriberLiteView.xaml +++ b/Views/GooseSubscriberLiteView.xaml @@ -51,6 +51,8 @@ - - - + - + @@ -279,7 +279,7 @@ - + @@ -292,7 +292,7 @@ - @@ -300,8 +300,11 @@ FontWeight="SemiBold" Foreground="#45648E"/> - + @@ -372,7 +375,7 @@ - + @@ -382,7 +385,7 @@ - @@ -391,7 +394,7 @@ - + diff --git a/Views/GooseSubscriberLiteView.xaml.cs b/Views/GooseSubscriberLiteView.xaml.cs index 8cf26a8e1..e0d59dc69 100644 --- a/Views/GooseSubscriberLiteView.xaml.cs +++ b/Views/GooseSubscriberLiteView.xaml.cs @@ -11,6 +11,9 @@ public partial class GooseSubscriberLiteView : UserControl public static readonly RoutedEvent RefreshModelsRequestedEvent = EventManager.RegisterRoutedEvent( nameof(RefreshModelsRequested), RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(GooseSubscriberLiteView)); + public static readonly RoutedEvent AdapterConfirmedEvent = EventManager.RegisterRoutedEvent( + nameof(AdapterConfirmed), RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(GooseSubscriberLiteView)); + public static readonly RoutedEvent StartRequestedEvent = EventManager.RegisterRoutedEvent( nameof(StartRequested), RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(GooseSubscriberLiteView)); @@ -37,6 +40,12 @@ public event RoutedEventHandler RefreshModelsRequested remove => RemoveHandler(RefreshModelsRequestedEvent, value); } + public event RoutedEventHandler AdapterConfirmed + { + add => AddHandler(AdapterConfirmedEvent, value); + remove => RemoveHandler(AdapterConfirmedEvent, value); + } + public event RoutedEventHandler StartRequested { add => AddHandler(StartRequestedEvent, value); @@ -55,6 +64,15 @@ public event RoutedEventHandler ClearRequested remove => RemoveHandler(ClearRequestedEvent, value); } + private void AdapterComboBox_DropDownClosed(object sender, EventArgs e) + => RaiseEvent(new RoutedEventArgs(AdapterConfirmedEvent, this)); + + private void AdapterComboBox_KeyUp(object sender, System.Windows.Input.KeyEventArgs e) + { + if (e.Key == System.Windows.Input.Key.Return && sender is ComboBox { IsDropDownOpen: false }) + RaiseEvent(new RoutedEventArgs(AdapterConfirmedEvent, this)); + } + private void RefreshAdapters_Click(object sender, RoutedEventArgs e) => RaiseEvent(new RoutedEventArgs(RefreshAdaptersRequestedEvent, this)); diff --git a/tests/ARSAS.Tests/FastWorkflowRegressionTests.cs b/tests/ARSAS.Tests/FastWorkflowRegressionTests.cs index 4610c36ff..84e26b381 100644 --- a/tests/ARSAS.Tests/FastWorkflowRegressionTests.cs +++ b/tests/ARSAS.Tests/FastWorkflowRegressionTests.cs @@ -3,14 +3,34 @@ namespace ARSAS.Tests; public sealed class FastWorkflowRegressionTests { [Fact] - public void IedCardQuickCapture_UsesSelectedEndpointRoute() + public void VisibleGooseCapabilityPill_OpensCorrectTabAndArmsOneShotAdapterAutoStart() { - var source = File.ReadAllText(FindRepoFile("MainWindow.IedGooseQuickStart.cs")); - Assert.Contains("MainTabs.SelectedIndex = 3", source, StringComparison.Ordinal); - Assert.Contains("ResolveLocalIpv4ForTarget", source, StringComparison.Ordinal); - Assert.Contains("CaptureAdapterMatchesNetworkInterface", source, StringComparison.Ordinal); - Assert.Contains("_gooseSubscriberRuntime.StartAsync", source, StringComparison.Ordinal); - Assert.DoesNotContain("GooseAdapters.First()", source, StringComparison.Ordinal); + var card = File.ReadAllText(FindRepoFile("FaultRecordUxBehavior.cs")); + var quick = File.ReadAllText(FindRepoFile("MainWindow.IedGooseQuickStart.cs")); + var subscriber = File.ReadAllText(FindRepoFile("MainWindow.GooseSubscriber.cs")); + var mainView = File.ReadAllText(FindRepoFile("MainWindow.xaml")); + + Assert.Contains("mainWindow.OpenIedGooseSubscriber(device)", card, StringComparison.Ordinal); + Assert.DoesNotContain("tabs.SelectedIndex = 3", card, StringComparison.Ordinal); + Assert.Contains("GooseSubscriberTabIndex = 4", quick, StringComparison.Ordinal); + Assert.Contains("MainTabs.SelectedIndex = GooseSubscriberTabIndex", quick, StringComparison.Ordinal); + Assert.Contains("OpenIedGooseSubscriber(device)", quick, StringComparison.Ordinal); + Assert.Contains("SelectedGooseAdapter = null;", quick, StringComparison.Ordinal); + Assert.Contains("_pendingIedGooseAutoStart = device;", quick, StringComparison.Ordinal); + var liteView = File.ReadAllText(FindRepoFile("Views/GooseSubscriberLiteView.xaml")); + var viewCode = File.ReadAllText(FindRepoFile("Views/GooseSubscriberLiteView.xaml.cs")); + Assert.Contains("StartGooseSubscriber_Click(this, new RoutedEventArgs())", subscriber, StringComparison.Ordinal); + Assert.Contains("ConfirmIedGooseAdapterSelection()", subscriber, StringComparison.Ordinal); + Assert.Contains("AdapterConfirmedEvent", viewCode, StringComparison.Ordinal); + Assert.Contains("DropDownClosed=\"AdapterComboBox_DropDownClosed\"", liteView, StringComparison.Ordinal); + Assert.Contains("OnGooseAdapterConfirmed", File.ReadAllText(FindRepoFile("MainWindow.GooseTimeline.cs")), StringComparison.Ordinal); + Assert.Contains("_pendingIedGooseAutoStart = null;", subscriber, StringComparison.Ordinal); + Assert.Contains("!IsGooseCapturing && !GooseActionBusy", subscriber, StringComparison.Ordinal); + Assert.Contains("Click=\"StartGooseSubscriber_Click\"", mainView, StringComparison.Ordinal); + Assert.DoesNotContain("Fast IEC 61850 Control", mainView, StringComparison.Ordinal); + Assert.DoesNotContain("Operate only the validated control objects selected for this IED.", mainView, StringComparison.Ordinal); + Assert.DoesNotContain("Click=\"RefreshCommandValues_Click\"", mainView, StringComparison.Ordinal); + Assert.Contains("SelectedDevice.CommandSignals", mainView, StringComparison.Ordinal); } [Fact] diff --git a/tests/ARSAS.Tests/GooseTimelineEventPolicyTests.cs b/tests/ARSAS.Tests/GooseTimelineEventPolicyTests.cs new file mode 100644 index 000000000..fb4410aa7 --- /dev/null +++ b/tests/ARSAS.Tests/GooseTimelineEventPolicyTests.cs @@ -0,0 +1,59 @@ +using ArIED61850Tester.Services; +namespace ARSAS.Tests; + +public sealed class GooseTimelineEventPolicyTests +{ + [Fact] + public void NormalRetransmissionWithPersistentTestWarning_DoesNotRepeatInMessages() + { + var first = new GooseTimelineSignature("intermediate|false", "1", "GOOSE test flag is set.", "New"); + var retrans = first with { Sequence = "Retransmission" }; + Assert.True(GooseTimelineEventPolicy.Evaluate(null, first, false).Include); + Assert.False(GooseTimelineEventPolicy.Evaluate(first, retrans, false).Include); + Assert.False(GooseTimelineEventPolicy.Evaluate(retrans, retrans, false).Include); + Assert.True(GooseTimelineEventPolicy.Evaluate(retrans, retrans, true).Include); + } + + [Fact] + public void UnexpectedPayloadChangeWithoutStateIncrement_IsVisible() + { + var first = new GooseTimelineSignature("intermediate|false", "1", "", "Retransmission"); + var changed = first with { Payload = "closed|false" }; + var result = GooseTimelineEventPolicy.Evaluate(first, changed, false); + Assert.True(result.Include); + Assert.True(result.PayloadChanged); + Assert.False(result.StateChanged); + } + + [Fact] + public void ChangedStateAndNewDiagnostic_AreVisible() + { + var first = new GooseTimelineSignature("intermediate", "1", "", "Normal"); + Assert.True(GooseTimelineEventPolicy.Evaluate(first, first with { State = "2" }, false).Include); + Assert.True(GooseTimelineEventPolicy.Evaluate(first, first with { Diagnostics = "Bad confRev" }, false).Include); + } + + [Fact] + public void CurrentGooseUi_HasThreeSecondHighlightAndOperatorRetransmissionToggle() + { + var view = Read("Views/GooseSubscriberLiteView.xaml"); + var timeline = Read("MainWindow.GooseTimeline.cs"); + var leaves = Read("Models/GooseSubscriberModels.cs"); + Assert.Contains("ShowGooseRetransmissions", view); + Assert.Contains("GooseTimelineEventPolicy.Evaluate", timeline); + Assert.Contains("AddSeconds(3)", leaves); + Assert.DoesNotContain("IsMeaningfulGooseTimelineEvent", timeline); + } + + private static string Read(string path) + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null) + { + var candidate = Path.Combine(dir.FullName, path.Replace('/', Path.DirectorySeparatorChar)); + if (File.Exists(candidate)) return File.ReadAllText(candidate); + dir = dir.Parent; + } + throw new FileNotFoundException(path); + } +}