diff --git a/Services/LiveDiscoverySignalSelectionPolicy.cs b/Services/LiveDiscoverySignalSelectionPolicy.cs new file mode 100644 index 000000000..cf3e02b70 --- /dev/null +++ b/Services/LiveDiscoverySignalSelectionPolicy.cs @@ -0,0 +1,66 @@ +using ArIED61850Tester.Models; + +namespace ArIED61850Tester.Services; + +/// +/// Signal Selection policy for broad online MMS discovery. +/// +/// Online GetNameList/VAA discovery is intentionally exhaustive. It contains process +/// values together with quality/timestamp companions, nameplate/configuration attributes, +/// substitution state, control-service structure and other engineering leaves. Those +/// objects remain available in the typed live model, but they are not independent operator +/// points and must not leak into Signal Selection. +/// +/// Static DataSet membership is authoritative and therefore wins over this presentation +/// noise classifier: an object explicitly configured in a DataSet remains visible even if +/// its object-level FCD identity is not an exact runtime value leaf. +/// +public static class LiveDiscoverySignalSelectionPolicy +{ + private static readonly HashSet NoiseSegments = new(StringComparer.OrdinalIgnoreCase) + { + "mod", "beh", "health", "eehealth", "namplt", + "q", "t", "tm", "d", "du", + "origin", "orcat", "orident", + "ctlmodel", "ctlval", "ctlnum", "stseld", + "sbo", "sbow", "oper", "cancel", "check", "test", + "datans", "subena", "subval", "subq", "subid", "blkena", + "configrev", "vendor", "swrev", "lnns", "numpts", "olddata" + }; + + public static bool IsVisible(SignalDefinition? signal) + { + if (signal is null || string.IsNullOrWhiteSpace(signal.ObjectReference)) + return false; + + if (!string.IsNullOrWhiteSpace(signal.DataSetReference)) + return true; + + if (IsProtocolOrEngineeringNoise(signal.ObjectReference)) + return false; + + return SasOperationalSignalPolicy.IsVisible(signal); + } + + public static bool IsProtocolOrEngineeringNoise(string? reference) + { + if (string.IsNullOrWhiteSpace(reference)) + return true; + + var normalized = reference.Trim().Replace('$', '.'); + var slash = normalized.IndexOf('/'); + var path = slash >= 0 && slash < normalized.Length - 1 + ? normalized[(slash + 1)..] + : normalized; + var segments = path.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + // Segment 0 is the Logical Node. Only data/control path segments are classified. + for (var index = 1; index < segments.Length; index++) + { + if (NoiseSegments.Contains(segments[index])) + return true; + } + + return false; + } +} diff --git a/SignalSelectionWizardWindow.LiveDiscoveryOperationalFilter.cs b/SignalSelectionWizardWindow.LiveDiscoveryOperationalFilter.cs new file mode 100644 index 000000000..d34e6e283 --- /dev/null +++ b/SignalSelectionWizardWindow.LiveDiscoveryOperationalFilter.cs @@ -0,0 +1,79 @@ +using System.Windows; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ArIED61850Tester; + +/// +/// Owns the Signal Selection presentation contract after a live MMS discovery. +/// +/// The ARIEC/live SignalDefinition inventory intentionally remains complete enough for +/// diagnostics, comparison, report planning, persistence and engineering inspection. +/// Signal Selection is narrower: it shows operator points and mandatory static DataSet +/// members, never protocol/service leaves such as Mod/Beh/Health/NamPlt/origin/q/t. +/// +/// This filter is installed on the wizard after its constructor has installed search and +/// column filters. That lifecycle point is important because a default WPF collection view +/// can be shared with another DataGrid; a previously installed global filter can otherwise +/// be replaced by the wizard's own FilterSignal predicate. +/// +public partial class SignalSelectionWizardWindow +{ + private Predicate? _signalSelectionBaseFilter; + private Predicate? _signalSelectionOperationalFilter; + private bool _signalSelectionOperationalFilterInstalled; + + static SignalSelectionWizardWindow() + { + EventManager.RegisterClassHandler( + typeof(SignalSelectionWizardWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(OnSignalSelectionOperationalFilterLoaded)); + } + + private static void OnSignalSelectionOperationalFilterLoaded(object sender, RoutedEventArgs args) + { + if (sender is SignalSelectionWizardWindow window) + window.InstallSignalSelectionOperationalFilter(); + } + + private void InstallSignalSelectionOperationalFilter() + { + if (_signalSelectionOperationalFilterInstalled) + return; + + _signalSelectionBaseFilter = SignalsView.Filter; + _signalSelectionOperationalFilter = item => + { + if (_signalSelectionBaseFilter is not null && !_signalSelectionBaseFilter(item)) + return false; + + return item is SignalDefinition signal && + LiveDiscoverySignalSelectionPolicy.IsVisible(signal); + }; + + SignalsView.Filter = _signalSelectionOperationalFilter; + _signalSelectionOperationalFilterInstalled = true; + Closed -= SignalSelectionOperationalFilter_Closed; + Closed += SignalSelectionOperationalFilter_Closed; + SignalsView.Refresh(); + RefreshViewState(); + } + + private void SignalSelectionOperationalFilter_Closed(object? sender, EventArgs args) + { + Closed -= SignalSelectionOperationalFilter_Closed; + + // Restore only our own wrapper. If another owner intentionally changed the shared + // collection view while this window was open, do not overwrite that newer filter. + if (_signalSelectionOperationalFilter is not null && + Equals(SignalsView.Filter, _signalSelectionOperationalFilter)) + { + SignalsView.Filter = _signalSelectionBaseFilter; + } + + _signalSelectionOperationalFilterInstalled = false; + _signalSelectionOperationalFilter = null; + _signalSelectionBaseFilter = null; + } +} diff --git a/tests/ARSAS.Tests/LiveDiscoverySignalSelectionNoiseRegressionTests.cs b/tests/ARSAS.Tests/LiveDiscoverySignalSelectionNoiseRegressionTests.cs new file mode 100644 index 000000000..1ad716d4a --- /dev/null +++ b/tests/ARSAS.Tests/LiveDiscoverySignalSelectionNoiseRegressionTests.cs @@ -0,0 +1,153 @@ +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class LiveDiscoverySignalSelectionNoiseRegressionTests +{ + [Theory] + [InlineData("AA1C1F13R4ADD/GAPC1.Mod.stVal", "GAPC1", "ST", "Boolean", "Status")] + [InlineData("AA1C1F13R4ADD/GAPC1.Beh.stVal", "GAPC1", "ST", "Boolean", "Status")] + [InlineData("AA1C1F13R4ADD/GAPC1.Health.stVal", "GAPC1", "ST", "Boolean", "Status")] + [InlineData("AA1C1F13R4ADD/GAPC1.NamPlt.d", "GAPC1", "DC", "VisString255", "Metadata")] + [InlineData("AA1C1F13R4ADD/GGIO1.LocOpnCMDsta.subVal", "GGIO1", "SV", "Boolean", "Status")] + [InlineData("AA1C1F13R4ADD/GGIO1.LocOpnCMDsta.subQ", "GGIO1", "SV", "Quality", "Quality")] + [InlineData("AA1C1F13R4ADD/GGIO1.LocOpnCMDsta.blkEna", "GGIO1", "BL", "Boolean", "Status")] + [InlineData("AA1C1F13R4ADD/GGIO1.LocOpnCMDsta.d.stVal", "GGIO1", "ST", "Boolean", "Status")] + [InlineData("AA1C1F13R4Application/LLN0.q.stVal", "LLN0", "ST", "Boolean", "Status")] + public void EngineeringAndProtocolLeaves_AreRejectedFromLiveSignalSelection( + string reference, + string logicalNode, + string fc, + string dataType, + string category) + { + var signal = NewSignal(reference, logicalNode, fc, dataType, category); + + Assert.True(LiveDiscoverySignalSelectionPolicy.IsProtocolOrEngineeringNoise(reference)); + Assert.False(LiveDiscoverySignalSelectionPolicy.IsVisible(signal)); + } + + [Theory] + [InlineData("AA1C1F13R4ADD/GGIO1.Mod.Oper.origin.orCat")] + [InlineData("AA1C1F13R4ADD/GGIO1.Mod.Oper.origin.orIdent")] + [InlineData("AA1C1F13R4ADD/GGIO1.Mod.Oper.ctlVal")] + [InlineData("AA1C1F13R4ADD/GGIO1.Mod.Oper.ctlNum")] + [InlineData("AA1C1F13R4ADD/GGIO1.Mod.Oper.Check")] + [InlineData("AA1C1F13R4ADD/GGIO1.Mod.ctlModel")] + public void ControlServiceLeaves_AreRejectedFromLiveSignalSelection(string reference) + { + var signal = NewSignal(reference, "GGIO1", "CO", "Struct", "Control"); + signal.IsControlSignal = true; + signal.ControlCdc = "SPC"; + + Assert.True(LiveDiscoverySignalSelectionPolicy.IsProtocolOrEngineeringNoise(reference)); + Assert.False(LiveDiscoverySignalSelectionPolicy.IsVisible(signal)); + } + + [Theory] + [InlineData("IEDLD/XCBR1.Pos.stVal", "XCBR1", "ST", "Enum", "Position")] + [InlineData("IEDLD/MMXU1.A.phsA.cVal.mag.f", "MMXU1", "MX", "Float32", "Measurement")] + [InlineData("IEDLD/PTRC1.Tr.general", "PTRC1", "ST", "Boolean", "Protection")] + [InlineData("IEDLD/GGIO1.Ind15.stVal", "GGIO1", "ST", "Boolean", "Status")] + public void RealOperatorPoints_RemainVisible( + string reference, + string logicalNode, + string fc, + string dataType, + string category) + { + var signal = NewSignal(reference, logicalNode, fc, dataType, category); + + Assert.False(LiveDiscoverySignalSelectionPolicy.IsProtocolOrEngineeringNoise(reference)); + Assert.True(LiveDiscoverySignalSelectionPolicy.IsVisible(signal)); + } + + [Fact] + public void RealPositionControl_RemainsVisible() + { + var signal = NewSignal("IEDLD/CSWI1.Pos", "CSWI1", "CO", "Struct", "Control"); + signal.IsControlSignal = true; + signal.ControlCdc = "DPC"; + + Assert.True(LiveDiscoverySignalSelectionPolicy.IsVisible(signal)); + } + + [Fact] + public void StaticDataSetObjectLevelMember_RemainsVisibleEvenWithoutRuntimeLeaf() + { + var signal = NewSignal( + "AA1C1F13R4ADD/GGIO6.CBOpnd", + "GGIO6", + "ST", + "Boolean", + "DataSet"); + signal.DataSetReference = "AA1C1F13R4Application/LLN0$Digital"; + signal.DisplayReference = signal.ObjectReference; + + Assert.False(SasOperationalSignalPolicy.IsVisible(signal)); + Assert.True(LiveDiscoverySignalSelectionPolicy.IsVisible(signal)); + Assert.DoesNotContain(".stVal", signal.DisplayReference, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void StaticDataSetAuthority_WinsEvenIfConfiguredMemberLooksLikeEngineeringState() + { + var signal = NewSignal( + "IEDLD/LLN0.Beh", + "LLN0", + "ST", + "Enum", + "DataSet"); + signal.DataSetReference = "IEDLD/LLN0$Configured"; + + Assert.True(LiveDiscoverySignalSelectionPolicy.IsVisible(signal)); + } + + [Fact] + public void SignalSelectionWizard_InstallsOperationalFilterAfterConstructorFilters() + { + var source = File.ReadAllText(FindRepoFile("SignalSelectionWizardWindow.LiveDiscoveryOperationalFilter.cs")); + + Assert.Contains("FrameworkElement.LoadedEvent", source, StringComparison.Ordinal); + Assert.Contains("_signalSelectionBaseFilter = SignalsView.Filter", source, StringComparison.Ordinal); + Assert.Contains("LiveDiscoverySignalSelectionPolicy.IsVisible(signal)", source, StringComparison.Ordinal); + Assert.Contains("SignalsView.Filter = _signalSelectionOperationalFilter", source, StringComparison.Ordinal); + Assert.Contains("SignalsView.Filter = _signalSelectionBaseFilter", source, StringComparison.Ordinal); + Assert.DoesNotContain("RemoveAt(", source, StringComparison.Ordinal); + Assert.DoesNotContain("Signals.Clear", source, StringComparison.Ordinal); + } + + private static SignalDefinition NewSignal( + string reference, + string logicalNode, + string fc, + string dataType, + string category) + => new() + { + Name = logicalNode, + ObjectReference = reference, + FunctionalConstraint = fc, + DataType = dataType, + Category = category, + Source = "ARIEC61850 live discovery", + ProbeStatus = "Readable", + Value = "0", + Quality = "Good" + }; + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + + throw new FileNotFoundException(relativePath); + } +}