diff --git a/src/AR.Iec61850/Mms/MmsCanonicalStaticAcquisitionPreflight.cs b/src/AR.Iec61850/Mms/MmsCanonicalStaticAcquisitionPreflight.cs
index f1d66004..9070d02a 100644
--- a/src/AR.Iec61850/Mms/MmsCanonicalStaticAcquisitionPreflight.cs
+++ b/src/AR.Iec61850/Mms/MmsCanonicalStaticAcquisitionPreflight.cs
@@ -43,7 +43,10 @@ public sealed class MmsCanonicalStaticAcquisitionProbePlan
public sealed class MmsCanonicalStaticAcquisitionProbeOptions
{
+ /// Maximum exact RCBs inspected per page, not a global catalog cap.
public int MaxExactTargets { get; init; } = 64;
+ public int MaxExactTargetPagesPerInvocation { get; init; } = 8;
+ public string? ExactTargetContinuationToken { get; init; }
public bool ReadDataSetDirectories { get; init; } = true;
public IReadOnlySet CallerOwnedRcbReferences { get; init; }
= new HashSet(StringComparer.OrdinalIgnoreCase);
@@ -54,6 +57,9 @@ public sealed class MmsCanonicalStaticAcquisitionProbeResult
public MmsCanonicalStaticAcquisitionProbePlan Plan { get; init; } = new();
public MmsCanonicalStaticAcquisitionProbeStatus Status { get; init; }
public bool NetworkProbePerformed { get; init; }
+ public int ExactTargetPageCount { get; init; }
+ public int DeferredExactTargetCount { get; init; }
+ public string? ExactTargetContinuationToken { get; init; }
public MmsRcbAvailabilityResult? Availability { get; init; }
public IReadOnlyList Warnings { get; init; } = Array.Empty();
@@ -247,47 +253,101 @@ public async Task ProbeCanonicalStatic
};
}
- var maxTargets = Math.Clamp(options.MaxExactTargets, 1, 512);
- if (plan.ExactTargetReportControlReferences.Count > maxTargets)
+ var maxPerPage = Math.Clamp(options.MaxExactTargets, 1, 512);
+ var maxPages = Math.Clamp(options.MaxExactTargetPagesPerInvocation, 1, 32);
+ EnsureMmsReady();
+
+ var snapshots = new List();
+ var probeWarnings = new List();
+ var continuation = options.ExactTargetContinuationToken;
+ var totalExact = 0;
+ var pages = 0;
+ var targetedProbes = 0;
+ var stateReads = 0;
+ var directoryReads = 0;
+ var directoryHits = 0;
+
+ while (pages < maxPages && IsMmsInitiated)
{
- return new MmsCanonicalStaticAcquisitionProbeResult
- {
- Plan = plan,
- Status = MmsCanonicalStaticAcquisitionProbeStatus.TargetBudgetExceeded,
- NetworkProbePerformed = false,
- Warnings = plan.Warnings
- .Concat(new[]
- {
- $"Exact static RCB target count {plan.ExactTargetReportControlReferences.Count} exceeds the configured hot-path budget {maxTargets}; no partial probe was started."
- })
- .ToArray()
- };
- }
+ cancellationToken.ThrowIfCancellationRequested();
+ var page = MmsCanonicalStaticExactTargetPager.Select(
+ plan, liveInventory, maxPerPage, continuation,
+ _canonicalStaticRcbAssociationKey);
+ totalExact = page.TotalExactTargets;
+ continuation = page.ContinuationToken;
+ if (page.References.Count == 0)
+ break;
- EnsureMmsReady();
+ pages++;
+ targetedProbes += page.References.Count;
+ // Exact target list, never a broad RCB availability probe.
+ var batch = await CheckReportControlAvailabilityAsync(
+ liveInventory,
+ directory,
+ new MmsRcbAvailabilityOptions
+ {
+ MaxReportControls = maxPerPage,
+ ReadDataSetDirectories = options.ReadDataSetDirectories,
+ TargetReportControlReferences = page.References,
+ CallerOwnedRcbReferences = options.CallerOwnedRcbReferences
+ },
+ cancellationToken).ConfigureAwait(false);
+ snapshots.AddRange(batch.ReportControls);
+ probeWarnings.AddRange(batch.Warnings);
+ stateReads += batch.TargetedRcbStateLogicalReadCount;
+ directoryReads += batch.DataSetDirectoryNetworkReadCount;
+ directoryHits += batch.DataSetDirectoryCacheHitCount;
- var availability = await CheckReportControlAvailabilityAsync(
- liveInventory,
- directory,
- new MmsRcbAvailabilityOptions
- {
- MaxReportControls = maxTargets,
- ReadDataSetDirectories = options.ReadDataSetDirectories,
- TargetReportControlReferences = plan.ExactTargetReportControlReferences,
- CallerOwnedRcbReferences = options.CallerOwnedRcbReferences
- },
- cancellationToken).ConfigureAwait(false);
+ // Once each resolved DataSet segment has an independently eligible
+ // exact RCB, additional pages provide no benefit to this one-shot
+ // selected acquisition. Availability is READ-ONLY; the consumer
+ // still verifies ordered members and user authorization before writes.
+ var resolvedSegments = plan.TargetResolution.Segments
+ .Where(segment => segment.ExactLiveReportControlReferences.Count > 0)
+ .ToArray();
+ var allHaveEligible = resolvedSegments.Length > 0 &&
+ resolvedSegments.All(segment =>
+ snapshots.Any(snapshot =>
+ segment.ExactLiveReportControlReferences.Any(reference =>
+ string.Equals(
+ MmsRcbAvailabilityEvaluator.NormalizeReference(reference),
+ MmsRcbAvailabilityEvaluator.NormalizeReference(snapshot.Reference),
+ StringComparison.OrdinalIgnoreCase)) &&
+ MmsConfiguredStaticRcbEligibilityPolicy.Evaluate(
+ snapshot, allowCallerOwned: true,
+ allowReducedMissingReservationEvidence: true).IsEligible));
+ if (allHaveEligible || continuation is null)
+ break;
+ }
- var status = plan.UnresolvedSegmentCount > 0
- ? MmsCanonicalStaticAcquisitionProbeStatus.PartialAvailabilityChecked
- : MmsCanonicalStaticAcquisitionProbeStatus.AvailabilityChecked;
+ var deferred = Math.Max(0, totalExact - targetedProbes);
+ if (continuation is not null)
+ probeWarnings.Add(
+ $"Exact target availability deferred {deferred} RCB(s) after {pages} bounded page(s); resume using ExactTargetContinuationToken.");
+ var availability = new MmsRcbAvailabilityResult
+ {
+ ReportControls = snapshots,
+ Warnings = probeWarnings.Distinct(StringComparer.Ordinal).ToArray(),
+ InventoryReportControlCount = liveInventory.ReportControls.Count,
+ TargetFilterApplied = true,
+ RequestedTargetReportControlCount = plan.ExactTargetReportControlReferences.Count,
+ MatchedTargetReportControlCount = snapshots.Count,
+ TargetedRcbStateLogicalReadCount = stateReads,
+ DataSetDirectoryNetworkReadCount = directoryReads,
+ DataSetDirectoryCacheHitCount = directoryHits
+ };
return new MmsCanonicalStaticAcquisitionProbeResult
{
Plan = plan,
- Status = status,
- NetworkProbePerformed = true,
- Availability = availability,
+ Status = !IsMmsInitiated || pages == 0 || continuation is not null || plan.UnresolvedSegmentCount > 0
+ ? MmsCanonicalStaticAcquisitionProbeStatus.PartialAvailabilityChecked
+ : MmsCanonicalStaticAcquisitionProbeStatus.AvailabilityChecked,
+ NetworkProbePerformed = pages > 0,
+ Availability = pages > 0 ? availability : null,
+ ExactTargetPageCount = pages,
+ DeferredExactTargetCount = deferred,
+ ExactTargetContinuationToken = continuation,
Warnings = plan.Warnings
.Concat(availability.Warnings)
.Distinct(StringComparer.Ordinal)
diff --git a/src/AR.Iec61850/Mms/MmsCanonicalStaticExactTargetPager.cs b/src/AR.Iec61850/Mms/MmsCanonicalStaticExactTargetPager.cs
new file mode 100644
index 00000000..42c72f26
--- /dev/null
+++ b/src/AR.Iec61850/Mms/MmsCanonicalStaticExactTargetPager.cs
@@ -0,0 +1,72 @@
+using System.Security.Cryptography;
+using System.Text;
+
+namespace AR.Iec61850.Mms;
+
+public sealed class MmsCanonicalStaticExactTargetPage
+{
+ public IReadOnlySet References { get; init; } =
+ new HashSet(StringComparer.Ordinal);
+ public int TotalExactTargets { get; init; }
+ public string? ContinuationToken { get; init; }
+ public bool HasMore => ContinuationToken is not null;
+}
+
+///
+/// Resumable exact-target availability admission. Unlike a global 64/128 RCB
+/// abort, this pure selector constrains work per page. It cannot add targets
+/// missing from the verified live inventory or invent indexed RCB siblings.
+///
+public static class MmsCanonicalStaticExactTargetPager
+{
+ public static MmsCanonicalStaticExactTargetPage Select(
+ MmsCanonicalStaticAcquisitionProbePlan plan,
+ MmsReportInventory inventory,
+ int maxPerPage,
+ string? continuationToken = null,
+ string? associationKey = null)
+ {
+ ArgumentNullException.ThrowIfNull(plan);
+ ArgumentNullException.ThrowIfNull(inventory);
+ if (maxPerPage <= 0)
+ throw new ArgumentOutOfRangeException(nameof(maxPerPage));
+ if (inventory.Authority != MmsReportInventoryAuthority.LiveMmsObserved)
+ throw new ArgumentException("Exact target paging requires live MMS inventory.", nameof(inventory));
+
+ var liveReferences = inventory.ReportControls
+ .Select(candidate => MmsRcbAvailabilityEvaluator.NormalizeReference(candidate.Reference))
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+ var targets = plan.ExactTargetReportControlReferences
+ .Select(MmsRcbAvailabilityEvaluator.NormalizeReference)
+ .Where(reference => reference.Length > 0 && liveReferences.Contains(reference))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .OrderBy(reference => reference, StringComparer.Ordinal)
+ .ToArray();
+ var input = new StringBuilder().Append("association=")
+ .Append(associationKey ?? "detached").Append('\n');
+ foreach (var reference in targets)
+ input.Append(reference).Append('\n');
+ var fingerprint = Convert.ToHexString(
+ SHA256.HashData(Encoding.UTF8.GetBytes(input.ToString())));
+
+ var start = 0;
+ if (continuationToken is not null)
+ {
+ var parts = continuationToken.Split(':');
+ if (parts.Length != 2 ||
+ !string.Equals(parts[0], fingerprint, StringComparison.Ordinal) ||
+ !int.TryParse(parts[1], out start) || start < 0 || start >= targets.Length)
+ throw new ArgumentException(
+ "Exact RCB target continuation does not match current association/catalog.",
+ nameof(continuationToken));
+ }
+ var next = Math.Min(targets.Length, start + maxPerPage);
+ return new MmsCanonicalStaticExactTargetPage
+ {
+ TotalExactTargets = targets.Length,
+ References = targets.Skip(start).Take(next - start)
+ .ToHashSet(StringComparer.OrdinalIgnoreCase),
+ ContinuationToken = next < targets.Length ? $"{fingerprint}:{next}" : null
+ };
+ }
+}
diff --git a/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveReconciliation.cs b/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveReconciliation.cs
index d2f3b82e..414c24a7 100644
--- a/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveReconciliation.cs
+++ b/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveReconciliation.cs
@@ -51,10 +51,16 @@ public sealed class MmsCanonicalStaticLiveReconciliationOptions
public int UnknownPeerMaxConcurrentDomains { get; init; } = 2;
///
- /// Maximum exact live RCB candidates that may be read for DatSet binding evidence.
- /// The operation fails closed rather than reading an arbitrary subset.
+ /// Maximum RCB Read candidates PER PAGE. A partial catalog is resumable;
+ /// the total number of unrelated RCBs must never abort selected discovery.
///
public int MaxReportControlCandidates { get; init; } = 64;
+
+ /// Bounded batches per invocation; never a global catalog cap.
+ public int MaxCandidatePagesPerInvocation { get; init; } = 8;
+
+ /// Checked catalog continuation from a previously partial reconciliation.
+ public string? CandidateContinuationToken { get; init; }
}
public sealed class MmsCanonicalStaticLiveReconciliationResult
@@ -65,6 +71,9 @@ public sealed class MmsCanonicalStaticLiveReconciliationResult
public int EnumeratedVariableCount { get; init; }
public int CandidateReportControlCount { get; init; }
public int DataSetBoundReportControlCount { get; init; }
+ public int ExaminedReportControlCount { get; init; }
+ public int DeferredReportControlCount { get; init; }
+ public string? CandidateContinuationToken { get; init; }
public IReadOnlyList Warnings { get; init; } = Array.Empty();
public bool HasLiveInventory =>
@@ -73,7 +82,8 @@ public sealed class MmsCanonicalStaticLiveReconciliationResult
public string Summary =>
$"targeted live RCB reconciliation: status={Status}, domains={Plan.Domains.Count}, " +
$"rawVariables={EnumeratedVariableCount}, candidates={CandidateReportControlCount}, " +
- $"datasetBound={DataSetBoundReportControlCount}, authority={Inventory.Authority}.";
+ $"datasetBound={DataSetBoundReportControlCount}, examined={ExaminedReportControlCount}, " +
+ $"deferred={DeferredReportControlCount}, authority={Inventory.Authority}.";
}
public sealed class MmsCanonicalStaticSmartPreparationResult
@@ -181,6 +191,8 @@ private static string ResolveDomain(
public sealed partial class MmsClientSession
{
+ private string _canonicalStaticRcbAssociationKey = Guid.NewGuid().ToString("N");
+
///
/// Performs bounded online reconciliation for configured static reporting without
/// running full IED discovery.
@@ -265,75 +277,122 @@ public async Task ReconcileCanonical
DomainVariableLists = new Dictionary>(StringComparer.Ordinal)
});
- var targetsByDomain = plan.Domains.ToDictionary(
- target => target.Domain,
- StringComparer.Ordinal);
-
- var candidates = observed.ReportControls
- .Where(candidate =>
- targetsByDomain.TryGetValue(candidate.Domain, out var target) &&
- ((candidate.Buffered && target.NeedsBuffered) ||
- (!candidate.Buffered && target.NeedsUnbuffered)))
- .OrderBy(candidate => candidate.Domain, StringComparer.Ordinal)
- .ThenByDescending(candidate => candidate.Buffered)
- .ThenBy(candidate => candidate.Reference, StringComparer.Ordinal)
- .ToArray();
-
- var maxCandidates = Math.Clamp(options.MaxReportControlCandidates, 1, 512);
- if (candidates.Length > maxCandidates)
- {
- return new MmsCanonicalStaticLiveReconciliationResult
- {
- Plan = plan,
- Status = MmsCanonicalStaticLiveReconciliationStatus.CandidateBudgetExceeded,
- Inventory = emptyInventory,
- EnumeratedVariableCount = variables.Values.Sum(list => list.Count),
- CandidateReportControlCount = candidates.Length,
- Warnings = plan.Warnings
- .Concat(new[]
- {
- $"Target domains expose {candidates.Length} required-family RCB candidates, exceeding live reconciliation budget {maxCandidates}; no partial RCB read subset was started."
- })
- .ToArray()
- };
- }
+ // The raw RCB directory has no authoritative DatSet association yet.
+ // Stage A: index by the exact selected DataSet and configured references.
+ // Stage B: read only bounded pages of still-unknown compatible candidates.
+ // Never reject the complete operation because *global* RCB count is 66/300.
+ // Directory/catalog objects remain immutable while pages are calculated;
+ // successful MMS reads enrich separate candidates, not continuation state.
+ var maxReadsPerPage = Math.Clamp(options.MaxReportControlCandidates, 1, 512);
+ var maxReadPages = Math.Clamp(options.MaxCandidatePagesPerInvocation, 1, 32);
+ var expectedDataSets = coverage.Segments
+ .Where(segment => segment.SelectedSignalReferences.Length > 0)
+ .Select(segment => MmsRcbAvailabilityEvaluator.NormalizeReference(segment.DataSetReference))
+ .Where(reference => reference.Length > 0)
+ .ToHashSet(StringComparer.Ordinal);
var inventory = new MmsReportInventory
{
Authority = MmsReportInventoryAuthority.LiveMmsObserved
};
- inventory.ReportControls.AddRange(candidates);
-
- foreach (var candidate in candidates)
+ var examined = 0;
+ var rejected = 0;
+ var catalogCount = 0;
+ var eligibleProbeCount = 0;
+ var pages = 0;
+ var continuation = options.CandidateContinuationToken;
+
+ do
{
cancellationToken.ThrowIfCancellationRequested();
-
- await TryReadReportControlStructureSmartAsync(
- candidate,
- cancellationToken)
- .ConfigureAwait(false);
-
- if (string.IsNullOrWhiteSpace(candidate.DataSetReference))
+ // The pager binds its cursor to exact catalog content and coverage.
+ // A changed/ambiguous cursor must fail closed, not silently skip RCBs.
+ var page = MmsCanonicalStaticRcbCatalogPager.Select(
+ coverage, observed, maxReadsPerPage, continuation,
+ _canonicalStaticRcbAssociationKey);
+ catalogCount = page.CatalogCount;
+ eligibleProbeCount = page.EligibleProbeCount;
+ continuation = page.ContinuationToken;
+ pages++;
+
+ foreach (var raw in page.Candidates)
{
- await TryReadReportAttributeSmartAsync(
- candidate,
- "DatSet",
- cancellationToken)
- .ConfigureAwait(false);
+ cancellationToken.ThrowIfCancellationRequested();
+ if (!IsMmsInitiated)
+ break;
+
+ // Do not mutate the directory source used for stable paging.
+ var candidate = new MmsReportControlCandidate
+ {
+ Domain = raw.Domain,
+ LogicalNode = raw.LogicalNode,
+ FunctionalConstraint = raw.FunctionalConstraint,
+ Name = raw.Name,
+ Reference = raw.Reference,
+ Buffered = raw.Buffered,
+ DataSetReference = raw.DataSetReference,
+ Attributes = raw.Attributes.ToList()
+ };
+ examined++;
+ if (string.IsNullOrWhiteSpace(candidate.DataSetReference))
+ {
+ await TryReadReportControlStructureSmartAsync(
+ candidate, cancellationToken).ConfigureAwait(false);
+
+ if (string.IsNullOrWhiteSpace(candidate.DataSetReference))
+ await TryReadReportAttributeSmartAsync(
+ candidate, "DatSet", cancellationToken).ConfigureAwait(false);
+ }
+
+ // Exact live DatSet equality is mandatory. A matching SCL RCB
+ // name alone cannot become an operational report target.
+ if (expectedDataSets.Contains(
+ MmsRcbAvailabilityEvaluator.NormalizeReference(candidate.DataSetReference)))
+ inventory.ReportControls.Add(candidate);
+ else
+ rejected++;
}
- if (!IsMmsInitiated)
+ // Stop additional catalog pages once every selected DataSet has
+ // one exact observed binding with explicit read-only "appears free"
+ // evidence. This is NOT activation authorization: the subsequent
+ // exact-target availability/owner/directory checks still decide.
+ // A busy or reservation-unknown RCB never prematurely ends search.
+ var allTargetsHaveFreeEvidence = expectedDataSets.Count > 0 &&
+ expectedDataSets.All(dataSet => inventory.ReportControls.Any(candidate =>
+ string.Equals(
+ MmsRcbAvailabilityEvaluator.NormalizeReference(candidate.DataSetReference),
+ dataSet, StringComparison.Ordinal) &&
+ MmsRcbAvailabilityEvaluator.ParseBool(candidate.EnabledState) == false &&
+ !MmsRcbAvailabilityEvaluator.HasOwner(candidate.Owner) &&
+ (candidate.Buffered
+ ? MmsRcbAvailabilityEvaluator.ParseUnsigned(candidate.ReservationTimeSeconds) == 0
+ : MmsRcbAvailabilityEvaluator.ParseBool(candidate.ReservationState) == false)));
+ if (allTargetsHaveFreeEvidence)
break;
- }
-
- var complete = IsMmsInitiated && chains.All(chain => chain.IsComplete);
+ } while (continuation is not null &&
+ pages < maxReadPages &&
+ IsMmsInitiated);
+
+ var complete = IsMmsInitiated &&
+ chains.All(chain => chain.IsComplete) &&
+ continuation is null;
+ var coveredDataSets = inventory.ReportControls
+ .Select(candidate => MmsRcbAvailabilityEvaluator.NormalizeReference(candidate.DataSetReference))
+ .ToHashSet(StringComparer.Ordinal);
+ var unresolved = expectedDataSets.Where(ds => !coveredDataSets.Contains(ds)).ToArray();
var warnings = plan.Warnings
.Concat(chains
.Where(chain => !chain.IsComplete)
.Select(chain => $"Target domain '{chain.Domain}' NamedVariable enumeration is partial: {chain.Message}"))
- .Concat(candidates
- .Where(candidate => string.IsNullOrWhiteSpace(candidate.DataSetReference))
- .Select(candidate => $"Live RCB '{candidate.Reference}' did not expose a usable DatSet binding."))
+ .Concat(rejected > 0
+ ? new[] { $"{rejected} probed RCB(s) had no exact selected live DatSet binding; not admitted for activation." }
+ : Array.Empty())
+ .Concat(continuation is not null
+ ? new[] { $"Target-scoped RCB discovery deferred {Math.Max(0, eligibleProbeCount - examined)} candidate(s) after {pages} bounded page(s); resume using CandidateContinuationToken." }
+ : Array.Empty())
+ .Concat(unresolved.Select(ds =>
+ $"Selected DataSet '{ds}' has no verified live RCB binding in this acquisition window."))
.Distinct(StringComparer.Ordinal)
.ToArray();
@@ -342,14 +401,16 @@ await TryReadReportAttributeSmartAsync(
Plan = plan,
Status = !IsMmsInitiated
? MmsCanonicalStaticLiveReconciliationStatus.AssociationLost
- : complete
+ : complete && unresolved.Length == 0
? MmsCanonicalStaticLiveReconciliationStatus.Ready
: MmsCanonicalStaticLiveReconciliationStatus.Partial,
Inventory = inventory,
EnumeratedVariableCount = variables.Values.Sum(list => list.Count),
- CandidateReportControlCount = candidates.Length,
- DataSetBoundReportControlCount = candidates.Count(candidate =>
- !string.IsNullOrWhiteSpace(candidate.DataSetReference)),
+ CandidateReportControlCount = catalogCount,
+ DataSetBoundReportControlCount = inventory.ReportControls.Count,
+ ExaminedReportControlCount = examined,
+ DeferredReportControlCount = Math.Max(0, eligibleProbeCount - examined),
+ CandidateContinuationToken = continuation,
Warnings = warnings
};
}
diff --git a/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbCatalogPager.cs b/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbCatalogPager.cs
new file mode 100644
index 00000000..f436ba52
--- /dev/null
+++ b/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbCatalogPager.cs
@@ -0,0 +1,164 @@
+using System.Security.Cryptography;
+using System.Text;
+using AR.Iec61850.Engineering.Canonical;
+
+namespace AR.Iec61850.Mms;
+
+public sealed class MmsCanonicalStaticRcbCatalogPage
+{
+ public IReadOnlyList Candidates { get; init; }
+ = Array.Empty();
+ public int CatalogCount { get; init; }
+ public int TargetBoundCount { get; init; }
+ public int EligibleProbeCount { get; init; }
+ public string? ContinuationToken { get; init; }
+ public bool HasMore => ContinuationToken is not null;
+}
+
+///
+/// Read-only candidate admission for one selected canonical DataSet scope.
+/// An observed DatSet binding is authority; an exact configured RCB reference
+/// is only a priority for a Read, never proof of its current DatSet.
+/// No sibling name guess can authorize report activation.
+/// Catalog size is never a reason to abandon discovery: a page limits only
+/// the number of actual read candidates and carries a checked continuation.
+///
+public static class MmsCanonicalStaticRcbCatalogPager
+{
+ public static MmsCanonicalStaticRcbCatalogPage Select(
+ CanonicalStaticReportCoveragePlan coverage,
+ MmsReportInventory inventory,
+ int maxReads,
+ string? continuationToken = null,
+ string? associationKey = null)
+ {
+ ArgumentNullException.ThrowIfNull(coverage);
+ ArgumentNullException.ThrowIfNull(inventory);
+ if (maxReads <= 0)
+ throw new ArgumentOutOfRangeException(nameof(maxReads));
+
+ // Exact scoped keys are derived from selected SCL coverage, not a
+ // vendor-specific RCB-name pattern or an arbitrary inventory cap.
+ var requested = coverage.Segments
+ .Where(s => s.SelectedSignalReferences.Length > 0)
+ .SelectMany(segment => segment.ReportControls.Select(report => new
+ {
+ Domain = DomainOf(report.MmsDomain, report.Reference),
+ report.Buffered,
+ Configured = Normalize(report.Reference),
+ TargetDataSet = Normalize(segment.DataSetReference)
+ }))
+ .Where(target => target.Domain.Length > 0 && target.TargetDataSet.Length > 0)
+ .ToArray();
+
+ var selectedDomains = requested.Select(x => (x.Domain, x.Buffered))
+ .ToHashSet();
+ var targetDataSets = requested.Select(x => x.TargetDataSet)
+ .ToHashSet(StringComparer.Ordinal);
+ var configured = requested.Select(x => x.Configured)
+ .Where(x => x.Length > 0).ToHashSet(StringComparer.Ordinal);
+
+ var catalog = inventory.ReportControls
+ .Where(c => selectedDomains.Contains((c.Domain, c.Buffered)))
+ .Where(c =>
+ {
+ var bound = Normalize(c.DataSetReference);
+ return bound.Length == 0 || targetDataSets.Contains(bound);
+ })
+ .DistinctBy(c => Normalize(c.Reference), StringComparer.Ordinal)
+ .OrderBy(c => c.Reference, StringComparer.Ordinal)
+ .ToArray();
+
+ var bound = catalog
+ .Where(c => targetDataSets.Contains(Normalize(c.DataSetReference)))
+ .ToArray();
+ var exactUnbound = catalog
+ .Where(c => string.IsNullOrWhiteSpace(c.DataSetReference) &&
+ configured.Contains(Normalize(c.Reference)))
+ .ToArray();
+
+ // A proven DataSet scope does not trigger a blind read of other,
+ // still-unbound RCBs. Without proof, an unknown candidate requires
+ // a real read of DatSet; exact configured references are probed first.
+ // A concrete indexed RCB instance (e.g. Events01 for declared
+ // Events) is ONLY a prioritization hint for a read-only DatSet probe.
+ // This never constitutes exact DataSet identity or activation proof.
+ var indexedUnbound = catalog
+ .Where(c => string.IsNullOrWhiteSpace(c.DataSetReference) &&
+ !configured.Contains(Normalize(c.Reference)) &&
+ configured.Any(reference =>
+ IsIndexedInstance(Normalize(c.Reference), reference)))
+ .ToArray();
+ var indexedSet = indexedUnbound.ToHashSet();
+
+ var probes = bound.Length > 0
+ ? bound.Concat(exactUnbound).ToArray()
+ : exactUnbound.Concat(indexedUnbound).Concat(catalog
+ .Where(c => string.IsNullOrWhiteSpace(c.DataSetReference) &&
+ !configured.Contains(Normalize(c.Reference)) &&
+ !indexedSet.Contains(c))).ToArray();
+
+ // The token is bound to the exact catalog/selection. On a changed
+ // inventory it fails closed instead of skipping an arbitrary RCB.
+ var input = new StringBuilder();
+ input.Append("association=").Append(associationKey ?? "detached").Append('\n');
+ foreach (var target in requested
+ .OrderBy(t => t.Domain, StringComparer.Ordinal)
+ .ThenBy(t => t.Configured, StringComparer.Ordinal)
+ .ThenBy(t => t.TargetDataSet, StringComparer.Ordinal))
+ input.Append(target.Domain).Append('|').Append(target.Buffered)
+ .Append('|').Append(target.Configured).Append('|')
+ .Append(target.TargetDataSet).Append('\n');
+ foreach (var item in catalog)
+ input.Append(item.Reference).Append('|').Append(item.DataSetReference).Append('\n');
+ var fingerprint = Convert.ToHexString(
+ SHA256.HashData(Encoding.UTF8.GetBytes(input.ToString())));
+
+ var start = 0;
+ if (continuationToken is not null)
+ {
+ var parts = continuationToken.Split(':');
+ if (parts.Length != 2 ||
+ !string.Equals(parts[0], fingerprint, StringComparison.Ordinal) ||
+ !int.TryParse(parts[1], out start) || start < 0 || start >= probes.Length)
+ throw new ArgumentException(
+ "RCB continuation does not belong to the current target catalog.",
+ nameof(continuationToken));
+ }
+
+ var size = Math.Min(maxReads, probes.Length - start);
+ var next = start + size;
+ return new MmsCanonicalStaticRcbCatalogPage
+ {
+ CatalogCount = catalog.Length,
+ TargetBoundCount = bound.Length,
+ EligibleProbeCount = probes.Length,
+ Candidates = probes.Skip(start).Take(size).ToArray(),
+ ContinuationToken = next < probes.Length ? $"{fingerprint}:{next}" : null
+ };
+ }
+
+ private static bool IsIndexedInstance(string candidate, string configured)
+ {
+ if (configured.Length == 0 || candidate.Length <= configured.Length ||
+ !candidate.StartsWith(configured, StringComparison.Ordinal))
+ return false;
+ // Exact numeric instance suffix only: no arbitrary text similarity,
+ // and still no usable target until the live DatSet read succeeds.
+ for (var i = configured.Length; i < candidate.Length; i++)
+ if (candidate[i] is < '0' or > '9')
+ return false;
+ return true;
+ }
+
+ private static string Normalize(string? reference)
+ => MmsRcbAvailabilityEvaluator.NormalizeReference(reference);
+
+ private static string DomainOf(string? domain, string? reference)
+ {
+ if (!string.IsNullOrWhiteSpace(domain))
+ return domain.Trim();
+ var slash = reference?.IndexOf('/') ?? -1;
+ return slash > 0 ? reference![..slash] : string.Empty;
+ }
+}
diff --git a/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbTargetResolver.cs b/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbTargetResolver.cs
index da44d3b8..8e12ae40 100644
--- a/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbTargetResolver.cs
+++ b/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbTargetResolver.cs
@@ -66,6 +66,15 @@ public static MmsCanonicalStaticRcbTargetResolution Resolve(
var exactReferenceMatches = liveInventory.ReportControls
.Where(candidate => configuredReferences.Contains(Normalize(candidate.Reference)))
+ .Where(candidate =>
+ configuredBufferedFamilies.Count == 0 ||
+ configuredBufferedFamilies.Contains(candidate.Buffered))
+ .Where(candidate =>
+ canonicalDataSet.Length == 0 ||
+ string.IsNullOrWhiteSpace(candidate.DataSetReference) ||
+ string.Equals(
+ Normalize(candidate.DataSetReference),
+ canonicalDataSet, StringComparison.OrdinalIgnoreCase))
.ToArray();
var exactDataSetMatches = canonicalDataSet.Length == 0
diff --git a/src/AR.Iec61850/Mms/MmsClientSession.cs b/src/AR.Iec61850/Mms/MmsClientSession.cs
index 636182e7..0ca63d33 100644
--- a/src/AR.Iec61850/Mms/MmsClientSession.cs
+++ b/src/AR.Iec61850/Mms/MmsClientSession.cs
@@ -764,6 +764,8 @@ private async Task AssociateAsync(bool resetAssociationDiagnostics, Cancellation
if (result.IsAccepted)
{
State = MmsAssociationState.MmsInitiated;
+ // Bind paged RCB discovery to this accepted association.
+ _canonicalStaticRcbAssociationKey = Guid.NewGuid().ToString("N");
LastHandshakeMessage = result.Message;
ResetReportSemanticTypeEvidence();
ResetConfiguredStaticDataSetDirectoryEvidence();
diff --git a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticAcquisitionPreflightTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticAcquisitionPreflightTests.cs
index 484ebd86..d3a64b96 100644
--- a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticAcquisitionPreflightTests.cs
+++ b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticAcquisitionPreflightTests.cs
@@ -146,11 +146,14 @@ public async Task ExecutorRejectsInventoryAuthoritySwapBeforeNetworkWork()
Assert.Null(result.Availability);
}
- [Fact]
- public async Task TargetBudgetFailsBeforeAnyNetworkWork_AndNeverRunsPartialProbe()
+ [Theory]
+ [InlineData(66, 2)]
+ [InlineData(300, 5)]
+ public void Exact_Targets_Are_Paged_Without_The_Previous_Global_Budget_Abort(
+ int count, int expectedPages)
{
- var targetSet = Enumerable.Range(1, 3)
- .Select(index => $"LD0/LLN0.BR.Rpt{index:00}")
+ var targetSet = Enumerable.Range(1, count)
+ .Select(index => $"LD0/LLN0.BR.Rpt{index:000}")
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var targetResolution = new MmsCanonicalStaticRcbTargetResolution
{
@@ -168,35 +171,41 @@ public async Task TargetBudgetFailsBeforeAnyNetworkWork_AndNeverRunsPartialProbe
var plan = new MmsCanonicalStaticAcquisitionProbePlan
{
InventoryAuthority = MmsReportInventoryAuthority.LiveMmsObserved,
- Coverage = Coverage(
- Segment(
- "LD0/LLN0.Events",
- "LD0/LLN0.BR.Rpt01",
- buffered: true,
- "LD0/XCBR1.Pos.stVal")),
+ Coverage = Coverage(Segment(
+ "LD0/LLN0.Events", "LD0/LLN0.BR.Rpt",
+ buffered: true, "LD0/XCBR1.Pos.stVal")),
TargetResolution = targetResolution,
Status = MmsCanonicalStaticAcquisitionProbeStatus.ExactTargetsReady,
ExactTargetReportControlReferences = targetSet
};
+ var inventory = Inventory(
+ MmsReportInventoryAuthority.LiveMmsObserved,
+ targetSet.Select(r => Candidate(r, "LD0/LLN0.Events", true)).ToArray());
- await using var session = new MmsClientSession();
- var result = await session.ProbeCanonicalStaticAcquisitionAsync(
- plan,
- Inventory(
- MmsReportInventoryAuthority.LiveMmsObserved,
- Candidate("LD0/LLN0.BR.Rpt01", "LD0/LLN0.Events", true),
- Candidate("LD0/LLN0.BR.Rpt02", "LD0/LLN0.Events", true),
- Candidate("LD0/LLN0.BR.Rpt03", "LD0/LLN0.Events", true)),
- options: new MmsCanonicalStaticAcquisitionProbeOptions
- {
- MaxExactTargets = 2
- });
+ var examined = new HashSet(StringComparer.OrdinalIgnoreCase);
+ string? cursor = null;
+ var pages = 0;
+ do
+ {
+ var page = MmsCanonicalStaticExactTargetPager.Select(
+ plan, inventory, 64, cursor, "active-association");
+ Assert.InRange(page.References.Count, 1, 64);
+ foreach (var reference in page.References)
+ Assert.True(examined.Add(reference), "Duplicate exact target across pages.");
+ cursor = page.ContinuationToken;
+ pages++;
+ } while (cursor is not null && pages < 10);
- Assert.Equal(MmsCanonicalStaticAcquisitionProbeStatus.TargetBudgetExceeded, result.Status);
- Assert.False(result.NetworkProbePerformed);
- Assert.Null(result.Availability);
- Assert.Contains(result.Warnings, warning =>
- warning.Contains("no partial probe", StringComparison.OrdinalIgnoreCase));
+ Assert.Null(cursor);
+ Assert.Equal(count, examined.Count);
+ Assert.Equal(expectedPages, pages);
+
+ var first = MmsCanonicalStaticExactTargetPager.Select(
+ plan, inventory, 64, associationKey: "active-association");
+ Assert.NotNull(first.ContinuationToken);
+ Assert.Throws(() =>
+ MmsCanonicalStaticExactTargetPager.Select(
+ plan, inventory, 64, first.ContinuationToken, "reconnected-association"));
}
private static CanonicalStaticReportCoveragePlan Coverage(
diff --git a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbCatalogPagerTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbCatalogPagerTests.cs
new file mode 100644
index 00000000..76beb371
--- /dev/null
+++ b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbCatalogPagerTests.cs
@@ -0,0 +1,211 @@
+using AR.Iec61850.Engineering.Canonical;
+using AR.Iec61850.Mms;
+
+namespace AR.Iec61850.Tests.Mms;
+
+public sealed class MmsCanonicalStaticRcbCatalogPagerTests
+{
+ [Theory]
+ [InlineData(66)]
+ [InlineData(300)]
+ public void Proven_Target_DataSet_Does_Not_Probe_Unrelated_Rcb_Inventory(int total)
+ {
+ var catalog = Enumerable.Range(0, total - 2)
+ .Select(i => Candidate($"LD0/LLN0.BR.Noise{i:000}", "", enabled: false))
+ .Append(Candidate("LD0/LLN0.BR.Events01", "LD0/LLN0.Events", enabled: true))
+ .Append(Candidate("LD0/LLN0.BR.Events02", "LD0/LLN0.Events", enabled: false))
+ .ToArray();
+ var first = MmsCanonicalStaticRcbCatalogPager.Select(Coverage(), Inventory(catalog), 4);
+ var reversed = MmsCanonicalStaticRcbCatalogPager.Select(Coverage(), Inventory(catalog.Reverse().ToArray()), 4);
+
+ Assert.Equal(2, first.Candidates.Count);
+ Assert.Equal(first.Candidates.Select(x => x.Reference), reversed.Candidates.Select(x => x.Reference));
+ Assert.False(first.HasMore);
+ Assert.Equal(total, first.CatalogCount);
+ Assert.Equal(2, first.TargetBoundCount);
+ Assert.DoesNotContain(first.Candidates, x => x.Name.StartsWith("Noise", StringComparison.Ordinal));
+ Assert.All(first.Candidates, x => Assert.Equal("LD0/LLN0.Events", x.DataSetReference));
+
+ // Read-only discovery sees both matching RCBs; activation policy must
+ // reject the occupied one and choose the independently proven free one.
+ var occupied = first.Candidates.Single(x => x.EnabledState == "true");
+ var free = first.Candidates.Single(x => x.EnabledState == "false");
+ Assert.False(MmsConfiguredStaticRcbEligibilityPolicy.Evaluate(
+ VerifiedAvailability(occupied, MmsRcbOperationalAvailability.InUse, "60"),
+ allowCallerOwned: false,
+ allowReducedMissingReservationEvidence: false).IsEligible);
+ var eligibility = MmsConfiguredStaticRcbEligibilityPolicy.Evaluate(
+ VerifiedAvailability(free, MmsRcbOperationalAvailability.Available, "0"),
+ allowCallerOwned: false,
+ allowReducedMissingReservationEvidence: false);
+ Assert.Equal(MmsConfiguredStaticRcbEligibilityKind.ExplicitFree, eligibility.Kind);
+ }
+
+ private static MmsRcbAvailabilitySnapshot VerifiedAvailability(
+ MmsReportControlCandidate candidate,
+ MmsRcbOperationalAvailability state,
+ string reservation)
+ => new()
+ {
+ Reference = candidate.Reference,
+ Domain = candidate.Domain,
+ LogicalNode = candidate.LogicalNode,
+ Buffered = candidate.Buffered,
+ DataSetReference = candidate.DataSetReference,
+ DataSetProbeState = MmsRcbDataSetProbeState.ReadSucceeded,
+ EnabledState = candidate.EnabledState,
+ ReservationTimeSeconds = reservation,
+ DataSetDirectorySuccess = true,
+ DataSetMembers = [new MmsDataSetDirectoryMember
+ {
+ Domain = "LD0",
+ MmsItemName = "XCBR1$ST$Pos",
+ UserReference = "LD0/XCBR1.Pos",
+ FunctionalConstraint = "ST"
+ }],
+ Availability = state,
+ Confidence = MmsRcbAvailabilityConfidence.Exact
+ };
+
+ [Fact]
+ public void Unresolved_300_Rcbs_Continues_In_Bounded_Deterministic_Pages_Without_Duplicates()
+ {
+ var catalog = Enumerable.Range(0, 300)
+ .Select(i => Candidate($"LD0/LLN0.BR.Unbound{i:000}", ""))
+ .ToArray();
+ var inventory = Inventory(catalog);
+ string? continuation = null;
+ var visited = new List();
+ var pages = 0;
+ do
+ {
+ var page = MmsCanonicalStaticRcbCatalogPager.Select(Coverage(), inventory, 64, continuation);
+ Assert.InRange(page.Candidates.Count, 1, 64);
+ visited.AddRange(page.Candidates.Select(x => x.Reference));
+ continuation = page.ContinuationToken;
+ pages++;
+ } while (continuation is not null && pages < 10);
+
+ Assert.Null(continuation);
+ Assert.Equal(5, pages);
+ Assert.Equal(300, visited.Distinct(StringComparer.Ordinal).Count());
+ Assert.Equal(300, visited.Count);
+ }
+
+ [Fact]
+ public void Indexed_Configured_Family_Is_Read_Priority_Not_Trusted_DataSet_Proof()
+ {
+ var catalog = Enumerable.Range(0, 300)
+ .Select(i => Candidate($"LD0/LLN0.BR.Unrelated{i:000}", ""))
+ .Append(Candidate("LD0/LLN0.BR.Events01", ""))
+ .Append(Candidate("LD0/LLN0.BR.Events02", ""))
+ .ToArray();
+ var first = MmsCanonicalStaticRcbCatalogPager.Select(
+ Coverage(), Inventory(catalog), 2);
+ Assert.Equal(302, first.CatalogCount);
+ Assert.Equal(0, first.TargetBoundCount);
+ Assert.Equal(2, first.Candidates.Count);
+ Assert.Equal(
+ new[] { "LD0/LLN0.BR.Events01", "LD0/LLN0.BR.Events02" },
+ first.Candidates.Select(c => c.Reference).ToArray());
+ Assert.True(first.HasMore);
+ Assert.All(first.Candidates, c => Assert.Equal("", c.DataSetReference));
+ }
+
+ [Fact]
+ public void Catalog_Or_Target_Mutation_Invalidates_Continuation()
+ {
+ var catalog = Enumerable.Range(0, 66)
+ .Select(i => Candidate($"LD0/LLN0.BR.Unbound{i:000}", ""))
+ .ToArray();
+ var initial = MmsCanonicalStaticRcbCatalogPager.Select(Coverage(), Inventory(catalog), 16);
+ Assert.NotNull(initial.ContinuationToken);
+ var changed = catalog[..^1];
+ Assert.Throws(() =>
+ MmsCanonicalStaticRcbCatalogPager.Select(Coverage(), Inventory(changed), 16, initial.ContinuationToken));
+ }
+
+ [Fact]
+ public void Reconnect_Must_Reject_Prior_Association_Cursor()
+ {
+ var catalog = Enumerable.Range(0, 66)
+ .Select(i => Candidate($"LD0/LLN0.BR.Unbound{i:000}", ""))
+ .ToArray();
+ var inventory = Inventory(catalog);
+ var old = MmsCanonicalStaticRcbCatalogPager.Select(
+ Coverage(), inventory, 16, associationKey: "epoch-a");
+ Assert.NotNull(old.ContinuationToken);
+ Assert.Throws(() =>
+ MmsCanonicalStaticRcbCatalogPager.Select(
+ Coverage(), inventory, 16, old.ContinuationToken, "epoch-b"));
+ }
+
+ [Fact]
+ public void Wrong_Dataset_And_Different_Functional_Constraint_Are_Not_Admitted()
+ {
+ var inventory = Inventory(
+ Candidate("LD0/LLN0.BR.Events", "LD0/LLN0.Wrong"),
+ Candidate("LD0/LLN0.RP.Events", "LD0/LLN0.Events", buffered: false),
+ Candidate("LD0/LLN0.BR.Events02", "LD0/LLN0.Events"),
+ Candidate("LD1/LLN0.BR.Events03", "LD0/LLN0.Events"));
+ var page = MmsCanonicalStaticRcbCatalogPager.Select(Coverage(), inventory, 32);
+ Assert.Equal("LD0/LLN0.BR.Events02", Assert.Single(page.Candidates).Reference);
+ }
+
+ [Fact]
+ public void Exact_Configured_Reference_Is_Probe_Priority_But_Not_Proof_Of_Dataset()
+ {
+ var catalog = Enumerable.Range(0, 66)
+ .Select(i => Candidate($"LD0/LLN0.BR.Noise{i:000}", ""))
+ .Append(Candidate("LD0/LLN0.BR.Events", ""))
+ .ToArray();
+ var page = MmsCanonicalStaticRcbCatalogPager.Select(Coverage(), Inventory(catalog), 1);
+ Assert.Equal("LD0/LLN0.BR.Events", Assert.Single(page.Candidates).Reference);
+ Assert.Equal(0, page.TargetBoundCount);
+ Assert.True(page.HasMore);
+ }
+
+ private static CanonicalStaticReportCoveragePlan Coverage() => new()
+ {
+ Segments =
+ [
+ new CanonicalStaticReportCoverageSegment
+ {
+ DataSetReference = "LD0/LLN0.Events",
+ SourceDataSetReference = "LD0/LLN0.Events",
+ SelectedSignalReferences = ["LD0/XCBR1.Pos.stVal"],
+ ReportControls =
+ [
+ new CanonicalReportControl
+ {
+ Reference = "LD0/LLN0.BR.Events",
+ MmsDomain = "LD0",
+ Buffered = true,
+ DataSetReference = "LD0/LLN0.Events"
+ }
+ ]
+ }
+ ]
+ };
+
+ private static MmsReportInventory Inventory(params MmsReportControlCandidate[] candidates)
+ {
+ var inventory = new MmsReportInventory { Authority = MmsReportInventoryAuthority.LiveMmsObserved };
+ inventory.ReportControls.AddRange(candidates);
+ return inventory;
+ }
+
+ private static MmsReportControlCandidate Candidate(
+ string reference, string dataSet, bool enabled = false, bool buffered = true)
+ => new()
+ {
+ Domain = reference[..reference.IndexOf('/')],
+ LogicalNode = "LLN0",
+ FunctionalConstraint = buffered ? "BR" : "RP",
+ Name = reference[(reference.LastIndexOf('.') + 1)..],
+ Reference = reference,
+ Buffered = buffered,
+ DataSetReference = dataSet,
+ EnabledState = enabled ? "true" : "false"
+ };
+}
diff --git a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbTargetResolverTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbTargetResolverTests.cs
index 12bcbef0..5293bf0c 100644
--- a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbTargetResolverTests.cs
+++ b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbTargetResolverTests.cs
@@ -54,6 +54,25 @@ public void Exact_Configured_Reference_Can_Target_When_Live_Dataset_Binding_Is_N
Assert.Equal("exact-configured-rcb-reference", resolution.Segments.Single().Resolution);
}
+ [Fact]
+ public void Exact_Configured_Reference_With_Proven_Wrong_DataSet_Cannot_Be_Target()
+ {
+ var coverage = Coverage(
+ dataSet: "LD0/LLN0.Events",
+ configured: new CanonicalReportControl
+ {
+ Reference = "LD0/LLN0.BR.Events",
+ DataSetReference = "LD0/LLN0.Events",
+ Buffered = true
+ });
+ var inventory = Inventory(
+ Candidate("LD0/LLN0.BR.Events", "LD0/LLN0.Other", buffered: true));
+
+ var result = MmsCanonicalStaticRcbTargetResolver.Resolve(coverage, inventory);
+ Assert.False(result.HasTargets);
+ Assert.Equal("unresolved", Assert.Single(result.Segments).Resolution);
+ }
+
[Fact]
public void Unresolved_Segment_Does_Not_Guess_Name_Family()
{