From 952b10170972b2068514d2822fe7282d54cf0a3e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 16:43:53 +0700 Subject: [PATCH 1/9] perf(global): eliminate Dispatcher natural sort for fat Explorer and multi-IED Global Live views --- MainWindow.IecNaturalSort.cs | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/MainWindow.IecNaturalSort.cs b/MainWindow.IecNaturalSort.cs index 0d9f34089..6c925697c 100644 --- a/MainWindow.IecNaturalSort.cs +++ b/MainWindow.IecNaturalSort.cs @@ -53,15 +53,21 @@ private void AttachIecNaturalGridSort(DataGrid? grid, IComparer comparer) private static void ApplyIecNaturalSort(DataGrid grid, IComparer comparer) { if (grid.ItemsSource is null) return; - // WPF CustomSort always sorts on the Dispatcher. For a fat IEC 61850 - // model defer presentation sorting entirely: source order is retained, - // and protocol FCDA/report order is never changed or re-created. - // Small views keep natural command/telegram sorting unchanged. - if (grid.Name == "ExplorerLiveValuesGrid" && - grid.ItemsSource is ICollection large && large.Count > 2048) - return; + // WPF CustomSort performs O(N log N) work synchronously on the Dispatcher. + // Both process grids can hold SG7-size models (Global may contain 4 IEDs). + // Preserve stable source order for large lists, without changing IEC + // DataSet/FCDA order. Command Dock ALWAYS retains its verified ordering. if (CollectionViewSource.GetDefaultView(grid.ItemsSource) is not ListCollectionView view) return; + if (grid.Name is "ExplorerLiveValuesGrid" or "GlobalLiveGrid" && + grid.ItemsSource is ICollection { Count: > 2048 }) + { + // A live collection may grow past the threshold AFTER a previous + // small-grid CustomSort was installed. Remove only our own sorter. + if (ReferenceEquals(view.CustomSort, comparer)) + view.CustomSort = null; + return; + } if (ReferenceEquals(view.CustomSort, comparer)) return; view.SortDescriptions.Clear(); view.CustomSort = comparer; From 8f5941a0f5225a46da782693ec859740664baf58 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 16:44:42 +0700 Subject: [PATCH 2/9] perf(explorer): debounce only large-model search typing; immediate clear, IED source switch and Enter --- MainWindow.LiveSignalSearch.cs | 57 +++++++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 5 deletions(-) diff --git a/MainWindow.LiveSignalSearch.cs b/MainWindow.LiveSignalSearch.cs index ffbbeb424..362861508 100644 --- a/MainWindow.LiveSignalSearch.cs +++ b/MainWindow.LiveSignalSearch.cs @@ -6,6 +6,7 @@ using System.Windows.Data; using System.Windows.Input; using System.Windows.Media; +using System.Windows.Threading; using ArIED61850Tester.Models; using ArIED61850Tester.Services; @@ -30,6 +31,9 @@ public partial class MainWindow private string _unifiedExplorerSearchText = ""; private string[] _unifiedLiveSearchTokens = Array.Empty(); private bool _liveSignalCountRefreshScheduled; + // Debounce only large point-model text edits. Switching IED and clearing + // the query still applies immediately so the visible workspace is current. + private DispatcherTimer? _liveSignalFilterDebounce; private static bool RegisterLiveSignalSearchClassHandler() { @@ -208,7 +212,41 @@ private void AttachLiveSignalSearchSource() } private void LiveSignalSearch_TextChanged(object sender, TextChangedEventArgs e) - => ApplyLiveSignalSearch(); + => ScheduleUnifiedLiveSignalSearch(); + + private void ScheduleUnifiedLiveSignalSearch() + { + var explorerText = ExplorerLiveSearchBox?.Text?.Trim(); + var toolbarText = _liveSignalSearchBox?.Text?.Trim(); + // Empty search is especially important during reconnect/IED switching: + // drop the old filter immediately, not after a debounce period. + var cleared = string.IsNullOrEmpty(explorerText) && + string.IsNullOrEmpty(toolbarText); + if (cleared || + _liveSignalSearchGrid?.ItemsSource is not ICollection { Count: > 2048 }) + { + _liveSignalFilterDebounce?.Stop(); + ApplyExplorerLiveSearchFilter(); + return; + } + + if (_liveSignalFilterDebounce is null) + { + _liveSignalFilterDebounce = new DispatcherTimer( + DispatcherPriority.Background, Dispatcher) + { + Interval = TimeSpan.FromMilliseconds(220) + }; + _liveSignalFilterDebounce.Tick += (_, _) => + { + _liveSignalFilterDebounce.Stop(); + ApplyExplorerLiveSearchFilter(); + }; + } + // Latest text wins. Never queue one 33k-row WPF Refresh per keystroke. + _liveSignalFilterDebounce.Stop(); + _liveSignalFilterDebounce.Start(); + } private void LiveSignalSearch_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { @@ -224,6 +262,7 @@ private void LiveSignalSearch_CollectionChanged(object? sender, NotifyCollection private void ApplyLiveSignalSearch() { + _liveSignalFilterDebounce?.Stop(); _unifiedExplorerSearchText = (ExplorerLiveSearchBox?.Text ?? "").Trim(); var detailedQuery = (_liveSignalSearchBox?.Text ?? "").Trim(); _unifiedLiveSearchTokens = detailedQuery.Split( @@ -306,10 +345,17 @@ private void LiveSignalSearch_WindowPreviewKeyDown(object sender, KeyEventArgs e private void LiveSignalSearch_BoxPreviewKeyDown(object sender, KeyEventArgs e) { - if (e.Key != Key.Escape) - return; - ClearLiveSignalSearch(); - e.Handled = true; + if (e.Key == Key.Enter) + { + _liveSignalFilterDebounce?.Stop(); + ApplyLiveSignalSearch(); + e.Handled = true; + } + else if (e.Key == Key.Escape) + { + ClearLiveSignalSearch(); + e.Handled = true; + } } private void ClearLiveSignalSearch() @@ -322,6 +368,7 @@ private void ClearLiveSignalSearch() private void LiveSignalSearch_WindowClosed(object? sender, EventArgs e) { + _liveSignalFilterDebounce?.Stop(); PreviewKeyDown -= LiveSignalSearch_WindowPreviewKeyDown; Closed -= LiveSignalSearch_WindowClosed; if (_liveSignalSearchGrid != null) From d558d9239aec1dc02ea10348e5ff63b137e80d11 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 16:44:56 +0700 Subject: [PATCH 3/9] perf(explorer): route native search TextChanged through shared large-IED debounce --- MainWindow.LiveSearch.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MainWindow.LiveSearch.cs b/MainWindow.LiveSearch.cs index ef4c33cd1..2fbfe9263 100644 --- a/MainWindow.LiveSearch.cs +++ b/MainWindow.LiveSearch.cs @@ -9,7 +9,7 @@ namespace ArIED61850Tester; public partial class MainWindow { private void ExplorerLiveSearch_TextChanged(object sender, TextChangedEventArgs e) - => ApplyExplorerLiveSearchFilter(); + => ScheduleUnifiedLiveSignalSearch(); private void ExplorerLiveSearchClear_Click(object sender, RoutedEventArgs e) { From d17abaac937aea7ab31c477d61478f9d99af74fd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 16:46:27 +0700 Subject: [PATCH 4/9] perf(global): detach old small-list sort as GlobalPoints grows without ItemsSource replacement --- MainWindow.IecNaturalSort.cs | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/MainWindow.IecNaturalSort.cs b/MainWindow.IecNaturalSort.cs index 6c925697c..65d5a758f 100644 --- a/MainWindow.IecNaturalSort.cs +++ b/MainWindow.IecNaturalSort.cs @@ -2,6 +2,7 @@ using System.Collections; using System.ComponentModel; using System.Windows; +using System.Windows.Threading; using System.Windows.Controls; using System.Windows.Data; using ArIED61850Tester.Models; @@ -16,6 +17,7 @@ namespace ArIED61850Tester; public partial class MainWindow { private readonly HashSet _iecNaturallySortedGrids = new(); + private bool _globalNaturalSortThresholdQueued; private void ExplorerLiveGrid_Loaded(object sender, RoutedEventArgs e) { @@ -46,6 +48,24 @@ private void AttachIecNaturalGridSort(DataGrid? grid, IComparer comparer) ItemsControl.ItemsSourceProperty, typeof(DataGrid)); descriptor?.AddValueChanged(grid, (_, _) => grid.Dispatcher.BeginInvoke(new Action(() => ApplyIecNaturalSort(grid, comparer)))); + + if (ReferenceEquals(grid, GlobalLiveGrid)) + { + // The global ItemsSource instance is stable while device sets + // accumulate. ItemsSource-change alone never sees the 2,049th + // point; remove our old small-list sort at the growth boundary. + GlobalPoints.CollectionChanged += (_, _) => + { + if (GlobalPoints.Count <= 2048 || _globalNaturalSortThresholdQueued) + return; + _globalNaturalSortThresholdQueued = true; + grid.Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() => + { + _globalNaturalSortThresholdQueued = false; + ApplyIecNaturalSort(grid, comparer); + })); + }; + } } ApplyIecNaturalSort(grid, comparer); } @@ -59,7 +79,7 @@ private static void ApplyIecNaturalSort(DataGrid grid, IComparer comparer) // DataSet/FCDA order. Command Dock ALWAYS retains its verified ordering. if (CollectionViewSource.GetDefaultView(grid.ItemsSource) is not ListCollectionView view) return; - if (grid.Name is "ExplorerLiveValuesGrid" or "GlobalLiveGrid" && + if ((grid.Name is "ExplorerLiveValuesGrid" or "GlobalLiveGrid") && grid.ItemsSource is ICollection { Count: > 2048 }) { // A live collection may grow past the threshold AFTER a previous From 11fcbf82a691420a0dcd77d24896e9e1235412f0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 16:47:39 +0700 Subject: [PATCH 5/9] perf(global): trigger WPF sorting only at large/small mode transitions, not per ingress row --- MainWindow.IecNaturalSort.cs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/MainWindow.IecNaturalSort.cs b/MainWindow.IecNaturalSort.cs index 65d5a758f..ff042ca99 100644 --- a/MainWindow.IecNaturalSort.cs +++ b/MainWindow.IecNaturalSort.cs @@ -18,6 +18,7 @@ public partial class MainWindow { private readonly HashSet _iecNaturallySortedGrids = new(); private bool _globalNaturalSortThresholdQueued; + private bool _globalNaturalSortLargeMode; private void ExplorerLiveGrid_Loaded(object sender, RoutedEventArgs e) { @@ -51,17 +52,20 @@ private void AttachIecNaturalGridSort(DataGrid? grid, IComparer comparer) if (ReferenceEquals(grid, GlobalLiveGrid)) { - // The global ItemsSource instance is stable while device sets - // accumulate. ItemsSource-change alone never sees the 2,049th - // point; remove our old small-list sort at the growth boundary. + // Source instance stays stable as device sets grow/shrink. + // Re-evaluate only when crossing the 2048-point threshold; + // not for each of the next 32,900 incoming point additions. + _globalNaturalSortLargeMode = GlobalPoints.Count > 2048; GlobalPoints.CollectionChanged += (_, _) => { - if (GlobalPoints.Count <= 2048 || _globalNaturalSortThresholdQueued) + if ((GlobalPoints.Count > 2048) == _globalNaturalSortLargeMode || + _globalNaturalSortThresholdQueued) return; _globalNaturalSortThresholdQueued = true; grid.Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() => { _globalNaturalSortThresholdQueued = false; + _globalNaturalSortLargeMode = GlobalPoints.Count > 2048; ApplyIecNaturalSort(grid, comparer); })); }; From 9a397f502a8b288105530fb51b970d09733920c7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 16:48:36 +0700 Subject: [PATCH 6/9] test(perf): WPF large Global/Explorer sort safeguards and bounded unified search interaction --- .../SsotV2LargeGridSearchSortBudgetTests.cs | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 tests/ARSAS.Tests/SsotV2LargeGridSearchSortBudgetTests.cs diff --git a/tests/ARSAS.Tests/SsotV2LargeGridSearchSortBudgetTests.cs b/tests/ARSAS.Tests/SsotV2LargeGridSearchSortBudgetTests.cs new file mode 100644 index 000000000..a119a571c --- /dev/null +++ b/tests/ARSAS.Tests/SsotV2LargeGridSearchSortBudgetTests.cs @@ -0,0 +1,156 @@ +using System.Collections; +using System.Collections.ObjectModel; +using System.Reflection; +using System.Windows.Controls; +using System.Windows.Data; +using ArIED61850Tester; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +/// +/// Regression gates for SG7-size WPF search/sort scheduling. This is a +/// presentation test: no MMS acquisition, SOE or command operation is mocked. +/// +public sealed class SsotV2LargeGridSearchSortBudgetTests +{ + [Fact] + public void FatGlobalLiveGrid_DropsOnlyOurCustomSort_KeepingSourceOrderUnchanged() + { + RunInSta(() => + { + var points = new ObservableCollection( + Enumerable.Range(0, 2500).Select(n => new Iec61850MonitorPoint + { + DeviceId = "SG7", + IecReference = $"SG7LD/MMXU1.A{n}.stVal", + FunctionalConstraint = "ST" + })); + var expectedFirst = points[0]; + var grid = new DataGrid { Name = "GlobalLiveGrid", ItemsSource = points }; + var view = Assert.IsType( + CollectionViewSource.GetDefaultView(points)); + view.CustomSort = IecNaturalGlobalMonitorSort.Instance; + ApplyNaturalSort(grid, IecNaturalGlobalMonitorSort.Instance); + + Assert.Null(view.CustomSort); + Assert.Same(expectedFirst, points[0]); + Assert.Equal(2500, view.Count); + }); + } + + [Fact] + public void SmallGlobalGrid_KeepsNumericNaturalSort() + { + RunInSta(() => + { + var points = new ObservableCollection( + [ + new() { DeviceName = "GE", IecReference = "LD/CSWI10.Pos.stVal" }, + new() { DeviceName = "GE", IecReference = "LD/CSWI2.Pos.stVal" } + ]); + var grid = new DataGrid { Name = "GlobalLiveGrid", ItemsSource = points }; + var view = Assert.IsType( + CollectionViewSource.GetDefaultView(points)); + + ApplyNaturalSort(grid, IecNaturalGlobalMonitorSort.Instance); + + Assert.Same(IecNaturalGlobalMonitorSort.Instance, view.CustomSort); + Assert.Same(points[1], view.Cast().First()); + }); + } + + [Fact] + public void FatExplorerGrid_IsProtectedButCommandGridRetainsVerifiedOrder() + { + RunInSta(() => + { + var points = new ObservableCollection( + Enumerable.Range(0, 2100).Select(n => new Iec61850MonitorPoint + { + DeviceId = "SG7", + IecReference = $"SG7LD/MMXU1.Freq{n}.stVal", + FunctionalConstraint = "ST" + })); + var explorer = new DataGrid + { + Name = "ExplorerLiveValuesGrid", + ItemsSource = points + }; + ApplyNaturalSort(explorer, IecNaturalLiveMonitorSort.Instance); + var view = Assert.IsType( + CollectionViewSource.GetDefaultView(points)); + Assert.Null(view.CustomSort); + + var commands = new ObservableCollection( + [ + new() { ObjectReference = "SG7/GGIO1.DPCSO10" }, + new() { ObjectReference = "SG7/GGIO1.DPCSO2" } + ]); + var commandGrid = new DataGrid + { + Name = "EquipmentCommandGrid", ItemsSource = commands + }; + ApplyNaturalSort(commandGrid, IecNaturalCommandSort.Instance); + Assert.Same(IecNaturalCommandSort.Instance, + CollectionViewSource.GetDefaultView(commands) + is ListCollectionView commandView ? commandView.CustomSort : null); + }); + } + + [Fact] + public void ExplorerHeavySearch_IsDebouncedButSwitchClearAndEnterApplyImmediately() + { + var dynamicSource = Read("MainWindow.LiveSignalSearch.cs"); + var nativeSource = Read("MainWindow.LiveSearch.cs"); + var globalSort = Read("MainWindow.IecNaturalSort.cs"); + + Assert.Contains("=> ScheduleUnifiedLiveSignalSearch()", nativeSource); + Assert.Contains("=> ScheduleUnifiedLiveSignalSearch()", dynamicSource); + Assert.Contains("Count: > 2048", dynamicSource); + Assert.Contains("TimeSpan.FromMilliseconds(220)", dynamicSource); + Assert.Contains("_liveSignalFilterDebounce.Stop();", dynamicSource); + Assert.Contains("if (e.Key == Key.Enter)", dynamicSource); + Assert.Contains("AttachLiveSignalSearchSource()", dynamicSource); + Assert.Contains("ApplyLiveSignalSearch();", dynamicSource); + Assert.Contains("GlobalPoints.CollectionChanged +=", globalSort); + Assert.Contains("_globalNaturalSortLargeMode", globalSort); + Assert.Contains("view.CustomSort = null", globalSort); + Assert.Contains("grid.Name is \"ExplorerLiveValuesGrid\" or \"GlobalLiveGrid\"", globalSort); + Assert.Contains("IecNaturalCommandSort.Instance", globalSort); + } + + private static void ApplyNaturalSort(DataGrid grid, IComparer comparer) + { + var method = typeof(MainWindow).GetMethod("ApplyIecNaturalSort", + BindingFlags.NonPublic | BindingFlags.Static); + Assert.NotNull(method); + method!.Invoke(null, [grid, comparer]); + } + + private static void RunInSta(Action action) + { + Exception? error = null; + var thread = new Thread(() => + { + try { action(); } catch (Exception ex) { error = ex; } + }); + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + Assert.True(thread.Join(TimeSpan.FromSeconds(25)), "WPF sort test timed out"); + if (error is not null) + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(error).Throw(); + } + + private static string Read(string file) + { + for (DirectoryInfo? dir = new(AppContext.BaseDirectory); + dir is not null; dir = dir.Parent) + { + var path = Path.Combine(dir.FullName, file); + if (File.Exists(path)) return File.ReadAllText(path); + } + throw new FileNotFoundException(file); + } +} From dd2dc862bfb3a387fb7a00b5b6e324e2af716858 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 16:53:48 +0700 Subject: [PATCH 7/9] test(perf): assert pattern threshold and exclude WPF new-item placeholder in read-only Global grid --- tests/ARSAS.Tests/P0Sg7UiSchedulingRegressionTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/P0Sg7UiSchedulingRegressionTests.cs b/tests/ARSAS.Tests/P0Sg7UiSchedulingRegressionTests.cs index 6ae93981d..d722955e8 100644 --- a/tests/ARSAS.Tests/P0Sg7UiSchedulingRegressionTests.cs +++ b/tests/ARSAS.Tests/P0Sg7UiSchedulingRegressionTests.cs @@ -52,7 +52,7 @@ public void ExplorerSearch_HasOneFilterOwner_NoPerRowStringJoinOrDoubleRefresh() Assert.DoesNotContain("_liveSignalSearchView.Refresh()", dynamic); Assert.DoesNotContain("string.Join('\\n', new[]", dynamic); Assert.Contains("sourceCollection.Count <= 2048", dynamic); - Assert.Contains("large.Count > 2048", sorting); + Assert.Contains("Count: > 2048", sorting); Assert.Contains("grid.Name == \"ExplorerLiveValuesGrid\"", sorting); } From f51f44fbe78c35cf472a7d2ede96c0a5e196559e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 16:53:55 +0700 Subject: [PATCH 8/9] test(perf): assert pattern threshold and exclude WPF new-item placeholder in read-only Global grid --- tests/ARSAS.Tests/SsotV2LargeGridSearchSortBudgetTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/ARSAS.Tests/SsotV2LargeGridSearchSortBudgetTests.cs b/tests/ARSAS.Tests/SsotV2LargeGridSearchSortBudgetTests.cs index a119a571c..9268d55e3 100644 --- a/tests/ARSAS.Tests/SsotV2LargeGridSearchSortBudgetTests.cs +++ b/tests/ARSAS.Tests/SsotV2LargeGridSearchSortBudgetTests.cs @@ -28,7 +28,7 @@ public void FatGlobalLiveGrid_DropsOnlyOurCustomSort_KeepingSourceOrderUnchanged FunctionalConstraint = "ST" })); var expectedFirst = points[0]; - var grid = new DataGrid { Name = "GlobalLiveGrid", ItemsSource = points }; + var grid = new DataGrid { Name = "GlobalLiveGrid", ItemsSource = points, CanUserAddRows = false }; var view = Assert.IsType( CollectionViewSource.GetDefaultView(points)); view.CustomSort = IecNaturalGlobalMonitorSort.Instance; @@ -50,7 +50,7 @@ public void SmallGlobalGrid_KeepsNumericNaturalSort() new() { DeviceName = "GE", IecReference = "LD/CSWI10.Pos.stVal" }, new() { DeviceName = "GE", IecReference = "LD/CSWI2.Pos.stVal" } ]); - var grid = new DataGrid { Name = "GlobalLiveGrid", ItemsSource = points }; + var grid = new DataGrid { Name = "GlobalLiveGrid", ItemsSource = points, CanUserAddRows = false }; var view = Assert.IsType( CollectionViewSource.GetDefaultView(points)); From 00afef5be5a5a4a84c071eb1a82435b8b3c262df Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 17:01:30 +0700 Subject: [PATCH 9/9] test(perf): validate both Explorer and Global large-grid guards in P0 source contract --- tests/ARSAS.Tests/P0Sg7UiSchedulingRegressionTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/P0Sg7UiSchedulingRegressionTests.cs b/tests/ARSAS.Tests/P0Sg7UiSchedulingRegressionTests.cs index d722955e8..ebbc11033 100644 --- a/tests/ARSAS.Tests/P0Sg7UiSchedulingRegressionTests.cs +++ b/tests/ARSAS.Tests/P0Sg7UiSchedulingRegressionTests.cs @@ -53,7 +53,7 @@ public void ExplorerSearch_HasOneFilterOwner_NoPerRowStringJoinOrDoubleRefresh() Assert.DoesNotContain("string.Join('\\n', new[]", dynamic); Assert.Contains("sourceCollection.Count <= 2048", dynamic); Assert.Contains("Count: > 2048", sorting); - Assert.Contains("grid.Name == \"ExplorerLiveValuesGrid\"", sorting); + Assert.Contains("grid.Name is \"ExplorerLiveValuesGrid\" or \"GlobalLiveGrid\"", sorting); } [Fact]