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"); } }