using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
///
/// Gitea #551: gained an accept gate — see
/// ModbusInspectAcceptGateTests's remarks for why both directions (never stricter, never silent)
/// are pinned.
///
/// MQTT's gate is the UNION of its two mappers, because Inspect is handed a blob with
/// no mode: the driver's MqttMode decides whether
/// or
/// reads it, and nothing on a tag row
/// says which. Gating on the Plain mapper alone would warn that every correctly-authored Sparkplug
/// tag has no topic — the falsification control below is exactly that case.
///
///
public sealed class MqttInspectAcceptGateTests
{
private const string RawPath = "cell/mqtt/dev1/T1";
private static bool EitherModeAccepts(string tagConfig)
=> MqttTagDefinitionFactory.FromTagConfig(tagConfig, RawPath, out _)
|| MqttTagDefinitionFactory.FromSparkplugTagConfig(tagConfig, RawPath, out _);
/// Blobs at least one mapper ACCEPTS, with no wildcard advisory — the gate must be silent.
private static readonly string[] AcceptedBlobs =
[
"""{"topic":"plant/line1/temp"}""",
"""{"topic":"plant/line1/temp","payloadFormat":"Json","dataType":"Float64","qos":1}""",
// Sparkplug: no topic at all, and that is CORRECT — the control this whole union exists for.
"""{"groupId":"OtOpcUaSim","edgeNodeId":"EdgeA","metricName":"Temp"}""",
"""{"groupId":"OtOpcUaSim","edgeNodeId":"EdgeA","deviceId":"Filler1","metricName":"Node Control/Rebirth"}""",
];
/// Blobs NEITHER mapper accepts — the gate must produce at least one warning for each.
private static readonly string[] RejectedBlobs =
[
"{}", // no address of either shape
"""{"payloadFormat":"Json","dataType":"String"}""", // still no address
"""{"groupId":"G","edgeNodeId":"E"}""", // incomplete Sparkplug tuple
"""{"topic":"a/b","payloadFormat":"Jsonn"}""", // typo'd payloadFormat
"""{"topic":"a/b","dataType":"Flooat64"}""", // typo'd dataType
"""{"topic":"a/b","qos":5}""", // out-of-range qos
"""{"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)
{
EitherModeAccepts(tagConfig).ShouldBeTrue("fixture must be accepted by at least one mode");
MqttTagDefinitionFactory.Inspect(tagConfig).ShouldBeEmpty();
}
[Theory]
[MemberData(nameof(Rejected))]
public void A_config_neither_mapper_accepts_is_never_passed_over_in_silence(string tagConfig)
{
EitherModeAccepts(tagConfig).ShouldBeFalse("fixture must be rejected by both modes");
MqttTagDefinitionFactory.Inspect(tagConfig).ShouldNotBeEmpty();
}
[Fact]
public void A_blob_with_no_address_of_either_shape_names_both()
{
var warnings = MqttTagDefinitionFactory.Inspect("{}");
warnings.ShouldContain(w => w.Contains("topic") && w.Contains("metricName"));
}
/// The wildcard advisory is about an ACCEPTED blob, so the accept gate must not swallow it.
[Fact]
public void A_wildcard_topic_still_warns_even_though_the_mapper_accepts_it()
{
const string Blob = """{"topic":"plant/+/temp"}""";
MqttTagDefinitionFactory.FromTagConfig(Blob, RawPath, out _).ShouldBeTrue("a wildcard topic is accepted on purpose");
MqttTagDefinitionFactory.Inspect(Blob).ShouldContain(w => w.Contains("wildcard"));
}
[Fact]
public void A_blank_reference_draws_nothing()
=> MqttTagDefinitionFactory.Inspect(" ").ShouldBeEmpty();
[Fact]
public void Unparseable_json_names_the_json()
=> MqttTagDefinitionFactory.Inspect("{oops").ShouldContain(w => w.Contains("not valid JSON"));
// ---- 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];
///
/// 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, padded below.
[Theory]
[MemberData(nameof(AcceptedAndRejected))]
public void Leading_whitespace_changes_neither_the_verdict_nor_the_inspection(string tagConfig)
{
var padded = Pad + tagConfig;
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");
}
///
/// Trimming widens the gate ONLY for JSON objects — a reference still not object-framed after the
/// trim is not an equipment-tag TagConfig at all and keeps drawing nothing.
///
/// A padded, non-object reference.
[Theory]
[InlineData(" just a string")]
[InlineData(" [1,2,3]")]
[InlineData(" \t\r\n 42")]
[InlineData(" \"a\"")]
[InlineData(" ")]
public void A_non_object_reference_still_draws_nothing_after_trimming(string reference)
{
EitherModeAccepts(reference).ShouldBeFalse();
MqttTagDefinitionFactory.Inspect(reference).ShouldBeEmpty();
}
// ---- Gitea #556 item 2: an advisory can no longer swallow the catch-all rejection ----
///
/// 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_rejected_config_always_draws_more_than_its_advisories_alone()
{
// 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(Accepted).ShouldBeTrue("control must be accepted");
EitherModeAccepts(Rejected).ShouldBeFalse("subject must be rejected");
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 ----
///
/// The decided contract. An invalid payloadFormat is fatal to the Plain reader and invisible
/// to the Sparkplug one (which hard-codes Json and never consults the key), so the UNION
/// verdict both gates use ADMITS a Sparkplug-shaped blob carrying one. That is intended: in
/// Sparkplug mode the driver genuinely serves the tag, and a gate that rejected it would be
/// stricter than the runtime. The cost — the served behaviour depends on the driver's
/// MqttMode, which no tag row records — is paid by an ADVISORY, not by changing the verdict.
///
[Fact]
public void An_invalid_payloadFormat_on_a_Sparkplug_blob_is_accepted_and_advised_not_rejected()
{
const string Blob =
"""{"groupId":"OtOpcUaSim","edgeNodeId":"EdgeA","metricName":"Temp","payloadFormat":"Jsonn"}""";
MqttTagDefinitionFactory.FromSparkplugTagConfig(Blob, RawPath, out _)
.ShouldBeTrue("the Sparkplug reader never consults payloadFormat");
MqttTagDefinitionFactory.FromTagConfig(Blob, RawPath, out _)
.ShouldBeFalse("the Plain reader reads payloadFormat strictly (and this blob has no topic)");
var warnings = MqttTagDefinitionFactory.Inspect(Blob);
warnings.ShouldContain(w => w.Contains("payloadFormat") && w.Contains("Jsonn"));
warnings.ShouldContain(w => w.Contains("Plain mode"), "the advisory must name the mode dependency");
// The verdict itself is unchanged — this is an advisory, not a rejection. A rejection would name
// the tag as unresolvable, which is false for every Sparkplug deployment.
warnings.ShouldNotContain(w => w.Contains("rejected by the driver's tag reader"));
}
///
/// The falsification control for the rule above: the SAME invalid payloadFormat on a blob
/// the union REFUSES is a rejection, not an advisory — so the advisory branch cannot be a blanket
/// "payloadFormat is always advisory", which would under-report a Plain tag that is dead in every
/// mode. The class a key lands in is decided by the accept gate, never by the key.
///
[Fact]
public void The_same_invalid_payloadFormat_on_a_rejected_blob_is_a_rejection()
{
const string Blob = """{"topic":"plant/line1/temp","payloadFormat":"Jsonn"}""";
EitherModeAccepts(Blob).ShouldBeFalse("Plain refuses the enum, Sparkplug has no binding tuple");
var warnings = MqttTagDefinitionFactory.Inspect(Blob);
warnings.ShouldContain(w => w.Contains("payloadFormat") && w.Contains("Jsonn"));
warnings.ShouldNotContain(w => w.Contains("Plain mode"), "an unserved tag is not a mode-dependent case");
}
// ---- The two authoring-only rules the deploy gate used to pass over in silence ----
///
/// MqttTagConfigModel.ValidatePlain refuses a wildcard writeTopic outright, but the
/// typed editor is only ONE authoring path — the raw-JSON textarea, CSV import and the API all
/// reach the deploy gate directly. A wildcard cannot be published to at all (MQTT-3.3.2-2), so the
/// tag reads perfectly while every write is refused; that has to be said somewhere the operator
/// sees it, and an ADVISORY is the right class because the blob IS accepted.
///
[Fact]
public void A_wildcard_write_topic_is_advised_on_an_accepted_blob()
{
const string Blob = """{"topic":"plant/line1/temp","writeTopic":"plant/+/set"}""";
EitherModeAccepts(Blob).ShouldBeTrue("the tag still READS fine — only the write is unaddressable");
MqttTagDefinitionFactory.Inspect(Blob)
.ShouldContain(w => w.Contains("writeTopic") && w.Contains("wildcard"));
}
///
/// A Sparkplug group / edge-node / device id is a single MQTT topic segment, so a decoded incoming
/// id can never contain /, + or # — an authored one that does is a binding
/// that can never resolve, and the mapper accepts it (it validates presence, not shape). The
/// AdminUI model calls this out in words and refuses the save; the deploy gate said nothing.
///
/// The key the advisory must name.
/// The tag config carrying an unbindable id.
[Theory]
[InlineData("groupId", """{"groupId":"Ot/Sim","edgeNodeId":"EdgeA","metricName":"T"}""")]
[InlineData("edgeNodeId", """{"groupId":"OtSim","edgeNodeId":"Edge+A","metricName":"T"}""")]
[InlineData("deviceId", """{"groupId":"OtSim","edgeNodeId":"EdgeA","deviceId":"F#1","metricName":"T"}""")]
public void A_sparkplug_id_that_is_not_a_topic_segment_is_advised(string key, string blob)
{
EitherModeAccepts(blob).ShouldBeTrue("the reader accepts it — which is exactly why it must be advised");
MqttTagDefinitionFactory.Inspect(blob).ShouldContain(w => w.Contains(key));
}
///
/// The falsification control for the rule above: metricName is NOT a topic segment — it
/// rides in the payload — and Sparkplug's own canonical names use /. Advising it would
/// report every conformant Node Control/Rebirth tag as broken.
///
[Fact]
public void A_metric_name_containing_a_slash_is_never_advised()
=> MqttTagDefinitionFactory
.Inspect("""{"groupId":"OtSim","edgeNodeId":"EdgeA","metricName":"Node Control/Rebirth"}""")
.ShouldBeEmpty();
///
/// Both gates must keep agreeing on the VERDICT (Gitea #557 item 2). The #554 authoring consult in
/// MqttTagConfigModel asks the identical union and only words a REFUSAL, so an advisory can
/// never leak into the modal as a blocking error. Re-stated here as the union itself, since the
/// AdminUI model is not referenced from this project.
///
[Fact]
public void The_authoring_consults_union_is_the_same_union_the_deploy_gate_uses()
{
const string Blob =
"""{"groupId":"OtOpcUaSim","edgeNodeId":"EdgeA","metricName":"Temp","payloadFormat":"Jsonn"}""";
// Both gates read this verdict; the deploy gate's non-empty inspection above does not change it.
EitherModeAccepts(Blob).ShouldBeTrue();
MqttTagDefinitionFactory.Inspect(Blob).ShouldNotBeEmpty("advisories are allowed on an accepted blob");
}
}