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.
This commit is contained in:
@@ -0,0 +1,130 @@
|
|||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||||
|
|
||||||
|
/// <summary>Typed working model for an MTConnect tag's TagConfig JSON. The tag binds a single Agent
|
||||||
|
/// <c>DataItem</c> by its <c>id</c> attribute (<see cref="FullName"/> — 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.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="MtCategory"/>/<see cref="MtType"/>/<see cref="MtSubType"/>/<see cref="Units"/> come
|
||||||
|
/// from the Agent's <c>/probe</c> response (see <c>MTConnectTagDefinition</c> 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 <see cref="DataType"/>.
|
||||||
|
/// <see cref="MtDevice"/>/<see cref="MtComponent"/> are pure author context (which physical
|
||||||
|
/// device/component this DataItem belongs to); nothing downstream consumes them.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="DataType"/> is an override of the driver's own probe-based inference
|
||||||
|
/// (<c>MTConnectDataTypeInference.Infer</c>, which the browse-commit path already applied when it
|
||||||
|
/// wrote <see cref="FullName"/> 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.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class MTConnectTagConfigModel
|
||||||
|
{
|
||||||
|
/// <summary>The MTConnect DataItem's <c>id</c> attribute — the driver's read/subscribe key. Required.</summary>
|
||||||
|
public string FullName { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>Logical data type override for the DataItem's value. Defaults to <see cref="DriverDataType.String"/>
|
||||||
|
/// — MTConnect is weakly typed on the wire, so an unset override should never coerce to a numeric type
|
||||||
|
/// that can fail to parse (mirrors <c>MTConnectDataTypeInference</c>'s own String-leaning default).</summary>
|
||||||
|
public DriverDataType DataType { get; set; } = DriverDataType.String;
|
||||||
|
|
||||||
|
/// <summary>The DataItem's probe-sourced <c>category</c> (<c>SAMPLE</c>/<c>EVENT</c>/<c>CONDITION</c>). Read-only display metadata.</summary>
|
||||||
|
public string? MtCategory { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The DataItem's probe-sourced <c>type</c> (e.g. <c>POSITION</c>, <c>EXECUTION</c>). Read-only display metadata.</summary>
|
||||||
|
public string? MtType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The DataItem's probe-sourced <c>subType</c> (e.g. <c>ACTUAL</c>, <c>COMMANDED</c>). Read-only display metadata.</summary>
|
||||||
|
public string? MtSubType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The DataItem's probe-sourced <c>units</c> (e.g. <c>MILLIMETER</c>). Read-only display metadata.</summary>
|
||||||
|
public string? Units { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Author-entered note identifying the owning MTConnect device. Pure authoring context; not
|
||||||
|
/// consumed by the driver or the runtime.</summary>
|
||||||
|
public string? MtDevice { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Author-entered note identifying the owning MTConnect component. Pure authoring context; not
|
||||||
|
/// consumed by the driver or the runtime.</summary>
|
||||||
|
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();
|
||||||
|
|
||||||
|
/// <summary>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).</summary>
|
||||||
|
/// <param name="json">The raw TagConfig JSON string, or <c>null</c> for a new/empty config.</param>
|
||||||
|
/// <returns>The populated <see cref="MTConnectTagConfigModel"/>.</returns>
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Serialises this model back to a TagConfig JSON string over the preserved key bag.
|
||||||
|
/// <c>dataType</c> 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.</summary>
|
||||||
|
/// <returns>The serialised TagConfig JSON string.</returns>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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 the model is valid.</returns>
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@
|
|||||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts.csproj"/>
|
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts.csproj"/>
|
||||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj"/>
|
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj"/>
|
||||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts.csproj"/>
|
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts.csproj"/>
|
||||||
|
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts.csproj"/>
|
||||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj"/>
|
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj"/>
|
||||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.csproj"/>
|
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.csproj"/>
|
||||||
<!-- Sql schema-browse (IDriverBrowser, SqlBrowseNodeId codec, SqlEquipmentTagParser). Pulls
|
<!-- Sql schema-browse (IDriverBrowser, SqlBrowseNodeId codec, SqlEquipmentTagParser). Pulls
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||||
|
|
||||||
|
public sealed class MTConnectTagConfigModelTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Roundtrip_preserves_unknown_keys_and_writes_enum_as_name()
|
||||||
|
{
|
||||||
|
var json = "{\"fullName\":\"dev1_pos\",\"dataType\":\"Float64\",\"somethingUnknown\":42}";
|
||||||
|
var m = MTConnectTagConfigModel.FromJson(json);
|
||||||
|
var outJson = m.ToJson();
|
||||||
|
outJson.ShouldContain("\"somethingUnknown\":42");
|
||||||
|
outJson.ShouldContain("\"dataType\":\"Float64\""); // name, never a number
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_blocks_blank_fullname()
|
||||||
|
=> MTConnectTagConfigModel.FromJson("{}").Validate().ShouldNotBeNull();
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
[InlineData("{}")]
|
||||||
|
public void FromJson_returns_defaults_for_empty_input(string? json)
|
||||||
|
{
|
||||||
|
var m = MTConnectTagConfigModel.FromJson(json);
|
||||||
|
|
||||||
|
m.FullName.ShouldBe("");
|
||||||
|
m.DataType.ShouldBe(DriverDataType.String);
|
||||||
|
m.MtCategory.ShouldBeNull();
|
||||||
|
m.MtType.ShouldBeNull();
|
||||||
|
m.MtSubType.ShouldBeNull();
|
||||||
|
m.Units.ShouldBeNull();
|
||||||
|
m.MtDevice.ShouldBeNull();
|
||||||
|
m.MtComponent.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
public void Validate_blocks_missing_and_whitespace_fullname(string? fullName)
|
||||||
|
{
|
||||||
|
var m = new MTConnectTagConfigModel { FullName = fullName! };
|
||||||
|
m.Validate().ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_returns_null_for_a_valid_model()
|
||||||
|
{
|
||||||
|
var m = new MTConnectTagConfigModel { FullName = "dev1_pos", DataType = DriverDataType.Float64 };
|
||||||
|
m.Validate().ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToJson_emits_camelCase_keys_with_enum_name_and_all_fields()
|
||||||
|
{
|
||||||
|
var m = new MTConnectTagConfigModel
|
||||||
|
{
|
||||||
|
FullName = "dev1_partcount",
|
||||||
|
DataType = DriverDataType.Int64,
|
||||||
|
MtCategory = "EVENT",
|
||||||
|
MtType = "PART_COUNT",
|
||||||
|
MtSubType = "ACTUAL",
|
||||||
|
Units = "COUNT",
|
||||||
|
MtDevice = "Mill-01",
|
||||||
|
MtComponent = "Controller",
|
||||||
|
};
|
||||||
|
|
||||||
|
var json = m.ToJson();
|
||||||
|
|
||||||
|
json.ShouldContain("\"fullName\":\"dev1_partcount\"");
|
||||||
|
json.ShouldContain("\"dataType\":\"Int64\"");
|
||||||
|
json.ShouldContain("\"mtCategory\":\"EVENT\"");
|
||||||
|
json.ShouldContain("\"mtType\":\"PART_COUNT\"");
|
||||||
|
json.ShouldContain("\"mtSubType\":\"ACTUAL\"");
|
||||||
|
json.ShouldContain("\"units\":\"COUNT\"");
|
||||||
|
json.ShouldContain("\"mtDevice\":\"Mill-01\"");
|
||||||
|
json.ShouldContain("\"mtComponent\":\"Controller\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToJson_never_emits_a_bare_numeric_dataType()
|
||||||
|
{
|
||||||
|
foreach (DriverDataType value in Enum.GetValues<DriverDataType>())
|
||||||
|
{
|
||||||
|
var json = new MTConnectTagConfigModel { FullName = "x", DataType = value }.ToJson();
|
||||||
|
|
||||||
|
// The enum-serialization trap: "dataType":8 would fault the driver at deploy (string-typed
|
||||||
|
// factory DTOs). Assert the value after the key is a quoted name, not a bare digit.
|
||||||
|
json.ShouldNotContain($"\"dataType\":{(int)value}");
|
||||||
|
json.ShouldContain($"\"dataType\":\"{value}\"");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FromJson_then_ToJson_preserves_unknown_keys_including_nested_object_and_isHistorized()
|
||||||
|
{
|
||||||
|
var json = """
|
||||||
|
{
|
||||||
|
"fullName": "dev1_pos",
|
||||||
|
"dataType": "Float64",
|
||||||
|
"isHistorized": true,
|
||||||
|
"historianTagname": "Mill01.Position",
|
||||||
|
"nested": { "a": 1, "b": [1, 2, 3] }
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var roundTripped = MTConnectTagConfigModel.FromJson(json).ToJson();
|
||||||
|
|
||||||
|
roundTripped.ShouldContain("\"isHistorized\":true");
|
||||||
|
roundTripped.ShouldContain("\"historianTagname\":\"Mill01.Position\"");
|
||||||
|
roundTripped.ShouldContain("\"nested\":{\"a\":1,\"b\":[1,2,3]}");
|
||||||
|
// and the modelled fields still round-trip alongside the preserved keys
|
||||||
|
roundTripped.ShouldContain("\"fullName\":\"dev1_pos\"");
|
||||||
|
roundTripped.ShouldContain("\"dataType\":\"Float64\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FromJson_of_malformed_json_falls_back_to_defaults_rather_than_throwing()
|
||||||
|
{
|
||||||
|
var m = MTConnectTagConfigModel.FromJson("{not valid json");
|
||||||
|
|
||||||
|
m.FullName.ShouldBe("");
|
||||||
|
m.DataType.ShouldBe(DriverDataType.String);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Round_trip_is_stable_for_a_fully_populated_model()
|
||||||
|
{
|
||||||
|
var m = new MTConnectTagConfigModel
|
||||||
|
{
|
||||||
|
FullName = "dev1_feedrate",
|
||||||
|
DataType = DriverDataType.Float64,
|
||||||
|
MtCategory = "SAMPLE",
|
||||||
|
MtType = "PATH_FEEDRATE",
|
||||||
|
MtSubType = "PROGRAMMED",
|
||||||
|
Units = "MILLIMETER/SECOND",
|
||||||
|
MtDevice = "Mill-01",
|
||||||
|
MtComponent = "Path",
|
||||||
|
};
|
||||||
|
|
||||||
|
var m2 = MTConnectTagConfigModel.FromJson(m.ToJson());
|
||||||
|
|
||||||
|
m2.FullName.ShouldBe(m.FullName);
|
||||||
|
m2.DataType.ShouldBe(m.DataType);
|
||||||
|
m2.MtCategory.ShouldBe(m.MtCategory);
|
||||||
|
m2.MtType.ShouldBe(m.MtType);
|
||||||
|
m2.MtSubType.ShouldBe(m.MtSubType);
|
||||||
|
m2.Units.ShouldBe(m.Units);
|
||||||
|
m2.MtDevice.ShouldBe(m.MtDevice);
|
||||||
|
m2.MtComponent.ShouldBe(m.MtComponent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Optional_metadata_keys_are_omitted_when_blank_rather_than_persisted_empty()
|
||||||
|
{
|
||||||
|
var json = new MTConnectTagConfigModel { FullName = "dev1_pos", MtCategory = " " }.ToJson();
|
||||||
|
|
||||||
|
json.ShouldNotContain("mtCategory");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_rejects_a_dataType_that_was_stored_as_a_bare_number()
|
||||||
|
{
|
||||||
|
// Enum.TryParse (which TagConfigJson.GetEnum uses under the hood) silently accepts numeric text,
|
||||||
|
// so a config that was ever hand-authored/migrated with a quoted-number dataType would otherwise
|
||||||
|
// load into a plausible-looking enum value with no signal. Guard it explicitly.
|
||||||
|
var m = MTConnectTagConfigModel.FromJson("{\"fullName\":\"dev1_pos\",\"dataType\":\"8\"}");
|
||||||
|
|
||||||
|
m.DataType.ShouldBe(DriverDataType.Float64); // 8 == DriverDataType.Float64 — parsed "successfully"
|
||||||
|
m.Validate().ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user