diff --git a/MainWindow.GooseIedScope.cs b/MainWindow.GooseIedScope.cs new file mode 100644 index 000000000..70114bb88 --- /dev/null +++ b/MainWindow.GooseIedScope.cs @@ -0,0 +1,77 @@ +using System.ComponentModel; +using System.Windows.Data; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ArIED61850Tester; + +public partial class MainWindow +{ + // 0 = selected IED; 1 = all IEDs. Never filters the capture/decoder. + private int _gooseIedScopeIndex; + private ListCollectionView? _gooseEventScopeView; + private ListCollectionView? _gooseStreamScopeView; + + public ICollectionView GooseVisibleEvents => _gooseEventScopeView ??= CreateGooseEventsView(); + public ICollectionView GooseVisibleStreams => _gooseStreamScopeView ??= CreateGooseStreamsView(); + + public int GooseIedScopeIndex + { + get => _gooseIedScopeIndex; + set + { + var scope = value == 1 ? 1 : 0; + if (!Set(ref _gooseIedScopeIndex, scope)) return; + Raise(nameof(GooseSelectedScopeHint)); + RefreshGooseScopeViews(); + } + } + + public string GooseSelectedScopeHint => GooseIedScopeIndex == 1 + ? "Display messages from every captured GOOSE publisher, including unresolved identities." + : SelectedDevice is null + ? "No IED selected; showing all GOOSE publishers." + : $"Only confirmed {SelectedDevice.Name} GOOSE streams. Unresolved publishers remain in All IED mode."; + + private ListCollectionView CreateGooseEventsView() + { + var view = new ListCollectionView(GooseEvents); + view.Filter = value => value is GooseEventRow row && IsGooseIedInScope(row.IedName); + return view; + } + + private ListCollectionView CreateGooseStreamsView() + { + var view = new ListCollectionView(GooseStreams); + view.Filter = value => value is GooseStreamRow row && IsGooseIedInScope(row.ModelIedName); + return view; + } + + private bool IsGooseIedInScope(string? publisherIedName) + => GooseIedScopePolicy.Matches(GooseIedScopeIndex, + SelectedDevice?.Name, SelectedDevice?.SclIedName, publisherIedName); + + private void RefreshGooseScopeViews() + { + // Refresh only the operator views. The 500-event ring and up to 256 + // original publisher streams continue collecting all captured traffic. + _gooseEventScopeView?.Refresh(); + _gooseStreamScopeView?.Refresh(); + Raise(nameof(GooseSelectedScopeHint)); + _settingGooseLiveSelection = true; + try + { + if (SelectedGooseEvent is not null && + !IsGooseIedInScope(SelectedGooseEvent.IedName)) + SelectedGooseEvent = null; + if (FollowLatestGooseEvents) + SelectedGooseEvent = GooseVisibleEvents.Cast().LastOrDefault(); + if (SelectedGooseEvent is null && + SelectedGooseStream is not null && + !IsGooseIedInScope(SelectedGooseStream.ModelIedName)) + SelectedGooseStream = GooseVisibleStreams.Cast().FirstOrDefault(); + } + finally { _settingGooseLiveSelection = false; } + RaiseGoosePresentationState(); + } +} diff --git a/MainWindow.GooseSubscriber.cs b/MainWindow.GooseSubscriber.cs index 844f7476b..8d309ebfe 100644 --- a/MainWindow.GooseSubscriber.cs +++ b/MainWindow.GooseSubscriber.cs @@ -332,6 +332,8 @@ private void RebindGooseRowsFromLatestFrames() row.Apply(BuildGooseStreamSnapshot(captured, _gooseBindingCatalog)); } + if (_goosePresentationInstalled) + RefreshGooseScopeViews(); Raise(nameof(GooseSelectedStreamText)); Raise(nameof(GooseNoLeafValuesVisibility)); Raise(nameof(GooseSelectedLeafCountText)); @@ -423,6 +425,7 @@ private void FlushGooseSubscriberUi() return; var processed = 0; + var scopeIdentityChanged = false; foreach (var pair in _pendingGooseFrames.ToArray()) { if (processed >= 64) @@ -447,7 +450,10 @@ private void FlushGooseSubscriberUi() SelectedGooseStream ??= row; } + var previousIedName = row.ModelIedName; row.Apply(snapshot); + if (!string.Equals(previousIedName, row.ModelIedName, StringComparison.OrdinalIgnoreCase)) + scopeIdentityChanged = true; // In follow-live mode the inspector shows current stream state // between meaningful events; selecting history freezes the frame. if (FollowLatestGooseEvents && _selectedGooseEvent?.StreamKey == snapshot.StreamKey) @@ -455,6 +461,13 @@ private void FlushGooseSubscriberUi() processed++; } + if (scopeIdentityChanged) + RefreshGooseScopeViews(); + // The first captured publisher may be unresolved or belong to a + // different IED. Never leave that row in the selected-IED inspector. + if (SelectedGooseEvent is null && SelectedGooseStream is not null && + !IsGooseIedInScope(SelectedGooseStream.ModelIedName)) + SelectedGooseStream = GooseVisibleStreams.Cast().FirstOrDefault(); Raise(nameof(GooseCounterText)); Raise(nameof(GooseNoStreamsVisibility)); Raise(nameof(GooseNoLeafValuesVisibility)); diff --git a/MainWindow.GooseTimeline.cs b/MainWindow.GooseTimeline.cs index 61e0a6ba8..2d76b0f47 100644 --- a/MainWindow.GooseTimeline.cs +++ b/MainWindow.GooseTimeline.cs @@ -31,7 +31,7 @@ public bool FollowLatestGooseEvents if (value && GooseEvents.Count > 0) { _settingGooseLiveSelection = true; - try { SelectedGooseEvent = GooseEvents[^1]; } + try { SelectedGooseEvent = GooseVisibleEvents.Cast().LastOrDefault(); } finally { _settingGooseLiveSelection = false; } } } @@ -78,12 +78,12 @@ public GooseEventRow? SelectedGooseEvent } } - public string GoosePublisherCountText => $"{GooseStreams.Count:N0}"; - public string GooseEventCountText => $"{GooseEvents.Count:N0}"; + public string GoosePublisherCountText => $"{GooseVisibleStreams.Cast().Count():N0}"; + public string GooseEventCountText => $"{GooseVisibleEvents.Cast().Count():N0}"; public string GooseSelectedLeafCountText => SelectedGooseStream is null ? "0 signals" : $"{SelectedGooseStream.EngineeringLeaves.Count:N0} signals · {SelectedGooseStream.Leaves.Count:N0} entries"; - public Visibility GooseNoEventsVisibility => GooseEvents.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + public Visibility GooseNoEventsVisibility => GooseVisibleEvents.Cast().Any() ? Visibility.Collapsed : Visibility.Visible; [ModuleInitializer] internal static void RegisterGoosePresentationIntegration() @@ -151,6 +151,8 @@ private void GoosePresentation_PropertyChanged(object? sender, PropertyChangedEv { if (args.PropertyName == nameof(SelectedGooseStream)) Raise(nameof(GooseSelectedLeafCountText)); + if (args.PropertyName == nameof(SelectedDevice)) + RefreshGooseScopeViews(); } private static void OnRefreshGooseAdaptersRequested(object sender, RoutedEventArgs args) @@ -273,7 +275,7 @@ private void GooseTimelineUiFlushTimer_Tick(object? sender, EventArgs args) while (GooseEvents.Count > MaxGooseTimelineEvents) GooseEvents.RemoveAt(0); - if (FollowLatestGooseEvents) + if (FollowLatestGooseEvents && IsGooseIedInScope(eventRow.IedName)) { _settingGooseLiveSelection = true; try { SelectedGooseEvent = eventRow; } diff --git a/MainWindow.IecNaturalSort.cs b/MainWindow.IecNaturalSort.cs index 90226b39d..efcf7738f 100644 --- a/MainWindow.IecNaturalSort.cs +++ b/MainWindow.IecNaturalSort.cs @@ -1,41 +1,51 @@ +using System; +using System.Collections; using System.ComponentModel; using System.Windows; using System.Windows.Controls; using System.Windows.Data; +using ArIED61850Tester.Models; using ArIED61850Tester.Services; namespace ArIED61850Tester; /// -/// Natural IEC sorting is attached to the actual native seven-column grid. -/// The earlier search-module heuristic expected six columns and never ran. -/// This changes only the WPF collection view, not the report/DataSet order. +/// Numeric-aware sorting is installed on each real grid, not guessed by +/// column counts. Never mutates original RCB, FCDA or control signal order. /// public partial class MainWindow { private readonly HashSet _iecNaturallySortedGrids = new(); private void ExplorerLiveGrid_Loaded(object sender, RoutedEventArgs e) + => AttachIecNaturalGridSort(sender as DataGrid, IecNaturalLiveMonitorSort.Instance); + + private void CommandGrid_Loaded(object sender, RoutedEventArgs e) + => AttachIecNaturalGridSort(sender as DataGrid, IecNaturalCommandSort.Instance); + + private void GlobalLiveGrid_Loaded(object sender, RoutedEventArgs e) + => AttachIecNaturalGridSort(sender as DataGrid, IecNaturalGlobalMonitorSort.Instance); + + private void AttachIecNaturalGridSort(DataGrid? grid, IComparer comparer) { - if (sender is not DataGrid grid) return; + if (grid is null) return; if (_iecNaturallySortedGrids.Add(grid)) { var descriptor = DependencyPropertyDescriptor.FromProperty( ItemsControl.ItemsSourceProperty, typeof(DataGrid)); descriptor?.AddValueChanged(grid, (_, _) => - grid.Dispatcher.BeginInvoke(new Action(() => ApplyExplorerNaturalSort(grid)))); + grid.Dispatcher.BeginInvoke(new Action(() => ApplyIecNaturalSort(grid, comparer)))); } - ApplyExplorerNaturalSort(grid); + ApplyIecNaturalSort(grid, comparer); } - private static void ApplyExplorerNaturalSort(DataGrid grid) + private static void ApplyIecNaturalSort(DataGrid grid, IComparer comparer) { if (grid.ItemsSource is null) return; if (CollectionViewSource.GetDefaultView(grid.ItemsSource) is not ListCollectionView view) return; - if (ReferenceEquals(view.CustomSort, IecNaturalLiveMonitorSort.Instance)) - return; + if (ReferenceEquals(view.CustomSort, comparer)) return; view.SortDescriptions.Clear(); - view.CustomSort = IecNaturalLiveMonitorSort.Instance; + view.CustomSort = comparer; } } diff --git a/MainWindow.xaml b/MainWindow.xaml index 2a1a37657..717371180 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -747,7 +747,7 @@ - - diff --git a/Services/GooseIedScopePolicy.cs b/Services/GooseIedScopePolicy.cs new file mode 100644 index 000000000..29449adef --- /dev/null +++ b/Services/GooseIedScopePolicy.cs @@ -0,0 +1,21 @@ +namespace ArIED61850Tester.Services; + +/// +/// Identity-scoped presentation selection only: never infer an IED owner from +/// a MAC address, an IP, or an unverified GOOSE ID suffix. +/// +public static class GooseIedScopePolicy +{ + public static bool Matches(int scopeIndex, string? selectedDeviceName, + string? selectedSclIedName, string? publisherIedName) + { + if (scopeIndex == 1 || + (string.IsNullOrWhiteSpace(selectedDeviceName) && + string.IsNullOrWhiteSpace(selectedSclIedName))) return true; + if (string.IsNullOrWhiteSpace(publisherIedName) || + publisherIedName.Equals("Unresolved", StringComparison.OrdinalIgnoreCase)) + return false; + return publisherIedName.Equals(selectedDeviceName?.Trim(), StringComparison.OrdinalIgnoreCase) || + publisherIedName.Equals(selectedSclIedName?.Trim(), StringComparison.OrdinalIgnoreCase); + } +} diff --git a/Services/GooseTypedValueInterpreter.cs b/Services/GooseTypedValueInterpreter.cs index 2a5b73c7d..fc4c0d10f 100644 --- a/Services/GooseTypedValueInterpreter.cs +++ b/Services/GooseTypedValueInterpreter.cs @@ -97,179 +97,163 @@ public static string RenderQuality(MmsDataValue value) } /// -/// Engineering readout only: preserves every original ordered GOOSE FCDA in -/// GooseStreamRow.Leaves. Pairs q only where its owner is unambiguous. +/// Operator projection of ordered wire allData. Only a unique model-bound +/// IEC DO owner may absorb its q into the stVal engineering quality column. +/// The separate raw Leaves collection remains untouched for diagnostics. /// public static class GooseCanonicalLeafProjection { - public static IReadOnlyList Project(IReadOnlyList wireLeaves) + public static IReadOnlyList Project(IReadOnlyList wire) { - var values = wireLeaves.Where(leaf => - Owner(leaf.SignalReference, out var kind) is not null && kind == "value").ToArray(); - var qualities = wireLeaves.Where(leaf => - Owner(leaf.SignalReference, out var kind) is not null && kind == "quality").ToArray(); - + if (wire.Count == 0) return Array.Empty(); + var owners = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var statuses = new List(); + foreach (var leaf in wire) + { + if (!ModelBound(leaf) || !TryOwner(leaf, out var owner, out var isQuality)) continue; + if (isQuality) continue; + statuses.Add(leaf); + if (!owners.TryGetValue(owner, out var values)) + owners[owner] = values = new List(); + values.Add(leaf); + } var pairs = new Dictionary(); - var pairedQualities = new HashSet(); - foreach (var value in wireLeaves) + var consumedQuality = new HashSet(); + foreach (var status in statuses) { - if (!IsValue(value) && - !(IsModelBound(value) && value.SignalName.EndsWith(".stVal", StringComparison.OrdinalIgnoreCase))) - continue; - var valueRef = Owner(value.SignalReference, out _); - var exact = qualities.Where(q => - string.Equals(valueRef, Owner(q.SignalReference, out _), StringComparison.OrdinalIgnoreCase)).ToArray(); - // Exact qualified-reference pairing has priority. Never accept - // duplicate status/quality owners as a unique match. - var exactValueCount = values.Count(other => - string.Equals(valueRef, Owner(other.SignalReference, out _), StringComparison.OrdinalIgnoreCase)); - GooseLeafValueSnapshot? candidate = exact.Length == 1 && exactValueCount == 1 - ? exact[0] : null; + if (!TryOwner(status, out var owner, out _) || owners[owner].Count != 1) continue; + var candidates = wire.Where(q => ModelBound(q) && + TryOwner(q, out var qOwner, out var isQuality) && isQuality && + owner.Equals(qOwner, StringComparison.OrdinalIgnoreCase) && + q.DataSetIndex != status.DataSetIndex && QualityIsReadable(q.Value) && + CompatibleFc(status, q) && IdentityDoesNotConflict(status.SignalReference, q.SignalReference)) + .ToArray(); + if (candidates.Length == 0) continue; - if (candidate is null && exact.Length == 0 && IsModelBound(value)) + // Prefer one uniquely identified full IEC DO source pair. When + // metadata uses shortened/DO-scoped references (GE F650), the + // adjacent FCDA is authoritative only for a unique SCL bound owner. + var exact = candidates.Where(q => + !string.IsNullOrWhiteSpace(ReferenceOwner(status.SignalReference)) && + string.Equals(ReferenceOwner(status.SignalReference), + ReferenceOwner(q.SignalReference), StringComparison.OrdinalIgnoreCase)).ToArray(); + GooseLeafValueSnapshot? companion = exact.Length == 1 ? exact[0] : null; + if (exact.Length > 1) continue; + if (companion is null) { - // Some SCL publishers expose mixed abbreviated DA references. - // The ordered wire DataSet itself provides a stronger fallback: - // adjacent typed q following stVal, same FC, exact LN.DO name, - // unique in this stream. Never match unrelated LD-qualified refs. - var ownerName = Owner(value.SignalName, out var valueKind); - var adjacent = wireLeaves.Where(q => q.DataSetIndex == value.DataSetIndex + 1 && - IsModelBound(q) && IsQuality(q) && - q.FunctionalConstraint.Equals(value.FunctionalConstraint, StringComparison.OrdinalIgnoreCase) && - string.Equals(ownerName, Owner(q.SignalName, out var qKind), StringComparison.OrdinalIgnoreCase) && - valueKind == "value" && qKind == "quality" && - (CompatibleOwners(valueRef, Owner(q.SignalReference, out _)) || - CompatibleOwners(DoScopedReference(value.SignalReference), - DoScopedReference(q.SignalReference)))).ToArray(); - if (adjacent.Length == 1) - { - var occurrences = wireLeaves.Count(row => IsValue(row) && - string.Equals(ownerName, Owner(row.SignalName, out _), StringComparison.OrdinalIgnoreCase)); - if (occurrences == 1) candidate = adjacent[0]; - } + var adjacent = candidates.Where(q => q.DataSetIndex == status.DataSetIndex + 1).ToArray(); + if (adjacent.Length == 1 && candidates.Length == 1) companion = adjacent[0]; } - if (candidate is null && IsModelBound(value) && - value.SignalName.EndsWith(".stVal", StringComparison.OrdinalIgnoreCase)) - { - // Some GE F650 SCL models publish typed q in the next FCDA but - // use different naming conventions for MMS reference vs display. - // Only two *unique*, adjacent, model-bound wire members with - // identical IEC LN.DO and a decoded quality status may be joined. - var shortOwner = Owner(value.SignalName, out var valueKind); - var neighbors = wireLeaves.Where(q => - q.DataSetIndex == value.DataSetIndex + 1 && - IsModelBound(q) && - q.SignalName.EndsWith(".q", StringComparison.OrdinalIgnoreCase) && - Owner(q.SignalName, out var qKind) == shortOwner && - qKind == "quality" && valueKind == "value" && - IsReadableQuality(q.Value) && - (string.IsNullOrWhiteSpace(q.FunctionalConstraint) || - string.IsNullOrWhiteSpace(value.FunctionalConstraint) || - q.FunctionalConstraint.Equals(value.FunctionalConstraint, StringComparison.OrdinalIgnoreCase)) && - (CompatibleOwners(valueRef, Owner(q.SignalReference, out _)) || - CompatibleOwners(DoScopedReference(value.SignalReference), - DoScopedReference(q.SignalReference)))).ToArray(); - if (neighbors.Length == 1 && - wireLeaves.Count(row => row.SignalName.Equals(value.SignalName, StringComparison.OrdinalIgnoreCase)) == 1) - candidate = neighbors[0]; - } - if (candidate is null || !pairedQualities.Add(candidate.DataSetIndex)) - continue; - pairs[value.DataSetIndex] = candidate; + if (companion is null || !consumedQuality.Add(companion.DataSetIndex)) continue; + pairs[status.DataSetIndex] = companion; } - - var result = new List(wireLeaves.Count); - foreach (var leaf in wireLeaves) + var result = new List(wire.Count); + foreach (var leaf in wire) { - if (pairedQualities.Contains(leaf.DataSetIndex)) continue; - if (pairs.TryGetValue(leaf.DataSetIndex, out var quality)) + if (consumedQuality.Contains(leaf.DataSetIndex)) continue; + if (pairs.TryGetValue(leaf.DataSetIndex, out var q)) + { + var name = leaf.SignalName; + if (name.EndsWith(".stVal", StringComparison.OrdinalIgnoreCase)) + name = name[..^6]; result.Add(leaf with { - SignalName = leaf.SignalName.EndsWith(".stVal", StringComparison.OrdinalIgnoreCase) - ? leaf.SignalName[..^6] : leaf.SignalName, - Quality = quality.Value, - IsChanged = leaf.IsChanged || quality.IsChanged + SignalName = name, + Quality = q.Value, + IsChanged = leaf.IsChanged || q.IsChanged }); + } else result.Add(leaf); } return result; } - private static string? DoScopedReference(string? input) + private static bool ModelBound(GooseLeafValueSnapshot leaf) + => !string.IsNullOrWhiteSpace(leaf.SignalName) && + !leaf.BindingSource.Equals("Unbound", StringComparison.OrdinalIgnoreCase); + + private static bool TryOwner(GooseLeafValueSnapshot leaf, out string owner, out bool isQuality) { - if (string.IsNullOrWhiteSpace(input)) return null; - var key = input.Trim().Replace('$', '.'); - var slash = key.LastIndexOf('/'); - var dot = key.IndexOf('.', slash + 1); - if (dot >= 0 && dot + 4 < key.Length && - key.AsSpan(dot + 1, 3).Equals("ST.".AsSpan(), StringComparison.OrdinalIgnoreCase)) - key = key.Remove(dot + 1, 3); - foreach (var suffix in new[] { ".stVal", ".q" }) - if (key.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) - key = key[..^suffix.Length]; - return key; + // Display names are established by the ordered model leaves. A + // DO-scoped SignalReference can lack the stVal/q suffix entirely. + owner = string.Empty; + isQuality = false; + var name = ReferenceSuffix(leaf.SignalName); + if (name.EndsWith(".q", StringComparison.OrdinalIgnoreCase)) + { + isQuality = true; + owner = name[..^2]; + return true; + } + if (name.EndsWith(".stVal", StringComparison.OrdinalIgnoreCase)) + { + owner = name[..^6]; + return true; + } + if (name.EndsWith(".mag.f", StringComparison.OrdinalIgnoreCase)) + { + owner = name[..^6]; + return true; + } + // Some SCL aliases use an engineering signal display label rather + // than the FCDA leaf name. Accept only a typed source reference. + var reference = ReferenceSuffix(leaf.SignalReference); + if (reference.EndsWith(".q", StringComparison.OrdinalIgnoreCase)) + { + isQuality = true; + owner = reference[..^2]; + return true; + } + if (reference.EndsWith(".stVal", StringComparison.OrdinalIgnoreCase)) + { + owner = reference[..^6]; + return true; + } + return false; } - private static bool IsReadableQuality(string value) => - value.Equals("Good", StringComparison.OrdinalIgnoreCase) || - value.StartsWith("Good ·", StringComparison.OrdinalIgnoreCase) || - value.Equals("Invalid", StringComparison.OrdinalIgnoreCase) || - value.StartsWith("Invalid ·", StringComparison.OrdinalIgnoreCase) || - value.Equals("Questionable", StringComparison.OrdinalIgnoreCase) || - value.StartsWith("Questionable ·", StringComparison.OrdinalIgnoreCase) || - value.Equals("Reserved", StringComparison.OrdinalIgnoreCase); - - private static bool IsModelBound(GooseLeafValueSnapshot leaf) - => !leaf.BindingSource.Equals("Unbound", StringComparison.OrdinalIgnoreCase) && - !string.IsNullOrWhiteSpace(leaf.SignalReference); + private static bool CompatibleFc(GooseLeafValueSnapshot value, GooseLeafValueSnapshot q) + => string.IsNullOrWhiteSpace(value.FunctionalConstraint) || + string.IsNullOrWhiteSpace(q.FunctionalConstraint) || + value.FunctionalConstraint.Equals(q.FunctionalConstraint, StringComparison.OrdinalIgnoreCase); - private static bool IsValue(GooseLeafValueSnapshot leaf) - => Owner(leaf.SignalReference, out var kind) is not null && kind == "value"; + private static bool QualityIsReadable(string text) + => new[] { "Good", "Invalid", "Questionable", "Reserved" } + .Any(status => text.Equals(status, StringComparison.OrdinalIgnoreCase) || + text.StartsWith(status + " ·", StringComparison.OrdinalIgnoreCase)); - private static bool IsQuality(GooseLeafValueSnapshot leaf) - => Owner(leaf.SignalReference, out var kind) is not null && kind == "quality" && - (GooseTypedValueInterpreter.IsQuality(leaf.SignalReference, leaf.BType) || - leaf.SignalName.EndsWith(".q", StringComparison.OrdinalIgnoreCase)); + private static bool IdentityDoesNotConflict(string? statusRef, string? qualityRef) + { + var a = ReferenceOwner(statusRef); + var b = ReferenceOwner(qualityRef); + if (string.IsNullOrWhiteSpace(a) || string.IsNullOrWhiteSpace(b)) return true; + var slashA = a.LastIndexOf('/'); + var slashB = b.LastIndexOf('/'); + if (slashA >= 0 && slashB >= 0) return a.Equals(b, StringComparison.OrdinalIgnoreCase); + return (slashA >= 0 ? a[(slashA + 1)..] : a) + .Equals(slashB >= 0 ? b[(slashB + 1)..] : b, StringComparison.OrdinalIgnoreCase); + } - private static bool CompatibleOwners(string? primary, string? quality) + private static string? ReferenceOwner(string? text) { - if (primary is null || quality is null) return false; - if (primary.Equals(quality, StringComparison.OrdinalIgnoreCase)) return true; - // An abbreviated SCL reference may omit its LD prefix. Two explicit - // different LD prefixes are NOT equivalent, regardless of matching LN. - var a = primary.LastIndexOf('/'); - var b = quality.LastIndexOf('/'); - if (a >= 0 && b >= 0) return false; - return (a >= 0 ? primary[(a+1)..] : primary) - .Equals(b >= 0 ? quality[(b+1)..] : quality, - StringComparison.OrdinalIgnoreCase); + if (string.IsNullOrWhiteSpace(text)) return null; + var reference = text.Trim().Replace('$', '.'); + var slash = reference.LastIndexOf('/'); + var st = reference.IndexOf(".ST.", slash + 1, StringComparison.OrdinalIgnoreCase); + if (st >= 0) reference = reference.Remove(st, 3); + foreach (var suffix in new[] { ".stVal", ".q", ".mag.f" }) + if (reference.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) + return reference[..^suffix.Length]; + return reference; } - private static string? Owner(string? source, out string kind) + private static string ReferenceSuffix(string? text) { - kind = string.Empty; - if (string.IsNullOrWhiteSpace(source)) return null; - var key = source.Trim().Replace('$', '.'); - var slash = key.LastIndexOf('/'); - var dot = key.IndexOf('.', slash + 1); - if (dot >= 0 && dot + 4 < key.Length && - key.AsSpan(dot + 1, 3).Equals("ST.".AsSpan(), StringComparison.OrdinalIgnoreCase)) - key = key.Remove(dot + 1, 3); - var bracket = key.LastIndexOf('['); - if (bracket >= 0 && key.EndsWith("]", StringComparison.Ordinal) && - key[(bracket + 1)..^1].Equals("ST", StringComparison.OrdinalIgnoreCase)) - key = key[..bracket]; - if (key.EndsWith(".stVal", StringComparison.OrdinalIgnoreCase) || - key.EndsWith(".mag.f", StringComparison.OrdinalIgnoreCase)) - { - kind = "value"; - return key[..^6]; - } - if (key.EndsWith(".q", StringComparison.OrdinalIgnoreCase)) - { - kind = "quality"; - return key[..^2]; - } - return null; + var normalized = text?.Trim().Replace('$', '.') ?? string.Empty; + var slash = normalized.LastIndexOf('/'); + if (slash >= 0) normalized = normalized[(slash + 1)..]; + var st = normalized.IndexOf(".ST.", StringComparison.OrdinalIgnoreCase); + if (st >= 0) normalized = normalized.Remove(st, 3); + return normalized; } } diff --git a/Services/IecNaturalReferenceOrder.cs b/Services/IecNaturalReferenceOrder.cs index 7e5ce03d4..a62084398 100644 --- a/Services/IecNaturalReferenceOrder.cs +++ b/Services/IecNaturalReferenceOrder.cs @@ -57,3 +57,29 @@ public int Compare(object? a, object? b) IecNaturalReferenceOrder.Compare(first.IecReference,second.IecReference); } } + +public sealed class IecNaturalCommandSort : IComparer +{ + public static IecNaturalCommandSort Instance { get; } = new(); + 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); + var result = IecNaturalReferenceOrder.Compare(left.DisplayReference,right.DisplayReference); + return result != 0 ? result : IecNaturalReferenceOrder.Compare(left.ObjectReference,right.ObjectReference); + } +} + +public sealed class IecNaturalGlobalMonitorSort : IComparer +{ + public static IecNaturalGlobalMonitorSort Instance { get; } = new(); + public int Compare(object? a, object? b) + { + if (ReferenceEquals(a,b)) return 0; + if (a is not Iec61850MonitorPoint left || b is not Iec61850MonitorPoint right) + return Comparer.DefaultInvariant.Compare(a,b); + var device = string.Compare(left.DeviceName,right.DeviceName,StringComparison.OrdinalIgnoreCase); + return device != 0 ? device : IecNaturalLiveMonitorSort.Instance.Compare(left,right); + } +} diff --git a/Views/GooseSubscriberLiteView.xaml b/Views/GooseSubscriberLiteView.xaml index 1c5c708b9..6dda1530d 100644 --- a/Views/GooseSubscriberLiteView.xaml +++ b/Views/GooseSubscriberLiteView.xaml @@ -185,7 +185,7 @@ - + + + + - new(n,n-1,signal,reference,"ST","DPC",btype,value,"",false,"SCL"); + var raw=new[]{ + F(1,"CSWI6.Pos.stVal","BCUGEF650/CSWI6.Pos","Dbpos","Off"), + F(2,"CSWI6.Pos.q","BCUGEF650/CSWI6.Pos","Quality","Good"), + F(3,"CSWI7.Pos.stVal","BCUGEF650/CSWI7.Pos","Dbpos","Intermediate"), + F(4,"CSWI7.Pos.q","BCUGEF650/CSWI7.Pos","Quality","Good") + }; + var projected=GooseCanonicalLeafProjection.Project(raw); + Assert.Equal(4,raw.Length); + Assert.Equal(2,projected.Count); + Assert.Equal("Good",projected[0].Quality); + Assert.Equal("Good",projected[1].Quality); + Assert.Equal(new[]{0,2},projected.Select(x=>x.DataSetIndex)); + Assert.EndsWith(".q",raw[1].SignalName); + } + + [Fact] + public void MissingSclReferenceStillPairsUniqueAdjacentTypedMembersButNeverUnbound() + { + static GooseLeafValueSnapshot F(int n,string name,string value,string binding) + => new(n,n-1,name,"","ST","DPC",name.EndsWith(".q")?"Quality":"Dbpos",value,"",false,binding); + var resolved=new[]{F(1,"CSWI6.Pos.stVal","Off","SCL"),F(2,"CSWI6.Pos.q","Good","SCL")}; + var raw=new[]{F(1,"CSWI6.Pos.stVal","Off","Unbound"),F(2,"CSWI6.Pos.q","Good","Unbound")}; + Assert.Single(GooseCanonicalLeafProjection.Project(resolved)); + Assert.Equal("Good",GooseCanonicalLeafProjection.Project(resolved)[0].Quality); + Assert.Equal(2,GooseCanonicalLeafProjection.Project(raw).Count); + } + + [Fact] + public void NeverPairDifferentExplicitLogicalDevicesEvenWithSameDisplayNames() + { + static GooseLeafValueSnapshot F(int n,string label,string full,string t,string value) + => new(n,n-1,label,full,"ST","DPC",t,value,"",false,"SCL"); + var raw=new[]{ + F(1,"CSWI6.Pos.stVal","LDA/CSWI6.Pos.stVal","Dbpos","Off"), + F(2,"CSWI6.Pos.q","LDB/CSWI6.Pos.q","Quality","Good") + }; + Assert.Equal(2,GooseCanonicalLeafProjection.Project(raw).Count); + } + + private static string Read(string path) + { + DirectoryInfo? dir=new(AppContext.BaseDirectory); + while(dir is not null) + { + var file=Path.Combine(dir.FullName,path); + if(File.Exists(file))return File.ReadAllText(file); + dir=dir.Parent; + } + throw new FileNotFoundException(path); + } +} diff --git a/tests/ARSAS.Tests/MultiGridNaturalSortRegressionTests.cs b/tests/ARSAS.Tests/MultiGridNaturalSortRegressionTests.cs new file mode 100644 index 000000000..164159a12 --- /dev/null +++ b/tests/ARSAS.Tests/MultiGridNaturalSortRegressionTests.cs @@ -0,0 +1,67 @@ +using System.Xml.Linq; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class MultiGridNaturalSortRegressionTests +{ + [Fact] + public void ThreeRealGridsUseTheSharedViewOnlyNaturalSorter() + { + var xaml=Read("MainWindow.xaml"); + var xml=XDocument.Parse(xaml); + XNamespace wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation"; + var grids=xml.Descendants(wpf+"DataGrid").ToArray(); + Assert.Contains(grids, g=>(string?)g.Attribute("Loaded")=="ExplorerLiveGrid_Loaded" && + ((string?)g.Attribute("ItemsSource"))?.Contains("SelectedDevice.Points")==true); + Assert.Contains(grids, g=>(string?)g.Attribute("Loaded")=="CommandGrid_Loaded" && + ((string?)g.Attribute("ItemsSource"))?.Contains("SelectedDevice.CommandSignals")==true); + Assert.Contains(grids, g=>(string?)g.Attribute("Loaded")=="GlobalLiveGrid_Loaded" && + ((string?)g.Attribute("ItemsSource"))?.Contains("GlobalPoints")==true); + var sort=Read("MainWindow.IecNaturalSort.cs"); + Assert.Contains("view.CustomSort = comparer;",sort); + Assert.Contains("IecNaturalCommandSort.Instance",sort); + Assert.Contains("IecNaturalGlobalMonitorSort.Instance",sort); + } + + [Fact] + public void CommandDockSortingOrdersInstanceNumbersWithoutMutatingSource() + { + var one =new SignalDefinition { ObjectReference="BCUGEF650/CSWI1.Pos" }; + var two =new SignalDefinition { ObjectReference="BCUGEF650/CSWI2.Pos" }; + var ten =new SignalDefinition { ObjectReference="BCUGEF650/CSWI10.Pos" }; + var unordered =new[]{ten,two,one}; + var sorted=unordered.OrderBy(x=>x, + Comparer.Create((a,b)=>IecNaturalCommandSort.Instance.Compare(a,b))) + .Select(x=>x.ObjectReference).ToArray(); + Assert.Equal(new[]{one.ObjectReference,two.ObjectReference,ten.ObjectReference},sorted); + Assert.Same(ten,unordered[0]); + } + + [Fact] + public void GlobalMonitorGroupsIEDThenNaturalInstance() + { + static Iec61850MonitorPoint Point(string device,string ln) => + new(){DeviceName=device,IecReference=$"{device}F650/{ln}.Pos.stVal"}; + var points=new[]{Point("GE","CSWI10"),Point("GE","CSWI2"),Point("ABB","CSWI10"), + Point("GE","CSWI1"),Point("ABB","CSWI2")}; + var sorted=points.OrderBy(x=>x, + Comparer.Create((a,b)=>IecNaturalGlobalMonitorSort.Instance.Compare(a,b))).ToArray(); + Assert.Equal(new[]{"ABB","ABB","GE","GE","GE"},sorted.Select(x=>x.DeviceName)); + Assert.Equal(new[]{"CSWI2","CSWI10"},sorted.Take(2).Select(x=>x.IecTelegram.Split('/').Last().Split('.')[0])); + Assert.Equal(new[]{"CSWI1","CSWI2","CSWI10"},sorted.Skip(2).Select(x=>x.IecTelegram.Split('/').Last().Split('.')[0])); + } + + private static string Read(string path) + { + DirectoryInfo? dir=new(AppContext.BaseDirectory); + while(dir is not null) + { + var file=Path.Combine(dir.FullName,path); + if(File.Exists(file))return File.ReadAllText(file); + dir=dir.Parent; + } + throw new FileNotFoundException(path); + } +} diff --git a/tests/ARSAS.Tests/NativeExplorerNaturalSortRegressionTests.cs b/tests/ARSAS.Tests/NativeExplorerNaturalSortRegressionTests.cs index 96d10546c..62e8d1133 100644 --- a/tests/ARSAS.Tests/NativeExplorerNaturalSortRegressionTests.cs +++ b/tests/ARSAS.Tests/NativeExplorerNaturalSortRegressionTests.cs @@ -11,7 +11,8 @@ public void RealSevenColumnExplorerGrid_AttachesTheNaturalSortHandler() Assert.Contains("ItemsSource=\"{Binding SelectedDevice.Points}\" IsReadOnly=\"True\"",xaml); Assert.Contains("Loaded=\"ExplorerLiveGrid_Loaded\"",xaml); var source=Read("MainWindow.IecNaturalSort.cs"); - Assert.Contains("view.CustomSort = IecNaturalLiveMonitorSort.Instance",source); + Assert.Contains("AttachIecNaturalGridSort(sender as DataGrid, IecNaturalLiveMonitorSort.Instance)",source); + Assert.Contains("view.CustomSort = comparer;",source); Assert.Contains("view.SortDescriptions.Clear()",source); }