From b0046d6959c9752cbcd1014360c91838514506f6 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 17:56:22 -0400 Subject: [PATCH] feat(mtconnect): typed AdminUI tag-config model (Task 17) MTConnectTagConfigModel: pure FromJson/ToJson/Validate for the MTConnect tag TagConfig JSON blob, mirroring ModbusTagConfigModel/OpcUaClientTagConfigModel. Fields: fullName (DataItem@id, required), dataType (DriverDataType override, written as an enum name string), mtCategory/mtType/mtSubType/units (probe metadata), mtDevice/mtComponent (author context). Preserves unknown JSON keys across load->save (isHistorized/historianTagname and anything else). Guards against the enum-serialization trap (numeric dataType would fault the driver at deploy) both on write (always TagConfigJson.Set via enum name) and on read (Validate() rejects a dataType that was stored as a bare digit string, since Enum.TryParse silently accepts numeric text). AdminUI.csproj gains a ProjectReference to Driver.MTConnect.Contracts, matching the existing POCO-only driver-Contracts pattern used by the other six typed tag editors. Scope: pure model only (Task 17). The razor editor shell + TagConfigEditorMap/ TagConfigValidator registration is Task 18. --- .../Uns/TagEditors/MTConnectTagConfigModel.cs | 130 +++++++++++++ .../ZB.MOM.WW.OtOpcUa.AdminUI.csproj | 1 + .../Uns/MTConnectTagConfigModelTests.cs | 179 ++++++++++++++++++ 3 files changed, 310 insertions(+) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/MTConnectTagConfigModel.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/MTConnectTagConfigModelTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/MTConnectTagConfigModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/MTConnectTagConfigModel.cs new file mode 100644 index 00000000..20bb263a --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/MTConnectTagConfigModel.cs @@ -0,0 +1,130 @@ +using System.Text.Json.Nodes; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; + +/// Typed working model for an MTConnect tag's TagConfig JSON. The tag binds a single Agent +/// DataItem by its id attribute ( — MTConnect is read-only, so +/// there is no address encoding beyond the id); the remaining fields are probe-sourced display +/// metadata plus an author-settable type override. Preserves unrecognised JSON keys across a +/// load→save. +/// +/// +/// /// come +/// from the Agent's /probe response (see MTConnectTagDefinition in the driver's +/// Contracts project) and are shown read-only by Task 18's editor — this model still round-trips +/// them so a browse-commit that stamped them survives a manual edit of . +/// / are pure author context (which physical +/// device/component this DataItem belongs to); nothing downstream consumes them. +/// +/// +/// is an override of the driver's own probe-based inference +/// (MTConnectDataTypeInference.Infer, which the browse-commit path already applied when it +/// wrote and this field). It is deliberately NOT recomputed here — this +/// editor only re-serialises whatever the operator picks or the browse commit stamped, never a +/// locally-reinvented inference that could disagree with the one true rule. +/// +/// +public sealed class MTConnectTagConfigModel +{ + /// The MTConnect DataItem's id attribute — the driver's read/subscribe key. Required. + public string FullName { get; set; } = ""; + + /// Logical data type override for the DataItem's value. Defaults to + /// — MTConnect is weakly typed on the wire, so an unset override should never coerce to a numeric type + /// that can fail to parse (mirrors MTConnectDataTypeInference's own String-leaning default). + public DriverDataType DataType { get; set; } = DriverDataType.String; + + /// The DataItem's probe-sourced category (SAMPLE/EVENT/CONDITION). Read-only display metadata. + public string? MtCategory { get; set; } + + /// The DataItem's probe-sourced type (e.g. POSITION, EXECUTION). Read-only display metadata. + public string? MtType { get; set; } + + /// The DataItem's probe-sourced subType (e.g. ACTUAL, COMMANDED). Read-only display metadata. + public string? MtSubType { get; set; } + + /// The DataItem's probe-sourced units (e.g. MILLIMETER). Read-only display metadata. + public string? Units { get; set; } + + /// Author-entered note identifying the owning MTConnect device. Pure authoring context; not + /// consumed by the driver or the runtime. + public string? MtDevice { get; set; } + + /// Author-entered note identifying the owning MTConnect component. Pure authoring context; not + /// consumed by the driver or the runtime. + public string? MtComponent { get; set; } + + // The as-loaded raw "dataType" JSON value, kept only to detect the ParseEnum numeric-text trap in + // Validate() below — Enum.TryParse (which TagConfigJson.GetEnum uses) silently accepts a quoted + // number ("8" -> Float64) exactly as readily as a name, so a config that was ever hand-authored or + // migrated with a numeric dataType would otherwise load into a *valid-looking* enum value with no + // signal that it isn't a name. ToJson() always re-writes DataType as a name, so this can only ever + // be non-null on the FIRST load of an already-poisoned config, never after a save through this model. + private string? _rawDataType; + + private JsonObject _bag = new(); + + /// 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 for a new/empty config. + /// The populated . + public static MTConnectTagConfigModel FromJson(string? json) + { + var o = TagConfigJson.ParseOrNew(json); + return new MTConnectTagConfigModel + { + FullName = TagConfigJson.GetString(o, "fullName") ?? "", + DataType = TagConfigJson.GetEnum(o, "dataType", DriverDataType.String), + MtCategory = TagConfigJson.GetString(o, "mtCategory"), + MtType = TagConfigJson.GetString(o, "mtType"), + MtSubType = TagConfigJson.GetString(o, "mtSubType"), + Units = TagConfigJson.GetString(o, "units"), + MtDevice = TagConfigJson.GetString(o, "mtDevice"), + MtComponent = TagConfigJson.GetString(o, "mtComponent"), + _rawDataType = TagConfigJson.GetString(o, "dataType"), + _bag = o, + }; + } + + /// Serialises this model back to a TagConfig JSON string over the preserved key bag. + /// dataType is always written as its enum NAME (never a bare number — see the + /// enum-serialization trap in the class remarks); the optional metadata keys are omitted when + /// blank rather than persisted as empty strings. + /// The serialised TagConfig JSON string. + public string ToJson() + { + TagConfigJson.Set(_bag, "fullName", FullName.Trim()); + TagConfigJson.Set(_bag, "dataType", DataType); + TagConfigJson.Set(_bag, "mtCategory", Blank(MtCategory)); + TagConfigJson.Set(_bag, "mtType", Blank(MtType)); + TagConfigJson.Set(_bag, "mtSubType", Blank(MtSubType)); + TagConfigJson.Set(_bag, "units", Blank(Units)); + TagConfigJson.Set(_bag, "mtDevice", Blank(MtDevice)); + TagConfigJson.Set(_bag, "mtComponent", Blank(MtComponent)); + return TagConfigJson.Serialize(_bag); + } + + /// Validation hook; returns an error message or null when the model is valid. + /// An error message describing the validation failure, or null when the model is valid. + public string? Validate() + { + if (string.IsNullOrWhiteSpace(FullName)) + { + return "A DataItem id (fullName) is required."; + } + + // See the _rawDataType remarks: a quoted number parses as a "valid" enum today but is never + // something this editor itself would have written, so surface it instead of silently accepting it. + if (_rawDataType is { Length: > 0 } raw && raw.All(char.IsAsciiDigit)) + { + return $"dataType \"{raw}\" is a numeric value, not a named type — re-select a data type."; + } + + return null; + } + + // Normalises a blank/whitespace-only string to null so TagConfigJson.Set omits the key rather than + // persisting an empty string. + private static string? Blank(string? value) => string.IsNullOrWhiteSpace(value) ? null : value; +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj index 01e0e8b3..a81f6b25 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj @@ -37,6 +37,7 @@ +