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:
+12
-1
@@ -74,7 +74,9 @@ public static class MTConnectTagDefinitionFactory
|
|||||||
dataItemId = id;
|
dataItemId = id;
|
||||||
|
|
||||||
// "driverDataType" (the driver's own tags[] field) or "dataType" (what the AdminUI's typed
|
// "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);
|
var authoredType = FirstNonBlank(blob.DriverDataType, blob.DataType);
|
||||||
if (authoredType is null) return true;
|
if (authoredType is null) return true;
|
||||||
|
|
||||||
@@ -230,6 +232,15 @@ public static class MTConnectTagDefinitionFactory
|
|||||||
/// and the driver disagree about the type of every tag.
|
/// and the driver disagree about the type of every tag.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <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
|
/// 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
|
/// 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.
|
/// 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"/>
|
/// <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
|
/// — 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;
|
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>
|
/// <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>
|
/// consumed by the driver or the runtime.</summary>
|
||||||
public string? MtComponent { get; set; }
|
public string? MtComponent { get; set; }
|
||||||
|
|
||||||
// The as-loaded raw "dataType" JSON token, kept so Validate() below can put it to the DRIVER's own
|
// The as-loaded raw coercion-type JSON token — read under the DRIVER's precedence (see DataTypeKeys)
|
||||||
// accept/reject rule (MTConnectTagDefinitionFactory.IsAcceptedDataTypeName) rather than to the
|
// — kept so Validate() below can put it to the DRIVER's own accept/reject rule
|
||||||
// lenient load. TagConfigJson.GetEnum falls back to String on ANY unrecognised value, so without this
|
// (MTConnectTagDefinitionFactory.IsAcceptedDataTypeName) rather than to the lenient load. A plain
|
||||||
// a misspelled name ("Strng") or a quoted number ("8" -> Float64, the ParseEnum numeric-text trap)
|
// Enum.TryParse falls back to String on ANY unrecognised value, so without this a misspelled name
|
||||||
// loads as a *valid-looking* enum value with no signal that it is not what the operator wrote — while
|
// ("Strng") or a quoted number ("8" -> Float64, the ParseEnum numeric-text trap) loads as a
|
||||||
// the strict runtime rejects the tag outright (Gitea #549). Captured as the raw TOKEN, not via
|
// *valid-looking* enum value with no signal that it is not what the operator wrote — while the strict
|
||||||
// GetString: the runtime deserialises this key into a `string?` DTO field, so a NON-string token
|
// runtime rejects the tag outright (Gitea #549). Captured as the raw TOKEN, not via GetString: the
|
||||||
// (`"dataType": 8`) throws JsonException there and rejects the tag too, where GetString would report
|
// runtime deserialises both spellings into `string?` DTO fields, so a NON-string token
|
||||||
// it as simply absent. ToJson() always re-writes dataType as a name, so this can only ever be
|
// (`"dataType": 8`, `"driverDataType": 8`) throws JsonException there and rejects the tag too, where
|
||||||
// poisoned on the FIRST load of a blob authored elsewhere (hand-edited, CSV import, API), never after
|
// GetString would report it as simply absent. ToJson() always re-writes dataType as a name and drops
|
||||||
// a save through this model.
|
// 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;
|
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();
|
private JsonObject _bag = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -85,6 +93,34 @@ public sealed class MTConnectTagConfigModel
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private static readonly string[] IdKeys = ["fullName", "dataItemId", "address"];
|
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
|
/// <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>
|
/// 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>
|
/// <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)
|
public static MTConnectTagConfigModel FromJson(string? json)
|
||||||
{
|
{
|
||||||
var o = TagConfigJson.ParseOrNew(json);
|
var o = TagConfigJson.ParseOrNew(json);
|
||||||
|
var (rawDataTypeKey, rawDataType) = ReadRawDataType(o);
|
||||||
return new MTConnectTagConfigModel
|
return new MTConnectTagConfigModel
|
||||||
{
|
{
|
||||||
FullName = ReadId(o),
|
FullName = ReadId(o),
|
||||||
DataType = TagConfigJson.GetEnum(o, "dataType", DriverDataType.String),
|
DataType = ParseDataType(rawDataType),
|
||||||
MtCategory = TagConfigJson.GetString(o, "mtCategory"),
|
MtCategory = TagConfigJson.GetString(o, "mtCategory"),
|
||||||
MtType = TagConfigJson.GetString(o, "mtType"),
|
MtType = TagConfigJson.GetString(o, "mtType"),
|
||||||
MtSubType = TagConfigJson.GetString(o, "mtSubType"),
|
MtSubType = TagConfigJson.GetString(o, "mtSubType"),
|
||||||
Units = TagConfigJson.GetString(o, "units"),
|
Units = TagConfigJson.GetString(o, "units"),
|
||||||
MtDevice = TagConfigJson.GetString(o, "mtDevice"),
|
MtDevice = TagConfigJson.GetString(o, "mtDevice"),
|
||||||
MtComponent = TagConfigJson.GetString(o, "mtComponent"),
|
MtComponent = TagConfigJson.GetString(o, "mtComponent"),
|
||||||
_rawDataType = ReadRawDataType(o),
|
_rawDataType = rawDataType,
|
||||||
|
_rawDataTypeKey = rawDataTypeKey,
|
||||||
_bag = o,
|
_bag = o,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -115,11 +153,19 @@ public sealed class MTConnectTagConfigModel
|
|||||||
public string ToJson()
|
public string ToJson()
|
||||||
{
|
{
|
||||||
// Normalise onto the canonical key: a blob loaded via an alias must not keep the alias, or the
|
// 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, "dataItemId", null);
|
||||||
TagConfigJson.Set(_bag, "address", null);
|
TagConfigJson.Set(_bag, "address", null);
|
||||||
TagConfigJson.Set(_bag, "fullName", FullName.Trim());
|
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, "mtCategory", Blank(MtCategory));
|
||||||
TagConfigJson.Set(_bag, "mtType", Blank(MtType));
|
TagConfigJson.Set(_bag, "mtType", Blank(MtType));
|
||||||
TagConfigJson.Set(_bag, "mtSubType", Blank(MtSubType));
|
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.
|
// DataType above, because the CSV-import and API paths save the blob verbatim.
|
||||||
if (!MTConnectTagDefinitionFactory.IsAcceptedDataTypeName(_rawDataType))
|
if (!MTConnectTagDefinitionFactory.IsAcceptedDataTypeName(_rawDataType))
|
||||||
{
|
{
|
||||||
return $"dataType \"{_rawDataType}\" is not a valid DriverDataType name — re-select a data type. "
|
return $"{_rawDataTypeKey} \"{_rawDataType}\" is not a valid DriverDataType name — re-select a "
|
||||||
+ $"Valid: {string.Join(", ", Enum.GetNames<DriverDataType>())}.";
|
+ $"data type. Valid: {string.Join(", ", Enum.GetNames<DriverDataType>())}.";
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The as-loaded "dataType" TOKEN as text (see the _rawDataType remarks): a JSON string yields its
|
// The as-loaded coercion-type TOKEN as text (see the _rawDataType and DataTypeKeys remarks), read in
|
||||||
// value, any other present token yields its JSON text so Validate() judges what was actually written,
|
// the RUNTIME's precedence so this model judges — and displays — the token the driver would actually
|
||||||
// and absent / JSON null yields null — the one shape the runtime treats as "no type declared".
|
// read. Per key, mirroring FirstNonBlank(blob.DriverDataType, blob.DataType) over a DTO whose two
|
||||||
private static string? ReadRawDataType(JsonObject o)
|
// 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; }
|
foreach (var key in DataTypeKeys)
|
||||||
return node is JsonValue v && v.TryGetValue<string>(out var s) ? s : node.ToJsonString();
|
{
|
||||||
|
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
|
// Normalises a blank/whitespace-only string to null so TagConfigJson.Set omits the key rather than
|
||||||
// persisting an empty string.
|
// persisting an empty string.
|
||||||
private static string? Blank(string? value) => string.IsNullOrWhiteSpace(value) ? null : value;
|
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()
|
public void Validate_rejects_a_dataType_written_as_an_unquoted_number()
|
||||||
=> MTConnectTagConfigModel.FromJson("""{"fullName":"dev1_pos","dataType":8}""")
|
=> MTConnectTagConfigModel.FromJson("""{"fullName":"dev1_pos","dataType":8}""")
|
||||||
.Validate().ShouldNotBeNull().ShouldContain("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");
|
||||||
}
|
}
|
||||||
|
|||||||
+83
@@ -3,6 +3,7 @@ using Xunit;
|
|||||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||||
using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
|
using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
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
|
/// 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.
|
/// internal to the AdminUI project, which references ControlPlane.
|
||||||
/// </para>
|
/// </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>
|
/// </summary>
|
||||||
public sealed class TagConfigAuthoringDeployParityTests
|
public sealed class TagConfigAuthoringDeployParityTests
|
||||||
{
|
{
|
||||||
@@ -194,6 +203,80 @@ public sealed class TagConfigAuthoringDeployParityTests
|
|||||||
+ "import or API is unchecked — the exact G-7 asymmetry.");
|
+ "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
|
/// <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>
|
/// deploy gate then found nothing to say about it.</summary>
|
||||||
/// <param name="driverType">The canonical driver-type string both gates dispatch on.</param>
|
/// <param name="driverType">The canonical driver-type string both gates dispatch on.</param>
|
||||||
|
|||||||
Reference in New Issue
Block a user