diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index c1d3a1d49..2cde53614 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -551,8 +551,9 @@ void DrainReadResults() sourceDevice.LiveValues.Association != readAssociation || bindingIndex is not { IsCurrent: true }) return; // superseded association, catalog or read flight - var scalarValid = IedBrowserReadValuePolicy.TryAcceptDisplay( - result.Value, out var safeValue, out var scalarReason); + var scalarValid = IedBrowserReadValuePolicy.TryAcceptVerifiedMmsString( + result.Value, result.Target.DataType, result.VerifiedStringWireKind, + out var safeValue, out var scalarReason); if (result.Error.Length != 0 || !scalarValid) { failures++; @@ -575,7 +576,9 @@ void DrainReadResults() result.Target.FunctionalConstraint, safeValue, result.Quality, result.DeviceTimestamp, "MMS Read", result.UtcTimeEvidence, result.QualityEvidence, - typedBoolean: result.TypedBoolean); + typedBoolean: result.TypedBoolean, + verifiedStringWireKind: result.VerifiedStringWireKind, + declaredDataType: result.Target.DataType); if (!accepted) return; _iedBrowserReadFailureEvidence?.ClearFailure(result.Target); successes++; diff --git a/Models/IedBrowserLiveValues.cs b/Models/IedBrowserLiveValues.cs index 594988f85..ad2ef9e65 100644 --- a/Models/IedBrowserLiveValues.cs +++ b/Models/IedBrowserLiveValues.cs @@ -102,9 +102,13 @@ public bool PublishForAssociation(long association, string reference, string fc, string? value, string? quality, string? timestamp, string source, Iec61850UtcTimeEvidence? utcTimeEvidence = null, MmsQualityEvidence? qualityEvidence = null, - bool? typedBoolean = null) + bool? typedBoolean = null, + AR.Iec61850.Mms.MmsDataKind? verifiedStringWireKind = null, + string? declaredDataType = null) => TryPublish(reference, fc, value, quality, timestamp, source, association, - utcTimeEvidence, qualityEvidence, typedBoolean: typedBoolean); + utcTimeEvidence, qualityEvidence, typedBoolean: typedBoolean, + verifiedStringWireKind: verifiedStringWireKind, + declaredDataType: declaredDataType); /// /// Atomically attach a verified, exact-model-owned q or t Report companion @@ -156,12 +160,15 @@ private bool TryPublish(string reference, string fc, string? value, string? qual string? timestamp, string source, long? expectedAssociation, Iec61850UtcTimeEvidence? utcTimeEvidence, MmsQualityEvidence? qualityEvidence, bool onlyIfNoReport = false, - bool? typedBoolean = null) + bool? typedBoolean = null, + AR.Iec61850.Mms.MmsDataKind? verifiedStringWireKind = null, + string? declaredDataType = null) { if (string.IsNullOrWhiteSpace(reference) || string.IsNullOrWhiteSpace(fc) || string.IsNullOrWhiteSpace(value) || (string.Equals(source, "MMS Read", StringComparison.Ordinal) && - !IedBrowserReadValuePolicy.TryAcceptDisplay(value, out _, out _)) || + !IedBrowserReadValuePolicy.TryAcceptVerifiedMmsString( + value, declaredDataType, verifiedStringWireKind, out _, out _)) || value.Trim() is "-" or "Not read" or "Unknown") return false; @@ -350,5 +357,6 @@ public sealed record IedBrowserReadResult( string DeviceTimestamp, string Error, Iec61850UtcTimeEvidence? UtcTimeEvidence = null, MmsQualityEvidence? QualityEvidence = null, - bool? TypedBoolean = null); + bool? TypedBoolean = null, + AR.Iec61850.Mms.MmsDataKind? VerifiedStringWireKind = null); public sealed record IedBrowserReadProgress(int Completed, int Total); diff --git a/Models/IedBrowserReadValuePolicy.cs b/Models/IedBrowserReadValuePolicy.cs index b8b3a3524..b03904cb1 100644 --- a/Models/IedBrowserReadValuePolicy.cs +++ b/Models/IedBrowserReadValuePolicy.cs @@ -59,6 +59,40 @@ public static bool TryFormatVerifiedEmptyVisible( return false; } + /// + /// A square/curly-bracket prefix can be literal text in a verified MMS + /// VisibleString/MMSString scalar. Permit it ONLY with direct decoded wire + /// kind proof and an exactly compatible verified model declaration. + /// Never treat rendered arrays, structures or unknown BER tags as strings. + /// + public static bool TryAcceptVerifiedMmsString( + string? candidate, string? modelType, + AR.Iec61850.Mms.MmsDataKind? verifiedWireKind, + out string display, out string rejection) + { + if (TryAcceptDisplay(candidate, out display, out rejection)) + return true; + if (display.Length is 0 or > 512 || + !(display.StartsWith("[", StringComparison.Ordinal) || + display.StartsWith("{", StringComparison.Ordinal))) + return false; + + var declared = (modelType ?? "").Trim(); + var compatible = verifiedWireKind switch + { + AR.Iec61850.Mms.MmsDataKind.VisibleString => + declared.StartsWith("VisString", StringComparison.OrdinalIgnoreCase) || + declared.Equals("visible-string", StringComparison.OrdinalIgnoreCase), + AR.Iec61850.Mms.MmsDataKind.MmsString => + declared.StartsWith("MmsString", StringComparison.OrdinalIgnoreCase) || + declared.Equals("mms-string", StringComparison.OrdinalIgnoreCase), + _ => false + }; + if (!compatible) return false; + rejection = ""; + return true; + } + public static bool TryAcceptDisplay( string? candidate, out string display, out string rejection) { diff --git a/Models/IedBrowserReadWireShapeEvidence.cs b/Models/IedBrowserReadWireShapeEvidence.cs new file mode 100644 index 000000000..71d1ae424 --- /dev/null +++ b/Models/IedBrowserReadWireShapeEvidence.cs @@ -0,0 +1,30 @@ +using AR.Iec61850.Mms; + +namespace ArIED61850Tester.Models; + +/// +/// Bounded failure-diagnostic metadata from one decoded MMS result. Never +/// copies raw BER, object payload or vendor strings into the evidence CSV. +/// Wire kind is NOT invoke-ID, GVA status or proof of successful scalar read. +/// +public static class IedBrowserReadWireShapeEvidence +{ + public static string Describe(MmsDataValue? decoded) + { + if (decoded is null) return "wire=null"; + var type = decoded.Value?.GetType().Name ?? "null"; + var kind = decoded.Kind.ToString(); + var scalarOrTree = decoded.Kind is MmsDataKind.Structure or MmsDataKind.Array + ? "composite" : "primitive"; + return "wireKind=" + kind + + ", category=" + scalarOrTree + + ", clrKind=" + type + + ", rawOctets=" + decoded.RawValue.Count.ToString( + System.Globalization.CultureInfo.InvariantCulture) + + ", childCount=" + decoded.Children.Count.ToString( + System.Globalization.CultureInfo.InvariantCulture) + + (decoded.Kind == MmsDataKind.Unknown + ? ", unknownTag=" + decoded.UnknownTagNumber.ToString() + : ""); + } +} diff --git a/Models/IedBrowserRejectedScalarEvidence.cs b/Models/IedBrowserRejectedScalarEvidence.cs index 21841015d..49af6aa9b 100644 --- a/Models/IedBrowserRejectedScalarEvidence.cs +++ b/Models/IedBrowserRejectedScalarEvidence.cs @@ -35,6 +35,8 @@ static string Safe(string? value) ", modelType=" + Safe(modelType) + ", projection=" + Safe(read.Projection) + ", CLR=" + payloadType + + ", wire=" + Safe(string.IsNullOrWhiteSpace(read.WireShapeEvidence) + ? "not captured" : read.WireShapeEvidence) + ", display='" + Safe(read.DisplayValue) + "'" + ", source=" + Safe(read.ReadReference) + "; GVA/invoke raw not captured)."; diff --git a/Models/IedBrowserSgRootProjection.cs b/Models/IedBrowserSgRootProjection.cs index bae8ab041..3f306e40b 100644 --- a/Models/IedBrowserSgRootProjection.cs +++ b/Models/IedBrowserSgRootProjection.cs @@ -96,11 +96,20 @@ void Walk(MmsTypeSpecificationNode schema, MmsDataValue value, string path, int var key = canonical + "|" + constraint; if (!paths.Add(key) || !targetIndex.TryGetValue(key, out var target)) return; - var display = MmsDataValueRenderer.ToCompactString(value); - if (!IedBrowserReadValuePolicy.TryAcceptDisplay( - display, out var accepted, out _)) + // An exact ordered GVA component proves this primitive's kind, + // not the meaning of a rendered "[" or "{" character. Preserve + // legitimate strings without admitting unverified structures. + var stringKind = value.Kind is MmsDataKind.VisibleString or MmsDataKind.MmsString + ? value.Kind : (MmsDataKind?)null; + var display = stringKind is not null && value.Value is string direct + ? direct : MmsDataValueRenderer.ToCompactString(value); + if (!IedBrowserReadValuePolicy.TryAcceptVerifiedMmsString( + display, target.DataType, stringKind, out var accepted, out _)) return; - output.Add(new IedBrowserReadResult(target, accepted, "", "", "")); + output.Add(new IedBrowserReadResult(target, accepted, "", "", "", + TypedBoolean: value.Kind == MmsDataKind.Boolean && value.Value is bool typed + ? typed : null, + VerifiedStringWireKind: stringKind)); } Walk(wireType, wireData, "", 0); return output; diff --git a/Models/IedBrowserVerifiedDoProjection.cs b/Models/IedBrowserVerifiedDoProjection.cs index 8893c8883..9d9ff1d95 100644 --- a/Models/IedBrowserVerifiedDoProjection.cs +++ b/Models/IedBrowserVerifiedDoProjection.cs @@ -118,10 +118,16 @@ void Walk(MmsTypeSpecificationNode schema, MmsDataValue wire, if (path.Length == 0) return; if (!targets.TryGetValue(prefix + "." + path + "|" + fc, out var target)) return; - var rendered = MmsDataValueRenderer.ToCompactString(wire); - if (IedBrowserReadValuePolicy.TryAcceptDisplay( - rendered, out var accepted, out _)) - found.Add(new IedBrowserReadResult(target, accepted, "", "", "")); + var stringKind = wire.Kind is MmsDataKind.VisibleString or MmsDataKind.MmsString + ? wire.Kind : (MmsDataKind?)null; + var rendered = stringKind is not null && wire.Value is string direct + ? direct : MmsDataValueRenderer.ToCompactString(wire); + if (IedBrowserReadValuePolicy.TryAcceptVerifiedMmsString( + rendered, target.DataType, stringKind, out var accepted, out _)) + found.Add(new IedBrowserReadResult(target, accepted, "", "", "", + TypedBoolean: wire.Kind == MmsDataKind.Boolean && wire.Value is bool typed + ? typed : null, + VerifiedStringWireKind: stringKind)); } Walk(type, data, "", 0); return found; diff --git a/Services/Iec61850ReadValue.cs b/Services/Iec61850ReadValue.cs index b13e7f95e..fca3112bf 100644 --- a/Services/Iec61850ReadValue.cs +++ b/Services/Iec61850ReadValue.cs @@ -14,6 +14,15 @@ public sealed class Iec61850ReadValue public string ReadReference { get; init; } = string.Empty; public string Projection { get; init; } = string.Empty; + // Positive wire-kind proof, not inferred from the rendered display. Only + // direct, primitive MMS string results with matching projected CLR text + // receive this marker. A parent structure can never claim this proof. + public MmsDataKind? VerifiedStringWireKind { get; internal set; } + + // Diagnostic only: decoded kind/counts without BER or scalar payload. + // Does not claim GVA, invokeID capture or remote interoperability. + public string WireShapeEvidence { get; internal set; } = string.Empty; + // Authoritative engine-owned UTC-Time evidence. Available only when // a direct MMS .t read preserved the original eight wire bytes. public Iec61850UtcTimeEvidence? UtcTimeEvidence { get; internal set; } diff --git a/Services/IedBrowserIsolatedReadService.cs b/Services/IedBrowserIsolatedReadService.cs index fa1e2d056..5865d7553 100644 --- a/Services/IedBrowserIsolatedReadService.cs +++ b/Services/IedBrowserIsolatedReadService.cs @@ -41,13 +41,26 @@ await client.ConnectAsync(device.IpAddress, device.Port, cancellationToken) } var successful = 0; + // One exact key per observed child across typed FC roots and + // same-DO parent fallbacks. An overlapping fanout must never + // publish duplicate ledger revisions or inflate Read All success. + var emitted = new HashSet(StringComparer.Ordinal); + static string Key(IedBrowserReadTarget target) => + IedBrowserLiveValues.CanonicalReference(target.Reference) + "|" + + target.FunctionalConstraint.Trim().ToUpperInvariant(); + void EmitVerified(IedBrowserReadResult result) + { + if (!emitted.Add(Key(result.Target))) return; + values.Report(result); + successful++; + } var sgRootFlight = new IedBrowserSgRootReadFlight( client, targets, verifiedRootScope); // One association-scoped flight, only used AFTER a scalar Read // is genuinely unresolved; positive/negative parent probes are // memoized once per exact DO+FC, never per every child. var doReadFlight = new IedBrowserTypedDoReadFlight( - client, verifiedRootScope ?? targets); + client, verifiedRootScope ?? targets, targets); for (var i = 0; i < targets.Count; i++) { cancellationToken.ThrowIfCancellationRequested(); @@ -55,8 +68,20 @@ await client.ConnectAsync(device.IpAddress, device.Port, cancellationToken) throw new InvalidOperationException( "Live IED discovery/model or monitoring association changed during isolated MMS Read."); var target = targets[i]; + if (emitted.Contains(Key(target))) + { + progress.Report(new IedBrowserReadProgress(i + 1, targets.Count)); + continue; + } try { + var previouslyVerified = doReadFlight.PreviouslyVerified(target); + if (previouslyVerified is not null) + { + EmitVerified(previouslyVerified); + progress.Report(new IedBrowserReadProgress(i + 1, targets.Count)); + continue; + } using var leafToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); // The root attempt has its OWN bounded per-root timeout // and negative-result cache. Do not pass a short leaf @@ -69,13 +94,11 @@ await client.ConnectAsync(device.IpAddress, device.Port, cancellationToken) // the queued target itself was absent or refused. foreach (var extra in sgRootFlight.DrainUnscheduledSiblings(target)) { - values.Report(extra); - successful++; + EmitVerified(extra); } if (verifiedSg is not null) { - values.Report(verifiedSg); - successful++; + EmitVerified(verifiedSg); progress.Report(new IedBrowserReadProgress(i + 1, targets.Count)); continue; } @@ -85,14 +108,15 @@ await client.ConnectAsync(device.IpAddress, device.Port, cancellationToken) cancellationToken.ThrowIfCancellationRequested(); var read = raw as Iec61850ReadValue; var display = raw?.ToString() ?? ""; - if (IedBrowserReadValuePolicy.TryAcceptDisplay( - display, out var accepted, out var reason)) + if (IedBrowserReadValuePolicy.TryAcceptVerifiedMmsString( + display, target.DataType, read?.VerifiedStringWireKind, + out var accepted, out var reason)) { - values.Report(new IedBrowserReadResult(target, accepted, + EmitVerified(new IedBrowserReadResult(target, accepted, read?.Quality ?? "", read?.DeviceTimestamp ?? "", "", read?.UtcTimeEvidence, read?.QualityEvidence, - read?.Value is bool typed ? typed : null)); - successful++; + read?.Value is bool typed ? typed : null, + read?.VerifiedStringWireKind)); } else { @@ -104,8 +128,11 @@ await client.ConnectAsync(device.IpAddress, device.Port, cancellationToken) target, cancellationToken).ConfigureAwait(false); if (verifiedParent is not null) { - values.Report(verifiedParent); - successful++; + EmitVerified(verifiedParent); + foreach (var extra in doReadFlight.DrainUnscheduledSiblings(target)) + { + EmitVerified(extra); + } } else values.Report(new IedBrowserReadResult(target, "", "", "", diff --git a/Services/IedBrowserTypedDoReadFlight.cs b/Services/IedBrowserTypedDoReadFlight.cs index cf1ec3407..6614b632b 100644 --- a/Services/IedBrowserTypedDoReadFlight.cs +++ b/Services/IedBrowserTypedDoReadFlight.cs @@ -12,15 +12,20 @@ internal sealed class IedBrowserTypedDoReadFlight private const int MaximumParentAttempts = 24; private readonly NativeIec61850Client _client; private readonly Dictionary> _verified; + private readonly HashSet _scheduledKeys; + private readonly HashSet _drainedParents = new(StringComparer.Ordinal); private readonly Dictionary> _completed = new(StringComparer.Ordinal); private int _attemptCount; public IedBrowserTypedDoReadFlight( NativeIec61850Client client, - IReadOnlyList verifiedScope) + IReadOnlyList verifiedScope, + IReadOnlyList? scheduledTargets = null) { _client = client; + _scheduledKeys = (scheduledTargets ?? verifiedScope) + .Select(Key).ToHashSet(StringComparer.Ordinal); _verified = verifiedScope .GroupBy(IedBrowserVerifiedDoProjection.ParentFor, StringComparer.Ordinal) .Where(g => g.Key.Length > 0 && g.Count() <= 128) @@ -34,6 +39,30 @@ private static string Key(IedBrowserReadTarget t) => IedBrowserLiveValues.CanonicalReference(t.Reference) + "|" + t.FunctionalConstraint.Trim().ToUpperInvariant(); + // Reuse already GVA-verified siblings in this one Read operation. A + // successful parent DO read must not be followed by N redundant wire + // leaf reads that can fail independently and spoil coverage counts. + internal IedBrowserReadResult? PreviouslyVerified(IedBrowserReadTarget target) + { + var parent = IedBrowserVerifiedDoProjection.ParentFor(target); + return parent.Length > 0 && _completed.TryGetValue(parent, out var values) && + values.TryGetValue(Key(target), out var verified) ? verified : null; + } + + // A selected DO read may prove siblings that were not scheduled in this + // particular page; publish them at most once through the SAME evidence + // inbox. No speculative values: only present verified parent results. + internal IReadOnlyList DrainUnscheduledSiblings( + IedBrowserReadTarget selected) + { + var parent = IedBrowserVerifiedDoProjection.ParentFor(selected); + if (parent.Length == 0 || !_completed.TryGetValue(parent, out var verified) || + !_drainedParents.Add(parent)) + return []; + return verified.Where(item => !_scheduledKeys.Contains(item.Key)) + .Select(item => item.Value).ToArray(); + } + public async Task TryReadAsync( IedBrowserReadTarget failedLeaf, CancellationToken cancellationToken) { diff --git a/Services/NativeIec61850Client.cs b/Services/NativeIec61850Client.cs index 1e41e14dc..34a2cae0c 100644 --- a/Services/NativeIec61850Client.cs +++ b/Services/NativeIec61850Client.cs @@ -1129,6 +1129,20 @@ private static string BuildDynamicDataSetName(ReportControlPlan plan) Iec61850ReadValue? projected, ArMms.MmsDataValue? raw, string reference) { if (projected is null || raw is null) return projected; + projected.WireShapeEvidence = IedBrowserReadWireShapeEvidence.Describe(raw); + if (raw.Kind is ArMms.MmsDataKind.VisibleString or ArMms.MmsDataKind.MmsString && + raw.Value is string decoded && + projected.Value is string scalar && + string.Equals(decoded, scalar, StringComparison.Ordinal) && + string.Equals(projected.SourceReference, reference, StringComparison.Ordinal) && + // A successful parent DO read is NOT proof of this exact leaf. + // Only the primitive response to the same canonical leaf can + // override the conservative display-only array heuristic. + string.Equals( + IedBrowserLiveValues.CanonicalReference(projected.ReadReference), + IedBrowserLiveValues.CanonicalReference(reference), + StringComparison.Ordinal)) + projected.VerifiedStringWireKind = raw.Kind; if ((reference.EndsWith(".q", StringComparison.Ordinal) || reference.EndsWith("$q", StringComparison.Ordinal)) && raw.Kind == ArMms.MmsDataKind.BitString && diff --git a/tests/ARSAS.Tests/M214BrowserReadValueVerdictTests.cs b/tests/ARSAS.Tests/M214BrowserReadValueVerdictTests.cs index 05eb4f1ce..51baa6dd6 100644 --- a/tests/ARSAS.Tests/M214BrowserReadValueVerdictTests.cs +++ b/tests/ARSAS.Tests/M214BrowserReadValueVerdictTests.cs @@ -76,9 +76,15 @@ public void BothBrowserReadServicesAndUiRequireTheSameEvidenceVerdict() var isolated = File.ReadAllText(Find("Services/IedBrowserIsolatedReadService.cs")); var shared = File.ReadAllText(Find("Services/Iec61850MonitorRuntime.cs")); var ui = File.ReadAllText(Find("MainWindow.IedBrowser.cs")); - Assert.Contains("IedBrowserReadValuePolicy.TryAcceptDisplay", isolated); + // Both reader and UI now require wire-kind proof when an MMS string + // begins with array/structure-like punctuation. The shared monitoring + // service retains the conservative display-only policy; its untyped + // observations must never receive the explicit-read exception. + Assert.Contains("IedBrowserReadValuePolicy.TryAcceptVerifiedMmsString", isolated); Assert.Contains("IedBrowserReadValuePolicy.TryAcceptDisplay", shared); - Assert.Contains("IedBrowserReadValuePolicy.TryAcceptDisplay", ui); + Assert.Contains("IedBrowserReadValuePolicy.TryAcceptVerifiedMmsString", ui); + Assert.Contains("result.VerifiedStringWireKind", ui); + Assert.Contains("declaredDataType: result.Target.DataType", ui); Assert.Contains("failureExamples", ui); } diff --git a/tests/ARSAS.Tests/P0ModelTypedMmsStringTests.cs b/tests/ARSAS.Tests/P0ModelTypedMmsStringTests.cs new file mode 100644 index 000000000..b82fcef0e --- /dev/null +++ b/tests/ARSAS.Tests/P0ModelTypedMmsStringTests.cs @@ -0,0 +1,236 @@ +using AR.Iec61850.Mms; +using ArIED61850Tester.Models; + +namespace ARSAS.Tests; + +public sealed class P0ModelTypedMmsStringTests +{ + [Theory] + [InlineData("[IRV:vendor-valid", "VisString255", MmsDataKind.VisibleString)] + [InlineData("{literal vendor text}", "visible-string", MmsDataKind.VisibleString)] + [InlineData("[literal MMS text]", "MmsString128", MmsDataKind.MmsString)] + public void VerifiedDecodedStringsWithBracketPrefixAreNotMistakenForMmsArrays( + string scalar, string declaredType, MmsDataKind wireKind) + { + Assert.False(IedBrowserReadValuePolicy.TryAcceptDisplay(scalar, out _, out _)); + Assert.True(IedBrowserReadValuePolicy.TryAcceptVerifiedMmsString( + scalar, declaredType, wireKind, out var accepted, out var reason)); + Assert.Equal(scalar, accepted); + Assert.Equal("", reason); + } + + [Theory] + [InlineData("[unverified", "VisString255", null)] + [InlineData("{not a string}", "Int32", MmsDataKind.VisibleString)] + [InlineData("[wrong decoder]", "VisString255", MmsDataKind.MmsString)] + [InlineData("[wrong primitive]", "VisString255", MmsDataKind.Array)] + [InlineData("[unknown declaration]", "", MmsDataKind.VisibleString)] + [InlineData("unknown(tag=0x83)", "VisString255", MmsDataKind.VisibleString)] + [InlineData("Structure(2)", "VisString255", MmsDataKind.VisibleString)] + [InlineData("", "VisString255", MmsDataKind.VisibleString)] + public void UnverifiedArrayLikeAndUnsupportedResultsFailClosed( + string scalar, string modelType, MmsDataKind? kind) + { + Assert.False(IedBrowserReadValuePolicy.TryAcceptVerifiedMmsString( + scalar, modelType, kind, out _, out _)); + } + + [Fact] + public void ScalarBoundaryRemainsBoundedDespiteWireProof() + { + Assert.False(IedBrowserReadValuePolicy.TryAcceptVerifiedMmsString( + "[" + new string('x', 513), "VisString600", + MmsDataKind.VisibleString, out _, out _)); + } + + [Fact] + public void LiveLedgerRetainsTypedProofOnlyForCurrentReadAssociation() + { + const string reference = "ZIVAVR/LLN0.NamPlt.swRev"; + var ledger = new IedBrowserLiveValues(); + var epoch = ledger.BeginAssociation(); + Assert.False(ledger.PublishForAssociation(epoch, reference, "DC", "[IRV:firmware", + "", "", "MMS Read")); + Assert.False(ledger.PublishForAssociation(epoch, reference, "DC", "[IRV:firmware", + "", "", "MMS Read", verifiedStringWireKind: MmsDataKind.VisibleString, + declaredDataType: "Int32")); + Assert.True(ledger.PublishForAssociation(epoch, reference, "DC", "[IRV:firmware", + "", "", "MMS Read", verifiedStringWireKind: MmsDataKind.VisibleString, + declaredDataType: "VisString255")); + Assert.True(ledger.TryGet(reference, "DC", out var observed, preferReport: false)); + Assert.Equal("[IRV:firmware", observed.Value); + Assert.Equal("MMS Read", observed.Source); + ledger.BeginAssociation(); + Assert.False(ledger.PublishForAssociation(epoch, reference, "DC", "[IRV:old", + "", "", "MMS Read", verifiedStringWireKind: MmsDataKind.VisibleString, + declaredDataType: "VisString255")); + Assert.False(ledger.TryGet(reference, "DC", out _, preferReport: false)); + } + + + [Fact] + public void ExactGvaDcRootCanProjectVendorTextStartingWithBracket() + { + const string root = "ZIVAVR/LLN0$DC0"; + var target = new IedBrowserReadTarget( + "ZIVAVR/LLN0$DC$NamPlt$swRev", "DC", "VisString255"); + var gva = new MmsTypeSpecificationNode + { + MmsType = "structure", + Children = + [ + new MmsTypeSpecificationNode + { + Name = "NamPlt", MmsType = "structure", + Children = [new MmsTypeSpecificationNode + { + Name = "swRev", MmsType = "visible-string" + }] + } + ] + }; + var wire = MmsDataValue.Structure([ + MmsDataValue.Structure([MmsDataValue.VisibleString("[IRV:firmware")]) + ]); + var result = Assert.Single(IedBrowserSgRootProjection.Project( + root, gva, wire, [target])); + Assert.Equal(target, result.Target); + Assert.Equal("[IRV:firmware", result.Value); + Assert.Equal(MmsDataKind.VisibleString, result.VerifiedStringWireKind); + Assert.False(IedBrowserSgRootProjection.Project(root, gva, wire, + [target with { FunctionalConstraint = "CF" }]).Any()); + } + + [Fact] + public void ExactGvaDoParentCanProjectLiteralStringButRejectWrongModelType() + { + const string parent = "ZIVAVR/CSWI1$SE$VendorDO"; + var target = new IedBrowserReadTarget( + "ZIVAVR/CSWI1$SE$VendorDO$setVal", "SE", "VisString64"); + var gva = new MmsTypeSpecificationNode + { + MmsType = "structure", + Children = [new MmsTypeSpecificationNode + { + Name = "setVal", MmsType = "visible-string" + }] + }; + var wire = MmsDataValue.Structure([MmsDataValue.VisibleString("{safe literal")]); + var result = Assert.Single(IedBrowserVerifiedDoProjection.Project( + parent, gva, wire, [target])); + Assert.Equal("{safe literal", result.Value); + Assert.Equal(MmsDataKind.VisibleString, result.VerifiedStringWireKind); + Assert.Empty(IedBrowserVerifiedDoProjection.Project( + parent, gva, wire, [target with { DataType = "INT32" }])); + } + + [Fact] + public void ExactGvaParentPreservesFalseTypedBooleanWithoutInventingTrue() + { + const string parent = "SIEBCUCTRL/CSWI1$ST$SwAuth"; + var target = new IedBrowserReadTarget( + "SIEBCUCTRL/CSWI1$ST$SwAuth$stVal", "ST", "Boolean"); + var gva = new MmsTypeSpecificationNode + { + MmsType = "structure", + Children = [new MmsTypeSpecificationNode + { + Name = "stVal", MmsType = "boolean" + }] + }; + var wire = MmsDataValue.Structure([MmsDataValue.Boolean(false)]); + var result = Assert.Single(IedBrowserVerifiedDoProjection.Project( + parent, gva, wire, [target])); + Assert.False(result.TypedBoolean); + Assert.Null(result.VerifiedStringWireKind); + } + + [Fact] + public void StSeParentFlightReusesOnlyVerifiedScheduledSiblingsAndBoundedUnscheduledFanout() + { + var service = ReadRepoSource("Services/IedBrowserIsolatedReadService.cs"); + var flight = ReadRepoSource("Services/IedBrowserTypedDoReadFlight.cs"); + Assert.Contains("doReadFlight.PreviouslyVerified(target)", service); + Assert.Contains("doReadFlight.DrainUnscheduledSiblings(target)", service); + Assert.Contains("_scheduledKeys.Contains(item.Key)", flight); + Assert.Contains("_drainedParents.Add(parent)", flight); + Assert.Contains("_completed.TryGetValue(parent", flight); + Assert.Contains("MaximumParentAttempts = 24", flight); + } + + [Fact] + public void ReadAllDeduplicatesVerifiedRefAndFcAcrossIndependentParentFlights() + { + var isolated = ReadRepoSource("Services/IedBrowserIsolatedReadService.cs"); + Assert.Contains("var emitted = new HashSet(StringComparer.Ordinal)", isolated); + Assert.Contains("IedBrowserLiveValues.CanonicalReference(target.Reference)", isolated); + Assert.Contains("target.FunctionalConstraint.Trim().ToUpperInvariant()", isolated); + Assert.Contains("if (!emitted.Add(Key(result.Target))) return;", isolated); + Assert.Contains("if (emitted.Contains(Key(target)))", isolated); + Assert.Contains("EmitVerified(verifiedSg)", isolated); + Assert.Contains("EmitVerified(verifiedParent)", isolated); + Assert.Contains("EmitVerified(previouslyVerified)", isolated); + Assert.Equal(1, isolated.Split("successful++;").Length - 1); + } + + [Fact] + public void UnresolvedWireShapeExplainsDecoderKindWithoutLoggingVendorValues() + { + var primitive = IedBrowserReadWireShapeEvidence.Describe( + MmsDataValue.Integer(-517832)); + Assert.Contains("wireKind=Integer", primitive); + Assert.Contains("category=primitive", primitive); + Assert.Contains("childCount=0", primitive); + Assert.DoesNotContain("-517832", primitive); + + var composite = IedBrowserReadWireShapeEvidence.Describe( + MmsDataValue.Structure([ + MmsDataValue.Integer(468123), + MmsDataValue.Boolean(false) + ])); + Assert.Contains("wireKind=Structure", composite); + Assert.Contains("category=composite", composite); + Assert.Contains("childCount=2", composite); + Assert.DoesNotContain("468123", composite); + Assert.Equal("wire=null", IedBrowserReadWireShapeEvidence.Describe(null)); + + var native = ReadRepoSource("Services/NativeIec61850Client.cs"); + var failure = ReadRepoSource("Models/IedBrowserRejectedScalarEvidence.cs"); + Assert.Contains("WireShapeEvidence = IedBrowserReadWireShapeEvidence.Describe(raw)", native); + Assert.Contains("read.WireShapeEvidence", failure); + Assert.DoesNotContain("LastReadResponseHex", failure); + } + + private static string ReadRepoSource(string relativePath) + { + for (DirectoryInfo? root = new(AppContext.BaseDirectory); + root is not null; root = root.Parent) + { + var path = Path.Combine(root.FullName, + relativePath.Replace('/', Path.DirectorySeparatorChar)); + if (File.Exists(path)) return File.ReadAllText(path); + } + throw new FileNotFoundException(relativePath); + } + + [Fact] + public void ReadDecodeProofMustFlowThroughIsolatedReaderAndBrowserIngestion() + { + DirectoryInfo? root = new(AppContext.BaseDirectory); + while (root is not null && !File.Exists(Path.Combine(root.FullName, "MainWindow.IedBrowser.cs"))) + root = root.Parent; + Assert.NotNull(root); + var source = File.ReadAllText(Path.Combine(root!.FullName, "MainWindow.IedBrowser.cs")); + var isolated = File.ReadAllText(Path.Combine(root.FullName, + "Services", "IedBrowserIsolatedReadService.cs")); + var native = File.ReadAllText(Path.Combine(root.FullName, + "Services", "NativeIec61850Client.cs")); + Assert.Contains("result.VerifiedStringWireKind", source); + Assert.Contains("declaredDataType: result.Target.DataType", source); + Assert.Contains("read?.VerifiedStringWireKind", isolated); + Assert.Contains("raw.Value is string decoded", native); + Assert.Contains("CanonicalReference(projected.ReadReference)", native); + Assert.Contains("CanonicalReference(reference)", native); + Assert.Contains("projected.VerifiedStringWireKind = raw.Kind", native); + } +}