From bdbc47f5fcf244cfea6f838d0897b64f91ccbb3c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 11 Nov 2026 11:56:30 -0500 Subject: [PATCH 1/2] fix(mtconnect): serve an empty string-typed value as Good "" (#824) Owner decision 2026-11-11 (Java parity): a genuinely empty observation on a String/Reference or unauthored item is Good "" instead of BadNoCommunication. Non-string items (numeric, bool, DateTime, arrays) keep BadNoCommunication - the Agent supplied no value; BadTypeMismatch would wrongly blame the authored type. The UNAVAILABLE sentinel is unchanged, and a structured observation with no text stays BadNotSupported (structured check now precedes the empty check). Parity matrix, quality-mapping section and drivers rule updated. Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk --- .claude/rules/drivers.md | 5 + docs/drivers/MTConnect.md | 18 +++- .../MTConnectObservationIndex.cs | 31 ++++-- .../MTConnectObservationIndexTests.cs | 96 ++++++++++++++++++- 4 files changed, 136 insertions(+), 14 deletions(-) diff --git a/.claude/rules/drivers.md b/.claude/rules/drivers.md index abe6ffff9..8cfe9688a 100644 --- a/.claude/rules/drivers.md +++ b/.claude/rules/drivers.md @@ -312,6 +312,11 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa the adaptive raised count is clamped to the known `bufferSize`, #846). Every parse prohibits DTDs; the streams walk is depth-bounded (64 — an unbounded recursion is a process-killing `StackOverflowException`). Non-finite floats are never Good; integer-valued decimals serve integral tags losslessly. Matrix: `docs/drivers/MTConnect.md`. +- **MTConnect empty value (#824, Java parity):** an empty/whitespace observation on a string-typed (or unauthored) item is + Good `""`; on a non-string item (numeric/bool/DateTime/array) it stays `BadNoCommunication` (no value — not + `BadTypeMismatch`). Only the literal `UNAVAILABLE` is the no-value sentinel on a string item; it, the comms-loss + fan-out and the condition path are unchanged. A ScadaBridge string tag that used to go Bad on a blank value now + receives Good `""`. - **MTConnect refused-connection fast path (#846):** a `/sample`/re-baseline failure whose chain holds `SocketError.ConnectionRefused`, unbroken for `CommsLossConfirmMs` (default 2000; 0 = first; negative = off), publishes every subscribed value `BadNoCommunication` once per outage; the first delivery afterwards republishes all of them from diff --git a/docs/drivers/MTConnect.md b/docs/drivers/MTConnect.md index 1b84a9bee..d79624288 100644 --- a/docs/drivers/MTConnect.md +++ b/docs/drivers/MTConnect.md @@ -271,6 +271,18 @@ item. A `CONDITION` observation's value is taken from its **element name** condition element normalises onto the same `UNAVAILABLE` sentinel as every other category so one comparison covers all three. +**An empty value is not `UNAVAILABLE` (#824, owner decision 2026-11-11 — Java parity).** A +genuinely empty observation (``, ``, or whitespace-only text, +which the parser trims to `""`) on a **string-typed** item (`String`/`Reference`, or an unauthored +item, which coerces as String) is served **Good `""`** — an Agent can legitimately report a blank +`Program`, `Block` or `Message`. On any **non-string** item (numeric, `Boolean`, `DateTime`, a +`TIME_SERIES` array) `""` cannot be a value, so it stays **`BadNoCommunication`** with a null value +— deliberately not `BadTypeMismatch`, which would tell the operator the Agent sent text of the wrong +type and point them at the tag's authored data type; the Agent sent no value at all. Only the literal +`UNAVAILABLE` sentinel is the "no value" statement on a string item. Before #824 every empty value +was `BadNoCommunication`. A `DATA_SET`/`TABLE`-shaped observation is unaffected (its entry-less +form is still Good `{}`; non-`` children are still `BadNotSupported`). + **Integer-valued decimals are served on integral tags (#804).** Agents format integer-valued items as `42.000000`; an `Int*`/`UInt*` tag accepts any decimal whose value *is* an integer in range (`42.000000` → `42`, `1.5e3` → `1500`) losslessly. A real fraction stays `BadOutOfRange` — deliberately stricter than the Java @@ -296,7 +308,7 @@ limitations 4; a probe that does declare one yields a fixed `ArrayDim`). Parsing: the observation text is split on any whitespace, each token parsed invariant-culture. The result is **all-or-nothing** — one non-numeric / non-finite token is `BadTypeMismatch`, a literal `UNAVAILABLE` token or an -empty body is `BadNoCommunication`, and a count that disagrees with the +empty body is `BadNoCommunication` (an array is non-string, so #824's Good `""` never applies), and a count that disagrees with the observation's own `sampleCount` attribute (or a fixed `ArrayDim`) is `BadDecodingError`. A partial vector is never served as Good. Only a **Float64** array tag is served; any other declared element type stays @@ -677,7 +689,7 @@ deliberately different, kept. | Type inference SAMPLE→Double, numeric EVENT→Int64, else String (`MTConnectTypes`) | covered, **.NET exceeds** (TIME_SERIES → `double[]`, separator-insensitive types) | `MTConnectDataTypeInference` · `MTConnectDataTypeInferenceTests` | | UNAVAILABLE → `Bad_NoCommunication`, null value, source timestamp kept (`MTConnectValues`) | covered | `MTConnectObservationIndex.BuildSnapshot` · `Unavailable_observation_maps_to_BadNoCommunication_with_null_value`, `Unavailable_observation_keeps_the_agent_source_timestamp` | | UNAVAILABLE matched case-insensitively | divergence (kept) | ordinal match on the parser-normalised token, so a free-text EVENT reading "Unavailable" stays a value · `Unavailable_sentinel_is_matched_on_the_exact_literal` | -| Blank text on a String node is a Good empty string | divergence (kept) | empty text is `BadNoCommunication` (an empty element is not an empty-string value) | +| Blank text on a String node is a Good empty string | **covered** (was a divergence until #824, owner decision 2026-11-11) | `BuildSnapshot` serves Good `""` on a string-typed or unauthored item; a non-string item stays `BadNoCommunication` (Java has no typed node to refuse `""` on); `UNAVAILABLE` unchanged · `Empty_observation_value_on_a_string_item_is_a_Good_empty_string`, `A_parsed_empty_event_on_a_string_item_is_a_Good_empty_string`, controls `Empty_observation_value_on_a_non_string_item_stays_BadNoCommunication`, `Unavailable_on_a_string_item_is_still_BadNoCommunication` | | No fabricated numbers: narrow-integer range checks, float overflow ≠ Infinity, NaN/Infinity refused (`MTConnectValues.coerce`) | covered for integers; **gap → closed** for floats (`1e400`, `NaN`, `Infinity`, `1e39` on Float32 were served Good) | `Coerce`, `NonFiniteStatus` · `A_non_finite_float_is_never_served_as_good` | | Integer-valued decimal (`42.000000`) on an integral node | **gap → closed** (was `BadOutOfRange`) — lossless only; a real fraction is still refused, never truncated (Java truncates) | `TryIntegral` · `An_integer_valued_decimal_converts_losslessly`, control `A_fraction_or_an_out_of_range_decimal_stays_out_of_range` | | Source timestamps preserved; a non-Zulu offset normalised | covered | `ReadTimestampUtc` · `A_timestamp_with_a_non_zulu_offset_is_normalized_to_the_same_utc_instant` | @@ -932,7 +944,7 @@ for anything beyond values-and-conditions. - **Unit tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/` (canned-XML fixtures, no network) — 714/714 at #804 (2026-11-10). `MTConnectJavaParityTests` holds the #804 gap tests and the ported Java regression cases (see [Java reference parity](#java-reference-parity-804)). Covers discovery tree shape, `MTConnectDataTypeInference`, observation indexing, - `UNAVAILABLE` → `BadNoCommunication`, CONDITION state-word mapping, + `UNAVAILABLE` → `BadNoCommunication` (an empty string-typed value is Good `""`, #824), CONDITION state-word mapping, multipart chunk framing, and ring-buffer re-baseline paging (both the sequence-gap and the `OUT_OF_RANGE` legs). The write path adds `MTConnectAgentClientWriteTests` (`9f17d7ef` — verb, URL, agent-error-in-200 diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectObservationIndex.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectObservationIndex.cs index 40f6d0ada..5e24da78f 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectObservationIndex.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectObservationIndex.cs @@ -76,7 +76,10 @@ internal sealed class MTConnectObservationIndex private const uint Good = 0x00000000u; - /// The Agent reported UNAVAILABLE, or supplied no text at all. + /// + /// The Agent reported UNAVAILABLE, or supplied no text at all for a non-string item + /// (an empty string-typed item is a Good "" — lmxopcua#824). + /// private const uint BadNoCommunication = 0x80310000u; /// The tag is authored but the Agent has not yet reported it. @@ -443,10 +446,8 @@ internal sealed class MTConnectObservationIndex var serverTimestamp = Now; var sourceTimestamp = DateTime.SpecifyKind(observation.TimestampUtc, DateTimeKind.Utc); - // No text at all is the degenerate spelling of "no value": MTConnect defines UNAVAILABLE as - // the only way to report absence, so an empty element is not an empty string value. - if (string.IsNullOrWhiteSpace(observation.Value) || - string.Equals(observation.Value, UnavailableSentinel, StringComparison.Ordinal)) + // UNAVAILABLE is MTConnect's one explicit "no value" sentinel — always BadNoCommunication. + if (string.Equals(observation.Value, UnavailableSentinel, StringComparison.Ordinal)) { return new DataValueSnapshot(null, BadNoCommunication, sourceTimestamp, serverTimestamp); } @@ -456,7 +457,7 @@ internal sealed class MTConnectObservationIndex // Publishing that as a Good string would be noise an operator would trust. Checked before // the tag lookup because it is a fact about the wire shape, true whether or not the data // item is authored — and after the sentinel, so an unavailable data set still reports the - // truthful no-comms. + // truthful no-comms, but BEFORE the empty-text test (#824). if (observation.IsStructured) { return new DataValueSnapshot(null, BadNotSupported, sourceTimestamp, serverTimestamp); @@ -464,6 +465,24 @@ internal sealed class MTConnectObservationIndex _tags.TryGetValue(observation.DataItemId, out var tag); + // A genuinely EMPTY element (no text, or whitespace the parser trimmed away) is NOT the + // sentinel. Owner decision on lmxopcua#824 (Java parity): on a string-typed item it is a + // Good empty string — an Agent can legitimately report a blank Program/Message/Block. + // An unauthored item coerces as String (below), so it is served the same way. + // On any other type (numeric, Boolean, DateTime, any array) "" cannot be a value, so it + // stays Bad — and stays BadNoCommunication rather than BadTypeMismatch on purpose: + // the Agent supplied no value at all (the same meaning as UNAVAILABLE, just spelled + // degenerately), whereas BadTypeMismatch tells the operator the Agent sent text of the + // wrong type and points them at the tag's authored data type, which is not the fault. + // Checked AFTER the structured test above, so element-children with no text stay + // BadNotSupported rather than becoming a Good "". + if (string.IsNullOrWhiteSpace(observation.Value)) + { + return tag is null or { IsArray: false, DriverDataType: DriverDataType.String or DriverDataType.Reference } + ? new DataValueSnapshot(string.Empty, Good, sourceTimestamp, serverTimestamp) + : new DataValueSnapshot(null, BadNoCommunication, sourceTimestamp, serverTimestamp); + } + // TIME_SERIES vectors arrive as one whitespace-separated string. Checked AFTER the // sentinel so an unavailable array tag still reports the truthful no-comms. if (tag is { IsArray: true }) diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectObservationIndexTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectObservationIndexTests.cs index d8aab2f87..f064f6ff7 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectObservationIndexTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectObservationIndexTests.cs @@ -320,21 +320,107 @@ public sealed class MTConnectObservationIndexTests .ShouldBe(new DateTime(2026, 7, 24, 12, 0, 0, 500, DateTimeKind.Utc)); } - /// An observation whose text is empty is "the Agent supplied no value" — never a Good empty string. + /// + /// lmxopcua#824 (Java parity): an empty element on a string-typed item is a Good empty string, not an + /// absence. Whitespace-only text is the same (the parser trims it). + /// [Theory] - [InlineData("")] - [InlineData(" ")] - public void Empty_observation_value_maps_to_BadNoCommunication(string value) + [InlineData("", DriverDataType.String)] + [InlineData(" ", DriverDataType.String)] + [InlineData("", DriverDataType.Reference)] + public void Empty_observation_value_on_a_string_item_is_a_Good_empty_string(string value, DriverDataType type) { - var idx = new MTConnectObservationIndex([Tag("x", DriverDataType.String)]); + var idx = new MTConnectObservationIndex([Tag("x", type)]); idx.Apply(Synthetic("x", value)); var snap = idx.Get("x"); + snap.StatusCode.ShouldBe(Good); + snap.Value.ShouldBe(string.Empty); + } + + /// An unauthored item coerces as String, so its empty value is a Good empty string too (#824). + [Fact] + public void Empty_observation_value_on_an_unauthored_item_is_a_Good_empty_string() + { + var idx = new MTConnectObservationIndex(); + idx.Apply(Synthetic("x", "")); + + var snap = idx.Get("x"); + + snap.StatusCode.ShouldBe(Good); + snap.Value.ShouldBe(string.Empty); + } + + /// + /// #824: on a non-string item "" cannot be a value, so it stays BadNoCommunication (the Agent supplied + /// no value) — not BadTypeMismatch, which would blame the tag's authored type. + /// + [Theory] + [InlineData(DriverDataType.Float64)] + [InlineData(DriverDataType.Float32)] + [InlineData(DriverDataType.Int32)] + [InlineData(DriverDataType.Int64)] + [InlineData(DriverDataType.Boolean)] + [InlineData(DriverDataType.DateTime)] + public void Empty_observation_value_on_a_non_string_item_stays_BadNoCommunication(DriverDataType type) + { + var idx = new MTConnectObservationIndex([Tag("x", type)]); + idx.Apply(Synthetic("x", " ")); + + var snap = idx.Get("x"); + snap.StatusCode.ShouldBe(BadNoCommunication); snap.Value.ShouldBeNull(); } + /// + /// #824 control: the UNAVAILABLE sentinel is unaffected — still BadNoCommunication on a String item, where an + /// empty value is now Good. + /// + [Fact] + public void Unavailable_on_a_string_item_is_still_BadNoCommunication() + { + var idx = new MTConnectObservationIndex([Tag("x", DriverDataType.String)]); + idx.Apply(Synthetic("x", "UNAVAILABLE")); + + var snap = idx.Get("x"); + + snap.StatusCode.ShouldBe(BadNoCommunication); + snap.Value.ShouldBeNull(); + } + + /// #824 end to end through the parser: an empty, a self-closing and a blank element are all Good "". + [Theory] + [InlineData("""""")] + [InlineData("""""")] + [InlineData(""" """)] + public void A_parsed_empty_event_on_a_string_item_is_a_Good_empty_string(string element) + { + var idx = new MTConnectObservationIndex([Tag("p", DriverDataType.String)]); + idx.Apply(MTConnectStreamsParser.Parse($""" + +
+ {element} + + """)); + + var snap = idx.Get("p"); + + snap.StatusCode.ShouldBe(Good); + snap.Value.ShouldBe(string.Empty); + } + + /// A structured observation with no text stays BadNotSupported — it never becomes a Good "" (#824). + [Fact] + public void A_structured_observation_with_no_text_stays_BadNotSupported() + { + var idx = new MTConnectObservationIndex([Tag("x", DriverDataType.String)]); + idx.Apply(Synthetic("x", "", isStructured: true)); + + idx.Get("x").StatusCode.ShouldBe(BadNotSupported); + } + // -------------------------------------------------------------------------- unknown lookups /// A dataItemId that is neither authored nor ever observed is unknown to this driver. From e6ec5a09486e1c835ca2fa8f79d2aadebd288612 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 11 Nov 2026 11:57:59 -0500 Subject: [PATCH 2/2] docs(opcuaclient): keep server host-name check opt-in; surface it in the Security panel (#837) Owner decision 2026-11-11: certificateValidation.validateServerHostName stays opt-in (unset = off). The AdminUI toggle moves from the "Subscriptions, limits & discovery" panel to the Security panel beside mode/policy, with help text on the NAT/IP caveat, IPv6 zone ids and the loose first-label match. Binding unchanged (tri-state, "Default (off)" leaves the key absent). OpcUaClient.md states the decision; drivers rule updated; source-scan test pins placement + caveats. Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk --- .claude/rules/drivers.md | 3 +- docs/drivers/OpcUaClient.md | 12 +++++-- .../Drivers/Forms/OpcUaClientDriverForm.razor | 33 ++++++++++++++----- ...aClientDriverPageFormSerializationTests.cs | 28 ++++++++++++++++ 4 files changed, 64 insertions(+), 12 deletions(-) diff --git a/.claude/rules/drivers.md b/.claude/rules/drivers.md index 8cfe9688a..8586e567e 100644 --- a/.claude/rules/drivers.md +++ b/.claude/rules/drivers.md @@ -293,7 +293,8 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa - **OpcUaClient server host-name check (#816):** the SDK checks the server certificate's host only with `checkDomain: true`; its 8-arg `DefaultSessionFactory.CreateAsync` hard-codes `false`, so driver, AdminUI browser and Client.Shared never checked before #816. Now `certificateValidation.validateServerHostName` (tri-state, unset = off = - unchanged; AdminUI keeps it unset) / CLI `--validate-host-name`. It checks the **configured** host (the rewritten + unchanged; AdminUI keeps it unset; **stays opt-in by owner decision #837** — the toggle lives in the form's Security +panel with the NAT/IPv6-zone/loose-match caveats) / CLI `--validate-host-name`. It checks the **configured** host (the rewritten `ConfiguredEndpoint.EndpointUrl`) and auto-accept accepts a mismatch. ⚠️ The SDK checks in TWO places — `DefaultSessionFactory.CreateChannelAsync` (GetEndpoints cert, BEFORE any security-mode test, so it fired on `None`) and `Session.OpenAsync` — so pass `checkDomain` only for a non-`None` endpoint (`OpcUaEndpointSelection.CheckDomainFor`). diff --git a/docs/drivers/OpcUaClient.md b/docs/drivers/OpcUaClient.md index 677493e6a..fd7014681 100644 --- a/docs/drivers/OpcUaClient.md +++ b/docs/drivers/OpcUaClient.md @@ -189,8 +189,16 @@ Consequences: - Test Connect (`OpcUaClientDriverProbe`) only calls GetEndpoints on an unsecured discovery channel — no session, so no certificate or host-name check. -**Policy: `certificateValidation.validateServerHostName`** (driver JSON; AdminUI driver form "Server host-name check"; -the browser reads the same JSON): +**Decision (#837, owner, 2026-11-11): the check stays OPT-IN — unset means off, and it will not become a default.** +Turning it on by default would refuse every NAT / port-published / connect-by-IP endpoint whose certificate does not +list that exact host, and every link-local IPv6 endpoint, on upgrade; and because the match is loose (first label) it +would not deliver the identity guarantee a default implies. Operators enable it per driver where trust is CA-issued +and certificate names are fully-qualified and site-unique. The AdminUI shows the toggle in the driver form's +**Security** panel (beside mode/policy) as "Server certificate host-name check", with help text covering the NAT/IP +caveat, IPv6 zone ids and the loose first-label match. + +**Policy: `certificateValidation.validateServerHostName`** (driver JSON; AdminUI driver form, Security panel, +"Server certificate host-name check"; the browser reads the same JSON): | Value | Meaning | |---|---| diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/OpcUaClientDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/OpcUaClientDriverForm.razor index f52566321..1cbb7f2bd 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/OpcUaClientDriverForm.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/OpcUaClientDriverForm.razor @@ -111,6 +111,30 @@ } + @* #816 / #837: opt-in by owner decision (unset = off). Kept in the Security panel so it is + found next to the mode/policy it depends on; "Default (off)" leaves the key absent. *@ +
+ + + + + + +
+
+
+ Opt-in (off unless set). When validating, the server certificate must name the + endpoint host you configured; a miss refuses the connection + (BadCertificateHostNameInvalid). Applies to Sign / SignAndEncrypt only, never to + None, and auto-accept accepts a mismatch. Caveats: +
    +
  • NAT / port publish / connecting by IP: fails unless the certificate lists that exact host or IP — leave off.
  • +
  • IPv6 zone ids (fe80::1%eth0, link-local) never match — leave off.
  • +
  • Loose match: the first DNS label alone counts (plc1.site-b.corp passes a certificate for PLC1), so use fully-qualified, site-unique certificate names.
  • +
+ Most useful with CA-issued trust; adds little over a pinned self-signed certificate. +
+
@@ -195,15 +219,6 @@
Revoked certificates are always rejected.
-
- - - - - - -
Sign / SignAndEncrypt only (never on None): the server certificate must name the endpoint host you configured. Off for link-local IPv6.
-
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/OpcUaClientDriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/OpcUaClientDriverPageFormSerializationTests.cs index af598b20e..8774f94ba 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/OpcUaClientDriverPageFormSerializationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/OpcUaClientDriverPageFormSerializationTests.cs @@ -208,6 +208,34 @@ public sealed class OpcUaClientDriverPageFormSerializationTests reparsed.CertificateValidation.EffectiveValidateServerHostName.ShouldBe(value ?? false); } + /// + /// #837: the host-name check stays opt-in (unset = off) and sits in the Security panel with help text naming + /// the NAT/IP, IPv6-zone and loose-first-label caveats. Source scan (no bUnit in this repo). + /// + [Fact] + public void The_host_name_check_is_an_opt_in_in_the_security_panel_with_its_caveats() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "ZB.MOM.WW.OtOpcUa.slnx"))) + dir = dir.Parent; + dir.ShouldNotBeNull("could not locate the repository root"); + var razor = File.ReadAllText(Path.Combine(dir!.FullName, "src", "Server", "ZB.MOM.WW.OtOpcUa.AdminUI", + "Components", "Shared", "Drivers", "Forms", "OpcUaClientDriverForm.razor")); + + razor.ShouldContain(""); + + var security = razor.IndexOf("
Security
", StringComparison.Ordinal); + var next = razor.IndexOf("
", security + 1, StringComparison.Ordinal); + security.ShouldBeGreaterThan(-1); + var panel = razor[security..next]; + panel.ShouldContain("_form.OpcUa.ValidateServerHostName"); + panel.ShouldContain("Opt-in"); + panel.ShouldContain("NAT"); + panel.ShouldContain("IPv6 zone ids"); + panel.ShouldContain("Loose match"); + razor.Split("_form.OpcUa.ValidateServerHostName").Length.ShouldBe(2, "the toggle must be rendered once"); + } + /// /// #648: stored DriverConfig JSON from before targetNamespaceKind/unsMappingTable were removed /// must still load into the form (the form's options use UnmappedMemberHandling.Skip) and the legacy