621a9e5dc2
FromTagConfig and FromSparkplugTagConfig each carried a framing pre-check
(`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 other six structural-parse drivers
(Modbus, S7, AbCip, AbLegacy, TwinCAT, FOCAS) hand the identical blob to
JsonDocument.Parse, which skips leading whitespace per the JSON spec.
The clause is dropped (the blank guard stays). JsonDocument.Parse plus the
root-ValueKind check now decide framing, exactly as for the six siblings: an
array/number/string root fails ValueKind, non-JSON is a JsonException. The gate
was a validator, not a discriminator — no caller (MqttSubscriptionManager,
SparkplugIngestor, MqttTagConfigModel, Inspect) used the first byte to pick a
format — so verdicts are identical for every input except whitespace-padded
JSON objects, which now get their content's verdict.
Inspect needs no logic change (it has trimmed since #556 and hands the RAW
reference to the mappers), but its comments claimed the padded-blob refusal was
production-reachable; rewritten. That shape was also MQTT's only vehicle for the
catch-all backstop, which is now unreachable-today like FOCAS's — so its test is
replaced by the same count-relation pin, and the backstop code stays as
defence-in-depth.
Tests: both mappers pinned by padded-vs-unpadded DEFINITION equality (a record,
so field-wise), padded non-object/malformed negative controls, an Inspect
verdict+inspection parity theory over the accepted AND rejected corpus with an
anti-vacuity control, and a padded MQTT corpus entry in the AdminUI
authoring/runtime parity suite.
202 lines
10 KiB
C#
202 lines
10 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
|
|
|
/// <summary>
|
|
/// Gitea #551: <see cref="FocasTagDefinitionFactory.Inspect"/> gained an accept gate for its
|
|
/// <b>rejection</b> diagnosis — see <c>ModbusInspectAcceptGateTests</c>'s remarks for why both
|
|
/// directions are pinned.
|
|
/// <para>
|
|
/// FOCAS is the one factory that deliberately does NOT return empty on accept: two of its warnings
|
|
/// are <b>advisories</b> about blobs the mapper ACCEPTS but the driver still cannot serve as
|
|
/// authored — an unparseable FOCAS address (#540, dropped at the driver's own init) and an authored
|
|
/// <c>writable:true</c> (the wire is read-only). Folding FOCAS into the plain "accept ⇒ empty"
|
|
/// shape would have silently deleted both, so they are pinned here alongside the new rejections.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class FocasInspectAcceptGateTests
|
|
{
|
|
private const string RawPath = "cell/focas/dev1/T1";
|
|
|
|
/// <summary>Blobs the mapper ACCEPTS <b>and</b> that carry no advisory — the gate must be silent.</summary>
|
|
public static TheoryData<string> CleanAndAccepted() =>
|
|
[
|
|
"""{"address":"R100","dataType":"Int32"}""",
|
|
"""{"address":"X0.0","dataType":"Bit"}""",
|
|
"""{"address":"PARAM:1020"}""",
|
|
"""{"address":"FIXED:Identity/SeriesNumber","dataType":"String"}""",
|
|
];
|
|
|
|
/// <summary>Blobs the mapper REJECTS — the gate must produce at least one warning for each.</summary>
|
|
public static TheoryData<string> Rejected() =>
|
|
[
|
|
"""{"dataType":"Int32"}""", // no address
|
|
"""{"address":"","dataType":"Int32"}""", // blank address
|
|
"""{"address":100}""", // address not a string
|
|
"""{"address":"R100","dataType":"Intt32"}""", // typo'd dataType
|
|
];
|
|
|
|
[Theory]
|
|
[MemberData(nameof(CleanAndAccepted))]
|
|
public void A_clean_config_the_mapper_accepts_draws_no_warning(string tagConfig)
|
|
{
|
|
FocasTagDefinitionFactory.FromTagConfig(tagConfig, RawPath, out _).ShouldBeTrue("fixture must be accepted");
|
|
FocasTagDefinitionFactory.Inspect(tagConfig).ShouldBeEmpty();
|
|
}
|
|
|
|
[Theory]
|
|
[MemberData(nameof(Rejected))]
|
|
public void A_config_the_mapper_rejects_is_never_passed_over_in_silence(string tagConfig)
|
|
{
|
|
FocasTagDefinitionFactory.FromTagConfig(tagConfig, RawPath, out _).ShouldBeFalse("fixture must be rejected");
|
|
FocasTagDefinitionFactory.Inspect(tagConfig).ShouldNotBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void An_absent_address_is_named()
|
|
=> FocasTagDefinitionFactory.Inspect("""{"dataType":"Int32"}""")
|
|
.ShouldContain(w => w.Contains("'address'"));
|
|
|
|
// ---- The two advisories the accept gate must NOT swallow ----
|
|
|
|
[Fact]
|
|
public void An_unparseable_address_still_warns_even_though_the_mapper_accepts_it()
|
|
{
|
|
const string Blob = """{"address":"not-an-address","dataType":"Int32"}""";
|
|
FocasTagDefinitionFactory.FromTagConfig(Blob, RawPath, out _).ShouldBeTrue("the mapper accepts any non-blank address");
|
|
FocasTagDefinitionFactory.Inspect(Blob).ShouldContain(w => w.Contains("not parseable"));
|
|
}
|
|
|
|
[Fact]
|
|
public void An_authored_writable_still_warns_even_though_the_mapper_accepts_it()
|
|
{
|
|
const string Blob = """{"address":"R100","dataType":"Int32","writable":true}""";
|
|
FocasTagDefinitionFactory.FromTagConfig(Blob, RawPath, out _).ShouldBeTrue("the mapper accepts writable:true");
|
|
FocasTagDefinitionFactory.Inspect(Blob).ShouldContain(w => w.Contains("read-only"));
|
|
}
|
|
|
|
/// <summary>A rejected blob still gets its advisories — the two diagnoses are independent.</summary>
|
|
[Fact]
|
|
public void A_rejected_config_reports_the_rejection_and_the_advisory_together()
|
|
{
|
|
var warnings = FocasTagDefinitionFactory.Inspect(
|
|
"""{"address":"not-an-address","dataType":"Intt32","writable":true}""");
|
|
|
|
warnings.ShouldContain(w => w.Contains("dataType"));
|
|
warnings.ShouldContain(w => w.Contains("not parseable"));
|
|
warnings.ShouldContain(w => w.Contains("read-only"));
|
|
}
|
|
|
|
[Fact]
|
|
public void A_blank_reference_draws_nothing()
|
|
=> FocasTagDefinitionFactory.Inspect(" ").ShouldBeEmpty();
|
|
|
|
[Fact]
|
|
public void Unparseable_json_names_the_json()
|
|
=> FocasTagDefinitionFactory.Inspect("{oops").ShouldContain(w => w.Contains("not valid JSON"));
|
|
|
|
// ---- Gitea #556 item 2: an advisory can no longer swallow the catch-all rejection ----
|
|
|
|
/// <summary>
|
|
/// The structural invariant the backstop exists to hold: a blob the mapper REFUSES 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>
|
|
/// ⚠️ FOCAS's own backstop is UNREACHABLE today (audited): every <c>return false</c> in
|
|
/// <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. 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]
|
|
public void A_rejected_config_always_draws_more_than_its_advisories_alone()
|
|
{
|
|
// Identical but for the dataType typo, so both carry the SAME two advisories.
|
|
const string Accepted = """{"address":"not-an-address","dataType":"Int32","writable":true}""";
|
|
const string Rejected = """{"address":"not-an-address","dataType":"Intt32","writable":true}""";
|
|
|
|
FocasTagDefinitionFactory.FromTagConfig(Accepted, RawPath, out _).ShouldBeTrue("control must be accepted");
|
|
FocasTagDefinitionFactory.FromTagConfig(Rejected, RawPath, out _).ShouldBeFalse("subject must be rejected");
|
|
|
|
var advisoriesOnly = FocasTagDefinitionFactory.Inspect(Accepted);
|
|
var withRejection = FocasTagDefinitionFactory.Inspect(Rejected);
|
|
|
|
advisoriesOnly.Count.ShouldBe(2, "the control's warnings are purely advisory");
|
|
withRejection.Count.ShouldBeGreaterThan(
|
|
advisoriesOnly.Count,
|
|
"a refusal must add a rejection-class warning — the backstop supplies one when nothing names it");
|
|
}
|
|
|
|
// ---- Gitea #556 item 1: leading whitespace no longer defeats the framing pre-filter ----
|
|
|
|
/// <summary>
|
|
/// Padding a blob with leading whitespace. A driver is handed whatever authoring persisted, and
|
|
/// <c>FromTagConfig</c> passes it straight to <c>JsonDocument.Parse</c>, which skips leading
|
|
/// whitespace — so the MAPPER's verdict is identical padded or not, and the gate's must be too.
|
|
/// </summary>
|
|
private const string Pad = " \t\r\n ";
|
|
|
|
/// <summary>
|
|
/// Never stricter, under padding: a padded blob the mapper still accepts draws nothing. (This
|
|
/// direction already held before #556 — the pre-filter returned empty for everything padded — and
|
|
/// is kept so a future "just trim everywhere" change cannot start warning about a served tag.)
|
|
/// </summary>
|
|
/// <param name="tagConfig">An accepted fixture, padded below.</param>
|
|
[Theory]
|
|
[MemberData(nameof(CleanAndAccepted))]
|
|
public void Leading_whitespace_leaves_an_accepted_config_silent(string tagConfig)
|
|
{
|
|
var padded = Pad + tagConfig;
|
|
FocasTagDefinitionFactory.FromTagConfig(padded, RawPath, out _)
|
|
.ShouldBeTrue("the mapper's JsonDocument.Parse skips leading whitespace");
|
|
FocasTagDefinitionFactory.Inspect(padded).ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Never silent, under padding — the #556 item 1 defect itself. The pre-filter tested
|
|
/// <c>reference[0] != '{'</c>, so EVERY padded blob returned empty and a padded config the driver
|
|
/// refuses deployed clean. Asserting equality with the untrimmed inspection (rather than merely
|
|
/// "not empty") also pins that padding changes no message.
|
|
/// </summary>
|
|
/// <param name="tagConfig">A rejected fixture, padded below.</param>
|
|
[Theory]
|
|
[MemberData(nameof(Rejected))]
|
|
public void Leading_whitespace_draws_exactly_the_warnings_the_untrimmed_blob_draws(string tagConfig)
|
|
{
|
|
var padded = Pad + tagConfig;
|
|
FocasTagDefinitionFactory.FromTagConfig(padded, RawPath, out _)
|
|
.ShouldBeFalse("the mapper's verdict is unchanged by padding");
|
|
FocasTagDefinitionFactory.Inspect(padded).ShouldBe(FocasTagDefinitionFactory.Inspect(tagConfig));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The other half of the same fix, and the one it would be easy to lose: trimming widens the gate
|
|
/// ONLY for JSON objects. A reference that is still not object-framed after the trim is not an
|
|
/// equipment-tag TagConfig at all — the pre-existing convention shared by all eleven inspectors —
|
|
/// so it keeps drawing nothing even though the mapper refuses it.
|
|
/// </summary>
|
|
/// <param name="reference">A padded, non-object reference.</param>
|
|
[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)
|
|
{
|
|
FocasTagDefinitionFactory.FromTagConfig(reference, RawPath, out _).ShouldBeFalse();
|
|
FocasTagDefinitionFactory.Inspect(reference).ShouldBeEmpty();
|
|
}
|
|
}
|