diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index 588da198e..aea39751d 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -636,28 +636,32 @@ await _runtime.ReadBrowserValuesAsync(device, targets, // both cases as deferred/unknown, never as an IED AccessDenied. // Only the current explicit scope is accounted; metadata, // companion reads and unrelated model pages are excluded. + // Ledger truth, exact FC absence and the bounded queue must + // be classified once, identically for LN/DO/DataSet and SGCB. + // An Interrupted Read is NOT a confirmed terminal MMS failure. + bool IsObserved(IedBrowserReadTarget target) + => sourceDevice.LiveValues.TryGet( + target.Reference, target.FunctionalConstraint, out var value) && + !value.IsStale && value.Association == readAssociation; + bool HasTerminalFailure(IedBrowserReadTarget target) + { + if (_iedBrowserReadFailureEvidence is not { } evidence) + return false; + return evidence.TryGet(sourceDevice, target.Reference, "", + target.FunctionalConstraint, out _, out var kind) && + kind == IedBrowserReadAbsenceKind.Failed; + } + if (!readAll && currentPage?.Kind is "LN" or "DO" or "DA" or "DataSet" && configRequest is null && companionRequest is null && !reportUnitRequest) { var declaredSelection = IedBrowserReadPlan.Build( model, currentPage, readAll: false, selectedDoReference: selectedDo); - bool IsObserved(IedBrowserReadTarget target) - => sourceDevice.LiveValues.TryGet( - target.Reference, target.FunctionalConstraint, out var value) && - !value.IsStale && value.Association == readAssociation; - bool HasTerminalFailure(IedBrowserReadTarget target) - { - if (_iedBrowserReadFailureEvidence is not { } evidence) - return false; - return evidence.TryGet(sourceDevice, target.Reference, "", - target.FunctionalConstraint, out _, out var kind) && - kind != IedBrowserReadAbsenceKind.Deferred; - } - var gaps = IedBrowserReadCompletionAccounting.Classify( + var coverage = IedBrowserReadCompletionAccounting.Summarize( declaredSelection, targets, IsObserved, HasTerminalFailure); - unverifiedReadTargets = gaps.Count; - foreach (var gap in gaps) + unverifiedReadTargets = coverage.Gaps.Count; + foreach (var gap in coverage.Gaps) _iedBrowserReadFailureEvidence?.RecordDeferred(gap.Target, gap.Kind == IedBrowserDeferredReadKind.NotScheduled ? "Not scheduled in this bounded selected-object Read." @@ -666,38 +670,26 @@ bool HasTerminalFailure(IedBrowserReadTarget target) if (!readAll && currentPage?.Kind == "SGCB") { - // The completed 32-target focused chunk is NOT the total - // model inventory. Expose the accounted deferred remainder, - // including leaves never scheduled for MMS on this selection. + // SGCB uses the SAME exact-target accounting as LN/DO: + // 'queued' is not 'wire attempted', and one completed chunk + // is never represented as the whole setting-group inventory. var declared = IedBrowserReadPlan.Build(model, currentPage, readAll: false); - var observed = 0; - var unavailable = 0; - foreach (var target in declared) - { - if (sourceDevice.LiveValues.TryGet(target.Reference, - target.FunctionalConstraint, out var present) && - !present.IsStale && present.Association == readAssociation) - { - observed++; - continue; - } - if (_iedBrowserReadFailureEvidence?.TryGet( - sourceDevice, target.Reference, "", - target.FunctionalConstraint, out _, out var kind) == true && - kind != IedBrowserReadAbsenceKind.Deferred) - { - unavailable++; - continue; - } - _iedBrowserReadFailureEvidence?.RecordDeferred(target, - "Not yet scheduled within the bounded selected SGCB read; " + - "use explicit Read to request all verified SG leaves."); - } - var deferred = Math.Max(0, declared.Count - observed - unavailable); + var coverage = IedBrowserReadCompletionAccounting.Summarize( + declared, targets, IsObserved, HasTerminalFailure); + unverifiedReadTargets = coverage.Gaps.Count; + foreach (var gap in coverage.Gaps) + _iedBrowserReadFailureEvidence?.RecordDeferred(gap.Target, + gap.Kind == IedBrowserDeferredReadKind.NotScheduled + ? "Not scheduled in this bounded selected SGCB Read; " + + "use explicit Read for remaining verified SG leaves." + : "SGCB Read queued but no terminal result for this exact FC; " + + "on-wire attempt unverified."); IedBrowserSubtitle.Text = $"SGCB focused batch · {successes:N0}/{targets.Count:N0} received · " + - $"{observed:N0}/{declared.Count:N0} observed · " + - $"{unavailable:N0} unavailable · {deferred:N0} deferred"; + $"{coverage.ObservedTargets:N0}/{coverage.ConsideredTargets:N0} observed · " + + $"{coverage.TerminalFailures:N0} read failed · " + + $"{coverage.Gaps.Count:N0} deferred/unverified" + + (coverage.Truncated ? " · bounded evidence scope" : ""); } else IedBrowserSubtitle.Text = diff --git a/Models/IedBrowserReadCompletionAccounting.cs b/Models/IedBrowserReadCompletionAccounting.cs index 17aea6cb4..fe512d419 100644 --- a/Models/IedBrowserReadCompletionAccounting.cs +++ b/Models/IedBrowserReadCompletionAccounting.cs @@ -9,6 +9,18 @@ public enum IedBrowserDeferredReadKind public sealed record IedBrowserDeferredRead( IedBrowserReadTarget Target, IedBrowserDeferredReadKind Kind); +/// +/// One bounded, metadata-only summary of the exact selected Read scope. +/// A queued target is NOT proof that an MMS request reached the wire. +/// Observed values are read only from the caller's association-scoped ledger. +/// +public sealed record IedBrowserReadCoverageSummary( + int ConsideredTargets, + int ObservedTargets, + int TerminalFailures, + IReadOnlyList Gaps, + bool Truncated); + /// /// Describes read-coverage gaps on the operator's exact selection only. /// Queuing a variable is NOT evidence that an MMS Request was put on the wire. @@ -22,23 +34,52 @@ public static IReadOnlyList Classify( Func observed, Func terminalFailure, int maxTargets = 4096) + => Summarize(declared, scheduled, observed, terminalFailure, maxTargets).Gaps; + + /// + /// Count each exact FC target once, with observation preceding terminal + /// failure. Interrupted/unknown terminal results must be passed as false + /// to terminalFailure; the caller's absence ledger preserves their state. + /// + public static IedBrowserReadCoverageSummary Summarize( + IReadOnlyList declared, + IReadOnlyList scheduled, + Func observed, + Func terminalFailure, + int maxTargets = 4096) { ArgumentNullException.ThrowIfNull(declared); ArgumentNullException.ThrowIfNull(scheduled); ArgumentNullException.ThrowIfNull(observed); ArgumentNullException.ThrowIfNull(terminalFailure); - if (maxTargets < 1) return []; + if (maxTargets < 1) + return new IedBrowserReadCoverageSummary(0, 0, 0, [], declared.Count > 0); + + var considered = Math.Min(declared.Count, maxTargets); var scheduledKeys = scheduled.Select(Key).ToHashSet(StringComparer.Ordinal); - var output = new List(); - foreach (var target in declared.Take(maxTargets)) + var gaps = new List(); + var observedCount = 0; + var failedCount = 0; + for (var i = 0; i < considered; i++) { - if (observed(target) || terminalFailure(target)) continue; - output.Add(new IedBrowserDeferredRead(target, + var target = declared[i]; + if (observed(target)) + { + observedCount++; + continue; + } + if (terminalFailure(target)) + { + failedCount++; + continue; + } + gaps.Add(new IedBrowserDeferredRead(target, scheduledKeys.Contains(Key(target)) ? IedBrowserDeferredReadKind.TerminalResultUnverified : IedBrowserDeferredReadKind.NotScheduled)); } - return output; + return new IedBrowserReadCoverageSummary( + considered, observedCount, failedCount, gaps, declared.Count > considered); } private static string Key(IedBrowserReadTarget target) diff --git a/tests/ARSAS.Tests/IedBrowserReadResultInboxTests.cs b/tests/ARSAS.Tests/IedBrowserReadResultInboxTests.cs index cdd99c4be..7cc613d57 100644 --- a/tests/ARSAS.Tests/IedBrowserReadResultInboxTests.cs +++ b/tests/ARSAS.Tests/IedBrowserReadResultInboxTests.cs @@ -88,7 +88,11 @@ public void UiReadHandlerUsesExplicitInboxCompletionBeforeResultsSubtitle() var summary = normalized.IndexOf("Read finished ·", StringComparison.Ordinal); Assert.True(finishedProducers >= 0 && finalDrain > finishedProducers && catalogFence > finalDrain && summary > catalogFence); - Assert.Contains("IedBrowserReadCompletionAccounting.Classify(", normalized); + // SGCB and ordinary LN/DO reads share one bounded classification + // pass; the older Classify() API still delegates to this summary. + Assert.Contains("IedBrowserReadCompletionAccounting.Summarize(", normalized); + Assert.Contains("coverage.Gaps", normalized); + Assert.Contains("kind == IedBrowserReadAbsenceKind.Failed", normalized); Assert.Contains("deferred/unverified", normalized); Assert.Contains("sourceDevice.LiveValues.Association != readAssociation", source); } diff --git a/tests/ARSAS.Tests/P0DReadCompletionAccountingTests.cs b/tests/ARSAS.Tests/P0DReadCompletionAccountingTests.cs index 9346408c4..49d2d8cc5 100644 --- a/tests/ARSAS.Tests/P0DReadCompletionAccountingTests.cs +++ b/tests/ARSAS.Tests/P0DReadCompletionAccountingTests.cs @@ -39,6 +39,70 @@ public void FailureAndValidObservationCannotBeErasedByDeferredMarker() Assert.Empty(list); } + [Fact] + public void SgcbFocusedChunk_DoesNotConflateQueuedUnknownWithNeverScheduled() + { + var declared = new[] { + Target("vendor", "SG"), + Target("swRev", "SG"), + Target("d", "SG"), + Target("configRev", "SG"), + Target("swRev", "SE") + }; + var scheduled = new[] {declared[0], declared[1], declared[2]}; + var coverage = IedBrowserReadCompletionAccounting.Summarize( + declared, scheduled, + target => target.Reference == declared[0].Reference || + target.Reference == declared[4].Reference, + target => target.Reference == declared[2].Reference); + + Assert.Equal(5, coverage.ConsideredTargets); + Assert.Equal(2, coverage.ObservedTargets); + Assert.Equal(1, coverage.TerminalFailures); + Assert.False(coverage.Truncated); + Assert.Equal(2, coverage.Gaps.Count); + Assert.Equal(declared[1], coverage.Gaps[0].Target); + Assert.Equal(IedBrowserDeferredReadKind.TerminalResultUnverified, + coverage.Gaps[0].Kind); + Assert.Equal(declared[3], coverage.Gaps[1].Target); + Assert.Equal(IedBrowserDeferredReadKind.NotScheduled, + coverage.Gaps[1].Kind); + // The SG target must never borrow the same-name SE observation. + Assert.DoesNotContain(coverage.Gaps, gap => + gap.Target.FunctionalConstraint == "SE"); + } + + [Fact] + public void InterruptedQueuedRead_IsNotATerminalAccessResultFailure() + { + var requested = Target("setMag", "SG"); + var summary = IedBrowserReadCompletionAccounting.Summarize( + [requested], [requested], + _ => false, + // An interrupted response has NO confirmed terminal wire result. + _ => false); + Assert.Equal(0, summary.TerminalFailures); + Assert.Equal(IedBrowserDeferredReadKind.TerminalResultUnverified, + Assert.Single(summary.Gaps).Kind); + } + + [Fact] + public void SummaryBoundsAllEvidenceAndDoesNotExecuteBeyondScope() + { + var targets = Enumerable.Range(0, 8) + .Select(i => Target("attr" + i, "SG")).ToArray(); + var called = 0; + var summary = IedBrowserReadCompletionAccounting.Summarize( + targets, targets, + _ => { called++; return false; }, _ => false, maxTargets: 3); + Assert.Equal(3, called); + Assert.Equal(3, summary.ConsideredTargets); + Assert.True(summary.Truncated); + Assert.Equal(3, summary.Gaps.Count); + Assert.All(summary.Gaps, + gap => Assert.Equal(IedBrowserDeferredReadKind.TerminalResultUnverified, gap.Kind)); + } + [Fact] public void CaseSensitiveIecReference_AndBoundedPageCannotBorrowSiblingEvidence() {