test+docs: retire the stale EquipmentTagsDarkBatch4 skips, guard the deploy-time

tag gate, and fix three silently-broken OpcUaClient integration tests

Continues working through deferment.md's remaining items.

SKIPPED TESTS (13 -> 0 for this reason). The skip said "dark until Batch 4";
Batch 4 shipped as v3.0 and RETIRED the equipment-tag path rather than lighting
it, so every one of them was waiting on something that had already happened and
gone the other way. Resolved individually rather than in bulk:
  - REVIVED onto the raw/UNS shape (6): four DriverHostActor primary-gate cases,
    the cluster-scoping rebuild, and the real-SDK dual-namespace materialisation
    E2E. The behaviour never went dark, only the v2 seeding did.
  - FOLDED into a live parity test (3): DeploymentArtifactRawUnsParityTests
    already compared RawTagPlan record-equal and RawTagPlan carries array /
    historize / alarm intent, so widening its corpus by two tags covers what
    three empty placeholder suites had been promising. Those suites are deleted.
  - DELETED (4): subjects genuinely retired -- equipment-namespace EquipmentTags,
    and both dot-joint {{equip}}.X token suites (that resolver was deleted in
    Batch 3; the slash-joint form is covered by DeploymentArtifactEquipRefParityTests).
Falsified, not assumed: removing the seeded UnsTagReference turns two revived
primary-gate tests red; re-homing the SITE-A node to MAIN turns the scoping test
red. Widening a parity corpus also needed direct value assertions -- parity alone
cannot distinguish "both seams right" from "both seams blind".
Runtime.Tests skips 13->3, OpcUaServer.Tests 4->1; all remaining are deliberate
EquipmentDeviceBindingRetired tombstones.

G-7 (deploy-time TagConfig gate). MQTT shipped an Inspect() nobody ever called;
now wired. Replaced the hand-maintained list with a reflection guard, because the
existing test enumerates driver types by hand and so guards renames but can never
notice a gap. NOTE: the first version of that guard was hollow -- it discovered
candidates via GetReferencedAssemblies(), which is circular, since the compiler
elides a reference nothing in the IL uses. Removing MQTT from the map also removed
its Contracts assembly from the manifest, and the guard passed green with the bug
reintroduced. Rewritten to scan the output directory; re-falsified and it now
fails naming MqttTagDefinitionFactory. Residual recorded at the code: Sql,
MTConnect, Calculation and OpcUaClient have no Contracts-side Inspect at all, so
the deploy gate stays weaker than the AdminUI's authoring gate for them.

OPCUACLIENT INTEGRATION TESTS (3 of 4 failing, on master too). Not fixture rot:
the simulator was healthy and Client.CLI read the very same node Good. v3 made the
driver resolve every reference through its authored RawTags table, so a bare
"ns=3;s=StepUp" fails LOCALLY at the resolver with BadNodeIdInvalid and never
reaches the wire -- the tests were exercising the unresolved-reference guard.
Fixed by seeding OpcPlcProfile.RawTags in the deploy artifact's TagConfig shape
and addressing by RawPath. 4/4 pass.

DOCS. Applied the Phase7* -> AddressSpace* rename across the four live docs
(historical bannered files deliberately untouched), resolving the three that are
not renames to their real members. Found en route that the earlier banner pass
missed three files: VirtualTags.md, OpcUaServer.md and ScriptedAlarms.md all
still presented the test-scaffolding GenericDriverNodeManager as the production
dispatch path. All three now carry the correction.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
Joseph Doherty
2026-07-28 14:32:13 -04:00
parent 5fff597324
commit 4624141a5d
24 changed files with 556 additions and 495 deletions
@@ -156,17 +156,21 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable
}
/// <summary>
/// Full structure-materialisation pipeline against a real SDK node manager: real Config
/// entities (Area / Line / Equipment + an Equipment-namespace Tag) → <see cref="AddressSpaceComposer.Compose"/>
/// → MaterialiseHierarchy + MaterialiseEquipmentTags → <see cref="OtOpcUaNodeManager"/>. Proves
/// an Equipment namespace lands its Area/Line/Equipment folder tree + the equipment-signal
/// Variable in a live OPC UA address space (structure-only; live values are a later milestone).
/// Also covers the compose-side EquipmentTags extraction. The cluster-level deploy +
/// network-browse E2E (Host.IntegrationTests) needs the docker-dev fixture and is tracked
/// as a follow-up.
/// Full v3 structure-materialisation pipeline against a real SDK node manager: real Config entities
/// (RawFolder → Driver → Device → Tag, plus Area / Line / Equipment and a <c>UnsTagReference</c>) →
/// <see cref="AddressSpaceComposer.Compose"/> → MaterialiseHierarchy + MaterialiseRawSubtree +
/// MaterialiseUnsReferences → <see cref="OtOpcUaNodeManager"/>. Proves the <b>dual-namespace</b>
/// shape lands in a live OPC UA address space: the raw device tree AND the UNS folder tree, with the
/// raw tag's variable projected into its referencing equipment as a second variable.
/// <para>Revived from the <c>EquipmentTagsDarkBatch4</c> skip. It was parked pending "the Batch-4
/// UnsTagReference fan-out", which shipped as v3.0 — but the equipment-tag path it was written
/// against was RETIRED rather than lit, so unskipping alone would have failed. Rewritten to the
/// shape that actually replaced it. The sibling <c>MaterialiseHierarchy_against_real_SDK</c> still
/// covers folders only; this is the layer's only real-SDK proof that variables materialise in both
/// namespaces.</para>
/// </summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
public async Task Equipment_namespace_structure_materialises_end_to_end_against_real_SDK()
[Fact]
public async Task Raw_and_uns_variables_materialise_end_to_end_against_real_SDK()
{
await using var host = new OpcUaApplicationHost(
new OpcUaApplicationHostOptions
@@ -183,34 +187,46 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable
await host.StartAsync(sdkServer, Ct);
sdkServer.NodeManager.ShouldNotBeNull();
// One area / line / equipment + a Modbus driver. v3: no Namespace entity, DriverInstance no
// longer binds a Namespace, and Equipment no longer binds a driver. The equipment-bound Tag
// (the Speed signal) that this end-to-end test materialised as a Variable node is DARK until
// Batch 4 — the composer emits an empty EquipmentTags set, so it is no longer seeded/passed
// here. Equipment.Name is the UNS browse segment. (Folder-only materialisation is covered live
// by the sibling MaterialiseHierarchy_against_real_SDK test; this suite's equipment-tag
// Variable end-to-end leg re-enables in Batch 4 via the UnsTagReference fan-out.)
var driver = new DriverInstance { DriverInstanceId = "drv-modbus", ClusterId = "c1", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}" };
// The v3 raw chain: RawFolder "Plant" → Driver "Modbus" → Device "dev1" → Tag "Speed"
// (RawPath Plant/Modbus/dev1/Speed), plus the UNS tree it is projected into.
var folder = new RawFolder { RawFolderId = "rf-plant", ClusterId = "c1", Name = "Plant", ParentRawFolderId = null };
var driver = new DriverInstance { DriverInstanceId = "drv-modbus", ClusterId = "c1", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", RawFolderId = "rf-plant" };
var device = new Device { DeviceId = "dev-1", DriverInstanceId = "drv-modbus", Name = "dev1", DeviceConfig = "{}" };
var speed = new Tag
{
TagId = "tag-speed", DeviceId = "dev-1", TagGroupId = null, Name = "Speed", DataType = "Float",
AccessLevel = TagAccessLevel.Read, TagConfig = "{}",
};
var area = new UnsArea { UnsAreaId = "nw-area-filling", ClusterId = "c1", Name = "filling" };
var line = new UnsLine { UnsLineId = "nw-line-1", UnsAreaId = "nw-area-filling", Name = "line-1" };
var equipment = new Equipment { EquipmentId = "eq-1", UnsLineId = "nw-line-1", Name = "station-1", MachineCode = "STATION_001" };
var reference = new UnsTagReference { UnsTagReferenceId = "ref-1", EquipmentId = "eq-1", TagId = "tag-speed", DisplayNameOverride = null };
var composition = AddressSpaceComposer.Compose(
new[] { area }, new[] { line }, new[] { equipment }, new[] { driver },
Array.Empty<ScriptedAlarm>());
Array.Empty<ScriptedAlarm>(),
unsTagReferences: new[] { reference }, tags: new[] { speed },
rawFolders: new[] { folder }, devices: new[] { device });
// Compose-side EquipmentTags extraction (Batch-4 subject — empty this batch).
var planned = composition.EquipmentTags.ShouldHaveSingleItem();
planned.EquipmentId.ShouldBe("eq-1");
planned.FullName.ShouldBe("40001");
// Compose side: one raw tag at its RawPath, projected once into the referencing equipment.
var rawTag = composition.RawTags.ShouldHaveSingleItem();
rawTag.NodeId.ShouldBe("Plant/Modbus/dev1/Speed");
rawTag.Realm.ShouldBe(AddressSpaceRealm.Raw);
var unsVar = composition.UnsReferenceVariables.ShouldHaveSingleItem();
unsVar.NodeId.ShouldBe("filling/line-1/station-1/Speed");
unsVar.BackingRawPath.ShouldBe("Plant/Modbus/dev1/Speed");
var sink = new SdkAddressSpaceSink(sdkServer.NodeManager!);
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
applier.MaterialiseHierarchy(composition);
applier.MaterialiseEquipmentTags(composition);
applier.MaterialiseRawSubtree(composition);
applier.MaterialiseUnsReferences(composition);
sdkServer.NodeManager!.FolderCount.ShouldBe(3); // filling area + line-1 + station-1 equipment
sdkServer.NodeManager!.VariableCount.ShouldBe(1); // the Speed signal under the equipment folder
// UNS: filling + line-1 + station-1. Raw: the Plant folder + the Modbus driver + the dev1 device.
sdkServer.NodeManager!.FolderCount.ShouldBe(6);
// ONE source value, TWO nodes — the raw tag's variable and its UNS projection. This is the
// single-source-fan-out shape; a regression that dropped either namespace shows up here as 1.
sdkServer.NodeManager!.VariableCount.ShouldBe(2);
}
/// <summary>OpcUaServer-001 — a UNS Area / Line rename-only deploy refreshes the EXISTING folder's
@@ -1,30 +0,0 @@
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// Characterized the live-edit compose seam admitting a Galaxy point as an ordinary equipment tag:
/// an equipment-scoped <c>Tag</c> bound to a <c>GalaxyMxGateway</c> driver surfaced under
/// <see cref="AddressSpaceComposition.EquipmentTags"/> carrying its driver-side FullName (coalescing a
/// null FolderPath to <c>string.Empty</c>).
/// <para><b>v3 Batch-1 DARK + reshaped seed:</b> equipment-tag variable plans do not materialize until
/// Batch 4 (the composer emits an empty <c>EquipmentTags</c> set — the raw + UNS variable nodes light
/// up in Batch 4's dual-namespace UNS↔Raw fan-out). The seed mechanism this test used is also gone:
/// <c>Tag</c> is now raw-only (no <c>EquipmentId</c>/<c>DriverInstanceId</c>/<c>FolderPath</c>), the
/// Compose overload no longer takes tags/namespaces, and equipment references raw tags via
/// <c>UnsTagReference</c> rather than a Galaxy point carrying an <c>EquipmentId</c>. So there is no
/// Batch-1 analog to assert. Re-author against the UnsTagReference → EquipmentTags fan-out when Batch 4
/// lights the equipment-tag variable nodes.</para>
/// </summary>
public sealed class AddressSpaceComposerAliasTagTests
{
/// <summary>A <c>GalaxyMxGateway</c> equipment tag surfaces under EquipmentTags with its FullName —
/// dark until Batch 4 (empty equipment-tag plan set) and re-authored via UnsTagReference.</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
public void Compose_admits_galaxy_equipment_tag_in_equipment_tags()
{
// Dark until Batch 4: see class summary. EquipmentTags is emitted empty this batch and the
// Tag.EquipmentId binding this test relied on is retired (Tag is raw-only). The Batch-4 seam
// fans equipment tags in via UnsTagReference; re-author the assertion then.
}
}
@@ -1,81 +0,0 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// Verifies the live-edit compose seam (<see cref="AddressSpaceComposer.Compose"/>) substitutes the
/// reserved <c>{{equip}}</c> token in a shared VirtualTag script with each owning equipment's
/// derived tag base (from its child-tag <c>FullName</c>s) — so one script reused across N
/// identical machines resolves to N machine-specific dependency graphs.
///
/// <para><b>v3 Batch-1 DARK:</b> the per-equipment tag base is DERIVED from equipment-tag
/// <c>FullName</c>s, and equipment-tag variable plans do not materialize until Batch 4 (the composer
/// no longer takes tags — <c>baseByEquip</c> is empty this batch). With no base to substitute, the
/// <c>{{equip}}</c> token stays literal and these per-machine assertions have nothing to compare, so
/// the test is Skipped-with-reason. The seed + assertions are preserved verbatim (minus the retired
/// entity columns) so re-enabling in Batch 4 is a one-line change once the fan-out feeds the base.</para>
/// </summary>
public sealed class AddressSpaceComposerEquipTokenTests
{
/// <summary>One shared <see cref="Script"/> using <c>ctx.GetTag("{{equip}}.Source")</c>, bound
/// to two equipments (TestMachine_001 / _002) each with one equipment Tag whose FullName carries
/// the per-machine base. Compose must expand the token per equipment in both the Expression and
/// the parsed DependencyRefs.</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
public void Compose_substitutes_equip_token_per_equipment()
{
// v3: no Namespace entity; DriverInstance no longer binds a Namespace; Equipment no longer
// binds a driver. In Batch 4 the {{equip}} base derives from the equipment's child-tag
// FullNames — here TestMachine_001.Source / TestMachine_002.Source — which flow through the
// (dark this batch) equipment-tag plan set. Seeded for intent; not passed to Compose.
var driver1 = new DriverInstance
{
DriverInstanceId = "drv-1",
ClusterId = "c1",
Name = "Modbus1",
DriverType = "Modbus",
DriverConfig = "{}",
};
var driver2 = new DriverInstance
{
DriverInstanceId = "drv-2",
ClusterId = "c1",
Name = "Modbus2",
DriverType = "Modbus",
DriverConfig = "{}",
};
var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" };
var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" };
var equip1 = new Equipment { EquipmentId = "eq-1", UnsLineId = "line-1", Name = "TestMachine_001", MachineCode = "TESTMACHINE_001" };
var equip2 = new Equipment { EquipmentId = "eq-2", UnsLineId = "line-1", Name = "TestMachine_002", MachineCode = "TESTMACHINE_002" };
var script = new Script
{
ScriptId = "s-equip",
Name = "over-50",
SourceCode = "return System.Convert.ToInt32(ctx.GetTag(\"{{equip}}.Source\").Value) > 50;",
SourceHash = "hash-equip",
};
var vt1 = new VirtualTag { VirtualTagId = "vt-1", EquipmentId = "eq-1", Name = "over50", DataType = "Boolean", ScriptId = "s-equip" };
var vt2 = new VirtualTag { VirtualTagId = "vt-2", EquipmentId = "eq-2", Name = "over50", DataType = "Boolean", ScriptId = "s-equip" };
var result = AddressSpaceComposer.Compose(
new[] { area }, new[] { line }, new[] { equip1, equip2 },
new[] { driver1, driver2 }, Array.Empty<ScriptedAlarm>(),
virtualTags: new[] { vt1, vt2 },
scripts: new[] { script });
result.EquipmentVirtualTags.Count.ShouldBe(2);
var plan1 = result.EquipmentVirtualTags.Single(p => p.VirtualTagId == "vt-1");
plan1.Expression.ShouldContain("ctx.GetTag(\"TestMachine_001.Source\")");
plan1.Expression.ShouldNotContain("{{equip}}");
plan1.DependencyRefs.ShouldBe(new[] { "TestMachine_001.Source" });
var plan2 = result.EquipmentVirtualTags.Single(p => p.VirtualTagId == "vt-2");
plan2.Expression.ShouldContain("ctx.GetTag(\"TestMachine_002.Source\")");
plan2.Expression.ShouldNotContain("{{equip}}");
plan2.DependencyRefs.ShouldBe(new[] { "TestMachine_002.Source" });
}
}
@@ -1,27 +1,29 @@
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// Shared xUnit <c>Skip</c> reason constants for OpcUaServer tests whose intent is retained but
/// whose subject is not materialized in the v3 Batch-1 DARK address space. The composer
/// (<see cref="AddressSpaceComposer.Compose"/>) and the artifact both emit an EMPTY equipment-tag
/// variable plan set this batch — raw + UNS variable nodes are lit up in Batch 4's dual-namespace
/// UNS↔Raw fan-out. Until then any assertion whose subject is an equipment-tag variable node (its
/// FullName, its <c>{{equip}}</c>-token-derived base, its device-host binding) has nothing to
/// compare, so it is Skipped-with-reason rather than deleted. Mirrors
/// <c>Runtime.Tests.Drivers.DarkAddressSpaceReasons</c>.
/// Shared xUnit <c>Skip</c> reason constants for OpcUaServer tests whose subject was RETIRED by v3
/// rather than merely deferred — architectural tombstones that keep the removed coverage discoverable
/// and name what replaced it, so nobody re-writes a test for a concept that no longer exists.
/// <para><b>A skip reason is a claim with an expiry date.</b> The one this file used to carry
/// (<c>EquipmentTagsDarkBatch4</c>, "dark until Batch 4") outlived its own condition by a release —
/// see the note below. If you add one here, state the condition precisely enough that a reader can
/// check whether it has already happened.</para>
/// Mirrors <c>Runtime.Tests.Drivers.DarkAddressSpaceReasons</c>.
/// </summary>
internal static class DarkAddressSpaceReasons
{
/// <summary>
/// Equipment-tag variable materialization + its per-field intent (FullName / array / historize /
/// native-alarm / <c>{{equip}}</c>-token base derived from child-tag FullNames) is dark until Batch 4.
/// </summary>
public const string EquipmentTagsDarkBatch4 =
"v3 dark address space: equipment-tag variable plans (FullName / array / historize / alarm / " +
"{{equip}}-token base derived from child-tag FullNames) do not materialize until Batch 4 " +
"(dual-namespace UNS↔Raw fan-out). The composer emits an empty equipment-tag plan set this batch, " +
"so there is no per-equipment tag base to substitute and no equipment-tag variable node to assert. " +
"Migrate + re-enable when Batch 4 lights the raw/UNS variable nodes.";
// EquipmentTagsDarkBatch4 was RETIRED (2026-07-28) once its own unblock condition was examined. It read
// "dark until Batch 4"; Batch 4 shipped as v3.0 and RETIRED the equipment-tag plan set rather than
// lighting it, so every test wearing this reason was waiting on something that had already happened and
// gone the other way. Each was resolved on its merits rather than left to read as pending work:
// - revived onto the raw/UNS shape that replaced the subject (the real-SDK dual-namespace
// materialisation E2E in AddressSpaceApplierHierarchyTests; the cluster-scoping rebuild and the
// four primary-gate write/alarm cases in Runtime.Tests),
// - folded into a live parity test whose corpus was widened to cover them (array + native-alarm
// intent, now in DeploymentArtifactRawUnsParityTests),
// - or deleted, where the subject itself is retired (equipment-namespace EquipmentTags, the
// dot-joint {{equip}}.X token — superseded by the slash-joint form in
// DeploymentArtifactEquipRefParityTests).
/// <summary>Equipment↔device host binding is architecturally retired in v3.</summary>
public const string EquipmentDeviceBindingRetired =