diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinitionFactory.cs index 7d5ebf61..f3c725a9 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinitionFactory.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinitionFactory.cs @@ -49,7 +49,10 @@ public static class MqttTagDefinitionFactory /// /// Maps an authored TagConfig object to a typed definition keyed by . - /// The input is always an authored TagConfig JSON object (there is no name-vs-blob heuristic in v3). + /// The input is always an authored TagConfig JSON object (there is no name-vs-blob heuristic in v3), + /// and framing is decided solely by JsonDocument.Parse plus the + /// root- check — so leading whitespace is skipped exactly as the JSON + /// spec says, and a padded blob reaches the same verdict its unpadded form does (Gitea #559). /// Enum fields (payloadFormat / dataType) and qos are read STRICTLY — a /// present-but-invalid (typo'd) value rejects the whole tag (returns ⇒ the /// driver surfaces BadNodeIdUnknown) rather than silently defaulting to a wrong-typed Good or @@ -76,7 +79,14 @@ public static class MqttTagDefinitionFactory public static bool FromTagConfig(string tagConfig, string rawPath, out MqttTagDefinition def) { def = null!; - if (string.IsNullOrWhiteSpace(tagConfig) || tagConfig[0] != '{') return false; + // Gitea #559: the blank guard only — NOT a `tagConfig[0] != '{'` framing check. That check ran on + // the UNTRIMMED string, so a blob carrying leading whitespace (which a CSV or API authoring path can + // introduce invisibly, and which no author ever means as semantic) was refused here while the six + // sibling factories' mappers handed the identical blob to JsonDocument.Parse, which skips leading + // whitespace per the JSON spec. JsonDocument.Parse + the ValueKind check below are the single framing + // authority now, exactly as they are for every other driver: an array/number/string root fails the + // ValueKind check, and anything unparseable is caught as a JsonException. + if (string.IsNullOrWhiteSpace(tagConfig)) return false; try { using var doc = JsonDocument.Parse(tagConfig); @@ -129,7 +139,9 @@ public static class MqttTagDefinitionFactory /// (groupId, edgeNodeId, deviceId?, metricName) tuple; deviceId is optional (absent /// means the metric is published by the edge node itself), and there is no topic — /// a Sparkplug driver subscribes one group-wide filter and routes by the decoded topic + birth - /// certificate, never by a per-tag topic. Never throws. + /// certificate, never by a per-tag topic. Never throws. Framing is + /// JsonDocument.Parse plus the root-object check, so leading + /// whitespace does not change the verdict (Gitea #559) — see . /// /// /// @@ -175,7 +187,10 @@ public static class MqttTagDefinitionFactory public static bool FromSparkplugTagConfig(string tagConfig, string rawPath, out MqttTagDefinition def) { def = null!; - if (string.IsNullOrWhiteSpace(tagConfig) || tagConfig[0] != '{') return false; + // Gitea #559: the blank guard only — see the identical note on FromTagConfig. JsonDocument.Parse + + // the ValueKind check are the single framing authority; a leading-whitespace blob reaches the same + // verdict its unpadded form does, which is what every sibling factory has always done. + if (string.IsNullOrWhiteSpace(tagConfig)) return false; try { using var doc = JsonDocument.Parse(tagConfig); @@ -261,9 +276,12 @@ public static class MqttTagDefinitionFactory /// The two classes are kept in separate lists (Gitea #556 item 2), and the catch-all /// backstop fires on "no REJECTION was produced", never on "no warning at all" — otherwise an /// advisory would silently swallow the only message that reports a refusal nothing can name. - /// For MQTT that shape is reachable in production: a leading-whitespace blob is refused by both - /// mappers' own framing gates while every rejection branch finds the parsed document perfectly - /// well-formed. + /// ⚠️ Since Gitea #559 removed the mappers' own framing gate, that shape has no known + /// vehicle: the leading-whitespace blob that used to reach it is now accepted or refused on its + /// content alone, and every remaining return false in either mapper has a matching + /// branch. The backstop stays as defence-in-depth for the next + /// refusal cause someone adds without one — the same posture as the FOCAS inspector's, and pinned + /// the same way (a count relation, not a constructed vehicle). /// /// /// @@ -280,18 +298,17 @@ public static class MqttTagDefinitionFactory // Gitea #556 item 2: the two classes live in SEPARATE lists, and the backstop below gates on the // REJECTION list alone. Sharing one list meant an advisory (which fires on an accepted blob too) // could satisfy a `warnings.Count == 0` guard and silently swallow the catch-all rejection message - // — the only thing that reports a refusal neither DescribeRejection branch can name, which for - // MQTT is a REACHABLE shape, not a theoretical one: see the leading-whitespace note below. + // — the only thing that reports a refusal neither DescribeRejection branch can name. That shape has + // no vehicle today (see the summary), so this is the structural guarantee, not a live fix. var rejections = new List(); var advisories = new List(); - // Gitea #556 item 1: the framing check TRIMS FIRST, so a blob whose only sin is leading whitespace - // is no longer waved through in silence. MQTT is the one factory of the seven whose RUNTIME - // mappers carry the same `[0] != '{'` gate, so — unlike its siblings, whose mappers hand the blob - // to a whitespace-tolerant JsonDocument.Parse — " {…}" is genuinely REFUSED by this driver. Which - // is exactly why the untrimmed check was a defect: the tag read BadNodeIdUnknown forever and the - // deploy gate said nothing. A reference that still does not start with '{' after trimming is not - // an equipment-tag TagConfig at all and keeps drawing nothing. + // The framing pre-filter is purely an is-this-even-a-TagConfig filter, exactly as it is for the six + // sibling factories: a reference that is not object-framed is a bare name or some other artefact, + // not a TagConfig this inspector has anything to say about, so it draws nothing. It TRIMS FIRST + // (Gitea #556 item 1) and the mappers no longer carry a framing gate of their own at all (Gitea + // #559 — JsonDocument.Parse skips leading whitespace, so " {…}" is served), which means this filter + // and the mappers agree on padded blobs instead of the filter being the laxer of the two. if (string.IsNullOrWhiteSpace(reference) || reference.TrimStart().FirstOrDefault() != '{') return rejections; diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasInspectAcceptGateTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasInspectAcceptGateTests.cs index 1e5dbffa..7481f2ee 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasInspectAcceptGateTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasInspectAcceptGateTests.cs @@ -111,10 +111,12 @@ public sealed class FocasInspectAcceptGateTests /// — absent / non-string / blank /// address, invalid dataType — has a matching describe branch, and /// TryReadEnumStrict and DescribeInvalidEnum share one classifier so they cannot - /// disagree. So the guard is protective here rather than a live fix; MQTT's is live (a padded - /// blob its own mappers refuse and every branch finds well-formed — see - /// MqttInspectAcceptGateTests). What this pins is the property that must survive the - /// next rejection cause someone adds without a describe branch. + /// disagree. So the guard is protective here rather than a live fix. It is now protective + /// fleet-wide: MQTT's used to be live — a padded blob its own mappers refused and every branch + /// found well-formed — until Gitea #559 removed those mappers' framing gates, since the refusal + /// was itself the defect. MqttInspectAcceptGateTests pins the same count relation. + /// What this pins is the property that must survive the next rejection cause someone adds + /// without a describe branch. /// /// [Fact] diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttInspectAcceptGateTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttInspectAcceptGateTests.cs index 1af21085..498648f0 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttInspectAcceptGateTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttInspectAcceptGateTests.cs @@ -25,7 +25,7 @@ public sealed class MqttInspectAcceptGateTests || MqttTagDefinitionFactory.FromSparkplugTagConfig(tagConfig, RawPath, out _); /// Blobs at least one mapper ACCEPTS, with no wildcard advisory — the gate must be silent. - public static TheoryData Accepted() => + private static readonly string[] AcceptedBlobs = [ """{"topic":"plant/line1/temp"}""", """{"topic":"plant/line1/temp","payloadFormat":"Json","dataType":"Float64","qos":1}""", @@ -35,7 +35,7 @@ public sealed class MqttInspectAcceptGateTests ]; /// Blobs NEITHER mapper accepts — the gate must produce at least one warning for each. - public static TheoryData Rejected() => + private static readonly string[] RejectedBlobs = [ "{}", // no address of either shape """{"payloadFormat":"Json","dataType":"String"}""", // still no address @@ -46,6 +46,14 @@ public sealed class MqttInspectAcceptGateTests """{"groupId":"G","edgeNodeId":"E","metricName":"M","qos":"high"}""", // non-numeric qos, both modes ]; + /// Blobs at least one mapper ACCEPTS, with no wildcard advisory — the gate must be silent. + /// The accepted fixtures. + public static TheoryData Accepted() => [.. AcceptedBlobs]; + + /// Blobs NEITHER mapper accepts — the gate must produce at least one warning for each. + /// The rejected fixtures. + public static TheoryData Rejected() => [.. RejectedBlobs]; + [Theory] [MemberData(nameof(Accepted))] public void A_config_a_mapper_accepts_draws_no_warning(string tagConfig) @@ -86,28 +94,56 @@ public sealed class MqttInspectAcceptGateTests public void Unparseable_json_names_the_json() => MqttTagDefinitionFactory.Inspect("{oops").ShouldContain(w => w.Contains("not valid JSON")); - // ---- Gitea #556 item 1: leading whitespace no longer defeats the framing pre-filter ---- + // ---- Gitea #559: leading whitespace no longer changes ANY verdict (was #556 item 1) ---- /// Padding a blob with leading whitespace. private const string Pad = " \t\r\n "; + /// Every fixture above — accepted and rejected alike — so the padding claim is checked on both. + /// The full fixture corpus. + public static TheoryData AcceptedAndRejected() => [.. AcceptedBlobs, .. RejectedBlobs]; + /// - /// MQTT is the one factory of the seven whose RUNTIME mappers carry the same [0] != '{' - /// framing gate the inspector did, so a padded blob is genuinely REFUSED by this driver — which is - /// precisely why the untrimmed pre-filter was a defect rather than a cosmetic mismatch: the tag - /// read BadNodeIdUnknown forever and the deploy gate said nothing at all. Its siblings hand - /// the blob to a whitespace-tolerant JsonDocument.Parse and so keep accepting it; both - /// behaviours are correct, because each inspector mirrors its own mapper. + /// Gitea #559. MQTT used to be the one factory of the seven whose RUNTIME mappers carried their own + /// [0] != '{' framing gate — run on the UNTRIMMED string — so a blob whose only sin was + /// leading whitespace was refused by this driver alone and read BadNodeIdUnknown forever, + /// while the identical blob was served by Modbus, S7, AbCip, AbLegacy, TwinCAT and FOCAS (whose + /// mappers hand it to a whitespace-tolerant JsonDocument.Parse). The mappers now do the same, + /// so the pad is inert everywhere: the verdict AND the inspection are whatever the unpadded blob's + /// content earns. + /// + /// Asserting parity with the unpadded fixture rather than hardcoding "accepted" is the + /// stronger form — it holds for the rejected fixtures too, and it goes red if a future change + /// makes padding matter in either direction. + /// /// - /// A fixture that is accepted unpadded. + /// A fixture, padded below. [Theory] - [MemberData(nameof(Accepted))] - public void A_padded_config_the_readers_refuse_is_no_longer_passed_over_in_silence(string tagConfig) + [MemberData(nameof(AcceptedAndRejected))] + public void Leading_whitespace_changes_neither_the_verdict_nor_the_inspection(string tagConfig) { var padded = Pad + tagConfig; - EitherModeAccepts(tagConfig).ShouldBeTrue("the unpadded fixture must be accepted"); - EitherModeAccepts(padded).ShouldBeFalse("both MQTT readers carry their own framing gate"); - MqttTagDefinitionFactory.Inspect(padded).ShouldNotBeEmpty(); + + EitherModeAccepts(padded).ShouldBe( + EitherModeAccepts(tagConfig), + $"leading whitespace must not change the union verdict. Blob: {tagConfig}"); + MqttTagDefinitionFactory.Inspect(padded).ShouldBe( + MqttTagDefinitionFactory.Inspect(tagConfig), + $"leading whitespace must not change the inspection. Blob: {tagConfig}"); + } + + /// + /// The anti-vacuity control for the theory above: the corpus it runs over must contain both verdicts, + /// or "padded == unpadded" could hold across a corpus that is uniformly refused (which is exactly what + /// the pre-#559 code did — every padded blob was refused, so a parity claim stated only over the + /// accepted fixtures would have been the thing that changed). + /// + [Fact] + public void The_padding_corpus_carries_both_verdicts() + { + string[] blobs = [.. AcceptedBlobs, .. RejectedBlobs]; + blobs.ShouldContain(b => EitherModeAccepts(Pad + b), "no padded fixture is ACCEPTED"); + blobs.ShouldContain(b => !EitherModeAccepts(Pad + b), "no padded fixture is REFUSED"); } /// @@ -130,25 +166,42 @@ public sealed class MqttInspectAcceptGateTests // ---- Gitea #556 item 2: an advisory can no longer swallow the catch-all rejection ---- /// - /// The advisory-plus-unnameable-rejection shape, and for MQTT it is REACHABLE in production rather - /// than theoretical: a padded blob is refused by both readers' own framing gates, while every - /// DescribeRejection branch — which walks the PARSED document — finds it perfectly - /// well-formed and names nothing. The wildcard advisory fires regardless. Before #556 the backstop - /// was gated on warnings.Count == 0, so that advisory satisfied the guard and the operator - /// was told only that the topic had a wildcard — never that the tag would not resolve at all. + /// The structural invariant the backstop exists to hold: a blob the readers REFUSE always draws + /// strictly more than the advisories that same blob would draw if it were accepted. Before #556 the + /// backstop was gated on warnings.Count == 0 — and an advisory fires on an ACCEPTED blob too, + /// so it says nothing about the refusal while still satisfying that guard. Any refusal no + /// DescribeRejection branch can name was therefore dropped whenever an advisory was present. + /// Comparing the two counts states the property without string-matching a message to guess its class, + /// which is the whole point of keeping the two classes in separate lists. + /// + /// ⚠️ Gitea #559 made MQTT's own backstop UNREACHABLE, so this replaced a constructed + /// vehicle. That vehicle was a leading-whitespace blob: refused by both mappers' own framing + /// gates while every describe branch — which walks the PARSED document — found it perfectly + /// well-formed. #559 deleted those gates (the refusal was the defect), and no other vehicle + /// exists: a union refusal carrying a topic requires a strict-field failure, which is + /// named; without a topic and without the full Sparkplug tuple the no-address branch fires, which + /// is also named. So the guard is protective here rather than a live fix — exactly the posture + /// FocasInspectAcceptGateTests pins for FOCAS, and pinned the same way. What this holds is + /// the property that must survive the next rejection cause someone adds without a describe branch. + /// /// [Fact] - public void A_refusal_nothing_can_name_still_reports_the_backstop_when_an_advisory_is_present() + public void A_rejected_config_always_draws_more_than_its_advisories_alone() { - var padded = Pad + """{"topic":"plant/+/temp"}"""; + // Identical but for the out-of-range qos, so both carry the SAME wildcard advisory. + const string Accepted = """{"topic":"plant/+/temp","qos":2}"""; + const string Rejected = """{"topic":"plant/+/temp","qos":5}"""; - EitherModeAccepts(padded).ShouldBeFalse("the framing gate in both readers refuses a padded blob"); + EitherModeAccepts(Accepted).ShouldBeTrue("control must be accepted"); + EitherModeAccepts(Rejected).ShouldBeFalse("subject must be rejected"); - var warnings = MqttTagDefinitionFactory.Inspect(padded); - warnings.ShouldContain(w => w.Contains("wildcard"), "the advisory still fires"); - warnings.ShouldContain( - w => w.Contains("rejected by the driver's tag reader"), - "the backstop must survive the advisory — it is the only message naming the refusal"); + var advisoriesOnly = MqttTagDefinitionFactory.Inspect(Accepted); + var withRejection = MqttTagDefinitionFactory.Inspect(Rejected); + + advisoriesOnly.Count.ShouldBe(1, "the control's only warning is the wildcard advisory"); + withRejection.Count.ShouldBeGreaterThan( + advisoriesOnly.Count, + "a refusal must add a rejection-class warning — the backstop supplies one when nothing names it"); } // ---- Gitea #557 item 2: the payloadFormat mode-asymmetry contract ---- diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttTagDefinitionFactoryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttTagDefinitionFactoryTests.cs index 2155beef..9a16f744 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttTagDefinitionFactoryTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttTagDefinitionFactoryTests.cs @@ -148,7 +148,10 @@ public sealed class MqttTagDefinitionFactoryTests [InlineData(null)] [InlineData("")] [InlineData(" ")] - [InlineData("factory/oven/temp")] // a bare reference, not a TagConfig blob (no leading '{') + // Since Gitea #559 the mapper carries no framing gate of its own: these are refused by + // JsonDocument.Parse (a JsonException) and by the root-ValueKind check respectively, which is exactly + // how the six sibling factories have always refused them. + [InlineData("factory/oven/temp")] // a bare reference, not JSON at all ⇒ JsonException [InlineData("[1,2,3]")] // valid JSON, wrong root kind [InlineData("{ not json at all")] // unparseable — must not throw [InlineData("""{"payloadFormat":"Raw"}""")] // no topic ⇒ nothing to subscribe to @@ -218,7 +221,10 @@ public sealed class MqttTagDefinitionFactoryTests [InlineData("")] [InlineData(" ")] [InlineData("factory/oven/temp")] - [InlineData("[1,2,3]")] // no leading '{' ⇒ not a TagConfig blob at all (mirrors Modbus) + // Inspect keeps a framing PRE-FILTER (trimmed since #556): a reference that is not object-framed is not + // an equipment-tag TagConfig at all, so it draws nothing rather than a rejection. The mappers no longer + // carry one (#559) — they reach the same verdict via Parse + ValueKind. Mirrors Modbus. + [InlineData("[1,2,3]")] public void Inspect_NotATagConfigBlob_ReturnsEmpty(string? reference) => MqttTagDefinitionFactory.Inspect(reference!).ShouldBeEmpty(); @@ -259,4 +265,87 @@ public sealed class MqttTagDefinitionFactoryTests .ShouldBeTrue(); plain.Topic.ShouldBe("a/b"); } + + // ---- Gitea #559: the runtime mappers are whitespace-tolerant, like every sibling factory ---- + + /// + /// A pad containing every whitespace form JSON allows before a value — space, tab, CR, LF. + /// + private const string Pad = " \t\r\n "; + + /// + /// Gitea #559. Both mappers used to carry tagConfig[0] != '{' — evaluated on the UNTRIMMED + /// string — so a TagConfig blob with leading whitespace was refused by MQTT alone and the tag read + /// BadNodeIdUnknown forever, while the same blob was served by the other six structural-parse + /// drivers (whose mappers hand it straight to JsonDocument.Parse, which skips leading + /// whitespace per the JSON spec). No author means leading whitespace as semantic, and a CSV or API + /// authoring path can introduce it invisibly. + /// + /// ⚠️ It asserts the produced definition, not just the bool: a fix that accepted the padded + /// blob but mapped it differently (e.g. by trimming into the wrong field) would pass a + /// verdict-only test. is a record, so this is value equality over + /// every field. + /// + /// + /// A valid Plain-shape blob, padded below. + [Theory] + [InlineData("""{"topic":"factory/oven/temp"}""")] + [InlineData("""{"topic":"factory/oven/temp","payloadFormat":"Json","jsonPath":"$.v","dataType":"Float64","qos":1}""")] + [InlineData("""{"topic":"factory/oven/temp","writeTopic":"factory/oven/setpoint","retainSeed":false}""")] + public void FromTagConfig_LeadingWhitespace_MapsIdenticallyToTheUnpaddedBlob(string blob) + { + MqttTagDefinitionFactory.FromTagConfig(blob, RawPath, out var unpadded) + .ShouldBeTrue("the unpadded control must be accepted"); + MqttTagDefinitionFactory.FromTagConfig(Pad + blob, RawPath, out var padded) + .ShouldBeTrue("leading whitespace is not semantic — JsonDocument.Parse skips it"); + + padded.ShouldBe(unpadded); + } + + /// The same claim for the Sparkplug reader — it carried the identical gate (Gitea #559). + /// A valid Sparkplug-shape blob, padded below. + [Theory] + [InlineData("""{"groupId":"OtOpcUaSim","edgeNodeId":"EdgeA","metricName":"Temp"}""")] + [InlineData("""{"groupId":"OtOpcUaSim","edgeNodeId":"EdgeA","deviceId":"Filler1","metricName":"Speed","dataType":"Float64","qos":1}""")] + public void FromSparkplugTagConfig_LeadingWhitespace_MapsIdenticallyToTheUnpaddedBlob(string blob) + { + MqttTagDefinitionFactory.FromSparkplugTagConfig(blob, RawPath, out var unpadded) + .ShouldBeTrue("the unpadded control must be accepted"); + MqttTagDefinitionFactory.FromSparkplugTagConfig(Pad + blob, RawPath, out var padded) + .ShouldBeTrue("leading whitespace is not semantic — JsonDocument.Parse skips it"); + + padded.ShouldBe(unpadded); + } + + /// + /// The falsification control: trimming widens the mappers ONLY for JSON objects. A padded blank, a + /// padded array and padded non-JSON are still refused by both readers — JsonDocument.Parse + /// plus the root-ValueKind check is the whole framing authority, and it is not laxer than the + /// first-byte check was for anything but whitespace. + /// + /// A padded reference that is not a TagConfig object. + [Theory] + [InlineData(" \t\r\n ")] // whitespace only + [InlineData(" \t\r\n [1,2,3]")] // valid JSON, wrong root kind + [InlineData(" \t\r\n garbage")] // not JSON at all + [InlineData(" \t\r\n 42")] // number root + [InlineData(" \t\r\n \"a\"")] // string root + [InlineData(" \t\r\n factory/oven/temp")] // a bare reference, padded + public void BothMappers_RefusePaddedNonTagConfigReferences(string reference) + { + MqttTagDefinitionFactory.FromTagConfig(reference, RawPath, out _).ShouldBeFalse(); + MqttTagDefinitionFactory.FromSparkplugTagConfig(reference, RawPath, out _).ShouldBeFalse(); + } + + /// + /// A padded blob whose CONTENT is bad is still refused for that content — the pad neither rescues a + /// malformed blob nor supplies a second reason to refuse a good one. + /// + /// A padded blob that is object-framed but unreadable. + [Theory] + [InlineData("""{"topic":"a/b","payloadFormat":"Jsonn"}""")] + [InlineData("""{"topic":"a/b","qos":5}""")] + [InlineData("{}")] + public void FromTagConfig_LeadingWhitespace_DoesNotRescueAMalformedBlob(string blob) + => MqttTagDefinitionFactory.FromTagConfig(Pad + blob, RawPath, out _).ShouldBeFalse(); } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigNonEnumParityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigNonEnumParityTests.cs index 3828cbca..3481c394 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigNonEnumParityTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigNonEnumParityTests.cs @@ -50,6 +50,13 @@ public sealed class TagConfigNonEnumParityTests // --------------------------------------------------------------------------------------------- private const string P = "(test)"; + /// + /// Leading whitespace, in every form JSON allows before a value. Gitea #559: a CSV or API authoring + /// path can introduce it invisibly, and no author means it as semantic — so it must not change any + /// verdict on either side of the parity claim. + /// + private const string Pad = " \t\r\n "; + private static readonly IReadOnlyDictionary> RuntimeParses = new Dictionary>(StringComparer.OrdinalIgnoreCase) { @@ -352,6 +359,14 @@ public sealed class TagConfigNonEnumParityTests (DriverTypeNames.Mqtt, """{"topic":"otopcua/fixture/a","qos":2}"""), (DriverTypeNames.Mqtt, """{"groupId":"OtOpcUaSim","edgeNodeId":"EdgeA","metricName":"Filler/Speed"}"""), (DriverTypeNames.Mqtt, """{"groupId":"OtOpcUaSim","edgeNodeId":"EdgeA"}"""), + // Gitea #559 — leading whitespace, in both shapes and in both verdicts. MQTT's runtime mappers used + // to carry their own untrimmed `[0] != '{'` framing gate, so these padded blobs were REFUSED by the + // driver while the authoring model (whose JsonNode.Parse skips whitespace) happily saved them: the + // absolute direction of this suite, broken. The mappers now let JsonDocument.Parse decide framing, + // like the six siblings, so the pad is inert on both sides. + (DriverTypeNames.Mqtt, Pad + """{"topic":"otopcua/fixture/a","dataType":"Float64"}"""), + (DriverTypeNames.Mqtt, Pad + """{"groupId":"OtOpcUaSim","edgeNodeId":"EdgeA","metricName":"Filler/Speed"}"""), + (DriverTypeNames.Mqtt, Pad + """{"groupId":"OtOpcUaSim","edgeNodeId":"EdgeA"}"""), (DriverTypeNames.Sql, "{}"), (DriverTypeNames.Sql, """{"model":"KeyValue","table":"dbo.T","keyColumn":"k","valueColumn":"v","keyValue":"x"}"""),