feat(deploy): wire the four G-7 inspectors into the deploy-time TagConfig gate

Adds the Sql / MTConnect / Calculation / OpcUaClient Contracts ProjectReferences to
ControlPlane and their entries to EquipmentTagConfigInspector, closing the G-7 asymmetry: the
deploy gate now checks every driver the AdminUI checks. Galaxy is the sole remaining unmapped
type and is so by design — its dotted tag_name.AttributeName reference has no typed shape, and
the AdminUI has no typed editor for it either, so the two gates agree.

Adding the ProjectReference is what makes EquipmentTagConfigInspectorCoverageTests demand a map
entry (its sweep scans the output directory, so an unreferenced Contracts assembly is invisible
to it). Falsified: deleting the Sql entry fails
Every_driver_shipping_an_Inspect_is_wired_into_the_dispatch_map with
["SqlTagDefinitionFactory"]; restored.

Tests: three cases per driver in EquipmentTagConfigInspectorTests (canonical valid config warns
nothing / present-but-invalid field draws a warning naming it / broken JSON warns without
throwing), plus a Calculation control pinning that the two shapes the runtime accepts and the
editor refuses draw nothing. OpcUaClient moved out of the unmapped-driver theory.

New TagConfigAuthoringDeployParityTests (in AdminUI.Tests, the only suite that sees both gates)
pins the direction that matters: a config the AdminUI model's own ToJson produces and Validate
accepts draws no deploy warning. Its falsifiability control — the same driver types warning
about a blob the editor refuses — caught a pre-existing, unrelated authoring-side gap:
MTConnectTagConfigModel reads dataType leniently, so a misspelled name loads as String and
saves clean while the strict runtime rejects it. Left alone (rewriting the editors is out of
scope); the control uses the digits-only shape both gates refuse, and the case is documented
inline.
This commit is contained in:
Joseph Doherty
2026-08-09 21:14:49 -04:00
parent 2947a2b9e2
commit 1469fbb4d1
5 changed files with 348 additions and 12 deletions
@@ -1,10 +1,14 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
using ZB.MOM.WW.OtOpcUa.Driver.Calculation;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
using ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
using ZB.MOM.WW.OtOpcUa.Driver.S7;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
@@ -20,13 +24,19 @@ namespace ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
/// <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
/// 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,
/// MTConnect and Calculation have <b>no</b> <c>Inspect</c> in a Contracts assembly, so this deploy-time
/// gate cannot check them — while the AdminUI's <c>TagConfigValidator</c> DOES cover all but Galaxy via
/// its <c>*TagConfigModel.Validate()</c> models, which live in the AdminUI project and cannot be
/// referenced from here. The asymmetry runs the wrong way: config authored through the modal is checked,
/// but config arriving by CSV import or API is not. Closing it means promoting those models (or an
/// equivalent parser) into each driver's Contracts assembly. Tracked as deferment-register G-7.</para>
/// <para><b>The G-7 gap is CLOSED (2026-08-09).</b> Sql, MTConnect, Calculation and OpcUaClient used to
/// have no <c>Inspect</c> in a Contracts assembly, so this gate was blind to them while the AdminUI's
/// <c>TagConfigValidator</c> checked them at authoring — the asymmetry ran the wrong way, since config
/// arriving by CSV import or API meets ONLY this gate. Each now ships a Contracts-side
/// <c>*TagDefinitionFactory.Inspect</c>, wired below. Three of the four wrap the driver's own runtime
/// parse rather than mirroring the AdminUI model, which is what keeps the gate from ever being stricter
/// than the runtime: <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>
public static class EquipmentTagConfigInspector
{
@@ -40,6 +50,11 @@ public static class EquipmentTagConfigInspector
[DriverTypeNames.TwinCAT] = TwinCATTagDefinitionFactory.Inspect,
[DriverTypeNames.FOCAS] = FocasTagDefinitionFactory.Inspect,
[DriverTypeNames.Mqtt] = MqttTagDefinitionFactory.Inspect,
// G-7 (2026-08-09): the four the gate used to be blind to.
[DriverTypeNames.Sql] = SqlTagDefinitionFactory.Inspect,
[DriverTypeNames.MTConnect] = MTConnectTagDefinitionFactory.Inspect,
[DriverTypeNames.Calculation] = CalculationTagDefinitionFactory.Inspect,
[DriverTypeNames.OpcUaClient] = OpcUaClientTagDefinitionFactory.Inspect,
};
/// <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.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"/>
<!-- 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>
</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
/// source.</para>
/// <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,
/// MTConnect, Calculation, OpcUaClient) is invisible here — adding the reference is itself the act
/// that makes this guard start demanding a map entry.</para>
/// ControlPlane project-references. A driver whose Contracts project is not referenced at all is
/// invisible here — adding the reference is itself the act that makes this guard start demanding a
/// map entry. That was the whole shape of deferment-register G-7: Sql, MTConnect, Calculation and
/// OpcUaClient were unreferenced and therefore unseen. <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>
private static IReadOnlyList<Type> DiscoverInspectableFactories() =>
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;
/// <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
{
[Fact]
@@ -25,9 +29,11 @@ public sealed class EquipmentTagConfigInspectorTests
.ShouldBeEmpty();
}
// Galaxy is the last unmapped type and is so by design (no Contracts-side parser, and no typed AdminUI
// editor either). "Unknown" is the never-heard-of-it control. OpcUaClient used to sit in this list —
// G-7 moved it out; the pair of tests below is the pin that it cannot silently drift back.
[Theory]
[InlineData("GalaxyMxGateway")]
[InlineData("OpcUaClient")]
[InlineData("Unknown")]
public void Unmapped_driver_types_are_skipped(string driverType)
{
@@ -35,16 +41,169 @@ public sealed class EquipmentTagConfigInspectorTests
EquipmentTagConfigInspector.IsMapped(driverType).ShouldBeFalse();
}
/// <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]
[InlineData("modbus")]
[InlineData("FOCAS")]
[InlineData("focas")]
[InlineData("twincat")]
[InlineData("sql")]
[InlineData("mtconnect")]
[InlineData("calculation")]
[InlineData("opcuaclient")]
public void Driver_type_matching_is_case_insensitive(string driverType)
{
EquipmentTagConfigInspector.IsMapped(driverType).ShouldBeTrue();
}
// ---- G-7: Sql ----
[Fact]
public void Clean_sql_config_has_no_warnings()
=> EquipmentTagConfigInspector.Inspect(
"Sql",
"""{"model":"KeyValue","table":"dbo.TagValues","keyColumn":"TagName","keyValue":"Line1.Speed","valueColumn":"Value"}""")
.ShouldBeEmpty();
[Fact]
public void Sql_typo_in_model_names_the_field()
{
var warnings = EquipmentTagConfigInspector.Inspect(
"Sql",
"""{"model":"KeyValu","table":"dbo.T","keyColumn":"k","keyValue":"v","valueColumn":"val"}""");
warnings.ShouldContain(w => w.Contains("model") && w.Contains("KeyValu"));
}
[Fact]
public void Sql_deferred_query_model_is_reported()
=> EquipmentTagConfigInspector.Inspect("Sql", """{"model":"Query","table":"dbo.T"}""")
.ShouldContain(w => w.Contains("Query"));
[Fact]
public void Sql_unparseable_config_warns_without_throwing()
{
IReadOnlyList<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]
public void Focas_writable_true_dispatches_read_only_warning()
{