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:
+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
|
||||
/// 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")
|
||||
|
||||
+161
-2
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user