fix(adminui): OpcUaClient editor reads the legacy PascalCase NodeId at the reader's precedence (#557 item 1)

`OpcUaClientTagDefinitionFactory.TryReadNodeId` still honours a legacy PascalCase
`NodeId` when the v3 camelCase `nodeId` is wholly absent, but
`OpcUaClientTagConfigModel` read only `nodeId` — so a legacy blob was SERVED by
the driver yet opened with an empty reference field and could not be saved
untouched. Authoring stricter than the reader, which #554 names as never
legitimate: the editor was blocking config the runtime honours.

Fixed with the #550 pattern (read both spellings, normalise on save), NOT by
retiring the spelling from the reader — that would break every legacy blob the
driver currently serves at the next deploy, a data-plane regression traded for
an editor asymmetry.

The precedence is PRESENCE-shadows, not first-non-blank. The reader's
`TryGetProperty("nodeId", out n) || TryGetProperty("NodeId", out n)`
short-circuits on the mere PRESENCE of the camelCase key and then checks
string-and-non-blank ONCE, so a blank / non-string / JSON-null `nodeId` beside a
valid legacy `NodeId` is a REJECTED blob, not a fallback. Copying MTConnect's
`FirstNonBlank` walk here would have made authoring WEAKER than the reader —
the same defect in mirror image.

- `NodeIdKeys` drives both halves, so the read and the drop cannot drift.
- `ToJson` writes `nodeId` and drops `NodeId`; the TagModal history-merge keys
  (`isHistorized` / `historianTagname`) are disjoint and untouched.
- `Validate`'s required-reference fast path is now precedence-aware by
  construction, so it agrees with the #554 parse consult across the whole
  rejection surface.
- The `TagConfigNonEnumParityTests` exemption for OpcUaClient is REMOVED — its
  anti-vacuity guard `Every_deliberately_stricter_entry_is_still_exactly_that`
  went red on the fix, which is this change's falsification control. The legacy
  shapes moved into the corpus, where both fleet theories now cover them.

Tests: a nine-row precedence theory driving the authoring verdict against the
READER's verdict per shape, on the original blob AND on the `ToJson()` output;
no-retype (legacy loads showing its reference, saves canonical, same node id
before and after); no-drift (output never carries `NodeId`). Each half was
reverted in isolation to confirm it reddens tests of its own: read-only-revert
=> 6 red incl. the fleet parity theory; drop-only-revert => 4 red.

Suites: AdminUI.Tests 1459/1459, ControlPlane.Tests 261/261,
Driver.OpcUaClient.Tests 143/143, Driver.OpcUaClient.Browser.Tests 10/10.
This commit is contained in:
Joseph Doherty
2026-08-10 03:01:41 -04:00
parent ca3968cc31
commit be5899bc7d
6 changed files with 255 additions and 17 deletions
@@ -32,6 +32,15 @@ public static class OpcUaClientTagDefinitionFactory
/// Read the upstream node id string from an authored <c>TagConfig</c> blob's <c>nodeId</c> key
/// (camelCase, per the AdminUI <c>OpcUaClientTagConfigModel</c>; a legacy PascalCase <c>NodeId</c>
/// is also accepted). Never throws — a malformed blob yields <see langword="false"/>.
/// <para>
/// ⚠️ <b>The two spellings are PRESENCE-shadows, not first-non-blank.</b> The <c>||</c> below
/// short-circuits on the mere presence of <c>nodeId</c> — whatever it holds — and the string /
/// non-blank check then runs ONCE, on that one element. So the legacy <c>NodeId</c> is consulted
/// only when <c>nodeId</c> is <i>wholly absent</i>: a blank or non-string <c>nodeId</c> beside a
/// valid legacy <c>NodeId</c> is a rejected blob, not a fallback. The AdminUI model mirrors this
/// walk exactly (Gitea #557 item 1) rather than the looser first-non-blank shape MTConnect's
/// reader uses, which would let authoring accept a blob this reader refuses.
/// </para>
/// </summary>
/// <param name="tagConfig">The authored equipment-tag TagConfig JSON.</param>
/// <param name="nodeId">The upstream node id string when this returns <see langword="true"/>.</param>
@@ -10,17 +10,50 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
/// <remarks>
/// Under the v3 identity contract, <c>TagConfig</c> no longer carries identity — the upstream
/// node reference is an ordinary driver-specific address field. The key is the camelCase
/// <c>nodeId</c> (replacing the legacy PascalCase <c>FullName</c> identity convention). The
/// <c>nodeId</c> (replacing the legacy PascalCase <c>FullName</c> identity convention); a legacy
/// PascalCase <c>NodeId</c> is still READ, at the driver reader's own precedence, and normalised away
/// on save — see <see cref="NodeIdKeys"/>. The
/// driver-agnostic server-side HistoryRead intent keys (<c>isHistorized</c> /
/// <c>historianTagname</c>) are NOT modelled here — they are owned by the TagModal-merge seam
/// (<see cref="TagHistorizeConfig"/>) and survive a load→save of this model as preserved unknown keys.
/// </remarks>
public sealed class OpcUaClientTagConfigModel
{
/// <summary>The camelCase TagConfig JSON key holding the upstream node reference.</summary>
/// <summary>The camelCase TagConfig JSON key holding the upstream node reference — the spelling this
/// model writes, and the LAST of <see cref="NodeIdKeys"/>, i.e. the one the reader consults only when
/// no earlier spelling is present.</summary>
internal const string NodeIdKey = "nodeId";
/// <summary>Upstream OPC UA node reference the tag binds to (the driver-side full reference). Required.</summary>
/// <summary>
/// The accepted spellings of the upstream node reference, <b>in the reader's precedence order</b>,
/// mirroring <see cref="OpcUaClientTagDefinitionFactory.TryReadNodeId"/>'s
/// <c>TryGetProperty("nodeId", …) || TryGetProperty("NodeId", …)</c>: the v3 camelCase
/// <c>nodeId</c> first, then the legacy PascalCase <c>NodeId</c> a pre-v3 blob carries.
/// <para>
/// ⚠️ <b>This precedence is PRESENCE-shadows, not first-non-blank</b> — see
/// <see cref="ReadNodeId"/>. The reader's <c>||</c> short-circuits on the mere PRESENCE of
/// <c>nodeId</c> and only then asks whether the value is a non-blank string, so a present-but-blank
/// or present-but-non-string <c>nodeId</c> beside a perfectly good legacy <c>NodeId</c> is a
/// REJECTED blob, not a fallback. Copying <c>MTConnectTagConfigModel</c>'s <c>FirstNonBlank</c>
/// walk here would make this editor accept a blob the driver skips — the absolute direction
/// Gitea #554 forbids.
/// </para>
/// <para>
/// Reading only <c>nodeId</c> was Gitea <b>#557</b> item 1: a legacy blob was SERVED by the driver
/// yet opened with an empty reference field and could not be saved untouched, so the editor was
/// stricter than the reader — authoring blocking config the runtime honours.
/// <see cref="ToJson"/> therefore normalises onto <see cref="NodeIdKey"/> and DROPS the legacy
/// spelling, exactly as <c>MTConnectTagConfigModel</c> does for its own aliases. Both halves are
/// load-bearing together: the precedence read alone would leave the legacy key in the bag to be
/// re-read on the next load, and the drop alone would blank every legacy tag's reference on its
/// next save.
/// </para>
/// </summary>
private static readonly string[] NodeIdKeys = [NodeIdKey, "NodeId"];
/// <summary>Upstream OPC UA node reference the tag binds to (the driver-side full reference). Required.
/// Loaded from <c>nodeId</c>, or from the legacy PascalCase <c>NodeId</c> when <c>nodeId</c> is wholly
/// absent (see <see cref="NodeIdKeys"/>); always saved as <c>nodeId</c>, with the legacy key dropped.</summary>
public string NodeId { get; set; } = "";
private JsonObject _bag = new();
@@ -38,31 +71,43 @@ public sealed class OpcUaClientTagConfigModel
var o = TagConfigJson.ParseOrNew(json);
return new OpcUaClientTagConfigModel
{
NodeId = TagConfigJson.GetString(o, NodeIdKey) ?? "",
NodeId = ReadNodeId(o),
_source = json ?? "",
_bag = o,
};
}
/// <summary>Serialises this model back to a TagConfig JSON string over the preserved key bag.
/// <c>nodeId</c> is written camelCase (the v3 address key); any history keys merged by the TagModal
/// (<c>isHistorized</c> / <c>historianTagname</c>) are carried through untouched as preserved unknown
/// keys.</summary>
/// <c>nodeId</c> is written camelCase (the v3 address key) and every legacy spelling of it is DROPPED
/// (see <see cref="NodeIdKeys"/>); any history keys merged by the TagModal (<c>isHistorized</c> /
/// <c>historianTagname</c>) are carried through untouched as preserved unknown keys — they are
/// disjoint from the address key, so the normalisation below cannot disturb that merge.</summary>
/// <returns>The serialised TagConfig JSON string.</returns>
public string ToJson()
{
// Normalise onto the canonical key: a blob loaded via the legacy spelling must not keep it, or the
// two spellings drift on the next edit and a later reader change could pick the stale one. Driven
// off NodeIdKeys rather than naming "NodeId" a second time, so a spelling added to the READ can
// never be left behind by the DROP.
foreach (var legacy in NodeIdKeys)
{
if (legacy != NodeIdKey) { TagConfigJson.Set(_bag, legacy, null); }
}
TagConfigJson.Set(_bag, NodeIdKey, NodeId.Trim());
return _source = TagConfigJson.Serialize(_bag);
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
/// <remarks>
/// The Gitea #554 parse consult is a backstop here: the required-<c>nodeId</c> check above already
/// covers the reader's whole rejection surface, and this model is in fact <b>stricter</b> in one
/// narrow shape — the reader still honours a legacy PascalCase <c>NodeId</c> key when <c>nodeId</c>
/// is wholly absent, while this model reads only the camelCase key and so refuses that blob. That
/// predates #554 and is deliberately left alone; the fix for such a blob is to re-enter the
/// reference, which rewrites it to the v3 key.
/// The Gitea #554 parse consult is a backstop here, and the required-reference check above is now
/// exactly as precedence-aware as the reader (Gitea #557 item 1): <see cref="ReadNodeId"/> yields
/// the very token <see cref="OpcUaClientTagDefinitionFactory.TryReadNodeId"/> would consult — blank
/// for every shape it refuses, non-blank for every shape it serves — so the fast path agrees with
/// the parse verdict on the whole rejection surface rather than under-reading one spelling of it.
/// Neither half is redundant: the fast path is the only one that fires for a model built by object
/// initialiser (no source to parse), and the parse consult is what stops a future reader rule from
/// silently becoming an authoring gap.
/// </remarks>
/// <returns>An error message describing the validation failure, or <c>null</c> when valid.</returns>
public string? Validate()
@@ -73,4 +118,26 @@ public sealed class OpcUaClientTagConfigModel
j => OpcUaClientTagDefinitionFactory.TryReadNodeId(j, out _),
OpcUaClientTagDefinitionFactory.Inspect,
"OpcUaClient");
// The as-loaded node reference, read in the READER's precedence (see NodeIdKeys). Per spelling,
// mirroring `TryGetProperty("nodeId", out n) || TryGetProperty("NodeId", out n)` followed by ONE
// `n.ValueKind == String && !IsNullOrWhiteSpace(s)` check:
// · wholly absent → the || moves on: try the next spelling.
// · present, any JSON null
// or non-string token → STOP. The || has already short-circuited on this spelling, and the single
// kind check then refuses the whole blob — it can NEVER fall through to the
// legacy key. Yield "" so Validate() refuses exactly what the driver does.
// · present string → STOP, this is the effective token, blank or not. A blank one likewise
// shadows the legacy key and is a refusal, not a fallback.
// Nothing present ⇒ "", the no-reference-authored shape the reader also refuses.
private static string ReadNodeId(JsonObject o)
{
foreach (var key in NodeIdKeys)
{
if (!o.TryGetPropertyValue(key, out var node)) { continue; }
return node is JsonValue v && v.TryGetValue<string>(out var s) ? s : "";
}
return "";
}
}