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:
Joseph Doherty
2026-07-24 17:56:22 -04:00
parent c232a3ce67
commit b0046d6959
3 changed files with 310 additions and 0 deletions
@@ -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();
}
}