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
@@ -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()
{