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:
@@ -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"}"""),
|
||||
|
||||
Reference in New Issue
Block a user