diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index 0d9e5f18..f9170dfc 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -22,6 +22,7 @@ + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts/CalculationTagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts/CalculationTagDefinitionFactory.cs new file mode 100644 index 00000000..37ca4929 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts/CalculationTagDefinitionFactory.cs @@ -0,0 +1,161 @@ +using System.Text.Json; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation; + +/// +/// The four fields a calc tag's TagConfig blob carries, as +/// read them. Deliberately not the +/// driver's CalculationTagDefinition: that record additionally carries the tag's RawPath identity +/// and the dependency refs extracted from the script source, neither of which is in the blob. +/// +/// Logical FK to the shared Script entity that computes the tag; never blank. +/// The resolved C# script source; empty when the blob carried none. +/// Re-evaluate whenever a declared dependency changes (default ). +/// Periodic re-evaluation cadence in ms, or for change-trigger only. +public readonly record struct CalculationTagConfigFields( + string ScriptId, + string ScriptSource, + bool ChangeTriggered, + int? TimerIntervalMs); + +/// +/// The authored-raw-tag reader for the Calculation pseudo-driver: one authority, consumed by both +/// the runtime (CalculationTagDefinition.TryFromTagConfig, which adds the RawPath identity and the +/// script's dependency refs on top) and the deploy-time TagConfig gate (, +/// deferment-register G-7). +/// +/// The JSON reading moved here from the driver; the definition-building did not. Building a +/// CalculationTagDefinition needs EquipmentScriptPaths.ExtractDependencyRefs, which +/// lives in Commons and is genuinely runtime infrastructure — dragging it into a Contracts leaf to +/// co-locate the whole thing would make every consumer of this gate carry the scripting stack. The +/// split is at the blob boundary: everything the blob says is read here, once. +/// +/// +/// is never stricter than . It reports +/// only two things: a blob the read REJECTS (which the driver skips as "not a calc tag"), and fields +/// that are present but in a shape the lenient read silently discards — changeTriggered that +/// is not a JSON boolean (read as ) and timerIntervalMs that is not a +/// positive JSON integer (read as no timer). Both are the sanctioned "present-but-invalid, silently +/// defaulted" warning class. +/// +/// +/// What it deliberately does NOT warn about: a tag with no trigger at all +/// (changeTriggered:false and no timer). The AdminUI's CalculationTagConfigModel.Validate +/// rejects that shape and the runtime accepts it, and this gate follows the runtime — it may +/// never call a blob the driver serves invalid, because the deploy gate can be configured to turn its +/// warnings into blocking errors. The same reasoning covers a sub-50 ms timer interval, which the +/// editor refuses and the driver honours. +/// +/// +public static class CalculationTagDefinitionFactory +{ + /// + /// Reads a calc tag's TagConfig blob + /// ({ "scriptId", "scriptSource", "changeTriggered", "timerIntervalMs" }). Returns + /// — never throws — only when the blob is unreadable, is not a JSON object, + /// or names no scriptId: a tag with no identifiable script cannot be a calc tag. A present + /// scriptId with an empty/absent scriptSource still reads (the driver logs it and the + /// tag never computes). + /// + /// The calc tag's schemaless TagConfig JSON. + /// The read fields when this returns . + /// when the blob names a script. + public static bool TryReadTagConfig(string tagConfig, out CalculationTagConfigFields fields) + { + fields = default; + if (string.IsNullOrWhiteSpace(tagConfig)) return false; + + try + { + using var doc = JsonDocument.Parse(tagConfig); + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object) return false; + + var scriptId = root.TryGetProperty("scriptId", out var sidEl) && sidEl.ValueKind == JsonValueKind.String + ? sidEl.GetString() : null; + if (string.IsNullOrWhiteSpace(scriptId)) return false; + + var scriptSource = root.TryGetProperty("scriptSource", out var srcEl) && srcEl.ValueKind == JsonValueKind.String + ? srcEl.GetString() ?? string.Empty : string.Empty; + + // changeTriggered defaults to true (mini-design §2) — only an explicit false disables it. + var changeTriggered = !(root.TryGetProperty("changeTriggered", out var ctEl) + && ctEl.ValueKind == JsonValueKind.False); + + var timerMs = root.TryGetProperty("timerIntervalMs", out var tEl) + && tEl.ValueKind == JsonValueKind.Number && tEl.TryGetInt32(out var ms) && ms > 0 + ? ms : (int?)null; + + fields = new CalculationTagConfigFields(scriptId!, scriptSource, changeTriggered, timerMs); + return true; + } + catch (JsonException) + { + return false; + } + } + + /// + /// Deploy-time inspection of a calc tag's TagConfig blob. Warns for a blob + /// rejects (unparseable JSON, a non-object root, or no usable + /// scriptId), and for a present-but-discarded changeTriggered / timerIntervalMs + /// — see the class remarks for what is deliberately NOT warned about. Empty when the blob is clean, + /// blank, or not a JSON object at all. Never throws. + /// + /// The equipment tag's TagConfig JSON. + /// The warnings; empty when clean. + public static IReadOnlyList Inspect(string reference) + { + var warnings = new List(); + if (string.IsNullOrWhiteSpace(reference) || reference.TrimStart().FirstOrDefault() != '{') + return warnings; + + JsonDocument doc; + try + { + doc = JsonDocument.Parse(reference); + } + catch (JsonException) + { + warnings.Add("Calculation TagConfig is not valid JSON — the driver will skip this tag and it will never compute."); + return warnings; + } + + using (doc) + { + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + warnings.Add("Calculation TagConfig root is not a JSON object — the driver will skip this tag and it will never compute."); + return warnings; + } + + if (!TryReadTagConfig(reference, out _)) + { + warnings.Add( + "Calculation TagConfig has no usable 'scriptId' (a non-blank string) — the driver does not " + + "treat this as a calc tag, skips it, and it never computes."); + } + + // Present-but-discarded fields. The read is lenient here by design, so these say what was + // IGNORED — they never claim the tag is invalid. + if (root.TryGetProperty("changeTriggered", out var ct) + && ct.ValueKind is not (JsonValueKind.True or JsonValueKind.False)) + { + warnings.Add( + $"value '{ct.GetRawText()}' for 'changeTriggered' is not a JSON boolean; anything but an " + + "explicit false is read as TRUE, so this tag is change-triggered whatever the value looks like."); + } + + if (root.TryGetProperty("timerIntervalMs", out var timer) + && !(timer.ValueKind == JsonValueKind.Number && timer.TryGetInt32(out var ms) && ms > 0)) + { + warnings.Add( + $"value '{timer.GetRawText()}' for 'timerIntervalMs' is not a positive whole number of " + + "milliseconds; it is discarded and the tag gets NO periodic re-evaluation."); + } + } + + return warnings; + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts.csproj new file mode 100644 index 00000000..ad5239ba --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + true + true + $(NoWarn);CS1591 + ZB.MOM.WW.OtOpcUa.Driver.Calculation + + + + + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationTagDefinition.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationTagDefinition.cs index 416f3ba2..b08f3f37 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationTagDefinition.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationTagDefinition.cs @@ -1,4 +1,3 @@ -using System.Text.Json; using ZB.MOM.WW.OtOpcUa.Commons.Types; namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation; @@ -40,45 +39,20 @@ public sealed record CalculationTagDefinition( public static bool TryFromTagConfig(string tagConfig, string rawPath, out CalculationTagDefinition def) { def = null!; - if (string.IsNullOrWhiteSpace(tagConfig)) return false; - string? scriptId; - string scriptSource; - bool changeTriggered; - int? timerMs; - try - { - using var doc = JsonDocument.Parse(tagConfig); - var root = doc.RootElement; - if (root.ValueKind != JsonValueKind.Object) return false; + // The blob read itself lives in Driver.Calculation.Contracts so the deploy-time TagConfig gate + // (EquipmentTagConfigInspector) and this mapping cannot disagree about what a calc tag's blob says + // (G-7). Everything below is what the BLOB does not carry: the RawPath identity and the dependency + // refs extracted from the script source. + if (!CalculationTagDefinitionFactory.TryReadTagConfig(tagConfig, out var fields)) return false; - scriptId = root.TryGetProperty("scriptId", out var sidEl) && sidEl.ValueKind == JsonValueKind.String - ? sidEl.GetString() : null; - if (string.IsNullOrWhiteSpace(scriptId)) return false; - - scriptSource = root.TryGetProperty("scriptSource", out var srcEl) && srcEl.ValueKind == JsonValueKind.String - ? srcEl.GetString() ?? string.Empty : string.Empty; - - // changeTriggered defaults to true (mini-design §2) — only an explicit false disables it. - changeTriggered = !(root.TryGetProperty("changeTriggered", out var ctEl) - && ctEl.ValueKind == JsonValueKind.False); - - timerMs = root.TryGetProperty("timerIntervalMs", out var tEl) - && tEl.ValueKind == JsonValueKind.Number && tEl.TryGetInt32(out var ms) && ms > 0 - ? ms : null; - } - catch (JsonException) - { - return false; - } - - var deps = EquipmentScriptPaths.ExtractDependencyRefs(scriptSource); + var deps = EquipmentScriptPaths.ExtractDependencyRefs(fields.ScriptSource); def = new CalculationTagDefinition( RawPath: rawPath, - ScriptId: scriptId!, - ScriptSource: scriptSource, - ChangeTriggered: changeTriggered, - TimerInterval: timerMs is { } m ? TimeSpan.FromMilliseconds(m) : null, + ScriptId: fields.ScriptId, + ScriptSource: fields.ScriptSource, + ChangeTriggered: fields.ChangeTriggered, + TimerInterval: fields.TimerIntervalMs is { } m ? TimeSpan.FromMilliseconds(m) : null, DependencyRefs: deps); return true; } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/ZB.MOM.WW.OtOpcUa.Driver.Calculation.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/ZB.MOM.WW.OtOpcUa.Driver.Calculation.csproj index 606fd3e9..0efbc7e0 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/ZB.MOM.WW.OtOpcUa.Driver.Calculation.csproj +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/ZB.MOM.WW.OtOpcUa.Driver.Calculation.csproj @@ -19,6 +19,9 @@ for CompilationErrorException). Core.Scripting.Abstractions owns VirtualTagContext + PassthroughScript; Commons owns VirtualTagEvalResult + IScriptCacheOwner; Core.Abstractions owns DataValueSnapshot. --> + + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectTagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectTagDefinitionFactory.cs new file mode 100644 index 00000000..129fba5e --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectTagDefinitionFactory.cs @@ -0,0 +1,230 @@ +using System.Text.Json; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect; + +/// +/// The authored-raw-tag reader for the MTConnect driver: one authority, consumed by both the runtime +/// (MTConnectDriver's RawPath → definition table build) and the deploy-time TagConfig gate +/// (, deferment-register G-7). +/// +/// This parse used to be private to MTConnectDriver. It moved here — unchanged — +/// rather than being mirrored, because a mirror of an accept/reject boundary drifts the moment +/// either side is touched, and the drift is invisible: the gate would go on describing a shape the +/// driver no longer reads. Nothing about the reading changed in the move; the driver now calls +/// where it called its own copy. +/// +/// +/// is never stricter than — it asks the +/// parse for the verdict and only explains a rejection, so a blob the driver serves can never draw +/// a warning calling it invalid. +/// +/// +public static class MTConnectTagDefinitionFactory +{ + /// + /// Reader options, mirroring MTConnectDriver's config reader (from which this moved). Note + /// there is deliberately no JsonStringEnumConverter: the type-carrying field stays a + /// string? and is parsed by name below, which is the project-wide guard against the + /// numerically-serialized-enum trap. + /// + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }; + + /// + /// Reads one authored raw tag's TagConfig blob: the DataItem id it binds, and the definition + /// its blob declares ( when the blob declares no type and the caller must fall + /// back to the Agent's /probe declaration). Never throws. + /// + /// The authored equipment-tag TagConfig JSON. + /// The Agent DataItem id the tag binds, when this returns . + /// The definition the blob declared, or when it declared no type. + /// + /// when the blob is unreadable, names no DataItem id, or carries a + /// driverDataType/dataType that is not a — a + /// present-but-invalid type rejects the tag rather than silently defaulting, which would publish the + /// Agent's text as a Good value of the wrong type (the sibling drivers' strict-enum posture). + /// + public static bool TryFromTagConfig(string? tagConfig, out string dataItemId, out MTConnectTagDefinition? authored) + { + dataItemId = string.Empty; + authored = null; + + if (string.IsNullOrWhiteSpace(tagConfig)) return false; + + MTConnectRawTagConfigDto? blob; + try + { + blob = JsonSerializer.Deserialize(tagConfig, JsonOptions); + } + catch (JsonException) + { + return false; + } + + if (blob is null) return false; + + var id = FirstNonBlank(blob.FullName, blob.DataItemId, blob.Address); + if (id is null) return false; + + dataItemId = id; + + // "driverDataType" (the driver's own tags[] field) or "dataType" (what the AdminUI's typed + // MTConnectTagConfigModel writes) — see the DTO remarks. + var authoredType = FirstNonBlank(blob.DriverDataType, blob.DataType); + if (authoredType is null) return true; + + if (!TryParseDataType(authoredType, out var parsed)) return false; + + authored = new MTConnectTagDefinition( + dataItemId, + parsed, + blob.IsArray ?? false, + blob.ArrayDim ?? 0, + blob.MtCategory, + blob.MtType, + blob.MtSubType, + blob.Units); + + return true; + } + + /// + /// Deploy-time inspection of an equipment-tag TagConfig blob: warns for a blob + /// would reject — unparseable JSON, no DataItem id under any of the + /// three accepted spellings, or a present-but-invalid coercion type (including the digits-only + /// numerically-serialized-enum shape). Empty when the parse accepts the blob, when it is blank, or + /// when it is not a JSON object at all. Never throws. + /// + /// The equipment tag's TagConfig JSON. + /// The warnings; empty when clean. + public static IReadOnlyList Inspect(string reference) + { + var warnings = new List(); + if (string.IsNullOrWhiteSpace(reference) || reference.TrimStart().FirstOrDefault() != '{') + return warnings; + + // The accept gate: whatever the driver reads draws nothing. See the class remarks. + if (TryFromTagConfig(reference, out _, out _)) return warnings; + + MTConnectRawTagConfigDto? blob; + try + { + blob = JsonSerializer.Deserialize(reference, JsonOptions); + } + catch (JsonException) + { + warnings.Add("MTConnect TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown)."); + return warnings; + } + + if (blob is null) + { + warnings.Add("MTConnect TagConfig is a JSON null — the tag binds no DataItem and will be skipped."); + return warnings; + } + + if (FirstNonBlank(blob.FullName, blob.DataItemId, blob.Address) is null) + { + warnings.Add( + "MTConnect TagConfig names no DataItem id (expected 'fullName', 'dataItemId' or 'address') — " + + "the driver will skip this tag and it will read BadNodeIdUnknown."); + } + + var authoredType = FirstNonBlank(blob.DriverDataType, blob.DataType); + if (authoredType is not null && !TryParseDataType(authoredType, out _)) + { + var field = string.IsNullOrWhiteSpace(blob.DriverDataType) ? "dataType" : "driverDataType"; + warnings.Add( + $"value '{authoredType}' for '{field}' is not a valid DriverDataType name; valid: " + + string.Join(", ", Enum.GetNames())); + } + + if (warnings.Count == 0) + { + warnings.Add( + "MTConnect TagConfig was rejected by the driver's tag reader — the driver will skip this tag " + + "and it will read BadNodeIdUnknown."); + } + + return warnings; + } + + /// + /// Parses an authored coercion type by NAME. A digits-only value is rejected outright, BEFORE the + /// parse: accepts "8" as readily + /// as "Float64" and returns the member with that ordinal, so a numerically-serialized enum + /// (the repo-wide authoring trap) would otherwise coerce every value of that tag to a type nobody + /// chose, under Good quality. + /// + /// The authored type text. + /// The parsed type when this returns . + /// when is a defined member NAME. + private static bool TryParseDataType(string raw, out DriverDataType parsed) + { + parsed = default; + if (raw.All(char.IsAsciiDigit)) return false; + return Enum.TryParse(raw, ignoreCase: true, out parsed) && Enum.IsDefined(parsed); + } + + /// The first of that carries text, trimmed; else null. + /// The candidate values, in precedence order. + /// The first non-blank candidate, trimmed, or null. + private static string? FirstNonBlank(params string?[] candidates) + { + foreach (var candidate in candidates) + { + if (!string.IsNullOrWhiteSpace(candidate)) return candidate.Trim(); + } + + return null; + } + + /// + /// A raw tag's TagConfig blob: the driver-specific address of one authored raw tag. + /// Every field is optional — the blob is operator-authorable and reaches the driver through + /// several authoring surfaces that populate different subsets of it. + /// + /// + /// + /// Three accepted spellings of the DataItem id, tried in order: + /// fullName (the driver's own tag shape, and what the typed editor writes), + /// dataItemId (the protocol's own word for it, and the spelling an operator + /// hand-authoring the blob reaches for first), and address (what + /// RawBrowseCommitMapper writes for a driver with no typed editor of its own — the + /// shape every browse-committed MTConnect tag currently arrives in). Accepting all three + /// costs two dictionary probes; rejecting two of them would make a tag committed from the + /// address picker silently unreadable. + /// + /// + /// Two accepted spellings of the coercion type, likewise: dataType — what + /// the AdminUI's MTConnectTagConfigModel writes, and therefore what every tag + /// authored through the typed editor carries — and driverDataType, matching the + /// driver's own tags[] field name. Reading only one of them would make the editor + /// and the driver disagree about the type of every tag. + /// + /// + /// Kept separate from the driver's strict tags[] DTO rather than reusing it: there a + /// missing fullName is a config error worth failing the deployment over. A raw tag's + /// blob is authored elsewhere and its failure mode is per-tag, not per-driver. + /// + /// + private sealed class MTConnectRawTagConfigDto + { + public string? FullName { get; init; } + public string? DataItemId { get; init; } + public string? Address { get; init; } + public string? DriverDataType { get; init; } + public string? DataType { get; init; } + public bool? IsArray { get; init; } + public int? ArrayDim { get; init; } + public string? MtCategory { get; init; } + public string? MtType { get; init; } + public string? MtSubType { get; init; } + public string? Units { get; init; } + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs index 6302aa9f..a666a27d 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs @@ -3250,7 +3250,7 @@ public sealed class MTConnectDriver { if (entry is null || string.IsNullOrWhiteSpace(entry.RawPath)) continue; - if (!TryReadRawTag(entry, out var dataItemId, out var authored)) + if (!MTConnectTagDefinitionFactory.TryFromTagConfig(entry.TagConfig, out var dataItemId, out var authored)) { _skippedTags.Skip( entry.RawPath, @@ -3392,71 +3392,6 @@ public sealed class MTConnectDriver return map.ToFrozenDictionary(StringComparer.Ordinal); } - /// - /// Reads one authored raw tag: the DataItem id it binds, and the definition its blob declares - /// (null when the blob declares no type and the caller must fall back). - /// - /// - /// when the blob is unreadable, names no DataItem id, or carries a - /// driverDataType that is not a — a present-but-invalid - /// type rejects the tag rather than silently defaulting, which would publish the Agent's text - /// as a Good value of the wrong type (the sibling drivers' strict-enum posture). - /// - private static bool TryReadRawTag( - RawTagEntry entry, out string dataItemId, out MTConnectTagDefinition? authored) - { - dataItemId = string.Empty; - authored = null; - - if (string.IsNullOrWhiteSpace(entry.TagConfig)) return false; - - MTConnectRawTagConfigDto? blob; - try - { - blob = JsonSerializer.Deserialize(entry.TagConfig, JsonOptions); - } - catch (JsonException) - { - return false; - } - - if (blob is null) return false; - - var id = FirstNonBlank(blob.FullName, blob.DataItemId, blob.Address); - if (id is null) return false; - - dataItemId = id; - - // "driverDataType" (the driver's own tags[] field) or "dataType" (what the AdminUI's typed - // MTConnectTagConfigModel writes) — see the DTO remarks. - var authoredType = FirstNonBlank(blob.DriverDataType, blob.DataType); - if (authoredType is null) return true; - - // A digits-only type is rejected outright, BEFORE the parse: Enum.TryParse accepts "8" as - // readily as "Float64" and returns the member with that ordinal, so a numerically-serialized - // enum (the repo-wide authoring trap) would otherwise coerce every value of that tag to a - // type nobody chose, under Good quality. The AdminUI's MTConnectTagConfigModel.Validate - // refuses the same shape; this is the runtime half of that guard. - if (authoredType.All(char.IsAsciiDigit) - || !Enum.TryParse(authoredType, ignoreCase: true, out var parsed) - || !Enum.IsDefined(parsed)) - { - return false; - } - - authored = new MTConnectTagDefinition( - dataItemId, - parsed, - blob.IsArray ?? false, - blob.ArrayDim ?? 0, - blob.MtCategory, - blob.MtType, - blob.MtSubType, - blob.Units); - - return true; - } - /// /// The definition for a raw tag whose blob declared no type: the Agent's own /probe /// declaration when the model is in hand, else — the one @@ -3505,17 +3440,6 @@ public sealed class MTConnectDriver return map.ToFrozenDictionary(StringComparer.Ordinal); } - /// The first of that carries text, trimmed; else null. - private static string? FirstNonBlank(params string?[] candidates) - { - foreach (var candidate in candidates) - { - if (!string.IsNullOrWhiteSpace(candidate)) return candidate.Trim(); - } - - return null; - } - /// /// Parses an enum authored as a name. Config surfaces serialize enums as names /// project-wide; a numerically-serialized enum is the known trap that faults a driver at @@ -3735,51 +3659,6 @@ public sealed class MTConnectDriver public MTConnectWriteDto? Write { get; init; } } - /// - /// A raw tag's TagConfig blob: the driver-specific address of one authored raw tag. - /// Every field is optional — the blob is operator-authorable and reaches the driver through - /// several authoring surfaces that populate different subsets of it. - /// - /// - /// - /// Three accepted spellings of the DataItem id, tried in order: - /// fullName (the driver's own tag shape, and what the typed editor writes), - /// dataItemId (the protocol's own word for it, and the spelling an operator - /// hand-authoring the blob reaches for first), and address (what - /// RawBrowseCommitMapper writes for a driver with no typed editor of its own — the - /// shape every browse-committed MTConnect tag currently arrives in). Accepting all three - /// costs two dictionary probes; rejecting two of them would make a tag committed from the - /// address picker silently unreadable. - /// - /// - /// Two accepted spellings of the coercion type, likewise: dataType — what - /// the AdminUI's MTConnectTagConfigModel writes, and therefore what every tag - /// authored through the typed editor carries — and driverDataType, matching the - /// driver's own tags[] field name. Reading only one of them would make the editor - /// and the driver disagree about the type of every tag. - /// - /// - /// Kept separate from rather than reusing it: that DTO is - /// the strict tags[] surface, where a missing fullName is a config error - /// worth failing the deployment over. A raw tag's blob is authored elsewhere and its - /// failure mode is per-tag, not per-driver. - /// - /// - private sealed class MTConnectRawTagConfigDto - { - public string? FullName { get; init; } - public string? DataItemId { get; init; } - public string? Address { get; init; } - public string? DriverDataType { get; init; } - public string? DataType { get; init; } - public bool? IsArray { get; init; } - public int? ArrayDim { get; init; } - public string? MtCategory { get; init; } - public string? MtType { get; init; } - public string? MtSubType { get; init; } - public string? Units { get; init; } - } - /// One authored tag. DriverDataType stays a string — see . private sealed class MTConnectTagDto { diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/NamespaceMap.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/NamespaceMap.cs index 776dfa40..d7371d86 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/NamespaceMap.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/NamespaceMap.cs @@ -1,4 +1,3 @@ -using System.Text.Json; using Opc.Ua; using Opc.Ua.Client; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; @@ -119,37 +118,14 @@ public sealed class NamespaceMap foreach (var entry in rawTags) { if (string.IsNullOrWhiteSpace(entry.RawPath)) continue; - if (!TryReadNodeId(entry.TagConfig, out var nodeId)) continue; + // The blob read lives on OpcUaClientTagDefinitionFactory so the deploy-time TagConfig gate and + // this table build cannot disagree about what a usable nodeId is (G-7). + if (!OpcUaClientTagDefinitionFactory.TryReadNodeId(entry.TagConfig, out var nodeId)) continue; table[entry.RawPath] = new RawTagBinding(nodeId, entry.WriteIdempotent); } return table; } - /// - /// Read the upstream node id string from an authored TagConfig blob's nodeId key - /// (camelCase, per the AdminUI OpcUaClientTagConfigModel; a legacy PascalCase NodeId - /// is also accepted). Never throws — a malformed blob yields . - /// - private static bool TryReadNodeId(string tagConfig, out string nodeId) - { - nodeId = string.Empty; - if (string.IsNullOrWhiteSpace(tagConfig)) return false; - try - { - using var doc = JsonDocument.Parse(tagConfig); - var root = doc.RootElement; - if (root.ValueKind != JsonValueKind.Object) return false; - if ((root.TryGetProperty("nodeId", out var n) || root.TryGetProperty("NodeId", out n)) - && n.ValueKind == JsonValueKind.String) - { - var s = n.GetString(); - if (!string.IsNullOrWhiteSpace(s)) { nodeId = s; return true; } - } - } - catch (JsonException) { /* malformed blob — treat as unresolvable */ } - return false; - } - /// Number of namespaces captured. Index 0 is always the OPC UA core namespace. public int Count => _uris.Length; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaClientTagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaClientTagDefinitionFactory.cs new file mode 100644 index 00000000..6f35ab84 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaClientTagDefinitionFactory.cs @@ -0,0 +1,95 @@ +using System.Text.Json; + +namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; + +/// +/// The authored-raw-tag reader for the OPC UA Client (gateway) driver: one authority, consumed by both +/// the runtime ('s RawPath → upstream node id table build) and the +/// deploy-time TagConfig gate (, deferment-register G-7). +/// +/// This read used to be private to . It moved here — unchanged — +/// rather than being mirrored: a mirror of an accept/reject boundary drifts the moment either side +/// is touched, and the drift is silent, with the gate describing a shape the driver no longer +/// reads. now calls . +/// +/// +/// is never stricter than — it asks the +/// read for the verdict and only explains a rejection, so a blob the driver resolves can never draw +/// a warning calling it invalid. Nothing else in the blob is checked: the upstream node id is an +/// opaque reference string that the SDK re-binds against the live session, so whether it names a +/// node that exists is knowable only at connect time, not at deploy time. +/// +/// +public static class OpcUaClientTagDefinitionFactory +{ + /// + /// Read the upstream node id string from an authored TagConfig blob's nodeId key + /// (camelCase, per the AdminUI OpcUaClientTagConfigModel; a legacy PascalCase NodeId + /// is also accepted). Never throws — a malformed blob yields . + /// + /// The authored equipment-tag TagConfig JSON. + /// The upstream node id string when this returns . + /// when the blob carries a usable upstream node reference. + public static bool TryReadNodeId(string tagConfig, out string nodeId) + { + nodeId = string.Empty; + if (string.IsNullOrWhiteSpace(tagConfig)) return false; + try + { + using var doc = JsonDocument.Parse(tagConfig); + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object) return false; + if ((root.TryGetProperty("nodeId", out var n) || root.TryGetProperty("NodeId", out n)) + && n.ValueKind == JsonValueKind.String) + { + var s = n.GetString(); + if (!string.IsNullOrWhiteSpace(s)) { nodeId = s; return true; } + } + } + catch (JsonException) { /* malformed blob — treat as unresolvable */ } + return false; + } + + /// + /// Deploy-time inspection of an equipment-tag TagConfig blob: warns for a blob + /// would reject — unparseable JSON, a non-object root, or no usable + /// nodeId (absent, non-string, or blank). Such a tag is dropped from the driver's resolution + /// table and reads BadNodeIdInvalid forever, which before Gitea #541 was the quietest skip + /// of the twelve drivers — not even a log line. Empty when the blob resolves, is blank, or is not a + /// JSON object at all. Never throws. + /// + /// The equipment tag's TagConfig JSON. + /// The warnings; empty when clean. + public static IReadOnlyList Inspect(string reference) + { + var warnings = new List(); + if (string.IsNullOrWhiteSpace(reference) || reference.TrimStart().FirstOrDefault() != '{') + return warnings; + + // The accept gate: whatever the driver resolves draws nothing. See the class remarks. + if (TryReadNodeId(reference, out _)) return warnings; + + try + { + using var doc = JsonDocument.Parse(reference); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + { + warnings.Add( + "OpcUaClient TagConfig root is not a JSON object — the tag binds no upstream node and " + + "will read BadNodeIdInvalid."); + return warnings; + } + } + catch (JsonException) + { + warnings.Add("OpcUaClient TagConfig is not valid JSON — the tag will not resolve (BadNodeIdInvalid)."); + return warnings; + } + + warnings.Add( + "OpcUaClient TagConfig has no usable 'nodeId' (a non-blank string holding the upstream " + + "nsu=…/ns=N;… reference) — the driver drops this tag from its resolution table and it reads " + + "BadNodeIdInvalid."); + return warnings; + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlTagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlTagDefinitionFactory.cs new file mode 100644 index 00000000..cf06e1d3 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlTagDefinitionFactory.cs @@ -0,0 +1,183 @@ +using System.Text.Json; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +/// +/// Deploy-time inspection of a Sql raw tag's TagConfig blob (deferment-register G-7). The Sql +/// driver already owns its runtime parse in this assembly +/// (), so this type adds only the operator-facing +/// half: it never re-implements the accept/reject decision, it asks the parser and then explains a +/// rejection. +/// +/// Why the verdict comes from the parser and not from a mirror. The one rule this gate must +/// never break is being stricter than the runtime: a blob the driver happily serves must not draw a +/// deploy warning calling it invalid. Running first and +/// returning empty on success makes that structural rather than a promise — a warning is reachable +/// only for a blob the driver would have refused (and then skipped silently, reading +/// BadNodeIdUnknown forever). +/// +/// +/// The class is named SqlTagDefinitionFactory to match the six sibling +/// *TagDefinitionFactory.Inspect entry points the deploy gate discovers by reflection +/// (EquipmentTagConfigInspectorCoverageTests); the mapping half of a "factory" already lives +/// on and is deliberately not duplicated here. +/// +/// +public static class SqlTagDefinitionFactory +{ + /// + /// The RawPath handed to the parser during inspection. The parser only copies it onto + /// and never validates it, so inspection is free to pass a + /// placeholder — the deploy gate is inspecting a blob, not a placed tag. + /// + private const string InspectionRawPath = "(inspect)"; + + /// + /// Deploy-time inspection of an equipment-tag TagConfig blob. Returns human-readable warnings + /// for a blob would reject — an unparseable or + /// non-object document, a blob the driver does not recognise as a Sql one, a present-but-invalid + /// model / type enum, the P3-deferred Query model, a missing table, or a + /// missing model-required field. Empty when the parser accepts the blob, when the blob is blank, or + /// when it is not a JSON object at all (the sibling factories' "not an equipment-tag TagConfig" + /// convention). Never throws. + /// + /// The equipment tag's TagConfig JSON. + /// The warnings; empty when clean. + public static IReadOnlyList Inspect(string reference) + { + var warnings = new List(); + if (string.IsNullOrWhiteSpace(reference) || reference.TrimStart().FirstOrDefault() != '{') + return warnings; + + // The accept gate: whatever the driver serves draws nothing. See the class remarks. + if (SqlEquipmentTagParser.TryParse(reference, InspectionRawPath, out _)) return warnings; + + try + { + using var doc = JsonDocument.Parse(reference); + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + warnings.Add("Sql TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown)."); + return warnings; + } + + DescribeRejection(root, warnings); + } + catch (JsonException) + { + warnings.Add("Sql TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown)."); + return warnings; + } + + // Backstop: the parser refused for a reason nothing above names (it also swallows FormatException). + // Saying so is worth more than reporting a clean blob the driver will silently skip. + if (warnings.Count == 0) + { + warnings.Add( + "Sql TagConfig was rejected by the driver's tag parser — the driver will skip this tag and it " + + "will read BadNodeIdUnknown."); + } + + return warnings; + } + + /// + /// Explains why refused an object-rooted blob, walking + /// its checks in the same order. Every branch here mirrors a return false in the parser — this + /// runs only after the parser has already refused, so it can never invent strictness of its own. + /// + /// The TagConfig root object. + /// The list to append warnings to. + private static void DescribeRejection(JsonElement root, List warnings) + { + // Recognition (parser: a "driver":"Sql" marker or a "model" discriminator). + var hasModel = root.TryGetProperty("model", out _); + var driver = ReadString(root, "driver"); + if (!hasModel && !string.Equals(driver, "Sql", StringComparison.OrdinalIgnoreCase)) + { + warnings.Add( + "Sql TagConfig carries neither a 'model' field nor 'driver':'Sql', so the driver does not " + + "recognise it as a Sql tag and will skip it (BadNodeIdUnknown)."); + return; + } + + // Strict enums: a typo'd value rejects the whole tag rather than silently defaulting. + var modelWarning = TagConfigJson.DescribeInvalidEnum(root, "model"); + if (modelWarning is not null) warnings.Add(modelWarning); + var typeWarning = TagConfigJson.DescribeInvalidEnum(root, "type"); + if (typeWarning is not null) warnings.Add(typeWarning); + if (modelWarning is not null) return; // the model is unknown; the per-model checks cannot run + + var model = TagConfigJson.ReadEnumOrDefault(root, "model", SqlTagModel.KeyValue); + if (model == SqlTagModel.Query) + { + warnings.Add( + "Sql tag model 'Query' is deferred (design §5.4 / P3) and is not served — the driver will " + + "skip this tag."); + return; + } + + if (string.IsNullOrWhiteSpace(ReadString(root, "table"))) + warnings.Add("Sql TagConfig has no 'table' — every Sql tag model requires one."); + + switch (model) + { + case SqlTagModel.KeyValue: + if (string.IsNullOrWhiteSpace(ReadString(root, "keyColumn"))) + warnings.Add("Sql KeyValue tag has no 'keyColumn'."); + if (string.IsNullOrWhiteSpace(ReadString(root, "valueColumn"))) + warnings.Add("Sql KeyValue tag has no 'valueColumn'."); + if (ReadString(root, "keyValue") is null) + warnings.Add("Sql KeyValue tag has no 'keyValue' (an empty string is allowed; an absent key is not)."); + break; + + case SqlTagModel.WideRow: + if (string.IsNullOrWhiteSpace(ReadString(root, "columnName"))) + warnings.Add("Sql WideRow tag has no 'columnName'."); + if (!HasRowSelector(root)) + { + warnings.Add( + "Sql WideRow tag has no usable 'rowSelector' — it needs a 'whereColumn'/'whereValue' " + + "pair or a 'topByTimestamp' column, or the driver cannot tell which row to read."); + } + + break; + + default: + break; + } + } + + /// + /// Whether a WideRow blob names a row the driver can select, mirroring the parser's + /// hasWherePair || topByTimestamp test (a where-pair needs BOTH keys, though + /// whereValue may legitimately be an empty string). + /// + /// The TagConfig root object. + /// when the blob names a selectable row. + private static bool HasRowSelector(JsonElement root) + { + if (!root.TryGetProperty("rowSelector", out var sel) || sel.ValueKind != JsonValueKind.Object) + return false; + var hasWherePair = !string.IsNullOrWhiteSpace(ReadString(sel, "whereColumn")) + && HasScalar(sel, "whereValue"); + return hasWherePair || !string.IsNullOrWhiteSpace(ReadString(sel, "topByTimestamp")); + } + + /// Reads a string-valued property; null when absent or not a JSON string (as the parser does). + /// The object to read from. + /// The property name. + /// The string value, or null. + private static string? ReadString(JsonElement o, string name) + => o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String ? e.GetString() : null; + + /// Whether a property is present as a scalar the parser's ReadScalar would capture. + /// The object to read from. + /// The property name. + /// when the property is a string, number or boolean. + private static bool HasScalar(JsonElement o, string name) + => o.TryGetProperty(name, out var e) + && e.ValueKind is JsonValueKind.String or JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False; +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/EquipmentTagConfigInspector.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/EquipmentTagConfigInspector.cs index 7e8a25e5..b17ad450 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/EquipmentTagConfigInspector.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/EquipmentTagConfigInspector.cs @@ -1,10 +1,14 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.AbCip; using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; +using ZB.MOM.WW.OtOpcUa.Driver.Calculation; using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; using ZB.MOM.WW.OtOpcUa.Driver.Modbus; using ZB.MOM.WW.OtOpcUa.Driver.Mqtt; +using ZB.MOM.WW.OtOpcUa.Driver.MTConnect; +using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; using ZB.MOM.WW.OtOpcUa.Driver.S7; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; namespace ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations; @@ -20,13 +24,19 @@ namespace ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations; /// *.Contracts assemblies — so a driver that gains an inspector cannot be silently left out. This /// is the same guard shape the driver-form dispatch maps use, and for the same reason: the previous /// hand-maintained list had drifted (MQTT shipped an Inspect and was never added). -/// Residual gap, deliberately recorded rather than papered over. Galaxy, OpcUaClient, Sql, -/// MTConnect and Calculation have no Inspect in a Contracts assembly, so this deploy-time -/// gate cannot check them — while the AdminUI's TagConfigValidator DOES cover all but Galaxy via -/// its *TagConfigModel.Validate() models, which live in the AdminUI project and cannot be -/// referenced from here. The asymmetry runs the wrong way: config authored through the modal is checked, -/// but config arriving by CSV import or API is not. Closing it means promoting those models (or an -/// equivalent parser) into each driver's Contracts assembly. Tracked as deferment-register G-7. +/// The G-7 gap is CLOSED (2026-08-09). Sql, MTConnect, Calculation and OpcUaClient used to +/// have no Inspect in a Contracts assembly, so this gate was blind to them while the AdminUI's +/// TagConfigValidator checked them at authoring — the asymmetry ran the wrong way, since config +/// arriving by CSV import or API meets ONLY this gate. Each now ships a Contracts-side +/// *TagDefinitionFactory.Inspect, wired below. Three of the four wrap the driver's own runtime +/// parse rather than mirroring the AdminUI model, which is what keeps the gate from ever being stricter +/// than the runtime: SqlTagDefinitionFactory wraps SqlEquipmentTagParser, and the +/// MTConnect / OpcUaClient / Calculation blob readers were MOVED into their Contracts assemblies (the +/// drivers now call them) rather than copied. +/// Galaxy is the sole remaining unmapped type, deliberately. Its tag reference is a dotted +/// tag_name.AttributeName authored through the Galaxy address picker, with no typed shape to +/// check — the AdminUI has no typed editor for it either (it uses the raw-JSON textarea), so this is +/// symmetric rather than a gap. It gains an entry here if it ever gains a Contracts-side parser. /// public static class EquipmentTagConfigInspector { @@ -40,6 +50,11 @@ public static class EquipmentTagConfigInspector [DriverTypeNames.TwinCAT] = TwinCATTagDefinitionFactory.Inspect, [DriverTypeNames.FOCAS] = FocasTagDefinitionFactory.Inspect, [DriverTypeNames.Mqtt] = MqttTagDefinitionFactory.Inspect, + // G-7 (2026-08-09): the four the gate used to be blind to. + [DriverTypeNames.Sql] = SqlTagDefinitionFactory.Inspect, + [DriverTypeNames.MTConnect] = MTConnectTagDefinitionFactory.Inspect, + [DriverTypeNames.Calculation] = CalculationTagDefinitionFactory.Inspect, + [DriverTypeNames.OpcUaClient] = OpcUaClientTagDefinitionFactory.Inspect, }; /// Inspects a tag's TagConfig for the given driver type. Returns the driver's warnings, diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj index 05f6a470..e4f43208 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj @@ -50,6 +50,18 @@ + + + + + diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigAuthoringDeployParityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigAuthoringDeployParityTests.cs new file mode 100644 index 00000000..7dcac8ce --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigAuthoringDeployParityTests.cs @@ -0,0 +1,146 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; +using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +/// +/// Deferment-register G-7 pin: the AdminUI's authoring-time and the +/// deploy-time must agree in the direction that matters — +/// anything the modal lets an operator save must sail through the deploy gate. +/// +/// The reverse direction is deliberately NOT asserted, and must not be: the deploy gate follows the +/// runtime parser, which is lenient in places the editor is opinionated (a calc tag with no +/// trigger, a sub-50 ms timer). Config authored by CSV import or API meets only the deploy gate, so +/// a gate stricter than the runtime would block deployments the driver would have served — and the +/// gate's warnings can be configured (TagConfigValidationMode.Error) into blocking errors. +/// +/// +/// The configs are produced by the models' own ToJson() rather than hand-written JSON, +/// which is the only way this proves anything about the real authoring path: a hand-written blob is +/// a guess at what the editor writes, and the editors normalise keys on save (MTConnect collapses +/// dataItemId/address onto fullName; Sql rewrites its enums as names). This +/// lives in AdminUI.Tests because it is the only suite that can see both sides — the models are +/// internal to the AdminUI project, which references ControlPlane. +/// +/// +public sealed class TagConfigAuthoringDeployParityTests +{ + /// A Sql tag as the typed editor saves it: both the editor and the gate accept it. + [Fact] + public void Sql_config_the_editor_accepts_draws_no_deploy_warning() + { + var model = new SqlTagConfigModel + { + Model = ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.SqlTagModel.KeyValue, + Table = "dbo.TagValues", + KeyColumn = "TagName", + KeyValue = "Line1.Speed", + ValueColumn = "Value", + Type = DriverDataType.Float32, + }; + AssertParity(DriverTypeNames.Sql, model.Validate(), model.ToJson()); + } + + /// The WideRow shape too — its row selector is the half of the parser a KeyValue tag never + /// exercises, and the editor and the parser have to agree that a where-pair needs BOTH keys. + [Fact] + public void Sql_wideRow_config_the_editor_accepts_draws_no_deploy_warning() + { + var model = new SqlTagConfigModel + { + Model = ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.SqlTagModel.WideRow, + Table = "dbo.LineSnapshot", + ColumnName = "Speed", + RowSelector = new SqlRowSelectorModel { WhereColumn = "LineId", WhereValue = "L1" }, + }; + AssertParity(DriverTypeNames.Sql, model.Validate(), model.ToJson()); + } + + [Fact] + public void MTConnect_config_the_editor_accepts_draws_no_deploy_warning() + { + var model = new MTConnectTagConfigModel + { + FullName = "Xact", + DataType = DriverDataType.Float64, + MtCategory = "SAMPLE", + MtType = "POSITION", + Units = "MILLIMETER", + }; + AssertParity(DriverTypeNames.MTConnect, model.Validate(), model.ToJson()); + } + + [Fact] + public void Calculation_config_the_editor_accepts_draws_no_deploy_warning() + { + var model = new CalculationTagConfigModel + { + ScriptId = "8f1c2f6e-0000-4000-8000-000000000001", + ChangeTriggered = true, + TimerIntervalMs = 5000, + }; + AssertParity(DriverTypeNames.Calculation, model.Validate(), model.ToJson()); + } + + /// Change-trigger only — the editor omits timerIntervalMs entirely rather than writing a + /// null or a zero, and an absent key must not read as a discarded value at the gate. + [Fact] + public void Calculation_change_triggered_only_config_draws_no_deploy_warning() + { + var model = new CalculationTagConfigModel { ScriptId = "S1", ChangeTriggered = true }; + AssertParity(DriverTypeNames.Calculation, model.Validate(), model.ToJson()); + } + + [Fact] + public void OpcUaClient_config_the_editor_accepts_draws_no_deploy_warning() + { + var model = new OpcUaClientTagConfigModel { NodeId = "nsu=urn:plc:sim;s=Line1.Speed" }; + AssertParity(DriverTypeNames.OpcUaClient, model.Validate(), model.ToJson()); + } + + /// + /// Falsifiability control. Every assertion above would also pass against a build where the four G-7 + /// inspectors were never wired — an unmapped driver type returns no warnings for anything. This one + /// proves the gate is actually looking: the same driver types must warn about a blob the editor + /// itself would refuse. + /// + [Theory] + [InlineData(DriverTypeNames.Sql, """{"model":"KeyValu","table":"dbo.T"}""")] + // Note the MTConnect case is the DIGITS-only dataType, not a spelling typo: MTConnectTagConfigModel + // reads dataType leniently (an unrecognised NAME silently loads as String, and a save rewrites it), so + // "Strng" is accepted at authoring while the strict runtime rejects it — a pre-existing + // authoring-weaker-than-runtime gap in the editor, untouched here and out of G-7's scope. It does not + // affect the parity direction this file pins, because a blob saved through the modal always carries a + // valid NAME. + [InlineData(DriverTypeNames.MTConnect, """{"fullName":"avail","dataType":"8"}""")] + [InlineData(DriverTypeNames.Calculation, """{"changeTriggered":true}""")] + [InlineData(DriverTypeNames.OpcUaClient, """{"nodeId":""}""")] + public void The_same_driver_types_do_warn_about_a_config_the_editor_refuses(string driverType, string tagConfig) + { + TagConfigValidator.Validate(driverType, tagConfig).ShouldNotBeNullOrEmpty( + $"{driverType}: the authoring gate is expected to refuse this blob — if it accepts it, the " + + "control below is testing the wrong thing."); + EquipmentTagConfigInspector.Inspect(driverType, tagConfig).ShouldNotBeEmpty( + $"{driverType}: the deploy gate accepted a blob the modal refuses, so config arriving by CSV " + + "import or API is unchecked — the exact G-7 asymmetry."); + } + + /// Asserts the editor accepted the config (so the parity claim is not vacuous) and that the + /// deploy gate then found nothing to say about it. + /// The canonical driver-type string both gates dispatch on. + /// What the model's own Validate() returned. + /// The config the model serialised. + private static void AssertParity(string driverType, string? validationError, string tagConfig) + { + validationError.ShouldBeNull( + $"{driverType}: the model must accept this config, or the parity assertion below proves nothing."); + TagConfigValidator.Validate(driverType, tagConfig).ShouldBeNull( + $"{driverType}: the dispatched authoring validator must accept what the model itself accepts."); + EquipmentTagConfigInspector.Inspect(driverType, tagConfig).ShouldBeEmpty( + $"{driverType}: the deploy gate must never be stricter than the authoring gate — a config the " + + "operator was allowed to save cannot draw a deploy warning."); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/EquipmentTagConfigInspectorCoverageTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/EquipmentTagConfigInspectorCoverageTests.cs index d453cc3e..d6b7aed9 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/EquipmentTagConfigInspectorCoverageTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/EquipmentTagConfigInspectorCoverageTests.cs @@ -78,9 +78,13 @@ public sealed class EquipmentTagConfigInspectorCoverageTests /// project-referenced assembly to the output regardless of IL usage, so the directory is the honest /// source. /// Boundary, stated so it is not mistaken for more: this sees the Contracts assemblies - /// ControlPlane project-references. A driver whose Contracts project is not referenced at all (Sql, - /// MTConnect, Calculation, OpcUaClient) is invisible here — adding the reference is itself the act - /// that makes this guard start demanding a map entry. + /// ControlPlane project-references. A driver whose Contracts project is not referenced at all is + /// invisible here — adding the reference is itself the act that makes this guard start demanding a + /// map entry. That was the whole shape of deferment-register G-7: Sql, MTConnect, Calculation and + /// OpcUaClient were unreferenced and therefore unseen. All four were referenced and wired on + /// 2026-08-09, so the only driver left outside this sweep is Galaxy, which ships no Contracts-side + /// parser at all (its dotted tag_name.AttributeName reference has no typed shape to check, and + /// the AdminUI has no typed editor for it either). /// private static IReadOnlyList DiscoverInspectableFactories() => Directory.GetFiles(AppContext.BaseDirectory, "ZB.MOM.WW.OtOpcUa.Driver.*.Contracts.dll") diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/EquipmentTagConfigInspectorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/EquipmentTagConfigInspectorTests.cs index b99a5052..0a9b19da 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/EquipmentTagConfigInspectorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/EquipmentTagConfigInspectorTests.cs @@ -5,7 +5,11 @@ using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations; namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests; /// R2-11 (05/CONV-2): the DriverType-dispatched deploy-time TagConfig inspector — known drivers -/// route to their parser's Inspect; unmapped drivers (Galaxy/OpcUaClient) skip; keys are case-insensitive. +/// route to their parser's Inspect; the one unmapped driver (Galaxy) skips; keys are case-insensitive. +/// The Sql / MTConnect / Calculation / OpcUaClient cases below landed with deferment-register G-7 +/// (2026-08-09), which gave those four a Contracts-side Inspect. Each follows the same three-case +/// shape as its siblings: a canonical valid config warns about nothing, a present-but-invalid field draws a +/// warning that NAMES the field, and structurally broken JSON draws a warning without throwing. public sealed class EquipmentTagConfigInspectorTests { [Fact] @@ -25,9 +29,11 @@ public sealed class EquipmentTagConfigInspectorTests .ShouldBeEmpty(); } + // Galaxy is the last unmapped type and is so by design (no Contracts-side parser, and no typed AdminUI + // editor either). "Unknown" is the never-heard-of-it control. OpcUaClient used to sit in this list — + // G-7 moved it out; the pair of tests below is the pin that it cannot silently drift back. [Theory] [InlineData("GalaxyMxGateway")] - [InlineData("OpcUaClient")] [InlineData("Unknown")] public void Unmapped_driver_types_are_skipped(string driverType) { @@ -35,16 +41,169 @@ public sealed class EquipmentTagConfigInspectorTests EquipmentTagConfigInspector.IsMapped(driverType).ShouldBeFalse(); } + /// G-7: the four drivers the deploy gate used to be blind to are dispatched now. Named + /// explicitly rather than derived, so a map entry deleted alongside its Contracts reference (which would + /// take the reflection coverage guard dark with it) still fails something. + [Theory] + [InlineData("Sql")] + [InlineData("MTConnect")] + [InlineData("Calculation")] + [InlineData("OpcUaClient")] + public void The_G7_driver_types_are_mapped(string driverType) + => EquipmentTagConfigInspector.IsMapped(driverType).ShouldBeTrue(driverType); + [Theory] [InlineData("modbus")] [InlineData("FOCAS")] [InlineData("focas")] [InlineData("twincat")] + [InlineData("sql")] + [InlineData("mtconnect")] + [InlineData("calculation")] + [InlineData("opcuaclient")] public void Driver_type_matching_is_case_insensitive(string driverType) { EquipmentTagConfigInspector.IsMapped(driverType).ShouldBeTrue(); } + // ---- G-7: Sql ---- + + [Fact] + public void Clean_sql_config_has_no_warnings() + => EquipmentTagConfigInspector.Inspect( + "Sql", + """{"model":"KeyValue","table":"dbo.TagValues","keyColumn":"TagName","keyValue":"Line1.Speed","valueColumn":"Value"}""") + .ShouldBeEmpty(); + + [Fact] + public void Sql_typo_in_model_names_the_field() + { + var warnings = EquipmentTagConfigInspector.Inspect( + "Sql", + """{"model":"KeyValu","table":"dbo.T","keyColumn":"k","keyValue":"v","valueColumn":"val"}"""); + warnings.ShouldContain(w => w.Contains("model") && w.Contains("KeyValu")); + } + + [Fact] + public void Sql_deferred_query_model_is_reported() + => EquipmentTagConfigInspector.Inspect("Sql", """{"model":"Query","table":"dbo.T"}""") + .ShouldContain(w => w.Contains("Query")); + + [Fact] + public void Sql_unparseable_config_warns_without_throwing() + { + IReadOnlyList warnings = []; + Should.NotThrow(() => warnings = EquipmentTagConfigInspector.Inspect("Sql", "{not json")); + warnings.ShouldNotBeEmpty(); + } + + // ---- G-7: MTConnect ---- + + [Fact] + public void Clean_mtconnect_config_has_no_warnings() + => EquipmentTagConfigInspector.Inspect( + "MTConnect", """{"fullName":"avail","dataType":"String"}""") + .ShouldBeEmpty(); + + [Fact] + public void MTConnect_typo_in_dataType_names_the_field() + { + var warnings = EquipmentTagConfigInspector.Inspect( + "MTConnect", """{"fullName":"avail","dataType":"Strng"}"""); + warnings.ShouldContain(w => w.Contains("dataType") && w.Contains("Strng")); + } + + /// The numerically-serialized-enum trap: Enum.TryParse takes "8" as readily as a name, + /// so a digits-only value must be reported rather than silently becoming member 8. + [Fact] + public void MTConnect_numeric_dataType_is_reported() + => EquipmentTagConfigInspector.Inspect("MTConnect", """{"fullName":"avail","dataType":"8"}""") + .ShouldContain(w => w.Contains("dataType")); + + [Fact] + public void MTConnect_config_naming_no_dataitem_is_reported() + => EquipmentTagConfigInspector.Inspect("MTConnect", """{"dataType":"String"}""") + .ShouldContain(w => w.Contains("fullName")); + + [Fact] + public void MTConnect_unparseable_config_warns_without_throwing() + { + IReadOnlyList warnings = []; + Should.NotThrow(() => warnings = EquipmentTagConfigInspector.Inspect("MTConnect", "{not json")); + warnings.ShouldNotBeEmpty(); + } + + // ---- G-7: Calculation ---- + + [Fact] + public void Clean_calculation_config_has_no_warnings() + => EquipmentTagConfigInspector.Inspect( + "Calculation", """{"scriptId":"S1","changeTriggered":true,"timerIntervalMs":5000}""") + .ShouldBeEmpty(); + + [Fact] + public void Calculation_non_boolean_changeTriggered_names_the_field() + { + var warnings = EquipmentTagConfigInspector.Inspect( + "Calculation", """{"scriptId":"S1","changeTriggered":"false"}"""); + warnings.ShouldContain(w => w.Contains("changeTriggered")); + } + + [Fact] + public void Calculation_non_numeric_timer_names_the_field() + => EquipmentTagConfigInspector.Inspect("Calculation", """{"scriptId":"S1","timerIntervalMs":"5000"}""") + .ShouldContain(w => w.Contains("timerIntervalMs")); + + [Fact] + public void Calculation_config_naming_no_script_is_reported() + => EquipmentTagConfigInspector.Inspect("Calculation", """{"changeTriggered":true}""") + .ShouldContain(w => w.Contains("scriptId")); + + /// Falsification control for the never-stricter-than-the-runtime rule: the calc driver ACCEPTS + /// a tag with no trigger at all (it simply never computes) and honours a sub-50 ms timer the AdminUI + /// editor refuses, so the deploy gate — whose warnings can be configured into blocking errors — must + /// stay silent on both. + [Theory] + [InlineData("""{"scriptId":"S1","changeTriggered":false}""")] + [InlineData("""{"scriptId":"S1","changeTriggered":false,"timerIntervalMs":10}""")] + public void Calculation_shapes_the_runtime_accepts_draw_no_warning(string tagConfig) + => EquipmentTagConfigInspector.Inspect("Calculation", tagConfig).ShouldBeEmpty(); + + [Fact] + public void Calculation_unparseable_config_warns_without_throwing() + { + IReadOnlyList warnings = []; + Should.NotThrow(() => warnings = EquipmentTagConfigInspector.Inspect("Calculation", "{not json")); + warnings.ShouldNotBeEmpty(); + } + + // ---- G-7: OpcUaClient ---- + + [Fact] + public void Clean_opcuaclient_config_has_no_warnings() + => EquipmentTagConfigInspector.Inspect( + "OpcUaClient", """{"nodeId":"nsu=urn:plc:sim;s=Line1.Speed"}""") + .ShouldBeEmpty(); + + // The OpcUaClient blob carries no enum, so its present-but-unusable case is a nodeId that is blank or + // not a string — both of which the driver drops from its resolution table without a word. + [Theory] + [InlineData("""{"nodeId":""}""")] + [InlineData("""{"nodeId":" "}""")] + [InlineData("""{"nodeId":123}""")] + [InlineData("""{"tagName":"Line1.Speed"}""")] + public void OpcUaClient_unusable_nodeId_names_the_field(string tagConfig) + => EquipmentTagConfigInspector.Inspect("OpcUaClient", tagConfig) + .ShouldContain(w => w.Contains("nodeId")); + + [Fact] + public void OpcUaClient_unparseable_config_warns_without_throwing() + { + IReadOnlyList warnings = []; + Should.NotThrow(() => warnings = EquipmentTagConfigInspector.Inspect("OpcUaClient", "{not json")); + warnings.ShouldNotBeEmpty(); + } + [Fact] public void Focas_writable_true_dispatches_read_only_warning() {