v3 B1-WP2: TagConfigIntent sheds FullName identity; RawPaths tests; nodeId key normalization

This commit is contained in:
Joseph Doherty
2026-07-15 18:58:57 -04:00
parent cb720bb8c3
commit d4dab75323
6 changed files with 271 additions and 176 deletions
@@ -8,28 +8,17 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Types;
/// (<c>AddressSpaceComposer</c>), the artifact-decode seam (<c>DeploymentArtifact</c>), the draft
/// gate (<c>DraftValidator</c>), and the walker (<c>EquipmentNodeWalker</c>). One
/// <see cref="JsonDocument.Parse(string)"/> per blob. Never throws.
/// <para>Two deliberate variations are carried on the record so every seam can be served from one
/// parse:</para>
/// <list type="bullet">
/// <item><description><see cref="FullName"/> is the driver-side wire reference: the top-level
/// <c>FullName</c> string when present, else the RAW blob (an equipment tag's TagConfig JSON
/// <em>is</em> its wire reference for the six protocol drivers). <c>Parse(null)</c> ⇒
/// <c>FullName == ""</c>.</description></item>
/// <item><description><see cref="ExplicitFullName"/> is the <c>FullName</c> property ONLY when
/// present-and-string, else <see langword="null"/> — the DraftValidator Galaxy-rule semantics
/// (it wants the explicit reference, not the raw-blob fallback).</description></item>
/// </list>
/// <para>Under the v3 identity contract, <c>TagConfig</c> no longer carries identity — the
/// RawPath (computed elsewhere) is the single identity string at every seam. This type parses
/// only the cross-driver PLATFORM intents (alarm / historize / array); the driver-specific
/// address fields stay opaque inside the blob and are read by the per-driver resolvers.</para>
/// </summary>
/// <param name="FullName">Top-level <c>FullName</c> string, else the raw blob (wire-reference fallback).</param>
/// <param name="ExplicitFullName">The <c>FullName</c> property when present-and-string, else <see langword="null"/>.</param>
/// <param name="Alarm">The optional native-alarm intent parsed from the <c>alarm</c> object; <see langword="null"/> ⇒ plain value variable.</param>
/// <param name="IsHistorized"><c>isHistorized</c> — true only when the value is a bool <c>true</c>.</param>
/// <param name="HistorianTagname">Non-whitespace <c>historianTagname</c> string override, else <see langword="null"/> (not trimmed).</param>
/// <param name="IsArray"><c>isArray</c> bool.</param>
/// <param name="ArrayLength"><c>arrayLength</c> honoured ONLY when <see cref="IsArray"/> and a JSON number fitting <see cref="uint"/>; else <see langword="null"/>.</param>
public sealed record TagConfigIntent(
string FullName,
string? ExplicitFullName,
TagAlarmIntent? Alarm,
bool IsHistorized,
string? HistorianTagname,
@@ -38,40 +27,33 @@ public sealed record TagConfigIntent(
{
/// <summary>
/// Parse a Tag's schemaless <c>TagConfig</c> JSON into the cross-driver platform intents.
/// Never throws: malformed JSON / non-object root / blank / null ⇒ all intents default with
/// <see cref="FullName"/> = the raw blob (or "" for null). Semantics are ported verbatim from the
/// historical <c>AddressSpaceComposer.Extract*</c> statics (the canonical copy).
/// Never throws: malformed JSON / non-object root / blank / null ⇒ all intents default.
/// Semantics are ported verbatim from the historical <c>AddressSpaceComposer.Extract*</c>
/// statics (the canonical copy).
/// </summary>
/// <param name="tagConfig">The tag's raw <c>TagConfig</c> JSON (nullable/blank tolerated).</param>
/// <returns>The parsed intents; never <see langword="null"/>.</returns>
public static TagConfigIntent Parse(string? tagConfig)
{
if (string.IsNullOrWhiteSpace(tagConfig))
return new TagConfigIntent(tagConfig ?? string.Empty, null, null, false, null, false, null);
return new TagConfigIntent(null, false, null, false, null);
try
{
using var doc = JsonDocument.Parse(tagConfig);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object)
return new TagConfigIntent(tagConfig, null, null, false, null, false, null);
var explicitFullName =
root.TryGetProperty("FullName", out var fn) && fn.ValueKind == JsonValueKind.String
? fn.GetString()
: null;
var fullName = explicitFullName ?? tagConfig;
return new TagConfigIntent(null, false, null, false, null);
var (isHistorized, historianTagname) = ParseHistorize(root);
var (isArray, arrayLength) = ParseArray(root);
return new TagConfigIntent(
fullName, explicitFullName, ParseAlarm(root),
isHistorized, historianTagname, isArray, arrayLength);
ParseAlarm(root), isHistorized, historianTagname, isArray, arrayLength);
}
catch (JsonException)
{
return new TagConfigIntent(tagConfig, null, null, false, null, false, null);
return new TagConfigIntent(null, false, null, false, null);
}
}
@@ -3,21 +3,24 @@ using System.Text.Json.Nodes;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
/// <summary>Typed working model for an OPC UA Client (gateway) equipment tag's TagConfig JSON. The tag
/// is bound to the upstream OPC UA server node by its full reference (<c>FullName</c> — the persisted
/// is bound to the upstream OPC UA server node by its full reference (<c>nodeId</c> — the persisted
/// stable <c>nsu=…;s=…</c> or plain <c>ns=2;s=…</c> NodeId the driver reads/writes/subscribes against).
/// Preserves unrecognised JSON keys across a load→save.</summary>
/// <remarks>
/// The <c>FullName</c> key is intentionally PascalCase: the shared deploy-time parser
/// (<c>Commons.Types.TagConfigIntent.Parse</c>, consumed by the composer, artifact decoder, draft
/// gate, and node walker) reads it via a case-sensitive <c>TryGetProperty("FullName", …)</c>, so the editor MUST persist that exact
/// casing. The driver-agnostic server-side HistoryRead intent keys (<c>isHistorized</c> /
/// 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
/// 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>
internal const string NodeIdKey = "nodeId";
/// <summary>Upstream OPC UA node reference the tag binds to (the driver-side full reference). Required.</summary>
public string FullName { get; set; } = "";
public string NodeId { get; set; } = "";
private JsonObject _bag = new();
@@ -30,24 +33,24 @@ public sealed class OpcUaClientTagConfigModel
var o = TagConfigJson.ParseOrNew(json);
return new OpcUaClientTagConfigModel
{
FullName = TagConfigJson.GetString(o, "FullName") ?? "",
NodeId = TagConfigJson.GetString(o, NodeIdKey) ?? "",
_bag = o,
};
}
/// <summary>Serialises this model back to a TagConfig JSON string over the preserved key bag.
/// <c>FullName</c> is written PascalCase (the composer/walker contract 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); any history keys merged by the TagModal
/// (<c>isHistorized</c> / <c>historianTagname</c>) are carried through untouched as preserved unknown
/// keys.</summary>
/// <returns>The serialised TagConfig JSON string.</returns>
public string ToJson()
{
TagConfigJson.Set(_bag, "FullName", FullName.Trim());
TagConfigJson.Set(_bag, NodeIdKey, NodeId.Trim());
return TagConfigJson.Serialize(_bag);
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
/// <returns>An error message describing the validation failure, or <c>null</c> when valid.</returns>
public string? Validate()
=> string.IsNullOrWhiteSpace(FullName) ? "An upstream node reference (FullName) is required." : null;
=> string.IsNullOrWhiteSpace(NodeId) ? "An upstream node reference (nodeId) is required." : null;
}