Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusInspectAcceptGateTests.cs
T
Joseph Doherty 05b424788f fix(tagconfig): close the four #551 residuals — accept-gate-first Inspect, three authoring rules, CSV/service gate, doc sweep
Gitea #551.

1. CSV import ran no driver-typed config gate. Its typed columns were always
   strictly parsed, but the free-form TagConfigJson column reached the database
   unread, so a CSV could author a tag the driver rejects — one that deploys
   green and reads BadNodeIdUnknown forever. RawTagCsvMapper.Parse now names the
   offending row in the staged review grid, and RawTreeService enforces the same
   gate on CreateTag/UpdateTag/ImportTags: a form cannot be the guarantee (the
   API and any future caller reach none of them, and this repo has no bUnit).
   A rejected import row is an ERROR — the batch inserts nothing and the row is
   named — never a skip.

   Note the brief's premise that manual entry rides ImportTagsAsync is wrong:
   RawManualTagEntryModal commits row-by-row via CreateTagAsync, which is why
   the gate had to land on all three write paths.

2/3. The seven pre-#549 Inspect methods ran a hand-list of DescribeInvalidEnum
   checks, so every rejection cause not on the list was invisible. They are now
   ACCEPT-GATE-FIRST (the MTConnect/Sql/OpcUaClient/Calculation shape): ask the
   driver's own reader for the verdict, then explain the refusal, with a
   catch-all so "the parse rejects but Inspect said nothing" is unreachable.
   That closes S7's absent dataType, AbCip members[], Modbus stringByteOrder and
   the type-dependent guards, plus causes the issue did not name (absent
   region/address/tagPath/symbolPath/topic, TwinCAT Structure, MQTT's Sparkplug
   tuple). FOCAS and MQTT keep advisories about ACCEPTED blobs, so they
   diagnose rather than return empty.

   Authoring half: ModbusTagConfigModel gains stringByteOrder;
   AbCipTagConfigModel asks the new AbCipTagDefinitionFactory.DescribeInvalidMember;
   S7TagConfigModel captures whether the SOURCE blob declared dataType and asks
   S7TagDefinitionFactory.DataTypeKey/DeclaresDataType. One authority per rule.

4. Doc sweep: the six factory Inspect summaries, EquipmentTagConfigInspector's
   class remarks and AdminOperationsActor's comment all claimed invalid enums are
   "silently defaulted by the lenient runtime" — false since R2-11 Phase C made
   the runtime reads strict. They describe rejections now.
2026-08-10 00:08:06 -04:00

129 lines
7.1 KiB
C#

using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// Gitea #551 items 2/3: <see cref="ModbusTagDefinitionFactory.Inspect"/> is <b>accept-gate-first</b> —
/// it asks <see cref="ModbusTagDefinitionFactory.FromTagConfig"/> for the verdict and only explains a
/// refusal.
/// <para>
/// Two properties, and they pull in opposite directions, which is why both are pinned here. The
/// gate must never be <b>stricter</b> than the mapper (a warning can be promoted to a blocking
/// deploy error by <c>TagConfigValidationMode.Error</c>, so a warning on a blob the driver serves
/// would refuse a working config); and it must never be <b>silent</b> on a blob the mapper refuses
/// (that tag deploys green and then reads <c>BadNodeIdUnknown</c> forever). The previous hand-list
/// of enum checks satisfied the first and failed the second for every cause not on the list —
/// an absent <c>region</c>, a bad <c>address</c>, <c>stringByteOrder</c>, and the two
/// type-dependent guards.
/// </para>
/// </summary>
public sealed class ModbusInspectAcceptGateTests
{
private const string RawPath = "cell/modbus/dev1/T1";
private const string Clean = """{"region":"HoldingRegisters","address":1,"dataType":"Int16"}""";
/// <summary>Every blob below is one the mapper ACCEPTS, so the gate must say nothing about it.</summary>
public static TheoryData<string> Accepted() =>
[
Clean,
"""{"region":"Coils","address":0,"dataType":"Bool"}""",
"""{"region":"HoldingRegisters","address":65535,"dataType":"String","stringLength":10,"stringByteOrder":"LowByteFirst"}""",
"""{"region":"HoldingRegisters","address":1,"dataType":"BitInRegister","bitIndex":15}""",
// The byte/ushort NARROWING is part of the mapper's rule: bitIndex 256 truncates to bit 0 and is
// ACCEPTED. A gate that described the un-narrowed value would refuse a tag the driver serves.
"""{"region":"HoldingRegisters","address":1,"dataType":"BitInRegister","bitIndex":256}""",
// A non-string enum token reads as ABSENT, not invalid — the mapper accepts it as the default.
"""{"region":"HoldingRegisters","address":1,"dataType":3}""",
];
/// <summary>Every blob below is one the mapper REJECTS, so the gate must produce at least one warning.</summary>
public static TheoryData<string> Rejected() =>
[
"""{"address":1,"dataType":"Int16"}""", // no region
"""{"region":"HoldingRegisters","dataType":"Int16"}""", // no address
"""{"region":"HoldingRegisters","address":-1,"dataType":"Int16"}""", // address < 0
"""{"region":"HoldingRegisters","address":65536,"dataType":"Int16"}""", // address > ushort
"""{"region":"HoldingRegisters","address":"1","dataType":"Int16"}""", // address not a number
"""{"region":"HoldingRegisterz","address":1,"dataType":"Int16"}""", // typo'd region
"""{"region":"HoldingRegisters","address":1,"dataType":"Intt16"}""", // typo'd dataType
"""{"region":"HoldingRegisters","address":1,"dataType":"Int16","byteOrder":"Bigg"}""", // typo'd byteOrder
"""{"region":"HoldingRegisters","address":1,"dataType":"Int16","stringByteOrder":"Nope"}""", // typo'd stringByteOrder
"""{"region":"HoldingRegisters","address":1,"dataType":"String"}""", // String with no length
"""{"region":"HoldingRegisters","address":1,"dataType":"BitInRegister","bitIndex":16}""", // bit out of range
];
[Theory]
[MemberData(nameof(Accepted))]
public void A_config_the_mapper_accepts_draws_no_warning(string tagConfig)
{
ModbusTagDefinitionFactory.FromTagConfig(tagConfig, RawPath, out _).ShouldBeTrue("fixture must be accepted");
ModbusTagDefinitionFactory.Inspect(tagConfig).ShouldBeEmpty();
}
[Theory]
[MemberData(nameof(Rejected))]
public void A_config_the_mapper_rejects_is_never_passed_over_in_silence(string tagConfig)
{
ModbusTagDefinitionFactory.FromTagConfig(tagConfig, RawPath, out _).ShouldBeFalse("fixture must be rejected");
ModbusTagDefinitionFactory.Inspect(tagConfig).ShouldNotBeEmpty();
}
// ---- The specific causes the issue named, each with a message that points at the right key ----
[Fact]
public void An_absent_region_is_named()
=> ModbusTagDefinitionFactory.Inspect("""{"address":1,"dataType":"Int16"}""")
.ShouldContain(w => w.Contains("'region'"));
[Fact]
public void An_out_of_range_address_is_named()
=> ModbusTagDefinitionFactory.Inspect("""{"region":"Coils","address":70000,"dataType":"Int16"}""")
.ShouldContain(w => w.Contains("'address'"));
/// <summary>Gitea #551 item 3: read strictly by the mapper since R2-11, checked by nothing until now.</summary>
[Fact]
public void A_typo_in_stringByteOrder_is_named()
=> ModbusTagDefinitionFactory.Inspect(
"""{"region":"HoldingRegisters","address":1,"dataType":"Int16","stringByteOrder":"HighByteFrist"}""")
.ShouldContain(w => w.Contains("stringByteOrder") && w.Contains("HighByteFrist"));
[Fact]
public void A_String_tag_with_no_stringLength_is_named()
=> ModbusTagDefinitionFactory.Inspect("""{"region":"HoldingRegisters","address":1,"dataType":"String"}""")
.ShouldContain(w => w.Contains("stringLength"));
[Fact]
public void A_BitInRegister_tag_with_a_bit_over_15_is_named()
=> ModbusTagDefinitionFactory.Inspect(
"""{"region":"HoldingRegisters","address":1,"dataType":"BitInRegister","bitIndex":16}""")
.ShouldContain(w => w.Contains("bitIndex"));
// ---- Framing rules preserved from the pre-#551 shape ----
[Fact]
public void A_blank_reference_draws_nothing()
=> ModbusTagDefinitionFactory.Inspect(" ").ShouldBeEmpty();
/// <summary>
/// The pre-existing framing convention, deliberately left alone by #551 and shared by all eleven
/// inspectors: a reference that does not START with <c>{</c> is not an equipment-tag TagConfig at
/// all, so it draws nothing — even though the mapper would refuse it. Widening this is a separate
/// decision about what a TagConfig IS, not about how a refusal is explained.
/// </summary>
/// <param name="reference">The non-object reference.</param>
[Theory]
[InlineData("just a string")]
[InlineData("[1,2,3]")]
[InlineData("42")]
public void A_non_object_reference_draws_nothing_because_it_is_not_a_TagConfig(string reference)
{
ModbusTagDefinitionFactory.FromTagConfig(reference, RawPath, out _).ShouldBeFalse();
ModbusTagDefinitionFactory.Inspect(reference).ShouldBeEmpty();
}
[Fact]
public void Unparseable_json_names_the_json()
=> ModbusTagDefinitionFactory.Inspect("{oops").ShouldContain(w => w.Contains("not valid JSON"));
}