From f2d072513aa71e18a614528164aac60bd4c767a3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 10 Oct 2026 17:02:38 +0700 Subject: [PATCH 1/3] fix(goose ui): wire up native IEC sort and expose decoded GOOSE metadata for operators --- MainWindow.GooseSubscriber.cs | 5 +- MainWindow.GooseTimeline.cs | 2 + MainWindow.IecNaturalSort.cs | 41 +++++ MainWindow.xaml | 1 + Models/GoosePresentationModels.cs | 18 ++ Models/GooseSubscriberModels.cs | 15 +- Services/GooseTypedValueInterpreter.cs | 32 ++++ Views/GooseSubscriberLiteView.xaml | 158 ++++++++++++------ .../GooseCanonicalEngineeringTests.cs | 18 ++ .../GooseNoviceInspectorRegressionTests.cs | 77 +++++++++ ...ativeExplorerNaturalSortRegressionTests.cs | 39 +++++ 11 files changed, 351 insertions(+), 55 deletions(-) create mode 100644 MainWindow.IecNaturalSort.cs create mode 100644 tests/ARSAS.Tests/GooseNoviceInspectorRegressionTests.cs create mode 100644 tests/ARSAS.Tests/NativeExplorerNaturalSortRegressionTests.cs diff --git a/MainWindow.GooseSubscriber.cs b/MainWindow.GooseSubscriber.cs index 981d69b46..844f7476b 100644 --- a/MainWindow.GooseSubscriber.cs +++ b/MainWindow.GooseSubscriber.cs @@ -796,7 +796,10 @@ private static GooseStreamSnapshot BuildGooseStreamSnapshot( streamEvent.ChangedValueCount, frame.Pdu.Test, frame.Pdu.NeedsCommissioning, - leaves); + leaves, + frame.Vlan is { } tagged ? tagged.VlanId.ToString(CultureInfo.InvariantCulture) : "Untagged", + frame.Vlan is { } priority ? priority.PriorityCodePoint.ToString(CultureInfo.InvariantCulture) : "—", + rawValueCount); } private static string FirstNonEmpty(params string?[] values) diff --git a/MainWindow.GooseTimeline.cs b/MainWindow.GooseTimeline.cs index d57a3cfde..61e0a6ba8 100644 --- a/MainWindow.GooseTimeline.cs +++ b/MainWindow.GooseTimeline.cs @@ -324,6 +324,8 @@ private GooseEventRow BuildGooseEventRow(GooseSubscriberFrameSnapshot captured, EventText = eventText, EventTone = tone, Publisher = BuildGoosePublisherName(stream), + IedName = string.IsNullOrWhiteSpace(stream.ModelIedName) ? "Unresolved" : stream.ModelIedName, + GooseId = string.IsNullOrWhiteSpace(stream.GoId) ? "Not provided" : stream.GoId, StateSequenceText = $"{stream.StateNumberText} / {stream.SequenceNumberText}", SourceMac = stream.SourceMac, DestinationMac = stream.DestinationMac, diff --git a/MainWindow.IecNaturalSort.cs b/MainWindow.IecNaturalSort.cs new file mode 100644 index 000000000..90226b39d --- /dev/null +++ b/MainWindow.IecNaturalSort.cs @@ -0,0 +1,41 @@ +using System.ComponentModel; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; +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. +/// +public partial class MainWindow +{ + private readonly HashSet _iecNaturallySortedGrids = new(); + + private void ExplorerLiveGrid_Loaded(object sender, RoutedEventArgs e) + { + if (sender is not DataGrid grid) return; + if (_iecNaturallySortedGrids.Add(grid)) + { + var descriptor = DependencyPropertyDescriptor.FromProperty( + ItemsControl.ItemsSourceProperty, typeof(DataGrid)); + descriptor?.AddValueChanged(grid, (_, _) => + grid.Dispatcher.BeginInvoke(new Action(() => ApplyExplorerNaturalSort(grid)))); + } + ApplyExplorerNaturalSort(grid); + } + + private static void ApplyExplorerNaturalSort(DataGrid grid) + { + if (grid.ItemsSource is null) return; + if (CollectionViewSource.GetDefaultView(grid.ItemsSource) is not ListCollectionView view) + return; + if (ReferenceEquals(view.CustomSort, IecNaturalLiveMonitorSort.Instance)) + return; + view.SortDescriptions.Clear(); + view.CustomSort = IecNaturalLiveMonitorSort.Instance; + } +} diff --git a/MainWindow.xaml b/MainWindow.xaml index f3acaadf5..2a1a37657 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -662,6 +662,7 @@ string.IsNullOrWhiteSpace(ModelIedName) ? "Not resolved" : ModelIedName; public string GooseIdDisplay => string.IsNullOrWhiteSpace(GoId) ? "Not provided" : GoId; + public string ApplicationIdDecimal + { + get + { + var raw = AppIdText.Trim(); + return raw.StartsWith("0x", StringComparison.OrdinalIgnoreCase) && + ushort.TryParse(raw.AsSpan(2), NumberStyles.HexNumber, + CultureInfo.InvariantCulture, out var value) + ? value.ToString(CultureInfo.InvariantCulture) : raw; + } + } + public string SimulationTestText => Test ? "True" : "False"; + public string DataSetEntryCountText => WireDataSetEntryCount.ToString(CultureInfo.InvariantCulture); public string DataSetShortName => string.IsNullOrWhiteSpace(DataSetReference) ? "Not provided" : ShortReference(DataSetReference); public string GoCbRefShortName => string.IsNullOrWhiteSpace(GoCbRef) ? "Not provided" : ShortReference(GoCbRef); public string StateSequenceText => $"{StateNumberText} / {SequenceNumberText}"; @@ -36,6 +49,9 @@ private void RaisePresentationProperties() Raise(nameof(DisplayName)); Raise(nameof(DisplaySecondary)); Raise(nameof(ModelIedDisplay)); + Raise(nameof(ApplicationIdDecimal)); + Raise(nameof(SimulationTestText)); + Raise(nameof(DataSetEntryCountText)); Raise(nameof(GooseIdDisplay)); Raise(nameof(DataSetShortName)); Raise(nameof(GoCbRefShortName)); @@ -78,6 +94,8 @@ public sealed class GooseEventRow : ObservableObject public string DestinationMac { get; init; } = "—"; public string DataSetName { get; init; } = "—"; public string RelativeTime { get; init; } = "—"; + public string IedName { get; init; } = "Unresolved"; + public string GooseId { get; init; } = "Not provided"; public GooseStreamSnapshot? Snapshot { get; init; } public bool IsRecent { get => _isRecent; private set => Set(ref _isRecent, value); } diff --git a/Models/GooseSubscriberModels.cs b/Models/GooseSubscriberModels.cs index 2d3d55ea2..6f2d64b09 100644 --- a/Models/GooseSubscriberModels.cs +++ b/Models/GooseSubscriberModels.cs @@ -104,6 +104,9 @@ public sealed partial class GooseStreamRow : ObservableObject private int _changedValueCount; private bool _test; private bool _needsCommissioning; + private string _vlanId = "Untagged"; + private string _vlanPriority = "—"; + private int _wireDataSetEntryCount; public string StreamKey { get; init; } = string.Empty; public ObservableCollection Leaves { get; } = new(); @@ -130,6 +133,9 @@ public sealed partial class GooseStreamRow : ObservableObject public int ChangedValueCount { get => _changedValueCount; set => Set(ref _changedValueCount, Math.Max(0, value)); } public bool Test { get => _test; set => Set(ref _test, value); } public bool NeedsCommissioning { get => _needsCommissioning; set => Set(ref _needsCommissioning, value); } + public string VlanId { get => _vlanId; private set => Set(ref _vlanId, value); } + public string VlanPriority { get => _vlanPriority; private set => Set(ref _vlanPriority, value); } + public int WireDataSetEntryCount { get => _wireDataSetEntryCount; private set => Set(ref _wireDataSetEntryCount, value); } public string IdentityText => !string.IsNullOrWhiteSpace(GoCbRef) ? GoCbRef : (!string.IsNullOrWhiteSpace(GoId) ? GoId : AppIdText); public string FlagsText => Test && NeedsCommissioning ? "TEST • ndsCom" : Test ? "TEST" : NeedsCommissioning ? "ndsCom" : "Normal"; @@ -144,6 +150,10 @@ public void Apply(GooseStreamSnapshot snapshot) SourceMac = snapshot.SourceMac; DestinationMac = snapshot.DestinationMac; VlanText = snapshot.VlanText; + VlanId = snapshot.VlanId; + VlanPriority = snapshot.VlanPriority; + WireDataSetEntryCount = snapshot.WireDataSetEntryCount == 0 + ? snapshot.Leaves.Count : snapshot.WireDataSetEntryCount; StateNumberText = snapshot.StateNumberText; SequenceNumberText = snapshot.SequenceNumberText; SequenceStatus = snapshot.SequenceStatus; @@ -252,4 +262,7 @@ public sealed record GooseStreamSnapshot( int ChangedValueCount, bool Test, bool NeedsCommissioning, - IReadOnlyList Leaves); + IReadOnlyList Leaves, + string VlanId = "Untagged", + string VlanPriority = "—", + int WireDataSetEntryCount = 0); diff --git a/Services/GooseTypedValueInterpreter.cs b/Services/GooseTypedValueInterpreter.cs index 51a818f4e..72f0b808a 100644 --- a/Services/GooseTypedValueInterpreter.cs +++ b/Services/GooseTypedValueInterpreter.cs @@ -144,6 +144,29 @@ public static IReadOnlyList Project(IReadOnlyList + 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 _))).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; @@ -166,6 +189,15 @@ public static IReadOnlyList Project(IReadOnlyList + 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); diff --git a/Views/GooseSubscriberLiteView.xaml b/Views/GooseSubscriberLiteView.xaml index f27e2e488..065ba6d96 100644 --- a/Views/GooseSubscriberLiteView.xaml +++ b/Views/GooseSubscriberLiteView.xaml @@ -354,9 +354,10 @@ - - - + + + + - - - - - + + @@ -462,24 +460,33 @@ - + - - + + + + - - - - - - - - + + + + + + + + @@ -490,22 +497,39 @@ - + - - + + + + + - - - - - - - - + + + + + + + + + + @@ -516,23 +540,51 @@ - + - - + + + + + + + - - - - - - - - + + + + + + + + + + + + + + diff --git a/tests/ARSAS.Tests/GooseCanonicalEngineeringTests.cs b/tests/ARSAS.Tests/GooseCanonicalEngineeringTests.cs index 48f38b530..b21ebf1db 100644 --- a/tests/ARSAS.Tests/GooseCanonicalEngineeringTests.cs +++ b/tests/ARSAS.Tests/GooseCanonicalEngineeringTests.cs @@ -106,6 +106,24 @@ static GooseLeafValueSnapshot Leaf(int n,string name,string path,string type,str Assert.Equal("—",projected[0].Quality); } + [Fact] + public void F650WireDataSet_RendersQInQualityColumn_WithoutReorderingRawMembers() + { + static GooseLeafValueSnapshot Leaf(int pos,string name,string reference,string type,string value) + => new(pos,pos-1,name,reference,"ST","DPC",type,value,"",false,"SCL"); + var wire = 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","Good") + }; + var view = GooseCanonicalLeafProjection.Project(wire); + Assert.Equal(4, wire.Length); + Assert.Equal(2, view.Count); + Assert.All(view, leaf => Assert.Equal("Good",leaf.Quality)); + Assert.Equal(new[]{0,2},view.Select(leaf=>leaf.DataSetIndex).ToArray()); + } + [Fact] public void PreviousTypedQualityAndDbpos_AreReadableInChangeSummary() { diff --git a/tests/ARSAS.Tests/GooseNoviceInspectorRegressionTests.cs b/tests/ARSAS.Tests/GooseNoviceInspectorRegressionTests.cs new file mode 100644 index 000000000..17bab657f --- /dev/null +++ b/tests/ARSAS.Tests/GooseNoviceInspectorRegressionTests.cs @@ -0,0 +1,77 @@ +using System.Globalization; +using System.Xml.Linq; +using ArIED61850Tester.Models; + +namespace ARSAS.Tests; + +public sealed class GooseNoviceInspectorRegressionTests +{ + [Fact] + public void ApplicationId_IsDerivedFromRealWireValue_NotHardcoded() + { + var row = new GooseStreamRow { AppIdText = "0x002C" }; + Assert.Equal("44", row.ApplicationIdDecimal); + row.AppIdText = "0x0021"; + Assert.Equal("33", row.ApplicationIdDecimal); + } + + [Fact] + public void Inspector_UsesRealWireVlanAndDatasetEntryCount() + { + var row = new GooseStreamRow { StreamKey="G1" }; + var snapshot = new GooseStreamSnapshot( + StreamKey:"G1",AppIdText:"0x002C",GoCbRef:"LD/LLN0$GO$gcb", + GoId:"BCUGE03",DataSetReference:"LD/LLN0.GOOSE1", + SourceMac:"00:11:22:33:44:55",DestinationMac:"01:0C:CD:01:00:33", + VlanText:"VID 0 / PCP 4",StateNumberText:"2",SequenceNumberText:"185", + SequenceStatus:"Retransmission",TimeAllowedToLiveText:"2000 ms", + ConfigurationRevisionText:"1",ModelIedName:"BCUGE",BindingSource:"SCL", + DiagnosticsSummary:"",LastSeenText:"16:00:00.000",PacketCount:14, + ChangedValueCount:0,Test:true,NeedsCommissioning:false, + Leaves:Array.Empty(),VlanId:"0",VlanPriority:"4", + WireDataSetEntryCount:4); + row.Apply(snapshot); + Assert.Equal("44",row.ApplicationIdDecimal); + Assert.Equal("0",row.VlanId); + Assert.Equal("4",row.VlanPriority); + Assert.Equal("2",row.StateNumberText); + Assert.Equal("185",row.SequenceNumberText); + Assert.Equal("True",row.SimulationTestText); + Assert.Equal("4",row.DataSetEntryCountText); + } + + [Fact] + public void ActualGooseView_UsesNoviceMetadataLabelsAndTimelineColumns() + { + var view=Read("Views/GooseSubscriberLiteView.xaml"); + var xml=XDocument.Parse(view); + XNamespace wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation"; + var headers=xml.Descendants(wpf+"DataGridTextColumn") + .Select(x=>(string?)x.Attribute("Header")).ToArray(); + Assert.Contains("IED",headers); + Assert.Contains("GOOSE ID",headers); + var time=xml.Descendants(wpf+"DataGridTextColumn") + .Single(x=>(string?)x.Attribute("Header")=="Time"); + Assert.True(int.Parse((string)time.Attribute("Width")!,CultureInfo.InvariantCulture)>=110); + foreach(var label in new[]{"Application ID","VLAN ID","VLAN priority", + "Time allowed to live","Status number","Sequence number", + "Configuration revision","Simulation test","Number of DataSet entries"}) + Assert.Contains($"Text=\"{label}\"",view); + Assert.Contains("SelectedGooseStream.ApplicationIdDecimal",view); + Assert.Contains("WireDataSetEntryCount",Read("MainWindow.GooseSubscriber.cs")); + Assert.Contains("IedName = ",Read("MainWindow.GooseTimeline.cs")); + Assert.Contains("GooseId = ",Read("MainWindow.GooseTimeline.cs")); + } + + private static string Read(string path) + { + DirectoryInfo? d=new(AppContext.BaseDirectory); + while(d!=null) + { + var file=Path.Combine(d.FullName,path); + if(File.Exists(file))return File.ReadAllText(file); + d=d.Parent; + } + throw new FileNotFoundException(path); + } +} diff --git a/tests/ARSAS.Tests/NativeExplorerNaturalSortRegressionTests.cs b/tests/ARSAS.Tests/NativeExplorerNaturalSortRegressionTests.cs new file mode 100644 index 000000000..96d10546c --- /dev/null +++ b/tests/ARSAS.Tests/NativeExplorerNaturalSortRegressionTests.cs @@ -0,0 +1,39 @@ +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class NativeExplorerNaturalSortRegressionTests +{ + [Fact] + public void RealSevenColumnExplorerGrid_AttachesTheNaturalSortHandler() + { + var xaml=Read("MainWindow.xaml"); + 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("view.SortDescriptions.Clear()",source); + } + + [Fact] + public void CorrectOrderingIncludesDoubleDigitInstances() + { + var source=new[]{"F650/CSWI11.Pos.stVal","F650/CSWI2.Pos.stVal", + "F650/CSWI1.Pos.stVal","F650/CSWI10.Pos.stVal"}; + var sorted=source.OrderBy(x=>x,Comparer.Create(IecNaturalReferenceOrder.Compare)).ToArray(); + Assert.Equal(new[]{"F650/CSWI1.Pos.stVal","F650/CSWI2.Pos.stVal", + "F650/CSWI10.Pos.stVal","F650/CSWI11.Pos.stVal"},sorted); + } + + private static string Read(string path) + { + DirectoryInfo? d=new(AppContext.BaseDirectory); + while(d!=null) + { + var f=Path.Combine(d.FullName,path); + if(File.Exists(f))return File.ReadAllText(f); + d=d.Parent; + } + throw new FileNotFoundException(path); + } +} From 12df86fb5e9f301dd3d7291afdef365fff5c4eee Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 10 Oct 2026 17:07:06 +0700 Subject: [PATCH 2/3] fix(tests,goose): retain hidden engineering columns and project verified DO-scoped q --- Services/GooseTypedValueInterpreter.cs | 90 ++++++++++++++++++- Views/GooseSubscriberLiteView.xaml | 4 + .../GooseCanonicalEngineeringTests.cs | 18 ++++ .../GooseNoviceInspectorRegressionTests.cs | 3 +- 4 files changed, 111 insertions(+), 4 deletions(-) diff --git a/Services/GooseTypedValueInterpreter.cs b/Services/GooseTypedValueInterpreter.cs index 72f0b808a..de16ceb7e 100644 --- a/Services/GooseTypedValueInterpreter.cs +++ b/Services/GooseTypedValueInterpreter.cs @@ -113,7 +113,9 @@ public static IReadOnlyList Project(IReadOnlyList(); foreach (var value in wireLeaves) { - if (!IsValue(value)) continue; + 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(); @@ -136,7 +138,9 @@ public static IReadOnlyList Project(IReadOnlyList IsValue(row) && @@ -162,7 +166,9 @@ public static IReadOnlyList Project(IReadOnlyList row.SignalName.Equals(value.SignalName, StringComparison.OrdinalIgnoreCase)) == 1) candidate = neighbors[0]; @@ -189,6 +195,84 @@ public static IReadOnlyList Project(IReadOnlyList !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; + 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 slash=key.LastIndexOf('/'); + var dot=key.IndexOf('.',slash+1); + if(dot>=0 && dot+4 value.Equals("Good", StringComparison.OrdinalIgnoreCase) || value.StartsWith("Good ·", StringComparison.OrdinalIgnoreCase) || diff --git a/Views/GooseSubscriberLiteView.xaml b/Views/GooseSubscriberLiteView.xaml index 065ba6d96..1c5c708b9 100644 --- a/Views/GooseSubscriberLiteView.xaml +++ b/Views/GooseSubscriberLiteView.xaml @@ -382,6 +382,10 @@ + + + + diff --git a/tests/ARSAS.Tests/GooseCanonicalEngineeringTests.cs b/tests/ARSAS.Tests/GooseCanonicalEngineeringTests.cs index b21ebf1db..ff032c54d 100644 --- a/tests/ARSAS.Tests/GooseCanonicalEngineeringTests.cs +++ b/tests/ARSAS.Tests/GooseCanonicalEngineeringTests.cs @@ -92,6 +92,24 @@ static GooseLeafValueSnapshot Leaf(int n,string name,string reference,string typ Assert.Equal(2,projected[1].DataSetIndex); } + [Fact] + public void SameSclDoScopedReferencePlusAdjacentTypedQuality_PairsWithoutGuessing() + { + static GooseLeafValueSnapshot Leaf(int order,string name,string reference,string type,string value) + => new(order,order-1,name,reference,"ST","DPC",type,value,"",false,"SCL"); + var wire=new[]{ + Leaf(1,"CSWI6.Pos.stVal","BCUGEF650/CSWI6.Pos","Dbpos","Off"), + Leaf(2,"CSWI6.Pos.q","BCUGEF650/CSWI6.Pos","Quality","Good"), + Leaf(3,"CSWI7.Pos.stVal","BCUGEF650/CSWI7.Pos","Dbpos","Intermediate"), + Leaf(4,"CSWI7.Pos.q","BCUGEF650/CSWI7.Pos","Quality","Questionable") + }; + var view=GooseCanonicalLeafProjection.Project(wire); + Assert.Equal(2,view.Count); + Assert.Equal("Good",view[0].Quality); + Assert.Equal("Questionable",view[1].Quality); + Assert.Equal(4,wire.Length); + } + [Fact] public void DifferentExplicitLogicalDevicesCannotTransferQuality() { diff --git a/tests/ARSAS.Tests/GooseNoviceInspectorRegressionTests.cs b/tests/ARSAS.Tests/GooseNoviceInspectorRegressionTests.cs index 17bab657f..9c1eb35e3 100644 --- a/tests/ARSAS.Tests/GooseNoviceInspectorRegressionTests.cs +++ b/tests/ARSAS.Tests/GooseNoviceInspectorRegressionTests.cs @@ -58,7 +58,8 @@ public void ActualGooseView_UsesNoviceMetadataLabelsAndTimelineColumns() "Configuration revision","Simulation test","Number of DataSet entries"}) Assert.Contains($"Text=\"{label}\"",view); Assert.Contains("SelectedGooseStream.ApplicationIdDecimal",view); - Assert.Contains("WireDataSetEntryCount",Read("MainWindow.GooseSubscriber.cs")); + Assert.Contains("WireDataSetEntryCount",Read("Models/GooseSubscriberModels.cs")); + Assert.Contains("rawValueCount);",Read("MainWindow.GooseSubscriber.cs")); Assert.Contains("IedName = ",Read("MainWindow.GooseTimeline.cs")); Assert.Contains("GooseId = ",Read("MainWindow.GooseTimeline.cs")); } From bccf18e33cf68289e26cdeddaae0daf203750f96 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 10 Oct 2026 17:09:34 +0700 Subject: [PATCH 3/3] fix(goose): restore valid verified quality projection source and preserve DO-scoped pairing --- Services/GooseTypedValueInterpreter.cs | 71 ++------------------------ 1 file changed, 4 insertions(+), 67 deletions(-) diff --git a/Services/GooseTypedValueInterpreter.cs b/Services/GooseTypedValueInterpreter.cs index de16ceb7e..2a5b73c7d 100644 --- a/Services/GooseTypedValueInterpreter.cs +++ b/Services/GooseTypedValueInterpreter.cs @@ -198,78 +198,15 @@ public static IReadOnlyList Project(IReadOnlyList !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; - var key = source.Trim().Replace('$', '.'); + 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); - 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 slash=key.LastIndexOf('/'); - var dot=key.IndexOf('.',slash+1); - if(dot>=0 && dot+4