From 8fd51b934b1f1c92d78725d81f7ec83ee170cdeb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 15:02:46 +0700 Subject: [PATCH 01/59] feat(goose): add stable multi-source Messages scope and move repeats control --- src/ProcessBus.Core/Presentation/GoosePublisherCatalog.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/ProcessBus.Core/Presentation/GoosePublisherCatalog.cs b/src/ProcessBus.Core/Presentation/GoosePublisherCatalog.cs index 7aa91f4e..d07b7103 100644 --- a/src/ProcessBus.Core/Presentation/GoosePublisherCatalog.cs +++ b/src/ProcessBus.Core/Presentation/GoosePublisherCatalog.cs @@ -98,7 +98,8 @@ public sealed class GoosePublisherCatalog public GoosePublisherRow? Find(GoosePublisherKey? key) => key.HasValue && _byKey.TryGetValue(key.Value, out var row) ? row : null; - public bool Reconcile(IReadOnlyList messages, GoosePublisherKey? selected) + public bool Reconcile(IReadOnlyList messages, GoosePublisherKey? selected, + IReadOnlySet? selectedKeys = null) { var seen = new HashSet(); var changed = false; @@ -124,7 +125,7 @@ public bool Reconcile(IReadOnlyList messages, GoosePublisherKe { var row = Rows[i]; if (seen.Contains(row.Key)) continue; - if (selected.HasValue && selected.Value == row.Key) + if ((selected.HasValue && selected.Value == row.Key) || selectedKeys?.Contains(row.Key) == true) { row.MarkStale(); continue; From 897c810a8af1d304b41a87c9ebbca44869a5d580 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 15:02:49 +0700 Subject: [PATCH 02/59] feat(goose): add stable multi-source Messages scope and move repeats control --- .../ViewModels/MainWindowViewModel.cs | 67 +++++++++++-------- 1 file changed, 39 insertions(+), 28 deletions(-) diff --git a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs index b4ab56c8..2b23ee3e 100644 --- a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs +++ b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs @@ -45,6 +45,7 @@ public sealed class MainWindowViewModel : INotifyPropertyChanged, IDisposable private SvStreamItem? _selectedStream; private readonly GoosePublisherCatalog _gooseCatalog = new(); private GoosePublisherKey? _selectedGoosePublisherKey; + private readonly HashSet _selectedGooseSourceKeys = new(); private GooseTrafficRow? _selectedGooseTrafficRow; private bool _gooseInspectHistoricalEvent; private bool _gooseMessageExpanded = true; @@ -247,14 +248,41 @@ public string GoosePublisherFilterText OnPropertyChanged(nameof(GooseHasSearchText)); OnPropertyChanged(nameof(GooseFilterSummaryText)); OnPropertyChanged(nameof(GooseEmptyGuidance)); - _gooseHistoryView.Refresh(); - UpdateGooseVisibleEventCount(); _goosePublisherRowsView.Refresh(); OnPropertyChanged(nameof(SelectedGoosePublisherRow)); } } public bool GooseHasSearchText => !string.IsNullOrWhiteSpace(GoosePublisherFilterText); + public IReadOnlyCollection SelectedGooseSourceKeys => _selectedGooseSourceKeys; + public bool GooseSourceFilterActive => _selectedGooseSourceKeys.Count > 0; + public string GooseSourceScopeText => _selectedGooseSourceKeys.Count == 0 + ? "All sources" : _selectedGooseSourceKeys.Count == 1 ? "1 source" : $"{_selectedGooseSourceKeys.Count} sources"; + + // Source scope is intentionally separate from the live Inspector focus and + // the pinned historical event. Only an explicit plain click changes focus. + public void SetGooseSourceSelection(IEnumerable keys, GoosePublisherRow? liveFocus = null) + { + var next = keys.ToHashSet(); + if (!_selectedGooseSourceKeys.SetEquals(next)) + { + _selectedGooseSourceKeys.Clear(); + _selectedGooseSourceKeys.UnionWith(next); + OnPropertyChanged(nameof(GooseSourceFilterActive)); + OnPropertyChanged(nameof(GooseSourceScopeText)); + OnPropertyChanged(nameof(GoosePublisherCountText)); + OnPropertyChanged(nameof(GooseFilterSummaryText)); + _gooseHistoryView.Refresh(); + UpdateGooseVisibleEventCount(); + OnPropertyChanged(nameof(GooseEmptyGuidance)); + } + if (liveFocus is not null) + SelectedGoosePublisherRow = liveFocus; + } + + public void ClearGooseSourceSelection() => + SetGooseSourceSelection(Array.Empty()); + // Operator accordion state is SSOT in the session ViewModel, independent // of capture snapshots and TabControl visual tree recreation. @@ -344,7 +372,8 @@ public string SelectedGooseIdFilter : "No new state changes are shown. Turn on repeated messages to see ongoing traffic."; public string GooseRetransmissionModeText => IncludeGooseRetransmission ? "Repeated messages visible" : "Showing changes only"; - public string GoosePublisherCountText => $"{_gooseMessages.Count} device{(_gooseMessages.Count == 1 ? "" : "s")}"; + public string GoosePublisherCountText => $"{_gooseMessages.Count} device{(_gooseMessages.Count == 1 ? "" : "s")}" + + (_selectedGooseSourceKeys.Count == 0 ? "" : $" · {_selectedGooseSourceKeys.Count} selected"); public string GooseTimelineCountersText => $"{_gooseSnapshot.TotalMessages:N0} messages received · {_gooseVisibleEventCount:N0} shown"; public string GooseWorkspaceCaptureSummaryText @@ -848,6 +877,7 @@ public MainWindowViewModel() GooseReturnToLiveCommand = new RelayCommand(ReturnGooseToLive); GooseOpenConfigurationCommand = new RelayCommand(() => CurrentWorkspaceTabIndex = 5); GooseClearSearchCommand = new RelayCommand(() => GoosePublisherFilterText = string.Empty); + GooseClearSourceSelectionCommand = new RelayCommand(ClearGooseSourceSelection); _timer = new DispatcherTimer { @@ -1269,6 +1299,7 @@ private void ApplySelectedStreamScale(string? streamId) public ICommand GooseReturnToLiveCommand { get; } public ICommand GooseOpenConfigurationCommand { get; } public ICommand GooseClearSearchCommand { get; } + public ICommand GooseClearSourceSelectionCommand { get; } public ICommand CopyEvidenceCommand { get; } public ICommand LoadSclCommand { get; } public ICommand InspectPcapCommand { get; } @@ -2343,7 +2374,7 @@ private async Task RefreshAsync() _gooseSnapshot = gooseSnapshot; _gooseMessages = _gooseSnapshot.Messages; - _gooseCatalog.Reconcile(_gooseMessages, _selectedGoosePublisherKey); + _gooseCatalog.Reconcile(_gooseMessages, _selectedGoosePublisherKey, _selectedGooseSourceKeys); UpdateGooseIdFilterOptions(); foreach (var msg in _gooseSnapshot.Messages) @@ -4536,6 +4567,7 @@ private async Task ClearAsync() _gooseSnapshot = new GooseMonitorSnapshot { StatusText = "Cleared" }; _gooseMessages = Array.Empty(); _gooseCatalog.Clear(); + ClearGooseSourceSelection(); _selectedGoosePublisherKey = null; _gooseInspectHistoricalEvent = false; OnPropertyChanged(nameof(IsGooseHistoryPinned)); @@ -4639,32 +4671,11 @@ private bool FilterGooseHistory(object item) if (!IncludeGooseRetransmission && string.Equals(row.EventType, "Retrans", StringComparison.OrdinalIgnoreCase)) return false; - if (!string.Equals(SelectedGooseIdFilter, "All", StringComparison.OrdinalIgnoreCase)) - { - var matchesSelected = string.Equals(row.GoId, SelectedGooseIdFilter, StringComparison.OrdinalIgnoreCase) || - string.Equals(row.GoCbRef, SelectedGooseIdFilter, StringComparison.OrdinalIgnoreCase); - if (!matchesSelected) - return false; - } - - if (!string.IsNullOrWhiteSpace(GoosePublisherFilterText)) - { - var filter = GoosePublisherFilterText.Trim(); - var matchesText = Contains(row.GoId, filter) || - Contains(row.GoCbRef, filter) || - Contains(row.AppId, filter) || - Contains(row.DataSet, filter) || - Contains(row.SourceMac, filter) || - Contains(row.DestinationMac, filter); - if (!matchesText) - return false; - } + if (_selectedGooseSourceKeys.Count > 0 && + !_selectedGooseSourceKeys.Contains(GoosePublisherKey.From(row.Source))) + return false; return true; - - static bool Contains(string? text, string filter) => - !string.IsNullOrWhiteSpace(text) && - text.Contains(filter, StringComparison.OrdinalIgnoreCase); } private IReadOnlyList BuildGooseDatasetValues(GooseMessageItem? goose) From 4db55dfb5822960f7bb5dc7fe5f722d8fdd19b08 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 15:02:56 +0700 Subject: [PATCH 03/59] feat(goose): add stable multi-source Messages scope and move repeats control --- src/ProcessBus.App.Wpf/MainWindow.xaml | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/ProcessBus.App.Wpf/MainWindow.xaml b/src/ProcessBus.App.Wpf/MainWindow.xaml index dc1fa255..b7c3c9be 100644 --- a/src/ProcessBus.App.Wpf/MainWindow.xaml +++ b/src/ProcessBus.App.Wpf/MainWindow.xaml @@ -1399,7 +1399,7 @@ - + @@ -1453,8 +1453,10 @@ - - - + + + Date: Wed, 21 Oct 2026 15:03:40 +0700 Subject: [PATCH 06/59] fix(goose): preserve filtered keyed selection and avoid unregistered XAML converter --- src/ProcessBus.App.Wpf/MainWindow.xaml.cs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/ProcessBus.App.Wpf/MainWindow.xaml.cs b/src/ProcessBus.App.Wpf/MainWindow.xaml.cs index 70e86ed9..df49ecc3 100644 --- a/src/ProcessBus.App.Wpf/MainWindow.xaml.cs +++ b/src/ProcessBus.App.Wpf/MainWindow.xaml.cs @@ -22,7 +22,15 @@ public MainWindow() InitializeComponent(); if (DataContext is MainWindowViewModel viewModel) { } - Loaded += (_, _) => { UpdateHeaderDensity(); UpdateWorkspaceActivePill(animate: false); }; + Loaded += (_, _) => + { + UpdateHeaderDensity(); UpdateWorkspaceActivePill(animate: false); + if (DataContext is MainWindowViewModel vm) + { + vm.PropertyChanged += GooseViewModel_PropertyChanged; + SyncGooseSourceVisualSelection(); + } + }; SizeChanged += (_, _) => UpdateHeaderDensity(); Closing += MainWindow_Closing; Closed += (_, _) => Application.Current?.Shutdown(); @@ -266,6 +274,14 @@ private void GooseClearSearch_Click(object sender, RoutedEventArgs e) GooseSourceSearchBox.Focus(); } + private void GooseViewModel_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName is not (nameof(MainWindowViewModel.GoosePublisherFilterText) or + nameof(MainWindowViewModel.GooseSourceScopeText))) return; + Dispatcher.BeginInvoke(new Action(SyncGooseSourceVisualSelection), + System.Windows.Threading.DispatcherPriority.ContextIdle); + } + private bool _syncingGooseSourceSelection; private ProcessBus.Core.Presentation.GoosePublisherKey? _gooseSourceRangeAnchor; From bd4b91932edd53278572d59e8e85f0d5e485317d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 15:04:06 +0700 Subject: [PATCH 07/59] test(goose): selected physical publisher keys survive stale and return --- .../GoosePublisherSelectionTests.cs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/ProcessBus.Tests/GoosePublisherSelectionTests.cs b/tests/ProcessBus.Tests/GoosePublisherSelectionTests.cs index 4b4e3366..7fcd351c 100644 --- a/tests/ProcessBus.Tests/GoosePublisherSelectionTests.cs +++ b/tests/ProcessBus.Tests/GoosePublisherSelectionTests.cs @@ -163,6 +163,35 @@ public void TestFlagUpdatesWithoutRecreatingTheSelectedPublisherRow() Assert.Contains(nameof(GoosePublisherRow.IsTest), changed); } + [Fact] + public void MultipleSelectedPhysicalKeysStayStableThroughAbsenceAndReturn() + { + var catalog = new GoosePublisherCatalog(); + var first = Frame(); + var second = Frame(source: "02:00:4C:4E:4E:51"); + var outsider = Frame(source: "02:00:4C:4E:4E:52"); + var pinned = new HashSet + { + GoosePublisherKey.From(first), GoosePublisherKey.From(second) + }; + catalog.Reconcile([first, second, outsider], null, pinned); + var firstRow = catalog.Find(GoosePublisherKey.From(first)); + var secondRow = catalog.Find(GoosePublisherKey.From(second)); + + catalog.Reconcile([outsider], null, pinned); + Assert.Equal(2, catalog.Rows.Count); + Assert.Same(firstRow, catalog.Find(GoosePublisherKey.From(first))); + Assert.Same(secondRow, catalog.Find(GoosePublisherKey.From(second))); + Assert.True(firstRow!.IsStale); + Assert.True(secondRow!.IsStale); + + catalog.Reconcile([Frame(sq: 88), second], null, pinned); + Assert.Same(firstRow, catalog.Find(GoosePublisherKey.From(first))); + Assert.False(firstRow.IsStale); + Assert.Equal((uint)88, firstRow.Current.SqNum); + Assert.False(secondRow.IsStale); + } + [Fact] public void HighTrafficDoesNotCauseUnboundedRows() { From fcc73c88f973d398664e5efee33ca91fbb1a5d27 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 15:04:19 +0700 Subject: [PATCH 08/59] test(goose): account for the live unselected publisher --- tests/ProcessBus.Tests/GoosePublisherSelectionTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ProcessBus.Tests/GoosePublisherSelectionTests.cs b/tests/ProcessBus.Tests/GoosePublisherSelectionTests.cs index 7fcd351c..7de275eb 100644 --- a/tests/ProcessBus.Tests/GoosePublisherSelectionTests.cs +++ b/tests/ProcessBus.Tests/GoosePublisherSelectionTests.cs @@ -179,7 +179,7 @@ public void MultipleSelectedPhysicalKeysStayStableThroughAbsenceAndReturn() var secondRow = catalog.Find(GoosePublisherKey.From(second)); catalog.Reconcile([outsider], null, pinned); - Assert.Equal(2, catalog.Rows.Count); + Assert.Equal(3, catalog.Rows.Count); Assert.Same(firstRow, catalog.Find(GoosePublisherKey.From(first))); Assert.Same(secondRow, catalog.Find(GoosePublisherKey.From(second))); Assert.True(firstRow!.IsStale); From 3d09b18e4291ac78eb6f572abd9148f1cf535af5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 15:18:02 +0700 Subject: [PATCH 09/59] feat(goose): define bounded decoded-value change envelope --- src/ProcessBus.Core/Models/GooseMonitorSnapshot.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/ProcessBus.Core/Models/GooseMonitorSnapshot.cs b/src/ProcessBus.Core/Models/GooseMonitorSnapshot.cs index 5317a495..9cee22d8 100644 --- a/src/ProcessBus.Core/Models/GooseMonitorSnapshot.cs +++ b/src/ProcessBus.Core/Models/GooseMonitorSnapshot.cs @@ -1,10 +1,17 @@ +using ProcessBus.Core.Presentation; + namespace ProcessBus.Core.Models; +// Bounded decoder-side evidence, independent of the UI snapshot cadence. +public sealed record GooseValueChangeEvent(GoosePublisherKey PublisherKey, long Revision, DateTime TimeUtc); + public sealed class GooseMonitorSnapshot { public bool IsRunning { get; init; } public string StatusText { get; init; } = "Stopped"; public long TotalMessages { get; init; } public IReadOnlyList Messages { get; init; } = Array.Empty(); + public IReadOnlyList ValueChanges { get; init; } = Array.Empty(); + public long ValueChangeOverflowCount { get; init; } public IReadOnlyList Events { get; init; } = Array.Empty(); } From 80a58f6bb8e82f3e246a0a93a3f3a8366836b9c7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 15:18:22 +0700 Subject: [PATCH 10/59] feat(goose): decoder-side bounded verified DataSet change ring and safe rebaseline --- .../Analysis/RawProcessBusAnalyzer.cs | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs b/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs index 6129f936..ce81337c 100644 --- a/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs +++ b/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs @@ -1,4 +1,5 @@ using ProcessBus.Core.Models; +using ProcessBus.Core.Presentation; using ProcessBus.Iec61850.Raw.Decoding; using ProcessBus.Iec61850.Raw.Goose; using ProcessBus.Iec61850.Raw.Protocol; @@ -24,6 +25,10 @@ public sealed class RawProcessBusAnalyzer // Avoid scoring + LINQ allocations for every 4,000/4,800 Hz ASDU. private readonly Dictionary _resolvedSvMappings = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _gooseStates = new(StringComparer.Ordinal); + private const int MaxBufferedGooseValueChanges = 512; + private readonly Queue _gooseValueChanges = new(); + private long _gooseValueChangeRevision; + private long _gooseValueChangeOverflowCount; private readonly Dictionary _aggregatedEvents = new(StringComparer.OrdinalIgnoreCase); private readonly PtpHealthTracker _ptpTracker = new(); private readonly Queue _recentPtpEvents = new(); @@ -131,6 +136,8 @@ public GooseMonitorSnapshot GetGooseSnapshot() ? "Raw GOOSE monitor waiting for traffic" : $"Raw GOOSE monitor active: {_gooseStates.Count} message(s)", TotalMessages = _goosePackets, + ValueChanges = _gooseValueChanges.ToArray(), + ValueChangeOverflowCount = _gooseValueChangeOverflowCount, Messages = _gooseStates.Values .OrderBy(x => x.FirstSeenOrder) .Select(x => x.Clone()) @@ -152,6 +159,9 @@ public void Reset() _svScales.Clear(); _resolvedSvMappings.Clear(); _gooseStates.Clear(); + _gooseValueChanges.Clear(); + _gooseValueChangeRevision = 0; + _gooseValueChangeOverflowCount = 0; _events.Clear(); _aggregatedEvents.Clear(); _selectedSvKey = null; @@ -501,6 +511,16 @@ private void ObserveGoose(GoosePacket packet) var previous = state.Item; AnnotateGooseChanges(item, previous); + if (item.DataValues.Any(value => value.IsChanged)) + { + _gooseValueChanges.Enqueue(new GooseValueChangeEvent( + GoosePublisherKey.From(item), ++_gooseValueChangeRevision, item.LastSeenUtc)); + if (_gooseValueChanges.Count > MaxBufferedGooseValueChanges) + { + _gooseValueChanges.Dequeue(); + _gooseValueChangeOverflowCount++; + } + } if (previous.StNum != item.StNum || previous.SqNum != item.SqNum) { @@ -768,7 +788,23 @@ private static void AnnotateGooseChanges(GooseMessageItem current, GooseMessageI return; } + // A structural/confRev change has no trustworthy position-to-signal mapping. + // Fail closed: re-baseline rather than fabricate a changed value. + if (current.ConfRev != previous.ConfRev || + current.DataValues.Count != previous.DataValues.Count || + current.DataValues.Select(x => x.Index).Distinct().Count() != current.DataValues.Count || + previous.DataValues.Select(x => x.Index).Distinct().Count() != previous.DataValues.Count) + { + current.ChangedSummaryText = "DataSet layout changed - new baseline"; + return; + } var previousByIndex = previous.DataValues.ToDictionary(x => x.Index); + if (current.DataValues.Any(value => !previousByIndex.TryGetValue(value.Index, out var old) || + !string.Equals(value.Type, old.Type, StringComparison.Ordinal))) + { + current.ChangedSummaryText = "DataSet entries changed - new baseline"; + return; + } foreach (var value in current.DataValues) { if (!previousByIndex.TryGetValue(value.Index, out var oldValue)) From 6d2a399bbe2fe5409e49f19cc528c81d7201e825 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 15:18:36 +0700 Subject: [PATCH 11/59] feat(goose): keep transient decoded-value change evidence on stable source rows --- .../Presentation/GoosePublisherCatalog.cs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/ProcessBus.Core/Presentation/GoosePublisherCatalog.cs b/src/ProcessBus.Core/Presentation/GoosePublisherCatalog.cs index d07b7103..de480958 100644 --- a/src/ProcessBus.Core/Presentation/GoosePublisherCatalog.cs +++ b/src/ProcessBus.Core/Presentation/GoosePublisherCatalog.cs @@ -39,6 +39,28 @@ public GoosePublisherRow(GoosePublisherKey key, GooseMessageItem current) public GoosePublisherKey Key { get; } public GooseMessageItem Current { get; private set; } public bool IsStale { get; private set; } + public DateTime? LastValueChangeUtc { get; private set; } + public long ChangeRevision { get; private set; } + public bool HasRecentValueChange { get; private set; } + public string LastValueChangeText => LastValueChangeUtc?.ToLocalTime().ToString("HH:mm:ss.fff") ?? "-"; + + public void MarkDecodedValueChange(DateTime timeUtc, long revision) + { + if (revision <= ChangeRevision) return; + ChangeRevision = revision; + LastValueChangeUtc = timeUtc; + HasRecentValueChange = true; + OnPropertyChanged(nameof(LastValueChangeText)); + OnPropertyChanged(nameof(HasRecentValueChange)); + } + + public void ExpireValueChangeNotice(DateTime nowUtc) + { + if (!HasRecentValueChange || !LastValueChangeUtc.HasValue || + (nowUtc - LastValueChangeUtc.Value).TotalMilliseconds < 2500) return; + HasRecentValueChange = false; + OnPropertyChanged(nameof(HasRecentValueChange)); + } public string GoId => Current.GoId; public string GoCbRef => Current.GoCbRef; public string AppId => Current.AppId; From 7d0db02ec66ddbf0a2b38c6a75626148f53df4eb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 15:18:58 +0700 Subject: [PATCH 12/59] feat(goose): project bounded decoder value-change evidence into GOOSE Sources --- .../ViewModels/MainWindowViewModel.cs | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs index 2b23ee3e..582d9aeb 100644 --- a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs +++ b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs @@ -46,6 +46,9 @@ public sealed class MainWindowViewModel : INotifyPropertyChanged, IDisposable private readonly GoosePublisherCatalog _gooseCatalog = new(); private GoosePublisherKey? _selectedGoosePublisherKey; private readonly HashSet _selectedGooseSourceKeys = new(); + private long _lastGooseValueChangeRevision; + private long _lastGooseValueChangeOverflow; + private long _gooseValueChangeDroppedSinceLastRead; private GooseTrafficRow? _selectedGooseTrafficRow; private bool _gooseInspectHistoricalEvent; private bool _gooseMessageExpanded = true; @@ -254,6 +257,9 @@ public string GoosePublisherFilterText } public bool GooseHasSearchText => !string.IsNullOrWhiteSpace(GoosePublisherFilterText); + public string GooseValueChangeLossText => _gooseValueChangeDroppedSinceLastRead == 0 + ? "" : $"{_gooseValueChangeDroppedSinceLastRead:N0} decoded changes dropped from buffer"; + public bool GooseHasValueChangeLoss => _gooseValueChangeDroppedSinceLastRead > 0; public IReadOnlyCollection SelectedGooseSourceKeys => _selectedGooseSourceKeys; public bool GooseSourceFilterActive => _selectedGooseSourceKeys.Count > 0; public string GooseSourceScopeText => _selectedGooseSourceKeys.Count == 0 @@ -2375,6 +2381,7 @@ private async Task RefreshAsync() _gooseSnapshot = gooseSnapshot; _gooseMessages = _gooseSnapshot.Messages; _gooseCatalog.Reconcile(_gooseMessages, _selectedGoosePublisherKey, _selectedGooseSourceKeys); + ApplyDecodedGooseValueChangeEvents(_gooseSnapshot, now); UpdateGooseIdFilterOptions(); foreach (var msg in _gooseSnapshot.Messages) @@ -4567,6 +4574,11 @@ private async Task ClearAsync() _gooseSnapshot = new GooseMonitorSnapshot { StatusText = "Cleared" }; _gooseMessages = Array.Empty(); _gooseCatalog.Clear(); + _lastGooseValueChangeRevision = 0; + _lastGooseValueChangeOverflow = 0; + _gooseValueChangeDroppedSinceLastRead = 0; + OnPropertyChanged(nameof(GooseValueChangeLossText)); + OnPropertyChanged(nameof(GooseHasValueChangeLoss)); ClearGooseSourceSelection(); _selectedGoosePublisherKey = null; _gooseInspectHistoricalEvent = false; @@ -4659,6 +4671,27 @@ private static PhasorDisplayItem CreatePhasor(string name, PhasorFamily family, Freshness = channel.Freshness }; + private void ApplyDecodedGooseValueChangeEvents(GooseMonitorSnapshot snapshot, DateTime nowUtc) + { + // The decoder ring is repeatedly sampled. Monotonic revisions prevent + // duplicate UI notifications; no raw frames are retained here. + foreach (var change in snapshot.ValueChanges) + { + if (change.Revision <= _lastGooseValueChangeRevision) continue; + _gooseCatalog.Find(change.PublisherKey)?.MarkDecodedValueChange(change.TimeUtc, change.Revision); + _lastGooseValueChangeRevision = change.Revision; + } + if (snapshot.ValueChangeOverflowCount > _lastGooseValueChangeOverflow) + { + _gooseValueChangeDroppedSinceLastRead += snapshot.ValueChangeOverflowCount - _lastGooseValueChangeOverflow; + _lastGooseValueChangeOverflow = snapshot.ValueChangeOverflowCount; + OnPropertyChanged(nameof(GooseValueChangeLossText)); + OnPropertyChanged(nameof(GooseHasValueChangeLoss)); + } + foreach (var row in _gooseCatalog.Rows) + if (row.HasRecentValueChange) row.ExpireValueChangeNotice(nowUtc); + } + private bool FilterGoosePublisher(object item) => item is GoosePublisherRow row && GoosePublisherCatalog.MatchesFilter(row.Current, SelectedGooseIdFilter, GoosePublisherFilterText); From 6f22f64f4241381f2eacb993d42325174c74f275 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 15:19:12 +0700 Subject: [PATCH 13/59] feat(goose): understated time-bounded Changed marker on true DataSet value delta --- src/ProcessBus.App.Wpf/MainWindow.xaml | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/ProcessBus.App.Wpf/MainWindow.xaml b/src/ProcessBus.App.Wpf/MainWindow.xaml index dc39e8d5..9b0736a3 100644 --- a/src/ProcessBus.App.Wpf/MainWindow.xaml +++ b/src/ProcessBus.App.Wpf/MainWindow.xaml @@ -1490,7 +1490,22 @@ - + + + + + + + Date: Wed, 21 Oct 2026 15:20:37 +0700 Subject: [PATCH 14/59] feat(goose): explicit follow state and once-per-batch visible append revision; truthful gap loss --- .../ViewModels/MainWindowViewModel.cs | 84 ++++++++++++++++--- 1 file changed, 72 insertions(+), 12 deletions(-) diff --git a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs index 582d9aeb..93fc9874 100644 --- a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs +++ b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs @@ -47,9 +47,11 @@ public sealed class MainWindowViewModel : INotifyPropertyChanged, IDisposable private GoosePublisherKey? _selectedGoosePublisherKey; private readonly HashSet _selectedGooseSourceKeys = new(); private long _lastGooseValueChangeRevision; - private long _lastGooseValueChangeOverflow; private long _gooseValueChangeDroppedSinceLastRead; private GooseTrafficRow? _selectedGooseTrafficRow; + private bool _gooseFollowLive = true; + private long _gooseVisibleAppendRevision; + private int _gooseUnseenVisibleEventCount; private bool _gooseInspectHistoricalEvent; private bool _gooseMessageExpanded = true; private bool _gooseNetworkExpanded; @@ -235,6 +237,7 @@ public bool IncludeGooseRetransmission OnPropertyChanged(nameof(GooseEmptyGuidance)); _gooseHistoryView.Refresh(); UpdateGooseVisibleEventCount(); + RequestGooseFollowScroll(); } } @@ -260,6 +263,37 @@ public string GoosePublisherFilterText public string GooseValueChangeLossText => _gooseValueChangeDroppedSinceLastRead == 0 ? "" : $"{_gooseValueChangeDroppedSinceLastRead:N0} decoded changes dropped from buffer"; public bool GooseHasValueChangeLoss => _gooseValueChangeDroppedSinceLastRead > 0; + public bool GooseFollowLive => _gooseFollowLive; + public long GooseVisibleAppendRevision => _gooseVisibleAppendRevision; + public int GooseUnseenVisibleEventCount => _gooseUnseenVisibleEventCount; + public string GooseFollowButtonText => _gooseUnseenVisibleEventCount == 0 + ? "Follow live" : $"Follow live · {_gooseUnseenVisibleEventCount}"; + public GooseTrafficRow? GooseLatestVisibleTrafficRow => + _gooseHistoryView.Cast().LastOrDefault(); + + public void SuspendGooseFollow() + { + if (!_gooseFollowLive) return; + _gooseFollowLive = false; + OnPropertyChanged(nameof(GooseFollowLive)); + } + + public void ResumeGooseFollow() + { + _gooseFollowLive = true; + _gooseUnseenVisibleEventCount = 0; + OnPropertyChanged(nameof(GooseUnseenVisibleEventCount)); + OnPropertyChanged(nameof(GooseFollowButtonText)); + OnPropertyChanged(nameof(GooseFollowLive)); + RequestGooseFollowScroll(); + } + + private void RequestGooseFollowScroll() + { + _gooseVisibleAppendRevision++; + OnPropertyChanged(nameof(GooseVisibleAppendRevision)); + } + public IReadOnlyCollection SelectedGooseSourceKeys => _selectedGooseSourceKeys; public bool GooseSourceFilterActive => _selectedGooseSourceKeys.Count > 0; public string GooseSourceScopeText => _selectedGooseSourceKeys.Count == 0 @@ -280,6 +314,7 @@ public void SetGooseSourceSelection(IEnumerable keys, GoosePu OnPropertyChanged(nameof(GooseFilterSummaryText)); _gooseHistoryView.Refresh(); UpdateGooseVisibleEventCount(); + RequestGooseFollowScroll(); OnPropertyChanged(nameof(GooseEmptyGuidance)); } if (liveFocus is not null) @@ -884,6 +919,7 @@ public MainWindowViewModel() GooseOpenConfigurationCommand = new RelayCommand(() => CurrentWorkspaceTabIndex = 5); GooseClearSearchCommand = new RelayCommand(() => GoosePublisherFilterText = string.Empty); GooseClearSourceSelectionCommand = new RelayCommand(ClearGooseSourceSelection); + GooseResumeFollowCommand = new RelayCommand(ResumeGooseFollow); _timer = new DispatcherTimer { @@ -1004,6 +1040,7 @@ public GooseTrafficRow? SelectedGooseTrafficRow return; _selectedGooseTrafficRow = value; _gooseInspectHistoricalEvent = true; + SuspendGooseFollow(); BeginWorkspaceTargetTransition(); OnPropertyChanged(); OnPropertyChanged(nameof(IsGooseHistoryPinned)); @@ -1306,6 +1343,7 @@ private void ApplySelectedStreamScale(string? streamId) public ICommand GooseOpenConfigurationCommand { get; } public ICommand GooseClearSearchCommand { get; } public ICommand GooseClearSourceSelectionCommand { get; } + public ICommand GooseResumeFollowCommand { get; } public ICommand CopyEvidenceCommand { get; } public ICommand LoadSclCommand { get; } public ICommand InspectPcapCommand { get; } @@ -4574,8 +4612,13 @@ private async Task ClearAsync() _gooseSnapshot = new GooseMonitorSnapshot { StatusText = "Cleared" }; _gooseMessages = Array.Empty(); _gooseCatalog.Clear(); + _gooseFollowLive = true; + _gooseUnseenVisibleEventCount = 0; + _gooseVisibleAppendRevision = 0; + OnPropertyChanged(nameof(GooseFollowLive)); + OnPropertyChanged(nameof(GooseUnseenVisibleEventCount)); + OnPropertyChanged(nameof(GooseFollowButtonText)); _lastGooseValueChangeRevision = 0; - _lastGooseValueChangeOverflow = 0; _gooseValueChangeDroppedSinceLastRead = 0; OnPropertyChanged(nameof(GooseValueChangeLossText)); OnPropertyChanged(nameof(GooseHasValueChangeLoss)); @@ -4673,21 +4716,25 @@ private static PhasorDisplayItem CreatePhasor(string name, PhasorFamily family, private void ApplyDecodedGooseValueChangeEvents(GooseMonitorSnapshot snapshot, DateTime nowUtc) { - // The decoder ring is repeatedly sampled. Monotonic revisions prevent - // duplicate UI notifications; no raw frames are retained here. + // Only revision gaps PROVE that the reader missed an event. + // Total ring evictions are NOT necessarily losses: earlier entries may + // already have been consumed by previous UI snapshots. + if (snapshot.ValueChanges.Count > 0) + { + var first = snapshot.ValueChanges[0].Revision; + if (first > _lastGooseValueChangeRevision + 1) + { + _gooseValueChangeDroppedSinceLastRead += first - _lastGooseValueChangeRevision - 1; + OnPropertyChanged(nameof(GooseValueChangeLossText)); + OnPropertyChanged(nameof(GooseHasValueChangeLoss)); + } + } foreach (var change in snapshot.ValueChanges) { if (change.Revision <= _lastGooseValueChangeRevision) continue; _gooseCatalog.Find(change.PublisherKey)?.MarkDecodedValueChange(change.TimeUtc, change.Revision); _lastGooseValueChangeRevision = change.Revision; } - if (snapshot.ValueChangeOverflowCount > _lastGooseValueChangeOverflow) - { - _gooseValueChangeDroppedSinceLastRead += snapshot.ValueChangeOverflowCount - _lastGooseValueChangeOverflow; - _lastGooseValueChangeOverflow = snapshot.ValueChangeOverflowCount; - OnPropertyChanged(nameof(GooseValueChangeLossText)); - OnPropertyChanged(nameof(GooseHasValueChangeLoss)); - } foreach (var row in _gooseCatalog.Rows) if (row.HasRecentValueChange) row.ExpireValueChangeNotice(nowUtc); } @@ -4928,10 +4975,13 @@ private void FlushBufferedGooseRows(bool flushAll = false) { var limit = flushAll ? int.MaxValue : GooseBufferedFlushLimit; var flushed = 0; + var newVisible = 0; while (_pendingGooseRows.Count > 0 && flushed < limit) { - _gooseHistory.Add(_pendingGooseRows.Dequeue()); + var row = _pendingGooseRows.Dequeue(); + if (FilterGooseHistory(row)) newVisible++; + _gooseHistory.Add(row); flushed++; } @@ -4946,6 +4996,16 @@ private void FlushBufferedGooseRows(bool flushAll = false) // The view observes incremental changes. Never Refresh() on each event. if (flushed > 0 || removeCount > 0) UpdateGooseVisibleEventCount(); + if (newVisible > 0) + { + if (!_gooseFollowLive) + { + _gooseUnseenVisibleEventCount = Math.Min(100000, _gooseUnseenVisibleEventCount + newVisible); + OnPropertyChanged(nameof(GooseUnseenVisibleEventCount)); + OnPropertyChanged(nameof(GooseFollowButtonText)); + } + RequestGooseFollowScroll(); // ONE viewport signal per completed batch + } } private void TryFlushBufferedGooseRows() From c589465bd40cd03dab99016a83b5d18f287aadc1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 15:21:38 +0700 Subject: [PATCH 15/59] feat(goose): compact Messages actions and follow live button; no mouse-hover deferral --- src/ProcessBus.App.Wpf/MainWindow.xaml | 133 ++++++++----------------- 1 file changed, 44 insertions(+), 89 deletions(-) diff --git a/src/ProcessBus.App.Wpf/MainWindow.xaml b/src/ProcessBus.App.Wpf/MainWindow.xaml index 9b0736a3..3b71404c 100644 --- a/src/ProcessBus.App.Wpf/MainWindow.xaml +++ b/src/ProcessBus.App.Wpf/MainWindow.xaml @@ -1294,7 +1294,6 @@ - @@ -1399,60 +1398,8 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - - - - - - - - - - - - - - + + + + + Margin="0,0,7,0" VerticalAlignment="Center"/> + + + + + - + PreviewMouseDown="GooseTraffic_ClickInteraction" + PreviewMouseWheel="GooseMessageGrid_PreviewMouseWheel" + PreviewKeyDown="GooseMessageGrid_PreviewKeyDown" + PreviewTouchDown="GooseMessageGrid_PreviewTouchDown"> From 908f544e6b14a7edff46123145cfc31337545eb4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 15:22:33 +0700 Subject: [PATCH 16/59] feat(goose): suspend on real operator interaction; coalesce batch follow without row selection --- src/ProcessBus.App.Wpf/MainWindow.xaml.cs | 84 +++++++++++++++++++++-- 1 file changed, 80 insertions(+), 4 deletions(-) diff --git a/src/ProcessBus.App.Wpf/MainWindow.xaml.cs b/src/ProcessBus.App.Wpf/MainWindow.xaml.cs index df49ecc3..9368c006 100644 --- a/src/ProcessBus.App.Wpf/MainWindow.xaml.cs +++ b/src/ProcessBus.App.Wpf/MainWindow.xaml.cs @@ -276,10 +276,16 @@ private void GooseClearSearch_Click(object sender, RoutedEventArgs e) private void GooseViewModel_PropertyChanged(object? sender, PropertyChangedEventArgs e) { - if (e.PropertyName is not (nameof(MainWindowViewModel.GoosePublisherFilterText) or - nameof(MainWindowViewModel.GooseSourceScopeText))) return; - Dispatcher.BeginInvoke(new Action(SyncGooseSourceVisualSelection), - System.Windows.Threading.DispatcherPriority.ContextIdle); + if (e.PropertyName is nameof(MainWindowViewModel.GoosePublisherFilterText) or + nameof(MainWindowViewModel.GooseSourceScopeText)) + Dispatcher.BeginInvoke(new Action(SyncGooseSourceVisualSelection), + System.Windows.Threading.DispatcherPriority.ContextIdle); + + if (e.PropertyName == nameof(MainWindowViewModel.GooseVisibleAppendRevision)) + ScheduleGooseFollowScroll(force: false); + else if (e.PropertyName == nameof(MainWindowViewModel.GooseFollowLive) && + sender is MainWindowViewModel { GooseFollowLive: true }) + ScheduleGooseFollowScroll(force: true); } private bool _syncingGooseSourceSelection; @@ -373,9 +379,79 @@ private void GooseTraffic_Interaction(object sender, MouseEventArgs e) private void GooseTraffic_ClickInteraction(object sender, MouseButtonEventArgs e) { + SuspendGooseFollowFromOperator(); DeferGooseTrafficFlush(); } + private bool _gooseFollowScrollScheduled; + private bool _gooseForceFollowScroll; + + private void SuspendGooseFollowFromOperator() + { + if (DataContext is MainWindowViewModel vm) + vm.SuspendGooseFollow(); + } + + private void GooseMessageGrid_PreviewMouseWheel(object sender, MouseWheelEventArgs e) => + SuspendGooseFollowFromOperator(); + + private void GooseMessageGrid_PreviewTouchDown(object sender, TouchEventArgs e) => + SuspendGooseFollowFromOperator(); + + private void GooseMessageGrid_PreviewKeyDown(object sender, KeyEventArgs e) + { + if (e.Key is Key.Up or Key.Down or Key.Home or Key.End or + Key.PageUp or Key.PageDown or Key.Space or Key.Left or Key.Right) + SuspendGooseFollowFromOperator(); + } + + private static T? FindGooseVisualChild(DependencyObject root) where T : DependencyObject + { + for (var i = 0; i < VisualTreeHelper.GetChildrenCount(root); i++) + { + var child = VisualTreeHelper.GetChild(root, i); + if (child is T result) return result; + var descendant = FindGooseVisualChild(child); + if (descendant is not null) return descendant; + } + return null; + } + + private bool IsGooseGridAtBottom() + { + var scroll = FindGooseVisualChild(GooseMessageGrid); + return scroll is null || scroll.VerticalOffset >= scroll.ScrollableHeight - 1.5; + } + + private void ScheduleGooseFollowScroll(bool force) + { + if (DataContext is not MainWindowViewModel { GooseFollowLive: true }) return; + if (!force && !IsGooseGridAtBottom()) + { + SuspendGooseFollowFromOperator(); + return; + } + _gooseForceFollowScroll |= force; + if (_gooseFollowScrollScheduled) return; + _gooseFollowScrollScheduled = true; + // The view has already received the entire bounded append batch. + // No selection/focus mutation, no per-frame or per-packet dispatch. + Dispatcher.BeginInvoke(new Action(() => + { + _gooseFollowScrollScheduled = false; + var shouldScroll = _gooseForceFollowScroll; + _gooseForceFollowScroll = false; + if (DataContext is not MainWindowViewModel vm || !vm.GooseFollowLive || + !GooseMessageGrid.IsVisible) return; + if (shouldScroll || IsGooseGridAtBottom() || + vm.GooseVisibleAppendRevision > 0) + { + var last = vm.GooseLatestVisibleTrafficRow; + if (last is not null) GooseMessageGrid.ScrollIntoView(last); + } + }), System.Windows.Threading.DispatcherPriority.Loaded); + } + private void DeferGooseTrafficFlush() { var now = DateTime.UtcNow; From 2ff9b119855418ede9886376e50f735a7b3ba03f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 15:24:28 +0700 Subject: [PATCH 17/59] test(goose): parameterize golden GOOSE payload value and revision --- tests/ProcessBus.Tests/GoldenFrames.cs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/ProcessBus.Tests/GoldenFrames.cs b/tests/ProcessBus.Tests/GoldenFrames.cs index 3345a753..88a6182f 100644 --- a/tests/ProcessBus.Tests/GoldenFrames.cs +++ b/tests/ProcessBus.Tests/GoldenFrames.cs @@ -104,7 +104,7 @@ public static byte[] GooseApdu( string dataSet = "IED1LD0/LLN0$DataSet01", string goId = "GO_TRIP", uint stNum = 3, - uint sqNum = 7) + uint sqNum = 7, bool signalValue = true, byte confRev = 1) { var utcTimestamp = new byte[8]; @@ -117,10 +117,10 @@ public static byte[] GooseApdu( Tlv(0x85, new[] { (byte)stNum }), Tlv(0x86, new[] { (byte)sqNum }), Tlv(0x87, new byte[] { 0x00 }), - Tlv(0x88, new byte[] { 0x01 }), + Tlv(0x88, new byte[] { confRev }), Tlv(0x89, new byte[] { 0x00 }), Tlv(0x8A, new byte[] { 0x01 }), - Tlv(0xAB, Tlv(0x83, new byte[] { 0x01 }))); + Tlv(0xAB, Tlv(0x83, new byte[] { signalValue ? (byte)0x01 : (byte)0x00 }))); } /// @@ -163,8 +163,10 @@ public static byte[] SvFrame(ushort appId = 0x4000, ushort? vlanTci = null) return EthernetFrame(SvEtherType, ProcessBusPayload(appId, SvApdu()), vlanTci); } - public static byte[] GooseFrame(ushort appId = 0x0001) + public static byte[] GooseFrame(ushort appId = 0x0001, uint stNum = 3, uint sqNum = 7, + bool signalValue = true, byte confRev = 1) { - return EthernetFrame(GooseEtherType, ProcessBusPayload(appId, GooseApdu())); + return EthernetFrame(GooseEtherType, ProcessBusPayload(appId, + GooseApdu(stNum: stNum, sqNum: sqNum, signalValue: signalValue, confRev: confRev))); } } From e304501da939eb8fdfd256b3923e010d6b8db389 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 15:24:51 +0700 Subject: [PATCH 18/59] test(goose): genuine decoded value deltas baseline retrans confRev burst and notice --- .../GooseDecodedValueChangesTests.cs | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 tests/ProcessBus.Tests/GooseDecodedValueChangesTests.cs diff --git a/tests/ProcessBus.Tests/GooseDecodedValueChangesTests.cs b/tests/ProcessBus.Tests/GooseDecodedValueChangesTests.cs new file mode 100644 index 00000000..3f52e407 --- /dev/null +++ b/tests/ProcessBus.Tests/GooseDecodedValueChangesTests.cs @@ -0,0 +1,90 @@ +using ProcessBus.Core.Presentation; +using ProcessBus.Iec61850.Raw.Analysis; +using Xunit; + +namespace ProcessBus.Tests; + +public sealed class GooseDecodedValueChangesTests +{ + private static void Observe(RawProcessBusAnalyzer analyzer, uint st, uint sq, bool value, + byte confRev = 1) + { + analyzer.ObserveOwnedFrame(GoldenFrames.GooseFrame(stNum: st, sqNum: sq, + signalValue: value, confRev: confRev)); + } + + [Fact] + public void BaselineStateOnlyAndRetransmissionAreNotDecodedValueChanges() + { + var analyzer = new RawProcessBusAnalyzer(); + Observe(analyzer, 3, 1, true); + Assert.Empty(analyzer.GetGooseSnapshot().ValueChanges); + + Observe(analyzer, 3, 2, true); // sqNum-only retransmission + Observe(analyzer, 4, 1, true); // stNum-only state update + Assert.Empty(analyzer.GetGooseSnapshot().ValueChanges); + + Observe(analyzer, 5, 1, false); // actual DataSet value transition + var first = Assert.Single(analyzer.GetGooseSnapshot().ValueChanges); + Assert.Equal(1, first.Revision); + var packet = Assert.Single(analyzer.GetGooseSnapshot().Messages); + Assert.Equal(GoosePublisherKey.From(packet), first.PublisherKey); + + Observe(analyzer, 5, 2, false); // same value, new sqNum + Assert.Single(analyzer.GetGooseSnapshot().ValueChanges); + Observe(analyzer, 6, 1, true); // reverse transition + var changes = analyzer.GetGooseSnapshot().ValueChanges; + Assert.Equal(2, changes.Count); + Assert.Equal(2, changes[1].Revision); + } + + [Fact] + public void ConfigurationRevisionChangeIsSafelyRebaselined() + { + var analyzer = new RawProcessBusAnalyzer(); + Observe(analyzer, 3, 1, true, 1); + Observe(analyzer, 4, 1, false, 2); // changed confRev invalidates pairing + Assert.Empty(analyzer.GetGooseSnapshot().ValueChanges); + Observe(analyzer, 5, 1, true, 2); + Assert.Single(analyzer.GetGooseSnapshot().ValueChanges); + } + + [Fact] + public void ChangeEnvelopeRemainsBoundedAndReportsEviction() + { + var analyzer = new RawProcessBusAnalyzer(); + Observe(analyzer, 3, 1, true); + for (uint st = 4; st < 620; st++) + Observe(analyzer, st, 1, st % 2 == 0); + + var snapshot = analyzer.GetGooseSnapshot(); + Assert.Equal(512, snapshot.ValueChanges.Count); + Assert.True(snapshot.ValueChangeOverflowCount > 0); + Assert.True(snapshot.ValueChanges[0].Revision > 1); + Assert.Equal(snapshot.ValueChanges[^1].Revision - snapshot.ValueChanges[0].Revision + 1, + snapshot.ValueChanges.Count); + } + + [Fact] + public void PublisherNoticeExpiresWithoutChangingSelectionOrIdentity() + { + var packet = new ProcessBus.Core.Models.GooseMessageItem + { + SourceMac = "00:11:22:33:44:55", + AppId = "0x0001", + GoCbRef = "IED/LLN0$GO$gcb", + VlanId = "0" + }; + var row = new GoosePublisherRow(GoosePublisherKey.From(packet), packet); + var now = DateTime.UtcNow; + row.MarkDecodedValueChange(now, 1); + Assert.True(row.HasRecentValueChange); + row.MarkDecodedValueChange(now.AddMilliseconds(-100), 1); + Assert.Equal(now, row.LastValueChangeUtc); + row.ExpireValueChangeNotice(now.AddMilliseconds(2000)); + Assert.True(row.HasRecentValueChange); + row.ExpireValueChangeNotice(now.AddMilliseconds(2600)); + Assert.False(row.HasRecentValueChange); + Assert.Equal(1, row.ChangeRevision); + } +} From d93f46be9a075af14b23f9e813f5b4aa51fed09d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 15:26:38 +0700 Subject: [PATCH 19/59] fix(goose): show source scope and transient meaningful event accent; no invisible source filter --- .../ViewModels/MainWindowViewModel.cs | 36 ++++++++++++++++--- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs index 93fc9874..363ebdcf 100644 --- a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs +++ b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs @@ -310,6 +310,7 @@ public void SetGooseSourceSelection(IEnumerable keys, GoosePu _selectedGooseSourceKeys.UnionWith(next); OnPropertyChanged(nameof(GooseSourceFilterActive)); OnPropertyChanged(nameof(GooseSourceScopeText)); + OnPropertyChanged(nameof(GooseTimelineCountersText)); OnPropertyChanged(nameof(GoosePublisherCountText)); OnPropertyChanged(nameof(GooseFilterSummaryText)); _gooseHistoryView.Refresh(); @@ -416,7 +417,7 @@ public string SelectedGooseIdFilter public string GoosePublisherCountText => $"{_gooseMessages.Count} device{(_gooseMessages.Count == 1 ? "" : "s")}" + (_selectedGooseSourceKeys.Count == 0 ? "" : $" · {_selectedGooseSourceKeys.Count} selected"); public string GooseTimelineCountersText => - $"{_gooseSnapshot.TotalMessages:N0} messages received · {_gooseVisibleEventCount:N0} shown"; + $"{_gooseSnapshot.TotalMessages:N0} received · {_gooseVisibleEventCount:N0} shown · {GooseSourceScopeText}"; public string GooseWorkspaceCaptureSummaryText { get @@ -4737,11 +4738,13 @@ private void ApplyDecodedGooseValueChangeEvents(GooseMonitorSnapshot snapshot, D } foreach (var row in _gooseCatalog.Rows) if (row.HasRecentValueChange) row.ExpireValueChangeNotice(nowUtc); + foreach (var row in _gooseHistory) + if (row.IsRecentMeaningfulEvent) row.ExpireEmphasis(nowUtc); } private bool FilterGoosePublisher(object item) => item is GoosePublisherRow row && - GoosePublisherCatalog.MatchesFilter(row.Current, SelectedGooseIdFilter, GoosePublisherFilterText); + GoosePublisherCatalog.MatchesFilter(row.Current, "All", GoosePublisherFilterText); private bool FilterGooseHistory(object item) { @@ -4980,7 +4983,11 @@ private void FlushBufferedGooseRows(bool flushAll = false) while (_pendingGooseRows.Count > 0 && flushed < limit) { var row = _pendingGooseRows.Dequeue(); - if (FilterGooseHistory(row)) newVisible++; + if (FilterGooseHistory(row)) + { + newVisible++; + row.MarkNewVisibleEvent(DateTime.UtcNow); + } _gooseHistory.Add(row); flushed++; } @@ -5115,8 +5122,29 @@ private static Brush FrozenBrush(byte red, byte green, byte blue) } } -public sealed class GooseTrafficRow +public sealed class GooseTrafficRow : INotifyPropertyChanged { + private DateTime? _emphasisStartUtc; + private bool _isRecentMeaningfulEvent; + public bool IsRecentMeaningfulEvent => _isRecentMeaningfulEvent; + public event PropertyChangedEventHandler? PropertyChanged; + + public void MarkNewVisibleEvent(DateTime nowUtc) + { + if (EventType == "Retrans") return; + _emphasisStartUtc = nowUtc; + _isRecentMeaningfulEvent = true; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsRecentMeaningfulEvent))); + } + + public void ExpireEmphasis(DateTime nowUtc) + { + if (!_isRecentMeaningfulEvent || !_emphasisStartUtc.HasValue || + (nowUtc - _emphasisStartUtc.Value).TotalMilliseconds < 1500) return; + _isRecentMeaningfulEvent = false; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsRecentMeaningfulEvent))); + } + public required GooseMessageItem Source { get; init; } public string EventType { get; init; } = "STATE"; From b26971bed8b3be97ee15f3bb2beefb1bffc49a4a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 15:27:04 +0700 Subject: [PATCH 20/59] feat(goose): limited-time meaningful event accent and visible decoder loss disclosure --- src/ProcessBus.App.Wpf/MainWindow.xaml | 41 ++++++++++++++++++++------ 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/src/ProcessBus.App.Wpf/MainWindow.xaml b/src/ProcessBus.App.Wpf/MainWindow.xaml index 3b71404c..6c0aeb92 100644 --- a/src/ProcessBus.App.Wpf/MainWindow.xaml +++ b/src/ProcessBus.App.Wpf/MainWindow.xaml @@ -1304,12 +1304,24 @@ - + + + + + + + + - + + ToolTip="{Binding EventDescriptionText}" + HorizontalAlignment="Center" VerticalAlignment="Center"> + + + + From 5b15ec91ff07afbdd1d0c4f8dfb22b2552ae11c0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 15:27:57 +0700 Subject: [PATCH 21/59] fix(goose): distinguish decoder sessions so revision reset cannot suppress new value changes --- src/ProcessBus.Core/Models/GooseMonitorSnapshot.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ProcessBus.Core/Models/GooseMonitorSnapshot.cs b/src/ProcessBus.Core/Models/GooseMonitorSnapshot.cs index 9cee22d8..a2bc6bf4 100644 --- a/src/ProcessBus.Core/Models/GooseMonitorSnapshot.cs +++ b/src/ProcessBus.Core/Models/GooseMonitorSnapshot.cs @@ -13,5 +13,6 @@ public sealed class GooseMonitorSnapshot public IReadOnlyList Messages { get; init; } = Array.Empty(); public IReadOnlyList ValueChanges { get; init; } = Array.Empty(); public long ValueChangeOverflowCount { get; init; } + public Guid ValueChangeSessionId { get; init; } public IReadOnlyList Events { get; init; } = Array.Empty(); } From 2da10a5777804b0eb28d85ee322109a52d28da28 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 15:28:01 +0700 Subject: [PATCH 22/59] fix(goose): distinguish decoder sessions so revision reset cannot suppress new value changes --- src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs b/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs index ce81337c..6b83eeae 100644 --- a/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs +++ b/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs @@ -27,6 +27,7 @@ public sealed class RawProcessBusAnalyzer private readonly Dictionary _gooseStates = new(StringComparer.Ordinal); private const int MaxBufferedGooseValueChanges = 512; private readonly Queue _gooseValueChanges = new(); + private Guid _gooseValueChangeSessionId = Guid.NewGuid(); private long _gooseValueChangeRevision; private long _gooseValueChangeOverflowCount; private readonly Dictionary _aggregatedEvents = new(StringComparer.OrdinalIgnoreCase); @@ -138,6 +139,7 @@ public GooseMonitorSnapshot GetGooseSnapshot() TotalMessages = _goosePackets, ValueChanges = _gooseValueChanges.ToArray(), ValueChangeOverflowCount = _gooseValueChangeOverflowCount, + ValueChangeSessionId = _gooseValueChangeSessionId, Messages = _gooseStates.Values .OrderBy(x => x.FirstSeenOrder) .Select(x => x.Clone()) @@ -161,6 +163,7 @@ public void Reset() _gooseStates.Clear(); _gooseValueChanges.Clear(); _gooseValueChangeRevision = 0; + _gooseValueChangeSessionId = Guid.NewGuid(); _gooseValueChangeOverflowCount = 0; _events.Clear(); _aggregatedEvents.Clear(); From 5785e6ec4e6f9303ca0e989c01305a19a3529ed5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 15:28:05 +0700 Subject: [PATCH 23/59] fix(goose): distinguish decoder sessions so revision reset cannot suppress new value changes --- src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs index 363ebdcf..8382c3ae 100644 --- a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs +++ b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs @@ -46,6 +46,7 @@ public sealed class MainWindowViewModel : INotifyPropertyChanged, IDisposable private readonly GoosePublisherCatalog _gooseCatalog = new(); private GoosePublisherKey? _selectedGoosePublisherKey; private readonly HashSet _selectedGooseSourceKeys = new(); + private Guid _lastGooseValueChangeSessionId; private long _lastGooseValueChangeRevision; private long _gooseValueChangeDroppedSinceLastRead; private GooseTrafficRow? _selectedGooseTrafficRow; @@ -4620,6 +4621,7 @@ private async Task ClearAsync() OnPropertyChanged(nameof(GooseUnseenVisibleEventCount)); OnPropertyChanged(nameof(GooseFollowButtonText)); _lastGooseValueChangeRevision = 0; + _lastGooseValueChangeSessionId = Guid.Empty; _gooseValueChangeDroppedSinceLastRead = 0; OnPropertyChanged(nameof(GooseValueChangeLossText)); OnPropertyChanged(nameof(GooseHasValueChangeLoss)); @@ -4717,6 +4719,12 @@ private static PhasorDisplayItem CreatePhasor(string name, PhasorFamily family, private void ApplyDecodedGooseValueChangeEvents(GooseMonitorSnapshot snapshot, DateTime nowUtc) { + if (snapshot.ValueChangeSessionId != Guid.Empty && + snapshot.ValueChangeSessionId != _lastGooseValueChangeSessionId) + { + _lastGooseValueChangeSessionId = snapshot.ValueChangeSessionId; + _lastGooseValueChangeRevision = 0; + } // Only revision gaps PROVE that the reader missed an event. // Total ring evictions are NOT necessarily losses: earlier entries may // already have been consumed by previous UI snapshots. From ccc8bec1e949486e996869b0dd1f4b98aa4410b0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 15:29:08 +0700 Subject: [PATCH 24/59] perf(goose): allocation-free per-frame DataSet value comparison with fail-closed layout changes --- .../Analysis/RawProcessBusAnalyzer.cs | 45 ++++++++++--------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs b/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs index 6b83eeae..96993e43 100644 --- a/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs +++ b/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs @@ -777,7 +777,8 @@ private static GooseMessageItem BuildGooseItem(GoosePacket packet) private static void AnnotateGooseChanges(GooseMessageItem current, GooseMessageItem? previous) { - if (current.DataValues.Count == 0) + var currentValues = current.DataValues; + if (currentValues.Count == 0) { current.ChangedSummaryText = "N/A"; return; @@ -785,39 +786,43 @@ private static void AnnotateGooseChanges(GooseMessageItem current, GooseMessageI if (previous is null || previous.DataValues.Count == 0) { - // The first observation has no comparative baseline. Calling every - // value "changed" fabricates a state transition not seen on the wire. current.ChangedSummaryText = "Initial DataSet baseline"; return; } - // A structural/confRev change has no trustworthy position-to-signal mapping. - // Fail closed: re-baseline rather than fabricate a changed value. + // Decoder indexes follow the wire order. A ConfRev, entry count, order + // or type change invalidates positional evidence: re-baseline safely. + // O(N), no dictionary/LINQ allocations in the frame hot path. + var previousValues = previous.DataValues; if (current.ConfRev != previous.ConfRev || - current.DataValues.Count != previous.DataValues.Count || - current.DataValues.Select(x => x.Index).Distinct().Count() != current.DataValues.Count || - previous.DataValues.Select(x => x.Index).Distinct().Count() != previous.DataValues.Count) + currentValues.Count != previousValues.Count) { current.ChangedSummaryText = "DataSet layout changed - new baseline"; return; } - var previousByIndex = previous.DataValues.ToDictionary(x => x.Index); - if (current.DataValues.Any(value => !previousByIndex.TryGetValue(value.Index, out var old) || - !string.Equals(value.Type, old.Type, StringComparison.Ordinal))) + for (var i = 0; i < currentValues.Count; i++) { - current.ChangedSummaryText = "DataSet entries changed - new baseline"; - return; + if (currentValues[i].Index != previousValues[i].Index || + !string.Equals(currentValues[i].Type, previousValues[i].Type, StringComparison.Ordinal)) + { + current.ChangedSummaryText = "DataSet entries changed - new baseline"; + return; + } } - foreach (var value in current.DataValues) - { - if (!previousByIndex.TryGetValue(value.Index, out var oldValue)) - continue; - value.PreviousValue = oldValue.Value; - value.IsChanged = !string.Equals(value.Value, oldValue.Value, StringComparison.Ordinal); + var changed = false; + for (var i = 0; i < currentValues.Count; i++) + { + var value = currentValues[i]; + var previousValue = previousValues[i]; + value.PreviousValue = previousValue.Value; + value.IsChanged = !string.Equals(value.Value, previousValue.Value, StringComparison.Ordinal); + changed |= value.IsChanged; } - current.ChangedSummaryText = BuildChangedSummary(current.DataValues); + current.ChangedSummaryText = changed + ? BuildChangedSummary(currentValues) + : "No dataset value change"; } private static string BuildChangedSummary(IReadOnlyList values) From d9895077a1b2688015f952f1f683736323aa4d76 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 15:30:12 +0700 Subject: [PATCH 25/59] ci(goose): execute exact-head Windows, stability, architecture, CodeQL, security and Inno candidate gates --- .github/workflows/candidate-package.yml | 4 +++- .github/workflows/ci.yml | 2 +- .github/workflows/codeql.yml | 2 +- .github/workflows/dependency-review.yml | 3 +++ .github/workflows/runtime-architecture.yml | 2 ++ .github/workflows/runtime-stability.yml | 2 ++ 6 files changed, 12 insertions(+), 3 deletions(-) diff --git a/.github/workflows/candidate-package.yml b/.github/workflows/candidate-package.yml index 4a391dde..a23ba851 100644 --- a/.github/workflows/candidate-package.yml +++ b/.github/workflows/candidate-package.yml @@ -1,6 +1,8 @@ name: Release candidate package on: + push: + branches: [fix/goose-multisource-filter-20261021] pull_request: branches: [main] paths: @@ -45,7 +47,7 @@ env: jobs: candidate: name: Build verified Windows x64 candidate - if: github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'stabilization/') || startsWith(github.head_ref, 'architecture/') || startsWith(github.head_ref, 'legal/') || startsWith(github.head_ref, 'fix/') || startsWith(github.head_ref, 'feat/') + if: github.ref_name == 'fix/goose-multisource-filter-20261021' || github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'stabilization/') || startsWith(github.head_ref, 'architecture/') || startsWith(github.head_ref, 'legal/') || startsWith(github.head_ref, 'fix/') || startsWith(github.head_ref, 'feat/') runs-on: windows-latest timeout-minutes: 50 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3cd98395..c2f32862 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [main] + branches: [main, fix/goose-multisource-filter-20261021] pull_request: branches: [main] workflow_dispatch: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index df4e5cc7..dead4de5 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -2,7 +2,7 @@ name: CodeQL on: push: - branches: [main] + branches: [main, fix/goose-multisource-filter-20261021] pull_request: branches: [main] schedule: diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index f170ce33..faec1f79 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -1,6 +1,8 @@ name: Dependency security review on: + push: + branches: [fix/goose-multisource-filter-20261021] pull_request: branches: [main] @@ -58,6 +60,7 @@ jobs: exit 1 github-dependency-review: + if: github.event_name == 'pull_request' name: GitHub dependency delta review runs-on: ubuntu-latest timeout-minutes: 6 diff --git a/.github/workflows/runtime-architecture.yml b/.github/workflows/runtime-architecture.yml index a4135197..07bb0ace 100644 --- a/.github/workflows/runtime-architecture.yml +++ b/.github/workflows/runtime-architecture.yml @@ -1,6 +1,8 @@ name: Runtime architecture on: + push: + branches: [fix/goose-multisource-filter-20261021] pull_request: branches: [main] paths: diff --git a/.github/workflows/runtime-stability.yml b/.github/workflows/runtime-stability.yml index 97cbcdb9..1dbeb2ee 100644 --- a/.github/workflows/runtime-stability.yml +++ b/.github/workflows/runtime-stability.yml @@ -1,6 +1,8 @@ name: Runtime stability on: + push: + branches: [fix/goose-multisource-filter-20261021] pull_request: branches: [main] paths: From 44a30c8cf51229f05c24fdd50860c6603807fa85 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 15:32:07 +0700 Subject: [PATCH 26/59] test(goose): align source viewport geometry assertion after removing obsolete toggle row --- scripts/verify-workspace-spacing.ps1 | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/verify-workspace-spacing.ps1 b/scripts/verify-workspace-spacing.ps1 index d610805a..7e3ca14a 100644 --- a/scripts/verify-workspace-spacing.ps1 +++ b/scripts/verify-workspace-spacing.ps1 @@ -163,7 +163,9 @@ Assert-True ($gooseLeft.Contains('Command="{Binding GooseClearSearchCommand}"')) Assert-True ($gooseLeft.Contains('GooseHasSearchText')) 'Clear control visibility responds to actual input' Assert-True ($gooseLeft.Contains('ScrollViewer.VerticalScrollBarVisibility="Auto"')) 'Source list has an independent scrollbar' Assert-True ($gooseLeft.Contains('VirtualizingPanel.VirtualizationMode="Recycling"')) 'Source rows remain virtualized' -Assert-True ($gooseLeft.Contains('')) 'Scrollable source viewport is height constrained' +# The obsolete Sources repeat toggle was removed. The bounded source list now +# occupies row 2 rather than row 3; the star-sized viewport is unchanged. +Assert-True ($gooseLeft.Contains('')) 'Scrollable source viewport is height constrained' Assert-True (-not $gooseLeft.Contains('Text="{Binding GooseFilterSummaryText}"')) 'Redundant source footer removed' $sectionNodes = $window.SelectNodes("//w:Expander[@Style='{StaticResource GooseInspectorSectionExpanderStyle}']", $ns) From 0f3bd7cba3fcbde013d35214ac5469e7f94f6098 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 15:35:30 +0700 Subject: [PATCH 27/59] fix(goose): bound source change revision to decoder capture session --- .../Presentation/GoosePublisherCatalog.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/ProcessBus.Core/Presentation/GoosePublisherCatalog.cs b/src/ProcessBus.Core/Presentation/GoosePublisherCatalog.cs index de480958..74268404 100644 --- a/src/ProcessBus.Core/Presentation/GoosePublisherCatalog.cs +++ b/src/ProcessBus.Core/Presentation/GoosePublisherCatalog.cs @@ -44,14 +44,22 @@ public GoosePublisherRow(GoosePublisherKey key, GooseMessageItem current) public bool HasRecentValueChange { get; private set; } public string LastValueChangeText => LastValueChangeUtc?.ToLocalTime().ToString("HH:mm:ss.fff") ?? "-"; - public void MarkDecodedValueChange(DateTime timeUtc, long revision) + private Guid _lastChangeSessionId; + + public void MarkDecodedValueChange(DateTime timeUtc, long revision, Guid sessionId = default) { + if (sessionId != Guid.Empty && sessionId != _lastChangeSessionId) + { + _lastChangeSessionId = sessionId; + ChangeRevision = 0; // Decoder restart; preserve row identity, reset revision scope. + } if (revision <= ChangeRevision) return; ChangeRevision = revision; LastValueChangeUtc = timeUtc; + var wasRecent = HasRecentValueChange; HasRecentValueChange = true; OnPropertyChanged(nameof(LastValueChangeText)); - OnPropertyChanged(nameof(HasRecentValueChange)); + if (!wasRecent) OnPropertyChanged(nameof(HasRecentValueChange)); } public void ExpireValueChangeNotice(DateTime nowUtc) From 722443274b97c56cf332860ca2377301b6c58978 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 15:35:46 +0700 Subject: [PATCH 28/59] perf(goose): coalesce decoded change notices by physical publisher once per UI batch --- src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs index 8382c3ae..db9b5499 100644 --- a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs +++ b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs @@ -4738,12 +4738,19 @@ private void ApplyDecodedGooseValueChangeEvents(GooseMonitorSnapshot snapshot, D OnPropertyChanged(nameof(GooseHasValueChangeLoss)); } } + // Hundreds of decoded transitions can share one 500 ms UI batch. + // Coalesce by stable publisher key: exactly one property-update group + // per source per batch, never one WPF notification per RX packet. + var latestByPublisher = new Dictionary(); foreach (var change in snapshot.ValueChanges) { if (change.Revision <= _lastGooseValueChangeRevision) continue; - _gooseCatalog.Find(change.PublisherKey)?.MarkDecodedValueChange(change.TimeUtc, change.Revision); + latestByPublisher[change.PublisherKey] = change; _lastGooseValueChangeRevision = change.Revision; } + foreach (var change in latestByPublisher.Values) + _gooseCatalog.Find(change.PublisherKey)?.MarkDecodedValueChange( + change.TimeUtc, change.Revision, snapshot.ValueChangeSessionId); foreach (var row in _gooseCatalog.Rows) if (row.HasRecentValueChange) row.ExpireValueChangeNotice(nowUtc); foreach (var row in _gooseHistory) From 74b6385504396a7fb9487c28445e04c99d20762b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 15:35:57 +0700 Subject: [PATCH 29/59] test(goose): row notices survive decoder-session revision restart --- tests/ProcessBus.Tests/GooseDecodedValueChangesTests.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/ProcessBus.Tests/GooseDecodedValueChangesTests.cs b/tests/ProcessBus.Tests/GooseDecodedValueChangesTests.cs index 3f52e407..e152f176 100644 --- a/tests/ProcessBus.Tests/GooseDecodedValueChangesTests.cs +++ b/tests/ProcessBus.Tests/GooseDecodedValueChangesTests.cs @@ -86,5 +86,14 @@ public void PublisherNoticeExpiresWithoutChangingSelectionOrIdentity() row.ExpireValueChangeNotice(now.AddMilliseconds(2600)); Assert.False(row.HasRecentValueChange); Assert.Equal(1, row.ChangeRevision); + + // Reconnected analyzer may start at revision 1 again; a stable UI row + // must not suppress that first verified transition. + var firstSession = Guid.NewGuid(); + var secondSession = Guid.NewGuid(); + row.MarkDecodedValueChange(now.AddSeconds(3), 42, firstSession); + row.MarkDecodedValueChange(now.AddSeconds(4), 1, secondSession); + Assert.Equal(1, row.ChangeRevision); + Assert.Equal(now.AddSeconds(4), row.LastValueChangeUtc); } } From bea07c47958be27fe218cb128862cd0512e78916 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 15:36:25 +0700 Subject: [PATCH 30/59] fix(goose): follow newest visible history on first GOOSE tab activation --- src/ProcessBus.App.Wpf/MainWindow.xaml | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ProcessBus.App.Wpf/MainWindow.xaml b/src/ProcessBus.App.Wpf/MainWindow.xaml index 6c0aeb92..89b746f9 100644 --- a/src/ProcessBus.App.Wpf/MainWindow.xaml +++ b/src/ProcessBus.App.Wpf/MainWindow.xaml @@ -3077,6 +3077,7 @@ Date: Wed, 21 Oct 2026 15:36:28 +0700 Subject: [PATCH 31/59] fix(goose): follow newest visible history on first GOOSE tab activation --- src/ProcessBus.App.Wpf/MainWindow.xaml.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/ProcessBus.App.Wpf/MainWindow.xaml.cs b/src/ProcessBus.App.Wpf/MainWindow.xaml.cs index 9368c006..073a1379 100644 --- a/src/ProcessBus.App.Wpf/MainWindow.xaml.cs +++ b/src/ProcessBus.App.Wpf/MainWindow.xaml.cs @@ -392,6 +392,11 @@ private void SuspendGooseFollowFromOperator() vm.SuspendGooseFollow(); } + private void GooseMessageGrid_IsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e) + { + if (e.NewValue is true) ScheduleGooseFollowScroll(force: true); + } + private void GooseMessageGrid_PreviewMouseWheel(object sender, MouseWheelEventArgs e) => SuspendGooseFollowFromOperator(); From 2c9232820429425b0693cdea54c747b0c5cc4531 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 17:43:32 +0700 Subject: [PATCH 32/59] feat(goose): latch decoded publisher change until explicit per-source acknowledgement --- .../Presentation/GoosePublisherCatalog.cs | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/ProcessBus.Core/Presentation/GoosePublisherCatalog.cs b/src/ProcessBus.Core/Presentation/GoosePublisherCatalog.cs index 74268404..4d4c900f 100644 --- a/src/ProcessBus.Core/Presentation/GoosePublisherCatalog.cs +++ b/src/ProcessBus.Core/Presentation/GoosePublisherCatalog.cs @@ -41,7 +41,10 @@ public GoosePublisherRow(GoosePublisherKey key, GooseMessageItem current) public bool IsStale { get; private set; } public DateTime? LastValueChangeUtc { get; private set; } public long ChangeRevision { get; private set; } - public bool HasRecentValueChange { get; private set; } + // Latched notification: decoder evidence is acknowledged only by an + // explicit operator action on THIS stable physical publisher key. + // Selection/filter/search, snapshot refresh and time never acknowledge it. + public bool HasUnacknowledgedValueChange { get; private set; } public string LastValueChangeText => LastValueChangeUtc?.ToLocalTime().ToString("HH:mm:ss.fff") ?? "-"; private Guid _lastChangeSessionId; @@ -51,23 +54,24 @@ public void MarkDecodedValueChange(DateTime timeUtc, long revision, Guid session if (sessionId != Guid.Empty && sessionId != _lastChangeSessionId) { _lastChangeSessionId = sessionId; - ChangeRevision = 0; // Decoder restart; preserve row identity, reset revision scope. + ChangeRevision = 0; // Decoder restart; retain the stable row and ACK state. } if (revision <= ChangeRevision) return; ChangeRevision = revision; LastValueChangeUtc = timeUtc; - var wasRecent = HasRecentValueChange; - HasRecentValueChange = true; + if (!HasUnacknowledgedValueChange) + { + HasUnacknowledgedValueChange = true; + OnPropertyChanged(nameof(HasUnacknowledgedValueChange)); + } OnPropertyChanged(nameof(LastValueChangeText)); - if (!wasRecent) OnPropertyChanged(nameof(HasRecentValueChange)); } - public void ExpireValueChangeNotice(DateTime nowUtc) + public void AcknowledgeValueChange() { - if (!HasRecentValueChange || !LastValueChangeUtc.HasValue || - (nowUtc - LastValueChangeUtc.Value).TotalMilliseconds < 2500) return; - HasRecentValueChange = false; - OnPropertyChanged(nameof(HasRecentValueChange)); + if (!HasUnacknowledgedValueChange) return; + HasUnacknowledgedValueChange = false; + OnPropertyChanged(nameof(HasUnacknowledgedValueChange)); } public string GoId => Current.GoId; public string GoCbRef => Current.GoCbRef; From dad963246567b8fab0872e453aa4ebaf831c717d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 17:44:04 +0700 Subject: [PATCH 33/59] feat(goose): explicit follow switch state and latched per-message ACK without timers --- .../ViewModels/MainWindowViewModel.cs | 49 +++++++++++-------- 1 file changed, 28 insertions(+), 21 deletions(-) diff --git a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs index db9b5499..6a98366a 100644 --- a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs +++ b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs @@ -264,7 +264,18 @@ public string GoosePublisherFilterText public string GooseValueChangeLossText => _gooseValueChangeDroppedSinceLastRead == 0 ? "" : $"{_gooseValueChangeDroppedSinceLastRead:N0} decoded changes dropped from buffer"; public bool GooseHasValueChangeLoss => _gooseValueChangeDroppedSinceLastRead > 0; - public bool GooseFollowLive => _gooseFollowLive; + // Two-way switch binding. Switching ON explicitly resumes at newest visible + // row; switching OFF freezes the viewport without altering Inspector/selection. + public bool GooseFollowLive + { + get => _gooseFollowLive; + set + { + if (value == _gooseFollowLive) return; + if (value) ResumeGooseFollow(); + else SuspendGooseFollow(); + } + } public long GooseVisibleAppendRevision => _gooseVisibleAppendRevision; public int GooseUnseenVisibleEventCount => _gooseUnseenVisibleEventCount; public string GooseFollowButtonText => _gooseUnseenVisibleEventCount == 0 @@ -326,6 +337,9 @@ public void SetGooseSourceSelection(IEnumerable keys, GoosePu public void ClearGooseSourceSelection() => SetGooseSourceSelection(Array.Empty()); + public void AcknowledgeGooseSource(GoosePublisherKey publisherKey) => + _gooseCatalog.Find(publisherKey)?.AcknowledgeValueChange(); + // Operator accordion state is SSOT in the session ViewModel, independent // of capture snapshots and TabControl visual tree recreation. @@ -921,7 +935,6 @@ public MainWindowViewModel() GooseOpenConfigurationCommand = new RelayCommand(() => CurrentWorkspaceTabIndex = 5); GooseClearSearchCommand = new RelayCommand(() => GoosePublisherFilterText = string.Empty); GooseClearSourceSelectionCommand = new RelayCommand(ClearGooseSourceSelection); - GooseResumeFollowCommand = new RelayCommand(ResumeGooseFollow); _timer = new DispatcherTimer { @@ -1345,7 +1358,6 @@ private void ApplySelectedStreamScale(string? streamId) public ICommand GooseOpenConfigurationCommand { get; } public ICommand GooseClearSearchCommand { get; } public ICommand GooseClearSourceSelectionCommand { get; } - public ICommand GooseResumeFollowCommand { get; } public ICommand CopyEvidenceCommand { get; } public ICommand LoadSclCommand { get; } public ICommand InspectPcapCommand { get; } @@ -4751,10 +4763,6 @@ private void ApplyDecodedGooseValueChangeEvents(GooseMonitorSnapshot snapshot, D foreach (var change in latestByPublisher.Values) _gooseCatalog.Find(change.PublisherKey)?.MarkDecodedValueChange( change.TimeUtc, change.Revision, snapshot.ValueChangeSessionId); - foreach (var row in _gooseCatalog.Rows) - if (row.HasRecentValueChange) row.ExpireValueChangeNotice(nowUtc); - foreach (var row in _gooseHistory) - if (row.IsRecentMeaningfulEvent) row.ExpireEmphasis(nowUtc); } private bool FilterGoosePublisher(object item) => @@ -5001,7 +5009,7 @@ private void FlushBufferedGooseRows(bool flushAll = false) if (FilterGooseHistory(row)) { newVisible++; - row.MarkNewVisibleEvent(DateTime.UtcNow); + row.MarkNewVisibleEvent(); } _gooseHistory.Add(row); flushed++; @@ -5139,25 +5147,24 @@ private static Brush FrozenBrush(byte red, byte green, byte blue) public sealed class GooseTrafficRow : INotifyPropertyChanged { - private DateTime? _emphasisStartUtc; - private bool _isRecentMeaningfulEvent; - public bool IsRecentMeaningfulEvent => _isRecentMeaningfulEvent; + private bool _isUnacknowledgedEvent; + public bool IsUnacknowledgedEvent => _isUnacknowledgedEvent; public event PropertyChangedEventHandler? PropertyChanged; - public void MarkNewVisibleEvent(DateTime nowUtc) + // A historical event's ACK belongs to this immutable row, not to the + // publisher or current selection. Ordinary repeats do not create alerts. + public void MarkNewVisibleEvent() { - if (EventType == "Retrans") return; - _emphasisStartUtc = nowUtc; - _isRecentMeaningfulEvent = true; - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsRecentMeaningfulEvent))); + if (EventType == "Retrans" || _isUnacknowledgedEvent) return; + _isUnacknowledgedEvent = true; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsUnacknowledgedEvent))); } - public void ExpireEmphasis(DateTime nowUtc) + public void Acknowledge() { - if (!_isRecentMeaningfulEvent || !_emphasisStartUtc.HasValue || - (nowUtc - _emphasisStartUtc.Value).TotalMilliseconds < 1500) return; - _isRecentMeaningfulEvent = false; - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsRecentMeaningfulEvent))); + if (!_isUnacknowledgedEvent) return; + _isUnacknowledgedEvent = false; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsUnacknowledgedEvent))); } public required GooseMessageItem Source { get; init; } From eeb69f1eca1d4fc8380dcf818eec1a2b1f301882 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 17:44:55 +0700 Subject: [PATCH 34/59] feat(goose): explicit physical publisher and history row ACK from click or keyboard --- src/ProcessBus.App.Wpf/MainWindow.xaml.cs | 35 +++++++++++++++++++++-- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/src/ProcessBus.App.Wpf/MainWindow.xaml.cs b/src/ProcessBus.App.Wpf/MainWindow.xaml.cs index 073a1379..31b8fca8 100644 --- a/src/ProcessBus.App.Wpf/MainWindow.xaml.cs +++ b/src/ProcessBus.App.Wpf/MainWindow.xaml.cs @@ -297,6 +297,9 @@ private void GoosePublisherRow_PreviewMouseLeftButtonDown(object sender, MouseBu sender is not ListViewItem { DataContext: ProcessBus.Core.Presentation.GoosePublisherRow row }) return; + // This click ACKs exactly this physical publisher's decoded signal changes. + // Modifier/range selection never ACKs any other source in the set. + vm.AcknowledgeGooseSource(row.Key); var next = vm.SelectedGooseSourceKeys.ToHashSet(); var modifiers = Keyboard.Modifiers; var shift = modifiers.HasFlag(ModifierKeys.Shift); @@ -347,10 +350,12 @@ private void GooseSourceList_SelectionChanged(object sender, SelectionChangedEve private void GooseSourceList_PreviewKeyDown(object sender, KeyEventArgs e) { - if (e.Key != Key.Space || DataContext is not MainWindowViewModel vm || - GooseSourceList.ItemContainerGenerator.ContainerFromItem(GooseSourceList.SelectedItem) is not ListViewItem || + if (e.Key is not (Key.Space or Key.Enter) || DataContext is not MainWindowViewModel vm || GooseSourceList.SelectedItem is not ProcessBus.Core.Presentation.GoosePublisherRow row) return; + // Space toggles membership and ACKs; Enter ACKs the focused source only. + vm.AcknowledgeGooseSource(row.Key); + if (e.Key == Key.Enter) { e.Handled = true; return; } var next = vm.SelectedGooseSourceKeys.ToHashSet(); if (!next.Add(row.Key)) next.Remove(row.Key); vm.SetGooseSourceSelection(next); @@ -381,6 +386,21 @@ private void GooseTraffic_ClickInteraction(object sender, MouseButtonEventArgs e { SuspendGooseFollowFromOperator(); DeferGooseTrafficFlush(); + if (e.ChangedButton != MouseButton.Left) return; + + // ACK only the row that was actually clicked, not the selected item in + // the VM: that item may still reference an earlier pinned history event. + // Never ACK all rows, nor ACK on passive streaming/virtualized recycling. + for (var node = e.OriginalSource as DependencyObject; + node is not null && !ReferenceEquals(node, GooseMessageGrid); + node = VisualTreeHelper.GetParent(node)) + { + if (node is DataGridRow { DataContext: GooseTrafficRow history }) + { + history.Acknowledge(); + return; + } + } } private bool _gooseFollowScrollScheduled; @@ -406,8 +426,17 @@ private void GooseMessageGrid_PreviewTouchDown(object sender, TouchEventArgs e) private void GooseMessageGrid_PreviewKeyDown(object sender, KeyEventArgs e) { if (e.Key is Key.Up or Key.Down or Key.Home or Key.End or - Key.PageUp or Key.PageDown or Key.Space or Key.Left or Key.Right) + Key.PageUp or Key.PageDown or Key.Space or Key.Left or Key.Right or Key.Enter) SuspendGooseFollowFromOperator(); + + // Accessible keyboard acknowledgement of the explicitly focused event. + // Directional navigation suspends follow but does not ACK history. + if (e.Key is Key.Enter or Key.Space && + GooseMessageGrid.SelectedItem is GooseTrafficRow selected) + { + selected.Acknowledge(); + e.Handled = true; + } } private static T? FindGooseVisualChild(DependencyObject root) where T : DependencyObject From a2af9afdbefe1605d1b57688234253f5f0ce2d83 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 17:46:06 +0700 Subject: [PATCH 35/59] style(goose): paired compact vector switches and acknowledgement-driven source/message accents --- src/ProcessBus.App.Wpf/MainWindow.xaml | 95 +++++++++++++++++++++----- 1 file changed, 78 insertions(+), 17 deletions(-) diff --git a/src/ProcessBus.App.Wpf/MainWindow.xaml b/src/ProcessBus.App.Wpf/MainWindow.xaml index 89b746f9..0311c2a7 100644 --- a/src/ProcessBus.App.Wpf/MainWindow.xaml +++ b/src/ProcessBus.App.Wpf/MainWindow.xaml @@ -27,6 +27,53 @@ + + - + @@ -3103,6 +3155,15 @@ PreviewMouseWheel="GooseMessageGrid_PreviewMouseWheel" PreviewKeyDown="GooseMessageGrid_PreviewKeyDown" PreviewTouchDown="GooseMessageGrid_PreviewTouchDown"> + + + @@ -3114,7 +3175,7 @@ - - - - - + + + + + + + + @@ -3523,9 +3526,19 @@ - + + + @@ -3542,6 +3555,19 @@ + + + + + Date: Wed, 21 Oct 2026 18:35:39 +0700 Subject: [PATCH 48/59] test(goose): verify inherited chevrons and single-row, single-ACK tri-pane event workflow --- scripts/verify-workspace-spacing.ps1 | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/scripts/verify-workspace-spacing.ps1 b/scripts/verify-workspace-spacing.ps1 index be710516..fd2e6233 100644 --- a/scripts/verify-workspace-spacing.ps1 +++ b/scripts/verify-workspace-spacing.ps1 @@ -90,8 +90,10 @@ foreach ($name in @('WorkspacePanelInset', 'WorkspacePanelGapBelow', 'WorkspaceH # compact disclosure style; all other workspace layout tokens stay untouched. $gooseExpanderStyle = $theme.SelectNodes("//w:Style[@x:Key='GooseDatasetExpanderStyle']", $ns) Assert-True ($gooseExpanderStyle.Count -eq 1) 'GOOSE has one scoped engineer disclosure style' -$gooseExpanderUsage = $window.SelectNodes("//w:Expander[@Style='{StaticResource GooseDatasetExpanderStyle}']", $ns) -Assert-True ($gooseExpanderUsage.Count -eq 1) 'GOOSE typed rows use scoped compact chevrons' +# The event-aware DataSet row is based on the shared GOOSE-only disclosure style, +# preserving its exact compact chevron template while adding conditional value accents. +$gooseExpanderUsage = $window.SelectNodes("//w:Expander/w:Expander.Style/w:Style[@BasedOn='{StaticResource GooseDatasetExpanderStyle}']", $ns) +Assert-True ($gooseExpanderUsage.Count -eq 1) 'GOOSE typed rows retain scoped compact chevrons through BasedOn' foreach ($binding in @( 'GooseInspectorRows', 'GooseTimelineCountersText', @@ -192,6 +194,19 @@ Assert-True ($vmSource.Contains('public void Acknowledge()') -and $vmSource.Contains('public void AcknowledgeGooseSource(')) 'Operator-owned acknowledgement is available for history and sources' Assert-True (-not $vmSource.Contains('ExpireEmphasis(') -and -not $vmSource.Contains('ExpireValueChangeNotice(')) 'Streaming UI does not auto-expire unread engineering evidence' +$messageStart = $source.IndexOf('') +$messageEnd = $source.IndexOf('x:Name="GooseMessageGrid"', $messageStart) +Assert-True ($messageStart -ge 0 -and $messageEnd -gt $messageStart) 'Messages header markup present' +$messageHeader = $source.Substring($messageStart, $messageEnd - $messageStart) +Assert-True (-not $messageHeader.Contains('Clear filter')) 'Messages header has no ugly Clear filter button' +Assert-True (-not $messageHeader.Contains('Grid.Row="1"')) 'Messages header uses exactly one horizontal line' +Assert-True ($messageHeader.Contains('GooseTimelineCountersText')) 'Compact counters remain inline in Messages header' +Assert-True ($vmSource.Contains('InspectNextUnreadGooseSource(') -and + $vmSource.Contains('AcknowledgeGooseHistoryEvent(') -and + $vmSource.Contains('ProjectGooseUnreadIndicators(')) 'Sources and Messages share one unread-event journal' +Assert-True ($inspector.Contains('GooseInspectorChangedCountText') -and + $inspector.Contains('Binding IsChanged')) 'Inspector highlights exact decoded changed entries' +Write-Host 'GOOSE tri-pane event contract: PASS (one-line header, decoder evidence, source navigation, single ACK, inspector deltas)' Write-Host 'GOOSE acknowledgement UX contract: PASS (paired switches, per-source/event ACK, no timer expiry)' Write-Host 'GOOSE calm UX contract: PASS (Material icons, searchable scrolling sources, independent Inspector accordions)' Write-Host 'GOOSE readout contract: PASS (scoped chevron, RX/events, TEST, stable typed rows, live return)' From 62bd30a5ce4bd532046673042e12d5725d912a26 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 18:36:38 +0700 Subject: [PATCH 49/59] test(goose): align Data header contract with signal-change count in same right column --- scripts/verify-workspace-spacing.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/verify-workspace-spacing.ps1 b/scripts/verify-workspace-spacing.ps1 index fd2e6233..8c32594d 100644 --- a/scripts/verify-workspace-spacing.ps1 +++ b/scripts/verify-workspace-spacing.ps1 @@ -114,7 +114,7 @@ $scrollOffset = $inspector.IndexOf('')) 'Value and changed counts share a separate aligned column' Assert-True ($inspector.Contains('Text="{Binding TypeText}"')) 'Expanded signal kind uses friendly type description' Assert-True ($inspector.Contains('Text="{Binding ProtocolTypeText}"')) 'Exact original wire type stays available in expanded evidence' Assert-True ($inspector.Contains('Text="{Binding ConfigurationTypeText}"')) 'Matched configuration type remains available as technical evidence' From bc03f50d1cac490e403395c1c69c56182e6d76c7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 18:36:55 +0700 Subject: [PATCH 50/59] test(goose): decoder journal retains exact timestamp, frame and previous/current typed values --- .../GooseDecodedValueChangesTests.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/ProcessBus.Tests/GooseDecodedValueChangesTests.cs b/tests/ProcessBus.Tests/GooseDecodedValueChangesTests.cs index a384c4e3..950d8c84 100644 --- a/tests/ProcessBus.Tests/GooseDecodedValueChangesTests.cs +++ b/tests/ProcessBus.Tests/GooseDecodedValueChangesTests.cs @@ -27,6 +27,13 @@ public void BaselineStateOnlyAndRetransmissionAreNotDecodedValueChanges() Observe(analyzer, 5, 1, false); // actual DataSet value transition var first = Assert.Single(analyzer.GetGooseSnapshot().ValueChanges); Assert.Equal(1, first.Revision); + Assert.Equal((uint)5, first.Frame.StNum); + Assert.Equal((uint)1, first.Frame.SqNum); + Assert.Equal(first.TimeUtc, first.Frame.LastSeenUtc); + var exactDelta = Assert.Single(first.Frame.DataValues); + Assert.True(exactDelta.IsChanged); + Assert.Equal("True", exactDelta.PreviousValue, ignoreCase: true); + Assert.Equal("False", exactDelta.Value, ignoreCase: true); var packet = Assert.Single(analyzer.GetGooseSnapshot().Messages); Assert.Equal(GoosePublisherKey.From(packet), first.PublisherKey); @@ -36,6 +43,12 @@ public void BaselineStateOnlyAndRetransmissionAreNotDecodedValueChanges() var changes = analyzer.GetGooseSnapshot().ValueChanges; Assert.Equal(2, changes.Count); Assert.Equal(2, changes[1].Revision); + Assert.Equal((uint)5, changes[0].Frame.StNum); + Assert.Equal((uint)6, changes[1].Frame.StNum); + Assert.Equal(changes[0].Frame.DataValues[0].Value, changes[1].Frame.DataValues[0].PreviousValue, + ignoreCase: true); + // The old event's exact typed evidence must survive later frames. + Assert.Equal("False", changes[0].Frame.DataValues[0].Value, ignoreCase: true); } [Fact] From 1c7ec6b2921dd635ffcda13a180d5cdb06c9df53 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 18:38:32 +0700 Subject: [PATCH 51/59] test(goose): enforce event-owned ACK instead of obsolete independent source ACK --- scripts/verify-workspace-spacing.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/verify-workspace-spacing.ps1 b/scripts/verify-workspace-spacing.ps1 index 8c32594d..b9edbdc1 100644 --- a/scripts/verify-workspace-spacing.ps1 +++ b/scripts/verify-workspace-spacing.ps1 @@ -191,7 +191,7 @@ Assert-True ($source.Contains('IsChecked="{Binding GooseFollowLive, Mode=TwoWay} Assert-True (-not $source.Contains('Command="{Binding GooseResumeFollowCommand}"')) 'Old transient Follow live button is removed' Assert-True ($source.Contains('IsUnacknowledgedEvent') -and $source.Contains('HasUnacknowledgedValueChange')) 'Message and source alert visuals bind to acknowledged state' Assert-True ($vmSource.Contains('public void Acknowledge()') -and - $vmSource.Contains('public void AcknowledgeGooseSource(')) 'Operator-owned acknowledgement is available for history and sources' + $vmSource.Contains('public void AcknowledgeGooseHistoryEvent(')) 'ACK belongs to the history event, not an independent source flag' Assert-True (-not $vmSource.Contains('ExpireEmphasis(') -and -not $vmSource.Contains('ExpireValueChangeNotice(')) 'Streaming UI does not auto-expire unread engineering evidence' $messageStart = $source.IndexOf('') From 751808176f356fb6b3f8437209a2911d123f21bc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 18:39:02 +0700 Subject: [PATCH 52/59] reliability(goose): retain unread decoded events ahead of repeats and disclose unavoidable bounded evictions --- .../ViewModels/MainWindowViewModel.cs | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs index ccd1e0a2..04040fd9 100644 --- a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs +++ b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs @@ -49,6 +49,7 @@ public sealed class MainWindowViewModel : INotifyPropertyChanged, IDisposable private Guid _lastGooseValueChangeSessionId; private long _lastGooseValueChangeRevision; private long _gooseValueChangeDroppedSinceLastRead; + private long _gooseUnreadEvictedCount; private GooseTrafficRow? _selectedGooseTrafficRow; private bool _gooseFollowLive = true; private long _gooseVisibleAppendRevision; @@ -261,9 +262,15 @@ public string GoosePublisherFilterText } public bool GooseHasSearchText => !string.IsNullOrWhiteSpace(GoosePublisherFilterText); - public string GooseValueChangeLossText => _gooseValueChangeDroppedSinceLastRead == 0 - ? "" : $"{_gooseValueChangeDroppedSinceLastRead:N0} decoded changes dropped from buffer"; - public bool GooseHasValueChangeLoss => _gooseValueChangeDroppedSinceLastRead > 0; + public string GooseValueChangeLossText => string.Join(" · ", new[] + { + _gooseValueChangeDroppedSinceLastRead > 0 + ? $"{_gooseValueChangeDroppedSinceLastRead:N0} decoded changes missed before UI read" : "", + _gooseUnreadEvictedCount > 0 + ? $"{_gooseUnreadEvictedCount:N0} unread events evicted at the bounded history limit" : "" + }.Where(text => text.Length > 0)); + public bool GooseHasValueChangeLoss => + _gooseValueChangeDroppedSinceLastRead > 0 || _gooseUnreadEvictedCount > 0; // Two-way switch binding. Switching ON explicitly resumes at newest visible // row; switching OFF freezes the viewport without altering Inspector/selection. public bool GooseFollowLive @@ -4645,6 +4652,7 @@ private async Task ClearAsync() _lastGooseValueChangeRevision = 0; _lastGooseValueChangeSessionId = Guid.Empty; _gooseValueChangeDroppedSinceLastRead = 0; + _gooseUnreadEvictedCount = 0; OnPropertyChanged(nameof(GooseValueChangeLossText)); OnPropertyChanged(nameof(GooseHasValueChangeLoss)); ClearGooseSourceSelection(); @@ -5078,7 +5086,21 @@ private void FlushBufferedGooseRows(bool flushAll = false) { removeCount = _gooseHistory.Count - GooseHistoryLimit; for (var i = 0; i < removeCount; i++) - _gooseHistory.RemoveAt(0); + { + // Retain unread engineering evidence ahead of retransmissions + // and acknowledged history, while preserving a hard memory cap. + var removeAt = 0; + while (removeAt < _gooseHistory.Count && _gooseHistory[removeAt].IsUnacknowledgedEvent) + removeAt++; + if (removeAt == _gooseHistory.Count) + { + removeAt = 0; // Bounded overflow: never retain indefinitely. + _gooseUnreadEvictedCount++; + OnPropertyChanged(nameof(GooseValueChangeLossText)); + OnPropertyChanged(nameof(GooseHasValueChangeLoss)); + } + _gooseHistory.RemoveAt(removeAt); + } } // The view observes incremental changes. Never Refresh() on each event. From 1bc4fa9954c633bf0f4de239494358544e1bf088 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 18:39:47 +0700 Subject: [PATCH 53/59] fix(goose): source-driven historical focus preserves correct publisher for Return to Live --- src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs index 04040fd9..f6d68c69 100644 --- a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs +++ b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs @@ -4823,6 +4823,13 @@ public void AcknowledgeGooseHistoryEvent(GooseTrafficRow entry) if (target is null) return null; SuspendGooseFollow(); + // Preserve source context for Show Latest / return-to-live while + // inspecting the exact historical decoded event from that publisher. + if (_selectedGoosePublisherKey != key) + { + _selectedGoosePublisherKey = key; + OnPropertyChanged(nameof(SelectedGoosePublisherRow)); + } SelectedGooseTrafficRow = target; // Inspector pins the SAME frame. return target; // View scrolls exactly this row. } From 6ea7863e908050e904d3414255a29972c43851eb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 19:14:05 +0700 Subject: [PATCH 54/59] fix(goose): derive Inspector value accent from unread selected event; clear immediately on SSOT ACK and keep tooltip evidence --- .../ViewModels/MainWindowViewModel.cs | 55 +++++++++++++++---- 1 file changed, 45 insertions(+), 10 deletions(-) diff --git a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs index f6d68c69..04a8d8aa 100644 --- a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs +++ b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs @@ -1087,8 +1087,15 @@ private void ReturnGooseToLive() public string GooseInspectorDatasetCountText => (GooseInspectorMessage?.DataValues.Count ?? 0).ToString(); - public int GooseInspectorChangedCount => - GooseInspectorMessage?.DataValues.Count(value => value.IsChanged) ?? 0; + // Acknowledgement controls only the visual alert, never the saved decoder evidence. + // "Changed" belongs to the selected exact history event while unread, not + // to a latest-only live snapshot or previously acknowledged history. + private bool GooseInspectorAlertActive => + _gooseInspectHistoricalEvent && _selectedGooseTrafficRow?.IsUnacknowledgedEvent == true; + + public int GooseInspectorChangedCount => GooseInspectorAlertActive + ? GooseInspectorMessage?.DataValues.Count(value => value.IsChanged) ?? 0 + : 0; public bool GooseInspectorHasChangedValues => GooseInspectorChangedCount > 0; public string GooseInspectorChangedCountText => $"{GooseInspectorChangedCount} changed"; @@ -1164,12 +1171,13 @@ private void ReconcileGooseInspectorRows() _gooseInspectorRows.Clear(); } var latest = BuildGooseDatasetValues(GooseInspectorMessage); + var highlightUnread = GooseInspectorAlertActive; for (var index = 0; index < latest.Count; index++) { var item = latest[index]; if (index < _gooseInspectorRows.Count && _gooseInspectorRows[index].Index == item.Index) { - _gooseInspectorRows[index].Update(item); + _gooseInspectorRows[index].Update(item, highlightUnread); continue; } var existing = -1; @@ -1182,10 +1190,10 @@ private void ReconcileGooseInspectorRows() if (existing >= 0) { _gooseInspectorRows.Move(existing, index); - _gooseInspectorRows[index].Update(item); + _gooseInspectorRows[index].Update(item, highlightUnread); } else - _gooseInspectorRows.Insert(index, new GooseDatasetDisplayRow(item)); + _gooseInspectorRows.Insert(index, new GooseDatasetDisplayRow(item, highlightUnread)); } while (_gooseInspectorRows.Count > latest.Count) @@ -4811,6 +4819,18 @@ public void AcknowledgeGooseHistoryEvent(GooseTrafficRow entry) if (!entry.IsUnacknowledgedEvent) return; entry.Acknowledge(); ProjectGooseUnreadIndicators(); + // The Inspector uses the very same journal-row ACK. Refresh only the + // currently pinned context, without modifying its historical DataSet. + if (_gooseInspectHistoricalEvent && ReferenceEquals(_selectedGooseTrafficRow, entry)) + RaiseGooseInspectorProperties(); + } + + public bool AcknowledgeInspectedGooseEvent() + { + if (!_gooseInspectHistoricalEvent || _selectedGooseTrafficRow is not { IsUnacknowledgedEvent: true } entry) + return false; + AcknowledgeGooseHistoryEvent(entry); + return true; } public GooseTrafficRow? InspectNextUnreadGooseSource(GoosePublisherKey key) @@ -5149,10 +5169,21 @@ private void TryFlushBufferedGooseRows() public sealed class GooseDatasetDisplayRow : INotifyPropertyChanged { private GooseDatasetValueDisplayItem _current; + private bool _highlightUnread; - public GooseDatasetDisplayRow(GooseDatasetValueDisplayItem item) => _current = item; + public GooseDatasetDisplayRow(GooseDatasetValueDisplayItem item, bool highlightUnread = false) + { + _current = item; + _highlightUnread = highlightUnread; + } public int Index => _current.Index; public bool IsChanged => _current.IsChanged; + public bool IsChangeHighlighted => _current.IsChanged && _highlightUnread; + // Raw before/after evidence remains inspectable after ACK, with no extra + // visual line. For normal entries this is simply the name of the signal. + public string ChangeTooltipText => _current.IsChanged + ? $"Changed: {_current.ChangeText}" + : _current.NameText; public string IndexText => _current.IndexText; public string NameText => _current.NameText; public string TypeText => _current.TypeText; @@ -5169,14 +5200,17 @@ public sealed class GooseDatasetDisplayRow : INotifyPropertyChanged public Visibility ChangeVisibility => _current.ChangeVisibility; public string QualityDetailsText => _current.QualityDetailsText; public Visibility QualityDetailsVisibility => _current.QualityDetailsVisibility; - public Brush ValueBrush => _current.ValueBrush; + public Brush ValueBrush => IsChangeHighlighted + ? _current.ValueBrush + : GooseDatasetValueDisplayItem.NormalValueBrush; public event PropertyChangedEventHandler? PropertyChanged; - public void Update(GooseDatasetValueDisplayItem next) + public void Update(GooseDatasetValueDisplayItem next, bool highlightUnread = false) { var previous = _current; - if (previous.Index == next.Index && + if (_highlightUnread == highlightUnread && + previous.Index == next.Index && previous.NameText == next.NameText && previous.TypeText == next.TypeText && previous.ProtocolTypeText == next.ProtocolTypeText && @@ -5191,6 +5225,7 @@ public void Update(GooseDatasetValueDisplayItem next) return; _current = next; + _highlightUnread = highlightUnread; // Empty property name means all bound fields of this one row changed. // WPF preserves the selected/expanded item container object. PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(string.Empty)); @@ -5199,7 +5234,7 @@ public void Update(GooseDatasetValueDisplayItem next) public sealed class GooseDatasetValueDisplayItem { - private static readonly Brush NormalValueBrush = FrozenBrush(228, 241, 253); + internal static readonly Brush NormalValueBrush = FrozenBrush(228, 241, 253); private static readonly Brush ChangedValueBrush = FrozenBrush(255, 190, 82); public int Index { get; init; } From 80f192d7b58687c85c09c1dfbf38fb2ab5ae3a2d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 19:14:35 +0700 Subject: [PATCH 55/59] style(goose): keep Data rows single-line and natural; put before/after in tooltip and only accent unread values --- src/ProcessBus.App.Wpf/MainWindow.xaml | 23 +++-------------------- 1 file changed, 3 insertions(+), 20 deletions(-) diff --git a/src/ProcessBus.App.Wpf/MainWindow.xaml b/src/ProcessBus.App.Wpf/MainWindow.xaml index e4effbfb..2cca24c5 100644 --- a/src/ProcessBus.App.Wpf/MainWindow.xaml +++ b/src/ProcessBus.App.Wpf/MainWindow.xaml @@ -3527,11 +3527,11 @@ + Margin="0,0,0,2" ToolTip="{Binding ChangeTooltipText}"> - - - + ToolTip="{Binding ChangeTooltipText}"/> From d8269bf52c2da7ebcb75a54d3427fb37f6acc091 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 19:15:36 +0700 Subject: [PATCH 56/59] feat(goose): single workspace click or Enter ACKs currently inspected event, no accidental ACK of next row --- src/ProcessBus.App.Wpf/MainWindow.xaml.cs | 44 ++++++++++++++++++----- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/src/ProcessBus.App.Wpf/MainWindow.xaml.cs b/src/ProcessBus.App.Wpf/MainWindow.xaml.cs index a8c54da9..e768355e 100644 --- a/src/ProcessBus.App.Wpf/MainWindow.xaml.cs +++ b/src/ProcessBus.App.Wpf/MainWindow.xaml.cs @@ -20,7 +20,11 @@ public partial class MainWindow : Window public MainWindow() { InitializeComponent(); - if (DataContext is MainWindowViewModel viewModel) { } + // One GOOSE read-ack gesture for the whole visible workspace. + // Tunnelling preview runs before selection changes, so a click on a + // different row acknowledges the previously inspected event only. + PreviewMouseDown += GooseWindow_PreviewMouseDown; + PreviewKeyDown += GooseWindow_PreviewKeyDown; Loaded += (_, _) => { @@ -405,6 +409,25 @@ private void GooseTraffic_Interaction(object sender, MouseEventArgs e) DeferGooseTrafficFlush(); } + private void GooseWindow_PreviewMouseDown(object sender, MouseButtonEventArgs e) + { + if (e.ChangedButton != MouseButton.Left || !GooseMessageGrid.IsVisible) + return; + if (DataContext is MainWindowViewModel vm) + vm.AcknowledgeInspectedGooseEvent(); + // Do not mark Handled: the original button, source, table row and + // DataSet expander still receive and perform their normal click. + } + + private void GooseWindow_PreviewKeyDown(object sender, KeyEventArgs e) + { + if (e.Key != Key.Enter || e.IsRepeat || !GooseMessageGrid.IsVisible) + return; + if (DataContext is MainWindowViewModel vm) + vm.AcknowledgeInspectedGooseEvent(); + // Enter remains available to the focused control for normal operation. + } + private void GooseTraffic_ClickInteraction(object sender, MouseButtonEventArgs e) { SuspendGooseFollowFromOperator(); @@ -420,7 +443,11 @@ private void GooseTraffic_ClickInteraction(object sender, MouseButtonEventArgs e { if (node is DataGridRow { DataContext: GooseTrafficRow history }) { - if (DataContext is MainWindowViewModel vm) + // Root preview already ACKed the event that was being read. + // A first click on a DIFFERENT row is navigation, not an + // accidental acknowledgement of evidence not yet inspected. + if (DataContext is MainWindowViewModel vm && + ReferenceEquals(vm.SelectedGooseTrafficRow, history)) vm.AcknowledgeGooseHistoryEvent(history); return; } @@ -452,13 +479,14 @@ private void GooseMessageGrid_PreviewKeyDown(object sender, KeyEventArgs e) Key.PageUp or Key.PageDown or Key.Space or Key.Left or Key.Right or Key.Enter) SuspendGooseFollowFromOperator(); - // Accessible keyboard acknowledgement of the explicitly focused event. - // Directional navigation suspends follow but does not ACK history. - if (e.Key is Key.Enter or Key.Space && - GooseMessageGrid.SelectedItem is GooseTrafficRow selected) + // Enter is handled centrally by the window (including when focus is + // inside the Inspector). Space ACKs the selected pinned history row. + if (e.Key == Key.Space && + GooseMessageGrid.SelectedItem is GooseTrafficRow selected && + DataContext is MainWindowViewModel vm && + ReferenceEquals(vm.SelectedGooseTrafficRow, selected)) { - if (DataContext is MainWindowViewModel vm) - vm.AcknowledgeGooseHistoryEvent(selected); + vm.AcknowledgeGooseHistoryEvent(selected); e.Handled = true; } } From b25e66ea47270ae0881ec5a7c72d2b6cf3ff168e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 19:16:16 +0700 Subject: [PATCH 57/59] test(goose): guard one-line value, tooltip, event-gated accent and central click/Enter ACK semantics --- scripts/verify-workspace-spacing.ps1 | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/scripts/verify-workspace-spacing.ps1 b/scripts/verify-workspace-spacing.ps1 index b9edbdc1..72cabc6d 100644 --- a/scripts/verify-workspace-spacing.ps1 +++ b/scripts/verify-workspace-spacing.ps1 @@ -205,7 +205,17 @@ Assert-True ($vmSource.Contains('InspectNextUnreadGooseSource(') -and $vmSource.Contains('AcknowledgeGooseHistoryEvent(') -and $vmSource.Contains('ProjectGooseUnreadIndicators(')) 'Sources and Messages share one unread-event journal' Assert-True ($inspector.Contains('GooseInspectorChangedCountText') -and - $inspector.Contains('Binding IsChanged')) 'Inspector highlights exact decoded changed entries' + $inspector.Contains('Binding IsChangeHighlighted') -and + $inspector.Contains('ChangeTooltipText')) 'Inspector shows only unread decoded changes and keeps before/after in tooltips' +Assert-True (-not $inspector.Contains('Text="CHANGED"') -and + -not $inspector.Contains('Text="{Binding ChangeText}"')) 'GOOSE Data values remain one line, no stacked change labels' +Assert-True ($vmSource.Contains('public bool AcknowledgeInspectedGooseEvent()') -and + $vmSource.Contains('private bool GooseInspectorAlertActive =>') -and + $vmSource.Contains('ReferenceEquals(_selectedGooseTrafficRow, entry)')) 'One ACK invalidates selected Inspector visual state immediately' +$windowCode = Get-Content -LiteralPath (Join-Path $Root 'src/ProcessBus.App.Wpf/MainWindow.xaml.cs') -Raw +Assert-True ($windowCode.Contains('PreviewMouseDown += GooseWindow_PreviewMouseDown') -and + $windowCode.Contains('PreviewKeyDown += GooseWindow_PreviewKeyDown')) 'Workspace click/Enter acknowledge current inspected event without suppressing controls' +Assert-True ($windowCode.Contains('ReferenceEquals(vm.SelectedGooseTrafficRow, history)')) 'Clicking a different history row never pre-ACKs unread evidence' Write-Host 'GOOSE tri-pane event contract: PASS (one-line header, decoder evidence, source navigation, single ACK, inspector deltas)' Write-Host 'GOOSE acknowledgement UX contract: PASS (paired switches, per-source/event ACK, no timer expiry)' Write-Host 'GOOSE calm UX contract: PASS (Material icons, searchable scrolling sources, independent Inspector accordions)' From 75bcf5ce3134450294d1a112c4d957dc480da0ae Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 19:43:34 +0700 Subject: [PATCH 58/59] style(goose): replace noisy Changed label with minimal red unread dot --- src/ProcessBus.App.Wpf/MainWindow.xaml | 50 +++++++++++--------------- 1 file changed, 21 insertions(+), 29 deletions(-) diff --git a/src/ProcessBus.App.Wpf/MainWindow.xaml b/src/ProcessBus.App.Wpf/MainWindow.xaml index 2cca24c5..4b955fad 100644 --- a/src/ProcessBus.App.Wpf/MainWindow.xaml +++ b/src/ProcessBus.App.Wpf/MainWindow.xaml @@ -1496,38 +1496,30 @@ - - - - - - - - + + + + + + - - - - - - - - From 27715a97323829827c2d3f5f6356e08a66d47475 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 19:43:58 +0700 Subject: [PATCH 59/59] test(goose): lock minimal red-dot unread notification UX contract --- scripts/verify-workspace-spacing.ps1 | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/scripts/verify-workspace-spacing.ps1 b/scripts/verify-workspace-spacing.ps1 index 72cabc6d..0265d04b 100644 --- a/scripts/verify-workspace-spacing.ps1 +++ b/scripts/verify-workspace-spacing.ps1 @@ -217,6 +217,19 @@ Assert-True ($windowCode.Contains('PreviewMouseDown += GooseWindow_PreviewMouseD $windowCode.Contains('PreviewKeyDown += GooseWindow_PreviewKeyDown')) 'Workspace click/Enter acknowledge current inspected event without suppressing controls' Assert-True ($windowCode.Contains('ReferenceEquals(vm.SelectedGooseTrafficRow, history)')) 'Clicking a different history row never pre-ACKs unread evidence' Write-Host 'GOOSE tri-pane event contract: PASS (one-line header, decoder evidence, source navigation, single ACK, inspector deltas)' +# Source unread state must be an Android-style minimal dot, never on-card text +# or a second notification border. Tooltip retains the count without noise. +$gooseSourceStart = $source.IndexOf('x:Name="GooseSourceList"') +$gooseSourceEnd = $source.IndexOf('