fix(adminui): MTConnect editor must read driverDataType at the runtime's precedence and drop the alias on save (#550)

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.
This commit is contained in:
Joseph Doherty
2026-08-09 23:44:14 -04:00
parent b0d2fa30e3
commit 8971861b7c
4 changed files with 267 additions and 25 deletions
@@ -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.
/// </para>
/// <para>
/// <b>The alias out-ranks the canonical key, and the editor mirrors that</b> (Gitea #550).
/// <c>MTConnectTagConfigModel</c> used to read <c>dataType</c> alone, so a blob carrying a
/// valid <c>driverDataType</c> 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
/// <c>dataType</c> on save, dropping the alias, so a tag edited through the editor keeps
/// the type this parse read for it.
/// </para>
/// <para>
/// Kept separate from the driver's strict <c>tags[]</c> DTO rather than reusing it: there a
/// missing <c>fullName</c> 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.
@@ -35,7 +35,9 @@ public sealed class MTConnectTagConfigModel
/// <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>
/// that can fail to parse (mirrors <c>MTConnectDataTypeInference</c>'s own String-leaning default).
/// Loaded from <c>driverDataType</c> or <c>dataType</c> in the driver's own precedence (see
/// <see cref="DataTypeKeys"/>); always saved as <c>dataType</c>, with the alias dropped.</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>
@@ -58,19 +60,25 @@ public sealed class MTConnectTagConfigModel
/// consumed by the driver or the runtime.</summary>
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();
/// <summary>
@@ -85,6 +93,34 @@ public sealed class MTConnectTagConfigModel
/// </summary>
private static readonly string[] IdKeys = ["fullName", "dataItemId", "address"];
/// <summary>
/// The accepted spellings of the coercion type, <b>in the runtime's precedence order</b>, mirroring
/// <c>MTConnectTagDefinitionFactory.TryFromTagConfig</c>'s
/// <c>FirstNonBlank(blob.DriverDataType, blob.DataType)</c>: <c>driverDataType</c> (the driver's own
/// <c>tags[]</c> field name, and the spelling a hand-authored or driver-shaped blob carries) wins
/// over <c>dataType</c> (this model's canonical key).
/// <para>
/// Reading only <c>dataType</c> was Gitea <b>#550</b>, and it broke in both directions at once: a
/// blob carrying <c>"driverDataType": "Strng"</c> sailed through authoring while the runtime
/// rejected the tag (the residual slice of #549), and — worse — a VALID <c>driverDataType</c>
/// silently overrode whatever type this editor displayed and saved, so the editor showed a lie.
/// </para>
/// <para>
/// <see cref="ToJson"/> therefore normalises onto <c>dataType</c> and DROPS the alias, exactly as
/// it does for <see cref="IdKeys"/>. 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 <c>driverDataType</c> tag to whatever the
/// lenient <c>dataType</c> read defaulted to on its next save.
/// </para>
/// </summary>
private static readonly string[] DataTypeKeys = ["driverDataType", CanonicalDataTypeKey];
/// <summary>The spelling of the coercion type this model writes — the LAST of <see cref="DataTypeKeys"/>,
/// i.e. the one the driver reads only when no alias out-ranks it. <see cref="ToJson"/> 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.</summary>
private const string CanonicalDataTypeKey = "dataType";
/// <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>
@@ -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<DriverDataType>())}.";
return $"{_rawDataTypeKey} \"{_rawDataType}\" is not a valid DriverDataType name — re-select a "
+ $"data type. Valid: {string.Join(", ", Enum.GetNames<DriverDataType>())}.";
}
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<string>(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<string>(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<DriverDataType>(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;
@@ -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.
// ---------------------------------------------------------------------------------------------
/// <summary>
/// The retype half of #550. <c>driverDataType</c> out-ranks <c>dataType</c> at the driver
/// (<c>FirstNonBlank(blob.DriverDataType, blob.DataType)</c>), so this blob reads as
/// <c>Int32</c> at the runtime while the editor used to show — and save — <c>String</c>. 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.
/// </summary>
[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");
}
/// <summary>
/// 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 <c>dataItemId</c>/<c>address</c> are.
/// </summary>
[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);
}
/// <summary>A present-but-blank alias declares nothing — the runtime's <c>FirstNonBlank</c> falls
/// through to <c>dataType</c>, so this load must too rather than reading the blank as "String".</summary>
[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();
}
/// <summary>
/// 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
/// <c>dataType</c> alongside it does not rescue the blob at the runtime and must not here either.
/// </summary>
[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");
/// <summary>A NON-string alias token throws <c>JsonException</c> in the runtime's deserialise and takes
/// the whole tag down, so it can never fall through to a valid <c>dataType</c>.</summary>
[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");
}
@@ -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.
/// </para>
/// <para>
/// <b>One narrow slice of the reverse direction IS asserted</b>, 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 <c>BadNodeIdUnknown</c> (Gitea #549, and its MTConnect <c>driverDataType</c> 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.
/// </para>
/// </summary>
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.
// -------------------------------------------------------------------------------------------------
/// <summary>
/// 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).
/// </summary>
[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);
}
/// <summary>
/// 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 <c>dataType</c> — it is not rescued — and a non-string alias token throws in the
/// runtime's deserialise and takes the whole tag down.
/// </summary>
[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();
}
/// <summary>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.</summary>
/// <param name="driverType">The canonical driver-type string both gates dispatch on.</param>