Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 37 additions & 7 deletions MainWindow.IecNaturalSort.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -16,6 +17,8 @@ namespace ArIED61850Tester;
public partial class MainWindow
{
private readonly HashSet<DataGrid> _iecNaturallySortedGrids = new();
private bool _globalNaturalSortThresholdQueued;
private bool _globalNaturalSortLargeMode;

private void ExplorerLiveGrid_Loaded(object sender, RoutedEventArgs e)
{
Expand Down Expand Up @@ -46,22 +49,49 @@ 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))
{
// 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) == _globalNaturalSortLargeMode ||
_globalNaturalSortThresholdQueued)
return;
_globalNaturalSortThresholdQueued = true;
grid.Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() =>
{
_globalNaturalSortThresholdQueued = false;
_globalNaturalSortLargeMode = GlobalPoints.Count > 2048;
ApplyIecNaturalSort(grid, comparer);
}));
};
}
}
ApplyIecNaturalSort(grid, 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;
Expand Down
2 changes: 1 addition & 1 deletion MainWindow.LiveSearch.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down
57 changes: 52 additions & 5 deletions MainWindow.LiveSignalSearch.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -30,6 +31,9 @@ public partial class MainWindow
private string _unifiedExplorerSearchText = "";
private string[] _unifiedLiveSearchTokens = Array.Empty<string>();
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()
{
Expand Down Expand Up @@ -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)
{
Expand All @@ -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(
Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions tests/ARSAS.Tests/P0Sg7UiSchedulingRegressionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ 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("grid.Name == \"ExplorerLiveValuesGrid\"", sorting);
Assert.Contains("Count: > 2048", sorting);
Assert.Contains("grid.Name is \"ExplorerLiveValuesGrid\" or \"GlobalLiveGrid\"", sorting);
}

[Fact]
Expand Down
156 changes: 156 additions & 0 deletions tests/ARSAS.Tests/SsotV2LargeGridSearchSortBudgetTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Regression gates for SG7-size WPF search/sort scheduling. This is a
/// presentation test: no MMS acquisition, SOE or command operation is mocked.
/// </summary>
public sealed class SsotV2LargeGridSearchSortBudgetTests
{
[Fact]
public void FatGlobalLiveGrid_DropsOnlyOurCustomSort_KeepingSourceOrderUnchanged()
{
RunInSta(() =>
{
var points = new ObservableCollection<Iec61850MonitorPoint>(
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, CanUserAddRows = false };
var view = Assert.IsType<ListCollectionView>(
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<Iec61850MonitorPoint>(
[
new() { DeviceName = "GE", IecReference = "LD/CSWI10.Pos.stVal" },
new() { DeviceName = "GE", IecReference = "LD/CSWI2.Pos.stVal" }
]);
var grid = new DataGrid { Name = "GlobalLiveGrid", ItemsSource = points, CanUserAddRows = false };
var view = Assert.IsType<ListCollectionView>(
CollectionViewSource.GetDefaultView(points));

ApplyNaturalSort(grid, IecNaturalGlobalMonitorSort.Instance);

Assert.Same(IecNaturalGlobalMonitorSort.Instance, view.CustomSort);
Assert.Same(points[1], view.Cast<Iec61850MonitorPoint>().First());
});
}

[Fact]
public void FatExplorerGrid_IsProtectedButCommandGridRetainsVerifiedOrder()
{
RunInSta(() =>
{
var points = new ObservableCollection<Iec61850MonitorPoint>(
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<ListCollectionView>(
CollectionViewSource.GetDefaultView(points));
Assert.Null(view.CustomSort);

var commands = new ObservableCollection<SignalDefinition>(
[
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);
}
}
Loading