diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index 33e456be6..23b4a626f 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -475,6 +475,13 @@ _iedBrowserReadFailureEvidence is { } previousFailures && return; } + // Plan transparency: the clicked scope, not whichever DO is visually + // expanded elsewhere, determines Read targets. Show FC admission so a + // bounded focused sample cannot be mistaken for a whole LN Read. + var scheduledFc = IedBrowserReadPlan.DescribeScheduledFunctionalConstraints(targets); + var readScope = readAll ? "IED" : + selectedDo is not null ? "DO" : + currentPage?.Kind ?? "Selection"; // Admission is claimed only after a concrete, nonempty verified plan // reaches the read handler. A queued Dispatcher callback is NOT a read. // Rejected/busy/cancelled preflight cannot poison a future visit. @@ -575,6 +582,9 @@ void DrainReadResults() RefreshIedBrowserVisibleValues(); } } + if (!automatic) + IedBrowserSubtitle.Text = + $"Read {readScope} · {targets.Count:N0} verified target(s) · FC {scheduledFc}"; if (!automatic) device.ResetDiscoveryProgress("Preparing read-only MMS values…", readAll ? "Reading all IED values" : selectedDo is not null @@ -703,12 +713,14 @@ bool HasTerminalFailure(IedBrowserReadTarget target) } else IedBrowserSubtitle.Text = - $"Read finished · {successes:N0} verified scalar values / {targets.Count:N0} scheduled attributes · " + + $"Read {readScope} finished · {successes:N0}/{targets.Count:N0} observed · " + $"{failures:N0} failed · {unverifiedReadTargets:N0} deferred/unverified"; - IedBrowserSubtitle.ToolTip = failureExamples.Count == 0 - ? null - : "First unavailable attributes (no scalar was published):\n" + - string.Join("\n", failureExamples); + IedBrowserSubtitle.ToolTip = + $"Explicit scope: {readScope}; scheduled FC: {scheduledFc}" + + (failureExamples.Count == 0 + ? string.Empty + : "\nFirst unavailable attributes (no scalar was published):\n" + + string.Join("\n", failureExamples)); RefreshIedBrowserVisibleValues(); // A few permanently refused SG leaves must not block the next // finite SGCB chunk. Per-page auto admission still caps attempts diff --git a/Models/IedBrowserReadPlan.cs b/Models/IedBrowserReadPlan.cs index dab093c42..a7999284e 100644 --- a/Models/IedBrowserReadPlan.cs +++ b/Models/IedBrowserReadPlan.cs @@ -1,4 +1,5 @@ using AR.Iec61850.Discovery; +using System.Runtime.CompilerServices; namespace ArIED61850Tester.Models; @@ -28,6 +29,54 @@ public static IedBrowserReadSelectionKind SelectionKind(IedBrowserTreeRow? page) _ => IedBrowserReadSelectionKind.FcAttributes }; + // Per-canonical-model compiled READ-ONLY admission catalog. A completed + // LiveDiscoveryModel is a frozen per-association snapshot; reconnect creates + // a new model object. Weak keys release all index memory when that snapshot + // is replaced. This is an index of model metadata, NOT a value cache. + private sealed class CompiledCatalog + { + internal IedBrowserSchemaIndex Schema { get; } + internal LiveIedDataAttributeModel[] Attributes { get; } + internal Dictionary<(string Reference, string Fc), LiveIedDataAttributeModel> + Authorized { get; } + + internal CompiledCatalog(LiveIedModelDiscoveryDocument model) + { + Schema = new IedBrowserSchemaIndex(model); + Attributes = model.LogicalDevices + .SelectMany(ld => ld.LogicalNodes) + .SelectMany(ln => ln.DataObjects) + .SelectMany(obj => obj.Attributes) + .Where(a => ReadableFc.Contains(a.FunctionalConstraint)) + .ToArray(); + // Exact wire reference + FC. A duplicate declaration makes that + // identity ambiguous; never grant first-inventory wins authority. + Authorized = Attributes + .Where(a => !string.IsNullOrWhiteSpace(a.MmsReference)) + .GroupBy(a => (a.MmsReference, Fc: a.FunctionalConstraint.ToUpperInvariant())) + .Where(group => group.Count() == 1) + .ToDictionary(group => group.Key, group => group.Single()); + } + } + + private static readonly ConditionalWeakTable Compiled = new(); + + /// + /// Describe exactly the FC namespaces admitted for THIS explicit read; + /// the result is plan metadata, never an inferred network observation. + /// + public static string DescribeScheduledFunctionalConstraints( + IReadOnlyList targets) + { + ArgumentNullException.ThrowIfNull(targets); + return targets.Count == 0 ? "none" : string.Join(", ", + targets.GroupBy(t => (t.FunctionalConstraint ?? "").Trim().ToUpperInvariant()) + .OrderBy(group => group.Key, StringComparer.Ordinal) + .Select(group => group.Key + ":" + group.Count().ToString( + System.Globalization.CultureInfo.InvariantCulture))); + } + // The live MMS model may declare status-only substitution (SV), operation // supervision (OR), and blocking (BL) leaves. They are safe to *read* // explicitly, never to operate or modify. FC CO remains excluded. @@ -58,12 +107,9 @@ public static IReadOnlyList Build( bool readAll, string? selectedDoReference = null) { if (model is null) return Array.Empty(); - var schema = new IedBrowserSchemaIndex(model); - var all = model.LogicalDevices.SelectMany(ld => ld.LogicalNodes) - .SelectMany(ln => ln.DataObjects) - .SelectMany(obj => obj.Attributes) - .Where(a => ReadableFc.Contains(a.FunctionalConstraint)) - .ToArray(); + var compiled = Compiled.GetValue(model, static m => new CompiledCatalog(m)); + var schema = compiled.Schema; + var all = compiled.Attributes; IEnumerable selected = all; if (!readAll) @@ -108,10 +154,20 @@ public static IReadOnlyList Build( }; } + // Page payloads can outlive a discovery/association change. Never turn + // their detached DA objects into MMS targets merely because they contain + // a plausible reference. Resolve every selection back through THIS live + // model's exact MMS reference and FC, and use the model's declared type. + // Duplicate identities in the model are ambiguous and fail closed. + var authorized = compiled.Authorized; + var attributes = selected.Where(a => ReadableFc.Contains(a.FunctionalConstraint)) .Where(a => !string.IsNullOrWhiteSpace(a.MmsReference)) + .Select(a => authorized.GetValueOrDefault( + (a.MmsReference, a.FunctionalConstraint.ToUpperInvariant()))) + .Where(a => a is not null) .Select(a => new IedBrowserReadTarget( - a.MmsReference, a.FunctionalConstraint, + a!.MmsReference, a.FunctionalConstraint, string.IsNullOrWhiteSpace(a.SclBType) ? a.MmsType : a.SclBType)); // Exact, model-inventory-proven control state is an additional // read-only projection. It is never added to cyclic or Read All scans. diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs index 5e834f5e8..75af112e2 100644 --- a/Services/Iec61850MonitorRuntime.cs +++ b/Services/Iec61850MonitorRuntime.cs @@ -64,6 +64,9 @@ private sealed class DeviceSession public List ActiveReportPlanOrder { get; } = new(); public Dictionary PointPlanIds { get; } = new(StringComparer.OrdinalIgnoreCase); public Dictionary ReportReferenceIndex { get; } = new(StringComparer.OrdinalIgnoreCase); + // Alias collisions are never resolved by first-insertion order. Lifetime + // matches the report reference index and the current association. + public HashSet AmbiguousReportReferenceKeys { get; } = new(StringComparer.OrdinalIgnoreCase); public PriorityQueue PollQueue { get; } = new(); public Dictionary ReportStreams { get; } = new(StringComparer.OrdinalIgnoreCase); // Active association only: exact ARIEC write-step outcomes for each @@ -838,6 +841,7 @@ public async Task> StartMonitoringAsync( session.ActiveReportPlanOrder.Clear(); session.PointPlanIds.Clear(); session.ReportReferenceIndex.Clear(); + session.AmbiguousReportReferenceKeys.Clear(); session.PollQueue.Clear(); session.ReportStreams.Clear(); session.ReportActivationByPlanId.Clear(); @@ -2958,6 +2962,7 @@ private async Task StopMonitoringSessionAsync(DeviceSession session, bool preser session.Points.Clear(); session.States.Clear(); session.ReportReferenceIndex.Clear(); + session.AmbiguousReportReferenceKeys.Clear(); } } @@ -3351,37 +3356,83 @@ internal static void EnsureDistinctRuntimePointIdentities( private static void IndexPointReference(DeviceSession session, Iec61850MonitorPoint point) { foreach (var key in GetReferenceKeys(point.IecReference)) - session.ReportReferenceIndex.TryAdd(key, point); + AddReportReferenceCandidate( + session.ReportReferenceIndex, session.AmbiguousReportReferenceKeys, key, point); } - private static Iec61850MonitorPoint? FindPointForReportReference(DeviceSession session, string reference) + // One association-scoped index only. When two distinct selected points + // claim a canonical reference, neither may own its Report value. + internal static void AddReportReferenceCandidate( + IDictionary index, + ISet ambiguousKeys, + string key, + Iec61850MonitorPoint candidate) + { + if (ambiguousKeys.Contains(key)) + return; + if (index.TryGetValue(key, out var prior)) + { + if (!ReferenceEquals(prior, candidate)) + { + index.Remove(key); + ambiguousKeys.Add(key); + } + return; + } + index.Add(key, candidate); + } + + private static Iec61850MonitorPoint? FindPointForReportReference( + DeviceSession session, string reference) { foreach (var key in GetReferenceKeys(reference)) { + if (session.AmbiguousReportReferenceKeys.Contains(key)) + continue; if (session.ReportReferenceIndex.TryGetValue(key, out var indexed)) return indexed; } - // Rare vendor projection fallback. Cache the resolved alias so subsequent report - // events do not scan every monitored point again. - var canonical = CanonicalDataReference(reference); - var point = session.Points.Values.FirstOrDefault(candidate => - { - var candidateCanonical = CanonicalDataReference(candidate.IecReference); - return candidateCanonical.Equals(canonical, StringComparison.OrdinalIgnoreCase) || - candidateCanonical.StartsWith(canonical + ".", StringComparison.OrdinalIgnoreCase) || - canonical.StartsWith(candidateCanonical + ".", StringComparison.OrdinalIgnoreCase); - }); - + // Nonexact aliases occur on some older report projections. Never + // select the first child of a structured parent (e.g. phase A), or + // overwrite an existing alias. Only a unique selected point may own + // the alias; otherwise its Report observation remains unrouted. + var point = FindUniqueReportReferenceFallback(session.Points.Values, reference); if (point != null) { foreach (var key in GetReferenceKeys(reference)) - session.ReportReferenceIndex[key] = point; + AddReportReferenceCandidate( + session.ReportReferenceIndex, session.AmbiguousReportReferenceKeys, + key, point); } - return point; } + internal static Iec61850MonitorPoint? FindUniqueReportReferenceFallback( + IEnumerable points, string reference) + { + var canonical = CanonicalDataReference(reference); + if (string.IsNullOrWhiteSpace(canonical)) + return null; + + Iec61850MonitorPoint? unique = null; + foreach (var candidate in points) + { + var candidateCanonical = CanonicalDataReference(candidate.IecReference); + // Prefix ancestry does NOT prove a phase's scalar identity: + // one selected phsA under a three-phase parent is still ambiguous + // in the IED schema. ARIEC's typed structured projection must emit + // the exact semantic leaf; a parent cannot borrow its value. + if (!candidateCanonical.Equals(canonical, StringComparison.OrdinalIgnoreCase)) + continue; + + if (unique is not null && !ReferenceEquals(unique, candidate)) + return null; // Ambiguous phase/child/FC — fail closed. + unique = candidate; + } + return unique; + } + private static IReadOnlyList ResolveCoveredPoints( ReportControlPlan plan, IReadOnlyList coveredReferences) diff --git a/docs/P0_READ_COMMAND_INTEGRATION_CHECKPOINT_20261021.md b/docs/P0_READ_COMMAND_INTEGRATION_CHECKPOINT_20261021.md new file mode 100644 index 000000000..6afa0280c --- /dev/null +++ b/docs/P0_READ_COMMAND_INTEGRATION_CHECKPOINT_20261021.md @@ -0,0 +1,29 @@ +# P0 Read / Command checkpoint — 2026-10-21 + +## Immutable ancestry (never resume this work from `main`) + +- **Accepted newer UI/ZIV/GoCB line:** #650 `5a33fb101026707caf6410ff097a569cfabf3939`, inheriting field-accepted ZIV/GoCB #649 `4c232184f64584308396ea18ff6c82700ac02d29` and approved UI phases #646–#648. Full 11/11 exact-HEAD workflow PASS at the source checkpoint. +- **Independent field Browser/Report line:** #644 `80e4836affce0b1f5f142c2966199a2ef5af41b3` → #645 `2b4e4ea0b917b6c10fbe4b64b6a3a7b127870598`; source/CI green, original physical SV/OR/BL and all data model values **not proven**. +- **Current integration:** draft #652 stacked directly on #650. Exactly four authoritative files from #645 were replayed after verifying both pre-change files on #650 are blob-identical to original #642. Regressions and mainline UI/GoCB/Command Dock protections are retained. +- **Pinned engine:** ARIEC61850 #183 `c85d4e20db722bd0c2729f2533b323b13d3ef735`. The engine P0-C trial #184 is separate and **not pinned or consumed by #652**. CI-only draft #185 triggers Windows engine validation for #184 because the engine workflow omits the stacked base branch. +- **No main merge/public release** until the current *combined* app and engine exactly matched head passes Windows, package, and authorized physical FAT. + +## P0-R production code + +1. `Models/IedBrowserReadPlan.cs`: single source-of-truth exact MMS reference + FC admission; per-model weak compiled read-only metadata index and immutable `IedBrowserSchemaIndex` reused only for the same frozen `LiveDiscoveryModel`. Model replacement produces new index; duplicates remain quarantined and all observed values continue exclusively in `LiveValues`. +2. Explicit Read now states the *actual clicked scope* (IED, DO, LN, or other) and per-FC **scheduled** counts, not synthetic wire observations. Thus a 5-target DO Read is visibly distinct from a complete LN Read. Passive bounded focus still intentionally excludes SV/OR/BL, while verified explicit Read DO/LN/All admits them; CO is never a scalar Read. +3. `IedBrowserReadResultInboxTests` continues to enforce synchronous final inbox drain before the success subtitle; P0H tests preserve anti-spoof identity, epoch switch, FC count and duplicate rules. No new network call, timer or global mutable IED-state cache. + +## P0-C source-proven delay and safe trial + +Physical SIEBCU/CSWI4 samples show SBOw service 5–7ms + *post-response application-error observation* 399–404ms + Operate service 2–3ms. Engine `Iec61850ControlServiceOptions.ApplicationErrorGracePeriod` defaults to 400ms; `SelectWithValueCoreAsync` awaits it after successful SBOw and before Operate. **Do not simply set it to zero:** delayed asynchronous negative LastApplError must still prevent Operate. Engine draft #184 adds stage labels/configured grace to wire evidence and tests negative events arriving immediately or after 35ms. A qualified no-fixed-wait path needs event-correlated identity (association/object/ctlNum/origin) plus negative, late, collision, timeout and disconnect regressions and field acceptance; no such performance fix is claimed yet. + +## Field limitations and required gates + +- User's actual 57-row XCBR1 CSV: 41 observed; 15 without MMS attempt evidence; 1 failed scalar projection. Four `Pos.subEna/subVal/subQ/subID` SV leaves were **not attempted**, rather than server-rejected. Read actual XCBR1 LN/DO explicitly, record exact app+engine SHA, planned FCs, typed GVA responses, invoke/partial-failure evidence, per-IED ledger and UI. A separately observed client display supplies comparison data only; it is not a source of protocol code. +- `SumSwARs.units.multiplier` returned no scalar in application evidence without raw GVA; classify as unresolved read/projection, not a confirmed server refusal. +- Report anomalies: 52 received BRCB frames, 13 sequence irregularities, BufOvfl=1, EntryID absent. Never infer lost SOE or continuity completion without synchronized wire capture. +- User's endpoint `192.16.1.192` is also assigned to the Windows host's `KM_Test` adapter; do not label a same-host simulator observation as physical relay. +- Required: all 11 CI workflows, targeted+full positive/negative suites, exact engine lock, portable packaged smoke/manifest, actual Read SV/OR/BL and control stage evidence on authorized device, plus UI/DPI and installer qualification. No automatic command/RCB writes or cyclic MMS process polling. + +See `AGENTS.md`, `docs/WORKSTREAM_COORDINATION.md` and Oct-20 Global Read & Command Strategy attachment for governing constraints. diff --git a/tests/ARSAS.Tests/IedBrowserReadResultInboxTests.cs b/tests/ARSAS.Tests/IedBrowserReadResultInboxTests.cs index cdd99c4be..4c259a81f 100644 --- a/tests/ARSAS.Tests/IedBrowserReadResultInboxTests.cs +++ b/tests/ARSAS.Tests/IedBrowserReadResultInboxTests.cs @@ -85,7 +85,7 @@ public void UiReadHandlerUsesExplicitInboxCompletionBeforeResultsSubtitle() " DrainReadResults();", finishedProducers, StringComparison.Ordinal); var catalogFence = normalized.IndexOf( "if (bindingIndex is not { IsCurrent: true })", finalDrain, StringComparison.Ordinal); - var summary = normalized.IndexOf("Read finished ·", StringComparison.Ordinal); + var summary = normalized.IndexOf("Read {readScope} finished ·", catalogFence, StringComparison.Ordinal); Assert.True(finishedProducers >= 0 && finalDrain > finishedProducers && catalogFence > finalDrain && summary > catalogFence); Assert.Contains("IedBrowserReadCompletionAccounting.Classify(", normalized); diff --git a/tests/ARSAS.Tests/P0HBrowserModelInventoryAuthorityTests.cs b/tests/ARSAS.Tests/P0HBrowserModelInventoryAuthorityTests.cs new file mode 100644 index 000000000..7eddb928e --- /dev/null +++ b/tests/ARSAS.Tests/P0HBrowserModelInventoryAuthorityTests.cs @@ -0,0 +1,158 @@ +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; + +namespace ARSAS.Tests; + +/// +/// Ensures Browser selection never trusts stale/detached UI nodes as MMS inventory. +/// All targets and types must originate in the active live discovery model. +/// +public sealed class P0HBrowserModelInventoryAuthorityTests +{ + private const string Prefix = "IEDLD0/XCBR1"; + + private static LiveIedDataAttributeModel Leaf( + string leaf, string fc, string type = "Boolean") => new() + { + ObjectReference = Prefix + ".Pos", + AttributePath = "Pos." + leaf, + MmsReference = Prefix + "$" + fc + "$Pos$" + leaf, + FunctionalConstraint = fc, + SclBType = type, + MmsType = type, + TypeDiscoveryStatus = "Exact" + }; + + private static LiveIedModelDiscoveryDocument Model( + params LiveIedDataAttributeModel[] attributes) => new() + { + LogicalDevices = + [ + new LiveIedLogicalDeviceModel + { + MmsDomain = "IEDLD0", + LogicalNodes = + [ + new LiveIedLogicalNodeModel + { + Name = "XCBR1", + LnClass = "XCBR", + DataObjects = + [ + new LiveIedDataObjectModel + { + Name = "Pos", + Reference = Prefix + ".Pos", + Attributes = attributes + } + ] + } + ] + } + ] + }; + + private static IedBrowserTreeRow DetachedPage( + params LiveIedDataAttributeModel[] attributes) => + new(Prefix + ".Pos", "Pos", "DO", 2, new LiveIedDataObjectModel + { + Name = "Pos", + Reference = Prefix + ".Pos", + Attributes = attributes + }); + + [Fact] + public void DetachedUiPage_CannotInjectUnknownSvOrCoReferences() + { + var live = Model(Leaf("subEna", "SV"), Leaf("stVal", "ST")); + var page = DetachedPage( + Leaf("subEna", "SV", "ForgedDataType"), + Leaf("opRcvd", "OR"), // not in active model + Leaf("blkEna", "BL"), // not in active model + Leaf("Oper", "CO")); // control is never a scalar Read + var selected = IedBrowserReadPlan.Build(live, page, readAll: false); + var target = Assert.Single(selected); + Assert.Equal(Prefix + "$SV$Pos$subEna", target.Reference); + Assert.Equal("SV", target.FunctionalConstraint); + Assert.Equal("Boolean", target.DataType); // type comes from live model, not UI + } + + [Fact] + public void SameReferenceWrongFc_IsNotAuthorizedByCatalog() + { + var live = Model(Leaf("stVal", "ST")); + var page = DetachedPage(Leaf("stVal", "SV")); + Assert.Empty(IedBrowserReadPlan.Build(live, page, readAll: false)); + } + + [Fact] + public void DuplicateModelWireIdentity_FailsClosedInsteadOfChoosingFirstType() + { + var live = Model(Leaf("subVal", "SV", "Dbpos"), + Leaf("subVal", "SV", "Boolean")); + var page = DetachedPage(Leaf("subVal", "SV", "Dbpos")); + Assert.Empty(IedBrowserReadPlan.Build(live, page, readAll: false)); + Assert.Empty(IedBrowserReadPlan.Build(live, null, readAll: true)); + } + + [Fact] + public void LegitimateLiveModel_StillReadsExactSvOrBlAndSt() + { + var live = Model(Leaf("subEna", "SV"), + Leaf("opOk", "OR"), Leaf("blkEna", "BL"), Leaf("stVal", "ST")); + var page = DetachedPage(Leaf("subEna", "SV"), + Leaf("opOk", "OR"), Leaf("blkEna", "BL"), Leaf("stVal", "ST")); + var selected = IedBrowserReadPlan.Build(live, page, readAll: false); + Assert.Equal(4, selected.Count); + Assert.Equal(new[] { "SV", "OR", "BL", "ST" }, + selected.Select(target => target.FunctionalConstraint)); + } + + [Fact] + public void IndependentReDiscoveryModels_DoNotReusePriorCompiledAuthority() + { + var first = Model(Leaf("subEna", "SV", "Boolean")); + var second = Model(Leaf("subEna", "SV", "Dbpos"), + Leaf("opRcvd", "OR", "Boolean")); + var page = DetachedPage(Leaf("subEna", "SV", "ForgedType"), + Leaf("opRcvd", "OR")); + + // Repeated selections on the same immutable model must be stable. + var initial = Assert.Single(IedBrowserReadPlan.Build(first, page, readAll: false)); + Assert.Equal("Boolean", initial.DataType); + Assert.Equal("Boolean", Assert.Single( + IedBrowserReadPlan.Build(first, page, readAll: false)).DataType); + + // A reconnect / new model instance owns a NEW authority index. + var reconnected = IedBrowserReadPlan.Build(second, page, readAll: false); + Assert.Equal(2, reconnected.Count); + Assert.Equal("Dbpos", reconnected[0].DataType); + Assert.Contains(reconnected, entry => entry.FunctionalConstraint == "OR"); + } + + [Fact] + public void ExactReadPlanFcSummary_ReflectsScheduledSubsetNotWholeIed() + { + var model = Model(Leaf("subEna", "SV"), Leaf("subVal", "SV", "Dbpos"), + Leaf("opOk", "OR"), Leaf("blkEna", "BL"), Leaf("stVal", "ST"), + Leaf("Oper", "CO")); + var planned = IedBrowserReadPlan.Build(model, null, readAll: true); + Assert.Equal("BL:1, OR:1, ST:1, SV:2", + IedBrowserReadPlan.DescribeScheduledFunctionalConstraints(planned)); + Assert.DoesNotContain(planned, p => p.FunctionalConstraint == "CO"); + Assert.Equal("none", IedBrowserReadPlan.DescribeScheduledFunctionalConstraints( + Array.Empty())); + } + + [Fact] + public void DuplicateAdmission_RemainsQuarantinedAcrossRepeatedPlanRequests() + { + var model = Model(Leaf("subVal", "SV", "Dbpos"), + Leaf("subVal", "SV", "BOOLEAN"), Leaf("stVal", "ST")); + for (var attempt = 0; attempt < 3; attempt++) + { + var planned = IedBrowserReadPlan.Build(model, null, readAll: true); + Assert.Equal("ST", Assert.Single(planned).FunctionalConstraint); + } + } +} diff --git a/tests/ARSAS.Tests/P0HReportReferenceAmbiguityTests.cs b/tests/ARSAS.Tests/P0HReportReferenceAmbiguityTests.cs new file mode 100644 index 000000000..6893bf3ec --- /dev/null +++ b/tests/ARSAS.Tests/P0HReportReferenceAmbiguityTests.cs @@ -0,0 +1,86 @@ +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +/// +/// Prevents a Report parent alias from borrowing the first sibling phase, +/// or a canonical FC collision from inheriting insertion order. +/// +public sealed class P0HReportReferenceAmbiguityTests +{ + private static Iec61850MonitorPoint Point(string reference, string fc = "MX") => new() + { + DeviceId = "IED-1", + IecReference = reference, + FunctionalConstraint = fc + }; + + [Fact] + public void StructuredParentCannotChooseFirstOfMultiplePhases() + { + var first = Point("IEDLD0/MMXU1.A.phsA.cVal.mag.f"); + var second = Point("IEDLD0/MMXU1.A.phsB.cVal.mag.f"); + Assert.Null(Iec61850MonitorRuntime.FindUniqueReportReferenceFallback( + new[] { first, second }, "IEDLD0/MMXU1.A")); + Assert.Null(Iec61850MonitorRuntime.FindUniqueReportReferenceFallback( + new[] { second, first }, "IEDLD0/MMXU1.A")); + } + + [Fact] + public void OneSelectedPhaseDoesNotProveParentAggregateButExactFcAliasDoes() + { + var phaseA = Point("IEDLD0/MMXU1.A.phsA.cVal.mag.f"); + var unrelated = Point("IEDLD0/MMXU1.B.phsB.cVal.mag.f"); + // A three-phase parent cannot be reduced to a selected child, even + // when that child is the only one currently monitored. + Assert.Null(Iec61850MonitorRuntime.FindUniqueReportReferenceFallback( + new[] { phaseA, unrelated }, "IEDLD0/MMXU1.A")); + Assert.Same(phaseA, Iec61850MonitorRuntime.FindUniqueReportReferenceFallback( + new[] { phaseA, unrelated }, "IEDLD0/MMXU1$MX$A$phsA$cVal$mag$f")); + } + + [Fact] + public void SameNamePrefixWithoutSegmentBoundaryDoesNotMatch() + { + var phaseA1 = Point("IEDLD0/MMXU1.A1.phsA.cVal.mag.f"); + Assert.Null(Iec61850MonitorRuntime.FindUniqueReportReferenceFallback( + new[] { phaseA1 }, "IEDLD0/MMXU1.A")); + } + + [Fact] + public void CanonicalCollisionRejectsAllCandidatesAndCannotBeRecached() + { + var index = new Dictionary( + StringComparer.OrdinalIgnoreCase); + var ambiguous = new HashSet(StringComparer.OrdinalIgnoreCase); + var st = Point("IEDLD0/XCBR1.Pos.stVal", "ST"); + var sv = Point("IEDLD0/XCBR1.Pos.subVal", "SV"); + const string key = "iedld0/xcbr1.pos"; + Iec61850MonitorRuntime.AddReportReferenceCandidate( + index, ambiguous, key, st); + Assert.Same(st, index[key]); + Iec61850MonitorRuntime.AddReportReferenceCandidate( + index, ambiguous, key, sv); + Assert.False(index.ContainsKey(key)); + Assert.Contains(key, ambiguous); + Iec61850MonitorRuntime.AddReportReferenceCandidate( + index, ambiguous, key, st); + Assert.False(index.ContainsKey(key)); + } + + [Fact] + public void RepeatOfSamePointKeepsStableAliasAndExactDifferentPointIndependent() + { + var index = new Dictionary( + StringComparer.OrdinalIgnoreCase); + var ambiguous = new HashSet(StringComparer.OrdinalIgnoreCase); + var point = Point("IEDLD0/XCBR1.Pos.stVal", "ST"); + Iec61850MonitorRuntime.AddReportReferenceCandidate( + index, ambiguous, "iedld0/xcbr1.pos.stval", point); + Iec61850MonitorRuntime.AddReportReferenceCandidate( + index, ambiguous, "iedld0/xcbr1.pos.stval", point); + Assert.Same(point, index["iedld0/xcbr1.pos.stval"]); + Assert.Empty(ambiguous); + } +}