From fb7ae16f07a0715924a88a8bd33245e8765b1353 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 08:12:19 +0700 Subject: [PATCH] fix(command-dock): unify WPF visual priority with verified live model and pin tap-first viewport --- MainWindow.xaml | 2 +- MainWindow.xaml.cs | 18 +++- Services/IecNaturalReferenceOrder.cs | 15 ++-- .../MultiGridNaturalSortRegressionTests.cs | 85 +++++++++++++++++++ 4 files changed, 107 insertions(+), 13 deletions(-) diff --git a/MainWindow.xaml b/MainWindow.xaml index a6b1a12fa..7e18da2bc 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -697,7 +697,7 @@ - { - if (ReferenceEquals(SelectedDevice, device) && - device.CommandSignals.Contains(first)) - EquipmentCommandGrid.ScrollIntoView(first); + if (!ReferenceEquals(SelectedDevice, device) || + !device.CommandSignals.Contains(first)) + return; + + // ScrollIntoView alone only ensures visibility, so a Tap + // already visible on the LAST row would remain at the bottom. + // Reset the virtualized viewport on expand/device switch only. + if (EquipmentCommandGrid.Template?.FindName( + "DG_ScrollViewer", EquipmentCommandGrid) is ScrollViewer scroll) + scroll.ScrollToTop(); + EquipmentCommandGrid.ScrollIntoView(first); })); } diff --git a/Services/IecNaturalReferenceOrder.cs b/Services/IecNaturalReferenceOrder.cs index 0b247384a..335b83713 100644 --- a/Services/IecNaturalReferenceOrder.cs +++ b/Services/IecNaturalReferenceOrder.cs @@ -66,15 +66,12 @@ public int Compare(object? a, object? b) if (ReferenceEquals(a, b)) return 0; if (a is not SignalDefinition left || b is not SignalDefinition right) return Comparer.DefaultInvariant.Compare(a,b); - // WPF's ListCollectionView.CustomSort is the FINAL visual order. - // The underlying device.OperableCommandSignals is already ranked, - // but the view's alphabetical comparer used to override that rank: - // SG7_BCUMaster/GGIO2 sorted above SG7_BCUSystem/Bkr0XCBR.Pos. - // Match exactly the live-verified command role priority at the view - // boundary, while keeping natural instance order WITHIN each role. - // This is presentation only; no rank authorizes an MMS Operate. - var role = Iec61850ControlIdentity.CommandDockPriority(left) - .CompareTo(Iec61850ControlIdentity.CommandDockPriority(right)); + // WPF CustomSort is the final visible order (not CommandSignals). + // Use the SAME presentation-only priority as the source projection; + // the former role-only sorter put StatusOnly CSWI above verified BSC + // tap control. Live ctlModel/CDC admission remains independent. + var role = Iec61850ControlIdentity.CommandDockDisplayPriority(left) + .CompareTo(Iec61850ControlIdentity.CommandDockDisplayPriority(right)); if (role != 0) return role; var result = IecNaturalReferenceOrder.Compare(left.DisplayReference,right.DisplayReference); return result != 0 ? result : IecNaturalReferenceOrder.Compare(left.ObjectReference,right.ObjectReference); diff --git a/tests/ARSAS.Tests/MultiGridNaturalSortRegressionTests.cs b/tests/ARSAS.Tests/MultiGridNaturalSortRegressionTests.cs index 22c09021d..8e4a890f7 100644 --- a/tests/ARSAS.Tests/MultiGridNaturalSortRegressionTests.cs +++ b/tests/ARSAS.Tests/MultiGridNaturalSortRegressionTests.cs @@ -132,6 +132,91 @@ static SignalDefinition Verified(string reference, string cdc = "DPC") }); } + [Fact] + public void RealWpfZivDockShowsLiveTapBeforeFourStatusOnlyCswi_WithoutSourceMutation() + { + RunInSta(() => + { + static SignalDefinition Control(string reference, string cdc, string model, bool verified) + { + var signal = new SignalDefinition + { + Name = reference, + ObjectReference = reference, + IsControlSignal = true, + IsSelected = true, + HasControlServiceEvidence = true, + FunctionalConstraint = "CO", + ControlCdc = cdc, + ControlModelText = model + }; + if (verified) + { + signal.AcceptVerifiedLiveControlCdc(cdc); + signal.ControlSemanticVerified = true; + } + return signal; + } + var tap = Control("ZIVAVRLD1/ATCC1.TapChg", "BSC", + "Select Before Operate (SBO) • Normal security", true); + var cswi = Enumerable.Range(1, 4) + .Select(i => Control($"ZIVAVRLD1/CSWI{i}.Pos", "DPC", "Status only", false)) + .ToArray(); + var device = new Iec61850MonitorDevice(); + device.Signals.ReplaceAll([..cswi, tap]); + device.RecountSelectedSignals(); + Assert.True(tap.ControlSupportsOperate); + Assert.All(cswi, s => Assert.False(s.ControlSupportsOperate)); + Assert.Same(tap, device.CommandSignals[0]); + + // This is the actual WPF collection view that painted the + // reported field screenshot incorrectly despite source order. + var grid = new DataGrid + { + CanUserAddRows = false, CanUserDeleteRows = false, + CanUserSortColumns = false, ItemsSource = device.CommandSignals + }; + var view = Assert.IsType( + CollectionViewSource.GetDefaultView(grid.ItemsSource)); + view.SortDescriptions.Clear(); + view.CustomSort = IecNaturalCommandSort.Instance; + SignalDefinition[] Visual() => view.Cast().ToArray(); + + Assert.Same(tap, Visual()[0]); + Assert.Equal(cswi, Visual().Skip(1)); + Assert.Equal(5, Visual().Length); + + // Report/value churn must not recycle rows or elevate read-only + // controls. Changing process values never changes ctlModel. + var changes = 0; + device.CommandSignals.CollectionChanged += (_, _) => changes++; + for (var i = 0; i < 100; ++i) + { + tap.ControlCurrentValue = (i % 2 + 1).ToString(); + device.RefreshCommandSignalProjection(); + } + Assert.Equal(0, changes); + Assert.Same(tap, Visual()[0]); + Assert.All(Visual().Skip(1), s => Assert.False(s.ControlSupportsOperate)); + }); + } + + [Fact] + public void CommandGridForbidsHeaderSortingAndResetsViewportOnIedSwitch() + { + var xaml = Read("MainWindow.xaml"); + var xml = XDocument.Parse(xaml); + XNamespace wpf = "http://schemas.microsoft.com/winfx/2006/xaml/presentation"; + XNamespace x = "http://schemas.microsoft.com/winfx/2006/xaml"; + var command = xml.Descendants(wpf + "DataGrid") + .Single(g => (string?)g.Attribute(x + "Name") == "EquipmentCommandGrid"); + Assert.Equal("False", (string?)command.Attribute("CanUserSortColumns")); + + var code = Read("MainWindow.xaml.cs"); + Assert.Contains("ScrollVerifiedEquipmentCommandsIntoView();", code); + Assert.Contains("scroll.ScrollToTop();", code); + } + [Fact] public void CommandComparatorDoesNotTreatUnverifiedNamesAsOperatePermission() {