From 57f92d9826de92401b2521e8fb45f3911e09492a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 10 Oct 2026 16:22:09 +0700 Subject: [PATCH] fix(ui): natural IEC telegram sorting, safe GOOSE quality pairing and correlated live events --- MainWindow.GooseSubscriber.cs | 4 + MainWindow.GooseTimeline.cs | 46 ++++++++- MainWindow.LiveSignalSearch.cs | 6 ++ Services/GooseCrossStreamEventPolicy.cs | 52 ++++++++++ Services/GooseTimelineEventPolicy.cs | 12 ++- Services/GooseTypedValueInterpreter.cs | 94 ++++++++++++++----- Services/IecNaturalReferenceOrder.cs | 59 ++++++++++++ Views/GooseSubscriberLiteView.xaml | 17 +++- .../GooseCanonicalEngineeringTests.cs | 35 +++++++ .../GooseCrossStreamEventPolicyTests.cs | 68 ++++++++++++++ .../GooseTimelineEventPolicyTests.cs | 2 + .../IecNaturalReferenceOrderTests.cs | 43 +++++++++ 12 files changed, 408 insertions(+), 30 deletions(-) create mode 100644 Services/GooseCrossStreamEventPolicy.cs create mode 100644 Services/IecNaturalReferenceOrder.cs create mode 100644 tests/ARSAS.Tests/GooseCrossStreamEventPolicyTests.cs create mode 100644 tests/ARSAS.Tests/IecNaturalReferenceOrderTests.cs diff --git a/MainWindow.GooseSubscriber.cs b/MainWindow.GooseSubscriber.cs index 0c78504a8..981d69b46 100644 --- a/MainWindow.GooseSubscriber.cs +++ b/MainWindow.GooseSubscriber.cs @@ -448,6 +448,10 @@ private void FlushGooseSubscriberUi() } row.Apply(snapshot); + // In follow-live mode the inspector shows current stream state + // between meaningful events; selecting history freezes the frame. + if (FollowLatestGooseEvents && _selectedGooseEvent?.StreamKey == snapshot.StreamKey) + SelectedGooseStream = row; processed++; } diff --git a/MainWindow.GooseTimeline.cs b/MainWindow.GooseTimeline.cs index 0acf1280a..d57a3cfde 100644 --- a/MainWindow.GooseTimeline.cs +++ b/MainWindow.GooseTimeline.cs @@ -19,6 +19,23 @@ public partial class MainWindow private readonly ConcurrentQueue<(GooseSubscriberFrameSnapshot Frame, GooseTimelineDecision Decision)> _pendingGooseTimeline = new(); private readonly Dictionary _lastGooseTimelineSignature = new(StringComparer.OrdinalIgnoreCase); private readonly object _gooseTimelineGate = new(); + private readonly GooseCrossStreamEventPolicy _gooseMirroredEvents = new(); + private bool _settingGooseLiveSelection; + private bool _followLatestGooseEvents = true; + public bool FollowLatestGooseEvents + { + get => _followLatestGooseEvents; + set + { + if (!Set(ref _followLatestGooseEvents, value)) return; + if (value && GooseEvents.Count > 0) + { + _settingGooseLiveSelection = true; + try { SelectedGooseEvent = GooseEvents[^1]; } + finally { _settingGooseLiveSelection = false; } + } + } + } private bool _showGooseRetransmissions; public bool ShowGooseRetransmissions { @@ -40,6 +57,8 @@ public GooseEventRow? SelectedGooseEvent { if (!Set(ref _selectedGooseEvent, value)) return; + if (!_settingGooseLiveSelection && value is not null) + FollowLatestGooseEvents = false; if (value is not null) { @@ -213,6 +232,18 @@ private void GooseTimeline_FrameReceived(GooseSubscriberFrameSnapshot frame) _lastGooseTimelineSignature.TryGetValue(frame.StreamKey, out var previous); decision = GooseTimelineEventPolicy.Evaluate(previous, fingerprint, ShowGooseRetransmissions); _lastGooseTimelineSignature[frame.StreamKey] = fingerprint; + if (decision.Include && decision.PayloadChanged && !decision.IsNew && + !ShowGooseRetransmissions) + { + var changes = frame.StreamEvent.GooseValues + .Where(v => v.IsChanged && v.SignalReference.EndsWith(".stVal",StringComparison.OrdinalIgnoreCase)) + .Select(v => new GooseProcessDelta(v.SignalReference,v.DisplayValue,v.IsMappedToScl)) + .ToArray(); + var identity = GooseCrossStreamEventPolicy.Identity( + frame.Frame.Source.ToString(),changes); + if (_gooseMirroredEvents.IsMirror(identity,frame.StreamKey,frame.CaptureTimestamp)) + return; + } } if (!decision.Include) return; _pendingGooseTimeline.Enqueue((frame, decision)); @@ -242,7 +273,12 @@ private void GooseTimelineUiFlushTimer_Tick(object? sender, EventArgs args) while (GooseEvents.Count > MaxGooseTimelineEvents) GooseEvents.RemoveAt(0); - SelectedGooseEvent ??= eventRow; + if (FollowLatestGooseEvents) + { + _settingGooseLiveSelection = true; + try { SelectedGooseEvent = eventRow; } + finally { _settingGooseLiveSelection = false; } + } processed++; } @@ -401,7 +437,13 @@ private void ResetGooseTimelineUi() } _lastGooseTimelineTimestamp.Clear(); _gooseTimelineStart = null; - lock (_gooseTimelineGate) _lastGooseTimelineSignature.Clear(); + lock (_gooseTimelineGate) + { + _lastGooseTimelineSignature.Clear(); + _gooseMirroredEvents.Reset(); + } + _followLatestGooseEvents = true; + Raise(nameof(FollowLatestGooseEvents)); _nextGooseHighlightExpiryCheckUtc = DateTimeOffset.MinValue; GooseEvents.Clear(); SelectedGooseEvent = null; diff --git a/MainWindow.LiveSignalSearch.cs b/MainWindow.LiveSignalSearch.cs index 8250a27af..fa437780d 100644 --- a/MainWindow.LiveSignalSearch.cs +++ b/MainWindow.LiveSignalSearch.cs @@ -7,6 +7,7 @@ using System.Windows.Input; using System.Windows.Media; using ArIED61850Tester.Models; +using ArIED61850Tester.Services; namespace ArIED61850Tester; @@ -191,6 +192,11 @@ private void AttachLiveSignalSearchSource() _liveSignalSearchView = source == null ? null : CollectionViewSource.GetDefaultView(source); + // Natural ordering is presentation-only. The underlying static DataSet + // descriptors and MMS/GOOSE FCDA order must remain untouched. + if (_liveSignalSearchView is ListCollectionView listView && + listView.SortDescriptions.Count == 0 && listView.CustomSort is null) + listView.CustomSort = IecNaturalLiveMonitorSort.Instance; ApplyLiveSignalSearch(); } diff --git a/Services/GooseCrossStreamEventPolicy.cs b/Services/GooseCrossStreamEventPolicy.cs new file mode 100644 index 000000000..1cc8930e3 --- /dev/null +++ b/Services/GooseCrossStreamEventPolicy.cs @@ -0,0 +1,52 @@ +namespace ArIED61850Tester.Services; + +public sealed record GooseProcessDelta(string Reference, string Value, bool IsVerified); + +/// +/// Coalesces only an identical, explicitly identified value transition reflected +/// by a second GOOSE stream from the same station within a short wire-time window. +/// Never affects capture counts, protocol state or source stream histories. +/// +public sealed class GooseCrossStreamEventPolicy +{ + private readonly Dictionary _recent = + new(StringComparer.OrdinalIgnoreCase); + + public static string? Identity(string sourceMac, IReadOnlyList changes) + { + if (changes.Count != 1 || !changes[0].IsVerified || + string.IsNullOrWhiteSpace(sourceMac)) return null; + var item = changes[0]; + var path = item.Reference.Trim().Replace('$','.'); + var slash = path.LastIndexOf('/'); + var pos = path.IndexOf(".ST.", slash+1, StringComparison.OrdinalIgnoreCase); + if (pos >= 0) path = path.Remove(pos,3); + if (!path.EndsWith(".stVal",StringComparison.OrdinalIgnoreCase) && + !path.EndsWith(".mag.f",StringComparison.OrdinalIgnoreCase)) + return null; + // Never assume an abbreviated LN name globally identifies a publisher. + if (path.LastIndexOf('/') < 1) return null; + return sourceMac+"|"+path.ToUpperInvariant()+"|"+item.Value; + } + + public bool IsMirror(string? identity,string streamKey,DateTimeOffset timestamp) + { + if (identity is null) return false; + if (_recent.TryGetValue(identity,out var seen) && + !seen.Stream.Equals(streamKey,StringComparison.OrdinalIgnoreCase) && + timestamp>=seen.Time && timestamp-seen.Time<=TimeSpan.FromMilliseconds(250)) + return true; + _recent[identity]=(streamKey,timestamp); + // Work is O(1) per accepted event; bounded occasional cleanup only. + if (_recent.Count>256) + { + foreach(var expired in _recent.Where(pair => + timestamp-pair.Value.Time>TimeSpan.FromSeconds(1)).Select(pair=>pair.Key).ToArray()) + _recent.Remove(expired); + if (_recent.Count>256) _recent.Clear(); + } + return false; + } + + public void Reset() => _recent.Clear(); +} diff --git a/Services/GooseTimelineEventPolicy.cs b/Services/GooseTimelineEventPolicy.cs index a6c7f2be6..deb2b4909 100644 --- a/Services/GooseTimelineEventPolicy.cs +++ b/Services/GooseTimelineEventPolicy.cs @@ -47,7 +47,15 @@ public static GooseTimelineDecision Evaluate(GooseTimelineSignature? previous, G current.Sequence.Contains("Normal", StringComparison.OrdinalIgnoreCase); var newAnomaly = !first && SequenceFamily(previous!.Sequence) != SequenceFamily(current.Sequence) && !normal && !string.IsNullOrWhiteSpace(current.Sequence); - return new GooseTimelineDecision(first || valueChanged || stateChanged || diagnosticChanged || newAnomaly || showRetransmissions, - first, valueChanged, stateChanged, diagnosticChanged, newAnomaly); + // Normal Test=true, changing TAL/gap and sequence classifications are + // packet diagnostics, not new process events. Only a newly introduced + // model identity/configuration fault is an independent warning row. + var newCriticalFinding = diagnosticChanged && + (current.Diagnostics.Contains("confRev", StringComparison.OrdinalIgnoreCase) || + current.Diagnostics.Contains("APPID differs", StringComparison.OrdinalIgnoreCase) || + current.Diagnostics.Contains("DataSet reference differs", StringComparison.OrdinalIgnoreCase)); + return new GooseTimelineDecision(first || valueChanged || stateChanged || + newCriticalFinding || showRetransmissions, + first, valueChanged, stateChanged, newCriticalFinding, newAnomaly); } } diff --git a/Services/GooseTypedValueInterpreter.cs b/Services/GooseTypedValueInterpreter.cs index f2023c9bb..51a818f4e 100644 --- a/Services/GooseTypedValueInterpreter.cs +++ b/Services/GooseTypedValueInterpreter.cs @@ -97,50 +97,102 @@ public static string RenderQuality(MmsDataValue value) } /// -/// Exact reference-identity engineering view of GOOSE allData. Raw wire -/// entries remain separately available and untouched, including orphan q. +/// Engineering readout only: preserves every original ordered GOOSE FCDA in +/// GooseStreamRow.Leaves. Pairs q only where its owner is unambiguous. /// public static class GooseCanonicalLeafProjection { public static IReadOnlyList Project(IReadOnlyList wireLeaves) { - var owners = new HashSet(StringComparer.OrdinalIgnoreCase); - var quality = new Dictionary>(StringComparer.OrdinalIgnoreCase); - foreach (var leaf in wireLeaves) + 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(); + + var pairs = new Dictionary(); + var pairedQualities = new HashSet(); + foreach (var value in wireLeaves) { - var key = CanonicalOwner(leaf.SignalReference, out var kind); - if (key is null) continue; - if (kind == "value") owners.Add(key); - else if (kind == "quality") + if (!IsValue(value)) 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 (candidate is null && exact.Length == 0 && IsModelBound(value)) { - if (!quality.TryGetValue(key, out var found)) - quality[key] = found = new List(1); - found.Add(leaf); + // 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 _))).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]; + } } + if (candidate is null || !pairedQualities.Add(candidate.DataSetIndex)) + continue; + pairs[value.DataSetIndex] = candidate; } var result = new List(wireLeaves.Count); foreach (var leaf in wireLeaves) { - var key = CanonicalOwner(leaf.SignalReference, out var kind); - quality.TryGetValue(key ?? string.Empty, out var companions); - var hasUniqueCompanion = key is not null && owners.Contains(key) && - companions is { Count: 1 }; - if (hasUniqueCompanion && kind == "quality") continue; - if (hasUniqueCompanion && kind == "value") + if (pairedQualities.Contains(leaf.DataSetIndex)) continue; + if (pairs.TryGetValue(leaf.DataSetIndex, out var quality)) result.Add(leaf with { SignalName = leaf.SignalName.EndsWith(".stVal", StringComparison.OrdinalIgnoreCase) ? leaf.SignalName[..^6] : leaf.SignalName, - Quality = companions![0].Value, - IsChanged = leaf.IsChanged || companions[0].IsChanged + Quality = quality.Value, + IsChanged = leaf.IsChanged || quality.IsChanged }); else result.Add(leaf); } return result; } - private static string? CanonicalOwner(string? source, out string kind) + private static bool IsModelBound(GooseLeafValueSnapshot leaf) + => !leaf.BindingSource.Equals("Unbound", StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrWhiteSpace(leaf.SignalReference); + + private static bool IsValue(GooseLeafValueSnapshot leaf) + => Owner(leaf.SignalReference, out var kind) is not null && kind == "value"; + + 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 CompatibleOwners(string? primary, string? quality) + { + 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); + } + + private static string? Owner(string? source, out string kind) { kind = string.Empty; if (string.IsNullOrWhiteSpace(source)) return null; diff --git a/Services/IecNaturalReferenceOrder.cs b/Services/IecNaturalReferenceOrder.cs new file mode 100644 index 000000000..7e5ce03d4 --- /dev/null +++ b/Services/IecNaturalReferenceOrder.cs @@ -0,0 +1,59 @@ +using System.Collections; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester.Services; + +/// Numeric-aware comparison of IEC telegram text for operator grids only. +public static class IecNaturalReferenceOrder +{ + public static int Compare(string? left, string? right) + { + left ??= string.Empty; + right ??= string.Empty; + var i = 0; + var j = 0; + while (i < left.Length && j < right.Length) + { + if (char.IsAsciiDigit(left[i]) && char.IsAsciiDigit(right[j])) + { + var startI = i; + var startJ = j; + while (i < left.Length && left[i] == '0') i++; + while (j < right.Length && right[j] == '0') j++; + var digitsI = i; + var digitsJ = j; + while (i < left.Length && char.IsAsciiDigit(left[i])) i++; + while (j < right.Length && char.IsAsciiDigit(right[j])) j++; + var numericI = i - digitsI; + var numericJ = j - digitsJ; + if (numericI != numericJ) return numericI.CompareTo(numericJ); + var byDigits = string.Compare(left, digitsI, right, digitsJ, numericI, StringComparison.Ordinal); + if (byDigits != 0) return byDigits; + // Same integer: fewer leading zeros first, deterministic and stable. + if (i - startI != j - startJ) return (i - startI).CompareTo(j - startJ); + continue; + } + var a = char.ToUpperInvariant(left[i++]); + var b = char.ToUpperInvariant(right[j++]); + if (a != b) return a.CompareTo(b); + } + return left.Length == i && right.Length == j ? 0 : left.Length == i ? -1 : 1; + } +} + +public sealed class IecNaturalLiveMonitorSort : IComparer +{ + public static IecNaturalLiveMonitorSort Instance { get; } = new(); + + public int Compare(object? a, object? b) + { + if (ReferenceEquals(a,b)) return 0; + if (a is not Iec61850MonitorPoint first || b is not Iec61850MonitorPoint second) + return Comparer.DefaultInvariant.Compare(a,b); + var result = IecNaturalReferenceOrder.Compare(first.IecTelegram,second.IecTelegram); + if (result != 0) return result; + result = IecNaturalReferenceOrder.Compare(first.DeviceName,second.DeviceName); + return result != 0 ? result : + IecNaturalReferenceOrder.Compare(first.IecReference,second.IecReference); + } +} diff --git a/Views/GooseSubscriberLiteView.xaml b/Views/GooseSubscriberLiteView.xaml index 50f05c81d..f27e2e488 100644 --- a/Views/GooseSubscriberLiteView.xaml +++ b/Views/GooseSubscriberLiteView.xaml @@ -300,11 +300,18 @@ FontWeight="SemiBold" Foreground="#45648E"/> - + + + + diff --git a/tests/ARSAS.Tests/GooseCanonicalEngineeringTests.cs b/tests/ARSAS.Tests/GooseCanonicalEngineeringTests.cs index 0476a0967..48f38b530 100644 --- a/tests/ARSAS.Tests/GooseCanonicalEngineeringTests.cs +++ b/tests/ARSAS.Tests/GooseCanonicalEngineeringTests.cs @@ -71,6 +71,41 @@ static GooseLeafValueSnapshot Leaf(int n,string reference,string value) Assert.Equal("Invalid",result[2].Value); } + [Fact] + public void OrderedBoundAdjacentQuality_DoesNotRemainAsDuplicateRow() + { + static GooseLeafValueSnapshot Leaf(int n,string name,string reference,string type,string value) + => new(n,n-1,name,reference,"ST","DPC",type,value,"",false,"SCL"); + // A producer's valid FCDA may mix full and abbreviated reference forms. + var source = new[] { + Leaf(1,"CSWI6.Pos.stVal","BCUGEF650/CSWI6.Pos.stVal","Dbpos","Off"), + Leaf(2,"CSWI6.Pos.q","CSWI6.Pos.q","Quality","Good"), + Leaf(3,"CSWI7.Pos.stVal","BCUGEF650/CSWI7.Pos.stVal","Dbpos","Intermediate"), + Leaf(4,"CSWI7.Pos.q","CSWI7.Pos.q","Quality","Questionable") + }; + var projected = GooseCanonicalLeafProjection.Project(source); + Assert.Equal(4,source.Length); + Assert.Equal(2,projected.Count); + Assert.Equal("Good",projected[0].Quality); + Assert.Equal("Questionable",projected[1].Quality); + Assert.Equal(0,projected[0].DataSetIndex); + Assert.Equal(2,projected[1].DataSetIndex); + } + + [Fact] + public void DifferentExplicitLogicalDevicesCannotTransferQuality() + { + static GooseLeafValueSnapshot Leaf(int n,string name,string path,string type,string value) + => new(n,n-1,name,path,"ST","DPC",type,value,"",false,"SCL"); + var leaves=new[]{ + Leaf(1,"CSWI6.Pos.stVal","LDA/CSWI6.Pos.stVal","Dbpos","Off"), + Leaf(2,"CSWI6.Pos.q","LDB/CSWI6.Pos.q","Quality","Good") + }; + var projected=GooseCanonicalLeafProjection.Project(leaves); + Assert.Equal(2,projected.Count); + Assert.Equal("—",projected[0].Quality); + } + [Fact] public void PreviousTypedQualityAndDbpos_AreReadableInChangeSummary() { diff --git a/tests/ARSAS.Tests/GooseCrossStreamEventPolicyTests.cs b/tests/ARSAS.Tests/GooseCrossStreamEventPolicyTests.cs new file mode 100644 index 000000000..09e264bad --- /dev/null +++ b/tests/ARSAS.Tests/GooseCrossStreamEventPolicyTests.cs @@ -0,0 +1,68 @@ +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class GooseCrossStreamEventPolicyTests +{ + [Fact] + public void SameTypedValueOnTwoDistinctStreamsWithin250ms_IsOneOperatorChange() + { + var key=GooseCrossStreamEventPolicy.Identity("02:00:4C:4F:4F:50",new[]{ + new GooseProcessDelta("BCUGEF650/CSWI6.Pos.stVal","Off",true) + }); + Assert.NotNull(key); + var policy=new GooseCrossStreamEventPolicy(); + var t=DateTimeOffset.Parse("2026-10-10T09:13:33.970+00:00"); + Assert.False(policy.IsMirror(key,"GOOSE1",t)); + Assert.True(policy.IsMirror(key,"GOOSE2",t.AddMilliseconds(3))); + Assert.False(policy.IsMirror(key,"GOOSE1",t.AddMilliseconds(6))); + Assert.False(policy.IsMirror(key,"GOOSE2",t.AddSeconds(3))); + } + + [Fact] + public void NeverMergeUnverifiedNamesOrIndependentProcessChanges() + { + Assert.Null(GooseCrossStreamEventPolicy.Identity("00:11",new[]{ + new GooseProcessDelta("CSWI6.Pos.stVal","Off",true) + })); + Assert.Null(GooseCrossStreamEventPolicy.Identity("00:11",new[]{ + new GooseProcessDelta("LD/CSWI6.Pos.stVal","Off",false) + })); + Assert.Null(GooseCrossStreamEventPolicy.Identity("00:11",new[]{ + new GooseProcessDelta("LD/CSWI6.Pos.stVal","Off",true), + new GooseProcessDelta("LD/CSWI7.Pos.stVal","On",true) + })); + var k1=GooseCrossStreamEventPolicy.Identity("00:11",new[]{ + new GooseProcessDelta("LD/CSWI6.Pos.stVal","Off",true) + }); + var k2=GooseCrossStreamEventPolicy.Identity("00:11",new[]{ + new GooseProcessDelta("LD/CSWI7.Pos.stVal","Off",true) + }); + Assert.NotEqual(k1,k2); + } + + [Fact] + public void FollowLatestAndRawRetransmissionControlsAreIndependent() + { + var file=Read("MainWindow.GooseTimeline.cs"); + var view=Read("Views/GooseSubscriberLiteView.xaml"); + Assert.Contains("FollowLatestGooseEvents",view); + Assert.Contains("ShowGooseRetransmissions",view); + Assert.Contains("_settingGooseLiveSelection",file); + Assert.Contains("_gooseMirroredEvents.IsMirror",file); + Assert.Contains("_gooseMirroredEvents.Reset()",file); + Assert.Contains("SelectedGooseEvent = eventRow",file); + } + + private static string Read(string path) + { + DirectoryInfo? dir=new(AppContext.BaseDirectory); + while(dir is not null) + { + var name=Path.Combine(dir.FullName,path.Replace('/',Path.DirectorySeparatorChar)); + if(File.Exists(name))return File.ReadAllText(name); + dir=dir.Parent; + } + throw new FileNotFoundException(path); + } +} diff --git a/tests/ARSAS.Tests/GooseTimelineEventPolicyTests.cs b/tests/ARSAS.Tests/GooseTimelineEventPolicyTests.cs index bb6b31a2f..cc29a2595 100644 --- a/tests/ARSAS.Tests/GooseTimelineEventPolicyTests.cs +++ b/tests/ARSAS.Tests/GooseTimelineEventPolicyTests.cs @@ -31,6 +31,8 @@ public void ChangedStateAndNewDiagnostic_AreVisible() var first = new GooseTimelineSignature("intermediate", "1", "", "Normal"); Assert.True(GooseTimelineEventPolicy.Evaluate(first, first with { State = "2" }, false).Include); Assert.True(GooseTimelineEventPolicy.Evaluate(first, first with { Diagnostics = "Bad confRev" }, false).Include); + Assert.False(GooseTimelineEventPolicy.Evaluate(first, + first with { Diagnostics = "GOOSE test flag is set." },false).Include); } [Fact] diff --git a/tests/ARSAS.Tests/IecNaturalReferenceOrderTests.cs b/tests/ARSAS.Tests/IecNaturalReferenceOrderTests.cs new file mode 100644 index 000000000..9b98866d4 --- /dev/null +++ b/tests/ARSAS.Tests/IecNaturalReferenceOrderTests.cs @@ -0,0 +1,43 @@ +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class IecNaturalReferenceOrderTests +{ + [Theory] + [InlineData("F650/CSWI1.Pos.stVal","F650/CSWI2.Pos.stVal")] + [InlineData("F650/CSWI2.Pos.stVal","F650/CSWI10.Pos.stVal")] + [InlineData("BCU/CSWI9.Pos","BCU/CSWI12.Pos")] + [InlineData("IED/evt9","IED/evt100")] + [InlineData("F650/CSWI2.Pos","F650/CSWI02.Pos")] + public void SortsNumericLogicalNodeInstanceNaturally(string first,string second) + => Assert.True(IecNaturalReferenceOrder.Compare(first,second)<0); + + [Fact] + public void LongNumericSuffixesAreComparedWithoutOverflow() + { + Assert.True(IecNaturalReferenceOrder.Compare("CSWI999999999999999999","CSWI1000000000000000000")<0); + Assert.Equal(0,IecNaturalReferenceOrder.Compare("cswi1","CSWI1")); + } + + [Fact] + public void PresenterSortsViewOnlyAndKeepsStaticDataSetWireOrder() + { + var source=Read("MainWindow.LiveSignalSearch.cs"); + Assert.Contains("listView.CustomSort = IecNaturalLiveMonitorSort.Instance;",source); + Assert.Contains("CollectionViewSource.GetDefaultView(source)",source); + Assert.DoesNotContain("LivePoints.Sort",source); + } + + 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); + } +}