From 6f25bd1a923e0bfac4c780509fc049e09e64875f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 21 Aug 2026 13:00:27 +0700 Subject: [PATCH 1/4] G2.5-B: add fail-closed shadow verification contract --- .../DynamicReportShadowVerificationService.cs | 309 ++++++++++++++++++ 1 file changed, 309 insertions(+) create mode 100644 Services/DynamicReportShadowVerificationService.cs diff --git a/Services/DynamicReportShadowVerificationService.cs b/Services/DynamicReportShadowVerificationService.cs new file mode 100644 index 00000000..ffb9ebe2 --- /dev/null +++ b/Services/DynamicReportShadowVerificationService.cs @@ -0,0 +1,309 @@ +using System.Globalization; + +namespace ArIED61850Tester.Services; + +internal sealed class DynamicReportShadowEventObservation +{ + public int EventOrdinal { get; init; } + public int DataSetIndex { get; init; } + public string MemberReference { get; init; } = string.Empty; + public string ReportValue { get; init; } = string.Empty; + public DateTimeOffset ReportObservedAtUtc { get; init; } + public IReadOnlyList Reasons { get; init; } = Array.Empty(); +} + +internal sealed class DynamicReportShadowReadObservation +{ + public int EventOrdinal { get; init; } + public int DataSetIndex { get; init; } + public string MemberReference { get; init; } = string.Empty; + public bool IsSuccess { get; init; } + public string DirectReadValue { get; init; } = string.Empty; + public DateTimeOffset ReadObservedAtUtc { get; init; } +} + +internal sealed class DynamicReportShadowEventAgreement +{ + public bool IsSuccess { get; init; } + public int EventOrdinal { get; init; } + public int DataSetIndex { get; init; } + public string MemberReference { get; init; } = string.Empty; + public string ReportValue { get; init; } = string.Empty; + public string DirectReadValue { get; init; } = string.Empty; + public TimeSpan VerificationLag { get; init; } + public string Reason { get; init; } = string.Empty; +} + +internal sealed class DynamicReportShadowVerificationResult +{ + public bool IsSuccess { get; init; } + public bool IsBlocked { get; init; } + public bool PrerequisiteAccepted { get; init; } + public int RequiredAgreementCount { get; init; } + public int AgreementCount { get; init; } + public int MismatchCount { get; init; } + public string Summary { get; init; } = string.Empty; + public IReadOnlyList Agreements { get; init; } = Array.Empty(); + public IReadOnlyList EvidenceLines { get; init; } = Array.Empty(); +} + +/// +/// G2.5-B shadow-verification contract. +/// +/// This phase does not activate production dynamic reporting and intentionally has no +/// operator shortcut yet. It is a fail-closed verifier that may only be entered after +/// a physical G2.5-A1 result proves both a real qualified-member transition and a +/// correlated spontaneous dchg report. Shadow observations compare report values with +/// independent direct MMS reads from the same exact member/index within a bounded lag. +/// +/// Runtime wiring is intentionally withheld until G2.5-A1 physical acceptance. This +/// keeps PR #226/A1 immutable and prevents G2.5-B from becoming an accidental bypass. +/// +internal sealed class DynamicReportShadowVerificationService +{ + internal const int RequiredConsecutiveAgreements = 3; + internal const int MaximumAllowedMismatches = 0; + internal static readonly TimeSpan MaximumVerificationLag = TimeSpan.FromSeconds(2); + + internal static bool IsPrerequisiteAccepted( + DynamicReportStimulusWitnessCommissioningResult? prerequisite, + out string reason) + { + if (prerequisite is null) + { + reason = "G2.5-B requires a physical G2.5-A1 result; none was supplied."; + return false; + } + + if (!prerequisite.IsSuccess) + { + reason = "G2.5-A1 did not PASS."; + return false; + } + + if (!prerequisite.StimulusWitnessProven || !prerequisite.Witness.ChangeObserved) + { + reason = "G2.5-A1 did not prove that a qualified member actually changed."; + return false; + } + + if (!prerequisite.ReportCorrelationProven || prerequisite.CorrelatedIndexes.Count == 0) + { + reason = "G2.5-A1 did not prove correlation between the witnessed transition and the spontaneous dchg report."; + return false; + } + + if (!prerequisite.CoreResult.IsSuccess || + !prerequisite.CoreResult.ActivationProven || + !prerequisite.CoreResult.SpontaneousDataChangeProven || + !prerequisite.CoreResult.MonitorCleanupSucceeded || + !prerequisite.CoreResult.ProofFieldRestoreSucceeded || + !prerequisite.CoreResult.FreshCleanupClosureSucceeded) + { + reason = "The underlying G2.5-A physical proof or cleanup closure is incomplete."; + return false; + } + + reason = "Physical G2.5-A1 prerequisite accepted: stimulus transition + correlated spontaneous dchg + complete cleanup are proven."; + return true; + } + + internal static DynamicReportShadowEventAgreement CompareEvent( + DynamicReportShadowEventObservation report, + DynamicReportShadowReadObservation directRead) + { + ArgumentNullException.ThrowIfNull(report); + ArgumentNullException.ThrowIfNull(directRead); + + if (report.EventOrdinal != directRead.EventOrdinal) + return Fail(report, directRead, "Event ordinal mismatch."); + + if (report.DataSetIndex != directRead.DataSetIndex) + return Fail(report, directRead, "DataSet index mismatch."); + + if (!SameReference(report.MemberReference, directRead.MemberReference)) + return Fail(report, directRead, "Member reference mismatch."); + + var reasons = report.Reasons + .Where(item => !string.IsNullOrWhiteSpace(item)) + .Select(item => item.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (!reasons.Contains("data-change", StringComparer.OrdinalIgnoreCase)) + return Fail(report, directRead, "Shadow event is not reason-for-inclusion=data-change."); + + if (reasons.Any(item => + item.Equals("general-interrogation", StringComparison.OrdinalIgnoreCase) || + item.Equals("integrity", StringComparison.OrdinalIgnoreCase) || + item.Equals("quality-change", StringComparison.OrdinalIgnoreCase) || + item.Equals("data-update", StringComparison.OrdinalIgnoreCase))) + { + return Fail(report, directRead, "Shadow event carries a non-dchg reason and is rejected fail-closed."); + } + + if (!directRead.IsSuccess) + return Fail(report, directRead, "Independent direct MMS verification read failed."); + + var lag = directRead.ReadObservedAtUtc - report.ReportObservedAtUtc; + if (lag < TimeSpan.Zero) + return Fail(report, directRead, "Direct-read observation predates the report observation."); + + if (lag > MaximumVerificationLag) + return Fail(report, directRead, $"Direct-read verification lag {lag.TotalMilliseconds:0} ms exceeds {MaximumVerificationLag.TotalMilliseconds:0} ms."); + + var reportValue = NormalizeValue(report.ReportValue); + var readValue = NormalizeValue(directRead.DirectReadValue); + if (reportValue.Length == 0 || readValue.Length == 0) + return Fail(report, directRead, "Report/direct-read value is empty or not usable for comparison."); + + if (!string.Equals(reportValue, readValue, StringComparison.OrdinalIgnoreCase)) + return Fail(report, directRead, $"Shadow mismatch: report={reportValue}, directRead={readValue}."); + + return new DynamicReportShadowEventAgreement + { + IsSuccess = true, + EventOrdinal = report.EventOrdinal, + DataSetIndex = report.DataSetIndex, + MemberReference = report.MemberReference, + ReportValue = reportValue, + DirectReadValue = readValue, + VerificationLag = lag, + Reason = $"Exact member/index/value agreement within {lag.TotalMilliseconds:0} ms; dchg-only reason confirmed." + }; + } + + internal static DynamicReportShadowVerificationResult EvaluateSeries( + DynamicReportStimulusWitnessCommissioningResult? prerequisite, + IReadOnlyList reports, + IReadOnlyList directReads) + { + ArgumentNullException.ThrowIfNull(reports); + ArgumentNullException.ThrowIfNull(directReads); + + var evidence = new List + { + "G2.5-B contract: SHADOW ONLY. Dynamic report values are never authoritative for production in this phase.", + $"G2.5-B acceptance: {RequiredConsecutiveAgreements} exact independent report/direct-read agreements, zero mismatches, dchg-only reasons, bounded verification lag <= {MaximumVerificationLag.TotalMilliseconds:0} ms.", + "G2.5-B safety: no ProductionEligible mutation and production automatic dynamic reporting remains OFF." + }; + + if (!IsPrerequisiteAccepted(prerequisite, out var prerequisiteReason)) + { + evidence.Add("G2.5-B prerequisite rejected: " + prerequisiteReason); + return new DynamicReportShadowVerificationResult + { + IsBlocked = true, + RequiredAgreementCount = RequiredConsecutiveAgreements, + Summary = prerequisiteReason, + EvidenceLines = evidence.ToArray() + }; + } + + evidence.Add("G2.5-B prerequisite accepted: " + prerequisiteReason); + + var agreements = new List(); + var mismatchCount = 0; + var eventOrdinals = reports.Select(item => item.EventOrdinal).OrderBy(item => item).ToArray(); + if (eventOrdinals.Distinct().Count() != eventOrdinals.Length) + { + evidence.Add("G2.5-B rejected: duplicate report event ordinals are ambiguous."); + return FailSeries(evidence, agreements, 1, "Duplicate report event ordinals are not allowed."); + } + + foreach (var report in reports.OrderBy(item => item.EventOrdinal)) + { + var candidates = directReads.Where(read => read.EventOrdinal == report.EventOrdinal).ToArray(); + if (candidates.Length != 1) + { + mismatchCount++; + evidence.Add($"G2.5-B event {report.EventOrdinal}: expected exactly one independent direct-read observation, found {candidates.Length}."); + continue; + } + + var agreement = CompareEvent(report, candidates[0]); + agreements.Add(agreement); + if (!agreement.IsSuccess) + mismatchCount++; + + evidence.Add($"G2.5-B event {report.EventOrdinal}: success={agreement.IsSuccess}; index={report.DataSetIndex}; member={report.MemberReference}; report={agreement.ReportValue}; direct={agreement.DirectReadValue}; lagMs={agreement.VerificationLag.TotalMilliseconds:0}; reason={agreement.Reason}"); + } + + var successCount = agreements.Count(item => item.IsSuccess); + var success = mismatchCount <= MaximumAllowedMismatches && + successCount >= RequiredConsecutiveAgreements && + reports.Count >= RequiredConsecutiveAgreements; + + evidence.Add($"G2.5-B result: agreements={successCount}; mismatches={mismatchCount}; requiredAgreements={RequiredConsecutiveAgreements}; maxMismatches={MaximumAllowedMismatches}; success={success}"); + + return new DynamicReportShadowVerificationResult + { + IsSuccess = success, + PrerequisiteAccepted = true, + RequiredAgreementCount = RequiredConsecutiveAgreements, + AgreementCount = successCount, + MismatchCount = mismatchCount, + Summary = success + ? "G2.5-B PASS: three shadow dchg events agreed exactly with independent direct MMS verification and zero mismatches. This still does not make production dynamic reporting eligible." + : "G2.5-B did not meet the fail-closed shadow agreement gate. Production dynamic reporting remains OFF.", + Agreements = agreements.ToArray(), + EvidenceLines = evidence.ToArray() + }; + } + + internal static string NormalizeValue(string? value) + { + var text = (value ?? string.Empty).Trim(); + if (text.Length == 0) + return string.Empty; + + const string prefix = "Native MMS Confirmed-Read decoded value:"; + if (text.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + text = text[prefix.Length..].Trim().TrimEnd('.').Trim(); + + if (bool.TryParse(text, out var boolean)) + return boolean ? "true" : "false"; + + if (decimal.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var number)) + return number.ToString("G29", CultureInfo.InvariantCulture); + + return text; + } + + private static DynamicReportShadowEventAgreement Fail( + DynamicReportShadowEventObservation report, + DynamicReportShadowReadObservation directRead, + string reason) + => new() + { + EventOrdinal = report.EventOrdinal, + DataSetIndex = report.DataSetIndex, + MemberReference = report.MemberReference, + ReportValue = NormalizeValue(report.ReportValue), + DirectReadValue = NormalizeValue(directRead.DirectReadValue), + VerificationLag = directRead.ReadObservedAtUtc - report.ReportObservedAtUtc, + Reason = reason + }; + + private static DynamicReportShadowVerificationResult FailSeries( + IReadOnlyList evidence, + IReadOnlyList agreements, + int mismatchCount, + string summary) + => new() + { + PrerequisiteAccepted = true, + RequiredAgreementCount = RequiredConsecutiveAgreements, + AgreementCount = agreements.Count(item => item.IsSuccess), + MismatchCount = mismatchCount, + Summary = summary + " Production dynamic reporting remains OFF.", + Agreements = agreements.ToArray(), + EvidenceLines = evidence.ToArray() + }; + + private static bool SameReference(string? left, string? right) + => NormalizeReference(left).Equals(NormalizeReference(right), StringComparison.OrdinalIgnoreCase); + + private static string NormalizeReference(string? reference) + => (reference ?? string.Empty).Trim().Replace('$', '.'); +} From d3487c6a0714a1fd389e9020f70ef94374e18551 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 21 Aug 2026 13:01:00 +0700 Subject: [PATCH 2/4] G2.5-B: add shadow agreement and prerequisite regressions --- ...micReportShadowVerificationServiceTests.cs | 246 ++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 tests/ARSAS.Tests/DynamicReportShadowVerificationServiceTests.cs diff --git a/tests/ARSAS.Tests/DynamicReportShadowVerificationServiceTests.cs b/tests/ARSAS.Tests/DynamicReportShadowVerificationServiceTests.cs new file mode 100644 index 00000000..ff952835 --- /dev/null +++ b/tests/ARSAS.Tests/DynamicReportShadowVerificationServiceTests.cs @@ -0,0 +1,246 @@ +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class DynamicReportShadowVerificationServiceTests +{ + [Fact] + public void Prerequisite_AcceptsOnlyCompletePhysicalA1Proof() + { + var ok = DynamicReportShadowVerificationService.IsPrerequisiteAccepted(PassedPrerequisite(), out var reason); + Assert.True(ok, reason); + Assert.Contains("prerequisite accepted", reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Prerequisite_RejectsMissingStimulusCorrelationOrCleanup() + { + Assert.False(DynamicReportShadowVerificationService.IsPrerequisiteAccepted(null, out _)); + + var noStimulus = PassedPrerequisite() withChanges(p => + { + p.StimulusWitnessProven = false; + p.Witness = new DynamicReportStimulusWitnessResult { ChangeObserved = false }; + }); + Assert.False(DynamicReportShadowVerificationService.IsPrerequisiteAccepted(noStimulus, out _)); + + var noCorrelation = PassedPrerequisite() withChanges(p => + { + p.ReportCorrelationProven = false; + p.CorrelatedIndexes = Array.Empty(); + }); + Assert.False(DynamicReportShadowVerificationService.IsPrerequisiteAccepted(noCorrelation, out _)); + + var badCleanup = PassedPrerequisite() withChanges(p => + { + p.CoreResult = PassedCore() withChanges(core => core.FreshCleanupClosureSucceeded = false); + }); + Assert.False(DynamicReportShadowVerificationService.IsPrerequisiteAccepted(badCleanup, out _)); + } + + [Theory] + [InlineData("true", "TRUE")] + [InlineData("Native MMS Confirmed-Read decoded value: false.", "false")] + [InlineData("12.5000", "12.5000")] + public void CompareEvent_AcceptsExactNormalizedDchgAgreement(string reportValue, string directValue) + { + var t0 = DateTimeOffset.Parse("2026-08-21T05:00:00Z"); + var agreement = DynamicReportShadowVerificationService.CompareEvent( + Report(1, 2, "LD0/GGIO1$ST$A$stVal", reportValue, t0, ["data-change"]), + Read(1, 2, "LD0/GGIO1$ST$A$stVal", directValue, t0.AddMilliseconds(250))); + + Assert.True(agreement.IsSuccess, agreement.Reason); + Assert.True(agreement.VerificationLag <= DynamicReportShadowVerificationService.MaximumVerificationLag); + } + + [Fact] + public void CompareEvent_RejectsMismatchWrongReasonAndLateVerifier() + { + var t0 = DateTimeOffset.Parse("2026-08-21T05:00:00Z"); + + var mismatch = DynamicReportShadowVerificationService.CompareEvent( + Report(1, 0, "LD0/A", "true", t0, ["data-change"]), + Read(1, 0, "LD0/A", "false", t0.AddMilliseconds(100))); + Assert.False(mismatch.IsSuccess); + Assert.Contains("mismatch", mismatch.Reason, StringComparison.OrdinalIgnoreCase); + + var gi = DynamicReportShadowVerificationService.CompareEvent( + Report(1, 0, "LD0/A", "true", t0, ["data-change", "general-interrogation"]), + Read(1, 0, "LD0/A", "true", t0.AddMilliseconds(100))); + Assert.False(gi.IsSuccess); + Assert.Contains("non-dchg", gi.Reason, StringComparison.OrdinalIgnoreCase); + + var late = DynamicReportShadowVerificationService.CompareEvent( + Report(1, 0, "LD0/A", "true", t0, ["data-change"]), + Read(1, 0, "LD0/A", "true", t0.AddSeconds(3))); + Assert.False(late.IsSuccess); + Assert.Contains("exceeds", late.Reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void EvaluateSeries_RequiresThreeAgreementsAndZeroMismatch() + { + var t0 = DateTimeOffset.Parse("2026-08-21T05:00:00Z"); + var reports = new[] + { + Report(1, 0, "LD0/A", "true", t0, ["data-change"]), + Report(2, 1, "LD0/B", "false", t0.AddSeconds(5), ["data-change"]), + Report(3, 2, "LD0/C", "1", t0.AddSeconds(10), ["data-change"]) + }; + var reads = new[] + { + Read(1, 0, "LD0/A", "true", t0.AddMilliseconds(100)), + Read(2, 1, "LD0/B", "false", t0.AddSeconds(5.2)), + Read(3, 2, "LD0/C", "1.0", t0.AddSeconds(10.4)) + }; + + var result = DynamicReportShadowVerificationService.EvaluateSeries(PassedPrerequisite(), reports, reads); + Assert.True(result.IsSuccess, result.Summary); + Assert.Equal(3, result.AgreementCount); + Assert.Equal(0, result.MismatchCount); + } + + [Fact] + public void EvaluateSeries_FailsClosedOnSingleMismatchOrTooFewEvents() + { + var t0 = DateTimeOffset.Parse("2026-08-21T05:00:00Z"); + var reports = new[] + { + Report(1, 0, "LD0/A", "true", t0, ["data-change"]), + Report(2, 1, "LD0/B", "true", t0.AddSeconds(5), ["data-change"]), + Report(3, 2, "LD0/C", "true", t0.AddSeconds(10), ["data-change"]) + }; + var reads = new[] + { + Read(1, 0, "LD0/A", "true", t0.AddMilliseconds(50)), + Read(2, 1, "LD0/B", "false", t0.AddSeconds(5.1)), + Read(3, 2, "LD0/C", "true", t0.AddSeconds(10.1)) + }; + + var mismatch = DynamicReportShadowVerificationService.EvaluateSeries(PassedPrerequisite(), reports, reads); + Assert.False(mismatch.IsSuccess); + Assert.Equal(1, mismatch.MismatchCount); + + var tooFew = DynamicReportShadowVerificationService.EvaluateSeries( + PassedPrerequisite(), + reports.Take(2).ToArray(), + reads.Take(2).ToArray()); + Assert.False(tooFew.IsSuccess); + Assert.Equal(2, tooFew.AgreementCount); + } + + [Fact] + public void Source_IsShadowOnlyAndNotExposedBeforePhysicalA1Acceptance() + { + var root = RepoRoot(); + var service = File.ReadAllText(Path.Combine(root, "Services", "DynamicReportShadowVerificationService.cs")); + var ui = File.ReadAllText(Path.Combine(root, "DynamicReportQualificationUiBehavior.cs")); + var runtime = File.ReadAllText(Path.Combine(root, "Services", "Iec61850MonitorRuntime.cs")); + + Assert.Contains("RequiredConsecutiveAgreements = 3", service, StringComparison.Ordinal); + Assert.Contains("MaximumAllowedMismatches = 0", service, StringComparison.Ordinal); + Assert.Contains("SHADOW ONLY", service, StringComparison.Ordinal); + Assert.Contains("G2.5-A1", service, StringComparison.Ordinal); + Assert.DoesNotContain("SaveAsync(", service, StringComparison.Ordinal); + Assert.DoesNotContain("MarkProductionEligible", service, StringComparison.Ordinal); + Assert.DoesNotContain("WriteReportAttributeAsync", service, StringComparison.Ordinal); + Assert.DoesNotContain("StartPersistentReportMonitor", service, StringComparison.Ordinal); + Assert.DoesNotContain("RunG25B", ui, StringComparison.Ordinal); + Assert.DoesNotContain("Key.B", ui, StringComparison.Ordinal); + Assert.DoesNotContain("AllowDynamicBrcb = true", runtime, StringComparison.Ordinal); + Assert.DoesNotContain("AllowDynamicUrcb = true", runtime, StringComparison.Ordinal); + } + + private static DynamicReportStimulusWitnessCommissioningResult PassedPrerequisite() + => new() + { + IsSuccess = true, + StimulusWitnessProven = true, + ReportCorrelationProven = true, + CorrelatedIndexes = [1], + CoreResult = PassedCore(), + Witness = new DynamicReportStimulusWitnessResult + { + ChangeObserved = true, + BaselineCaptured = true, + AssociationHealthy = true, + Transitions = + [ + new DynamicReportStimulusWitnessTransition + { + Index = 1, + MemberReference = "LD0/B", + BeforeValue = "false", + AfterValue = "true", + ObservedAtUtc = DateTimeOffset.UtcNow + } + ] + } + }; + + private static DynamicReportSpontaneousDataChangeCommissioningResult PassedCore() + => new() + { + IsSuccess = true, + ActivationProven = true, + SpontaneousDataChangeProven = true, + AssociationHealthyAfterReport = true, + MonitorCleanupSucceeded = true, + ProofFieldRestoreSucceeded = true, + FreshCleanupClosureSucceeded = true, + IncludedIndexes = [1], + IncludedMemberReferences = ["LD0/B"], + Reasons = ["data-change"] + }; + + private static DynamicReportShadowEventObservation Report( + int ordinal, + int index, + string member, + string value, + DateTimeOffset at, + IReadOnlyList reasons) + => new() + { + EventOrdinal = ordinal, + DataSetIndex = index, + MemberReference = member, + ReportValue = value, + ReportObservedAtUtc = at, + Reasons = reasons + }; + + private static DynamicReportShadowReadObservation Read( + int ordinal, + int index, + string member, + string value, + DateTimeOffset at) + => new() + { + EventOrdinal = ordinal, + DataSetIndex = index, + MemberReference = member, + IsSuccess = true, + DirectReadValue = value, + ReadObservedAtUtc = at + }; + + private static T withChanges(this T value, Action change) where T : class + { + change(value); + return value; + } + + private static string RepoRoot() + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current is not null) + { + if (File.Exists(Path.Combine(current.FullName, "ArIED61850Tester.csproj"))) + return current.FullName; + current = current.Parent; + } + throw new DirectoryNotFoundException("ARSAS repository root not found."); + } +} From 5b5af6171689f40ab709d703b7fae2db13a5aa33 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 21 Aug 2026 13:01:36 +0700 Subject: [PATCH 3/4] G2.5-B: fix immutable test fixtures --- ...micReportShadowVerificationServiceTests.cs | 79 +++++++++---------- 1 file changed, 36 insertions(+), 43 deletions(-) diff --git a/tests/ARSAS.Tests/DynamicReportShadowVerificationServiceTests.cs b/tests/ARSAS.Tests/DynamicReportShadowVerificationServiceTests.cs index ff952835..7620c341 100644 --- a/tests/ARSAS.Tests/DynamicReportShadowVerificationServiceTests.cs +++ b/tests/ARSAS.Tests/DynamicReportShadowVerificationServiceTests.cs @@ -17,25 +17,14 @@ public void Prerequisite_RejectsMissingStimulusCorrelationOrCleanup() { Assert.False(DynamicReportShadowVerificationService.IsPrerequisiteAccepted(null, out _)); - var noStimulus = PassedPrerequisite() withChanges(p => - { - p.StimulusWitnessProven = false; - p.Witness = new DynamicReportStimulusWitnessResult { ChangeObserved = false }; - }); - Assert.False(DynamicReportShadowVerificationService.IsPrerequisiteAccepted(noStimulus, out _)); + Assert.False(DynamicReportShadowVerificationService.IsPrerequisiteAccepted( + BuildPrerequisite(stimulusWitnessProven: false, changeObserved: false), out _)); - var noCorrelation = PassedPrerequisite() withChanges(p => - { - p.ReportCorrelationProven = false; - p.CorrelatedIndexes = Array.Empty(); - }); - Assert.False(DynamicReportShadowVerificationService.IsPrerequisiteAccepted(noCorrelation, out _)); + Assert.False(DynamicReportShadowVerificationService.IsPrerequisiteAccepted( + BuildPrerequisite(reportCorrelationProven: false, correlatedIndexes: Array.Empty()), out _)); - var badCleanup = PassedPrerequisite() withChanges(p => - { - p.CoreResult = PassedCore() withChanges(core => core.FreshCleanupClosureSucceeded = false); - }); - Assert.False(DynamicReportShadowVerificationService.IsPrerequisiteAccepted(badCleanup, out _)); + Assert.False(DynamicReportShadowVerificationService.IsPrerequisiteAccepted( + BuildPrerequisite(freshCleanupClosureSucceeded: false), out _)); } [Theory] @@ -152,42 +141,52 @@ public void Source_IsShadowOnlyAndNotExposedBeforePhysicalA1Acceptance() } private static DynamicReportStimulusWitnessCommissioningResult PassedPrerequisite() + => BuildPrerequisite(); + + private static DynamicReportStimulusWitnessCommissioningResult BuildPrerequisite( + bool stimulusWitnessProven = true, + bool changeObserved = true, + bool reportCorrelationProven = true, + IReadOnlyList? correlatedIndexes = null, + bool freshCleanupClosureSucceeded = true) => new() { - IsSuccess = true, - StimulusWitnessProven = true, - ReportCorrelationProven = true, - CorrelatedIndexes = [1], - CoreResult = PassedCore(), + IsSuccess = stimulusWitnessProven && changeObserved && reportCorrelationProven && freshCleanupClosureSucceeded, + StimulusWitnessProven = stimulusWitnessProven, + ReportCorrelationProven = reportCorrelationProven, + CorrelatedIndexes = correlatedIndexes ?? [1], + CoreResult = PassedCore(freshCleanupClosureSucceeded), Witness = new DynamicReportStimulusWitnessResult { - ChangeObserved = true, + ChangeObserved = changeObserved, BaselineCaptured = true, AssociationHealthy = true, - Transitions = - [ - new DynamicReportStimulusWitnessTransition - { - Index = 1, - MemberReference = "LD0/B", - BeforeValue = "false", - AfterValue = "true", - ObservedAtUtc = DateTimeOffset.UtcNow - } - ] + Transitions = changeObserved + ? + [ + new DynamicReportStimulusWitnessTransition + { + Index = 1, + MemberReference = "LD0/B", + BeforeValue = "false", + AfterValue = "true", + ObservedAtUtc = DateTimeOffset.UtcNow + } + ] + : Array.Empty() } }; - private static DynamicReportSpontaneousDataChangeCommissioningResult PassedCore() + private static DynamicReportSpontaneousDataChangeCommissioningResult PassedCore(bool freshCleanupClosureSucceeded = true) => new() { - IsSuccess = true, + IsSuccess = freshCleanupClosureSucceeded, ActivationProven = true, SpontaneousDataChangeProven = true, AssociationHealthyAfterReport = true, MonitorCleanupSucceeded = true, ProofFieldRestoreSucceeded = true, - FreshCleanupClosureSucceeded = true, + FreshCleanupClosureSucceeded = freshCleanupClosureSucceeded, IncludedIndexes = [1], IncludedMemberReferences = ["LD0/B"], Reasons = ["data-change"] @@ -226,12 +225,6 @@ private static DynamicReportShadowReadObservation Read( ReadObservedAtUtc = at }; - private static T withChanges(this T value, Action change) where T : class - { - change(value); - return value; - } - private static string RepoRoot() { var current = new DirectoryInfo(AppContext.BaseDirectory); From 6bc3cd26d1b33274241c7b4ab0e915743fd282b0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 21 Aug 2026 13:06:09 +0700 Subject: [PATCH 4/4] G2.5-B: separate too-few good-event fixture --- ...ynamicReportShadowVerificationServiceTests.cs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/tests/ARSAS.Tests/DynamicReportShadowVerificationServiceTests.cs b/tests/ARSAS.Tests/DynamicReportShadowVerificationServiceTests.cs index 7620c341..98f45c30 100644 --- a/tests/ARSAS.Tests/DynamicReportShadowVerificationServiceTests.cs +++ b/tests/ARSAS.Tests/DynamicReportShadowVerificationServiceTests.cs @@ -110,12 +110,24 @@ public void EvaluateSeries_FailsClosedOnSingleMismatchOrTooFewEvents() Assert.False(mismatch.IsSuccess); Assert.Equal(1, mismatch.MismatchCount); + var twoGoodReports = new[] + { + Report(1, 0, "LD0/A", "true", t0, ["data-change"]), + Report(2, 1, "LD0/B", "false", t0.AddSeconds(5), ["data-change"]) + }; + var twoGoodReads = new[] + { + Read(1, 0, "LD0/A", "true", t0.AddMilliseconds(50)), + Read(2, 1, "LD0/B", "false", t0.AddSeconds(5.1)) + }; + var tooFew = DynamicReportShadowVerificationService.EvaluateSeries( PassedPrerequisite(), - reports.Take(2).ToArray(), - reads.Take(2).ToArray()); + twoGoodReports, + twoGoodReads); Assert.False(tooFew.IsSuccess); Assert.Equal(2, tooFew.AgreementCount); + Assert.Equal(0, tooFew.MismatchCount); } [Fact]