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
@@ -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;