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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user