From 8971861b7c56a2b1ac347f4fa80bd4883dbb3b76 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 9 Aug 2026 23:44:14 -0400 Subject: [PATCH] fix(adminui): MTConnect editor must read driverDataType at the runtime's precedence and drop the alias on save (#550) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MTConnectTagDefinitionFactory.TryFromTagConfig resolves the coercion type as FirstNonBlank(blob.DriverDataType, blob.DataType) — driverDataType OUT-RANKS dataType — while MTConnectTagConfigModel read and wrote dataType alone. That broke in both directions at once: * a blob carrying "driverDataType": "Strng" was accepted at authoring while the runtime rejected the tag (the residual slice of #549), and * a VALID driverDataType silently overrode whatever type the editor displayed and saved, so the editor showed a lie and a save RETYPED the tag. Both halves land together, mirroring the existing IdKeys pattern: * FromJson reads DataTypeKeys = [driverDataType, dataType] in the runtime's precedence, with the runtime's own per-key semantics — absent/JSON-null and present-but-blank fall through, a non-string token stops the walk (the runtime deserialise throws JsonException on it and rejects the whole tag), and the raw TOKEN is captured so Validate() judges what the driver judges. * ToJson drops every non-canonical DataTypeKeys entry and writes dataType as the enum NAME, so the alias can never out-rank the key the editor writes on the next load. * Validate() now names the offending spelling rather than always saying "dataType". Doing only one half silently retypes existing tags on their next save; the falsification runs recorded in the PR confirm each half-fix reddens a distinct test. --- .../MTConnectTagDefinitionFactory.cs | 13 +- .../Uns/TagEditors/MTConnectTagConfigModel.cs | 119 ++++++++++++++---- .../Uns/MTConnectTagConfigModelTests.cs | 77 ++++++++++++ .../TagConfigAuthoringDeployParityTests.cs | 83 ++++++++++++ 4 files changed, 267 insertions(+), 25 deletions(-) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectTagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectTagDefinitionFactory.cs index 02b843f3..04f5104e 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectTagDefinitionFactory.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectTagDefinitionFactory.cs @@ -74,7 +74,9 @@ public static class MTConnectTagDefinitionFactory dataItemId = id; // "driverDataType" (the driver's own tags[] field) or "dataType" (what the AdminUI's typed - // MTConnectTagConfigModel writes) — see the DTO remarks. + // MTConnectTagConfigModel writes) — see the DTO remarks. THIS PRECEDENCE IS MIRRORED by that + // model's own load (Gitea #550): a non-blank driverDataType out-ranks dataType there too, so the + // editor displays the type the driver will actually read rather than a key it is overriding. var authoredType = FirstNonBlank(blob.DriverDataType, blob.DataType); if (authoredType is null) return true; @@ -230,6 +232,15 @@ public static class MTConnectTagDefinitionFactory /// and the driver disagree about the type of every tag. /// /// + /// The alias out-ranks the canonical key, and the editor mirrors that (Gitea #550). + /// MTConnectTagConfigModel used to read dataType alone, so a blob carrying a + /// valid driverDataType was displayed and saved under a type the driver then + /// overrode — the editor showed a lie — while an INVALID one sailed through authoring and + /// was rejected here. It now reads both spellings in this precedence and normalises onto + /// dataType on save, dropping the alias, so a tag edited through the editor keeps + /// the type this parse read for it. + /// + /// /// Kept separate from the driver's strict tags[] DTO rather than reusing it: there a /// missing fullName is a config error worth failing the deployment over. A raw tag's /// blob is authored elsewhere and its failure mode is per-tag, not per-driver. 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 index c2679e46..2e83e343 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/MTConnectTagConfigModel.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/MTConnectTagConfigModel.cs @@ -35,7 +35,9 @@ public sealed class MTConnectTagConfigModel /// 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). + /// that can fail to parse (mirrors MTConnectDataTypeInference's own String-leaning default). + /// Loaded from driverDataType or dataType in the driver's own precedence (see + /// ); always saved as dataType, with the alias dropped. public DriverDataType DataType { get; set; } = DriverDataType.String; /// The DataItem's probe-sourced category (SAMPLE/EVENT/CONDITION). Read-only display metadata. @@ -58,19 +60,25 @@ public sealed class MTConnectTagConfigModel /// consumed by the driver or the runtime. public string? MtComponent { get; set; } - // The as-loaded raw "dataType" JSON token, kept so Validate() below can put it to the DRIVER's own - // accept/reject rule (MTConnectTagDefinitionFactory.IsAcceptedDataTypeName) rather than to the - // lenient load. TagConfigJson.GetEnum falls back to String on ANY unrecognised value, so without this - // a misspelled name ("Strng") or a quoted number ("8" -> Float64, the ParseEnum numeric-text trap) - // loads as a *valid-looking* enum value with no signal that it is not what the operator wrote — while - // the strict runtime rejects the tag outright (Gitea #549). Captured as the raw TOKEN, not via - // GetString: the runtime deserialises this key into a `string?` DTO field, so a NON-string token - // (`"dataType": 8`) throws JsonException there and rejects the tag too, where GetString would report - // it as simply absent. ToJson() always re-writes dataType as a name, so this can only ever be - // poisoned on the FIRST load of a blob authored elsewhere (hand-edited, CSV import, API), never after - // a save through this model. + // The as-loaded raw coercion-type JSON token — read under the DRIVER's precedence (see DataTypeKeys) + // — kept so Validate() below can put it to the DRIVER's own accept/reject rule + // (MTConnectTagDefinitionFactory.IsAcceptedDataTypeName) rather than to the lenient load. A plain + // Enum.TryParse falls back to String on ANY unrecognised value, so without this a misspelled name + // ("Strng") or a quoted number ("8" -> Float64, the ParseEnum numeric-text trap) loads as a + // *valid-looking* enum value with no signal that it is not what the operator wrote — while the strict + // runtime rejects the tag outright (Gitea #549). Captured as the raw TOKEN, not via GetString: the + // runtime deserialises both spellings into `string?` DTO fields, so a NON-string token + // (`"dataType": 8`, `"driverDataType": 8`) throws JsonException there and rejects the tag too, where + // GetString would report it as simply absent. ToJson() always re-writes dataType as a name and drops + // the alias, so this can only ever be poisoned on the FIRST load of a blob authored elsewhere + // (hand-edited, CSV import, API), never after a save through this model. private string? _rawDataType; + // Which of DataTypeKeys supplied _rawDataType, so a rejection names the spelling the operator actually + // wrote. Naming "dataType" for a blob that carries "driverDataType" sends them to a key that is not + // there — the runtime's own Inspect() names the offending field for the same reason. + private string _rawDataTypeKey = CanonicalDataTypeKey; + private JsonObject _bag = new(); /// @@ -85,6 +93,34 @@ public sealed class MTConnectTagConfigModel /// private static readonly string[] IdKeys = ["fullName", "dataItemId", "address"]; + /// + /// The accepted spellings of the coercion type, in the runtime's precedence order, mirroring + /// MTConnectTagDefinitionFactory.TryFromTagConfig's + /// FirstNonBlank(blob.DriverDataType, blob.DataType): driverDataType (the driver's own + /// tags[] field name, and the spelling a hand-authored or driver-shaped blob carries) wins + /// over dataType (this model's canonical key). + /// + /// Reading only dataType was Gitea #550, and it broke in both directions at once: a + /// blob carrying "driverDataType": "Strng" sailed through authoring while the runtime + /// rejected the tag (the residual slice of #549), and — worse — a VALID driverDataType + /// silently overrode whatever type this editor displayed and saved, so the editor showed a lie. + /// + /// + /// therefore normalises onto dataType and DROPS the alias, exactly as + /// it does for . Both halves are load-bearing together: the precedence read + /// alone would leave the alias in the bag to out-rank the key the editor writes on the next load, + /// and the drop alone would retype every existing driverDataType tag to whatever the + /// lenient dataType read defaulted to on its next save. + /// + /// + private static readonly string[] DataTypeKeys = ["driverDataType", CanonicalDataTypeKey]; + + /// The spelling of the coercion type this model writes — the LAST of , + /// i.e. the one the driver reads only when no alias out-ranks it. drops every other + /// entry of that array rather than naming the alias a second time, so adding a third spelling to the + /// read cannot leave the normalisation behind. + private const string CanonicalDataTypeKey = "dataType"; + /// 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. @@ -92,17 +128,19 @@ public sealed class MTConnectTagConfigModel public static MTConnectTagConfigModel FromJson(string? json) { var o = TagConfigJson.ParseOrNew(json); + var (rawDataTypeKey, rawDataType) = ReadRawDataType(o); return new MTConnectTagConfigModel { FullName = ReadId(o), - DataType = TagConfigJson.GetEnum(o, "dataType", DriverDataType.String), + DataType = ParseDataType(rawDataType), 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 = ReadRawDataType(o), + _rawDataType = rawDataType, + _rawDataTypeKey = rawDataTypeKey, _bag = o, }; } @@ -115,11 +153,19 @@ public sealed class MTConnectTagConfigModel public string ToJson() { // Normalise onto the canonical key: a blob loaded via an alias must not keep the alias, or the - // two spellings drift on the next edit and the driver silently reads the stale one. + // two spellings drift on the next edit and the driver silently reads the stale one. This holds + // for the coercion type (driverDataType, Gitea #550) exactly as it does for the id spellings — + // and because driverDataType OUT-RANKS dataType at the driver, leaving it behind would mean the + // type the operator just picked never reached the runtime at all. TagConfigJson.Set(_bag, "dataItemId", null); TagConfigJson.Set(_bag, "address", null); TagConfigJson.Set(_bag, "fullName", FullName.Trim()); - TagConfigJson.Set(_bag, "dataType", DataType); + foreach (var alias in DataTypeKeys) + { + if (alias != CanonicalDataTypeKey) { TagConfigJson.Set(_bag, alias, null); } + } + + TagConfigJson.Set(_bag, CanonicalDataTypeKey, DataType); TagConfigJson.Set(_bag, "mtCategory", Blank(MtCategory)); TagConfigJson.Set(_bag, "mtType", Blank(MtType)); TagConfigJson.Set(_bag, "mtSubType", Blank(MtSubType)); @@ -151,22 +197,47 @@ public sealed class MTConnectTagConfigModel // DataType above, because the CSV-import and API paths save the blob verbatim. if (!MTConnectTagDefinitionFactory.IsAcceptedDataTypeName(_rawDataType)) { - return $"dataType \"{_rawDataType}\" is not a valid DriverDataType name — re-select a data type. " - + $"Valid: {string.Join(", ", Enum.GetNames())}."; + return $"{_rawDataTypeKey} \"{_rawDataType}\" is not a valid DriverDataType name — re-select a " + + $"data type. Valid: {string.Join(", ", Enum.GetNames())}."; } return null; } - // The as-loaded "dataType" TOKEN as text (see the _rawDataType remarks): a JSON string yields its - // value, any other present token yields its JSON text so Validate() judges what was actually written, - // and absent / JSON null yields null — the one shape the runtime treats as "no type declared". - private static string? ReadRawDataType(JsonObject o) + // The as-loaded coercion-type TOKEN as text (see the _rawDataType and DataTypeKeys remarks), read in + // the RUNTIME's precedence so this model judges — and displays — the token the driver would actually + // read. Per key, mirroring FirstNonBlank(blob.DriverDataType, blob.DataType) over a DTO whose two + // fields are `string?`: + // · absent / JSON null → the field deserialises to null, which FirstNonBlank skips: try the next. + // · blank or whitespace → likewise blank to FirstNonBlank: try the next. A present-but-blank + // driverDataType must NOT shadow a populated dataType. + // · any non-string token → STOP. The runtime deserialise throws JsonException on it and rejects + // the whole tag, so it can never fall through to the other spelling; + // yield its JSON text so Validate() refuses exactly what the driver does. + // · non-blank string → STOP, this is the effective token. + // Nothing matched ⇒ null, the one shape the runtime treats as "no type declared" (it then falls back + // to the Agent's /probe declaration rather than rejecting the tag). + private static (string Key, string? Value) ReadRawDataType(JsonObject o) { - if (!o.TryGetPropertyValue("dataType", out var node) || node is null) { return null; } - return node is JsonValue v && v.TryGetValue(out var s) ? s : node.ToJsonString(); + foreach (var key in DataTypeKeys) + { + if (!o.TryGetPropertyValue(key, out var node) || node is null) { continue; } + if (node is not JsonValue v || !v.TryGetValue(out var s)) { return (key, node.ToJsonString()); } + if (string.IsNullOrWhiteSpace(s)) { continue; } + return (key, s); + } + + return (CanonicalDataTypeKey, null); } + // The lenient load of the effective token (see ReadRawDataType) for DISPLAY: an unrecognised value + // falls back to String so a poisoned blob still opens and can be fixed. Validate() — not this — is + // what refuses it, against the driver's own predicate. + private static DriverDataType ParseDataType(string? raw) + => raw is not null && Enum.TryParse(raw.Trim(), ignoreCase: true, out var v) + ? v + : DriverDataType.String; + // 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/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/MTConnectTagConfigModelTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/MTConnectTagConfigModelTests.cs index 9105f15e..8374ebe5 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/MTConnectTagConfigModelTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/MTConnectTagConfigModelTests.cs @@ -218,4 +218,81 @@ public sealed class MTConnectTagConfigModelTests public void Validate_rejects_a_dataType_written_as_an_unquoted_number() => MTConnectTagConfigModel.FromJson("""{"fullName":"dev1_pos","dataType":8}""") .Validate().ShouldNotBeNull().ShouldContain("8"); + + // --------------------------------------------------------------------------------------------- + // Gitea #550 — driverDataType is a SECOND spelling of the coercion type, and the runtime prefers it. + // --------------------------------------------------------------------------------------------- + + /// + /// The retype half of #550. driverDataType out-ranks dataType at the driver + /// (FirstNonBlank(blob.DriverDataType, blob.DataType)), so this blob reads as + /// Int32 at the runtime while the editor used to show — and save — String. Opening + /// and saving the tag therefore RETYPED it, silently, without the operator touching the control. + /// The load must show what the driver reads, and the save must keep it. + /// + [Fact] + public void A_driverDataType_out_ranks_dataType_on_load_and_survives_the_save() + { + var m = MTConnectTagConfigModel.FromJson( + """{"driverDataType":"Int32","dataType":"String","fullName":"x"}"""); + + m.DataType.ShouldBe(DriverDataType.Int32); + + var saved = m.ToJson(); + saved.ShouldContain("\"dataType\":\"Int32\""); + saved.ShouldNotContain("driverDataType"); + } + + /// + /// The normalisation half. Leaving the alias in the bag would let it out-rank the key this editor + /// writes on the very next load, so every later type change would be silently ignored by the + /// driver — the alias must be DROPPED, exactly as dataItemId/address are. + /// + [Fact] + public void ToJson_never_emits_the_driverDataType_alias() + { + var m = MTConnectTagConfigModel.FromJson("""{"fullName":"x","driverDataType":"Float64"}"""); + m.DataType = DriverDataType.Int16; + + var saved = m.ToJson(); + + saved.ShouldNotContain("driverDataType"); + saved.ShouldContain("\"dataType\":\"Int16\""); + // …and a reload of the saved blob agrees, which is what "the two spellings cannot drift" means. + MTConnectTagConfigModel.FromJson(saved).DataType.ShouldBe(DriverDataType.Int16); + } + + /// A present-but-blank alias declares nothing — the runtime's FirstNonBlank falls + /// through to dataType, so this load must too rather than reading the blank as "String". + [Theory] + [InlineData("""{"fullName":"x","driverDataType":"","dataType":"Int32"}""")] + [InlineData("""{"fullName":"x","driverDataType":" ","dataType":"Int32"}""")] + [InlineData("""{"fullName":"x","driverDataType":null,"dataType":"Int32"}""")] + public void A_blank_driverDataType_falls_through_to_dataType(string json) + { + var m = MTConnectTagConfigModel.FromJson(json); + + m.DataType.ShouldBe(DriverDataType.Int32); + m.Validate().ShouldBeNull(); + } + + /// + /// The residual #549 slice: a misspelled alias was accepted at authoring because nothing read the + /// alias, while the driver rejected the tag outright. It is non-blank, so it WINS — a valid + /// dataType alongside it does not rescue the blob at the runtime and must not here either. + /// + [Theory] + [InlineData("""{"driverDataType":"Strng","fullName":"x"}""")] + [InlineData("""{"driverDataType":"Strng","dataType":"Int32","fullName":"x"}""")] + [InlineData("""{"driverDataType":"8","fullName":"x"}""")] + public void Validate_rejects_a_misspelled_driverDataType_even_when_dataType_is_valid(string json) + => MTConnectTagConfigModel.FromJson(json).Validate().ShouldNotBeNull().ShouldContain("driverDataType"); + + /// A NON-string alias token throws JsonException in the runtime's deserialise and takes + /// the whole tag down, so it can never fall through to a valid dataType. + [Theory] + [InlineData("""{"fullName":"x","driverDataType":8}""")] + [InlineData("""{"fullName":"x","driverDataType":8,"dataType":"Int32"}""")] + public void Validate_rejects_a_driverDataType_written_as_an_unquoted_number(string json) + => MTConnectTagConfigModel.FromJson(json).Validate().ShouldNotBeNull().ShouldContain("8"); } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigAuthoringDeployParityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigAuthoringDeployParityTests.cs index 276c5992..095234bb 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigAuthoringDeployParityTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigAuthoringDeployParityTests.cs @@ -3,6 +3,7 @@ using Xunit; using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.MTConnect; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; @@ -25,6 +26,14 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; /// lives in AdminUI.Tests because it is the only suite that can see both sides — the models are /// internal to the AdminUI project, which references ControlPlane. /// +/// +/// One narrow slice of the reverse direction IS asserted, and the caveat above does not cover +/// it: where the RUNTIME rejects a TagConfig outright — a present-but-invalid enum spelling — the +/// modal must refuse it too, or config authored by CSV import or API reaches a driver that skips the +/// tag with BadNodeIdUnknown (Gitea #549, and its MTConnect driverDataType residual +/// #550). That is the opposite of the editor being merely opinionated: it is the editor being WEAKER +/// than the parser, which is never legitimate. +/// /// public sealed class TagConfigAuthoringDeployParityTests { @@ -194,6 +203,80 @@ public sealed class TagConfigAuthoringDeployParityTests + "import or API is unchecked — the exact G-7 asymmetry."); } + // ------------------------------------------------------------------------------------------------- + // Gitea #550 — MTConnect's second spelling of the coercion type. The assertions below drive the REAL + // runtime reader (MTConnectTagDefinitionFactory.TryFromTagConfig) rather than the deploy gate alone, + // because the defect is about which VALUE the runtime resolves, not merely whether it accepts. + // ------------------------------------------------------------------------------------------------- + + /// + /// Authoring accepts ⇔ the runtime accepts, across every mixed shape of the two spellings — and the + /// blob the editor SAVES resolves to the same type the runtime read from the original (no retype). + /// + [Theory] + // driverDataType alone — the driver's own tags[] spelling, and what a hand-authored blob carries. + [InlineData("""{"fullName":"x","driverDataType":"Int32"}""", DriverDataType.Int32)] + // dataType alone — what this editor writes. + [InlineData("""{"fullName":"x","dataType":"Int32"}""", DriverDataType.Int32)] + // Both, agreeing — the shape a #550-era save produced when the operator happened to pick the same type. + [InlineData("""{"fullName":"x","driverDataType":"Int32","dataType":"Int32"}""", DriverDataType.Int32)] + // Both, DISAGREEING — the alias wins at the driver, so it must win here. + [InlineData("""{"driverDataType":"Int32","dataType":"String","fullName":"x"}""", DriverDataType.Int32)] + // A present-but-blank alias declares nothing and falls through (FirstNonBlank). + [InlineData("""{"fullName":"x","driverDataType":"","dataType":"Float64"}""", DriverDataType.Float64)] + [InlineData("""{"fullName":"x","driverDataType":" ","dataType":"Float64"}""", DriverDataType.Float64)] + [InlineData("""{"fullName":"x","driverDataType":null,"dataType":"Float64"}""", DriverDataType.Float64)] + // Casing and surrounding whitespace are the runtime's business, and it accepts both. + [InlineData("""{"fullName":"x","driverDataType":" float64 "}""", DriverDataType.Float64)] + public void MTConnect_dataType_spellings_the_runtime_accepts_are_authorable_and_never_retyped( + string tagConfig, DriverDataType expected) + { + // The runtime's verdict on the ORIGINAL blob, which is the thing that must be preserved. + MTConnectTagDefinitionFactory.TryFromTagConfig(tagConfig, out _, out var authored).ShouldBeTrue( + "the runtime must accept this blob, or the authoring parity below proves nothing."); + authored.ShouldNotBeNull().DriverDataType.ShouldBe(expected); + + // Authoring agrees: it neither blocks the save nor draws a deploy warning. + var model = MTConnectTagConfigModel.FromJson(tagConfig); + model.Validate().ShouldBeNull("the editor refused a blob the runtime serves."); + TagConfigValidator.Validate(DriverTypeNames.MTConnect, tagConfig).ShouldBeNull(); + EquipmentTagConfigInspector.Inspect(DriverTypeNames.MTConnect, tagConfig).ShouldBeEmpty(); + + // …and it DISPLAYS the type the runtime read, not the key the runtime is overriding. + model.DataType.ShouldBe(expected); + + // The no-retype invariant: one load→save leaves a blob the runtime resolves identically. + var saved = model.ToJson(); + saved.Contains("driverDataType", StringComparison.OrdinalIgnoreCase).ShouldBeFalse( + "the alias must be dropped on save, or it out-ranks the key the editor writes on the next load."); + MTConnectTagDefinitionFactory.TryFromTagConfig(saved, out _, out var afterSave).ShouldBeTrue(); + afterSave.ShouldNotBeNull().DriverDataType.ShouldBe(expected); + } + + /// + /// The falsification direction, and the residual slice of #549 that #550 names: every blob here is + /// REJECTED by the runtime, so authoring must refuse it too. A non-blank but invalid alias wins over + /// a perfectly valid dataType — it is not rescued — and a non-string alias token throws in the + /// runtime's deserialise and takes the whole tag down. + /// + [Theory] + [InlineData("""{"driverDataType":"Strng","fullName":"x"}""")] + [InlineData("""{"driverDataType":"Strng","dataType":"Int32","fullName":"x"}""")] + [InlineData("""{"fullName":"x","driverDataType":"8"}""")] + [InlineData("""{"fullName":"x","driverDataType":8}""")] + [InlineData("""{"fullName":"x","driverDataType":8,"dataType":"Int32"}""")] + // A blank alias falls through — onto a dataType that is itself unreadable. + [InlineData("""{"fullName":"x","driverDataType":"","dataType":"Strng"}""")] + public void MTConnect_dataType_spellings_the_runtime_rejects_are_refused_at_authoring(string tagConfig) + { + MTConnectTagDefinitionFactory.TryFromTagConfig(tagConfig, out _, out _).ShouldBeFalse( + "the runtime must reject this blob, or this control is testing the wrong thing."); + + TagConfigValidator.Validate(DriverTypeNames.MTConnect, tagConfig).ShouldNotBeNullOrEmpty( + "the modal would let an operator save a blob the driver skips with BadNodeIdUnknown."); + EquipmentTagConfigInspector.Inspect(DriverTypeNames.MTConnect, tagConfig).ShouldNotBeEmpty(); + } + /// Asserts the editor accepted the config (so the parity claim is not vacuous) and that the /// deploy gate then found nothing to say about it. /// The canonical driver-type string both gates dispatch on.