merge: #559 — MQTT runtime mappers accept leading-whitespace TagConfig; JsonDocument.Parse + ValueKind is the single framing authority, aligning MQTT with the six sibling drivers; backstop re-pinned FOCAS-style (unreachable-today count relation)

This commit is contained in:
Joseph Doherty
2026-08-10 03:37:38 -04:00
5 changed files with 227 additions and 51 deletions
@@ -49,7 +49,10 @@ public static class MqttTagDefinitionFactory
/// <summary>
/// Maps an authored <c>TagConfig</c> object to a typed definition keyed by <paramref name="rawPath"/>.
/// 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 <c>JsonDocument.Parse</c> plus the
/// root-<see cref="JsonValueKind.Object"/> 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 (<c>payloadFormat</c> / <c>dataType</c>) and <c>qos</c> are read STRICTLY — a
/// present-but-invalid (typo'd) value rejects the whole tag (returns <see langword="false"/> ⇒ the
/// driver surfaces <c>BadNodeIdUnknown</c>) 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
/// <c>(groupId, edgeNodeId, deviceId?, metricName)</c> tuple; <c>deviceId</c> is optional (absent
/// means the metric is published by the edge node itself), and there is <b>no</b> <c>topic</c> —
/// 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
/// <c>JsonDocument.Parse</c> plus the root-object check, so leading
/// whitespace does not change the verdict (Gitea #559) — see <see cref="FromTagConfig"/>.
/// </summary>
/// <remarks>
/// <para>
@@ -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 <b>separate lists</b> (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 <b>no</b> known
/// vehicle: the leading-whitespace blob that used to reach it is now accepted or refused on its
/// content alone, and every remaining <c>return false</c> in either mapper has a matching
/// <see cref="DescribeRejection"/> 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).
/// </para>
/// </summary>
/// <remarks>
@@ -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<string>();
var advisories = new List<string>();
// 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;
@@ -111,10 +111,12 @@ public sealed class FocasInspectAcceptGateTests
/// <see cref="FocasTagDefinitionFactory.FromTagConfig"/> — absent / non-string / blank
/// <c>address</c>, invalid <c>dataType</c> — has a matching describe branch, and
/// <c>TryReadEnumStrict</c> and <c>DescribeInvalidEnum</c> 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
/// <c>MqttInspectAcceptGateTests</c>). 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. <c>MqttInspectAcceptGateTests</c> pins the same count relation.
/// What this pins is the property that must survive the next rejection cause someone adds
/// without a describe branch.
/// </para>
/// </summary>
[Fact]
@@ -25,7 +25,7 @@ public sealed class MqttInspectAcceptGateTests
|| MqttTagDefinitionFactory.FromSparkplugTagConfig(tagConfig, RawPath, out _);
/// <summary>Blobs at least one mapper ACCEPTS, with no wildcard advisory — the gate must be silent.</summary>
public static TheoryData<string> 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
];
/// <summary>Blobs NEITHER mapper accepts — the gate must produce at least one warning for each.</summary>
public static TheoryData<string> 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
];
/// <summary>Blobs at least one mapper ACCEPTS, with no wildcard advisory — the gate must be silent.</summary>
/// <returns>The accepted fixtures.</returns>
public static TheoryData<string> Accepted() => [.. AcceptedBlobs];
/// <summary>Blobs NEITHER mapper accepts — the gate must produce at least one warning for each.</summary>
/// <returns>The rejected fixtures.</returns>
public static TheoryData<string> 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) ----
/// <summary>Padding a blob with leading whitespace.</summary>
private const string Pad = " \t\r\n ";
/// <summary>Every fixture above — accepted and rejected alike — so the padding claim is checked on both.</summary>
/// <returns>The full fixture corpus.</returns>
public static TheoryData<string> AcceptedAndRejected() => [.. AcceptedBlobs, .. RejectedBlobs];
/// <summary>
/// MQTT is the one factory of the seven whose RUNTIME mappers carry the same <c>[0] != '{'</c>
/// 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 <c>BadNodeIdUnknown</c> forever and the deploy gate said nothing at all. Its siblings hand
/// the blob to a whitespace-tolerant <c>JsonDocument.Parse</c> 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
/// <c>[0] != '{'</c> framing gate — run on the UNTRIMMED string — so a blob whose only sin was
/// leading whitespace was refused by this driver alone and read <c>BadNodeIdUnknown</c> forever,
/// while the identical blob was served by Modbus, S7, AbCip, AbLegacy, TwinCAT and FOCAS (whose
/// mappers hand it to a whitespace-tolerant <c>JsonDocument.Parse</c>). 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.
/// <para>
/// Asserting <b>parity with the unpadded fixture</b> 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.
/// </para>
/// </summary>
/// <param name="tagConfig">A fixture that is accepted unpadded.</param>
/// <param name="tagConfig">A fixture, padded below.</param>
[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}");
}
/// <summary>
/// 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).
/// </summary>
[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");
}
/// <summary>
@@ -130,25 +166,42 @@ public sealed class MqttInspectAcceptGateTests
// ---- Gitea #556 item 2: an advisory can no longer swallow the catch-all rejection ----
/// <summary>
/// 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
/// <c>DescribeRejection</c> 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 <c>warnings.Count == 0</c>, 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 <c>warnings.Count == 0</c> — and an advisory fires on an ACCEPTED blob too,
/// so it says nothing about the refusal while still satisfying that guard. Any refusal no
/// <c>DescribeRejection</c> 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.
/// <para>
/// ⚠️ <b>Gitea #559 made MQTT's own backstop UNREACHABLE, so this replaced a constructed
/// vehicle.</b> 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 <c>topic</c> 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
/// <c>FocasInspectAcceptGateTests</c> 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.
/// </para>
/// </summary>
[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 ----
@@ -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 ----
/// <summary>
/// A pad containing every whitespace form JSON allows before a value — space, tab, CR, LF.
/// </summary>
private const string Pad = " \t\r\n ";
/// <summary>
/// Gitea #559. Both mappers used to carry <c>tagConfig[0] != '{'</c> — evaluated on the UNTRIMMED
/// string — so a TagConfig blob with leading whitespace was refused by MQTT alone and the tag read
/// <c>BadNodeIdUnknown</c> forever, while the same blob was served by the other six structural-parse
/// drivers (whose mappers hand it straight to <c>JsonDocument.Parse</c>, 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.
/// <para>
/// ⚠️ It asserts the produced <b>definition</b>, 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. <see cref="MqttTagDefinition"/> is a record, so this is value equality over
/// every field.
/// </para>
/// </summary>
/// <param name="blob">A valid Plain-shape blob, padded below.</param>
[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);
}
/// <summary>The same claim for the Sparkplug reader — it carried the identical gate (Gitea #559).</summary>
/// <param name="blob">A valid Sparkplug-shape blob, padded below.</param>
[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);
}
/// <summary>
/// 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 — <c>JsonDocument.Parse</c>
/// plus the root-<c>ValueKind</c> check is the whole framing authority, and it is not laxer than the
/// first-byte check was for anything but whitespace.
/// </summary>
/// <param name="reference">A padded reference that is not a TagConfig object.</param>
[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();
}
/// <summary>
/// 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.
/// </summary>
/// <param name="blob">A padded blob that is object-framed but unreadable.</param>
[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();
}
@@ -50,6 +50,13 @@ public sealed class TagConfigNonEnumParityTests
// ---------------------------------------------------------------------------------------------
private const string P = "(test)";
/// <summary>
/// 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.
/// </summary>
private const string Pad = " \t\r\n ";
private static readonly IReadOnlyDictionary<string, Func<string, bool>> RuntimeParses =
new Dictionary<string, Func<string, bool>>(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"}"""),