merge: #554 — authoring models consult the driver's own runtime parse (TagConfigParseGate): verdict from the parse, wording from Inspect; five drivers' non-enum gaps closed, deliberately-stricter rules kept and pinned

This commit is contained in:
Joseph Doherty
2026-08-10 01:55:31 -04:00
16 changed files with 940 additions and 45 deletions
@@ -22,6 +22,10 @@ public sealed class AbCipTagConfigModel
private JsonObject _bag = new();
// The blob this model was LOADED FROM, or null when built by object initialiser — the input to the
// Gitea #554 parse consult (see TagConfigParseGate).
private string? _source;
/// <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 TagConfig JSON string to parse, or <c>null</c>/empty for a new tag.</param>
@@ -35,6 +39,7 @@ public sealed class AbCipTagConfigModel
TagPath = TagConfigJson.GetString(o, "tagPath") ?? "",
DataType = TagConfigJson.GetEnum(o, "dataType", AbCipDataType.DInt),
Writable = TagConfigJson.GetBoolNullable(o, "writable"),
_source = json ?? "",
_bag = o,
};
}
@@ -48,7 +53,7 @@ public sealed class AbCipTagConfigModel
TagConfigJson.Set(_bag, "tagPath", TagPath.Trim());
TagConfigJson.Set(_bag, "dataType", DataType);
TagConfigJson.Set(_bag, "writable", Writable);
return TagConfigJson.Serialize(_bag);
return _source = TagConfigJson.Serialize(_bag);
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
@@ -76,6 +81,18 @@ public sealed class AbCipTagConfigModel
{
if (TagConfigJson.DescribeInvalidEnum<AbCipDataType>(_bag, "dataType") is { } dtError) { return dtError; }
if (string.IsNullOrWhiteSpace(TagPath)) { return "Tag path is required."; }
return AbCipTagDefinitionFactory.DescribeInvalidMember(TagConfigJson.Serialize(_bag));
if (AbCipTagDefinitionFactory.DescribeInvalidMember(TagConfigJson.Serialize(_bag)) is { } memberError)
{
return memberError;
}
// Gitea #554 backstop. The three checks above already mirror the whole of the reader's rejection
// surface, so this is expected to be silent for AbCip — it is here so a future reader rule cannot
// become a gap without anyone noticing, which is the failure #549/#551 kept repeating.
return TagConfigParseGate.Describe(
_source,
j => AbCipTagDefinitionFactory.FromTagConfig(j, TagConfigParseGate.AuthoringRawPath, out _),
AbCipTagDefinitionFactory.Inspect,
"AbCip");
}
}
@@ -22,6 +22,10 @@ public sealed class AbLegacyTagConfigModel
private JsonObject _bag = new();
// The blob this model was LOADED FROM, or null when built by object initialiser — the input to the
// Gitea #554 parse consult (see TagConfigParseGate).
private string? _source;
/// <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 TagConfig JSON string to parse, or <c>null</c>/empty for a new tag.</param>
@@ -35,6 +39,7 @@ public sealed class AbLegacyTagConfigModel
Address = TagConfigJson.GetString(o, "address") ?? "",
DataType = TagConfigJson.GetEnum(o, "dataType", AbLegacyDataType.Int),
Writable = TagConfigJson.GetBoolNullable(o, "writable"),
_source = json ?? "",
_bag = o,
};
}
@@ -48,7 +53,7 @@ public sealed class AbLegacyTagConfigModel
TagConfigJson.Set(_bag, "address", Address.Trim());
TagConfigJson.Set(_bag, "dataType", DataType);
TagConfigJson.Set(_bag, "writable", Writable);
return TagConfigJson.Serialize(_bag);
return _source = TagConfigJson.Serialize(_bag);
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
@@ -66,15 +71,26 @@ public sealed class AbLegacyTagConfigModel
if (string.IsNullOrWhiteSpace(Address))
return "Address is required.";
// Array-length cap: PCCC data-file elements are limited to AbLegacyArray.MaxElements (256).
// The driver-agnostic materialiser uses the raw arrayLength to set OPC UA ArrayDimensions, but the
// driver clamps to 256 at runtime — so an authored length > 256 produces a Part 6 §5.3.2 mismatch.
// Reject it here at author-time so the error surfaces in the TagModal before the config is saved.
// DELIBERATELY STRICTER THAN THE RUNTIME, and re-confirmed as such by the Gitea #554 survey — do not
// "align" it. PCCC data-file elements are limited to AbLegacyArray.MaxElements (256). The reader
// CLAMPS an authored length above that (Math.Min) and serves the tag, but the driver-agnostic
// materialiser sets OPC UA ArrayDimensions from the RAW authored length, so the served array is
// narrower than the node advertises — a Part 6 §5.3.2 mismatch, and the authored number is silently
// not honoured. Refusing at author-time, where the operator can still fix the number, is the honest
// treatment; the deploy gate deliberately stays silent about it (see AbLegacyTagDefinitionFactory
// .Inspect) because a warning there can be promoted to a blocking error against a tag the runtime
// does serve.
var isArray = TagConfigJson.GetBool(_bag, "isArray");
var arrayLength = TagConfigJson.GetInt(_bag, "arrayLength");
if (isArray && arrayLength > AbLegacyArray.MaxElements)
return $"AbLegacy array length cannot exceed {AbLegacyArray.MaxElements} (PCCC data-file element limit).";
return null;
// Gitea #554 backstop: the checks above mirror the reader's whole rejection surface today, so this is
// expected to be silent for AbLegacy. It is here so a future reader rule cannot become a gap.
return TagConfigParseGate.Describe(
_source,
j => AbLegacyTagDefinitionFactory.FromTagConfig(j, TagConfigParseGate.AuthoringRawPath, out _),
AbLegacyTagDefinitionFactory.Inspect,
"AbLegacy");
}
}
@@ -1,4 +1,5 @@
using System.Text.Json.Nodes;
using ZB.MOM.WW.OtOpcUa.Driver.Calculation;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
@@ -21,6 +22,10 @@ public sealed class CalculationTagConfigModel
private JsonObject _bag = new();
// The blob this model was LOADED FROM, or null when built by object initialiser — the input to the
// Gitea #554 parse consult (see TagConfigParseGate).
private string? _source;
/// <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>
@@ -38,6 +43,7 @@ public sealed class CalculationTagConfigModel
ScriptId = TagConfigJson.GetString(o, "scriptId") ?? "",
ChangeTriggered = TagConfigJson.GetBool(o, "changeTriggered", true),
TimerIntervalMs = timer,
_source = json ?? "",
_bag = o,
};
}
@@ -51,11 +57,19 @@ public sealed class CalculationTagConfigModel
TagConfigJson.Set(_bag, "scriptId", string.IsNullOrWhiteSpace(ScriptId) ? null : ScriptId);
TagConfigJson.Set(_bag, "changeTriggered", ChangeTriggered);
TagConfigJson.Set(_bag, "timerIntervalMs", TimerIntervalMs);
return TagConfigJson.Serialize(_bag);
return _source = TagConfigJson.Serialize(_bag);
}
/// <summary>Validates the calc binding: a script is required, at least one trigger (change or timer)
/// must be set, and a set timer must be ≥ 50 ms.</summary>
/// <remarks>
/// The trigger and 50 ms rules are <b>DELIBERATELY STRICTER THAN THE RUNTIME</b>, which serves a
/// trigger-less tag (it simply never computes) and honours a 1 ms cadence. The Gitea #554 survey
/// re-confirmed both and left them alone — they are documented on
/// <c>CalculationTagDefinitionFactory</c> as the reason its deploy gate must NOT restate them, since a
/// deploy warning can be promoted to a blocking error against a config the driver serves. The parse
/// consult below is additive and cannot weaken them.
/// </remarks>
/// <returns>An error message describing the validation failure, or <c>null</c> when the model is valid.</returns>
public string? Validate()
{
@@ -71,6 +85,15 @@ public sealed class CalculationTagConfigModel
{
return "Timer interval must be at least 50 ms.";
}
return null;
// Gitea #554 backstop — expected silent for Calculation, whose reader refuses only a blob with no
// usable 'scriptId'. ⚠️ It gates on the PARSE VERDICT: this driver's Inspect emits advisories for
// blobs the reader ACCEPTS (a non-boolean 'changeTriggered', a discarded 'timerIntervalMs'), so a
// non-empty-inspection gate would refuse configs the runtime serves.
return TagConfigParseGate.Describe(
_source,
j => CalculationTagDefinitionFactory.TryReadTagConfig(j, out _),
CalculationTagDefinitionFactory.Inspect,
"Calculation");
}
}
@@ -18,6 +18,10 @@ public sealed class FocasTagConfigModel
private JsonObject _bag = new();
// The blob this model was LOADED FROM, or null when built by object initialiser — the input to the
// Gitea #554 parse consult (see TagConfigParseGate).
private string? _source;
/// <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 null/empty for a fresh model.</param>
@@ -30,6 +34,7 @@ public sealed class FocasTagConfigModel
DeviceHostAddress = TagConfigJson.GetString(o, "deviceHostAddress") ?? "",
Address = TagConfigJson.GetString(o, "address") ?? "",
DataType = TagConfigJson.GetEnum(o, "dataType", FocasDataType.Int32),
_source = json ?? "",
_bag = o,
};
}
@@ -42,7 +47,7 @@ public sealed class FocasTagConfigModel
TagConfigJson.Set(_bag, "deviceHostAddress", string.IsNullOrWhiteSpace(DeviceHostAddress) ? null : DeviceHostAddress.Trim());
TagConfigJson.Set(_bag, "address", Address.Trim());
TagConfigJson.Set(_bag, "dataType", DataType);
return TagConfigJson.Serialize(_bag);
return _source = TagConfigJson.Serialize(_bag);
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
@@ -67,7 +72,22 @@ public sealed class FocasTagConfigModel
if (TagConfigJson.DescribeInvalidEnum<FocasDataType>(_bag, "dataType") is { } dtError) return dtError;
if (string.IsNullOrWhiteSpace(Address)) return "Address is required.";
return FocasAddress.TryParse(Address) is null ? DescribeUnparseableAddress(Address) : null;
// DELIBERATELY STRICTER THAN THE RUNTIME (Gitea #540), and the #554 survey re-confirmed it: the
// reader ACCEPTS an unparseable address — FocasTagDefinitionFactory.Inspect only advises about it —
// and the driver then skips the tag at startup. That is the defect #540 closed, so this check stays
// ahead of the parse consult below and is never replaced by it.
if (FocasAddress.TryParse(Address) is null) return DescribeUnparseableAddress(Address);
// Gitea #554 backstop. ⚠️ It gates on the PARSE VERDICT, never on Inspect being non-empty: FOCAS's
// inspector emits advisories for blobs the reader ACCEPTS (an unparseable address, an authored
// 'writable':true on a read-only wire), and promoting those to authoring errors would make the modal
// stricter than the runtime and leave rows nobody can save.
return TagConfigParseGate.Describe(
_source,
j => FocasTagDefinitionFactory.FromTagConfig(j, TagConfigParseGate.AuthoringRawPath, out _),
FocasTagDefinitionFactory.Inspect,
"FOCAS");
}
/// <summary>
@@ -79,6 +79,10 @@ public sealed class MTConnectTagConfigModel
// there — the runtime's own Inspect() names the offending field for the same reason.
private string _rawDataTypeKey = CanonicalDataTypeKey;
// The blob this model was LOADED FROM, or null when built by object initialiser — the input to the
// Gitea #554 parse consult (see TagConfigParseGate). Same capture-the-source pattern as _rawDataType.
private string? _source;
private JsonObject _bag = new();
/// <summary>
@@ -141,6 +145,7 @@ public sealed class MTConnectTagConfigModel
MtComponent = TagConfigJson.GetString(o, "mtComponent"),
_rawDataType = rawDataType,
_rawDataTypeKey = rawDataTypeKey,
_source = json ?? "",
_bag = o,
};
}
@@ -172,7 +177,7 @@ public sealed class MTConnectTagConfigModel
TagConfigJson.Set(_bag, "units", Blank(Units));
TagConfigJson.Set(_bag, "mtDevice", Blank(MtDevice));
TagConfigJson.Set(_bag, "mtComponent", Blank(MtComponent));
return TagConfigJson.Serialize(_bag);
return _source = TagConfigJson.Serialize(_bag);
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
@@ -201,7 +206,14 @@ public sealed class MTConnectTagConfigModel
+ $"data type. Valid: {string.Join(", ", Enum.GetNames<DriverDataType>())}.";
}
return null;
// Gitea #554 backstop — expected silent for MTConnect, whose reader refuses only an unparseable
// document, a JSON null, an id under none of the three spellings, and an unreadable coercion type,
// all covered above. It is here so a future reader rule cannot become a gap.
return TagConfigParseGate.Describe(
_source,
j => MTConnectTagDefinitionFactory.TryFromTagConfig(j, out _, out _),
MTConnectTagDefinitionFactory.Inspect,
"MTConnect");
}
// The as-loaded coercion-type TOKEN as text (see the _rawDataType and DataTypeKeys remarks), read in
@@ -32,6 +32,11 @@ public sealed class ModbusTagConfigModel
private JsonObject _bag = new();
// The blob this model was LOADED FROM, or null when it was built by object initialiser rather than
// loaded. See TagConfigParseGate's remarks: the lenient load above cannot be the input to the parse
// consult, because it is the leniency ("address":"notanumber" ⇒ 0) that hides the defect.
private string? _source;
/// <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>
@@ -41,6 +46,7 @@ public sealed class ModbusTagConfigModel
var o = TagConfigJson.ParseOrNew(json);
return new ModbusTagConfigModel
{
_source = json ?? "",
Region = TagConfigJson.GetEnum(o, "region", ModbusRegion.HoldingRegisters),
Address = TagConfigJson.GetInt(o, "address"),
DataType = TagConfigJson.GetEnum(o, "dataType", ModbusDataType.Int16),
@@ -84,11 +90,26 @@ public sealed class ModbusTagConfigModel
/// ever reach the bag from a hand-authored blob, a CSV import or the API — exactly the paths
/// this gate exists for.
/// </para>
/// <para>
/// <b>Everything the mapper refuses for a NON-enum reason is covered by the parse consult</b>
/// (Gitea #554), not by rules restated here: an absent <c>region</c>, an absent or non-numeric or
/// out-of-0–65535 <c>address</c>, a <c>String</c> tag with no <c>stringLength</c>, a
/// <c>BitInRegister</c> tag whose <c>bitIndex</c> narrows above 15. Modbus was the one editor with
/// no required field at all, so <c>{}</c> — what the modal persists for a tag whose config was
/// never touched — validated clean and then read <c>BadNodeIdUnknown</c> forever. The enum checks
/// stay ahead of the consult because they name the offending spelling, which the mapper's boolean
/// verdict cannot.
/// </para>
/// </remarks>
/// <returns>An error message describing the validation failure, or <c>null</c> when the model is valid.</returns>
public string? Validate()
=> TagConfigJson.DescribeInvalidEnum<ModbusRegion>(_bag, "region")
?? TagConfigJson.DescribeInvalidEnum<ModbusDataType>(_bag, "dataType")
?? TagConfigJson.DescribeInvalidEnum<ModbusByteOrder>(_bag, "byteOrder")
?? TagConfigJson.DescribeInvalidEnum<ModbusStringByteOrder>(_bag, "stringByteOrder");
?? TagConfigJson.DescribeInvalidEnum<ModbusStringByteOrder>(_bag, "stringByteOrder")
?? TagConfigParseGate.Describe(
_source,
j => ModbusTagDefinitionFactory.FromTagConfig(j, TagConfigParseGate.AuthoringRawPath, out _),
ModbusTagDefinitionFactory.Inspect,
"Modbus");
}
@@ -154,6 +154,10 @@ public sealed class MqttTagConfigModel
private JsonObject _bag = new();
// The blob this model was LOADED FROM, or null when built by object initialiser — the input to the
// Gitea #554 parse consult (see TagConfigParseGate).
private string? _source;
/// <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 — history intent, array/alarm objects, and
@@ -193,6 +197,7 @@ public sealed class MqttTagConfigModel
// Same "dataType" key as Plain's DataType above, read as an optional override — see the
// MetricDataType doc comment for why there are two typed fields over one JSON key.
MetricDataType = GetEnumNullable<DriverDataType>(o, DataTypeKey),
_source = json ?? "",
_bag = o,
};
}
@@ -245,7 +250,7 @@ public sealed class MqttTagConfigModel
TagConfigJson.Set(_bag, EdgeNodeIdKey, string.IsNullOrWhiteSpace(EdgeNodeId) ? null : EdgeNodeId.Trim());
TagConfigJson.Set(_bag, DeviceIdKey, string.IsNullOrWhiteSpace(DeviceId) ? null : DeviceId.Trim());
TagConfigJson.Set(_bag, MetricNameKey, string.IsNullOrWhiteSpace(MetricName) ? null : MetricName.Trim());
return TagConfigJson.Serialize(_bag);
return _source = TagConfigJson.Serialize(_bag);
}
/// <summary>
@@ -296,8 +301,27 @@ public sealed class MqttTagConfigModel
/// <returns>An error message describing the validation failure, or <c>null</c> when valid.</returns>
public string? Validate()
{
if (Mode == MqttMode.SparkplugB) { return ValidateSparkplug(); }
var error = Mode == MqttMode.SparkplugB ? ValidateSparkplug() : ValidatePlain();
if (error is not null) { return error; }
// Gitea #554 backstop, gating on the PARSE VERDICT (never on Inspect being non-empty — the MQTT
// inspector advises about a wildcard topic on blobs the reader ACCEPTS). The verdict is the UNION of
// the two readers, matching MqttTagDefinitionFactory.Inspect's own accept gate: a blob is served if
// EITHER shape reads it, and this model's Mode is only an inference from the descriptor keys.
// Expected to be near-silent — one shape it does newly catch is a `"qos": null`, which the reader
// refuses (Null is not a Number) while the model's own QoS check treats as absent.
return TagConfigParseGate.Describe(
_source,
j => MqttTagDefinitionFactory.FromTagConfig(j, TagConfigParseGate.AuthoringRawPath, out _)
|| MqttTagDefinitionFactory.FromSparkplugTagConfig(j, TagConfigParseGate.AuthoringRawPath, out _),
MqttTagDefinitionFactory.Inspect,
"Mqtt");
}
/// <summary>Plain-mode half of <see cref="Validate"/> — see its remarks for the full rationale.</summary>
/// <returns>An error message describing the validation failure, or <c>null</c> when valid.</returns>
private string? ValidatePlain()
{
if (DescribeInvalidEnum<MqttPayloadFormat>(_bag, PayloadFormatKey) is { } pfError) { return pfError; }
if (DescribeInvalidEnum<DriverDataType>(_bag, DataTypeKey) is { } dtError) { return dtError; }
if (DescribeInvalidQos(_bag) is { } qosError) { return qosError; }
@@ -1,4 +1,5 @@
using System.Text.Json.Nodes;
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
@@ -24,6 +25,10 @@ public sealed class OpcUaClientTagConfigModel
private JsonObject _bag = new();
// The blob this model was LOADED FROM, or null when built by object initialiser — the input to the
// Gitea #554 parse consult (see TagConfigParseGate).
private string? _source;
/// <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 tag's TagConfig JSON (null/blank/malformed ⇒ defaults).</param>
@@ -34,6 +39,7 @@ public sealed class OpcUaClientTagConfigModel
return new OpcUaClientTagConfigModel
{
NodeId = TagConfigJson.GetString(o, NodeIdKey) ?? "",
_source = json ?? "",
_bag = o,
};
}
@@ -46,11 +52,25 @@ public sealed class OpcUaClientTagConfigModel
public string ToJson()
{
TagConfigJson.Set(_bag, NodeIdKey, NodeId.Trim());
return TagConfigJson.Serialize(_bag);
return _source = TagConfigJson.Serialize(_bag);
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
/// <remarks>
/// The Gitea #554 parse consult is a backstop here: the required-<c>nodeId</c> check above already
/// covers the reader's whole rejection surface, and this model is in fact <b>stricter</b> in one
/// narrow shape — the reader still honours a legacy PascalCase <c>NodeId</c> key when <c>nodeId</c>
/// is wholly absent, while this model reads only the camelCase key and so refuses that blob. That
/// predates #554 and is deliberately left alone; the fix for such a blob is to re-enter the
/// reference, which rewrites it to the v3 key.
/// </remarks>
/// <returns>An error message describing the validation failure, or <c>null</c> when valid.</returns>
public string? Validate()
=> string.IsNullOrWhiteSpace(NodeId) ? "An upstream node reference (nodeId) is required." : null;
=> string.IsNullOrWhiteSpace(NodeId)
? "An upstream node reference (nodeId) is required."
: TagConfigParseGate.Describe(
_source,
j => OpcUaClientTagDefinitionFactory.TryReadNodeId(j, out _),
OpcUaClientTagDefinitionFactory.Inspect,
"OpcUaClient");
}
@@ -35,6 +35,10 @@ public sealed class S7TagConfigModel
// load of a blob authored elsewhere (hand-edited, CSV import, API).
private bool _dataTypeMissingInSource;
// The blob this model was LOADED FROM, or null when built by object initialiser — the parse consult's
// input (Gitea #554). Same capture-the-source reasoning as _dataTypeMissingInSource above.
private string? _source;
/// <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 null/empty for a fresh model.</param>
@@ -49,6 +53,7 @@ public sealed class S7TagConfigModel
StringLength = TagConfigJson.GetInt(o, "stringLength"),
Writable = TagConfigJson.GetBoolNullable(o, "writable"),
_dataTypeMissingInSource = !o.ContainsKey(S7TagDefinitionFactory.DataTypeKey),
_source = json ?? "",
_bag = o,
};
}
@@ -63,7 +68,7 @@ public sealed class S7TagConfigModel
_dataTypeMissingInSource = false;
TagConfigJson.Set(_bag, "stringLength", StringLength);
TagConfigJson.Set(_bag, "writable", Writable);
return TagConfigJson.Serialize(_bag);
return _source = TagConfigJson.Serialize(_bag);
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
@@ -100,10 +105,20 @@ public sealed class S7TagConfigModel
if (string.IsNullOrWhiteSpace(Address)) { return "Address is required."; }
return _dataTypeMissingInSource
? "A data type is required — the S7 driver rejects a tag whose TagConfig carries no "
+ $"'{S7TagDefinitionFactory.DataTypeKey}' key (it would read BadNodeIdUnknown). Valid: "
+ string.Join(", ", Enum.GetNames<S7DataType>()) + "."
: null;
if (_dataTypeMissingInSource)
{
return "A data type is required — the S7 driver rejects a tag whose TagConfig carries no "
+ $"'{S7TagDefinitionFactory.DataTypeKey}' key (it would read BadNodeIdUnknown). Valid: "
+ string.Join(", ", Enum.GetNames<S7DataType>()) + ".";
}
// Gitea #554: the reader's remaining, non-enum refusal — a String tag whose 'stringLength' is
// negative or above 254 (S7 REJECTS it rather than clamping) — is not restated here. The verdict and
// the wording both come from the driver's own reader, via TagConfigParseGate.
return TagConfigParseGate.Describe(
_source,
j => S7TagDefinitionFactory.FromTagConfig(j, TagConfigParseGate.AuthoringRawPath, out _),
S7TagDefinitionFactory.Inspect,
"S7");
}
}
@@ -50,6 +50,10 @@ public sealed class SqlTagConfigModel
private string? _invalidModelRaw;
private string? _invalidTypeRaw;
// The blob this model was LOADED FROM, or null when built by object initialiser — the input to the
// Gitea #554 parse consult (see TagConfigParseGate).
private string? _source;
/// <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>
@@ -74,6 +78,7 @@ public sealed class SqlTagConfigModel
RowSelector = SqlRowSelectorModel.FromJson(selectorObj),
Type = type,
_bag = o,
_source = json ?? "",
_invalidModelRaw = invalidModel,
_invalidTypeRaw = invalidType,
};
@@ -98,7 +103,7 @@ public sealed class SqlTagConfigModel
if (selector is null) { _bag.Remove("rowSelector"); }
else { _bag["rowSelector"] = selector; }
return TagConfigJson.Serialize(_bag);
return _source = TagConfigJson.Serialize(_bag);
}
/// <summary>Validates the model against the exact accept/reject boundary of
@@ -121,7 +126,7 @@ public sealed class SqlTagConfigModel
if (string.IsNullOrWhiteSpace(KeyColumn)) { return "keyColumn is required for a KeyValue tag."; }
if (string.IsNullOrWhiteSpace(ValueColumn)) { return "valueColumn is required for a KeyValue tag."; }
if (KeyValue is null) { return "keyValue is required for a KeyValue tag."; }
return null;
break;
case SqlTagModel.WideRow:
if (string.IsNullOrWhiteSpace(ColumnName)) { return "columnName is required for a WideRow tag."; }
@@ -132,11 +137,21 @@ public sealed class SqlTagConfigModel
{
return "a WideRow tag needs a row selector: a whereColumn/whereValue pair, or topByTimestamp.";
}
return null;
break;
default:
return "model is not a supported Sql tag model.";
}
// Gitea #554: the parser's RECOGNITION check is the rule nothing above states, and it is the one Sql
// rejection an otherwise-complete blob can hit — TryParse refuses a config that carries neither a
// 'model' key nor "driver":"Sql", because it cannot tell a Sql tag from any other driver's blob. The
// editor always writes 'model' on save, so this only ever fires for a hand-authored / CSV / API blob.
return TagConfigParseGate.Describe(
_source,
j => SqlEquipmentTagParser.TryParse(j, TagConfigParseGate.AuthoringRawPath, out _),
SqlTagDefinitionFactory.Inspect,
"Sql");
}
// Reads an enum-valued string field the way the parser's TryReadEnumStrict does: absent OR present-but-
@@ -0,0 +1,89 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
/// <summary>
/// The non-enum half of the authoring gate (Gitea #554): a driver's <c>&lt;Driver&gt;TagConfigModel.Validate()</c>
/// asks that driver's own runtime parse whether it would read the blob, and refuses the save when it
/// would not. One authority for the VERDICT and for the WORDING — the same shape #551 gave the deploy
/// gate, pointed at the other end of the pipe.
/// <para>
/// <b>Why the models could not simply grow more rules.</b> Before this, <c>Validate()</c> judged the
/// enum keys (against the runtime's own <c>DescribeInvalidEnumText</c>, Gitea #549/#551) plus a few
/// hand-written required-field checks, and nothing else. Every remaining reason a driver's reader
/// refuses a tag — an absent <c>region</c>, an <c>"address":"notanumber"</c>, an S7
/// <c>stringLength</c> of 300, a TwinCAT <c>dataType:"Structure"</c> — met no gate until deploy, or
/// until the driver skipped the tag. Re-stating each of those in each model would have been N more
/// hand-mirrored rules to drift, which is exactly the defect #551 closed on the deploy side.
/// </para>
/// </summary>
/// <remarks>
/// <para>
/// ⚠️ <b>The gate is the PARSE verdict, never the inspector's output.</b> An <c>Inspect</c> is
/// allowed to say things about a blob its parse ACCEPTS — FOCAS warns that an unparseable address
/// will be skipped and that <c>writable:true</c> cannot write, MQTT warns about a wildcard topic,
/// Calculation warns about a discarded <c>timerIntervalMs</c>. Treating a non-empty inspection as an
/// authoring error would make the modal STRICTER than the runtime and leave rows nobody can save —
/// the #520/#535 trap. So <see cref="Describe"/> calls the inspector only once the parse has already
/// refused, purely to word the refusal.
/// </para>
/// <para>
/// ⚠️ <b>It judges the blob the model was LOADED FROM, not the model's current fields.</b> The
/// lenient load is what hides these defects in the first place — <c>"address":"notanumber"</c> loads
/// as <c>0</c> — so a check against the typed fields would accept exactly the blobs this exists to
/// refuse. It is the same capture-the-source reasoning as <c>S7TagConfigModel</c>'s
/// <c>_dataTypeMissingInSource</c>, and it is also the honest input: on every authoring path the
/// string handed to <c>TagConfigValidator</c> is the string about to be persisted.
/// </para>
/// <para>
/// A model built by object initialiser rather than loaded (browse-commit's mapper, tests) passes
/// <see langword="null"/> and is not parse-checked — it has no source, and its <c>ToJson()</c> output
/// is what gets persisted. That output is covered instead by the fleet acceptance-parity theory in
/// <c>TagConfigAuthoringDeployParityTests</c>, which drives every mapped driver's freshly-authored
/// blob through both gates.
/// </para>
/// </remarks>
internal static class TagConfigParseGate
{
/// <summary>
/// The RawPath handed to a driver's parse during authoring inspection. Every mapper only copies it
/// onto the produced definition's identity and never validates it, so a placeholder is correct — the
/// gate is judging a blob, not a placed tag (the same reasoning as each factory's own
/// <c>InspectionRawPath</c>).
/// </summary>
internal const string AuthoringRawPath = "(authoring)";
/// <summary>
/// Returns an operator-facing error when <paramref name="parseAccepts"/> refuses
/// <paramref name="source"/>, worded by <paramref name="inspect"/>; <see langword="null"/> when the
/// parse accepts it or when there is no source to judge.
/// </summary>
/// <param name="source">The blob the model was loaded from, or <see langword="null"/> when it was not
/// loaded from JSON at all (see the class remarks).</param>
/// <param name="parseAccepts">The driver's own runtime parse, wrapped to a verdict.</param>
/// <param name="inspect">The driver's <c>Inspect</c>, consulted ONLY after a refusal, for wording.</param>
/// <param name="driverLabel">The driver name used by the backstop message.</param>
/// <returns>The error message, or <see langword="null"/> when the config is acceptable.</returns>
internal static string? Describe(
string? source,
Func<string, bool> parseAccepts,
Func<string, IReadOnlyList<string>> inspect,
string driverLabel)
{
ArgumentNullException.ThrowIfNull(parseAccepts);
ArgumentNullException.ThrowIfNull(inspect);
if (source is null) { return null; }
// A blank config is persisted as "{}" by every authoring path (RawTagModal, the manual-entry grid,
// RawTreeService), so that is the blob whose verdict the operator actually needs.
var blob = string.IsNullOrWhiteSpace(source) ? "{}" : source;
if (parseAccepts(blob)) { return null; }
var warnings = inspect(blob);
return warnings.Count > 0
? string.Join(" ", warnings)
// Every inspector short-circuits on a blob that does not start with '{', and some cannot name
// every refusal their mapper makes. Saying so beats letting a tag the driver skips be saved.
: $"{driverLabel} TagConfig is rejected by the driver's tag reader — the driver would skip this "
+ "tag and it would read BadNodeIdUnknown.";
}
}
@@ -22,6 +22,10 @@ public sealed class TwinCATTagConfigModel
private JsonObject _bag = new();
// The blob this model was LOADED FROM, or null when built by object initialiser — the input to the
// Gitea #554 parse consult (see TagConfigParseGate).
private string? _source;
/// <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 TagConfig JSON string to parse, or null/empty for a fresh model.</param>
@@ -35,6 +39,7 @@ public sealed class TwinCATTagConfigModel
SymbolPath = TagConfigJson.GetString(o, "symbolPath") ?? "",
DataType = TagConfigJson.GetEnum(o, "dataType", TwinCATDataType.DInt),
Writable = TagConfigJson.GetBoolNullable(o, "writable"),
_source = json ?? "",
_bag = o,
};
}
@@ -48,7 +53,7 @@ public sealed class TwinCATTagConfigModel
TagConfigJson.Set(_bag, "symbolPath", SymbolPath.Trim());
TagConfigJson.Set(_bag, "dataType", DataType);
TagConfigJson.Set(_bag, "writable", Writable);
return TagConfigJson.Serialize(_bag);
return _source = TagConfigJson.Serialize(_bag);
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
@@ -60,10 +65,21 @@ public sealed class TwinCATTagConfigModel
/// (<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).
/// <para>
/// <b><c>dataType: "Structure"</c> is the reason this model needed more than an enum check</b>
/// (Gitea #554): it is a perfectly valid <see cref="TwinCATDataType"/> NAME, so every spelling
/// gate passes it, and the reader refuses it anyway — the driver does not serve UDT / FB-instance
/// tags. The rule is not restated here; the parse consult asks the reader and quotes its answer.
/// </para>
/// </remarks>
public string? Validate()
{
if (TagConfigJson.DescribeInvalidEnum<TwinCATDataType>(_bag, "dataType") is { } dtError) { return dtError; }
return string.IsNullOrWhiteSpace(SymbolPath) ? "Symbol path is required." : null;
if (string.IsNullOrWhiteSpace(SymbolPath)) { return "Symbol path is required."; }
return TagConfigParseGate.Describe(
_source,
j => TwinCATTagDefinitionFactory.FromTagConfig(j, TagConfigParseGate.AuthoringRawPath, out _),
TwinCATTagDefinitionFactory.Inspect,
"TwinCAT");
}
}