From 05b424788f25c61a7ca63c09b4099ee71c727dae Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 10 Aug 2026 00:08:06 -0400 Subject: [PATCH] =?UTF-8?q?fix(tagconfig):=20close=20the=20four=20#551=20r?= =?UTF-8?q?esiduals=20=E2=80=94=20accept-gate-first=20Inspect,=20three=20a?= =?UTF-8?q?uthoring=20rules,=20CSV/service=20gate,=20doc=20sweep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../AbCipTagDefinitionFactory.cs | 141 ++++++- .../AbLegacyTagDefinitionFactory.cs | 65 +++- .../FocasTagDefinitionFactory.cs | 116 ++++-- .../ModbusTagDefinitionFactory.cs | 118 +++++- .../MqttTagDefinitionFactory.cs | 97 ++++- .../S7TagDefinitionFactory.cs | 106 +++++- .../TwinCATTagDefinitionFactory.cs | 68 +++- .../Uns/RawTagCsvMapper.cs | 24 +- .../Uns/RawTreeService.cs | 69 ++++ .../Uns/TagEditors/AbCipTagConfigModel.cs | 14 +- .../Uns/TagEditors/ModbusTagConfigModel.cs | 21 +- .../Uns/TagEditors/S7TagConfigModel.cs | 49 ++- .../AdminOperations/AdminOperationsActor.cs | 7 +- .../EquipmentTagConfigInspector.cs | 13 +- .../AbCipInspectAcceptGateTests.cs | 100 +++++ .../AbLegacyInspectAcceptGateTests.cs | 64 ++++ .../FocasInspectAcceptGateTests.cs | 98 +++++ .../ModbusInspectAcceptGateTests.cs | 128 +++++++ .../MqttInspectAcceptGateTests.cs | 88 +++++ .../S7InspectAcceptGateTests.cs | 98 +++++ .../TwinCATInspectAcceptGateTests.cs | 71 ++++ .../Uns/RawTagConfigGateTests.cs | 350 ++++++++++++++++++ .../Uns/RawTreeServiceWaveBTests.cs | 31 +- .../Uns/TagConfigValidatorTests.cs | 10 +- 24 files changed, 1846 insertions(+), 100 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipInspectAcceptGateTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyInspectAcceptGateTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasInspectAcceptGateTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusInspectAcceptGateTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttInspectAcceptGateTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7InspectAcceptGateTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATInspectAcceptGateTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTagConfigGateTests.cs diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipTagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipTagDefinitionFactory.cs index eaeaa4fb..77770c8c 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipTagDefinitionFactory.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipTagDefinitionFactory.cs @@ -189,16 +189,34 @@ public static class AbCipTagDefinitionFactory } /// - /// Deploy-time inspection (05/CONV-2): warns on a present-but-invalid dataType (silently - /// defaulted by the lenient runtime) and on a structurally unparseable TagConfig. Empty when clean - /// or not an equipment-tag object. Never throws. + /// The RawPath handed to during inspection — the mapper only copies it + /// onto and never validates it. /// + private const string InspectionRawPath = "(inspect)"; + + /// + /// Deploy-time inspection (05/CONV-2): warns for a blob would + /// reject — a structurally unparseable or non-object document, an absent / non-string / + /// blank tagPath, a present-but-invalid dataType (the runtime enum read is STRICT + /// since R2-11 Phase C, so a typo rejects the tag), or a malformed declared UDT member (see + /// ). Such a tag is skipped by the driver and reads + /// BadNodeIdUnknown forever. Empty when the mapper accepts the blob, when it is blank, or + /// when it is not an equipment-tag object. Never throws. + /// + /// + /// The verdict comes from , not from a mirror (Gitea #551) — see + /// ModbusTagDefinitionFactory.Inspect's remarks for the full argument. + /// /// The equipment tag's TagConfig JSON. /// The warnings; empty when clean. public static IReadOnlyList Inspect(string reference) { var warnings = new List(); if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings; + + // The accept gate: whatever the driver reads draws nothing. + if (FromTagConfig(reference, InspectionRawPath, out _)) return warnings; + try { using var doc = JsonDocument.Parse(reference); @@ -208,16 +226,129 @@ public static class AbCipTagDefinitionFactory warnings.Add("AbCip TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown)."); return warnings; } - var w = TagConfigJson.DescribeInvalidEnum(root, "dataType"); - if (w is not null) warnings.Add(w); + + DescribeRejection(root, warnings); } catch (JsonException) { warnings.Add("AbCip TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown)."); + return warnings; } + + if (warnings.Count == 0) + { + warnings.Add( + "AbCip TagConfig was rejected by the driver's tag reader — the driver will skip this tag " + + "and it will read BadNodeIdUnknown."); + } + return warnings; } + /// + /// Explains why refused an object-rooted blob, walking its checks in the + /// same order. Runs only after the mapper has already refused, so it can never invent strictness. + /// + /// The TagConfig root object. + /// The list to append warnings to. + private static void DescribeRejection(JsonElement root, List warnings) + { + if (!root.TryGetProperty("tagPath", out var tagPathEl) || tagPathEl.ValueKind != JsonValueKind.String) + { + warnings.Add( + "AbCip TagConfig has no string 'tagPath' — AbCip is a symbolic driver and addresses by " + + "Logix tag path, so the driver will skip this tag and it will read BadNodeIdUnknown."); + } + else if (string.IsNullOrWhiteSpace(tagPathEl.GetString())) + { + warnings.Add("AbCip TagConfig has a blank 'tagPath' — the tag will not resolve (BadNodeIdUnknown)."); + } + + if (TagConfigJson.DescribeInvalidEnum(root, "dataType") is { } w) warnings.Add(w); + + if (DescribeInvalidMember(root) is { } memberWarning) warnings.Add(memberWarning); + } + + /// + /// The single authority on the declared-UDT-member rules, consulted by + /// and by the AdminUI's AbCipTagConfigModel.Validate alike. Describes the FIRST malformed + /// entry of the optional members array — a non-object entry, one with no usable name, + /// or one whose dataType is a present-but-invalid — or + /// when every member reads. + /// + /// The member rules are as strict as the top-level ones ( refuses the + /// WHOLE tag for one bad member), but no authoring surface checked them: the typed editor emits + /// only tagPath/dataType/writable and members rides the model's + /// preserved key bag untouched, so a hand-authored / CSV / API blob with a typo'd member + /// deployed clean and died at the driver — Gitea #551 item 3. + /// + /// + /// A members value that is absent or not a JSON array draws nothing: the mapper reads it + /// only when it is an array, and an array of zero usable members simply means "no member + /// fan-out". Saying otherwise here would make the gate stricter than the reader. + /// + /// + /// The TagConfig root object. + /// The warning text, or when the members read. + public static string? DescribeInvalidMember(JsonElement root) + { + if (!root.TryGetProperty("members", out var membersEl) || membersEl.ValueKind != JsonValueKind.Array) + return null; + + var index = 0; + foreach (var member in membersEl.EnumerateArray()) + { + var label = $"members[{index}]"; + index++; + + if (member.ValueKind != JsonValueKind.Object) + { + return $"AbCip {label} is not a JSON object — a declared UDT member needs a 'name' and may " + + "carry a 'dataType'; the WHOLE tag is rejected (BadNodeIdUnknown)."; + } + + if (!member.TryGetProperty("name", out var nameEl) + || nameEl.ValueKind != JsonValueKind.String + || string.IsNullOrWhiteSpace(nameEl.GetString())) + { + return $"AbCip {label} has no usable 'name' — a declared UDT member must name itself; the " + + "WHOLE tag is rejected (BadNodeIdUnknown)."; + } + + if (TagConfigJson.DescribeInvalidEnum(member, "dataType") is { } dataTypeWarning) + { + return $"AbCip {label}: {dataTypeWarning} — the WHOLE tag is rejected (BadNodeIdUnknown)."; + } + } + + return null; + } + + /// + /// String-input overload of , for the AdminUI + /// authoring model — which holds a mutable JsonObject key bag rather than a + /// and so reaches the same rule through a different door. Never throws: a + /// blank / unparseable / non-object blob draws nothing here, because those shapes are the OTHER + /// gates' business, not the member rule's. + /// + /// The authored equipment-tag TagConfig JSON. + /// The warning text, or when the members read. + public static string? DescribeInvalidMember(string? tagConfig) + { + if (string.IsNullOrWhiteSpace(tagConfig)) return null; + try + { + using var doc = JsonDocument.Parse(tagConfig); + return doc.RootElement.ValueKind == JsonValueKind.Object + ? DescribeInvalidMember(doc.RootElement) + : null; + } + catch (JsonException) + { + return null; + } + } + private static bool ReadBool(JsonElement o, string name) => o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.True; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyTagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyTagDefinitionFactory.cs index 011be526..7cc458a2 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyTagDefinitionFactory.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyTagDefinitionFactory.cs @@ -119,16 +119,40 @@ public static class AbLegacyTagDefinitionFactory } /// - /// Deploy-time inspection (05/CONV-2): warns on a present-but-invalid dataType (silently - /// defaulted by the lenient runtime) and on a structurally unparseable TagConfig. Empty when clean - /// or not an equipment-tag object. Never throws. + /// The RawPath handed to during inspection — the mapper only copies it + /// onto and never validates it. /// + private const string InspectionRawPath = "(inspect)"; + + /// + /// Deploy-time inspection (05/CONV-2): warns for a blob would + /// reject — a structurally unparseable or non-object document, an absent / non-string / + /// blank address, or a present-but-invalid dataType (the runtime enum read is STRICT + /// since R2-11 Phase C, so a typo rejects the tag). Such a tag is skipped by the driver and reads + /// BadNodeIdUnknown forever. Empty when the mapper accepts the blob, when it is blank, or + /// when it is not an equipment-tag object. Never throws. + /// + /// + /// The verdict comes from , not from a mirror (Gitea #551) — see + /// ModbusTagDefinitionFactory.Inspect's remarks for the full argument. + /// + /// An arrayLength above is deliberately NOT + /// warned about: the mapper CLAMPS it rather than rejecting, so the driver serves the tag. The + /// AdminUI model refuses it at authoring because the un-clamped length also sets the OPC UA + /// ArrayDimensions; a warning here could be promoted to a deploy ERROR and would then + /// block a tag the runtime happily reads. + /// + /// /// The equipment tag's TagConfig JSON. /// The warnings; empty when clean. public static IReadOnlyList Inspect(string reference) { var warnings = new List(); if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings; + + // The accept gate: whatever the driver reads draws nothing. + if (FromTagConfig(reference, InspectionRawPath, out _)) return warnings; + try { using var doc = JsonDocument.Parse(reference); @@ -138,16 +162,47 @@ public static class AbLegacyTagDefinitionFactory warnings.Add("AbLegacy TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown)."); return warnings; } - var w = TagConfigJson.DescribeInvalidEnum(root, "dataType"); - if (w is not null) warnings.Add(w); + + DescribeRejection(root, warnings); } catch (JsonException) { warnings.Add("AbLegacy TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown)."); + return warnings; } + + if (warnings.Count == 0) + { + warnings.Add( + "AbLegacy TagConfig was rejected by the driver's tag reader — the driver will skip this tag " + + "and it will read BadNodeIdUnknown."); + } + return warnings; } + /// + /// Explains why refused an object-rooted blob, walking its checks in the + /// same order. Runs only after the mapper has already refused, so it can never invent strictness. + /// + /// The TagConfig root object. + /// The list to append warnings to. + private static void DescribeRejection(JsonElement root, List warnings) + { + if (!root.TryGetProperty("address", out var addr) || addr.ValueKind != JsonValueKind.String) + { + warnings.Add( + "AbLegacy TagConfig has no string 'address' (e.g. N7:0, B3:1/2, F8:5) — the driver will " + + "skip this tag and it will read BadNodeIdUnknown."); + } + else if (string.IsNullOrWhiteSpace(addr.GetString())) + { + warnings.Add("AbLegacy TagConfig has a blank 'address' — the tag will not resolve (BadNodeIdUnknown)."); + } + + if (TagConfigJson.DescribeInvalidEnum(root, "dataType") is { } w) warnings.Add(w); + } + private static int ReadInt(JsonElement o, string name) => o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.Number && e.TryGetInt32(out var v) ? v : 0; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasTagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasTagDefinitionFactory.cs index 3ece4da2..7b20ca1c 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasTagDefinitionFactory.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasTagDefinitionFactory.cs @@ -95,10 +95,33 @@ public static class FocasTagDefinitionFactory } /// - /// Deploy-time inspection (05/CONV-2, 05/UNDER-1): warns on a present-but-invalid dataType - /// (silently defaulted by the lenient runtime), on an authored writable:true (the FOCAS wire - /// is read-only — WriteAsync always returns BadNotWritable), and on a structurally - /// unparseable TagConfig. Empty when clean or not an equipment-tag object. Never throws. + /// The RawPath handed to during inspection — the mapper only copies it + /// onto and never validates it. + /// + private const string InspectionRawPath = "(inspect)"; + + /// + /// Deploy-time inspection (05/CONV-2, 05/UNDER-1). Two kinds of warning, and the distinction is + /// load-bearing: + /// + /// + /// Rejections — a blob would refuse: an unparseable or + /// non-object document, an absent / non-string / blank address, or a + /// present-but-invalid dataType (the runtime enum read is STRICT since R2-11 Phase C, + /// so a typo rejects the tag). The driver skips such a tag and it reads + /// BadNodeIdUnknown forever. These are diagnosed only when the mapper has actually + /// refused, so this gate can never be stricter than the runtime (Gitea #551). + /// + /// + /// Advisories — an accepted blob that still will not work: an unparseable + /// FOCAS address (#540; the mapper only requires a non-blank string, the driver's own init + /// then drops it) and an authored writable:true (the FOCAS wire is read-only, so + /// WriteAsync always returns BadNotWritable). These run whether or not the + /// blob parses, which is why FOCAS does not simply return empty on accept. + /// + /// + /// Empty for a clean, accepted blob, for a blank one, or for one that is not an equipment-tag + /// object. Never throws. /// /// The equipment tag's TagConfig JSON. /// The warnings; empty when clean. @@ -106,6 +129,11 @@ public static class FocasTagDefinitionFactory { var warnings = new List(); if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings; + + // The accept gate: only a blob the driver REFUSES gets a rejection diagnosis. The advisories below + // apply either way — see the summary. + var accepted = FromTagConfig(reference, InspectionRawPath, out _); + try { using var doc = JsonDocument.Parse(reference); @@ -115,30 +143,72 @@ public static class FocasTagDefinitionFactory warnings.Add("FOCAS TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown)."); return warnings; } - var w = TagConfigJson.DescribeInvalidEnum(root, "dataType"); - if (w is not null) warnings.Add(w); - // Gitea #540: an address the driver cannot parse is skipped at init and reads - // BadNodeIdUnknown forever, while driver health stays Healthy with zero errors. The - // AdminUI modal rejects it outright; this covers the paths that never touch the modal - // (CSV import, the API) by at least surfacing it on the deployment. - if (root.TryGetProperty("address", out var addr) - && addr.ValueKind == JsonValueKind.String - && addr.GetString() is { } address - && !string.IsNullOrWhiteSpace(address) - && FocasAddress.TryParse(address) is null) - { - warnings.Add( - $"FOCAS address '{address.Trim()}' is not parseable (expected X0.0 / R100, " + - "PARAM:1020, MACRO:500 or FIXED:Identity/SeriesNumber) — the driver will skip " + - "this tag at startup and it will read BadNodeIdUnknown."); - } - if (root.TryGetProperty("writable", out var wr) && wr.ValueKind == JsonValueKind.True) - warnings.Add("FOCAS wire is read-only — an authored 'writable:true' still fails at the wire with BadNotWritable."); + + if (!accepted) DescribeRejection(root, warnings); + DescribeAdvisories(root, warnings); } catch (JsonException) { warnings.Add("FOCAS TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown)."); + return warnings; } + + if (!accepted && warnings.Count == 0) + { + warnings.Add( + "FOCAS TagConfig was rejected by the driver's tag reader — the driver will skip this tag " + + "and it will read BadNodeIdUnknown."); + } + return warnings; } + + /// + /// Explains why refused an object-rooted blob, walking its checks in the + /// same order. Runs only after the mapper has already refused, so it can never invent strictness. + /// + /// The TagConfig root object. + /// The list to append warnings to. + private static void DescribeRejection(JsonElement root, List warnings) + { + if (!root.TryGetProperty("address", out var addr) || addr.ValueKind != JsonValueKind.String) + { + warnings.Add( + "FOCAS TagConfig has no string 'address' — the driver will skip this tag and it will read " + + "BadNodeIdUnknown."); + } + else if (string.IsNullOrWhiteSpace(addr.GetString())) + { + warnings.Add("FOCAS TagConfig has a blank 'address' — the tag will not resolve (BadNodeIdUnknown)."); + } + + if (TagConfigJson.DescribeInvalidEnum(root, "dataType") is { } w) warnings.Add(w); + } + + /// + /// Warnings about a blob the mapper ACCEPTS but the driver still cannot serve as authored — see the + /// "advisories" bullet on . + /// + /// The TagConfig root object. + /// The list to append warnings to. + private static void DescribeAdvisories(JsonElement root, List warnings) + { + // Gitea #540: an address the driver cannot parse is skipped at init and reads BadNodeIdUnknown + // forever, while driver health stays Healthy with zero errors. The AdminUI modal rejects it + // outright; this covers the paths that never touch the modal (CSV import, the API). + if (root.TryGetProperty("address", out var addr) + && addr.ValueKind == JsonValueKind.String + && addr.GetString() is { } address + && !string.IsNullOrWhiteSpace(address) + && FocasAddress.TryParse(address) is null) + { + warnings.Add( + $"FOCAS address '{address.Trim()}' is not parseable (expected X0.0 / R100, " + + "PARAM:1020, MACRO:500 or FIXED:Identity/SeriesNumber) — the driver will skip " + + "this tag at startup and it will read BadNodeIdUnknown."); + } + + if (root.TryGetProperty("writable", out var wr) && wr.ValueKind == JsonValueKind.True) + warnings.Add("FOCAS wire is read-only — an authored 'writable:true' still fails at the wire with BadNotWritable."); + } } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusTagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusTagDefinitionFactory.cs index 7d24df85..6c10215f 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusTagDefinitionFactory.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusTagDefinitionFactory.cs @@ -130,18 +130,41 @@ public static class ModbusTagDefinitionFactory } /// - /// Deploy-time inspection of an equipment-tag TagConfig blob (05/CONV-2). Returns - /// human-readable warnings for present-but-invalid enum values (region / dataType / - /// byteOrder — which the lenient runtime silently defaults) and for a structurally - /// unparseable TagConfig (which the runtime turns into a silent BadNodeIdUnknown). Empty when - /// the blob is clean or is not an equipment-tag TagConfig object. Never throws. + /// The RawPath handed to during inspection. The mapper only copies it + /// onto and never validates it, so inspection is free to pass + /// a placeholder — the deploy gate is inspecting a blob, not a placed tag. /// + private const string InspectionRawPath = "(inspect)"; + + /// + /// Deploy-time inspection of an equipment-tag TagConfig blob (05/CONV-2). Returns + /// human-readable warnings for a blob would reject — a + /// structurally unparseable or non-object document, an absent region, an absent or + /// out-of-range address, a present-but-invalid region / dataType / + /// byteOrder / stringByteOrder (each rejects the tag: the runtime enum reads are + /// STRICT since R2-11 Phase C), or a type-dependent guard (String with no + /// stringLength, BitInRegister with a bitIndex outside 0–15). Such a tag is + /// skipped by the driver and reads BadNodeIdUnknown forever. Empty when the mapper accepts + /// the blob, when it is blank, or when it is not an equipment-tag TagConfig object. Never throws. + /// + /// + /// The verdict comes from , not from a mirror (Gitea #551). The + /// rule this gate must never break is being stricter than the runtime — a blob the driver serves + /// must not draw a warning calling it invalid — and running the mapper first makes that structural + /// rather than a promise. It also closes the opposite gap: the previous hand-list of enum checks + /// was silent for every rejection cause that was not on it (an absent region, a bad + /// address, stringByteOrder), so a tag the driver refuses deployed clean. + /// /// The equipment tag's TagConfig JSON. /// The warnings; empty when clean. public static IReadOnlyList Inspect(string reference) { var warnings = new List(); if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings; + + // The accept gate: whatever the driver reads draws nothing. See the remarks. + if (FromTagConfig(reference, InspectionRawPath, out _)) return warnings; + try { using var doc = JsonDocument.Parse(reference); @@ -151,23 +174,90 @@ public static class ModbusTagDefinitionFactory warnings.Add("Modbus TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown)."); return warnings; } - foreach (var w in new[] - { - TagConfigJson.DescribeInvalidEnum(root, "region"), - TagConfigJson.DescribeInvalidEnum(root, "dataType"), - TagConfigJson.DescribeInvalidEnum(root, "byteOrder"), - }) - { - if (w is not null) warnings.Add(w); - } + + DescribeRejection(root, warnings); } catch (JsonException) { warnings.Add("Modbus TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown)."); + return warnings; } + + // Backstop: the mapper refused for a reason nothing above names (it also swallows FormatException + // and InvalidOperationException). Saying so beats reporting a clean blob the driver will skip. + if (warnings.Count == 0) + { + warnings.Add( + "Modbus TagConfig was rejected by the driver's tag reader — the driver will skip this tag " + + "and it will read BadNodeIdUnknown."); + } + return warnings; } + /// + /// Explains why refused an object-rooted blob, walking its checks in the + /// same order. Every branch mirrors a return false there — and it runs only after the mapper + /// has already refused, so it can never invent strictness of its own. + /// + /// The TagConfig root object. + /// The list to append warnings to. + private static void DescribeRejection(JsonElement root, List warnings) + { + if (!root.TryGetProperty("region", out _)) + { + warnings.Add( + "Modbus TagConfig has no 'region' — every Modbus tag must name one (valid: " + + string.Join(", ", Enum.GetNames()) + + "); the driver will skip this tag and it will read BadNodeIdUnknown."); + } + + if (!root.TryGetProperty("address", out var addr)) + { + warnings.Add("Modbus TagConfig has no 'address' — the tag will not resolve (BadNodeIdUnknown)."); + } + else if (addr.ValueKind != JsonValueKind.Number + || !addr.TryGetInt32(out var address) + || address < 0 || address > ushort.MaxValue) + { + warnings.Add( + $"value '{addr.GetRawText()}' for 'address' is not a Modbus register address (an integer " + + $"0–{ushort.MaxValue}); the tag will not resolve (BadNodeIdUnknown)."); + } + + // Strict enums, in the order FromTagConfig reads them. stringByteOrder is read as strictly as its + // three siblings, and was the one the previous hand-list forgot (Gitea #551). + foreach (var w in new[] + { + TagConfigJson.DescribeInvalidEnum(root, "region"), + TagConfigJson.DescribeInvalidEnum(root, "dataType"), + TagConfigJson.DescribeInvalidEnum(root, "byteOrder"), + TagConfigJson.DescribeInvalidEnum(root, "stringByteOrder"), + }) + { + if (w is not null) warnings.Add(w); + } + + // Type-dependent guards. The byte/ushort NARROWING is part of the rule, not an accident of the + // mapper: it reads `(byte)bitIndex` / `(ushort)stringLength`, so 256 is bit 0 and is ACCEPTED. + // Describing the un-narrowed value here would call a blob invalid that the driver serves. + if (!TagConfigJson.TryReadEnumStrict(root, "dataType", ModbusDataType.Int16, out var dataType)) return; + + if (dataType == ModbusDataType.String && (ushort)ReadInt(root, "stringLength") < 1) + { + warnings.Add( + "Modbus String tag has no 'stringLength' of at least 1 — a zero-length string reads zero " + + "registers, which is an illegal Modbus request, so the tag is rejected (BadNodeIdUnknown)."); + } + + if (dataType == ModbusDataType.BitInRegister && (byte)ReadInt(root, "bitIndex") > 15) + { + warnings.Add( + "Modbus BitInRegister tag has a 'bitIndex' outside 0–15 — the bit mask would overflow a " + + "16-bit register, so the tag is rejected (BadNodeIdUnknown)."); + } + } + private static int ReadInt(JsonElement o, string name) => o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.Number && e.TryGetInt32(out var v) ? v : 0; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinitionFactory.cs index abceea13..c8f08782 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinitionFactory.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinitionFactory.cs @@ -205,19 +205,46 @@ public static class MqttTagDefinitionFactory } /// - /// Deploy-time inspection of an equipment-tag TagConfig blob. Returns human-readable - /// warnings for a structurally unparseable blob (which the runtime turns into a silent - /// BadNodeIdUnknown), for present-but-invalid payloadFormat / dataType / - /// qos values, and for a wildcard tag topic (a tag bound to + / # - /// would receive values from many topics — ambiguous, and almost never what the operator meant). - /// Empty when the blob is clean or is not an equipment-tag TagConfig object. Never throws. + /// The RawPath handed to the mappers during inspection — they only copy it onto + /// and never validate it. /// + private const string InspectionRawPath = "(inspect)"; + + /// + /// Deploy-time inspection of an equipment-tag TagConfig blob. Two kinds of warning: + /// + /// + /// Rejections — a blob NEITHER runtime mapper would read: an unparseable or + /// non-object document, present-but-invalid payloadFormat / dataType / + /// qos values (all read strictly), or an address that names neither a Plain + /// topic nor a complete Sparkplug groupId/edgeNodeId/metricName + /// tuple. Such a tag is skipped by the driver and reads BadNodeIdUnknown forever. + /// + /// + /// Advisory — a wildcard tag topic (+ / #). The mapper + /// deliberately ACCEPTS it (ambiguous, not unparseable), so this runs either way. + /// + /// + /// Empty for a clean, readable blob, for a blank one, or for one that is not an equipment-tag + /// TagConfig object. Never throws. + /// + /// + /// The accept gate is the UNION of the two mappers (Gitea #551), because Inspect is + /// handed a blob with no mode: the driver's decides which of + /// / reads it, and nothing about a + /// tag row says which. Gating on the Plain mapper alone would warn that every correctly-authored + /// Sparkplug tag has no topic — a gate stricter than the runtime, the one thing it must never be. + /// /// The equipment tag's TagConfig JSON. /// The warnings; empty when clean. public static IReadOnlyList Inspect(string reference) { var warnings = new List(); if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings; + + var accepted = FromTagConfig(reference, InspectionRawPath, out _) + || FromSparkplugTagConfig(reference, InspectionRawPath, out _); + try { using var doc = JsonDocument.Parse(reference); @@ -227,16 +254,11 @@ public static class MqttTagDefinitionFactory warnings.Add("Mqtt TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown)."); return warnings; } - foreach (var w in new[] - { - TagConfigJson.DescribeInvalidEnum(root, "payloadFormat"), - TagConfigJson.DescribeInvalidEnum(root, "dataType"), - DescribeInvalidQos(root), - }) - { - if (w is not null) warnings.Add(w); - } - var topic = ReadString(root, "topic"); + + if (!accepted) DescribeRejection(root, warnings); + + // Advisory — the mappers accept a wildcard topic on purpose; see the summary. + var topic = ReadString(root, MqttTagConfigKeys.Topic); if (topic.IndexOfAny(TopicWildcards) >= 0) { warnings.Add( @@ -247,10 +269,53 @@ public static class MqttTagDefinitionFactory catch (JsonException) { warnings.Add("Mqtt TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown)."); + return warnings; } + + if (!accepted && warnings.Count == 0) + { + warnings.Add( + "Mqtt TagConfig was rejected by the driver's tag reader — the driver will skip this tag " + + "and it will read BadNodeIdUnknown."); + } + return warnings; } + /// + /// Explains why BOTH mappers refused an object-rooted blob. The strict-field checks are shared by + /// the two shapes; the address check names what each shape needed. Runs only after both mappers have + /// already refused, so it can never invent strictness. + /// + /// The TagConfig root object. + /// The list to append warnings to. + private static void DescribeRejection(JsonElement root, List warnings) + { + foreach (var w in new[] + { + TagConfigJson.DescribeInvalidEnum(root, "payloadFormat"), + TagConfigJson.DescribeInvalidEnum(root, "dataType"), + DescribeInvalidQos(root), + }) + { + if (w is not null) warnings.Add(w); + } + + // Address: Plain needs a topic, Sparkplug needs the whole binding tuple. Neither is satisfied, or + // one of the strict fields above already refused both — only say it when the address is the cause. + var hasTopic = !string.IsNullOrWhiteSpace(ReadString(root, MqttTagConfigKeys.Topic)); + var hasSparkplugTuple = !string.IsNullOrWhiteSpace(ReadString(root, MqttTagConfigKeys.GroupId)) + && !string.IsNullOrWhiteSpace(ReadString(root, MqttTagConfigKeys.EdgeNodeId)) + && !string.IsNullOrWhiteSpace(ReadString(root, MqttTagConfigKeys.MetricName)); + if (!hasTopic && !hasSparkplugTuple) + { + warnings.Add( + "Mqtt TagConfig names no address — a Plain-mode tag needs a 'topic', a Sparkplug B tag " + + "needs 'groupId' + 'edgeNodeId' + 'metricName'. The driver will skip this tag and it " + + "will read BadNodeIdUnknown."); + } + } + /// /// Strict qos read, mirroring 's /// absent / valid / present-but-invalid split: absent ⇒ (the driver-level diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7TagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7TagDefinitionFactory.cs index 48044406..6d1839b4 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7TagDefinitionFactory.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7TagDefinitionFactory.cs @@ -111,16 +111,61 @@ public static class S7TagDefinitionFactory } /// - /// Deploy-time inspection (05/CONV-2): warns on a present-but-invalid dataType (silently - /// defaulted by the lenient runtime) and on a structurally unparseable TagConfig (a silent - /// BadNodeIdUnknown at runtime). Empty when clean or not an equipment-tag object. Never throws. + /// The TagConfig key carrying the S7 data type. Shared with the AdminUI authoring model so + /// "does the blob declare a type?" is asked of one spelling, not two (Gitea #551). /// + public const string DataTypeKey = "dataType"; + + /// + /// The RawPath handed to during inspection — the mapper only copies it + /// onto and never validates it. + /// + private const string InspectionRawPath = "(inspect)"; + + /// + /// The single authority on whether an S7 blob DECLARES a data type, consulted by + /// and by the AdminUI's S7TagConfigModel.Validate alike. + /// + /// S7 is the one driver in the fleet whose reader requires the key to be present + /// ( refuses a blob without it, where every sibling falls back to a + /// default), so a hand-authored / CSV / API blob that omits it used to pass both gates and die + /// at the driver with BadNodeIdUnknown — Gitea #551 item 2. + /// + /// + /// Presence is judged on the KEY, not on the value: a non-string token + /// ("dataType": 8) satisfies the presence check and is then treated as absent by + /// , so the blob is ACCEPTED as + /// . Judging the value here would make this gate stricter than + /// the reader. + /// + /// + /// The TagConfig root object. + /// when the blob carries a dataType key of any kind. + public static bool DeclaresDataType(JsonElement root) => root.TryGetProperty(DataTypeKey, out _); + + /// + /// Deploy-time inspection (05/CONV-2): warns for a blob would + /// reject — a structurally unparseable or non-object document, an absent / non-string / + /// blank address, an absent dataType (see ), a + /// present-but-invalid dataType (the runtime enum read is STRICT since R2-11 Phase C, so a + /// typo rejects the tag), or an out-of-range stringLength on a String tag. Such a tag + /// is skipped by the driver and reads BadNodeIdUnknown forever. Empty when the mapper accepts + /// the blob, when it is blank, or when it is not an equipment-tag object. Never throws. + /// + /// + /// The verdict comes from , not from a mirror (Gitea #551) — see + /// ModbusTagDefinitionFactory.Inspect's remarks for the full argument. + /// /// The equipment tag's TagConfig JSON. /// The warnings; empty when clean. public static IReadOnlyList Inspect(string reference) { var warnings = new List(); if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings; + + // The accept gate: whatever the driver reads draws nothing. + if (FromTagConfig(reference, InspectionRawPath, out _)) return warnings; + try { using var doc = JsonDocument.Parse(reference); @@ -130,16 +175,67 @@ public static class S7TagDefinitionFactory warnings.Add("S7 TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown)."); return warnings; } - var w = TagConfigJson.DescribeInvalidEnum(root, "dataType"); - if (w is not null) warnings.Add(w); + + DescribeRejection(root, warnings); } catch (JsonException) { warnings.Add("S7 TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown)."); + return warnings; } + + if (warnings.Count == 0) + { + warnings.Add( + "S7 TagConfig was rejected by the driver's tag reader — the driver will skip this tag and " + + "it will read BadNodeIdUnknown."); + } + return warnings; } + /// + /// Explains why refused an object-rooted blob, walking its checks in the + /// same order. Runs only after the mapper has already refused, so it can never invent strictness. + /// + /// The TagConfig root object. + /// The list to append warnings to. + private static void DescribeRejection(JsonElement root, List warnings) + { + if (!root.TryGetProperty("address", out var addrEl) || addrEl.ValueKind != JsonValueKind.String) + { + warnings.Add( + "S7 TagConfig has no string 'address' (e.g. DB1.DBW0, M0.0, I0.0, QD4) — the driver will " + + "skip this tag and it will read BadNodeIdUnknown."); + } + else if (string.IsNullOrWhiteSpace(addrEl.GetString())) + { + warnings.Add("S7 TagConfig has a blank 'address' — the tag will not resolve (BadNodeIdUnknown)."); + } + + if (!DeclaresDataType(root)) + { + warnings.Add( + "S7 TagConfig has no 'dataType' — the S7 reader REQUIRES the key to be present (unlike its " + + "sibling drivers, which default it), so the driver will skip this tag and it will read " + + "BadNodeIdUnknown. Valid: " + string.Join(", ", Enum.GetNames()) + "."); + } + + if (TagConfigJson.DescribeInvalidEnum(root, DataTypeKey) is { } w) warnings.Add(w); + + if (TagConfigJson.TryReadEnumStrict(root, DataTypeKey, S7DataType.Int16, out var dataType) + && dataType == S7DataType.String) + { + var stringLength = ReadInt(root, "stringLength"); + if (stringLength < 0 || stringLength > MaxStringLength) + { + warnings.Add( + $"S7 String tag has a 'stringLength' of {stringLength} — an S7 string is 0–" + + $"{MaxStringLength} characters, so the tag is rejected (BadNodeIdUnknown)."); + } + } + } + private static int ReadInt(JsonElement o, string name) => o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.Number && e.TryGetInt32(out var v) ? v : 0; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/TwinCATTagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/TwinCATTagDefinitionFactory.cs index 92aca8c7..b7ed7357 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/TwinCATTagDefinitionFactory.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/TwinCATTagDefinitionFactory.cs @@ -109,16 +109,34 @@ public static class TwinCATTagDefinitionFactory } /// - /// Deploy-time inspection (05/CONV-2): warns on a present-but-invalid dataType (silently - /// defaulted by the lenient runtime) and on a structurally unparseable TagConfig. Empty when clean - /// or not an equipment-tag object. Never throws. + /// The RawPath handed to during inspection — the mapper only copies it + /// onto and never validates it. /// + private const string InspectionRawPath = "(inspect)"; + + /// + /// Deploy-time inspection (05/CONV-2): warns for a blob would + /// reject — a structurally unparseable or non-object document, an absent / non-string / + /// blank symbolPath, a present-but-invalid dataType (the runtime enum read is STRICT + /// since R2-11 Phase C, so a typo rejects the tag), or the unsupported + /// type. Such a tag is skipped by the driver and reads + /// BadNodeIdUnknown forever. Empty when the mapper accepts the blob, when it is blank, or + /// when it is not an equipment-tag object. Never throws. + /// + /// + /// The verdict comes from , not from a mirror (Gitea #551) — see + /// ModbusTagDefinitionFactory.Inspect's remarks for the full argument. + /// /// The equipment tag's TagConfig JSON. /// The warnings; empty when clean. public static IReadOnlyList Inspect(string reference) { var warnings = new List(); if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings; + + // The accept gate: whatever the driver reads draws nothing. + if (FromTagConfig(reference, InspectionRawPath, out _)) return warnings; + try { using var doc = JsonDocument.Parse(reference); @@ -128,16 +146,56 @@ public static class TwinCATTagDefinitionFactory warnings.Add("TwinCAT TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown)."); return warnings; } - var w = TagConfigJson.DescribeInvalidEnum(root, "dataType"); - if (w is not null) warnings.Add(w); + + DescribeRejection(root, warnings); } catch (JsonException) { warnings.Add("TwinCAT TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown)."); + return warnings; } + + if (warnings.Count == 0) + { + warnings.Add( + "TwinCAT TagConfig was rejected by the driver's tag reader — the driver will skip this tag " + + "and it will read BadNodeIdUnknown."); + } + return warnings; } + /// + /// Explains why refused an object-rooted blob, walking its checks in the + /// same order. Runs only after the mapper has already refused, so it can never invent strictness. + /// + /// The TagConfig root object. + /// The list to append warnings to. + private static void DescribeRejection(JsonElement root, List warnings) + { + if (!root.TryGetProperty("symbolPath", out var symbol) || symbol.ValueKind != JsonValueKind.String) + { + warnings.Add( + "TwinCAT TagConfig has no string 'symbolPath' (e.g. MAIN.bStart) — the driver will skip " + + "this tag and it will read BadNodeIdUnknown."); + } + else if (string.IsNullOrWhiteSpace(symbol.GetString())) + { + warnings.Add("TwinCAT TagConfig has a blank 'symbolPath' — the tag will not resolve (BadNodeIdUnknown)."); + } + + if (TagConfigJson.DescribeInvalidEnum(root, "dataType") is { } w) warnings.Add(w); + + if (TagConfigJson.TryReadEnumStrict(root, "dataType", TwinCATDataType.DInt, out var dataType) + && dataType == TwinCATDataType.Structure) + { + warnings.Add( + "TwinCAT 'dataType' is Structure — the driver does not support UDT / FB-instance tags and " + + "rejects them (BadNodeIdUnknown). Declare the atomic-typed members instead, or use " + + "EnableControllerBrowse to discover them individually."); + } + } + /// /// Reads the optional 1-D array length: arrayLength (a positive int32; values above /// are treated as absent/scalar) honoured ONLY when isArray diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagCsvMapper.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagCsvMapper.cs index c6390206..72b7e9d7 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagCsvMapper.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagCsvMapper.cs @@ -19,6 +19,14 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; /// columns. A typed column therefore wins over a conflicting key carried in the TagConfigJson /// fallback; the review model flags when that override happened so the operator sees it. /// +/// +/// The assembled blob is then put to the driver's own (Gitea #551): the +/// 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 clean and reads +/// BadNodeIdUnknown forever. A failing row is an error, blocking the all-or-nothing commit; +/// it is never silently skipped. This check is for the operator's benefit (it names the row before +/// commit); the guarantee lives in , which re-runs it. +/// /// public static class RawTagCsvMapper { @@ -84,7 +92,7 @@ public static class RawTagCsvMapper continue; } - reviewed.Add(BuildRow(r, headers, headerIndex, cells, map, groupPathPrefix)); + reviewed.Add(BuildRow(r, headers, headerIndex, cells, driverType, map, groupPathPrefix)); } FlagDuplicates(reviewed); @@ -96,6 +104,7 @@ public static class RawTagCsvMapper string[] headers, IReadOnlyDictionary headerIndex, string[] cells, + string driverType, CsvDriverTagMap? map, string? groupPathPrefix) { @@ -193,6 +202,19 @@ public static class RawTagCsvMapper // Alarm flags — an alarm is authored iff Alarm.AlarmType is present. json = ApplyAlarm(json, Cell("Alarm.AlarmType"), Cell("Alarm.Severity"), Cell("Alarm.HistorizeToAveva")); + // The driver-typed config gate, on the FINAL assembled blob (Gitea #551 item 1). Until this, + // the typed columns above were strictly parsed but the free-form TagConfigJson column was not + // checked at all, so a CSV could import a blob the driver rejects — a tag that deploys clean + // and then reads BadNodeIdUnknown forever. Surfacing it here, on the staged review row, is what + // lets the operator SEE which row is wrong before committing; IRawTreeService.ImportTagsAsync + // re-checks it as the actual guarantee (a form/grid cannot cover the API or a future caller). + // A rejected row is an ERROR, never a skip: the commit is all-or-nothing, and "skipped" would + // claim the tag is already present. + if (TagConfigValidator.Validate(driverType, json) is { } configError) + { + return Invalid(rowNumber, cells, $"TagConfig: {configError}"); + } + var importRow = new RawTagImportRow(groupPath, new RawTagInput(name, dataType, access, writeIdempotent, pollGroupId, json)); return new RawCsvReviewRow diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs index 1039b03c..a4e92493 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Microsoft.EntityFrameworkCore; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; @@ -834,6 +835,9 @@ public sealed class RawTreeService(IDbContextFactory dbF t => t.DeviceId == deviceId && t.TagGroupId == tagGroupId && t.Name == input.Name, ct)) return new UnsMutationResult(false, $"A tag named '{input.Name}' already exists here."); + if (await ValidateTagConfigAsync(db, deviceId, tagConfig, ct) is { } configError) + return new UnsMutationResult(false, configError); + var id = NewId("TAG"); db.Tags.Add(new Tag { @@ -872,6 +876,9 @@ public sealed class RawTreeService(IDbContextFactory dbF && t.Name == input.Name && t.TagId != tagId, ct)) return new UnsMutationResult(false, $"A tag named '{input.Name}' already exists here."); + if (await ValidateTagConfigAsync(db, entity.DeviceId, tagConfig, ct) is { } configError) + return new UnsMutationResult(false, configError); + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion; entity.Name = input.Name; entity.DataType = input.DataType; @@ -902,6 +909,10 @@ public sealed class RawTreeService(IDbContextFactory dbF if (!await db.Devices.AnyAsync(dev => dev.DeviceId == deviceId, ct)) return new RawTagImportResult(0, new[] { $"Device '{deviceId}' not found." }); + // The device's driver type, resolved ONCE: an import is per-device, and a device hangs off exactly + // one driver, so every row in the batch is judged by the same driver's rules (Gitea #551 item 1). + var driverType = await ResolveDriverTypeAsync(db, deviceId, ct); + // Existing groups on the device: a (parent-group-id | null, name) → group-id lookup we extend with // pending (auto-created) groups so a repeated path within the batch resolves to one group. var groupLookup = (await db.TagGroups.AsNoTracking() @@ -951,6 +962,15 @@ public sealed class RawTreeService(IDbContextFactory dbF continue; } + // The driver-typed config gate. A rejected row is an ERROR — the import is all-or-nothing, so + // the batch inserts nothing and the row is NAMED; it is never skipped ("skipped" would claim + // the tag is already present). See ValidateTagConfigAsync for why this lives in the service. + if (TagConfigValidator.Validate(driverType, tagConfig) is { } configError) + { + errors.Add($"{rowLabel}: TagConfig: {configError}"); + continue; + } + if (!takenNames.Add((groupId, row.Tag.Name))) { errors.Add($"{rowLabel}: a tag named '{row.Tag.Name}' already exists at that path."); @@ -1196,6 +1216,55 @@ public sealed class RawTreeService(IDbContextFactory dbF } } + /// + /// The driver-type string of the driver a device hangs off, or when the + /// device (or its driver) is gone. Joined in exactly as LoadDeviceForEditAsync does — a + /// device carries no driver type of its own. + /// + /// The open context. + /// The device whose driver type to resolve. + /// Cancellation token. + /// The canonical driver-type string, or null. + private static Task ResolveDriverTypeAsync( + OtOpcUaConfigDbContext db, string deviceId, CancellationToken ct) + => db.Devices.AsNoTracking() + .Where(dev => dev.DeviceId == deviceId) + .Join(db.DriverInstances.AsNoTracking(), + dev => dev.DriverInstanceId, + drv => drv.DriverInstanceId, + (dev, drv) => drv.DriverType) + .FirstOrDefaultAsync(ct); + + /// + /// The driver-typed TagConfig gate, applied on the SERVER for every tag-writing path — + /// create, update and bulk import alike (Gitea #551 item 1). + /// + /// It lives here rather than in the modals because a form cannot be the guarantee. The + /// tag modal and the manual-entry grid already run for fast + /// feedback, but the CSV import did not, and the API and any future caller reach none of them — + /// so a blob the driver rejects could be persisted, deploy clean, and read + /// BadNodeIdUnknown forever. This repo also has no bUnit, so a rule only a page enforces + /// is a rule no test can reach. + /// + /// + /// The rule itself is not stated here: dispatches to the + /// driver's typed model, which in turn asks the driver's own reader. An unmapped driver type + /// (Galaxy) validates to null, exactly as it does in the modals. + /// + /// + /// The open context. + /// The device the tag belongs to (supplies the driver type). + /// The normalised TagConfig JSON about to be persisted. + /// Cancellation token. + /// An operator-facing error, or when the config is acceptable. + private static async Task ValidateTagConfigAsync( + OtOpcUaConfigDbContext db, string deviceId, string tagConfig, CancellationToken ct) + { + var driverType = await ResolveDriverTypeAsync(db, deviceId, ct); + var error = TagConfigValidator.Validate(driverType, tagConfig); + return error is null ? null : $"TagConfig: {error}"; + } + /// Ordinal (case-sensitive) equality for a nullable-parent + name tag-group key. private sealed class GroupKeyComparer : IEqualityComparer<(string? Parent, string Name)> { diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/AbCipTagConfigModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/AbCipTagConfigModel.cs index ace7cbc6..b4cd3f43 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/AbCipTagConfigModel.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/AbCipTagConfigModel.cs @@ -59,11 +59,23 @@ public sealed class AbCipTagConfigModel /// (BadNodeIdUnknown). Without this the blob would be accepted here, silently rewritten to /// DInt on save, and — on the CSV-import and API paths, which save it verbatim — deployed /// into a tag the driver skips (Gitea #549). + /// + /// Declared UDT members are checked too (Gitea #551), and the rule is not restated here. + /// The member rules are as strict as the top-level ones — one member with no name or a + /// typo'd dataType rejects the WHOLE tag — but nothing on the authoring side saw them: + /// this editor emits only tagPath/dataType/writable, so members + /// rides untouched from whatever hand-authored / CSV / API blob produced it. + /// The verdict AND the wording come from + /// , the same authority the + /// driver's reader and the deploy gate consult, so this gate cannot drift from the runtime in + /// either direction. + /// /// /// An error message describing the validation failure, or null when valid. public string? Validate() { if (TagConfigJson.DescribeInvalidEnum(_bag, "dataType") is { } dtError) { return dtError; } - return string.IsNullOrWhiteSpace(TagPath) ? "Tag path is required." : null; + if (string.IsNullOrWhiteSpace(TagPath)) { return "Tag path is required."; } + return AbCipTagDefinitionFactory.DescribeInvalidMember(TagConfigJson.Serialize(_bag)); } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/ModbusTagConfigModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/ModbusTagConfigModel.cs index 9396b38c..dad040e5 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/ModbusTagConfigModel.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/ModbusTagConfigModel.cs @@ -69,17 +69,26 @@ public sealed class ModbusTagConfigModel /// Validation hook; returns an error message or null when the model is valid. /// - /// All three enums are read from the ORIGINAL key bag, not from the leniently-defaulted typed + /// All FOUR enums are read from the ORIGINAL key bag, not from the leniently-defaulted typed /// fields above: ModbusTagDefinitionFactory reads each with TryReadEnumStrict, so a /// present-but-invalid string rejects the whole tag at the driver (BadNodeIdUnknown) where - /// the load silently substitutes HoldingRegisters/Int16/BigEndian. Accepting - /// one here would let the CSV-import and API paths — which save the blob verbatim — deploy a tag - /// the driver skips (Gitea #549). Also note a wrong-but-valid region is not this gate's - /// business: only a spelling the runtime cannot read is rejected. + /// the load silently substitutes HoldingRegisters/Int16/BigEndian/ + /// HighByteFirst. Accepting one here would let the CSV-import and API paths — which save the + /// blob verbatim — deploy a tag the driver skips (Gitea #549). Also note a wrong-but-valid + /// region is not this gate's business: only a spelling the runtime cannot read is rejected. + /// + /// stringByteOrder is the fourth (Gitea #551): the runtime has always read it as + /// strictly as its three siblings, but this model — and the deploy gate's Inspect — + /// checked only the other three, so a typo'd stringByteOrder passed BOTH gates and died + /// at the driver. It is not in 's exposed-field list either, so it can only + /// ever reach the bag from a hand-authored blob, a CSV import or the API — exactly the paths + /// this gate exists for. + /// /// /// An error message describing the validation failure, or null when the model is valid. public string? Validate() => TagConfigJson.DescribeInvalidEnum(_bag, "region") ?? TagConfigJson.DescribeInvalidEnum(_bag, "dataType") - ?? TagConfigJson.DescribeInvalidEnum(_bag, "byteOrder"); + ?? TagConfigJson.DescribeInvalidEnum(_bag, "byteOrder") + ?? TagConfigJson.DescribeInvalidEnum(_bag, "stringByteOrder"); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/S7TagConfigModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/S7TagConfigModel.cs index 5617bb3c..f6a4f748 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/S7TagConfigModel.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/S7TagConfigModel.cs @@ -22,6 +22,19 @@ public sealed class S7TagConfigModel private JsonObject _bag = new(); + // Whether the blob this model was LOADED FROM was missing the "dataType" key entirely — captured + // because the lenient load above cannot tell "absent" from "present": both land on Int16. S7 is the one + // driver whose reader REQUIRES the key (S7TagDefinitionFactory.DeclaresDataType), so a blob without one + // is rejected outright and reads BadNodeIdUnknown; without this capture that blob validated clean + // (Gitea #551). Same capture-the-as-loaded-shape pattern as MTConnectTagConfigModel's _rawDataType. + // + // It records a property of the SOURCE, so the default (false) is the honest answer for a model built by + // object initialiser rather than loaded — RawBrowseCommitMapper does exactly that, and ToJson() then + // writes the key from the typed DataType field. ToJson() likewise clears it: a blob that has been + // re-serialised through this model always carries the key, so it can only ever be true on the FIRST + // load of a blob authored elsewhere (hand-edited, CSV import, API). + private bool _dataTypeMissingInSource; + /// Loads a model from a TagConfig JSON string, defaulting any absent field and retaining /// every original key (so fields this editor doesn't expose survive a load→save). /// The raw TagConfig JSON string, or null/empty for a fresh model. @@ -32,9 +45,10 @@ public sealed class S7TagConfigModel return new S7TagConfigModel { Address = TagConfigJson.GetString(o, "address") ?? "", - DataType = TagConfigJson.GetEnum(o, "dataType", S7DataType.Int16), + DataType = TagConfigJson.GetEnum(o, S7TagDefinitionFactory.DataTypeKey, S7DataType.Int16), StringLength = TagConfigJson.GetInt(o, "stringLength"), Writable = TagConfigJson.GetBoolNullable(o, "writable"), + _dataTypeMissingInSource = !o.ContainsKey(S7TagDefinitionFactory.DataTypeKey), _bag = o, }; } @@ -45,7 +59,8 @@ public sealed class S7TagConfigModel public string ToJson() { TagConfigJson.Set(_bag, "address", Address.Trim()); - TagConfigJson.Set(_bag, "dataType", DataType); + TagConfigJson.Set(_bag, S7TagDefinitionFactory.DataTypeKey, DataType); + _dataTypeMissingInSource = false; TagConfigJson.Set(_bag, "stringLength", StringLength); TagConfigJson.Set(_bag, "writable", Writable); return TagConfigJson.Serialize(_bag); @@ -59,10 +74,36 @@ public sealed class S7TagConfigModel /// so a present-but-invalid string rejects the whole tag at the driver (BadNodeIdUnknown) /// where the load silently substitutes Int16. Accepting it here would let the CSV-import /// and API paths — which save the blob verbatim — deploy an unreadable tag (Gitea #549). + /// + /// An ABSENT dataType is rejected too (Gitea #551), and S7 is the only driver where + /// that is right. S7TagDefinitionFactory.FromTagConfig refuses a blob that does not + /// carry the key at all — unique in the fleet, where every sibling falls back to a default — so + /// such a tag deployed clean through both gates and then read BadNodeIdUnknown forever. + /// Presence is judged on the key, not the value, matching + /// : a non-string token is present-enough + /// for the reader, which then treats it as absent and accepts the tag as Int16. + /// + /// + /// The one blob this can newly block in the modal is one that was already dead. The + /// typed editor pushes — which always writes dataType — only when a + /// field CHANGES, so opening a legacy key-less blob and saving it untouched hands this method + /// the as-loaded string. That save is refused, and correctly: the tag it would persist is one + /// the driver already skips. Picking any data type in the editor writes the key and clears it. + /// /// public string? Validate() { - if (TagConfigJson.DescribeInvalidEnum(_bag, "dataType") is { } dtError) { return dtError; } - return string.IsNullOrWhiteSpace(Address) ? "Address is required." : null; + if (TagConfigJson.DescribeInvalidEnum(_bag, S7TagDefinitionFactory.DataTypeKey) is { } dtError) + { + return dtError; + } + + if (string.IsNullOrWhiteSpace(Address)) { return "Address is required."; } + + return _dataTypeMissingInSource + ? "A data type is required — the S7 driver rejects a tag whose TagConfig carries no " + + $"'{S7TagDefinitionFactory.DataTypeKey}' key (it would read BadNodeIdUnknown). Valid: " + + string.Join(", ", Enum.GetNames()) + "." + : null; } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs index 6f21f0e3..7c5112a6 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs @@ -246,8 +246,11 @@ public sealed class AdminOperationsActor : ReceiveActor } // R2-11 (05/CONV-2): deploy-time TagConfig strictness inspection over equipment tags. The - // driver-typed EquipmentTagConfigInspector surfaces present-but-invalid enum values (which the - // lenient runtime silently defaults) + structurally-unparseable TagConfig. In Warn mode + // driver-typed EquipmentTagConfigInspector surfaces every blob the driver's own reader would + // REJECT — a present-but-invalid enum value, an absent or malformed address field, a + // structurally-unparseable TagConfig — each of which makes the driver skip the tag so it reads + // BadNodeIdUnknown forever. (Pre-R2-11 Phase C the runtime silently defaulted a typo'd enum; + // it has rejected the tag since, so these warnings describe rejections.) In Warn mode // (default) these are non-blocking (logged + appended to the accepted result Message); in Error // mode they fold into the reject list so a typo'd config cannot be re-deployed until fixed. // Running servers are untouched either way — the gate only sees re-deploys. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/EquipmentTagConfigInspector.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/EquipmentTagConfigInspector.cs index b17ad450..57163a36 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/EquipmentTagConfigInspector.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/EquipmentTagConfigInspector.cs @@ -17,8 +17,17 @@ namespace ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations; /// Deploy-time TagConfig inspection dispatched by DriverType (R2-11, 05/CONV-2). Mirrors the /// AdminUI TagConfigValidator shape: a case-insensitive map from the canonical driver-type string /// to that driver's lightweight Contracts parser Inspect(), returning human-readable -/// warnings (present-but-invalid enum values that the lenient runtime silently defaults, plus -/// structurally-unparseable TagConfig). Never throws. +/// warnings. Never throws. +/// The warnings describe REJECTIONS, not silent defaults. Every driver's runtime read has +/// been strict since R2-11 Phase C (TagConfigJson.TryReadEnumStrict): a present-but-invalid enum +/// value — like an absent or malformed address field, or an unparseable document — makes the reader +/// refuse the whole tag, so the driver skips it and it reads BadNodeIdUnknown forever with the +/// deployment otherwise green. That is what these warnings are for. (Before Phase C the runtime did +/// silently substitute a default for a typo'd enum; the doc-comments here and on the per-driver +/// Inspect methods still said so until Gitea #551.) Because a warning can be PROMOTED to a +/// blocking error by TagConfigValidationMode.Error, each Inspect asks its driver's own +/// reader for the verdict and only explains a refusal — never restating the rule, and so never able to +/// be stricter than the runtime it reports on. /// Coverage is every driver that ships an Inspect, and that invariant is guarded by /// EquipmentTagConfigInspectorCoverageTests, which reflects over the referenced /// *.Contracts assemblies — so a driver that gains an inspector cannot be silently left out. This diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipInspectAcceptGateTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipInspectAcceptGateTests.cs new file mode 100644 index 00000000..725eb52c --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipInspectAcceptGateTests.cs @@ -0,0 +1,100 @@ +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; + +/// +/// Gitea #551 item 3: is accept-gate-first — it +/// asks for the verdict and only explains a +/// refusal. See ModbusInspectAcceptGateTests's remarks for why both directions (never stricter, +/// never silent) are pinned. +/// +/// AbCip's uncovered surface was the declared-UDT members array: the mapper rejects the WHOLE +/// tag for one member with no name or a typo'd dataType, but neither this gate nor the +/// AdminUI editor (which emits only tagPath/dataType/writable and carries +/// members through its preserved key bag) ever looked. +/// +/// +public sealed class AbCipInspectAcceptGateTests +{ + private const string RawPath = "cell/abcip/dev1/T1"; + + /// Blobs the mapper ACCEPTS — the gate must say nothing about any of them. + public static TheoryData Accepted() => + [ + """{"tagPath":"Program:Main.Speed","dataType":"DInt"}""", + """{"tagPath":"Speed"}""", + """{"tagPath":"Motor","dataType":"Structure","members":[{"name":"Run","dataType":"Bool"}]}""", + // members present but not an ARRAY is ignored by the mapper, so it must be ignored here too. + """{"tagPath":"Speed","members":{"name":"Run"}}""", + """{"tagPath":"Speed","members":[]}""", + // A member's writable / writeIdempotent / array keys are all optional. + """{"tagPath":"Motor","members":[{"name":"Run"}]}""", + ]; + + /// Blobs the mapper REJECTS — the gate must produce at least one warning for each. + public static TheoryData Rejected() => + [ + """{"dataType":"DInt"}""", // no tagPath + """{"tagPath":"","dataType":"DInt"}""", // blank tagPath + """{"tagPath":123}""", // tagPath not a string + """{"tagPath":"Speed","dataType":"DIntt"}""", // typo'd top-level dataType + """{"tagPath":"Motor","members":[{"dataType":"Bool"}]}""", // member with no name + """{"tagPath":"Motor","members":[{"name":" ","dataType":"Bool"}]}""", // member with a blank name + """{"tagPath":"Motor","members":[{"name":"Run","dataType":"Booll"}]}""", // typo'd member dataType + """{"tagPath":"Motor","members":["Run"]}""", // member not an object + ]; + + [Theory] + [MemberData(nameof(Accepted))] + public void A_config_the_mapper_accepts_draws_no_warning(string tagConfig) + { + AbCipTagDefinitionFactory.FromTagConfig(tagConfig, RawPath, out _).ShouldBeTrue("fixture must be accepted"); + AbCipTagDefinitionFactory.Inspect(tagConfig).ShouldBeEmpty(); + } + + [Theory] + [MemberData(nameof(Rejected))] + public void A_config_the_mapper_rejects_is_never_passed_over_in_silence(string tagConfig) + { + AbCipTagDefinitionFactory.FromTagConfig(tagConfig, RawPath, out _).ShouldBeFalse("fixture must be rejected"); + AbCipTagDefinitionFactory.Inspect(tagConfig).ShouldNotBeEmpty(); + } + + [Fact] + public void An_absent_tagPath_is_named() + => AbCipTagDefinitionFactory.Inspect("""{"dataType":"DInt"}""") + .ShouldContain(w => w.Contains("'tagPath'")); + + [Fact] + public void A_typo_in_a_member_dataType_is_named_with_its_index() + => AbCipTagDefinitionFactory.Inspect( + """{"tagPath":"Motor","members":[{"name":"Run","dataType":"Bool"},{"name":"Speed","dataType":"Reall"}]}""") + .ShouldContain(w => w.Contains("members[1]") && w.Contains("Reall")); + + [Fact] + public void A_member_with_no_name_is_named_with_its_index() + => AbCipTagDefinitionFactory.Inspect("""{"tagPath":"Motor","members":[{"dataType":"Bool"}]}""") + .ShouldContain(w => w.Contains("members[0]") && w.Contains("name")); + + // ---- DescribeInvalidMember is the shared authority the AdminUI model asks (one rule, two doors) ---- + + [Theory] + [InlineData("""{"tagPath":"Motor","members":[{"name":"Run","dataType":"Bool"}]}""", false)] + [InlineData("""{"tagPath":"Motor"}""", false)] + [InlineData("""{"tagPath":"Motor","members":[]}""", false)] + [InlineData("""{"tagPath":"Motor","members":{"name":"Run"}}""", false)] + [InlineData("not json at all", false)] + [InlineData("""{"tagPath":"Motor","members":[{"name":"Run","dataType":"Booll"}]}""", true)] + [InlineData("""{"tagPath":"Motor","members":[{"dataType":"Bool"}]}""", true)] + public void DescribeInvalidMember_over_a_string_matches_the_mapper(string tagConfig, bool expectWarning) + => (AbCipTagDefinitionFactory.DescribeInvalidMember(tagConfig) is not null).ShouldBe(expectWarning); + + [Fact] + public void A_blank_reference_draws_nothing() + => AbCipTagDefinitionFactory.Inspect(" ").ShouldBeEmpty(); + + [Fact] + public void Unparseable_json_names_the_json() + => AbCipTagDefinitionFactory.Inspect("{oops").ShouldContain(w => w.Contains("not valid JSON")); +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyInspectAcceptGateTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyInspectAcceptGateTests.cs new file mode 100644 index 00000000..ae656596 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyInspectAcceptGateTests.cs @@ -0,0 +1,64 @@ +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; + +/// +/// Gitea #551: is accept-gate-first — it asks +/// for the verdict and only explains a refusal. +/// See ModbusInspectAcceptGateTests's remarks for why both directions (never stricter, never +/// silent) are pinned. +/// +public sealed class AbLegacyInspectAcceptGateTests +{ + private const string RawPath = "cell/ablegacy/dev1/T1"; + + /// Blobs the mapper ACCEPTS — the gate must say nothing about any of them. + public static TheoryData Accepted() => + [ + """{"address":"N7:0","dataType":"Int"}""", + """{"address":"B3:1/2"}""", + """{"address":"F8:5","dataType":"Float","isArray":true,"arrayLength":10}""", + // An over-long array is CLAMPED by the mapper, not rejected — so the gate must stay silent, even + // though the AdminUI model refuses it (the un-clamped length also sets OPC UA ArrayDimensions). + """{"address":"F8:5","dataType":"Float","isArray":true,"arrayLength":9999}""", + ]; + + /// Blobs the mapper REJECTS — the gate must produce at least one warning for each. + public static TheoryData Rejected() => + [ + """{"dataType":"Int"}""", // no address + """{"address":"","dataType":"Int"}""", // blank address + """{"address":7,"dataType":"Int"}""", // address not a string + """{"address":"N7:0","dataType":"Intt"}""", // typo'd dataType + ]; + + [Theory] + [MemberData(nameof(Accepted))] + public void A_config_the_mapper_accepts_draws_no_warning(string tagConfig) + { + AbLegacyTagDefinitionFactory.FromTagConfig(tagConfig, RawPath, out _).ShouldBeTrue("fixture must be accepted"); + AbLegacyTagDefinitionFactory.Inspect(tagConfig).ShouldBeEmpty(); + } + + [Theory] + [MemberData(nameof(Rejected))] + public void A_config_the_mapper_rejects_is_never_passed_over_in_silence(string tagConfig) + { + AbLegacyTagDefinitionFactory.FromTagConfig(tagConfig, RawPath, out _).ShouldBeFalse("fixture must be rejected"); + AbLegacyTagDefinitionFactory.Inspect(tagConfig).ShouldNotBeEmpty(); + } + + [Fact] + public void An_absent_address_is_named() + => AbLegacyTagDefinitionFactory.Inspect("""{"dataType":"Int"}""") + .ShouldContain(w => w.Contains("'address'")); + + [Fact] + public void A_blank_reference_draws_nothing() + => AbLegacyTagDefinitionFactory.Inspect(" ").ShouldBeEmpty(); + + [Fact] + public void Unparseable_json_names_the_json() + => AbLegacyTagDefinitionFactory.Inspect("{oops").ShouldContain(w => w.Contains("not valid JSON")); +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasInspectAcceptGateTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasInspectAcceptGateTests.cs new file mode 100644 index 00000000..8a0da241 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasInspectAcceptGateTests.cs @@ -0,0 +1,98 @@ +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; + +/// +/// Gitea #551: gained an accept gate for its +/// rejection diagnosis — see ModbusInspectAcceptGateTests's remarks for why both +/// directions are pinned. +/// +/// FOCAS is the one factory that deliberately does NOT return empty on accept: two of its warnings +/// are advisories 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 +/// writable:true (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. +/// +/// +public sealed class FocasInspectAcceptGateTests +{ + private const string RawPath = "cell/focas/dev1/T1"; + + /// Blobs the mapper ACCEPTS and that carry no advisory — the gate must be silent. + public static TheoryData CleanAndAccepted() => + [ + """{"address":"R100","dataType":"Int32"}""", + """{"address":"X0.0","dataType":"Bit"}""", + """{"address":"PARAM:1020"}""", + """{"address":"FIXED:Identity/SeriesNumber","dataType":"String"}""", + ]; + + /// Blobs the mapper REJECTS — the gate must produce at least one warning for each. + public static TheoryData 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")); + } + + /// A rejected blob still gets its advisories — the two diagnoses are independent. + [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")); +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusInspectAcceptGateTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusInspectAcceptGateTests.cs new file mode 100644 index 00000000..7ecfd2bb --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusInspectAcceptGateTests.cs @@ -0,0 +1,128 @@ +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests; + +/// +/// Gitea #551 items 2/3: is accept-gate-first — +/// it asks for the verdict and only explains a +/// refusal. +/// +/// Two properties, and they pull in opposite directions, which is why both are pinned here. The +/// gate must never be stricter than the mapper (a warning can be promoted to a blocking +/// deploy error by TagConfigValidationMode.Error, so a warning on a blob the driver serves +/// would refuse a working config); and it must never be silent on a blob the mapper refuses +/// (that tag deploys green and then reads BadNodeIdUnknown forever). The previous hand-list +/// of enum checks satisfied the first and failed the second for every cause not on the list — +/// an absent region, a bad address, stringByteOrder, and the two +/// type-dependent guards. +/// +/// +public sealed class ModbusInspectAcceptGateTests +{ + private const string RawPath = "cell/modbus/dev1/T1"; + private const string Clean = """{"region":"HoldingRegisters","address":1,"dataType":"Int16"}"""; + + /// Every blob below is one the mapper ACCEPTS, so the gate must say nothing about it. + public static TheoryData 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}""", + ]; + + /// Every blob below is one the mapper REJECTS, so the gate must produce at least one warning. + public static TheoryData 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'")); + + /// Gitea #551 item 3: read strictly by the mapper since R2-11, checked by nothing until now. + [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(); + + /// + /// The pre-existing framing convention, deliberately left alone by #551 and shared by all eleven + /// inspectors: a reference that does not START with { 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. + /// + /// The non-object reference. + [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")); +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttInspectAcceptGateTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttInspectAcceptGateTests.cs new file mode 100644 index 00000000..2750f36d --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttInspectAcceptGateTests.cs @@ -0,0 +1,88 @@ +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. + public static TheoryData Accepted() => + [ + """{"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. + public static TheoryData Rejected() => + [ + "{}", // 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 + ]; + + [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")); +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7InspectAcceptGateTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7InspectAcceptGateTests.cs new file mode 100644 index 00000000..af0913f4 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7InspectAcceptGateTests.cs @@ -0,0 +1,98 @@ +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; + +/// +/// Gitea #551 item 2: is accept-gate-first — it asks +/// for the verdict and only explains a refusal. See +/// ModbusInspectAcceptGateTests's remarks for why both directions (never stricter, never silent) +/// are pinned. +/// +/// S7 carries the fleet's one unique rule: the reader REQUIRES the dataType key to be +/// present. A blob with only an address therefore deploys green and reads +/// BadNodeIdUnknown forever — and neither this gate nor the AdminUI model said a word about +/// it until #551. +/// +/// +public sealed class S7InspectAcceptGateTests +{ + private const string RawPath = "cell/s7/dev1/T1"; + + /// Blobs the mapper ACCEPTS — the gate must say nothing about any of them. + public static TheoryData Accepted() => + [ + """{"address":"DB1.DBW0","dataType":"Int16"}""", + """{"address":"M0.0","dataType":"Bool"}""", + """{"address":"DB1.DBB0","dataType":"String","stringLength":254}""", + // stringLength is irrelevant to a non-String tag, so an out-of-range remnant is ACCEPTED. + """{"address":"DB1.DBW0","dataType":"Int16","stringLength":9999}""", + // A non-string dataType token satisfies the PRESENCE rule and then reads as absent ⇒ Int16. + """{"address":"DB1.DBW0","dataType":7}""", + ]; + + /// Blobs the mapper REJECTS — the gate must produce at least one warning for each. + public static TheoryData Rejected() => + [ + """{"dataType":"Int16"}""", // no address + """{"address":"","dataType":"Int16"}""", // blank address + """{"address":123,"dataType":"Int16"}""", // address not a string + """{"address":"DB1.DBW0"}""", // no dataType key at all (#551) + """{"address":"DB1.DBW0","dataType":"Intt16"}""", // typo'd dataType + """{"address":"DB1.DBB0","dataType":"String","stringLength":255}""", // over the 254 S7 limit + """{"address":"DB1.DBB0","dataType":"String","stringLength":-1}""", // negative length + ]; + + [Theory] + [MemberData(nameof(Accepted))] + public void A_config_the_mapper_accepts_draws_no_warning(string tagConfig) + { + S7TagDefinitionFactory.FromTagConfig(tagConfig, RawPath, out _).ShouldBeTrue("fixture must be accepted"); + S7TagDefinitionFactory.Inspect(tagConfig).ShouldBeEmpty(); + } + + [Theory] + [MemberData(nameof(Rejected))] + public void A_config_the_mapper_rejects_is_never_passed_over_in_silence(string tagConfig) + { + S7TagDefinitionFactory.FromTagConfig(tagConfig, RawPath, out _).ShouldBeFalse("fixture must be rejected"); + S7TagDefinitionFactory.Inspect(tagConfig).ShouldNotBeEmpty(); + } + + /// The headline case: present-and-typo'd was reported; ABSENT was not (Gitea #551 item 2). + [Fact] + public void An_absent_dataType_is_named() + => S7TagDefinitionFactory.Inspect("""{"address":"DB1.DBW0"}""") + .ShouldContain(w => w.Contains("no 'dataType'")); + + [Fact] + public void An_absent_address_is_named() + => S7TagDefinitionFactory.Inspect("""{"dataType":"Int16"}""") + .ShouldContain(w => w.Contains("'address'")); + + [Fact] + public void An_out_of_range_string_length_is_named() + => S7TagDefinitionFactory.Inspect("""{"address":"DB1.DBB0","dataType":"String","stringLength":255}""") + .ShouldContain(w => w.Contains("stringLength")); + + // ---- DeclaresDataType is the shared authority the AdminUI model asks (one rule, two doors) ---- + + [Theory] + [InlineData("""{"dataType":"Int16"}""", true)] + [InlineData("""{"dataType":7}""", true)] // presence is judged on the KEY, not the value + [InlineData("""{"dataType":null}""", true)] + [InlineData("""{"address":"DB1.DBW0"}""", false)] + public void DeclaresDataType_judges_the_key_not_the_value(string tagConfig, bool expected) + { + using var doc = System.Text.Json.JsonDocument.Parse(tagConfig); + S7TagDefinitionFactory.DeclaresDataType(doc.RootElement).ShouldBe(expected); + } + + [Fact] + public void A_blank_reference_draws_nothing() + => S7TagDefinitionFactory.Inspect(" ").ShouldBeEmpty(); + + [Fact] + public void Unparseable_json_names_the_json() + => S7TagDefinitionFactory.Inspect("{oops").ShouldContain(w => w.Contains("not valid JSON")); +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATInspectAcceptGateTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATInspectAcceptGateTests.cs new file mode 100644 index 00000000..468e40e0 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATInspectAcceptGateTests.cs @@ -0,0 +1,71 @@ +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests; + +/// +/// Gitea #551: is accept-gate-first — it asks +/// for the verdict and only explains a refusal. +/// See ModbusInspectAcceptGateTests's remarks for why both directions (never stricter, never +/// silent) are pinned. TwinCAT's uncovered rejections were an absent symbolPath and the +/// unsupported Structure data type — a VALID enum name that the mapper still refuses. +/// +public sealed class TwinCATInspectAcceptGateTests +{ + private const string RawPath = "cell/twincat/dev1/T1"; + + /// Blobs the mapper ACCEPTS — the gate must say nothing about any of them. + public static TheoryData Accepted() => + [ + """{"symbolPath":"MAIN.bStart","dataType":"Bool"}""", + """{"symbolPath":"MAIN.nCount"}""", + """{"symbolPath":"MAIN.aData","dataType":"DInt","isArray":true,"arrayLength":8}""", + // A stale arrayLength behind a cleared isArray is scalar, not a rejection. + """{"symbolPath":"MAIN.nCount","dataType":"DInt","arrayLength":8}""", + ]; + + /// Blobs the mapper REJECTS — the gate must produce at least one warning for each. + public static TheoryData Rejected() => + [ + """{"dataType":"Bool"}""", // no symbolPath + """{"symbolPath":"","dataType":"Bool"}""", // blank symbolPath + """{"symbolPath":42}""", // symbolPath not a string + """{"symbolPath":"MAIN.x","dataType":"Booll"}""", // typo'd dataType + """{"symbolPath":"MAIN.udt","dataType":"Structure"}""", // valid NAME the driver still refuses + ]; + + [Theory] + [MemberData(nameof(Accepted))] + public void A_config_the_mapper_accepts_draws_no_warning(string tagConfig) + { + TwinCATTagDefinitionFactory.FromTagConfig(tagConfig, RawPath, out _).ShouldBeTrue("fixture must be accepted"); + TwinCATTagDefinitionFactory.Inspect(tagConfig).ShouldBeEmpty(); + } + + [Theory] + [MemberData(nameof(Rejected))] + public void A_config_the_mapper_rejects_is_never_passed_over_in_silence(string tagConfig) + { + TwinCATTagDefinitionFactory.FromTagConfig(tagConfig, RawPath, out _).ShouldBeFalse("fixture must be rejected"); + TwinCATTagDefinitionFactory.Inspect(tagConfig).ShouldNotBeEmpty(); + } + + [Fact] + public void An_absent_symbolPath_is_named() + => TwinCATTagDefinitionFactory.Inspect("""{"dataType":"Bool"}""") + .ShouldContain(w => w.Contains("'symbolPath'")); + + /// A rejection an enum-spelling check can never see: the NAME parses, the driver still refuses. + [Fact] + public void A_Structure_typed_tag_is_named() + => TwinCATTagDefinitionFactory.Inspect("""{"symbolPath":"MAIN.udt","dataType":"Structure"}""") + .ShouldContain(w => w.Contains("Structure")); + + [Fact] + public void A_blank_reference_draws_nothing() + => TwinCATTagDefinitionFactory.Inspect(" ").ShouldBeEmpty(); + + [Fact] + public void Unparseable_json_names_the_json() + => TwinCATTagDefinitionFactory.Inspect("{oops").ShouldContain(w => w.Contains("not valid JSON")); +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTagConfigGateTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTagConfigGateTests.cs new file mode 100644 index 00000000..cf8ea634 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTagConfigGateTests.cs @@ -0,0 +1,350 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.AbCip; +using ZB.MOM.WW.OtOpcUa.Driver.Modbus; +using ZB.MOM.WW.OtOpcUa.Driver.S7; +using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +/// +/// Gitea #551 — the authoring half of the four residuals, and the seam that makes it a guarantee. +/// +/// +/// The CSV import runs the driver-typed gate. Its typed columns were always strictly +/// parsed, but the free-form TagConfigJson column reached the database unread. It is +/// checked twice on purpose: in so the staged review grid +/// NAMES the offending row before commit, and in +/// because a grid cannot cover the API or a future caller — and this repo has no bUnit, so a +/// rule only a page enforces is a rule no test can reach. Deleting the service check leaves the +/// mapper tests green and turns the service tests red; that asymmetry is the point. +/// +/// +/// A rejected row is an ERROR, not a skip. The import is all-or-nothing: nothing is +/// inserted and the row is named. "Skipped" would claim the tag is already present. +/// +/// +/// Modbus stringByteOrder, AbCip members[].dataType and the S7 absent +/// dataType — three rules the runtime has always enforced that no authoring surface +/// checked. +/// +/// +/// Every rejection below is paired with an acceptance control, so a gate that simply refuses everything +/// cannot pass this suite. +/// +[Trait("Category", "Unit")] +public sealed class RawTagConfigGateTests +{ + // A blob the S7 driver actually reads — RootDeviceId hangs off the seed's S7 driver. + private const string GoodS7 = """{"address":"DB1.DBW0","dataType":"Int16"}"""; + + private static (RawTreeService Service, string DbName) Seeded() + { + var name = RawTreeTestDb.SeedFresh(); + return (new RawTreeService(RawTreeTestDb.Factory(name)), name); + } + + private static RawTagInput Tag(string name, string tagConfig) => + new(name, "Float", TagAccessLevel.Read, WriteIdempotent: false, PollGroupId: null, tagConfig); + + // ------------------------------------------------------------------ 1. the AdminUI models (item 2/3) + + /// Gitea #551 item 3: the runtime reads it strictly; nothing on the authoring side did. + [Fact] + public void Modbus_rejects_a_typo_in_stringByteOrder() + => ModbusTagConfigModel + .FromJson("""{"region":"HoldingRegisters","address":1,"dataType":"Int16","stringByteOrder":"HighByteFrist"}""") + .Validate() + .ShouldNotBeNullOrEmpty(); + + [Fact] + public void Modbus_accepts_a_correctly_spelled_stringByteOrder() + => ModbusTagConfigModel + .FromJson("""{"region":"HoldingRegisters","address":1,"dataType":"Int16","stringByteOrder":"LowByteFirst"}""") + .Validate() + .ShouldBeNull(); + + /// Gitea #551 item 3: members ride the model's preserved key bag, unread until now. + [Fact] + public void AbCip_rejects_a_typo_in_a_member_dataType() + => AbCipTagConfigModel + .FromJson("""{"tagPath":"Motor","members":[{"name":"Run","dataType":"Booll"}]}""") + .Validate() + .ShouldNotBeNullOrEmpty(); + + [Fact] + public void AbCip_rejects_a_member_with_no_name() + => AbCipTagConfigModel + .FromJson("""{"tagPath":"Motor","members":[{"dataType":"Bool"}]}""") + .Validate() + .ShouldNotBeNullOrEmpty(); + + [Fact] + public void AbCip_accepts_well_formed_members() + => AbCipTagConfigModel + .FromJson("""{"tagPath":"Motor","members":[{"name":"Run","dataType":"Bool"},{"name":"Speed","dataType":"Real"}]}""") + .Validate() + .ShouldBeNull(); + + /// Gitea #551 item 2: S7 is the only driver whose reader requires the key to be PRESENT. + [Fact] + public void S7_rejects_a_config_carrying_no_dataType_key() + => S7TagConfigModel.FromJson("""{"address":"DB1.DBW0"}""").Validate().ShouldNotBeNullOrEmpty(); + + [Fact] + public void S7_accepts_a_config_carrying_a_dataType_key() + => S7TagConfigModel.FromJson(GoodS7).Validate().ShouldBeNull(); + + /// + /// The absent-key rule records a property of the SOURCE blob, so a model built by object initialiser + /// — which is what does — must validate clean: its + /// ToJson() writes the key from the typed field. Getting this backwards would refuse every + /// browse-committed S7 tag. + /// + [Fact] + public void S7_accepts_a_model_that_was_built_rather_than_loaded() + => new S7TagConfigModel { Address = "DB1.DBW0" }.Validate().ShouldBeNull(); + + /// A load→save through the editor always emits the key, so the round-trip stays authorable. + [Fact] + public void S7_round_trip_through_the_model_repairs_a_key_less_blob() + { + var model = S7TagConfigModel.FromJson("""{"address":"DB1.DBW0"}"""); + model.Validate().ShouldNotBeNullOrEmpty(); + + S7TagConfigModel.FromJson(model.ToJson()).Validate().ShouldBeNull(); + } + + // ------------------------------- 2. authoring ⇔ deploy-gate parity for the three new rules (item 2/3) + + /// + /// The pairing that matters: a blob the authoring gate refuses must draw a deploy warning, and a blob + /// it accepts must draw none. Either direction alone is satisfiable by a broken gate — refuse + /// everything, or refuse nothing. + /// + /// The driver type to dispatch both gates on. + /// The blob under test. + /// Whether both gates should refuse it. + [Theory] + [InlineData(DriverTypeNames.Modbus, """{"region":"HoldingRegisters","address":1,"dataType":"Int16","stringByteOrder":"Nope"}""", true)] + [InlineData(DriverTypeNames.Modbus, """{"region":"HoldingRegisters","address":1,"dataType":"Int16","stringByteOrder":"LowByteFirst"}""", false)] + [InlineData(DriverTypeNames.AbCip, """{"tagPath":"Motor","members":[{"name":"Run","dataType":"Booll"}]}""", true)] + [InlineData(DriverTypeNames.AbCip, """{"tagPath":"Motor","members":[{"name":"Run","dataType":"Bool"}]}""", false)] + [InlineData(DriverTypeNames.S7, """{"address":"DB1.DBW0"}""", true)] + [InlineData(DriverTypeNames.S7, """{"address":"DB1.DBW0","dataType":"Int16"}""", false)] + public void Authoring_and_the_deploy_gate_agree(string driverType, string tagConfig, bool expectRejected) + { + var authoringError = TagConfigValidator.Validate(driverType, tagConfig); + var deployWarnings = EquipmentTagConfigInspector.Inspect(driverType, tagConfig); + + (authoringError is not null).ShouldBe(expectRejected, $"authoring verdict for {driverType}"); + (deployWarnings.Count > 0).ShouldBe(expectRejected, $"deploy verdict for {driverType}"); + } + + // ---------------------------------------------- 3. the CSV review grid names the row (item 1, surface) + + /// + /// The gate runs on the FINAL assembled blob — the bytes that reach the database — so the fixture + /// puts the offending value in a key the Modbus typed map does NOT own (stringByteOrder, + /// #551 item 3). A key the map DOES own is round-tripped through + /// ModbusTagConfigModel.FromJson/ToJson by ApplyTypedColumns and silently normalised + /// to the model default before it can ever be judged — a separate, pre-existing defect noted with + /// this work, not one this gate can see. + /// + [Fact] + public void Csv_parse_marks_a_row_whose_TagConfigJson_the_driver_would_reject() + { + const string Csv = """ + Name,DataType,TagConfigJson + good,Float,"{""region"":""HoldingRegisters"",""address"":1,""stringByteOrder"":""LowByteFirst""}" + bad,Float,"{""region"":""HoldingRegisters"",""address"":1,""stringByteOrder"":""Nope""}" + """; + + var result = RawTagCsvMapper.Parse(DriverTypeNames.Modbus, Csv); + + result.FatalError.ShouldBeNull(); + result.Rows.Count.ShouldBe(2); + result.Rows[0].Ok.ShouldBeTrue("the clean row must still import"); + result.Rows[1].Ok.ShouldBeFalse(); + result.Rows[1].Error.ShouldNotBeNull(); + result.Rows[1].Error!.ShouldContain("Nope"); + result.CanCommit.ShouldBeFalse("the commit is blocked while any row is invalid"); + result.ToImportRows().Count.ShouldBe(1); + } + + /// + /// The acceptance control for the driver that has NO typed CSV map, where the fallback cell reaches + /// the database verbatim — the shape the gate most needed to cover. + /// + [Fact] + public void Csv_parse_judges_a_map_less_drivers_fallback_cell_verbatim() + { + const string Good = """ + Name,DataType,TagConfigJson + ok,Double,"{""nodeId"":""ns=2;s=Line3.Temp""}" + """; + const string Bad = """ + Name,DataType,TagConfigJson + broken,Double,"{""node_id"":""ns=2;s=Line3.Temp""}" + """; + + RawTagCsvMapper.Parse(DriverTypeNames.OpcUaClient, Good).CanCommit.ShouldBeTrue(); + + var bad = RawTagCsvMapper.Parse(DriverTypeNames.OpcUaClient, Bad); + bad.CanCommit.ShouldBeFalse(); + bad.Rows[0].Error.ShouldNotBeNull(); + } + + /// + /// Positive control for the half that already worked: a typed column's value has always been + /// strictly parsed (a bad enum throws FormatException into the row's error), so this row was + /// rejected before #551 too. It is here so a reader can tell which half of the CSV path was the gap. + /// + [Fact] + public void Csv_parse_still_marks_a_row_whose_TYPED_column_is_unparseable() + { + const string Csv = """ + Name,DataType,Region,Address,ModbusDataType + bad,Float,HoldingRegisters,1,Intt16 + """; + + var result = RawTagCsvMapper.Parse(DriverTypeNames.Modbus, Csv); + + result.Rows.Count.ShouldBe(1); + result.Rows[0].Ok.ShouldBeFalse(); + } + + // ------------------------------------- 4. the SERVICE is the guarantee (item 1, the enforcement point) + + /// + /// Called with rows the CSV mapper never saw — which is exactly the shape of an API caller. Delete + /// the check in ImportTagsAsync and this goes red while every mapper test above stays green. + /// + [Fact] + public async Task ImportTags_fails_the_whole_batch_and_names_the_row_with_a_rejected_config() + { + var (service, dbName) = Seeded(); + + var rows = new[] + { + new RawTagImportRow(null, Tag("good1", GoodS7)), + new RawTagImportRow(null, Tag("bad", """{"address":"DB1.DBW0"}""")), // no dataType key ⇒ S7 rejects + new RawTagImportRow(null, Tag("good2", GoodS7)), + }; + + var result = await service.ImportTagsAsync(RawTreeTestDb.RootDeviceId, rows); + + result.Inserted.ShouldBe(0); + result.Errors.ShouldContain(e => e.Contains("Row 2") && e.Contains("TagConfig")); + + // All-or-nothing: the two good rows are not quietly kept either. + using var db = RawTreeTestDb.CreateNamed(dbName); + db.Tags.Any(t => t.Name == "good1" || t.Name == "good2").ShouldBeFalse(); + } + + /// The acceptance control — without it, a gate that refuses every import would pass. + [Fact] + public async Task ImportTags_still_imports_a_batch_the_driver_can_read() + { + var (service, _) = Seeded(); + + var result = await service.ImportTagsAsync( + RawTreeTestDb.RootDeviceId, + new[] { new RawTagImportRow(null, Tag("ok1", GoodS7)), new RawTagImportRow(null, Tag("ok2", GoodS7)) }); + + result.Errors.ShouldBeEmpty(); + result.Inserted.ShouldBe(2); + } + + /// + /// Manual entry commits row-by-row through CreateTagAsync, NOT through + /// ImportTagsAsync — so the gate has to live on that path too, or the "enforce in the + /// service" claim covers only the CSV modal. + /// + [Fact] + public async Task CreateTag_refuses_a_config_the_driver_would_reject() + { + var (service, _) = Seeded(); + + var bad = await service.CreateTagAsync( + RawTreeTestDb.RootDeviceId, null, Tag("newTag", """{"address":"DB1.DBW0"}""")); + + bad.Ok.ShouldBeFalse(); + bad.Error.ShouldNotBeNull(); + bad.Error.ShouldContain("TagConfig"); + } + + [Fact] + public async Task CreateTag_accepts_a_config_the_driver_can_read() + { + var (service, _) = Seeded(); + + (await service.CreateTagAsync(RawTreeTestDb.RootDeviceId, null, Tag("newTag", GoodS7))) + .Ok.ShouldBeTrue(); + } + + [Fact] + public async Task UpdateTag_refuses_a_config_the_driver_would_reject() + { + var (service, dbName) = Seeded(); + byte[] rowVersion; + using (var db = RawTreeTestDb.CreateNamed(dbName)) + { + rowVersion = db.Tags.Single(t => t.TagId == RawTreeTestDb.ReferencedTagId).RowVersion; + } + + var bad = await service.UpdateTagAsync( + RawTreeTestDb.ReferencedTagId, Tag("temperature", """{"address":"DB1.DBW0"}"""), rowVersion); + + bad.Ok.ShouldBeFalse(); + bad.Error.ShouldNotBeNull(); + bad.Error.ShouldContain("TagConfig"); + } + + /// + /// A driver type with no typed model (Galaxy) validates to null in the modals, so the service must + /// not invent a rule for it either — otherwise adding this gate would make Galaxy tags unauthorable. + /// + [Fact] + public void An_unmapped_driver_type_is_left_alone() + { + TagConfigValidator.Validate(DriverTypeNames.Galaxy, """{"FullName":"Reactor_001.Temp"}""").ShouldBeNull(); + TagConfigValidator.Validate(DriverTypeNames.Galaxy, "{}").ShouldBeNull(); + } + + /// + /// Every blob emits for a browsable driver must + /// survive the gate the commit now passes through — otherwise browse-commit would author rows the + /// import refuses. + /// + /// The browsable driver type. + [Theory] + [InlineData(DriverTypeNames.S7)] + [InlineData(DriverTypeNames.AbCip)] + [InlineData(DriverTypeNames.AbLegacy)] + [InlineData(DriverTypeNames.TwinCAT)] + [InlineData(DriverTypeNames.OpcUaClient)] + [InlineData(DriverTypeNames.Galaxy)] + public void A_browse_committed_config_passes_the_gate(string driverType) + { + var tagConfig = RawBrowseCommitMapper.BuildTagConfig(driverType, "SomeAddress"); + + TagConfigValidator.Validate(driverType, tagConfig).ShouldBeNull( + $"browse-commit for {driverType} must not author a config the import refuses"); + } + + /// Sanity: the enums the three new rules read are the driver's own, not a local copy. + [Fact] + public void The_new_rules_read_the_drivers_own_enums() + { + Enum.GetNames().ShouldContain("LowByteFirst"); + Enum.GetNames().ShouldContain("Bool"); + Enum.GetNames().ShouldContain("Int16"); + Enum.GetNames().ShouldContain("Structure"); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTreeServiceWaveBTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTreeServiceWaveBTests.cs index 2b7d8ad5..77e0e51f 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTreeServiceWaveBTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTreeServiceWaveBTests.cs @@ -34,6 +34,19 @@ public sealed class RawTreeServiceWaveBTests private static RawTagInput Tag(string name, string dataType = "Float", string tagConfig = "{}") => new(name, dataType, TagAccessLevel.ReadWrite, WriteIdempotent: false, PollGroupId: null, tagConfig); + /// + /// A tag input whose TagConfig the S7 driver would actually read — RootDeviceId + /// hangs off the seed's S7 driver, and since Gitea #551 the service runs the driver-typed + /// TagConfigValidator on every tag it writes. S7 requires BOTH an address and a + /// present dataType key, so the bare {} the sibling helper defaults to is a config the + /// driver rejects and the service now refuses. The Modbus-device tests keep {}: Modbus reads + /// only enums out of the blob and accepts an empty one. + /// + /// The tag name. + /// The import-ready tag input. + private static RawTagInput S7Tag(string name) => + Tag(name, tagConfig: """{"address":"DB1.DBW0","dataType":"Int16"}"""); + // -------------------------------------------------------------------------------- CreateTag (WP4) [Fact] @@ -181,9 +194,9 @@ public sealed class RawTreeServiceWaveBTests var rows = new[] { - new RawTagImportRow("Line/A", Tag("t1")), - new RawTagImportRow("Line/A", Tag("t2")), // reuses Line + Line/A - new RawTagImportRow("Line/B", Tag("t3")), // reuses Line, new B + new RawTagImportRow("Line/A", S7Tag("t1")), + new RawTagImportRow("Line/A", S7Tag("t2")), // reuses Line + Line/A + new RawTagImportRow("Line/B", S7Tag("t3")), // reuses Line, new B }; var result = await service.ImportTagsAsync(RawTreeTestDb.RootDeviceId, rows); @@ -207,7 +220,7 @@ public sealed class RawTreeServiceWaveBTests var (service, dbName) = Seeded(); var result = await service.ImportTagsAsync( - RawTreeTestDb.RootDeviceId, new[] { new RawTagImportRow(null, Tag("rootTag")) }); + RawTreeTestDb.RootDeviceId, new[] { new RawTagImportRow(null, S7Tag("rootTag")) }); result.Inserted.ShouldBe(1); using var db = RawTreeTestDb.CreateNamed(dbName); @@ -221,9 +234,9 @@ public sealed class RawTreeServiceWaveBTests var rows = new[] { - new RawTagImportRow("Grp", Tag("good1")), + new RawTagImportRow("Grp", S7Tag("good1")), new RawTagImportRow("Grp", Tag("bad", tagConfig: "{oops")), // malformed JSON ⇒ error - new RawTagImportRow("Grp", Tag("good2")), + new RawTagImportRow("Grp", S7Tag("good2")), }; var result = await service.ImportTagsAsync(RawTreeTestDb.RootDeviceId, rows); @@ -245,8 +258,8 @@ public sealed class RawTreeServiceWaveBTests var rows = new[] { - new RawTagImportRow("Grp", Tag("dup")), - new RawTagImportRow("Grp", Tag("dup")), // same path + name within the batch + new RawTagImportRow("Grp", S7Tag("dup")), + new RawTagImportRow("Grp", S7Tag("dup")), // same path + name within the batch }; var result = await service.ImportTagsAsync(RawTreeTestDb.RootDeviceId, rows); @@ -262,7 +275,7 @@ public sealed class RawTreeServiceWaveBTests // "temperature" already sits at the device root of RootDevice. var result = await service.ImportTagsAsync( - RawTreeTestDb.RootDeviceId, new[] { new RawTagImportRow(null, Tag("temperature")) }); + RawTreeTestDb.RootDeviceId, new[] { new RawTagImportRow(null, S7Tag("temperature")) }); result.Inserted.ShouldBe(0); result.Errors.ShouldContain(e => e.Contains("already exists")); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigValidatorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigValidatorTests.cs index 385b9538..e0f39b49 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigValidatorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigValidatorTests.cs @@ -42,9 +42,15 @@ public sealed class TagConfigValidatorTests public void OpcUaClient_with_node_id_is_valid() => TagConfigValidator.Validate("OpcUaClient", """{"nodeId":"ns=2;s=Line3.Temp"}""").ShouldBeNull(); + // Gitea #551 item 2: S7 is the one driver whose reader REQUIRES the dataType key to be present, so a + // blob carrying only an address is one the driver skips — this used to assert it was valid. [Fact] - public void S7_with_address_is_valid() - => TagConfigValidator.Validate("S7", """{"address":"DB1.DBW0"}""").ShouldBeNull(); + public void S7_with_address_and_data_type_is_valid() + => TagConfigValidator.Validate("S7", """{"address":"DB1.DBW0","dataType":"Int16"}""").ShouldBeNull(); + + [Fact] + public void S7_with_address_but_no_data_type_is_rejected() + => TagConfigValidator.Validate("S7", """{"address":"DB1.DBW0"}""").ShouldNotBeNullOrEmpty(); [Fact] public void AbCip_with_tag_path_is_valid()