merge: G-7 closed — Contracts-side TagConfig Inspect for Sql/MTConnect/Calculation/OpcUaClient wired into the deploy gate
This commit is contained in:
@@ -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" />
|
||||||
|
|||||||
+161
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
+3
@@ -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"/>
|
||||||
|
|||||||
+230
@@ -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;
|
||||||
|
|
||||||
|
|||||||
+95
@@ -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;
|
||||||
|
}
|
||||||
+22
-7
@@ -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>
|
||||||
|
|||||||
+146
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-3
@@ -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")
|
||||||
|
|||||||
+161
-2
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user