fix(adminui): authoring must not accept a TagConfig enum the runtime rejects (#549)
MTConnectTagConfigModel read `dataType` with TagConfigJson.GetEnum, which falls
back to String on ANY unrecognised value. A misspelled name ("Strng") from a
hand-authored blob, a CSV import or the API was therefore accepted at authoring
and silently rewritten, while the driver's strict parse rejected it and the tag
died with BadNodeIdUnknown. Found by the G-7 parity tests' falsifiability
control, which had to use the digits-only shape to have a case that failed.
The rule is not restated in the AdminUI. MTConnectTagDefinitionFactory exposes
its parse as `IsAcceptedDataTypeName`, documented as the one authority the
driver, the deploy gate and the authoring model all consult; the model's
Validate() asks it about the raw value the blob carries. The old digits-only
message is NOT retained as a special case: the runtime draws no distinction
between a bare number and a typo — one predicate, one Inspect warning — so a
second message here would be a second statement of a rule this model does not
own. The raw value is captured as the JSON TOKEN, because the runtime
deserialises the key into a `string?` DTO field and an unquoted number throws
there too.
The survey found the same asymmetry at six more editors, contrary to the
assumption that those runtimes silently default: R2-11 Phase C moved every
driver's TagConfig enum read to TagConfigJson.TryReadEnumStrict, which REJECTS
the tag on a present-but-invalid string, while the editors kept loading them
with a silent fallback. Modbus (region/dataType/byteOrder), S7, AbCip, AbLegacy,
TwinCAT and FOCAS now check their enums in Validate() through the runtime's own
TagConfigJson.DescribeInvalidEnumText — added alongside a public
ClassifyEnumText that TryReadEnum now delegates to, so the JSON-token door and
the string door reach one implementation. Mqtt and Sql already carried
equivalent strict checks and are untouched.
Care taken NOT to become stricter than the runtime: a present-but-non-string
token stays accepted for those six (TryReadEnum calls it absent), as does an
absent key, each pinned by a test.
Also adds a direct ProjectReference to Core.Abstractions from the AdminUI — the
save gate now leans on a type it was consuming only transitively.
This commit is contained in:
@@ -48,7 +48,39 @@ public static class TagConfigJson
|
|||||||
value = default;
|
value = default;
|
||||||
return JsonEnumRead.Absent;
|
return JsonEnumRead.Absent;
|
||||||
}
|
}
|
||||||
if (Enum.TryParse(e.GetString(), ignoreCase: true, out value))
|
return ClassifyEnumText(e.GetString(), out value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The text-level heart of <see cref="TryReadEnum{TEnum}"/>, and with it the single authority on
|
||||||
|
/// which enum spellings a TagConfig may carry: classifies an already-extracted enum STRING.
|
||||||
|
/// <para>
|
||||||
|
/// It is public because the authoring side reads the same blob through a different door — the
|
||||||
|
/// AdminUI's typed tag-config editors hold a mutable <c>JsonObject</c> key bag, not a
|
||||||
|
/// <see cref="JsonElement"/> — and an authoring gate that RESTATED the accept/reject boundary
|
||||||
|
/// would drift from the runtime the moment either side moved, in the one direction that hurts:
|
||||||
|
/// a value the modal accepts and the driver then rejects (Gitea #549). Both doors reach this
|
||||||
|
/// method.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <see langword="null"/> is <see cref="JsonEnumRead.Absent"/>, matching the JSON reader's
|
||||||
|
/// treatment of an absent or present-but-non-string property — so a caller that extracts "the
|
||||||
|
/// string value, or null" gets the runtime's leniency for free.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="TEnum">The enum type to parse.</typeparam>
|
||||||
|
/// <param name="text">The authored value's text, or <see langword="null"/> when there is none.</param>
|
||||||
|
/// <param name="value">The parsed enum on <see cref="JsonEnumRead.Valid"/>; otherwise <c>default</c>.</param>
|
||||||
|
/// <returns>The read outcome.</returns>
|
||||||
|
public static JsonEnumRead ClassifyEnumText<TEnum>(string? text, out TEnum value)
|
||||||
|
where TEnum : struct, Enum
|
||||||
|
{
|
||||||
|
if (text is null)
|
||||||
|
{
|
||||||
|
value = default;
|
||||||
|
return JsonEnumRead.Absent;
|
||||||
|
}
|
||||||
|
if (Enum.TryParse(text, ignoreCase: true, out value))
|
||||||
return JsonEnumRead.Valid;
|
return JsonEnumRead.Valid;
|
||||||
value = default;
|
value = default;
|
||||||
return JsonEnumRead.Invalid;
|
return JsonEnumRead.Invalid;
|
||||||
@@ -120,10 +152,26 @@ public static class TagConfigJson
|
|||||||
/// <returns>The warning text, or <see langword="null"/>.</returns>
|
/// <returns>The warning text, or <see langword="null"/>.</returns>
|
||||||
public static string? DescribeInvalidEnum<TEnum>(JsonElement o, string name)
|
public static string? DescribeInvalidEnum<TEnum>(JsonElement o, string name)
|
||||||
where TEnum : struct, Enum
|
where TEnum : struct, Enum
|
||||||
|
=> TryReadEnum<TEnum>(o, name, out _) != JsonEnumRead.Invalid
|
||||||
|
? null
|
||||||
|
: DescribeInvalidEnumText<TEnum>(name, o.TryGetProperty(name, out var e) ? e.GetString() : null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The text-level counterpart of <see cref="DescribeInvalidEnum{TEnum}(JsonElement, string)"/>,
|
||||||
|
/// over the same <see cref="ClassifyEnumText{TEnum}"/> verdict: a human-readable warning for a
|
||||||
|
/// present-but-invalid enum value, or <see langword="null"/> when it is absent
|
||||||
|
/// (<paramref name="text"/> <see langword="null"/>) or valid. The authoring gate uses this so its
|
||||||
|
/// wording, and not only its verdict, comes from the same place as the deploy gate's.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="TEnum">The enum type expected.</typeparam>
|
||||||
|
/// <param name="name">The property name to describe.</param>
|
||||||
|
/// <param name="text">The authored value's text, or <see langword="null"/> when there is none.</param>
|
||||||
|
/// <returns>The warning text, or <see langword="null"/>.</returns>
|
||||||
|
public static string? DescribeInvalidEnumText<TEnum>(string name, string? text)
|
||||||
|
where TEnum : struct, Enum
|
||||||
{
|
{
|
||||||
if (TryReadEnum<TEnum>(o, name, out _) != JsonEnumRead.Invalid) return null;
|
if (ClassifyEnumText<TEnum>(text, out _) != JsonEnumRead.Invalid) return null;
|
||||||
var raw = o.TryGetProperty(name, out var e) ? e.GetString() : null;
|
|
||||||
var valid = string.Join(", ", Enum.GetNames<TEnum>());
|
var valid = string.Join(", ", Enum.GetNames<TEnum>());
|
||||||
return $"value '{raw}' for '{name}' is not a valid {typeof(TEnum).Name}; valid: {valid}";
|
return $"value '{text}' for '{name}' is not a valid {typeof(TEnum).Name}; valid: {valid}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+22
@@ -154,6 +154,28 @@ public static class MTConnectTagDefinitionFactory
|
|||||||
return warnings;
|
return warnings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <b>The single authority on which coercion-type spellings an MTConnect tag may carry</b>, consulted
|
||||||
|
/// by the runtime + deploy gate (through <see cref="TryFromTagConfig"/> / <see cref="Inspect"/>) and
|
||||||
|
/// by the AdminUI's authoring model (<c>MTConnectTagConfigModel.Validate</c>) alike.
|
||||||
|
/// <para>
|
||||||
|
/// It is public for exactly that second caller. The authoring model used to read the type with a
|
||||||
|
/// lenient enum parse that fell back to <c>String</c> on ANY unrecognised value, so a misspelled
|
||||||
|
/// name ("Strng") from a hand-authored blob, a CSV import or the API was accepted at authoring —
|
||||||
|
/// and silently rewritten — while this parse rejected it and the tag died at the driver
|
||||||
|
/// (Gitea #549). A second copy of the rule in the AdminUI would have reopened that gap the
|
||||||
|
/// moment either side moved, so there is one rule and both sides ask it.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// A blank/absent value is ACCEPTED: it declares no override, and <see cref="TryFromTagConfig"/>
|
||||||
|
/// then falls back to the Agent's <c>/probe</c> declaration rather than rejecting the tag.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="raw">The authored type text, or <see langword="null"/>/blank when none was authored.</param>
|
||||||
|
/// <returns><see langword="true"/> when the driver would accept this authored type.</returns>
|
||||||
|
public static bool IsAcceptedDataTypeName(string? raw)
|
||||||
|
=> string.IsNullOrWhiteSpace(raw) || TryParseDataType(raw.Trim(), out _);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parses an authored coercion type by NAME. A digits-only value is rejected outright, BEFORE the
|
/// Parses an authored coercion type by NAME. A digits-only value is rejected outright, BEFORE the
|
||||||
/// parse: <see cref="Enum.TryParse{TEnum}(string, bool, out TEnum)"/> accepts <c>"8"</c> as readily
|
/// parse: <see cref="Enum.TryParse{TEnum}(string, bool, out TEnum)"/> accepts <c>"8"</c> as readily
|
||||||
|
|||||||
@@ -52,7 +52,18 @@ public sealed class AbCipTagConfigModel
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
|
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The <c>dataType</c> check reads the ORIGINAL key bag rather than the leniently-defaulted
|
||||||
|
/// <see cref="DataType"/>, because <c>AbCipTagDefinitionFactory</c> reads it with
|
||||||
|
/// <c>TryReadEnumStrict</c> — a present-but-invalid string rejects the whole tag at the driver
|
||||||
|
/// (<c>BadNodeIdUnknown</c>). Without this the blob would be accepted here, silently rewritten to
|
||||||
|
/// <c>DInt</c> on save, and — on the CSV-import and API paths, which save it verbatim — deployed
|
||||||
|
/// into a tag the driver skips (Gitea #549).
|
||||||
|
/// </remarks>
|
||||||
/// <returns>An error message describing the validation failure, or <c>null</c> when valid.</returns>
|
/// <returns>An error message describing the validation failure, or <c>null</c> when valid.</returns>
|
||||||
public string? Validate()
|
public string? Validate()
|
||||||
=> string.IsNullOrWhiteSpace(TagPath) ? "Tag path is required." : null;
|
{
|
||||||
|
if (TagConfigJson.DescribeInvalidEnum<AbCipDataType>(_bag, "dataType") is { } dtError) { return dtError; }
|
||||||
|
return string.IsNullOrWhiteSpace(TagPath) ? "Tag path is required." : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,14 @@ public sealed class AbLegacyTagConfigModel
|
|||||||
/// <returns>An error message describing the validation failure, or <c>null</c> when valid.</returns>
|
/// <returns>An error message describing the validation failure, or <c>null</c> when valid.</returns>
|
||||||
public string? Validate()
|
public string? Validate()
|
||||||
{
|
{
|
||||||
|
// Strict enum, read from the ORIGINAL key bag: AbLegacyTagDefinitionFactory reads "dataType" with
|
||||||
|
// TryReadEnumStrict, so a present-but-invalid string rejects the whole tag at the driver
|
||||||
|
// (BadNodeIdUnknown) while the lenient load above would default it to Int. Accepting it here would
|
||||||
|
// let the CSV-import and API paths — which save the blob verbatim — deploy an unreadable tag
|
||||||
|
// (Gitea #549).
|
||||||
|
if (TagConfigJson.DescribeInvalidEnum<AbLegacyDataType>(_bag, "dataType") is { } dtError)
|
||||||
|
return dtError;
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(Address))
|
if (string.IsNullOrWhiteSpace(Address))
|
||||||
return "Address is required.";
|
return "Address is required.";
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,13 @@ public sealed class FocasTagConfigModel
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public string? Validate()
|
public string? Validate()
|
||||||
{
|
{
|
||||||
|
// Strict enum, read from the ORIGINAL key bag: FocasTagDefinitionFactory reads "dataType" with
|
||||||
|
// TryReadEnumStrict, so a present-but-invalid string rejects the whole tag at the driver
|
||||||
|
// (BadNodeIdUnknown) while the lenient load above defaults it to Int32. Accepting it here would
|
||||||
|
// let the CSV-import and API paths — which save the blob verbatim — deploy an unreadable tag,
|
||||||
|
// the same shape as the #540 address gap this method already closes (Gitea #549).
|
||||||
|
if (TagConfigJson.DescribeInvalidEnum<FocasDataType>(_bag, "dataType") is { } dtError) return dtError;
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(Address)) return "Address is required.";
|
if (string.IsNullOrWhiteSpace(Address)) return "Address is required.";
|
||||||
return FocasAddress.TryParse(Address) is null ? DescribeUnparseableAddress(Address) : null;
|
return FocasAddress.TryParse(Address) is null ? DescribeUnparseableAddress(Address) : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
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.Uns.TagEditors;
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||||
|
|
||||||
@@ -57,12 +58,17 @@ 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 value, kept only to detect the ParseEnum numeric-text trap in
|
// The as-loaded raw "dataType" JSON token, kept so Validate() below can put it to the DRIVER's own
|
||||||
// Validate() below — Enum.TryParse (which TagConfigJson.GetEnum uses) silently accepts a quoted
|
// accept/reject rule (MTConnectTagDefinitionFactory.IsAcceptedDataTypeName) rather than to the
|
||||||
// number ("8" -> Float64) exactly as readily as a name, so a config that was ever hand-authored or
|
// lenient load. TagConfigJson.GetEnum falls back to String on ANY unrecognised value, so without this
|
||||||
// migrated with a numeric dataType would otherwise load into a *valid-looking* enum value with no
|
// a misspelled name ("Strng") or a quoted number ("8" -> Float64, the ParseEnum numeric-text trap)
|
||||||
// signal that it isn't a name. ToJson() always re-writes DataType as a name, so this can only ever
|
// loads as a *valid-looking* enum value with no signal that it is not what the operator wrote — while
|
||||||
// be non-null on the FIRST load of an already-poisoned config, never after a save through this model.
|
// 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.
|
||||||
private string? _rawDataType;
|
private string? _rawDataType;
|
||||||
|
|
||||||
private JsonObject _bag = new();
|
private JsonObject _bag = new();
|
||||||
@@ -96,7 +102,7 @@ public sealed class MTConnectTagConfigModel
|
|||||||
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 = TagConfigJson.GetString(o, "dataType"),
|
_rawDataType = ReadRawDataType(o),
|
||||||
_bag = o,
|
_bag = o,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -124,6 +130,15 @@ public sealed class MTConnectTagConfigModel
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
|
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The <c>dataType</c> rule is not stated here — it is asked of
|
||||||
|
/// <see cref="MTConnectTagDefinitionFactory.IsAcceptedDataTypeName"/>, the same predicate the driver
|
||||||
|
/// and the deploy gate use. One authority, so this gate can never end up weaker than the runtime
|
||||||
|
/// again (Gitea #549). Note that the previous, digits-only check is NOT retained as a tailored
|
||||||
|
/// special case: the runtime draws no distinction between a bare number and a misspelled name —
|
||||||
|
/// both fail that one predicate and both draw the same <c>Inspect</c> warning — so a second message
|
||||||
|
/// here would be a second statement of a rule this model does not own.
|
||||||
|
/// </remarks>
|
||||||
/// <returns>An error message describing the validation failure, or <c>null</c> when the model is valid.</returns>
|
/// <returns>An error message describing the validation failure, or <c>null</c> when the model is valid.</returns>
|
||||||
public string? Validate()
|
public string? Validate()
|
||||||
{
|
{
|
||||||
@@ -132,16 +147,26 @@ public sealed class MTConnectTagConfigModel
|
|||||||
return "A DataItem id (fullName) is required.";
|
return "A DataItem id (fullName) is required.";
|
||||||
}
|
}
|
||||||
|
|
||||||
// See the _rawDataType remarks: a quoted number parses as a "valid" enum today but is never
|
// See the _rawDataType remarks: the value the blob CARRIES is judged, not the leniently-defaulted
|
||||||
// something this editor itself would have written, so surface it instead of silently accepting it.
|
// DataType above, because the CSV-import and API paths save the blob verbatim.
|
||||||
if (_rawDataType is { Length: > 0 } raw && raw.All(char.IsAsciiDigit))
|
if (!MTConnectTagDefinitionFactory.IsAcceptedDataTypeName(_rawDataType))
|
||||||
{
|
{
|
||||||
return $"dataType \"{raw}\" is a numeric value, not a named type — re-select a data type.";
|
return $"dataType \"{_rawDataType}\" is not a valid DriverDataType name — re-select a 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
|
||||||
|
// 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)
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
// 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;
|
||||||
|
|||||||
@@ -68,6 +68,18 @@ public sealed class ModbusTagConfigModel
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
|
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// All three enums are read from the ORIGINAL key bag, not from the leniently-defaulted typed
|
||||||
|
/// fields above: <c>ModbusTagDefinitionFactory</c> reads each with <c>TryReadEnumStrict</c>, so a
|
||||||
|
/// present-but-invalid string rejects the whole tag at the driver (<c>BadNodeIdUnknown</c>) where
|
||||||
|
/// the load silently substitutes <c>HoldingRegisters</c>/<c>Int16</c>/<c>BigEndian</c>. Accepting
|
||||||
|
/// one here would let the CSV-import and API paths — which save the blob verbatim — deploy a tag
|
||||||
|
/// the driver skips (Gitea #549). Also note a <b>wrong-but-valid</b> region is not this gate's
|
||||||
|
/// business: only a spelling the runtime cannot read is rejected.
|
||||||
|
/// </remarks>
|
||||||
/// <returns>An error message describing the validation failure, or <c>null</c> when the model is valid.</returns>
|
/// <returns>An error message describing the validation failure, or <c>null</c> when the model is valid.</returns>
|
||||||
public string? Validate() => null;
|
public string? Validate()
|
||||||
|
=> TagConfigJson.DescribeInvalidEnum<ModbusRegion>(_bag, "region")
|
||||||
|
?? TagConfigJson.DescribeInvalidEnum<ModbusDataType>(_bag, "dataType")
|
||||||
|
?? TagConfigJson.DescribeInvalidEnum<ModbusByteOrder>(_bag, "byteOrder");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,16 @@ public sealed class S7TagConfigModel
|
|||||||
|
|
||||||
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
|
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
|
||||||
/// <returns>An error message describing the validation failure, or null when the model is valid.</returns>
|
/// <returns>An error message describing the validation failure, or null when the model is valid.</returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// The <c>dataType</c> check reads the ORIGINAL key bag rather than the leniently-defaulted
|
||||||
|
/// <see cref="DataType"/>: <c>S7TagDefinitionFactory</c> reads it with <c>TryReadEnumStrict</c>,
|
||||||
|
/// so a present-but-invalid string rejects the whole tag at the driver (<c>BadNodeIdUnknown</c>)
|
||||||
|
/// where the load silently substitutes <c>Int16</c>. Accepting it here would let the CSV-import
|
||||||
|
/// and API paths — which save the blob verbatim — deploy an unreadable tag (Gitea #549).
|
||||||
|
/// </remarks>
|
||||||
public string? Validate()
|
public string? Validate()
|
||||||
=> string.IsNullOrWhiteSpace(Address) ? "Address is required." : null;
|
{
|
||||||
|
if (TagConfigJson.DescribeInvalidEnum<S7DataType>(_bag, "dataType") is { } dtError) { return dtError; }
|
||||||
|
return string.IsNullOrWhiteSpace(Address) ? "Address is required." : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
|
using RuntimeTagConfigJson = ZB.MOM.WW.OtOpcUa.Core.Abstractions.TagConfigJson;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||||
|
|
||||||
@@ -65,6 +66,33 @@ public static class TagConfigJson
|
|||||||
public static TEnum GetEnum<TEnum>(JsonObject o, string name, TEnum fallback) where TEnum : struct, Enum
|
public static TEnum GetEnum<TEnum>(JsonObject o, string name, TEnum fallback) where TEnum : struct, Enum
|
||||||
=> GetString(o, name) is { } s && Enum.TryParse<TEnum>(s, ignoreCase: true, out var v) ? v : fallback;
|
=> GetString(o, name) is { } s && Enum.TryParse<TEnum>(s, ignoreCase: true, out var v) ? v : fallback;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Describes a present-but-invalid enum field the way the RUNTIME parser judges it, or
|
||||||
|
/// <c>null</c> when the field is absent, present-but-non-string, or valid.
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="GetEnum{TEnum}"/> is deliberately lenient — it must be, because a model has to
|
||||||
|
/// LOAD a poisoned blob to let an operator fix it — but leniency at the save gate is the
|
||||||
|
/// Gitea #549 defect: every driver's runtime read is
|
||||||
|
/// <c>TagConfigJson.TryReadEnumStrict</c>, which REJECTS the whole tag on a present-but-invalid
|
||||||
|
/// string (R2-11 Phase C), so a typo the modal accepts and rewrites is a tag the driver would
|
||||||
|
/// have skipped with <c>BadNodeIdUnknown</c> on the CSV-import and API paths, which save the
|
||||||
|
/// blob verbatim. A model's <c>Validate()</c> therefore calls this on its ORIGINAL key bag.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The verdict and the wording both come from
|
||||||
|
/// <see cref="RuntimeTagConfigJson.DescribeInvalidEnumText{TEnum}"/> — the same authority the
|
||||||
|
/// runtime parsers and the deploy gate consult — rather than being restated here. Passing
|
||||||
|
/// <see cref="GetString"/>'s null-for-non-string result reproduces the runtime's
|
||||||
|
/// <c>Absent</c> treatment of a non-string token exactly.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="TEnum">The enum type expected.</typeparam>
|
||||||
|
/// <param name="o">The model's preserved TagConfig key bag, as loaded.</param>
|
||||||
|
/// <param name="name">The property name to check.</param>
|
||||||
|
/// <returns>The error text, or <c>null</c> when the runtime would accept the field.</returns>
|
||||||
|
public static string? DescribeInvalidEnum<TEnum>(JsonObject o, string name) where TEnum : struct, Enum
|
||||||
|
=> RuntimeTagConfigJson.DescribeInvalidEnumText<TEnum>(name, GetString(o, name));
|
||||||
|
|
||||||
/// <summary>Sets a string/number/enum-name value (enums via ToString()). A null value REMOVES the key, so it is omitted from the serialised JSON.</summary>
|
/// <summary>Sets a string/number/enum-name value (enums via ToString()). A null value REMOVES the key, so it is omitted from the serialised JSON.</summary>
|
||||||
/// <param name="o">The JSON object to mutate.</param>
|
/// <param name="o">The JSON object to mutate.</param>
|
||||||
/// <param name="name">The property name to set.</param>
|
/// <param name="name">The property name to set.</param>
|
||||||
|
|||||||
@@ -53,6 +53,17 @@ public sealed class TwinCATTagConfigModel
|
|||||||
|
|
||||||
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
|
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
|
||||||
/// <returns>An error message describing the validation failure, or null when the model is valid.</returns>
|
/// <returns>An error message describing the validation failure, or null when the model is valid.</returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// The <c>dataType</c> check reads the ORIGINAL key bag rather than the leniently-defaulted
|
||||||
|
/// <see cref="DataType"/>: <c>TwinCATTagDefinitionFactory</c> reads it with
|
||||||
|
/// <c>TryReadEnumStrict</c>, so a present-but-invalid string rejects the whole tag at the driver
|
||||||
|
/// (<c>BadNodeIdUnknown</c>) where the load silently substitutes <c>DInt</c>. Accepting it here
|
||||||
|
/// would let the CSV-import and API paths — which save the blob verbatim — deploy an unreadable
|
||||||
|
/// tag (Gitea #549).
|
||||||
|
/// </remarks>
|
||||||
public string? Validate()
|
public string? Validate()
|
||||||
=> string.IsNullOrWhiteSpace(SymbolPath) ? "Symbol path is required." : null;
|
{
|
||||||
|
if (TagConfigJson.DescribeInvalidEnum<TwinCATDataType>(_bag, "dataType") is { } dtError) { return dtError; }
|
||||||
|
return string.IsNullOrWhiteSpace(SymbolPath) ? "Symbol path is required." : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,12 @@
|
|||||||
<!-- #520: API-key minting + the reserved-principal rule live in Core.Authorization, because
|
<!-- #520: API-key minting + the reserved-principal rule live in Core.Authorization, because
|
||||||
both ends need them — the OPC UA server resolves principals, the AdminUI authors keys. -->
|
both ends need them — the OPC UA server resolves principals, the AdminUI authors keys. -->
|
||||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||||
|
<!-- #549: the typed tag-config editors ask Core.Abstractions' TagConfigJson whether an authored
|
||||||
|
enum spelling is one the RUNTIME parsers accept, so authoring can never be weaker than the
|
||||||
|
driver. That type is load-bearing for the save gate — referenced directly rather than
|
||||||
|
consumed transitively through Core, so a future re-plumbing of Core cannot silently take it
|
||||||
|
away. -->
|
||||||
|
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Scripting\ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj"/>
|
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Scripting\ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj"/>
|
||||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions.csproj"/>
|
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions.csproj"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -99,4 +99,25 @@ public sealed class AbCipTagConfigModelTests
|
|||||||
|
|
||||||
json.ShouldContain("\"deviceHostAddress\":\"ab://host\"");
|
json.ShouldContain("\"deviceHostAddress\":\"ab://host\"");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gitea #549: the LOAD is lenient — an unrecognised name silently becomes <c>DInt</c> — but
|
||||||
|
/// <c>AbCipTagDefinitionFactory</c> reads <c>dataType</c> with <c>TryReadEnumStrict</c> and rejects
|
||||||
|
/// the whole tag, so the save gate must judge the value the blob actually carries rather than the
|
||||||
|
/// one the load substituted.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Validate_refuses_a_dataType_the_driver_would_reject()
|
||||||
|
=> AbCipTagConfigModel.FromJson("""{"tagPath":"Motor1.Status","dataType":"DIntt"}""")
|
||||||
|
.Validate().ShouldNotBeNull().ShouldContain("DIntt");
|
||||||
|
|
||||||
|
/// <summary>The other direction, which matters just as much: the check is the runtime's rule and not a
|
||||||
|
/// stricter one — a valid name in any casing, an absent key, and a present-but-NON-STRING token (which
|
||||||
|
/// <c>TryReadEnum</c> treats as absent) are all served by the driver and so must all save.</summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData("""{"tagPath":"Motor1.Status","dataType":"real"}""")]
|
||||||
|
[InlineData("""{"tagPath":"Motor1.Status"}""")]
|
||||||
|
[InlineData("""{"tagPath":"Motor1.Status","dataType":2}""")]
|
||||||
|
public void Validate_accepts_every_dataType_the_driver_accepts(string json)
|
||||||
|
=> AbCipTagConfigModel.FromJson(json).Validate().ShouldBeNull();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,4 +129,25 @@ public sealed class AbLegacyTagConfigModelTests
|
|||||||
|
|
||||||
m.Validate().ShouldBeNull();
|
m.Validate().ShouldBeNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gitea #549: the LOAD is lenient — an unrecognised name silently becomes <c>Int</c> — but
|
||||||
|
/// <c>AbLegacyTagDefinitionFactory</c> reads <c>dataType</c> with <c>TryReadEnumStrict</c> and
|
||||||
|
/// rejects the whole tag, so the save gate must judge the value the blob actually carries rather
|
||||||
|
/// than the one the load substituted.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Validate_refuses_a_dataType_the_driver_would_reject()
|
||||||
|
=> AbLegacyTagConfigModel.FromJson("""{"address":"N7:0","dataType":"Intt"}""")
|
||||||
|
.Validate().ShouldNotBeNull().ShouldContain("Intt");
|
||||||
|
|
||||||
|
/// <summary>The other direction, which matters just as much: the check is the runtime's rule and not a
|
||||||
|
/// stricter one — a valid name in any casing, an absent key, and a present-but-NON-STRING token (which
|
||||||
|
/// <c>TryReadEnum</c> treats as absent) are all served by the driver and so must all save.</summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData("""{"address":"N7:0","dataType":"float"}""")]
|
||||||
|
[InlineData("""{"address":"N7:0"}""")]
|
||||||
|
[InlineData("""{"address":"N7:0","dataType":2}""")]
|
||||||
|
public void Validate_accepts_every_dataType_the_driver_accepts(string json)
|
||||||
|
=> AbLegacyTagConfigModel.FromJson(json).Validate().ShouldBeNull();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,4 +125,25 @@ public sealed class FocasTagConfigModelTests
|
|||||||
|
|
||||||
json.ShouldContain("\"deviceHostAddress\":\"ab://host\"");
|
json.ShouldContain("\"deviceHostAddress\":\"ab://host\"");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gitea #549: the LOAD is lenient — an unrecognised name silently becomes <c>Int32</c> — but
|
||||||
|
/// <c>FocasTagDefinitionFactory</c> reads <c>dataType</c> with <c>TryReadEnumStrict</c> and rejects
|
||||||
|
/// the whole tag, so the save gate must judge the value the blob actually carries. Same shape as
|
||||||
|
/// the #540 address gap this model already closes, one field over.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Validate_refuses_a_dataType_the_driver_would_reject()
|
||||||
|
=> FocasTagConfigModel.FromJson("""{"address":"R100","dataType":"Int322"}""")
|
||||||
|
.Validate().ShouldNotBeNull().ShouldContain("Int322");
|
||||||
|
|
||||||
|
/// <summary>The other direction, which matters just as much: the check is the runtime's rule and not a
|
||||||
|
/// stricter one — a valid name in any casing, an absent key, and a present-but-NON-STRING token (which
|
||||||
|
/// <c>TryReadEnum</c> treats as absent) are all served by the driver and so must all save.</summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData("""{"address":"R100","dataType":"float32"}""")]
|
||||||
|
[InlineData("""{"address":"R100"}""")]
|
||||||
|
[InlineData("""{"address":"R100","dataType":2}""")]
|
||||||
|
public void Validate_accepts_every_dataType_the_driver_accepts(string json)
|
||||||
|
=> FocasTagConfigModel.FromJson(json).Validate().ShouldBeNull();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,4 +176,46 @@ public sealed class MTConnectTagConfigModelTests
|
|||||||
m.DataType.ShouldBe(DriverDataType.Float64); // 8 == DriverDataType.Float64 — parsed "successfully"
|
m.DataType.ShouldBe(DriverDataType.Float64); // 8 == DriverDataType.Float64 — parsed "successfully"
|
||||||
m.Validate().ShouldNotBeNull();
|
m.Validate().ShouldNotBeNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gitea #549: a MISSPELLED type name is the same defect the bare number was, and it was the more
|
||||||
|
/// likely one — <c>TagConfigJson.GetEnum</c> falls back to <c>String</c> on any unrecognised value,
|
||||||
|
/// so the blob was accepted here, silently rewritten to <c>"String"</c> on the next save, and
|
||||||
|
/// rejected outright by the driver's strict parse in between. The message must name the value the
|
||||||
|
/// operator actually wrote, not the <c>String</c> it was coerced to.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Validate_rejects_a_misspelled_dataType_name_and_names_the_offending_value()
|
||||||
|
{
|
||||||
|
var m = MTConnectTagConfigModel.FromJson("""{"fullName":"dev1_pos","dataType":"Strng"}""");
|
||||||
|
|
||||||
|
m.DataType.ShouldBe(DriverDataType.String); // the lenient load's coercion — the trap itself
|
||||||
|
m.Validate().ShouldNotBeNull().ShouldContain("Strng");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The rejection is the RUNTIME's rule, so it must reject exactly what the runtime rejects and
|
||||||
|
/// no more: a valid name in any casing is accepted, and so is an absent, blank or JSON-null type (the
|
||||||
|
/// driver then falls back to the Agent's /probe declaration rather than rejecting the tag).</summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData("""{"fullName":"dev1_pos","dataType":"float64"}""")]
|
||||||
|
[InlineData("""{"fullName":"dev1_pos","dataType":"FLOAT64"}""")]
|
||||||
|
[InlineData("""{"fullName":"dev1_pos","dataType":" Float64 "}""")]
|
||||||
|
[InlineData("""{"fullName":"dev1_pos"}""")]
|
||||||
|
[InlineData("""{"fullName":"dev1_pos","dataType":""}""")]
|
||||||
|
[InlineData("""{"fullName":"dev1_pos","dataType":null}""")]
|
||||||
|
public void Validate_accepts_every_dataType_the_driver_accepts(string json)
|
||||||
|
=> MTConnectTagConfigModel.FromJson(json).Validate().ShouldBeNull();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A NON-string <c>dataType</c> token is rejected too, and for a reason the string cases do not
|
||||||
|
/// cover: the runtime deserialises this key into a <c>string?</c> DTO field, so an unquoted number
|
||||||
|
/// throws <c>JsonException</c> there and takes the whole tag down. Reading the value with
|
||||||
|
/// <c>GetString</c> (null for a non-string token) would have called that "absent" and accepted it —
|
||||||
|
/// which is why the model captures the raw TOKEN. The companion assertion in ControlPlane.Tests'
|
||||||
|
/// <c>EquipmentTagConfigInspectorTests</c> proves the runtime really does reject it.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Validate_rejects_a_dataType_written_as_an_unquoted_number()
|
||||||
|
=> MTConnectTagConfigModel.FromJson("""{"fullName":"dev1_pos","dataType":8}""")
|
||||||
|
.Validate().ShouldNotBeNull().ShouldContain("8");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,4 +89,29 @@ public sealed class ModbusTagConfigModelTests
|
|||||||
{
|
{
|
||||||
new ModbusTagConfigModel().Validate().ShouldBeNull();
|
new ModbusTagConfigModel().Validate().ShouldBeNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gitea #549: the LOAD is lenient — an unrecognised name silently becomes the default — but
|
||||||
|
/// <c>ModbusTagDefinitionFactory</c> reads all THREE enums with <c>TryReadEnumStrict</c> and
|
||||||
|
/// rejects the whole tag on any one of them, so the save gate must judge the values the blob
|
||||||
|
/// actually carries. Each key is asserted separately: a check that covered only <c>dataType</c>
|
||||||
|
/// would leave a typo'd region or byte order accepted here and dead at the driver.
|
||||||
|
/// </summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData("""{"region":"HoldingRegister","address":1}""", "HoldingRegister")]
|
||||||
|
[InlineData("""{"region":"HoldingRegisters","address":1,"dataType":"Intt16"}""", "Intt16")]
|
||||||
|
[InlineData("""{"region":"HoldingRegisters","address":1,"byteOrder":"BigEndain"}""", "BigEndain")]
|
||||||
|
public void Validate_refuses_an_enum_the_driver_would_reject(string json, string offending)
|
||||||
|
=> ModbusTagConfigModel.FromJson(json).Validate().ShouldNotBeNull().ShouldContain(offending);
|
||||||
|
|
||||||
|
/// <summary>The other direction, which matters just as much: the check is the runtime's rule and not a
|
||||||
|
/// stricter one. Valid names in any casing are accepted, an absent key is accepted (the runtime
|
||||||
|
/// defaults it), and so is a present-but-NON-STRING token — <c>TryReadEnum</c> calls that "absent" and
|
||||||
|
/// defaults it too, so refusing it here would block a blob the driver serves.</summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData("""{"region":"holdingregisters","address":1,"dataType":"float32","byteOrder":"wordswap"}""")]
|
||||||
|
[InlineData("""{"address":1}""")]
|
||||||
|
[InlineData("""{"region":3,"address":1,"dataType":true}""")]
|
||||||
|
public void Validate_accepts_every_enum_value_the_driver_accepts(string json)
|
||||||
|
=> ModbusTagConfigModel.FromJson(json).Validate().ShouldBeNull();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,4 +79,28 @@ public sealed class S7TagConfigModelTests
|
|||||||
{
|
{
|
||||||
new S7TagConfigModel { Address = "DB1.DBW0" }.Validate().ShouldBeNull();
|
new S7TagConfigModel { Address = "DB1.DBW0" }.Validate().ShouldBeNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gitea #549: the LOAD is lenient — an unrecognised name silently becomes <c>Int16</c> — but
|
||||||
|
/// <c>S7TagDefinitionFactory</c> reads <c>dataType</c> with <c>TryReadEnumStrict</c> and rejects the
|
||||||
|
/// whole tag, so the save gate must judge the value the blob actually carries. The blob reaches
|
||||||
|
/// deployment unrewritten on the API path, and the operator would see a tag that reads
|
||||||
|
/// <c>BadNodeIdUnknown</c> with nothing to explain it.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Validate_refuses_a_dataType_the_driver_would_reject()
|
||||||
|
=> S7TagConfigModel.FromJson("""{"address":"DB1.DBW0","dataType":"Int166"}""")
|
||||||
|
.Validate().ShouldNotBeNull().ShouldContain("Int166");
|
||||||
|
|
||||||
|
/// <summary>The other direction, which matters just as much: the check is the runtime's rule and not a
|
||||||
|
/// stricter one. A valid name in any casing is accepted, and so is a present-but-NON-STRING token —
|
||||||
|
/// <c>TryReadEnum</c> calls that "absent" and defaults it, so refusing it here would block a blob the
|
||||||
|
/// driver serves. (An absent key is deliberately not asserted: S7 alone hard-requires the key, an
|
||||||
|
/// asymmetry that predates this change and that its own <c>Inspect</c> does not report either.)</summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData("""{"address":"DB1.DBW0","dataType":"float32"}""")]
|
||||||
|
[InlineData("""{"address":"DB1.DBW0","dataType":"FLOAT32"}""")]
|
||||||
|
[InlineData("""{"address":"DB1.DBW0","dataType":7}""")]
|
||||||
|
public void Validate_accepts_every_dataType_the_driver_accepts(string json)
|
||||||
|
=> S7TagConfigModel.FromJson(json).Validate().ShouldBeNull();
|
||||||
}
|
}
|
||||||
|
|||||||
+72
-6
@@ -73,6 +73,60 @@ public sealed class TagConfigAuthoringDeployParityTests
|
|||||||
AssertParity(DriverTypeNames.MTConnect, model.Validate(), model.ToJson());
|
AssertParity(DriverTypeNames.MTConnect, model.Validate(), model.ToJson());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The six editors whose <c>Validate()</c> gained a strict enum check with Gitea #549. Each is
|
||||||
|
/// built with its DEFAULT enum values on purpose: the check reads the loaded key bag, so a model
|
||||||
|
/// that was never loaded from JSON has nothing to check, and the assertion that matters here is
|
||||||
|
/// that the enum names the editor itself writes are ones the runtime parser reads back — the
|
||||||
|
/// over-rejection direction the new check must not fall into.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Modbus_config_the_editor_accepts_draws_no_deploy_warning()
|
||||||
|
{
|
||||||
|
var model = new ModbusTagConfigModel { Address = 1 };
|
||||||
|
AssertParity(DriverTypeNames.Modbus, model.Validate(), model.ToJson());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc cref="Modbus_config_the_editor_accepts_draws_no_deploy_warning"/>
|
||||||
|
[Fact]
|
||||||
|
public void S7_config_the_editor_accepts_draws_no_deploy_warning()
|
||||||
|
{
|
||||||
|
var model = new S7TagConfigModel { Address = "DB1.DBW0" };
|
||||||
|
AssertParity(DriverTypeNames.S7, model.Validate(), model.ToJson());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc cref="Modbus_config_the_editor_accepts_draws_no_deploy_warning"/>
|
||||||
|
[Fact]
|
||||||
|
public void AbCip_config_the_editor_accepts_draws_no_deploy_warning()
|
||||||
|
{
|
||||||
|
var model = new AbCipTagConfigModel { TagPath = "Program:Main.Speed" };
|
||||||
|
AssertParity(DriverTypeNames.AbCip, model.Validate(), model.ToJson());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc cref="Modbus_config_the_editor_accepts_draws_no_deploy_warning"/>
|
||||||
|
[Fact]
|
||||||
|
public void AbLegacy_config_the_editor_accepts_draws_no_deploy_warning()
|
||||||
|
{
|
||||||
|
var model = new AbLegacyTagConfigModel { Address = "N7:0" };
|
||||||
|
AssertParity(DriverTypeNames.AbLegacy, model.Validate(), model.ToJson());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc cref="Modbus_config_the_editor_accepts_draws_no_deploy_warning"/>
|
||||||
|
[Fact]
|
||||||
|
public void TwinCAT_config_the_editor_accepts_draws_no_deploy_warning()
|
||||||
|
{
|
||||||
|
var model = new TwinCATTagConfigModel { SymbolPath = "MAIN.bStart" };
|
||||||
|
AssertParity(DriverTypeNames.TwinCAT, model.Validate(), model.ToJson());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc cref="Modbus_config_the_editor_accepts_draws_no_deploy_warning"/>
|
||||||
|
[Fact]
|
||||||
|
public void Focas_config_the_editor_accepts_draws_no_deploy_warning()
|
||||||
|
{
|
||||||
|
var model = new FocasTagConfigModel { Address = "R100" };
|
||||||
|
AssertParity(DriverTypeNames.FOCAS, model.Validate(), model.ToJson());
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Calculation_config_the_editor_accepts_draws_no_deploy_warning()
|
public void Calculation_config_the_editor_accepts_draws_no_deploy_warning()
|
||||||
{
|
{
|
||||||
@@ -109,15 +163,27 @@ public sealed class TagConfigAuthoringDeployParityTests
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(DriverTypeNames.Sql, """{"model":"KeyValu","table":"dbo.T"}""")]
|
[InlineData(DriverTypeNames.Sql, """{"model":"KeyValu","table":"dbo.T"}""")]
|
||||||
// Note the MTConnect case is the DIGITS-only dataType, not a spelling typo: MTConnectTagConfigModel
|
// Both MTConnect shapes of an unreadable type, and they used to disagree: the digits-only "8" was
|
||||||
// reads dataType leniently (an unrecognised NAME silently loads as String, and a save rewrites it), so
|
// refused at authoring, while a misspelled NAME was silently loaded as String and rewritten on save —
|
||||||
// "Strng" is accepted at authoring while the strict runtime rejects it — a pre-existing
|
// accepted here, rejected by the driver, which is Gitea #549. MTConnectTagConfigModel.Validate now
|
||||||
// authoring-weaker-than-runtime gap in the editor, untouched here and out of G-7's scope. It does not
|
// asks MTConnectTagDefinitionFactory.IsAcceptedDataTypeName, the driver's own predicate, so the two
|
||||||
// affect the parity direction this file pins, because a blob saved through the modal always carries a
|
// shapes are one rule and neither gate can drift from the runtime.
|
||||||
// valid NAME.
|
|
||||||
[InlineData(DriverTypeNames.MTConnect, """{"fullName":"avail","dataType":"8"}""")]
|
[InlineData(DriverTypeNames.MTConnect, """{"fullName":"avail","dataType":"8"}""")]
|
||||||
|
[InlineData(DriverTypeNames.MTConnect, """{"fullName":"avail","dataType":"Strng"}""")]
|
||||||
[InlineData(DriverTypeNames.Calculation, """{"changeTriggered":true}""")]
|
[InlineData(DriverTypeNames.Calculation, """{"changeTriggered":true}""")]
|
||||||
[InlineData(DriverTypeNames.OpcUaClient, """{"nodeId":""}""")]
|
[InlineData(DriverTypeNames.OpcUaClient, """{"nodeId":""}""")]
|
||||||
|
// #549 survey: the same lenient-load / strict-runtime asymmetry existed at every other typed editor
|
||||||
|
// that reads an enum key, because R2-11 Phase C made every driver's TagConfig enum read strict
|
||||||
|
// (TryReadEnumStrict REJECTS the tag on a present-but-invalid string) while the editors kept loading
|
||||||
|
// them with a silent fallback. Each model's Validate() now consults the runtime's own
|
||||||
|
// TagConfigJson.DescribeInvalidEnumText, so a typo is refused by BOTH gates.
|
||||||
|
[InlineData(DriverTypeNames.Modbus, """{"region":"HoldingRegisters","address":1,"dataType":"Intt16"}""")]
|
||||||
|
[InlineData(DriverTypeNames.Modbus, """{"region":"HoldingRegister","address":1}""")]
|
||||||
|
[InlineData(DriverTypeNames.S7, """{"address":"DB1.DBW0","dataType":"Int166"}""")]
|
||||||
|
[InlineData(DriverTypeNames.AbCip, """{"tagPath":"Program:Main.Speed","dataType":"DIntt"}""")]
|
||||||
|
[InlineData(DriverTypeNames.AbLegacy, """{"address":"N7:0","dataType":"Intt"}""")]
|
||||||
|
[InlineData(DriverTypeNames.TwinCAT, """{"symbolPath":"MAIN.bStart","dataType":"DIntt"}""")]
|
||||||
|
[InlineData(DriverTypeNames.FOCAS, """{"address":"R100","dataType":"Int322"}""")]
|
||||||
public void The_same_driver_types_do_warn_about_a_config_the_editor_refuses(string driverType, string tagConfig)
|
public void The_same_driver_types_do_warn_about_a_config_the_editor_refuses(string driverType, string tagConfig)
|
||||||
{
|
{
|
||||||
TagConfigValidator.Validate(driverType, tagConfig).ShouldNotBeNullOrEmpty(
|
TagConfigValidator.Validate(driverType, tagConfig).ShouldNotBeNullOrEmpty(
|
||||||
|
|||||||
@@ -99,4 +99,25 @@ public sealed class TwinCATTagConfigModelTests
|
|||||||
|
|
||||||
json.ShouldContain("\"deviceHostAddress\":\"ab://host\"");
|
json.ShouldContain("\"deviceHostAddress\":\"ab://host\"");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gitea #549: the LOAD is lenient — an unrecognised name silently becomes <c>DInt</c> — but
|
||||||
|
/// <c>TwinCATTagDefinitionFactory</c> reads <c>dataType</c> with <c>TryReadEnumStrict</c> and
|
||||||
|
/// rejects the whole tag, so the save gate must judge the value the blob actually carries rather
|
||||||
|
/// than the one the load substituted.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Validate_refuses_a_dataType_the_driver_would_reject()
|
||||||
|
=> TwinCATTagConfigModel.FromJson("""{"symbolPath":"MAIN.bStart","dataType":"DIntt"}""")
|
||||||
|
.Validate().ShouldNotBeNull().ShouldContain("DIntt");
|
||||||
|
|
||||||
|
/// <summary>The other direction, which matters just as much: the check is the runtime's rule and not a
|
||||||
|
/// stricter one — a valid name in any casing, an absent key, and a present-but-NON-STRING token (which
|
||||||
|
/// <c>TryReadEnum</c> treats as absent) are all served by the driver and so must all save.</summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData("""{"symbolPath":"MAIN.bStart","dataType":"real"}""")]
|
||||||
|
[InlineData("""{"symbolPath":"MAIN.bStart"}""")]
|
||||||
|
[InlineData("""{"symbolPath":"MAIN.bStart","dataType":2}""")]
|
||||||
|
public void Validate_accepts_every_dataType_the_driver_accepts(string json)
|
||||||
|
=> TwinCATTagConfigModel.FromJson(json).Validate().ShouldBeNull();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,18 @@ public sealed class EquipmentTagConfigInspectorTests
|
|||||||
=> EquipmentTagConfigInspector.Inspect("MTConnect", """{"fullName":"avail","dataType":"8"}""")
|
=> EquipmentTagConfigInspector.Inspect("MTConnect", """{"fullName":"avail","dataType":"8"}""")
|
||||||
.ShouldContain(w => w.Contains("dataType"));
|
.ShouldContain(w => w.Contains("dataType"));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gitea #549 companion to <c>MTConnectTagConfigModelTests</c>: an UNQUOTED number is rejected by
|
||||||
|
/// the runtime too, and by a different mechanism than the quoted one — the blob DTO's type field is
|
||||||
|
/// a <c>string?</c>, so the deserialize itself throws and the whole tag goes. The authoring model
|
||||||
|
/// therefore has to judge the raw JSON token rather than "the string value, or null", or it would
|
||||||
|
/// read this as an absent type and accept a blob the driver drops.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void MTConnect_unquoted_numeric_dataType_is_reported()
|
||||||
|
=> EquipmentTagConfigInspector.Inspect("MTConnect", """{"fullName":"avail","dataType":8}""")
|
||||||
|
.ShouldNotBeEmpty();
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void MTConnect_config_naming_no_dataitem_is_reported()
|
public void MTConnect_config_naming_no_dataitem_is_reported()
|
||||||
=> EquipmentTagConfigInspector.Inspect("MTConnect", """{"dataType":"String"}""")
|
=> EquipmentTagConfigInspector.Inspect("MTConnect", """{"dataType":"String"}""")
|
||||||
|
|||||||
Reference in New Issue
Block a user