diff --git a/Services/Iec61850DataSetSignalInventoryService.cs b/Services/Iec61850DataSetSignalInventoryService.cs index a4476b3c..705079fa 100644 --- a/Services/Iec61850DataSetSignalInventoryService.cs +++ b/Services/Iec61850DataSetSignalInventoryService.cs @@ -26,11 +26,11 @@ public static Iec61850DataSetSignalInventoryMergeResult EnsureMandatorySignals( { ArgumentNullException.ThrowIfNull(device); - // Signal Selection is also opened directly from an offline CID/SCD workspace. - // In that workflow LiveDiscoveryModel is intentionally null; the SCL design model - // is the authoritative inventory and must not be ignored. Prefer the live model - // only after a real association/discovery has produced one. - var authoritativeModel = device.LiveDiscoveryModel ?? device.SclWorkspace?.DesignModel; + // When an SCL workspace is open, its configured DataSet membership remains the + // engineering authority across cached/fast reconnects. A partial live discovery + // model must not erase FCDA/FCD rows that were proven by the opened CID/SCD. For an + // online-only IED with no SCL workspace, live discovery remains the authority. + var authoritativeModel = device.SclWorkspace?.DesignModel ?? device.LiveDiscoveryModel; if (authoritativeModel is null) return EmptyResult(); diff --git a/Services/Iec61850StaticDataSetAuthoritySelection.cs b/Services/Iec61850StaticDataSetAuthoritySelection.cs index 63844047..5296d816 100644 --- a/Services/Iec61850StaticDataSetAuthoritySelection.cs +++ b/Services/Iec61850StaticDataSetAuthoritySelection.cs @@ -6,11 +6,17 @@ namespace ArIED61850Tester.Services; /// /// Builds the exact Static DataSet selection used by the report-only workflow. /// -/// A DataSetReference on a browsed/runtime alias is not sufficient authority: several +/// Static report-only mode is RCB-backed by definition. A static DataSet that is not +/// referenced by an authoritative configured BRCB/URCB is valid engineering inventory, +/// but it is not a live acquisition source and therefore must not inflate the monitor with +/// permanently unavailable rows. When an SCL workspace is open, its ReportControl bindings +/// are the configuration authority; live discovery is verification only. For online-only +/// operation with no SCL workspace, live discovery becomes the configuration authority. +/// +/// A DataSetReference on a browsed/runtime alias is also not sufficient authority: several /// aliases can point at the same static FCDA/FCD member (for example cVal/instCVal or -/// structured measurement descendants). Static mode must select one presentation row -/// for each engine-authoritative DataSet membership, preserving the literal member -/// identity that appears in SCL/ARIEC. +/// structured measurement descendants). Static mode selects one presentation row for each +/// engine-authoritative membership, preserving the literal member identity from SCL/ARIEC. /// public static class Iec61850StaticDataSetAuthoritySelection { @@ -18,64 +24,108 @@ public static IReadOnlySet Build(Iec61850MonitorDevice device) { ArgumentNullException.ThrowIfNull(device); - var model = device.LiveDiscoveryModel ?? device.SclWorkspace?.DesignModel; - if (model is null) + // Opened SCL is the engineering authority. A partial or richer live model may verify + // the configuration later, but it must not introduce extra static memberships that + // were never configured in the opened CID/SCD. Online-only mode falls back to live. + var authorityModel = device.SclWorkspace?.DesignModel ?? device.LiveDiscoveryModel; + if (authorityModel is null) return new HashSet(ReferenceEqualityComparer.Instance); - var mandatory = Iec61850DataSetSignalInventoryProjection.GetMandatorySignals(model); + var reportBackedDataSets = BuildReportBackedDataSetReferences(device); + if (reportBackedDataSets.Count == 0) + return new HashSet(ReferenceEqualityComparer.Instance); + + var mandatory = Iec61850DataSetSignalInventoryProjection.GetMandatorySignals(authorityModel); var selected = new HashSet(ReferenceEqualityComparer.Instance); var signals = device.Signals.ToArray(); foreach (var descriptor in mandatory) { - var membership = descriptor.DataSetMemberships + // A descriptor may carry more than one membership. Do not arbitrarily take the + // first DataSet: choose only literal memberships that are backed by authoritative + // report-control configuration. + var memberships = descriptor.DataSetMemberships + .Where(item => reportBackedDataSets.Contains(NormalizeLiteral(item.DataSetReference))) .OrderBy(item => item.DataSetReference, StringComparer.OrdinalIgnoreCase) .ThenBy(item => item.MemberIndex) - .FirstOrDefault(); - if (membership is null) - continue; - - var memberReference = FirstNonEmpty( - membership.CanonicalMemberReference, - membership.OriginalMemberReference, - descriptor.DesignReference, - descriptor.ObservedReference, - descriptor.PrimaryValueReference); - if (string.IsNullOrWhiteSpace(memberReference) || - string.IsNullOrWhiteSpace(membership.DataSetReference)) - continue; - - var candidates = signals - .Where(signal => !signal.IsControlSignal && signal.CanPublishToRuntime) - .Where(signal => LiteralEquals(signal.DataSetReference, membership.DataSetReference)) - .Where(signal => LiteralEquals(signal.DisplayReference, memberReference)) .ToArray(); - if (candidates.Length == 0) - continue; - - // Prefer the runtime row already bound to ARIEC's resolved primary value. - // For an unresolved structured member, the inventory-created exact static row - // wins over a generic browsed alias. Never choose by fuzzy/prefix matching. - var chosen = candidates - .OrderByDescending(signal => - !string.IsNullOrWhiteSpace(descriptor.PrimaryValueReference) && - LiteralEquals(signal.ObjectReference, descriptor.PrimaryValueReference)) - .ThenByDescending(signal => - (signal.Source ?? string.Empty).Contains( - "mandatory static DataSet member", - StringComparison.OrdinalIgnoreCase)) - .ThenByDescending(signal => - string.Equals(signal.Category, "DataSet", StringComparison.OrdinalIgnoreCase)) - .ThenByDescending(signal => - string.Equals(signal.Confidence, "High", StringComparison.OrdinalIgnoreCase)) - .First(); - - selected.Add(chosen); + + foreach (var membership in memberships) + { + var memberReference = FirstNonEmpty( + membership.CanonicalMemberReference, + membership.OriginalMemberReference, + descriptor.DesignReference, + descriptor.ObservedReference, + descriptor.PrimaryValueReference); + if (string.IsNullOrWhiteSpace(memberReference) || + string.IsNullOrWhiteSpace(membership.DataSetReference)) + { + continue; + } + + var candidates = signals + .Where(signal => !signal.IsControlSignal && signal.CanPublishToRuntime) + .Where(signal => LiteralEquals(signal.DataSetReference, membership.DataSetReference)) + .Where(signal => LiteralEquals(signal.DisplayReference, memberReference)) + .ToArray(); + if (candidates.Length == 0) + continue; + + // Prefer the runtime row already bound to ARIEC's resolved primary value. + // For an unresolved structured member, the inventory-created exact static row + // wins over a generic browsed alias. Never choose by fuzzy/prefix matching. + var chosen = candidates + .OrderByDescending(signal => + !string.IsNullOrWhiteSpace(descriptor.PrimaryValueReference) && + LiteralEquals(signal.ObjectReference, descriptor.PrimaryValueReference)) + .ThenByDescending(signal => + (signal.Source ?? string.Empty).Contains( + "mandatory static DataSet member", + StringComparison.OrdinalIgnoreCase)) + .ThenByDescending(signal => + string.Equals(signal.Category, "DataSet", StringComparison.OrdinalIgnoreCase)) + .ThenByDescending(signal => + string.Equals(signal.Confidence, "High", StringComparison.OrdinalIgnoreCase)) + .First(); + + selected.Add(chosen); + } } return selected; } + /// + /// Returns the literal DataSet references that have authoritative ReportControl backing. + /// Opened SCL configuration wins absolutely over extra live ReportControls. Live discovery + /// is used as configuration authority only when no SCL design model is open. + /// + public static IReadOnlySet BuildReportBackedDataSetReferences(Iec61850MonitorDevice device) + { + ArgumentNullException.ThrowIfNull(device); + + var result = new HashSet(StringComparer.OrdinalIgnoreCase); + var configurationModel = device.SclWorkspace?.DesignModel ?? device.LiveDiscoveryModel; + AddReportBackedDataSets(configurationModel, result); + return result; + } + + private static void AddReportBackedDataSets( + LiveIedModelDiscoveryDocument? model, + HashSet target) + { + if (model is null) + return; + + foreach (var report in model.ReportControls) + { + var dataSetReference = NormalizeLiteral(report.DataSetReference); + if (!string.IsNullOrWhiteSpace(dataSetReference)) + target.Add(dataSetReference); + } + } + private static bool LiteralEquals(string? left, string? right) => string.Equals(NormalizeLiteral(left), NormalizeLiteral(right), StringComparison.OrdinalIgnoreCase); diff --git a/Services/Iec61850StaticRcbReferenceMatcher.cs b/Services/Iec61850StaticRcbReferenceMatcher.cs new file mode 100644 index 00000000..8adc6a7c --- /dev/null +++ b/Services/Iec61850StaticRcbReferenceMatcher.cs @@ -0,0 +1,74 @@ +namespace ArIED61850Tester.Services; + +/// +/// Matches one configured SCL ReportControl declaration to the concrete RCB object exposed +/// by the live MMS server. +/// +/// IEC 61850 SCL may describe one indexed ReportControl family while the server exposes +/// concrete instances such as Buffer01 / Buffer02. Exact identity always wins. The only +/// accepted fallback is a decimal indexed instance of the same literal RCB family; arbitrary +/// same-DataSet RCBs are never treated as substitutes. +/// +public static class Iec61850StaticRcbReferenceMatcher +{ + public static bool IsExact(string? configuredReference, string? liveReference) + => string.Equals( + Normalize(configuredReference), + Normalize(liveReference), + StringComparison.OrdinalIgnoreCase); + + public static bool IsConfiguredOrIndexedInstance(string? configuredReference, string? liveReference) + { + var configured = Normalize(configuredReference); + var live = Normalize(liveReference); + if (string.IsNullOrWhiteSpace(configured) || string.IsNullOrWhiteSpace(live)) + return false; + if (string.Equals(configured, live, StringComparison.OrdinalIgnoreCase)) + return true; + + SplitLeaf(configured, out var configuredParent, out var configuredLeaf); + SplitLeaf(live, out var liveParent, out var liveLeaf); + if (!string.Equals(configuredParent, liveParent, StringComparison.OrdinalIgnoreCase) || + string.IsNullOrWhiteSpace(configuredLeaf) || + string.IsNullOrWhiteSpace(liveLeaf)) + { + return false; + } + + // If the configured declaration already ends in a digit, treat it as a concrete + // object and require exact identity. This avoids accidental prefix matches such as + // Buffer0 -> Buffer01. + if (char.IsDigit(configuredLeaf[^1]) || + !liveLeaf.StartsWith(configuredLeaf, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var suffix = liveLeaf[configuredLeaf.Length..]; + return suffix.Length > 0 && suffix.All(char.IsDigit); + } + + public static int MatchRank(string? configuredReference, string? liveReference) + { + if (IsExact(configuredReference, liveReference)) + return 0; + return IsConfiguredOrIndexedInstance(configuredReference, liveReference) ? 1 : int.MaxValue; + } + + public static string Normalize(string? reference) + => (reference ?? string.Empty).Trim().Replace('$', '.'); + + private static void SplitLeaf(string reference, out string parent, out string leaf) + { + var separator = reference.LastIndexOf('.'); + if (separator < 0) + { + parent = string.Empty; + leaf = reference; + return; + } + + parent = reference[..separator]; + leaf = reference[(separator + 1)..]; + } +} diff --git a/Services/NativeIec61850Client.StaticDataSetReporting.cs b/Services/NativeIec61850Client.StaticDataSetReporting.cs index 68cdcd9b..32d5173a 100644 --- a/Services/NativeIec61850Client.StaticDataSetReporting.cs +++ b/Services/NativeIec61850Client.StaticDataSetReporting.cs @@ -1,3 +1,4 @@ +using AR.Iec61850.Discovery; using ArIED61850Tester.Models; using ArMms = AR.Iec61850.Mms; @@ -9,9 +10,10 @@ namespace ArIED61850Tester.Services; /// Static mode already has complete configuration authority in the opened/live SCL model: /// exact DataSet membership and configured RCB -> DataSet bindings. Do not run those facts /// through the adaptive Hybrid acquisition planner. The live association is used only to -/// verify that the exact configured RCB and exact DataSet directory exist, then ARIEC's -/// persistent monitor installs the InformationReport receiver, enables RptEna and requests -/// GI. No dynamic DataSet write and no cyclic process-value MMS read is permitted here. +/// verify that the configured RCB (or a concrete indexed instance of that configured family) +/// and exact DataSet directory exist, then ARIEC's persistent monitor installs the +/// InformationReport receiver, enables RptEna and requests GI. No dynamic DataSet write and +/// no cyclic process-value MMS read is permitted here. /// public sealed partial class NativeIec61850Client { @@ -30,8 +32,10 @@ public async Task BuildStaticDataSetReportPlan _deterministicStaticSubscriptions.Clear(); ResetSemanticReportProjectionContext(); - var model = device.LiveDiscoveryModel ?? device.SclWorkspace?.DesignModel; - if (model is null) + // Opened SCL is the semantic and configuration authority. A live model is authoritative + // only for online-only operation where no CID/SCD workspace is open. + var projectionModel = device.SclWorkspace?.DesignModel ?? device.LiveDiscoveryModel; + if (projectionModel is null) { return StaticPlanningUnavailable( points, @@ -45,12 +49,22 @@ public async Task BuildStaticDataSetReportPlan $"Static DataSet report-only requires an initiated MMS association. Current state: {_session.State}."); } - SetSemanticReportProjectionAuthority(model); + SetSemanticReportProjectionAuthority(projectionModel); + + // P0 authority rule: when an SCL workspace exists, only ReportControls from that + // design model may authorize static acquisition. Fresh live discovery is verification + // and concrete-instance evidence; it must never introduce a peer RCB that can displace + // an explicit SCL binding such as Digital -> Buffer02. + var configurationModel = projectionModel; + var configurationAuthorityLabel = device.SclWorkspace?.DesignModel is not null + ? "opened SCL design model" + : "live discovery model (online-only)"; // Fresh report discovery is verification, not permission policy. In particular we - // deliberately do NOT classify a configured BRCB through the Hybrid availability - // confidence gate. Some perfectly usable servers expose RptEna and DatSet but omit - // enough reservation metadata for that adaptive gate to call the RCB 'Available'. + // deliberately do NOT require the adaptive Hybrid availability gate to classify a + // configured BRCB as Available. Some perfectly usable servers omit enough reservation + // metadata to remain Unknown. Explicit enabled/reserved/owner evidence is still used + // to avoid stealing an occupied RCB. var discovery = await EnsureDiscoveryForReportingAsync(cancellationToken).ConfigureAwait(false); if (discovery is null) { @@ -61,6 +75,11 @@ public async Task BuildStaticDataSetReportPlan : LastErrorMessage); } + var callerOwnedRcbReferences = _reportMonitorSessions.Values + .Select(session => NormalizeStaticReference(session.ReportControl.Reference)) + .Where(reference => !string.IsNullOrWhiteSpace(reference)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var reportPlans = new List(); var warnings = new List(); var coveredPointKeys = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -76,8 +95,13 @@ public async Task BuildStaticDataSetReportPlan { cancellationToken.ThrowIfCancellationRequested(); - var configuredReports = model.ReportControls + var dataSetReference = dataSetGroup.First().DataSetReference.Trim(); + var configuredReports = configurationModel.ReportControls .Where(report => SameStaticReference(report.DataSetReference, dataSetGroup.Key)) + .GroupBy( + report => $"{NormalizeStaticReference(report.Reference)}|{NormalizeStaticReference(report.DataSetReference)}", + StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) .OrderByDescending(report => report.Buffered) .ThenBy(report => report.Reference, StringComparer.OrdinalIgnoreCase) .ToArray(); @@ -85,42 +109,129 @@ public async Task BuildStaticDataSetReportPlan if (configuredReports.Length == 0) { warnings.Add( - $"{dataSetGroup.First().DataSetReference}: no configured BRCB/URCB in the SCL model; {dataSetGroup.Count()} selected point(s) remain explicitly unavailable. No MMS process polling was substituted."); + $"{dataSetReference}: no configured BRCB/URCB in the authoritative {configurationAuthorityLabel}; {dataSetGroup.Count()} selected point(s) remain explicitly unavailable. No MMS process polling was substituted."); continue; } - if (configuredReports.Length > 1) - { - warnings.Add( - $"{dataSetGroup.First().DataSetReference}: {configuredReports.Length} configured RCBs reference this DataSet; deterministic Static mode selected {configuredReports[0].Reference} (BRCB preferred, then literal reference order)." ); - } + // Evaluate every authoritative configured RCB instead of selecting only the first. + // If several SCL ReportControls legitimately reference the same DataSet, one + // missing/occupied RCB must not hide another configured option. A configured family + // may resolve only to decimal indexed instances of that same literal family; + // arbitrary same-DataSet substitution remains forbidden. + var matchedLiveCandidates = configuredReports + .SelectMany(configured => discovery.ReportInventory.ReportControls + .Select(candidate => new + { + Configured = configured, + Candidate = candidate, + Rank = Iec61850StaticRcbReferenceMatcher.MatchRank( + configured.Reference, + candidate.Reference) + }) + .Where(item => item.Rank != int.MaxValue) + .Where(item => + string.IsNullOrWhiteSpace(item.Candidate.DataSetReference) || + SameStaticReference(item.Candidate.DataSetReference, dataSetReference))) + .OrderBy(item => item.Rank) + .ThenByDescending(item => item.Configured.Buffered) + .ThenBy(item => item.Configured.Reference, StringComparer.OrdinalIgnoreCase) + .ThenBy(item => item.Candidate.Reference, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + // Use ARIEC's operational evaluator so Owner is part of the same occupancy + // decision as RptEna/Resv/ResvTms. Directory evidence is intentionally omitted at + // this stage: Unknown is still eligible and the exact DataSet directory is verified + // before the plan is armed. If a currently active session already owns the RCB, + // preserve that fact as UsedByCaller instead of misclassifying it as foreign use. + var evaluatedLiveCandidates = matchedLiveCandidates + .Select(item => new + { + item.Configured, + item.Candidate, + item.Rank, + Availability = ArMms.MmsRcbAvailabilityEvaluator.Evaluate( + item.Candidate, + dataSetDirectory: null, + callerOwned: callerOwnedRcbReferences.Contains( + NormalizeStaticReference(item.Candidate.Reference))) + }) + .ToArray(); - var configured = configuredReports[0]; - var liveCandidates = discovery.ReportInventory.ReportControls - .Where(candidate => SameStaticReference(candidate.Reference, configured.Reference)) + // An indexed family can expose several concrete RCBs. Literal instance order is + // not an activation policy: Buffer01 may already be enabled/reserved/owned while + // Buffer02 is the usable instance. Reject only explicit operational blockers; + // Unknown remains eligible because some relays omit reservation metadata. Among + // equally matched candidates prefer exact identity, caller-owned/known-safe state, + // BRCB, explicitly disabled state, and finally stable literal order. + var liveCandidates = evaluatedLiveCandidates + .Where(item => StaticRcbAvailabilityRank(item.Availability.Availability) != int.MaxValue) + .Where(item => !ArMms.MmsReportSubscriptionPlanner.IsExplicitlyEnabled(item.Candidate)) + .Where(item => !ArMms.MmsReportSubscriptionPlanner.IsReservedByOtherClient(item.Candidate)) + .OrderBy(item => item.Rank) + .ThenBy(item => StaticRcbAvailabilityRank(item.Availability.Availability)) + .ThenByDescending(item => item.Configured.Buffered) + .ThenByDescending(item => ArMms.MmsReportSubscriptionPlanner.IsExplicitlyDisabled(item.Candidate)) + .ThenBy(item => item.Configured.Reference, StringComparer.OrdinalIgnoreCase) + .ThenBy(item => item.Candidate.Reference, StringComparer.OrdinalIgnoreCase) .ToArray(); - if (liveCandidates.Length != 1) + if (liveCandidates.Length == 0) { + if (evaluatedLiveCandidates.Length > 0) + { + var occupied = string.Join( + ", ", + evaluatedLiveCandidates.Take(8).Select(item => + $"{item.Configured.Reference}->{item.Candidate.Reference}[availability={item.Availability.Availability}, RptEna={item.Candidate.EnabledState}, Resv={item.Candidate.ReservationState}, ResvTms={item.Candidate.ReservationTimeSeconds}, Owner={item.Candidate.Owner}]")); + warnings.Add( + $"{dataSetReference}: exact/indexed-family RCB objects were found for authoritative configuration, but every concrete instance was explicitly unavailable/in-use ({occupied}). Static mode will not steal an occupied RCB and did not poll process values."); + continue; + } + + var configuredNames = string.Join(", ", configuredReports + .Select(report => report.Reference) + .Where(reference => !string.IsNullOrWhiteSpace(reference)) + .Take(8)); + var sameDataSet = discovery.ReportInventory.ReportControls + .Where(candidate => + !string.IsNullOrWhiteSpace(candidate.DataSetReference) && + SameStaticReference(candidate.DataSetReference, dataSetReference)) + .Select(candidate => candidate.Reference) + .Where(reference => !string.IsNullOrWhiteSpace(reference)) + .OrderBy(reference => reference, StringComparer.OrdinalIgnoreCase) + .Take(8) + .ToArray(); + var observed = sameDataSet.Length == 0 + ? "none" + : string.Join(", ", sameDataSet); warnings.Add( - $"{configured.Reference}: configured RCB was proven by SCL but exact live MMS discovery returned {liveCandidates.Length} matching RCB object(s). Static mode refused to guess and did not poll process values."); + $"{dataSetReference}: authoritative configured RCB(s) [{configuredNames}] had no exact/indexed-family live instance. Same-DataSet live RCBs: {observed}. Static mode refused arbitrary substitution and did not poll process values."); continue; } - var liveRcb = CloneReportControlForPlanning(liveCandidates[0]); - var dataSetReference = dataSetGroup.First().DataSetReference.Trim(); + var selected = liveCandidates[0]; + var configured = selected.Configured; + var liveSource = selected.Candidate; + var liveAvailability = selected.Availability; + var liveRcb = CloneReportControlForPlanning(liveSource); + + if (configuredReports.Length > 1 || liveCandidates.Length > 1) + { + warnings.Add( + $"{dataSetReference}: evaluated {configuredReports.Length} authoritative configured RCB(s) and {liveCandidates.Length} non-occupied exact/indexed live match(es); selected {configured.Reference} -> {liveSource.Reference} ({liveAvailability.Availability}). {configurationAuthorityLabel} remained authoritative."); + } if (!string.IsNullOrWhiteSpace(liveRcb.DataSetReference) && !SameStaticReference(liveRcb.DataSetReference, dataSetReference)) { warnings.Add( - $"{configured.Reference}: SCL binds {dataSetReference}, but live DatSet reports {liveRcb.DataSetReference}. Static mode refused the mismatch instead of guessing or polling."); + $"{configured.Reference}: authoritative configuration binds {dataSetReference}, but live DatSet reports {liveRcb.DataSetReference}. Static mode refused the mismatch instead of guessing or polling."); continue; } - // Missing live DatSet text is not treated as a reason to discard correct SCL - // configuration. The exact DataSet directory below is still required and is the - // ordered mapping authority for InformationReport values. + // Missing live DatSet text is not treated as a reason to discard correct + // authoritative configuration. The exact DataSet directory below is still required + // and is the ordered mapping authority for InformationReport values. if (string.IsNullOrWhiteSpace(liveRcb.DataSetReference)) liveRcb.DataSetReference = dataSetReference; @@ -149,13 +260,16 @@ public async Task BuildStaticDataSetReportPlan if (bindings.Count == 0) continue; + var concreteReportReference = string.IsNullOrWhiteSpace(liveRcb.Reference) + ? configured.Reference + : liveRcb.Reference; var plan = new ReportControlPlan { RelayId = device.DeviceId, RelayName = device.Name, RelayIpAddress = device.IpAddress, IedName = device.Name, - ReportControlReference = configured.Reference, + ReportControlReference = concreteReportReference, DataSetReference = dataSetReference, Mode = "Static DataSet • deterministic configured RCB", AllowDynamicDataSetWrites = false, @@ -171,10 +285,20 @@ public async Task BuildStaticDataSetReportPlan }; var subscriptionWarnings = new List(); - if (string.IsNullOrWhiteSpace(liveCandidates[0].DataSetReference)) + if (!Iec61850StaticRcbReferenceMatcher.IsExact(configured.Reference, concreteReportReference)) + { + subscriptionWarnings.Add( + $"Configured ReportControl family {configured.Reference} resolved to concrete live indexed instance {concreteReportReference}."); + } + if (liveAvailability.Availability == ArMms.MmsRcbOperationalAvailability.Unknown) { subscriptionWarnings.Add( - "Live RCB DatSet text was not returned; exact SCL RCB->DataSet configuration plus the successfully read live DataSet directory are the deterministic authority."); + $"Live RCB availability for {concreteReportReference} remains Unknown ({liveAvailability.Reason}). Static mode permits this because identity/configuration are authoritative and no explicit in-use evidence was observed."); + } + if (string.IsNullOrWhiteSpace(liveSource.DataSetReference)) + { + subscriptionWarnings.Add( + "Live RCB DatSet text was not returned; exact authoritative RCB->DataSet configuration plus the successfully read live DataSet directory are the deterministic authority."); } var subscription = new ArMms.MmsReportSubscriptionPlan @@ -187,7 +311,7 @@ public async Task BuildStaticDataSetReportPlan DynamicPoints = Array.Empty(), Steps = new[] { - $"Verify exact configured RCB {configured.Reference} exists on the live association.", + $"Verify authoritative configured RCB {configured.Reference} as live object {concreteReportReference}.", $"Use exact ordered live DataSet directory {dataSetReference} ({directory.Members.Count} members).", "Install InformationReport receiver before enabling the RCB.", "Write RptEna=true, then request GI=true.", @@ -384,6 +508,15 @@ private static NativeHybridReportPlanningResult StaticPlanningUnavailable( UncoveredSignalCount = points.Count }; + private static int StaticRcbAvailabilityRank(ArMms.MmsRcbOperationalAvailability availability) + => availability switch + { + ArMms.MmsRcbOperationalAvailability.UsedByCaller => 0, + ArMms.MmsRcbOperationalAvailability.Available => 1, + ArMms.MmsRcbOperationalAvailability.Unknown => 2, + _ => int.MaxValue + }; + private static bool SameStaticReference(string? left, string? right) => string.Equals( NormalizeStaticReference(left), @@ -391,7 +524,7 @@ private static bool SameStaticReference(string? left, string? right) StringComparison.OrdinalIgnoreCase); private static string NormalizeStaticReference(string? reference) - => (reference ?? string.Empty).Trim().Replace('$', '.'); + => Iec61850StaticRcbReferenceMatcher.Normalize(reference); private static int ParseStaticInteger(string? value) => int.TryParse(value, out var parsed) && parsed > 0 ? parsed : 0; diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 5d8014d0..94c589a8 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "69bfe70e2c779c7e8268af087bd1a3a38986c0fc", + "commit": "0d7525bd330900917fb9f6d15a46059dc3d7a70a", "sourcePullRequest": 111, - "purpose": "Pins the exact ARIEC61850 engine used by ARSAS while preserving the reviewed reporting/control ancestry. PR #76 preserves unresolved static DataSet members; PR #77 canonicalizes cross-logical-device SCL references; PR #78 keeps one descriptor per static DataSet member while separating the resolved runtime primary leaf from original FCDA/FCD identity; PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp; PR #80 normalizes validated DataRef-enabled InformationReport ordering; PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata; PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling; PR #85 evaluates association capabilities before automatic dynamic mutation; PR #86 records dynamic-attempt failure/skip evidence and best-effort rollback. PR #87 restores baseline-safe static precedence. PR #88 adds a fail-closed single-member DefineNamedVariableList -> GetNamedVariableListAttributes -> DeleteNamedVariableList probation with exact invoke/request/response/routing/member/association/cleanup evidence. PR #89 quarantines automatic full dynamic DataSet activation because a successful one-member NVL probation does not guarantee association survival; it also preserves safe instMag/mag and instCVal/cVal projection while ambiguous structures remain raw. PR #90 / field-proven engine a18e550d07f7bbe4ff7753c180b02615075f6292 preserves G1/G1.1 Smart Control: signed primitive constraints, ordered SBO/SBOw-to-Operate wire evidence, StationControl origin compatibility, and explicit MMS Write DataAccessError including object-access-denied. G2 PR #91 adds qualification-only bounded multi-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList evidence with exact ordered read-back, encoded request/PDU evidence and fail-closed cleanup; PR #92 adds the 1/4/8/16/32 qualification ladder, deterministic bisection and explicit EnvelopeQualified acceptance; PR #93 adds a default-disabled ExplicitCommissioning coordinator with hard attempt budget, exact-set failure localization and fresh-association stop semantics; PR #94 adds identity-bound qualification profiles and prevents ProductionEligible unless RCB activation, an actual correctly mapped InformationReport, and all G2.6 physical regression gates are proven. G2.4 engine PR #95 retains the commissioning-only transactional URCB TrgOps/OptFlds lease. P0 physically proved the corrected IEC 61850 MMS TrgOps reserved-bit mapping: bit 0 reserved, bits 1..5 dchg/qchg/dupd/integrity/GI, so dchg+GI encodes canonically as 0244; P0 also separates raw BER equality from IEC significant-bit equality and provides a one-URCB TrgOps-only micro-probe that never writes OptFlds, DatSet, Resv, RptEna, GI or any DataSet service. P1 adds a dedicated one-URCB OptFlds-only capture/write/readback/finally-restore micro-probe for reason-for-inclusion + data-set-name, canonical target 061800, using ten-bit significant-value comparison while never writing TrgOps, DatSet, Resv, RptEna, GI, Define/Delete DataSet, starting a report monitor, or changing profile state. The G2.4 Owner correction exposes the exact local TCP address of the active MMS association and fail-closed decodes a server RCB Owner as a 4-byte IPv4 or 16-byte IPv6 address; physical SIPROTEC Owner C0A851F0 decodes to 192.168.81.240 and may prove caller ownership only when it exactly matches the active local TCP endpoint. Owner mismatch or unsupported encoding remains a hard failure. Original RCB values remain captured for restore, raw BER evidence is retained, and Production automatic dynamic BRCB/URCB activation remains quarantined until a compatible ProductionEligible profile is consumed by a later G2 phase. FAT P5.3 engine PR #103 resolves intermediate structured static DataSet members such as MMXU A.phsA and PPV.phsAB only to typed descendants below the exact FCDA boundary, selects a unique semantic primary runtime leaf such as cVal.mag.f without crossing sibling phases, preserves original static membership identity, and leaves genuinely ambiguous structures unresolved rather than guessing. FAT P5.4 engine PR #106 adds fail-closed model-backed InformationReport projection for structured static DataSet members: an exact report member reference now resolves independently of sparse decoder-side report value position, while DataSet scope still prevents duplicate static memberships from collapsing; when a report omits the member reference, static DataSet index remains the unique fail-closed fallback. All schema-proven scalar descendants are fanned out without selecting a sibling phase, and schema mismatch preserves raw projection instead of guessing. ARSAS supplies the per-IED LiveDiscovery/SCL planning model at the report receive seam. PR #111 is a narrow continuation on the exact b9ee5fc ARSAS engine baseline: exact static DataSet/SCL semantic schema is now attempted before generic structured-value heuristics so TotPF and similar members can publish their exact scalar leaves; generic projection remains the fail-closed fallback, and report q/t companions are ordered ahead of semantic scalar values so ARSAS can preserve relay-native quality/timestamp without MMS polling." + "purpose": "Pins the exact ARIEC61850 engine used by ARSAS while preserving the reviewed reporting/control ancestry. PR #76 preserves unresolved static DataSet members; PR #77 canonicalizes cross-logical-device SCL references; PR #78 keeps one descriptor per static DataSet member while separating the resolved runtime primary leaf from original FCDA/FCD identity; PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp; PR #80 normalizes validated DataRef-enabled InformationReport ordering; PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata; PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling; PR #85 evaluates association capabilities before automatic dynamic mutation; PR #86 records dynamic-attempt failure/skip evidence and best-effort rollback. PR #87 restores baseline-safe static precedence. PR #88 adds a fail-closed single-member DefineNamedVariableList -> GetNamedVariableListAttributes -> DeleteNamedVariableList probation with exact invoke/request/response/routing/member/association/cleanup evidence. PR #89 quarantines automatic full dynamic DataSet activation because a successful one-member NVL probation does not guarantee association survival; it also preserves safe instMag/mag and instCVal/cVal projection while ambiguous structures remain raw. PR #90 / field-proven engine a18e550d07f7bbe4ff7753c180b02615075f6292 preserves G1/G1.1 Smart Control: signed primitive constraints, ordered SBO/SBOw-to-Operate wire evidence, StationControl origin compatibility, and explicit MMS Write DataAccessError including object-access-denied. G2 PR #91 adds qualification-only bounded multi-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList evidence with exact ordered read-back, encoded request/PDU evidence and fail-closed cleanup; PR #92 adds the 1/4/8/16/32 qualification ladder, deterministic bisection and explicit EnvelopeQualified acceptance; PR #93 adds a default-disabled ExplicitCommissioning coordinator with hard attempt budget, exact-set failure localization and fresh-association stop semantics; PR #94 adds identity-bound qualification profiles and prevents ProductionEligible unless RCB activation, an actual correctly mapped InformationReport, and all G2.6 physical regression gates are proven. G2.4 engine PR #95 retains the commissioning-only transactional URCB TrgOps/OptFlds lease. P0 physically proved the corrected IEC 61850 MMS TrgOps reserved-bit mapping: bit 0 reserved, bits 1..5 dchg/qchg/dupd/integrity/GI, so dchg+GI encodes canonically as 0244; P0 also separates raw BER equality from IEC significant-bit equality and provides a one-URCB TrgOps-only micro-probe that never writes OptFlds, DatSet, Resv, RptEna, GI or any DataSet service. P1 adds a dedicated one-URCB OptFlds-only capture/write/readback/finally-restore micro-probe for reason-for-inclusion + data-set-name, canonical target 061800, using ten-bit significant-value comparison while never writing TrgOps, DatSet, Resv, RptEna, GI, Define/Delete DataSet, starting a report monitor, or changing profile state. The G2.4 Owner correction exposes the exact local TCP address of the active MMS association and fail-closed decodes a server RCB Owner as a 4-byte IPv4 or 16-byte IPv6 address; physical SIPROTEC Owner C0A851F0 decodes to 192.168.81.240 and may prove caller ownership only when it exactly matches the active local TCP endpoint. Owner mismatch or unsupported encoding remains a hard failure. Original RCB values remain captured for restore, raw BER evidence is retained, and Production automatic dynamic BRCB/URCB activation remains quarantined until a compatible ProductionEligible profile is consumed by a later G2 phase. FAT P5.3 engine PR #103 resolves intermediate structured static DataSet members such as MMXU A.phsA and PPV.phsAB only to typed descendants below the exact FCDA boundary, selects a unique semantic primary runtime leaf such as cVal.mag.f without crossing sibling phases, preserves original static membership identity, and leaves genuinely ambiguous structures unresolved rather than guessing. FAT P5.4 engine PR #106 adds fail-closed model-backed InformationReport projection for structured static DataSet members: an exact report member reference now resolves independently of sparse decoder-side report value position, while DataSet scope still prevents duplicate static memberships from collapsing; when a report omits the member reference, static DataSet index remains the unique fail-closed fallback. All schema-proven scalar descendants are fanned out without selecting a sibling phase, and schema mismatch preserves raw projection instead of guessing. ARSAS supplies the per-IED LiveDiscovery/SCL planning model at the report receive seam. PR #111 is a narrow continuation on the exact b9ee5fc ARSAS engine baseline: exact static DataSet/SCL semantic schema is attempted before generic structured-value heuristics so TotPF and similar members publish exact scalar leaves; generic projection remains the fail-closed fallback, and report q/t companions are ordered ahead of semantic scalar values. P1 hardening at 0d7525bd330900917fb9f6d15a46059dc3d7a70a also makes semantic expansion return the resolved authoritative member identity and replaces generic output by report-value position after semantic success, so an InformationReport that omits MemberReference but resolves uniquely through static DataSet index cannot leak unrooted projected-mx-pair leaves alongside exact semantic values." } diff --git a/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs b/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs index af8ab324..ac7637b0 100644 --- a/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs +++ b/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs @@ -8,7 +8,8 @@ public void StaticPath_BypassesAdaptiveHybridPlannerAndPolling() var source = Read("Services/NativeIec61850Client.StaticDataSetReporting.cs"); Assert.Contains("Deterministic Static DataSet configured-RCB path", source, StringComparison.Ordinal); - Assert.Contains("model.ReportControls", source, StringComparison.Ordinal); + Assert.Contains("var configurationModel = projectionModel", source, StringComparison.Ordinal); + Assert.Contains("configurationModel.ReportControls", source, StringComparison.Ordinal); Assert.Contains("discovery.ReportInventory.ReportControls", source, StringComparison.Ordinal); Assert.Contains("GetDataSetDirectoriesAsync", source, StringComparison.Ordinal); Assert.Contains("MmsReportSubscriptionPlanStatus.ReadyRequiresWrite", source, StringComparison.Ordinal); @@ -17,6 +18,7 @@ public void StaticPath_BypassesAdaptiveHybridPlannerAndPolling() Assert.Contains("PollingPointKeys = Array.Empty()", source, StringComparison.Ordinal); Assert.Contains("PollingFallbackSignalCount = 0", source, StringComparison.Ordinal); + Assert.DoesNotContain("configurationModels", source, StringComparison.Ordinal); Assert.DoesNotContain("MmsCapabilityAwareHybridReportAcquisitionPlanner", source, StringComparison.Ordinal); Assert.DoesNotContain("BuildDynamicPlan", source, StringComparison.Ordinal); Assert.DoesNotContain("DefineNamedVariableList", source, StringComparison.Ordinal); @@ -24,17 +26,53 @@ public void StaticPath_BypassesAdaptiveHybridPlannerAndPolling() } [Fact] - public void StaticPath_RequiresExactConfiguredRcbAndOrderedLiveDataSetDirectory() + public void StaticPath_RequiresConfiguredRcbFamilyAndOrderedLiveDataSetDirectory() { var source = Read("Services/NativeIec61850Client.StaticDataSetReporting.cs"); Assert.Contains("SameStaticReference(report.DataSetReference, dataSetGroup.Key)", source, StringComparison.Ordinal); - Assert.Contains("SameStaticReference(candidate.Reference, configured.Reference)", source, StringComparison.Ordinal); + Assert.Contains("Iec61850StaticRcbReferenceMatcher.MatchRank", source, StringComparison.Ordinal); + Assert.Contains("arbitrary same-DataSet", source, StringComparison.OrdinalIgnoreCase); Assert.Contains("live DataSet directory could not prove an ordered non-empty member list", source, StringComparison.Ordinal); Assert.Contains("directory.Members", source, StringComparison.Ordinal); Assert.Contains("No MMS process polling was substituted", source, StringComparison.Ordinal); - Assert.Contains("SCL binds", source, StringComparison.Ordinal); + Assert.Contains("authoritative configuration binds", source, StringComparison.Ordinal); Assert.Contains("live DatSet reports", source, StringComparison.Ordinal); + Assert.Contains("ReportControlReference = concreteReportReference", source, StringComparison.Ordinal); + } + + [Fact] + public void StaticPath_SclAuthorityCannotBeDisplacedByLivePeerRcb() + { + var source = Read("Services/NativeIec61850Client.StaticDataSetReporting.cs"); + + Assert.Contains("device.SclWorkspace?.DesignModel ?? device.LiveDiscoveryModel", source, StringComparison.Ordinal); + Assert.Contains("only ReportControls from that", source, StringComparison.Ordinal); + Assert.Contains("design model may authorize static acquisition", source, StringComparison.Ordinal); + Assert.Contains("var configurationModel = projectionModel", source, StringComparison.Ordinal); + Assert.Contains("SelectMany(configured => discovery.ReportInventory.ReportControls", source, StringComparison.Ordinal); + Assert.DoesNotContain("SelectMany(configurationModel => configurationModel.ReportControls)", source, StringComparison.Ordinal); + Assert.DoesNotContain("var configured = configuredReports[0]", source, StringComparison.Ordinal); + Assert.Contains("remained authoritative", source, StringComparison.Ordinal); + } + + [Fact] + public void IndexedRcbFamily_PrefersNonOccupiedConcreteInstance() + { + var source = Read("Services/NativeIec61850Client.StaticDataSetReporting.cs"); + + Assert.Contains("matchedLiveCandidates", source, StringComparison.Ordinal); + Assert.Contains("evaluatedLiveCandidates", source, StringComparison.Ordinal); + Assert.Contains("MmsRcbAvailabilityEvaluator.Evaluate", source, StringComparison.Ordinal); + Assert.Contains("callerOwnedRcbReferences", source, StringComparison.Ordinal); + Assert.Contains("MmsReportSubscriptionPlanner.IsExplicitlyEnabled(item.Candidate)", source, StringComparison.Ordinal); + Assert.Contains("MmsReportSubscriptionPlanner.IsReservedByOtherClient(item.Candidate)", source, StringComparison.Ordinal); + Assert.Contains("MmsReportSubscriptionPlanner.IsExplicitlyDisabled(item.Candidate)", source, StringComparison.Ordinal); + Assert.Contains("StaticRcbAvailabilityRank", source, StringComparison.Ordinal); + Assert.Contains("Owner={item.Candidate.Owner}", source, StringComparison.Ordinal); + Assert.Contains("every concrete instance was explicitly unavailable/in-use", source, StringComparison.Ordinal); + Assert.Contains("Static mode will not steal an occupied RCB", source, StringComparison.Ordinal); + Assert.Contains("prefer exact identity, caller-owned/known-safe state", source, StringComparison.Ordinal); } [Fact] diff --git a/tests/ARSAS.Tests/FatScrollStabilityRegressionTests.cs b/tests/ARSAS.Tests/FatScrollStabilityRegressionTests.cs new file mode 100644 index 00000000..56d23d3e --- /dev/null +++ b/tests/ARSAS.Tests/FatScrollStabilityRegressionTests.cs @@ -0,0 +1,100 @@ +namespace ARSAS.Tests; + +public sealed class FatScrollStabilityRegressionTests +{ + [Fact] + public void Recovery_KeepsBuild1868Value1Value2GridContract() + { + var ux = File.ReadAllText(FindRepoFile("IoListTestingWindow.FatV2Ux.cs")); + + Assert.Contains("Header = \"TEST\"", ux, StringComparison.Ordinal); + Assert.Contains("TextColumn(\"SIGNAL\"", ux, StringComparison.Ordinal); + Assert.Contains("TextColumn(\"IEC REFERENCE\"", ux, StringComparison.Ordinal); + Assert.Contains("TextColumn(\"TYPE\"", ux, StringComparison.Ordinal); + Assert.Contains("Header = \"LIVE VALUE\"", ux, StringComparison.Ordinal); + Assert.Contains("Header = slot == FatValueSlot.Value1 ? \"VALUE 1\" : \"VALUE 2\"", ux, StringComparison.Ordinal); + Assert.Contains("TextColumn(\"STATUS\"", ux, StringComparison.Ordinal); + Assert.Contains("TextColumn(\"RESULT\"", ux, StringComparison.Ordinal); + Assert.DoesNotContain("ON · RELAY TIME", ux, StringComparison.Ordinal); + Assert.DoesNotContain("OFF · RELAY TIME", ux, StringComparison.Ordinal); + } + + [Fact] + public void Recovery_RestoresGoldenWpfVirtualizationAndForbidsRuntimeModeMutation() + { + var root = FindRepoRoot(); + var xaml = File.ReadAllText(Path.Combine(root, "IoListTestingWindow.xaml")); + + // The physical-bench failure proved that VirtualizationMode must never be mutated + // from Loaded/OnInitialized/runtime code. Restore the Build #1868 XAML-owned policy. + Assert.Contains("EnableRowVirtualization=\"True\"", xaml, StringComparison.Ordinal); + Assert.Contains("EnableColumnVirtualization=\"True\"", xaml, StringComparison.Ordinal); + Assert.Contains("VirtualizingPanel.IsVirtualizing=\"True\"", xaml, StringComparison.Ordinal); + Assert.Contains("VirtualizingPanel.VirtualizationMode=\"Recycling\"", xaml, StringComparison.Ordinal); + + Assert.False( + File.Exists(Path.Combine(root, "IoListTestingWindow.FatScrollStability.cs")), + "Do not reintroduce a runtime FAT virtualization patch. WPF throws if VirtualizationMode is changed after the ItemsHost has entered Measure."); + + foreach (var file in Directory.EnumerateFiles(root, "IoListTestingWindow*.cs", SearchOption.TopDirectoryOnly)) + { + var source = File.ReadAllText(file); + Assert.DoesNotContain("VirtualizingPanel.SetVirtualizationMode", source, StringComparison.Ordinal); + } + } + + [Fact] + public void Recovery_KeepsGoldenFatMembershipAndEngineeringReturnPath() + { + var fatUx = File.ReadAllText(FindRepoFile("IoListTestingWindow.FatV2Ux.cs")); + var fatModeSwitch = File.ReadAllText(FindRepoFile("IoListTestingWindow.WorkspaceModeSwitch.cs")); + var mainModeSwitch = File.ReadAllText(FindRepoFile("MainWindow.WorkspaceModeSwitch.cs")); + var sharedScl = File.ReadAllText(FindRepoFile("MainWindow.SharedSclWorkspace.cs")); + var fatHost = File.ReadAllText(FindRepoFile("MainWindow.IoTesting.cs")); + + Assert.Contains("point.WorkspaceSelected", fatUx, StringComparison.Ordinal); + Assert.Contains("point.IsIncludedInFat", fatUx, StringComparison.Ordinal); + + Assert.Contains("Engineering Workspace", fatModeSwitch, StringComparison.Ordinal); + Assert.Contains("Return to Engineering without unloading this FAT project", fatModeSwitch, StringComparison.Ordinal); + Assert.Contains("owner.ShowEngineeringWorkspaceFromFat(this)", fatModeSwitch, StringComparison.Ordinal); + Assert.Contains("Hide();", fatModeSwitch, StringComparison.Ordinal); + + Assert.Contains("_loadedIoFatWindow", mainModeSwitch, StringComparison.Ordinal); + Assert.Contains("ShowLoadedIoFatWorkspace", mainModeSwitch, StringComparison.Ordinal); + Assert.Contains("CurrentEngineeringSclSourcePaths", mainModeSwitch, StringComparison.Ordinal); + Assert.Contains("OpenSclFatSourcesAsync(sharedSources, selectionMode: null)", mainModeSwitch, StringComparison.Ordinal); + + Assert.Contains("_sharedSclSelectionAuthorityDeviceIds", sharedScl, StringComparison.Ordinal); + Assert.Contains("RegisterLoadedIoFatWindow(window)", fatHost, StringComparison.Ordinal); + Assert.DoesNotContain("window.ShowDialog();", fatHost, StringComparison.Ordinal); + } + + [Fact] + public void Recovery_DoesNotReintroducePerCellLiveValueAuthorityPatch() + { + var root = FindRepoRoot(); + var forbiddenPath = Path.Combine(root, "IoListTestingWindow.LiveValueAuthority.cs"); + + Assert.False( + File.Exists(forbiddenPath), + "The per-cell LIVE VALUE authority patch is forbidden on the Build #1868 recovery branch because it can fan out UI-thread subscriptions and regress FAT lifecycle behavior."); + } + + private static string FindRepoFile(string relativePath) + => Path.Combine(FindRepoRoot(), relativePath); + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + if (File.Exists(Path.Combine(directory.FullName, "IoListTestingWindow.FatV2Ux.cs"))) + return directory.FullName; + directory = directory.Parent; + } + + throw new DirectoryNotFoundException( + $"Could not locate repository root from '{AppContext.BaseDirectory}'."); + } +} diff --git a/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs b/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs index b9d397a6..03be0935 100644 --- a/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs +++ b/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs @@ -6,14 +6,18 @@ namespace ARSAS.Tests; public sealed class OfflineDataSetSignalSelectionRegressionTests { [Fact] - public void DeviceInventoryMerge_FallsBackToOfflineSclDesignModel() + public void DeviceInventoryMerge_PreservesOpenedSclAuthorityAcrossFastReconnect() { var source = File.ReadAllText(FindRepoFile("Services/Iec61850DataSetSignalInventoryService.cs")); Assert.Contains( - "device.LiveDiscoveryModel ?? device.SclWorkspace?.DesignModel", + "device.SclWorkspace?.DesignModel ?? device.LiveDiscoveryModel", source, StringComparison.Ordinal); + Assert.Contains( + "partial live discovery", + source, + StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain( "if (device.LiveDiscoveryModel is null)\n return EmptyResult();", source, diff --git a/tests/ARSAS.Tests/P0LifecycleAndTotPfRegressionTests.cs b/tests/ARSAS.Tests/P0LifecycleAndTotPfRegressionTests.cs index 3594d1ac..a82711b9 100644 --- a/tests/ARSAS.Tests/P0LifecycleAndTotPfRegressionTests.cs +++ b/tests/ARSAS.Tests/P0LifecycleAndTotPfRegressionTests.cs @@ -50,7 +50,10 @@ public void StaticReport_TotPf_UsesExactSemanticEngineAuthorityWithoutMmsFallbac var projection = Read("Services/StaticDataSetReportProjectionAccumulator.cs"); Assert.Contains("\"sourcePullRequest\": 111", engineLock, StringComparison.Ordinal); - Assert.Contains("69bfe70e2c779c7e8268af087bd1a3a38986c0fc", engineLock, StringComparison.Ordinal); + Assert.Contains("0d7525bd330900917fb9f6d15a46059dc3d7a70a", engineLock, StringComparison.Ordinal); + Assert.Contains("P1 hardening", engineLock, StringComparison.Ordinal); + Assert.Contains("report-value position", engineLock, StringComparison.Ordinal); + Assert.Contains("omits MemberReference", engineLock, StringComparison.Ordinal); Assert.Contains("MmsSemanticReportValueProjector.Project", semanticBridge, StringComparison.Ordinal); Assert.Contains("session.StaticReportProjection.Project", runtime, StringComparison.Ordinal); Assert.Contains("MMS process fallback is disabled", runtime, StringComparison.Ordinal); diff --git a/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs b/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs index 50a41a62..0e9e804b 100644 --- a/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs +++ b/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs @@ -40,7 +40,7 @@ public void RuntimeContract_StaticDataSetMode_DoesNotScheduleCyclicMmsProcessPol } [Fact] - public void SharedSclStaticSelection_UsesExactAriecMembershipRows_NotEveryDatasetTaggedAlias() + public void SharedSclStaticSelection_UsesOnlyExactRcbBackedAriecMembershipRows() { var source = File.ReadAllText(FindRepoFile("MainWindow.SharedSclWorkspace.cs")); var authority = File.ReadAllText(FindRepoFile("Services/Iec61850StaticDataSetAuthoritySelection.cs")); @@ -53,13 +53,62 @@ public void SharedSclStaticSelection_UsesExactAriecMembershipRows_NotEveryDatase Assert.DoesNotContain("UseStaticDataSetWithMmsFallback", source, StringComparison.Ordinal); Assert.DoesNotContain("fallback remains available", source, StringComparison.OrdinalIgnoreCase); - Assert.Contains("Iec61850DataSetSignalInventoryProjection.GetMandatorySignals(model)", authority, StringComparison.Ordinal); + Assert.Contains("var authorityModel = device.SclWorkspace?.DesignModel ?? device.LiveDiscoveryModel", authority, StringComparison.Ordinal); + Assert.Contains("Iec61850DataSetSignalInventoryProjection.GetMandatorySignals(authorityModel)", authority, StringComparison.Ordinal); + Assert.Contains("BuildReportBackedDataSetReferences(device)", authority, StringComparison.Ordinal); + Assert.Contains("reportBackedDataSets.Contains", authority, StringComparison.Ordinal); + Assert.Contains("var configurationModel = device.SclWorkspace?.DesignModel ?? device.LiveDiscoveryModel", authority, StringComparison.Ordinal); + Assert.Contains("AddReportBackedDataSets(configurationModel, result)", authority, StringComparison.Ordinal); + Assert.DoesNotContain("AddReportBackedDataSets(device.SclWorkspace?.DesignModel, result)", authority, StringComparison.Ordinal); + Assert.DoesNotContain("AddReportBackedDataSets(device.LiveDiscoveryModel, result)", authority, StringComparison.Ordinal); Assert.Contains("LiteralEquals(signal.DataSetReference, membership.DataSetReference)", authority, StringComparison.Ordinal); Assert.Contains("LiteralEquals(signal.DisplayReference, memberReference)", authority, StringComparison.Ordinal); Assert.DoesNotContain("StartsWith(memberReference", authority, StringComparison.Ordinal); Assert.DoesNotContain("Contains(memberReference", authority, StringComparison.Ordinal); } + [Theory] + [InlineData("IEDApplication/LLN0$BR$Buffer", "IEDApplication/LLN0$BR$Buffer01", true)] + [InlineData("IEDApplication/LLN0$BR$Buffer", "IEDApplication/LLN0$BR$Buffer02", true)] + [InlineData("IEDApplication/LLN0$BR$Buffer02", "IEDApplication/LLN0$BR$Buffer02", true)] + [InlineData("IEDApplication/LLN0$BR$Buffer02", "IEDApplication/LLN0$BR$Buffer01", false)] + [InlineData("IEDApplication/LLN0$BR$Buffer", "IEDApplication/LLN0$BR$Other01", false)] + [InlineData("IEDApplication/LLN0$BR$Buffer0", "IEDApplication/LLN0$BR$Buffer01", false)] + public void StaticRcbMatcher_AcceptsOnlyExactOrDecimalIndexedFamilyInstances( + string configured, + string live, + bool expected) + { + Assert.Equal(expected, Iec61850StaticRcbReferenceMatcher.IsConfiguredOrIndexedInstance(configured, live)); + } + + [Fact] + public void StaticRcbMatcher_ExactIdentityAlwaysRanksBeforeIndexedFamily() + { + const string configured = "IEDApplication/LLN0$BR$Buffer"; + Assert.Equal(0, Iec61850StaticRcbReferenceMatcher.MatchRank(configured, configured)); + Assert.Equal(1, Iec61850StaticRcbReferenceMatcher.MatchRank(configured, "IEDApplication/LLN0$BR$Buffer01")); + Assert.Equal(int.MaxValue, Iec61850StaticRcbReferenceMatcher.MatchRank(configured, "IEDApplication/LLN0$BR$Other01")); + } + + [Fact] + public void DeterministicStaticPlanner_PreservesSclConfigurationAndConcreteLiveRcbAuthority() + { + var source = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.StaticDataSetReporting.cs")); + + Assert.Contains("device.SclWorkspace?.DesignModel ?? device.LiveDiscoveryModel", source, StringComparison.Ordinal); + Assert.Contains("var configurationModel = projectionModel", source, StringComparison.Ordinal); + Assert.Contains("Iec61850StaticRcbReferenceMatcher.MatchRank", source, StringComparison.Ordinal); + Assert.Contains("SelectMany(configured => discovery.ReportInventory.ReportControls", source, StringComparison.Ordinal); + Assert.Contains("ReportControlReference = concreteReportReference", source, StringComparison.Ordinal); + Assert.Contains("Install InformationReport receiver before enabling the RCB", source, StringComparison.Ordinal); + Assert.Contains("Write RptEna=true, then request GI=true", source, StringComparison.Ordinal); + Assert.DoesNotContain("configurationModels", source, StringComparison.Ordinal); + Assert.DoesNotContain("var configured = configuredReports[0]", source, StringComparison.Ordinal); + Assert.DoesNotContain("AllowDynamicDataSetWrites = true", source, StringComparison.Ordinal); + Assert.DoesNotContain("PollingPointKeys = points.Select", source, StringComparison.Ordinal); + } + [Fact] public void StaticDataSetRegressionGuard_PreservesFa16ReportOnlyDirection() { @@ -103,8 +152,6 @@ public void SharedSclFat_IsConsumerOfExistingAcquisitionSession_NotASecondMonito Assert.Contains("if (!reuseSharedSclAcquisition)", source, StringComparison.Ordinal); Assert.Contains("Compatibility path for non-shared/legacy FAT only", source, StringComparison.Ordinal); - // The legacy helper may remain for workbook-only FAT, but the shared SCL branch - // must be structurally separate and must start through the normal Engineering owner. var sharedBranchStart = source.IndexOf("if (reuseSharedSclAcquisition)", StringComparison.Ordinal); var legacyBranchStart = source.IndexOf("Compatibility path for non-shared/legacy FAT only", StringComparison.Ordinal); Assert.True(sharedBranchStart >= 0 && legacyBranchStart > sharedBranchStart);