merge: G-7 closed — Contracts-side TagConfig Inspect for Sql/MTConnect/Calculation/OpcUaClient wired into the deploy gate

This commit is contained in:
Joseph Doherty
2026-08-09 21:16:16 -04:00
15 changed files with 1057 additions and 197 deletions
+1
View File
@@ -22,6 +22,7 @@
</Folder> </Folder>
<Folder Name="/src/Drivers/"> <Folder Name="/src/Drivers/">
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/ZB.MOM.WW.OtOpcUa.Driver.Calculation.csproj" /> <Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/ZB.MOM.WW.OtOpcUa.Driver.Calculation.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj" /> <Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.csproj" /> <Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts.csproj" /> <Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts.csproj" />
@@ -0,0 +1,161 @@
using System.Text.Json;
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation;
/// <summary>
/// The four fields a calc tag's <c>TagConfig</c> blob carries, as
/// <see cref="CalculationTagDefinitionFactory.TryReadTagConfig"/> read them. Deliberately not the
/// driver's <c>CalculationTagDefinition</c>: 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.
/// </summary>
/// <param name="ScriptId">Logical FK to the shared <c>Script</c> entity that computes the tag; never blank.</param>
/// <param name="ScriptSource">The resolved C# script source; empty when the blob carried none.</param>
/// <param name="ChangeTriggered">Re-evaluate whenever a declared dependency changes (default <see langword="true"/>).</param>
/// <param name="TimerIntervalMs">Periodic re-evaluation cadence in ms, or <see langword="null"/> for change-trigger only.</param>
public readonly record struct CalculationTagConfigFields(
string ScriptId,
string ScriptSource,
bool ChangeTriggered,
int? TimerIntervalMs);
/// <summary>
/// The authored-raw-tag reader for the <c>Calculation</c> pseudo-driver: one authority, consumed by both
/// the runtime (<c>CalculationTagDefinition.TryFromTagConfig</c>, which adds the RawPath identity and the
/// script's dependency refs on top) and the deploy-time TagConfig gate (<see cref="Inspect"/>,
/// deferment-register G-7).
/// <para>
/// <b>The JSON reading moved here from the driver; the definition-building did not.</b> Building a
/// <c>CalculationTagDefinition</c> needs <c>EquipmentScriptPaths.ExtractDependencyRefs</c>, 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 <i>blob</i> says is read here, once.
/// </para>
/// <para>
/// <b><see cref="Inspect"/> is never stricter than <see cref="TryReadTagConfig"/>.</b> 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 — <c>changeTriggered</c> that
/// is not a JSON boolean (read as <see langword="true"/>) and <c>timerIntervalMs</c> that is not a
/// positive JSON integer (read as no timer). Both are the sanctioned "present-but-invalid, silently
/// defaulted" warning class.
/// </para>
/// <para>
/// <b>What it deliberately does NOT warn about:</b> a tag with no trigger at all
/// (<c>changeTriggered:false</c> and no timer). The AdminUI's <c>CalculationTagConfigModel.Validate</c>
/// rejects that shape and the runtime accepts it, and this gate follows the <i>runtime</i> — 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.
/// </para>
/// </summary>
public static class CalculationTagDefinitionFactory
{
/// <summary>
/// Reads a calc tag's <c>TagConfig</c> blob
/// (<c>{ "scriptId", "scriptSource", "changeTriggered", "timerIntervalMs" }</c>). Returns
/// <see langword="false"/> — never throws — only when the blob is unreadable, is not a JSON object,
/// or names no <c>scriptId</c>: a tag with no identifiable script cannot be a calc tag. A present
/// <c>scriptId</c> with an empty/absent <c>scriptSource</c> still reads (the driver logs it and the
/// tag never computes).
/// </summary>
/// <param name="tagConfig">The calc tag's schemaless <c>TagConfig</c> JSON.</param>
/// <param name="fields">The read fields when this returns <see langword="true"/>.</param>
/// <returns><see langword="true"/> when the blob names a script.</returns>
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;
}
}
/// <summary>
/// Deploy-time inspection of a calc tag's <c>TagConfig</c> blob. Warns for a blob
/// <see cref="TryReadTagConfig"/> rejects (unparseable JSON, a non-object root, or no usable
/// <c>scriptId</c>), and for a present-but-discarded <c>changeTriggered</c> / <c>timerIntervalMs</c>
/// — 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.
/// </summary>
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string reference)
{
var warnings = new List<string>();
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;
}
}
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Calculation</RootNamespace>
</PropertyGroup>
<!-- The calc-tag TagConfig reader, shared by the driver (which turns it into a
CalculationTagDefinition) and the deploy-time TagConfig gate (G-7), mirroring
Driver.MTConnect.Contracts / Driver.Modbus.Contracts.
Deliberately reference-free, unlike its siblings: a calc tag's blob is
{ scriptId, scriptSource, changeTriggered, timerIntervalMs } — strings, a bool and an int, with
no DriverDataType or other Core.Abstractions type in it. Carrying the leaf reference anyway
would be dead weight that reads as a dependency. Add it the moment a field needs it. -->
</Project>
@@ -1,4 +1,3 @@
using System.Text.Json;
using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Commons.Types;
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation; 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) public static bool TryFromTagConfig(string tagConfig, string rawPath, out CalculationTagDefinition def)
{ {
def = null!; def = null!;
if (string.IsNullOrWhiteSpace(tagConfig)) return false;
string? scriptId; // The blob read itself lives in Driver.Calculation.Contracts so the deploy-time TagConfig gate
string scriptSource; // (EquipmentTagConfigInspector) and this mapping cannot disagree about what a calc tag's blob says
bool changeTriggered; // (G-7). Everything below is what the BLOB does not carry: the RawPath identity and the dependency
int? timerMs; // refs extracted from the script source.
try if (!CalculationTagDefinitionFactory.TryReadTagConfig(tagConfig, out var fields)) return false;
{
using var doc = JsonDocument.Parse(tagConfig);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object) return false;
scriptId = root.TryGetProperty("scriptId", out var sidEl) && sidEl.ValueKind == JsonValueKind.String var deps = EquipmentScriptPaths.ExtractDependencyRefs(fields.ScriptSource);
? 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);
def = new CalculationTagDefinition( def = new CalculationTagDefinition(
RawPath: rawPath, RawPath: rawPath,
ScriptId: scriptId!, ScriptId: fields.ScriptId,
ScriptSource: scriptSource, ScriptSource: fields.ScriptSource,
ChangeTriggered: changeTriggered, ChangeTriggered: fields.ChangeTriggered,
TimerInterval: timerMs is { } m ? TimeSpan.FromMilliseconds(m) : null, TimerInterval: fields.TimerIntervalMs is { } m ? TimeSpan.FromMilliseconds(m) : null,
DependencyRefs: deps); DependencyRefs: deps);
return true; return true;
} }
@@ -19,6 +19,9 @@
for CompilationErrorException). Core.Scripting.Abstractions owns VirtualTagContext + for CompilationErrorException). Core.Scripting.Abstractions owns VirtualTagContext +
PassthroughScript; Commons owns VirtualTagEvalResult + IScriptCacheOwner; Core.Abstractions PassthroughScript; Commons owns VirtualTagEvalResult + IScriptCacheOwner; Core.Abstractions
owns DataValueSnapshot. --> owns DataValueSnapshot. -->
<!-- The calc tag's TagConfig reader (CalculationTagDefinitionFactory), shared with the deploy-time
TagConfig gate so the two cannot drift (G-7). -->
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/> <ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/> <ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/> <ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
@@ -0,0 +1,230 @@
using System.Text.Json;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <summary>
/// The authored-raw-tag reader for the MTConnect driver: one authority, consumed by both the runtime
/// (<c>MTConnectDriver</c>'s <c>RawPath → definition</c> table build) and the deploy-time TagConfig gate
/// (<see cref="Inspect"/>, deferment-register G-7).
/// <para>
/// <b>This parse used to be private to <c>MTConnectDriver</c>.</b> 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
/// <see cref="TryFromTagConfig"/> where it called its own copy.
/// </para>
/// <para>
/// <b><see cref="Inspect"/> is never stricter than <see cref="TryFromTagConfig"/></b> — 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.
/// </para>
/// </summary>
public static class MTConnectTagDefinitionFactory
{
/// <summary>
/// Reader options, mirroring <c>MTConnectDriver</c>'s config reader (from which this moved). Note
/// there is deliberately <b>no</b> <c>JsonStringEnumConverter</c>: the type-carrying field stays a
/// <c>string?</c> and is parsed by name below, which is the project-wide guard against the
/// numerically-serialized-enum trap.
/// </summary>
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
/// <summary>
/// Reads one authored raw tag's <c>TagConfig</c> blob: the DataItem id it binds, and the definition
/// its blob declares (<see langword="null"/> when the blob declares no type and the caller must fall
/// back to the Agent's <c>/probe</c> declaration). Never throws.
/// </summary>
/// <param name="tagConfig">The authored equipment-tag TagConfig JSON.</param>
/// <param name="dataItemId">The Agent DataItem id the tag binds, when this returns <see langword="true"/>.</param>
/// <param name="authored">The definition the blob declared, or <see langword="null"/> when it declared no type.</param>
/// <returns>
/// <see langword="false"/> when the blob is unreadable, names no DataItem id, or carries a
/// <c>driverDataType</c>/<c>dataType</c> that is not a <see cref="DriverDataType"/> — 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).
/// </returns>
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<MTConnectRawTagConfigDto>(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;
}
/// <summary>
/// Deploy-time inspection of an equipment-tag <c>TagConfig</c> blob: warns for a blob
/// <see cref="TryFromTagConfig"/> 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.
/// </summary>
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string reference)
{
var warnings = new List<string>();
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<MTConnectRawTagConfigDto>(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<DriverDataType>()));
}
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;
}
/// <summary>
/// Parses an authored coercion type by NAME. A digits-only value is rejected outright, BEFORE the
/// parse: <see cref="Enum.TryParse{TEnum}(string, bool, out TEnum)"/> accepts <c>"8"</c> as readily
/// as <c>"Float64"</c> 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.
/// </summary>
/// <param name="raw">The authored type text.</param>
/// <param name="parsed">The parsed type when this returns <see langword="true"/>.</param>
/// <returns><see langword="true"/> when <paramref name="raw"/> is a defined member NAME.</returns>
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);
}
/// <summary>The first of <paramref name="candidates"/> that carries text, trimmed; else null.</summary>
/// <param name="candidates">The candidate values, in precedence order.</param>
/// <returns>The first non-blank candidate, trimmed, or null.</returns>
private static string? FirstNonBlank(params string?[] candidates)
{
foreach (var candidate in candidates)
{
if (!string.IsNullOrWhiteSpace(candidate)) return candidate.Trim();
}
return null;
}
/// <summary>
/// A raw tag's <c>TagConfig</c> 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.
/// </summary>
/// <remarks>
/// <para>
/// <b>Three accepted spellings of the DataItem id</b>, tried in order:
/// <c>fullName</c> (the driver's own tag shape, and what the typed editor writes),
/// <c>dataItemId</c> (the protocol's own word for it, and the spelling an operator
/// hand-authoring the blob reaches for first), and <c>address</c> (what
/// <c>RawBrowseCommitMapper</c> 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.
/// </para>
/// <para>
/// <b>Two accepted spellings of the coercion type</b>, likewise: <c>dataType</c> — what
/// the AdminUI's <c>MTConnectTagConfigModel</c> writes, and therefore what every tag
/// authored through the typed editor carries — and <c>driverDataType</c>, matching the
/// driver's own <c>tags[]</c> field name. Reading only one of them would make the editor
/// and the driver disagree about the type of every tag.
/// </para>
/// <para>
/// Kept separate from the driver's strict <c>tags[]</c> DTO rather than reusing it: there a
/// missing <c>fullName</c> 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.
/// </para>
/// </remarks>
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; }
}
}
@@ -3250,7 +3250,7 @@ public sealed class MTConnectDriver
{ {
if (entry is null || string.IsNullOrWhiteSpace(entry.RawPath)) continue; 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( _skippedTags.Skip(
entry.RawPath, entry.RawPath,
@@ -3392,71 +3392,6 @@ public sealed class MTConnectDriver
return map.ToFrozenDictionary(StringComparer.Ordinal); return map.ToFrozenDictionary(StringComparer.Ordinal);
} }
/// <summary>
/// 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).
/// </summary>
/// <returns>
/// <see langword="false"/> when the blob is unreadable, names no DataItem id, or carries a
/// <c>driverDataType</c> that is not a <see cref="DriverDataType"/> — 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).
/// </returns>
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<MTConnectRawTagConfigDto>(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<DriverDataType>(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;
}
/// <summary> /// <summary>
/// The definition for a raw tag whose blob declared no type: the Agent's own <c>/probe</c> /// The definition for a raw tag whose blob declared no type: the Agent's own <c>/probe</c>
/// declaration when the model is in hand, else <see cref="DriverDataType.String"/> — the one /// declaration when the model is in hand, else <see cref="DriverDataType.String"/> — the one
@@ -3505,17 +3440,6 @@ public sealed class MTConnectDriver
return map.ToFrozenDictionary(StringComparer.Ordinal); return map.ToFrozenDictionary(StringComparer.Ordinal);
} }
/// <summary>The first of <paramref name="candidates"/> that carries text, trimmed; else null.</summary>
private static string? FirstNonBlank(params string?[] candidates)
{
foreach (var candidate in candidates)
{
if (!string.IsNullOrWhiteSpace(candidate)) return candidate.Trim();
}
return null;
}
/// <summary> /// <summary>
/// Parses an enum authored as a <b>name</b>. Config surfaces serialize enums as names /// Parses an enum authored as a <b>name</b>. Config surfaces serialize enums as names
/// project-wide; a numerically-serialized enum is the known trap that faults a driver at /// 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; } public MTConnectWriteDto? Write { get; init; }
} }
/// <summary>
/// A raw tag's <c>TagConfig</c> 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.
/// </summary>
/// <remarks>
/// <para>
/// <b>Three accepted spellings of the DataItem id</b>, tried in order:
/// <c>fullName</c> (the driver's own tag shape, and what the typed editor writes),
/// <c>dataItemId</c> (the protocol's own word for it, and the spelling an operator
/// hand-authoring the blob reaches for first), and <c>address</c> (what
/// <c>RawBrowseCommitMapper</c> 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.
/// </para>
/// <para>
/// <b>Two accepted spellings of the coercion type</b>, likewise: <c>dataType</c> — what
/// the AdminUI's <c>MTConnectTagConfigModel</c> writes, and therefore what every tag
/// authored through the typed editor carries — and <c>driverDataType</c>, matching the
/// driver's own <c>tags[]</c> field name. Reading only one of them would make the editor
/// and the driver disagree about the type of every tag.
/// </para>
/// <para>
/// Kept separate from <see cref="MTConnectTagDto"/> rather than reusing it: that DTO is
/// the strict <c>tags[]</c> surface, where a missing <c>fullName</c> 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.
/// </para>
/// </remarks>
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; }
}
/// <summary>One authored tag. <c>DriverDataType</c> stays a string — see <see cref="ParseEnum{T}"/>.</summary> /// <summary>One authored tag. <c>DriverDataType</c> stays a string — see <see cref="ParseEnum{T}"/>.</summary>
private sealed class MTConnectTagDto private sealed class MTConnectTagDto
{ {
@@ -1,4 +1,3 @@
using System.Text.Json;
using Opc.Ua; using Opc.Ua;
using Opc.Ua.Client; using Opc.Ua.Client;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
@@ -119,37 +118,14 @@ public sealed class NamespaceMap
foreach (var entry in rawTags) foreach (var entry in rawTags)
{ {
if (string.IsNullOrWhiteSpace(entry.RawPath)) continue; 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); table[entry.RawPath] = new RawTagBinding(nodeId, entry.WriteIdempotent);
} }
return table; return table;
} }
/// <summary>
/// Read the upstream node id string from an authored <c>TagConfig</c> blob's <c>nodeId</c> key
/// (camelCase, per the AdminUI <c>OpcUaClientTagConfigModel</c>; a legacy PascalCase <c>NodeId</c>
/// is also accepted). Never throws — a malformed blob yields <see langword="false"/>.
/// </summary>
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;
}
/// <summary>Number of namespaces captured. Index 0 is always the OPC UA core namespace.</summary> /// <summary>Number of namespaces captured. Index 0 is always the OPC UA core namespace.</summary>
public int Count => _uris.Length; public int Count => _uris.Length;
@@ -0,0 +1,95 @@
using System.Text.Json;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
/// <summary>
/// The authored-raw-tag reader for the OPC UA Client (gateway) driver: one authority, consumed by both
/// the runtime (<see cref="NamespaceMap"/>'s <c>RawPath → upstream node id</c> table build) and the
/// deploy-time TagConfig gate (<see cref="Inspect"/>, deferment-register G-7).
/// <para>
/// <b>This read used to be private to <see cref="NamespaceMap"/>.</b> 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. <see cref="NamespaceMap"/> now calls <see cref="TryReadNodeId"/>.
/// </para>
/// <para>
/// <b><see cref="Inspect"/> is never stricter than <see cref="TryReadNodeId"/></b> — 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.
/// </para>
/// </summary>
public static class OpcUaClientTagDefinitionFactory
{
/// <summary>
/// Read the upstream node id string from an authored <c>TagConfig</c> blob's <c>nodeId</c> key
/// (camelCase, per the AdminUI <c>OpcUaClientTagConfigModel</c>; a legacy PascalCase <c>NodeId</c>
/// is also accepted). Never throws — a malformed blob yields <see langword="false"/>.
/// </summary>
/// <param name="tagConfig">The authored equipment-tag TagConfig JSON.</param>
/// <param name="nodeId">The upstream node id string when this returns <see langword="true"/>.</param>
/// <returns><see langword="true"/> when the blob carries a usable upstream node reference.</returns>
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;
}
/// <summary>
/// Deploy-time inspection of an equipment-tag <c>TagConfig</c> blob: warns for a blob
/// <see cref="TryReadNodeId"/> would reject — unparseable JSON, a non-object root, or no usable
/// <c>nodeId</c> (absent, non-string, or blank). Such a tag is dropped from the driver's resolution
/// table and reads <c>BadNodeIdInvalid</c> 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.
/// </summary>
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string reference)
{
var warnings = new List<string>();
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;
}
}
@@ -0,0 +1,183 @@
using System.Text.Json;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
/// <summary>
/// Deploy-time inspection of a Sql raw tag's <c>TagConfig</c> blob (deferment-register G-7). The Sql
/// driver already owns its runtime parse in this assembly
/// (<see cref="SqlEquipmentTagParser.TryParse"/>), so this type adds <b>only</b> the operator-facing
/// half: it never re-implements the accept/reject decision, it asks the parser and then explains a
/// rejection.
/// <para>
/// <b>Why the verdict comes from the parser and not from a mirror.</b> 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 <see cref="SqlEquipmentTagParser.TryParse"/> 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
/// <c>BadNodeIdUnknown</c> forever).
/// </para>
/// <para>
/// The class is named <c>SqlTagDefinitionFactory</c> to match the six sibling
/// <c>*TagDefinitionFactory.Inspect</c> entry points the deploy gate discovers by reflection
/// (<c>EquipmentTagConfigInspectorCoverageTests</c>); the mapping half of a "factory" already lives
/// on <see cref="SqlEquipmentTagParser"/> and is deliberately not duplicated here.
/// </para>
/// </summary>
public static class SqlTagDefinitionFactory
{
/// <summary>
/// The RawPath handed to the parser during inspection. The parser only copies it onto
/// <see cref="SqlTagDefinition.Name"/> and never validates it, so inspection is free to pass a
/// placeholder — the deploy gate is inspecting a blob, not a placed tag.
/// </summary>
private const string InspectionRawPath = "(inspect)";
/// <summary>
/// Deploy-time inspection of an equipment-tag <c>TagConfig</c> blob. Returns human-readable warnings
/// for a blob <see cref="SqlEquipmentTagParser.TryParse"/> would reject — an unparseable or
/// non-object document, a blob the driver does not recognise as a Sql one, a present-but-invalid
/// <c>model</c> / <c>type</c> enum, the P3-deferred <c>Query</c> model, a missing <c>table</c>, 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.
/// </summary>
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string reference)
{
var warnings = new List<string>();
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;
}
/// <summary>
/// Explains why <see cref="SqlEquipmentTagParser.TryParse"/> refused an object-rooted blob, walking
/// its checks in the same order. Every branch here mirrors a <c>return false</c> in the parser — this
/// runs only after the parser has already refused, so it can never invent strictness of its own.
/// </summary>
/// <param name="root">The TagConfig root object.</param>
/// <param name="warnings">The list to append warnings to.</param>
private static void DescribeRejection(JsonElement root, List<string> 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<SqlTagModel>(root, "model");
if (modelWarning is not null) warnings.Add(modelWarning);
var typeWarning = TagConfigJson.DescribeInvalidEnum<DriverDataType>(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;
}
}
/// <summary>
/// Whether a WideRow blob names a row the driver can select, mirroring the parser's
/// <c>hasWherePair || topByTimestamp</c> test (a where-pair needs BOTH keys, though
/// <c>whereValue</c> may legitimately be an empty string).
/// </summary>
/// <param name="root">The TagConfig root object.</param>
/// <returns><see langword="true"/> when the blob names a selectable row.</returns>
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"));
}
/// <summary>Reads a string-valued property; null when absent or not a JSON string (as the parser does).</summary>
/// <param name="o">The object to read from.</param>
/// <param name="name">The property name.</param>
/// <returns>The string value, or null.</returns>
private static string? ReadString(JsonElement o, string name)
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String ? e.GetString() : null;
/// <summary>Whether a property is present as a scalar the parser's <c>ReadScalar</c> would capture.</summary>
/// <param name="o">The object to read from.</param>
/// <param name="name">The property name.</param>
/// <returns><see langword="true"/> when the property is a string, number or boolean.</returns>
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;
}
@@ -1,10 +1,14 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip; using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; 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.FOCAS;
using ZB.MOM.WW.OtOpcUa.Driver.Modbus; using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt; 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.S7;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations; namespace ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
@@ -20,13 +24,19 @@ namespace ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
/// <c>*.Contracts</c> assemblies — so a driver that gains an inspector cannot be silently left out. This /// <c>*.Contracts</c> 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 /// 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 <c>Inspect</c> and was never added).</para> /// hand-maintained list had drifted (MQTT shipped an <c>Inspect</c> and was never added).</para>
/// <para><b>Residual gap, deliberately recorded rather than papered over.</b> Galaxy, OpcUaClient, Sql, /// <para><b>The G-7 gap is CLOSED (2026-08-09).</b> Sql, MTConnect, Calculation and OpcUaClient used to
/// MTConnect and Calculation have <b>no</b> <c>Inspect</c> in a Contracts assembly, so this deploy-time /// have no <c>Inspect</c> in a Contracts assembly, so this gate was blind to them while the AdminUI's
/// gate cannot check them — while the AdminUI's <c>TagConfigValidator</c> DOES cover all but Galaxy via /// <c>TagConfigValidator</c> checked them at authoring — the asymmetry ran the wrong way, since config
/// its <c>*TagConfigModel.Validate()</c> models, which live in the AdminUI project and cannot be /// arriving by CSV import or API meets ONLY this gate. Each now ships a Contracts-side
/// referenced from here. The asymmetry runs the wrong way: config authored through the modal is checked, /// <c>*TagDefinitionFactory.Inspect</c>, wired below. Three of the four wrap the driver's own runtime
/// but config arriving by CSV import or API is not. Closing it means promoting those models (or an /// parse rather than mirroring the AdminUI model, which is what keeps the gate from ever being stricter
/// equivalent parser) into each driver's Contracts assembly. Tracked as deferment-register G-7.</para> /// than the runtime: <c>SqlTagDefinitionFactory</c> wraps <c>SqlEquipmentTagParser</c>, and the
/// MTConnect / OpcUaClient / Calculation blob readers were MOVED into their Contracts assemblies (the
/// drivers now call them) rather than copied.</para>
/// <para><b>Galaxy is the sole remaining unmapped type, deliberately.</b> Its tag reference is a dotted
/// <c>tag_name.AttributeName</c> 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.</para>
/// </summary> /// </summary>
public static class EquipmentTagConfigInspector public static class EquipmentTagConfigInspector
{ {
@@ -40,6 +50,11 @@ public static class EquipmentTagConfigInspector
[DriverTypeNames.TwinCAT] = TwinCATTagDefinitionFactory.Inspect, [DriverTypeNames.TwinCAT] = TwinCATTagDefinitionFactory.Inspect,
[DriverTypeNames.FOCAS] = FocasTagDefinitionFactory.Inspect, [DriverTypeNames.FOCAS] = FocasTagDefinitionFactory.Inspect,
[DriverTypeNames.Mqtt] = MqttTagDefinitionFactory.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,
}; };
/// <summary>Inspects a tag's <c>TagConfig</c> for the given driver type. Returns the driver's warnings, /// <summary>Inspects a tag's <c>TagConfig</c> for the given driver type. Returns the driver's warnings,
@@ -50,6 +50,18 @@
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts.csproj"/> <ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts.csproj"/> <ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj"/> <ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj"/>
<!-- G-7: the four drivers the deploy gate used to be blind to, while the AdminUI's
TagConfigValidator checked them. Adding the reference is what makes
EquipmentTagConfigInspectorCoverageTests demand a dispatch-map entry for them — the guard scans
the output directory, so an unreferenced Contracts assembly is invisible to it.
Note OpcUaClient.Contracts brings OPCFoundation.NetStandard.Opc.Ua.Client transitively: its
NamespaceMap re-binds a stable nsu= reference against a live session. Every host that loads
ControlPlane (Host, AdminUI) already carries the OPC UA stack, so this adds no new deployment
surface. -->
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj"/>
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -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;
/// <summary>
/// Deferment-register G-7 pin: the AdminUI's authoring-time <see cref="TagConfigValidator"/> and the
/// deploy-time <see cref="EquipmentTagConfigInspector"/> must agree in the direction that matters —
/// <b>anything the modal lets an operator save must sail through the deploy gate.</b>
/// <para>
/// The reverse direction is deliberately NOT asserted, and must not be: the deploy gate follows the
/// <i>runtime</i> 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 (<c>TagConfigValidationMode.Error</c>) into blocking errors.
/// </para>
/// <para>
/// <b>The configs are produced by the models' own <c>ToJson()</c></b> 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
/// <c>dataItemId</c>/<c>address</c> onto <c>fullName</c>; 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.
/// </para>
/// </summary>
public sealed class TagConfigAuthoringDeployParityTests
{
/// <summary>A Sql tag as the typed editor saves it: both the editor and the gate accept it.</summary>
[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());
}
/// <summary>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.</summary>
[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());
}
/// <summary>Change-trigger only — the editor omits <c>timerIntervalMs</c> entirely rather than writing a
/// null or a zero, and an absent key must not read as a discarded value at the gate.</summary>
[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());
}
/// <summary>
/// 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.
/// </summary>
[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.");
}
/// <summary>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.</summary>
/// <param name="driverType">The canonical driver-type string both gates dispatch on.</param>
/// <param name="validationError">What the model's own <c>Validate()</c> returned.</param>
/// <param name="tagConfig">The config the model serialised.</param>
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.");
}
}
@@ -78,9 +78,13 @@ public sealed class EquipmentTagConfigInspectorCoverageTests
/// project-referenced assembly to the output regardless of IL usage, so the directory is the honest /// project-referenced assembly to the output regardless of IL usage, so the directory is the honest
/// source.</para> /// source.</para>
/// <para><b>Boundary, stated so it is not mistaken for more:</b> this sees the Contracts assemblies /// <para><b>Boundary, stated so it is not mistaken for more:</b> this sees the Contracts assemblies
/// ControlPlane project-references. A driver whose Contracts project is not referenced at all (Sql, /// ControlPlane project-references. A driver whose Contracts project is not referenced at all is
/// MTConnect, Calculation, OpcUaClient) is invisible here — adding the reference is itself the act /// invisible here — adding the reference is itself the act that makes this guard start demanding a
/// that makes this guard start demanding a map entry.</para> /// map entry. That was the whole shape of deferment-register G-7: Sql, MTConnect, Calculation and
/// OpcUaClient were unreferenced and therefore unseen. <b>All four were referenced and wired on
/// 2026-08-09</b>, so the only driver left outside this sweep is Galaxy, which ships no Contracts-side
/// parser at all (its dotted <c>tag_name.AttributeName</c> reference has no typed shape to check, and
/// the AdminUI has no typed editor for it either).</para>
/// </summary> /// </summary>
private static IReadOnlyList<Type> DiscoverInspectableFactories() => private static IReadOnlyList<Type> DiscoverInspectableFactories() =>
Directory.GetFiles(AppContext.BaseDirectory, "ZB.MOM.WW.OtOpcUa.Driver.*.Contracts.dll") Directory.GetFiles(AppContext.BaseDirectory, "ZB.MOM.WW.OtOpcUa.Driver.*.Contracts.dll")
@@ -5,7 +5,11 @@ using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests; namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
/// <summary>R2-11 (05/CONV-2): the DriverType-dispatched deploy-time TagConfig inspector — known drivers /// <summary>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.</summary> /// route to their parser's Inspect; the one unmapped driver (Galaxy) skips; keys are case-insensitive.
/// <para>The Sql / MTConnect / Calculation / OpcUaClient cases below landed with deferment-register G-7
/// (2026-08-09), which gave those four a Contracts-side <c>Inspect</c>. 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.</para></summary>
public sealed class EquipmentTagConfigInspectorTests public sealed class EquipmentTagConfigInspectorTests
{ {
[Fact] [Fact]
@@ -25,9 +29,11 @@ public sealed class EquipmentTagConfigInspectorTests
.ShouldBeEmpty(); .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] [Theory]
[InlineData("GalaxyMxGateway")] [InlineData("GalaxyMxGateway")]
[InlineData("OpcUaClient")]
[InlineData("Unknown")] [InlineData("Unknown")]
public void Unmapped_driver_types_are_skipped(string driverType) public void Unmapped_driver_types_are_skipped(string driverType)
{ {
@@ -35,16 +41,169 @@ public sealed class EquipmentTagConfigInspectorTests
EquipmentTagConfigInspector.IsMapped(driverType).ShouldBeFalse(); EquipmentTagConfigInspector.IsMapped(driverType).ShouldBeFalse();
} }
/// <summary>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.</summary>
[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] [Theory]
[InlineData("modbus")] [InlineData("modbus")]
[InlineData("FOCAS")] [InlineData("FOCAS")]
[InlineData("focas")] [InlineData("focas")]
[InlineData("twincat")] [InlineData("twincat")]
[InlineData("sql")]
[InlineData("mtconnect")]
[InlineData("calculation")]
[InlineData("opcuaclient")]
public void Driver_type_matching_is_case_insensitive(string driverType) public void Driver_type_matching_is_case_insensitive(string driverType)
{ {
EquipmentTagConfigInspector.IsMapped(driverType).ShouldBeTrue(); 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<string> 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"));
}
/// <summary>The numerically-serialized-enum trap: <c>Enum.TryParse</c> takes "8" as readily as a name,
/// so a digits-only value must be reported rather than silently becoming member 8.</summary>
[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<string> 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"));
/// <summary>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.</summary>
[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<string> 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<string> warnings = [];
Should.NotThrow(() => warnings = EquipmentTagConfigInspector.Inspect("OpcUaClient", "{not json"));
warnings.ShouldNotBeEmpty();
}
[Fact] [Fact]
public void Focas_writable_true_dispatches_read_only_warning() public void Focas_writable_true_dispatches_read_only_warning()
{ {