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
@@ -1,22 +1,28 @@
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// Shared xUnit <c>Skip</c> reason constants for tests whose intent is retained but whose subject is
/// not materialized in the v3 Batch-1 DARK address space. Equipment-tag variable nodes (and their
/// per-field intent: FullName / array / historize / native-alarm) are lit up by the Batch-4 dual
/// namespace + UNS↔Raw fan-out; until then the composer/artifact both emit an EMPTY equipment-tag
/// plan set, so any parity assertion over those plans has nothing to compare. The raw-tag parse
/// intent these tests characterized lives on in <see cref="TagConfigCorpusParityTests"/> (RawTagEntry
/// round-trip) and <c>TagConfigIntentTests</c> (the parse unit).
/// Shared xUnit <c>Skip</c> reason constants for 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>
/// </summary>
internal static class DarkAddressSpaceReasons
{
/// <summary>Equipment-tag variable materialization + its per-field intent is dark until Batch 4.</summary>
public const string EquipmentTagsDarkBatch4 =
"v3 dark address space: equipment-tag variable plans (FullName/array/historize/alarm) do not " +
"materialize until Batch 4 (dual-namespace UNS↔Raw fan-out). The composer + artifact both emit an " +
"empty equipment-tag plan set this batch, so this parity has nothing to compare. Raw-tag parse " +
"intent is covered by TagConfigCorpusParityTests (RawTagEntry round-trip) + TagConfigIntentTests.";
// 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).
// DiscoveryInjectionDormantV3 was removed with the injection path itself (§8.2, Gitea #507). The 18
// tests it skipped were v2 characterization tests — they asserted an equipment-rooted graft
@@ -1,32 +0,0 @@
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// Galaxy-as-ordinary-equipment-tag byte-parity: a Galaxy point (equipment-scoped tag bound to a
/// <c>GalaxyMxGateway</c> driver) surfaced into <c>EquipmentTags</c> with the same FullName /
/// EquipmentId / DriverInstanceId / AccessLevel→Writable / native-alarm intent on both the composer
/// and the artifact-decode seam.
/// <para>
/// <b>v3 DARK (Batch 1):</b> equipment-tag variable plans do not materialize — both producers emit an
/// EMPTY <c>EquipmentTags</c> set until the Batch-4 dual-namespace UNS↔Raw fan-out. Additionally, the
/// v3 schema retired the <c>Namespace</c>/<c>NamespaceKind</c> entities and the Galaxy
/// <c>ExplicitFullName</c> / namespace-Kind gate this test keyed off; Galaxy is an ordinary
/// Device-bound driver whose raw tags reach the UNS via <c>UnsTagReference</c>. The AccessLevel→Writable
/// and native-alarm parse this asserted are covered at the parse unit (<c>TagConfigIntentTests</c>) and
/// the RawTagEntry round-trip (<see cref="TagConfigCorpusParityTests"/>). Unskip + rewrite to the
/// Batch-4 UNS-projection shape when equipment-tag plans light up.
/// </para>
/// </summary>
public sealed class DeploymentArtifactAliasParityTests
{
/// <summary>Batch-4 pending: composer and artifact agree on a Galaxy equipment tag surfaced in
/// EquipmentTags (FullName + Writable + native alarm). See class remarks — dark this batch.</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
public void Composer_and_artifact_agree_on_galaxy_equipment_tag()
{
// Restored + rewritten to the Batch-4 UNS-projection shape when equipment-tag variable plans
// materialize: assert a Galaxy point + a Modbus point round-trip identically (FullName, Writable
// from AccessLevel, native-alarm intent incl. historizeToAveva) across composer and artifact.
}
}
@@ -1,28 +0,0 @@
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// Phase 4c array intent (<c>isArray</c> + optional <c>arrayLength</c>) byte-parity between the two
/// equipment-tag producers.
/// <para>
/// <b>v3 DARK (Batch 1):</b> equipment-tag variable plans do not materialize — the composer and the
/// artifact both emit an EMPTY <c>EquipmentTags</c> set until the Batch-4 dual-namespace UNS↔Raw
/// fan-out, so there is nothing to compare. The underlying parse of <c>isArray</c> / <c>arrayLength</c>
/// from a raw <c>TagConfig</c> blob is characterized today by <c>TagConfigIntentTests</c> and
/// round-tripped through the artifact by <see cref="TagConfigCorpusParityTests"/> (RawTagEntry
/// byte-preservation). Unskip when Batch 4 lights the equipment-tag plans.
/// </para>
/// </summary>
public sealed class DeploymentArtifactArrayParityTests
{
/// <summary>Batch-4 pending: composer and artifact agree on array equipment-tag plans
/// (IsArray + ArrayLength). See class remarks — equipment-tag plans are dark this batch.</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
public void Composer_and_artifact_agree_on_array_equipment_tags()
{
// Restored when Batch 4 materializes equipment-tag variable plans: compose a draft exercising every
// array branch (isArray:true+length, absent, isArray:true no length, non-number length) and assert
// decoded EquipmentTags == composed element-wise.
}
}
@@ -1,74 +0,0 @@
using System.Text.Json;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// Verifies the artifact-decode mirror substitutes the reserved <c>{{equip}}</c> token in a
/// VirtualTag script's <c>ctx.GetTag("…")</c> literals with the owning equipment's tag base
/// (derived from its child Equipment-namespace tag's FullName) — byte-parity with
/// <c>AddressSpaceComposer.Compose</c>'s live-edit path, using the same shared
/// <c>EquipmentScriptPaths</c> helper and the same equipmentTags-derived base.
/// </summary>
public sealed class DeploymentArtifactEquipTokenTests
{
// v3 dark: the {{equip}} tag base is DERIVED from the owning equipment's child equipment-tag FullNames,
// and equipment-tag plans do not materialize until Batch 4 — so baseByEquip is empty and {{equip}} has
// no per-equipment base to substitute. Unskip when Batch 4 lights the equipment-tag plans.
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
public void ParseComposition_substitutes_equip_token_in_virtual_tag_expression()
{
var blob = JsonSerializer.SerializeToUtf8Bytes(new
{
Namespaces = new[]
{
new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment
},
DriverInstances = new[]
{
new { DriverInstanceId = "drv-modbus", DriverType = "Modbus", DriverConfig = "{}", NamespaceId = "ns-eq" },
},
Tags = new object[]
{
new
{
TagId = "tag-source",
DriverInstanceId = "drv-modbus",
EquipmentId = "TestMachine_001",
Name = "Source",
FolderPath = (string?)null,
DataType = "Float",
TagConfig = "{\"FullName\":\"TestMachine_001.Source\"}",
},
},
Scripts = new[]
{
new
{
ScriptId = "s-equip",
SourceCode = "return System.Convert.ToInt32(ctx.GetTag(\"{{equip}}.Source\").Value) > 50;",
},
},
VirtualTags = new[]
{
new
{
VirtualTagId = "vt-equip",
EquipmentId = "TestMachine_001",
Name = "OverThreshold",
DataType = "Boolean",
ScriptId = "s-equip",
},
},
});
var c = DeploymentArtifact.ParseComposition(blob);
var vt = c.EquipmentVirtualTags.ShouldHaveSingleItem();
vt.Expression.ShouldContain("ctx.GetTag(\"TestMachine_001.Source\")");
vt.Expression.ShouldNotContain("{{equip}}");
vt.DependencyRefs.ShouldBe(new[] { "TestMachine_001.Source" });
}
}
@@ -1,30 +0,0 @@
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// Phase C HistoryRead intent (<c>isHistorized</c> + optional <c>historianTagname</c>) byte-parity
/// between the two equipment-tag producers.
/// <para>
/// <b>v3 DARK (Batch 1):</b> equipment-tag variable plans do not materialize —
/// <c>AddressSpaceComposer.Compose</c> and <c>DeploymentArtifact.ParseComposition</c> both emit an
/// EMPTY <c>EquipmentTags</c> set until the Batch-4 dual-namespace UNS↔Raw fan-out. There is nothing
/// to compare, so the parity assertion is parked. The underlying parse of <c>isHistorized</c> /
/// <c>historianTagname</c> from a raw <c>TagConfig</c> blob is characterized today by
/// <c>TagConfigIntentTests</c> (the parse unit) and round-tripped through the artifact by
/// <see cref="TagConfigCorpusParityTests"/> (RawTagEntry byte-preservation). Unskip + restore the
/// composer↔artifact equipment-tag comparison when Batch 4 lights the equipment-tag plans.
/// </para>
/// </summary>
public sealed class DeploymentArtifactHistorizeParityTests
{
/// <summary>Batch-4 pending: composer and artifact agree on historized equipment-tag plans
/// (IsHistorized + HistorianTagname). See class remarks — equipment-tag plans are dark this batch.</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
public void Composer_and_artifact_agree_on_historized_equipment_tags()
{
// Restored when Batch 4 materializes equipment-tag variable plans: compose a draft with a
// historized tag (no explicit tagname → HistorianTagname null), a historized tag WITH an explicit
// historianTagname override, and a plain tag; assert decoded EquipmentTags == composed element-wise.
}
}
@@ -23,8 +23,15 @@ public sealed class DeploymentArtifactRawUnsParityTests
[Fact]
public void Raw_and_uns_node_sets_are_byte_parity_between_compose_and_artifact_decode()
{
// Entity-side config: folder → driver → device → group → 2 tags (one historized+writable+override,
// one plain read-only), an area/line/equipment, and a UNS reference with a display-name override.
// Entity-side config: folder → driver → device → group → 4 tags, an area/line/equipment, and a UNS
// reference with a display-name override. The four tags cover every per-field intent RawTagPlan
// carries: historize (+ tagname override), plain read-only, ARRAY (isArray + arrayLength), and
// NATIVE ALARM (a TagConfig.alarm object → EquipmentTagAlarmInfo).
//
// The array and alarm tags were folded in here when the three placeholder suites
// (DeploymentArtifact{Array,Historize,Alias}ParityTests) were deleted — they had been parked on
// "unskip when Batch 4 lights the equipment-tag plans", but Batch 4 RETIRED equipment-tag plans
// instead of lighting them, so their subject moved onto the raw tag and belongs in this comparison.
var folder = new RawFolder { RawFolderId = "fld-1", ClusterId = "c1", Name = "Cell1", ParentRawFolderId = null };
var driver = new DriverInstance { DriverInstanceId = "drv-1", ClusterId = "c1", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", RawFolderId = "fld-1" };
var device = new Device { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "Dev1", DeviceConfig = "{}" };
@@ -39,6 +46,17 @@ public sealed class DeploymentArtifactRawUnsParityTests
TagId = "tag-run", DeviceId = "dev-1", TagGroupId = null, Name = "Run", DataType = "Boolean",
AccessLevel = TagAccessLevel.Read, TagConfig = "{}",
};
var profile = new Tag
{
TagId = "tag-profile", DeviceId = "dev-1", TagGroupId = "grp-1", Name = "Profile", DataType = "Float",
AccessLevel = TagAccessLevel.Read, TagConfig = "{\"isArray\":true,\"arrayLength\":16}",
};
var tempHi = new Tag
{
TagId = "tag-temphi", DeviceId = "dev-1", TagGroupId = null, Name = "TempHi", DataType = "Boolean",
AccessLevel = TagAccessLevel.ReadWrite,
TagConfig = "{\"alarm\":{\"alarmType\":\"OffNormalAlarm\",\"severity\":700}}",
};
var area = new UnsArea { UnsAreaId = "a1", ClusterId = "c1", Name = "filling" };
var line = new UnsLine { UnsLineId = "l1", UnsAreaId = "a1", Name = "line1" };
var equip = new Equipment { EquipmentId = "EQ-1", UnsLineId = "l1", Name = "station1", MachineCode = "M1" };
@@ -47,7 +65,7 @@ public sealed class DeploymentArtifactRawUnsParityTests
var composed = AddressSpaceComposer.Compose(
new[] { area }, new[] { line }, new[] { equip },
new[] { driver }, Array.Empty<ScriptedAlarm>(),
unsTagReferences: new[] { reference }, tags: new[] { speed, run },
unsTagReferences: new[] { reference }, tags: new[] { speed, run, profile, tempHi },
rawFolders: new[] { folder }, devices: new[] { device }, tagGroups: new[] { group });
// Artifact JSON — the same shape ConfigComposer serializes (enums numeric: ReadWrite = 1, Read = 0).
@@ -61,6 +79,8 @@ public sealed class DeploymentArtifactRawUnsParityTests
{
new { TagId = "tag-speed", DeviceId = "dev-1", TagGroupId = "grp-1", Name = "Speed", DataType = "Float", AccessLevel = 1, TagConfig = "{\"isHistorized\":true,\"historianTagname\":\"WW.Speed\"}" },
new { TagId = "tag-run", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "Run", DataType = "Boolean", AccessLevel = 0, TagConfig = "{}" },
new { TagId = "tag-profile", DeviceId = "dev-1", TagGroupId = "grp-1", Name = "Profile", DataType = "Float", AccessLevel = 0, TagConfig = "{\"isArray\":true,\"arrayLength\":16}" },
new { TagId = "tag-temphi", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "TempHi", DataType = "Boolean", AccessLevel = 1, TagConfig = "{\"alarm\":{\"alarmType\":\"OffNormalAlarm\",\"severity\":700}}" },
},
UnsAreas = new[] { new { UnsAreaId = "a1", Name = "filling", ClusterId = "c1" } },
UnsLines = new[] { new { UnsLineId = "l1", UnsAreaId = "a1", Name = "line1" } },
@@ -70,8 +90,19 @@ public sealed class DeploymentArtifactRawUnsParityTests
var decoded = DeploymentArtifact.ParseComposition(blob);
// Sanity: the sets are non-empty (both raw tags + the projected UNS variable materialised).
composed.RawTags.Count.ShouldBe(2);
// Sanity: the sets are non-empty (all four raw tags + the projected UNS variable materialised).
composed.RawTags.Count.ShouldBe(4);
// Sanity on the two folded-in branches — without these, a composer that dropped array/alarm intent
// ENTIRELY would still pass the parity assertion below, because the artifact decode would drop it too
// and the two empties would compare equal. Parity alone cannot tell "both right" from "both blind".
var composedArray = composed.RawTags.Single(t => t.Name == "Profile");
composedArray.IsArray.ShouldBeTrue();
composedArray.ArrayLength.ShouldBe(16u);
var composedAlarm = composed.RawTags.Single(t => t.Name == "TempHi");
composedAlarm.Alarm.ShouldNotBeNull();
composedAlarm.Alarm!.AlarmType.ShouldBe("OffNormalAlarm");
composedAlarm.Writable.ShouldBeTrue();
composed.UnsReferenceVariables.Count.ShouldBe(1);
composed.UnsReferenceVariables[0].NodeId.ShouldBe("filling/line1/station1/MotorSpeed");
composed.UnsReferenceVariables[0].BackingRawPath.ShouldBe("Cell1/Modbus/Dev1/Fast/Speed");
@@ -190,69 +190,6 @@ public sealed class DeploymentArtifactTests
c.ScriptedAlarmPlans.Single().ScriptedAlarmId.ShouldBe("alarm-1");
}
/// <summary>
/// Verifies ParseComposition surfaces Equipment-namespace tags (non-null EquipmentId in an
/// <c>Equipment</c>-kind namespace) as <c>EquipmentTags</c>, with <c>FullName</c> extracted
/// from the tag's TagConfig blob. A tag in a non-Equipment (Simulated) namespace with a
/// null EquipmentId must NOT surface in EquipmentTags — byte-parity with the composer's pure
/// <c>ns.Kind == NamespaceKind.Equipment</c> predicate (only Equipment-kind namespaces route
/// into EquipmentTags, so such a tag routes nowhere).
/// </summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
public void ParseComposition_reads_EquipmentTags_from_equipment_namespace()
{
var blob = JsonSerializer.SerializeToUtf8Bytes(new
{
Namespaces = new[]
{
new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment
new { NamespaceId = "ns-sim", Kind = 1 }, // NamespaceKind.Simulated (non-Equipment)
},
DriverInstances = new[]
{
new { DriverInstanceId = "drv-modbus", DriverType = "Modbus", DriverConfig = "{}", NamespaceId = "ns-eq" },
new { DriverInstanceId = "drv-sim", DriverType = "Modbus", DriverConfig = "{}", NamespaceId = "ns-sim" },
},
Tags = new object[]
{
new
{
TagId = "tag-eq",
DriverInstanceId = "drv-modbus",
EquipmentId = "eq-1",
Name = "Speed",
FolderPath = (string?)null,
DataType = "Float",
TagConfig = "{\"FullName\":\"40001\"}",
},
new
{
TagId = "tag-sim",
DriverInstanceId = "drv-sim",
EquipmentId = (string?)null,
Name = "Temp",
FolderPath = "area",
DataType = "Float",
TagConfig = "{\"FullName\":\"area.Temp\"}",
},
},
});
var c = DeploymentArtifact.ParseComposition(blob);
var tag = c.EquipmentTags.ShouldHaveSingleItem();
tag.TagId.ShouldBe("tag-eq");
tag.EquipmentId.ShouldBe("eq-1");
tag.DriverInstanceId.ShouldBe("drv-modbus");
tag.Name.ShouldBe("Speed");
tag.DataType.ShouldBe("Float");
tag.FullName.ShouldBe("40001"); // extracted from TagConfig, not the raw blob
// The tag in the non-Equipment (Simulated) namespace, with null EquipmentId, does NOT leak
// into EquipmentTags — byte-parity with the composer's pure ns.Kind == Equipment predicate.
c.EquipmentTags.ShouldNotContain(t => t.TagId == "tag-sim");
}
/// <summary>
/// Verifies ParseComposition surfaces Equipment-namespace VirtualTags (joined to their Script
/// by ScriptId for the expression source) as <c>EquipmentVirtualTags</c>, with the
@@ -39,6 +39,17 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
private static readonly DateTime Ts = new(2026, 7, 13, 10, 0, 0, DateTimeKind.Utc);
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
/// <summary>v3 RawPath of the seeded write tag: RawFolder "Plant" / driver Name "Modbus" / Device "dev1" /
/// Tag "speed". Its UNS projection (Area "filling" / Line "line1" / Equipment "station1") is
/// <see cref="WriteUnsNodeId"/>.</summary>
private const string WriteRawPath = "Plant/Modbus/dev1/speed";
private const string WriteUnsNodeId = "filling/line1/station1/speed";
/// <summary>v3 RawPath of the seeded alarm tag. In v3 a native alarm materializes ONCE at the raw tag with
/// <c>ConditionId == RawPath</c>, so this is both the wire-ref the driver raises and the condition
/// NodeId the host routes to.</summary>
private const string AlarmRawPath = "Plant/Modbus/dev1/temp_hi";
// ---------------- write gate ----------------
/// <summary>Role unknown + a real driver peer (count 2) ⇒ RouteNodeWrite is DENIED with the
@@ -52,7 +63,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
var actor = SpawnWriteHost(db, dep, recorder, driverMemberCount: 2);
var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0, AddressSpaceRealm.Uns), asker.Ref);
actor.Tell(new DriverHostActor.RouteNodeWrite(WriteUnsNodeId, 123.0, AddressSpaceRealm.Uns), asker.Ref);
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
result.Success.ShouldBeFalse();
@@ -62,7 +73,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
/// <summary>Role unknown + no driver peer (count 1) ⇒ the write is SERVICED (single-node / boot-window
/// posture preserved).</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
[Fact]
public void Unknown_role_single_driver_services_write()
{
var db = NewInMemoryDbFactory();
@@ -76,7 +87,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
AwaitAssert(() =>
{
var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0, AddressSpaceRealm.Uns), asker.Ref);
actor.Tell(new DriverHostActor.RouteNodeWrite(WriteUnsNodeId, 123.0, AddressSpaceRealm.Uns), asker.Ref);
var res = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
res.Reason.ShouldNotBe("not primary");
res.Reason.ShouldNotBe("not primary (role unknown)");
@@ -87,7 +98,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
/// <summary>A Primary snapshot services even at count 2; a Secondary snapshot denies with the steady-state
/// reason (distinct from the boot-window reason).</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
[Fact]
public void Known_role_wins_over_member_count()
{
var db = NewInMemoryDbFactory();
@@ -101,7 +112,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
AwaitAssert(() =>
{
var asker1 = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 1.0, AddressSpaceRealm.Uns), asker1.Ref);
actor.Tell(new DriverHostActor.RouteNodeWrite(WriteUnsNodeId, 1.0, AddressSpaceRealm.Uns), asker1.Ref);
var res = asker1.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
res.Reason.ShouldNotBe("not primary");
res.Success.ShouldBeTrue(res.Reason);
@@ -110,7 +121,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
// Secondary snapshot ⇒ denied with the steady-state reason.
TellRole(actor, RedundancyRole.Secondary);
var asker2 = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 2.0, AddressSpaceRealm.Uns), asker2.Ref);
actor.Tell(new DriverHostActor.RouteNodeWrite(WriteUnsNodeId, 2.0, AddressSpaceRealm.Uns), asker2.Ref);
var denied = asker2.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
denied.Success.ShouldBeFalse();
denied.Reason.ShouldBe("not primary");
@@ -127,7 +138,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
var actor = SpawnWriteHost(db, dep, factory, driverMemberCount: 2);
var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 9.0, AddressSpaceRealm.Uns), asker.Ref);
actor.Tell(new DriverHostActor.RouteNodeWrite(WriteUnsNodeId, 9.0, AddressSpaceRealm.Uns), asker.Ref);
asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout).Success.ShouldBeFalse();
AwaitAssert(() =>
@@ -151,7 +162,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
var (actor, _) = SpawnAlarmHost(db, dep, driverMemberCount: 2);
EventFilter.Warning(contains: "role unknown").ExpectOne(() =>
actor.Tell(new DriverHostActor.RouteNativeAlarmAck("eq-1/temp_hi", Comment: null, OperatorUser: "op")));
actor.Tell(new DriverHostActor.RouteNativeAlarmAck(AlarmRawPath, Comment: null, OperatorUser: "op")));
AwaitAssert(() =>
{
@@ -163,7 +174,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
/// <summary>Role unknown + count 2 ⇒ ForwardNativeAlarm still writes the (ungated) OPC UA condition update
/// but publishes NO cluster-wide alerts transition.</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
[Fact]
public void Unknown_role_multi_driver_suppresses_alerts_emit_but_updates_condition()
{
var db = NewInMemoryDbFactory();
@@ -175,14 +186,14 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
actor.Tell(RaiseAlarm());
// The ungated OPC UA condition write still arrives.
publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(Timeout).AlarmNodeId.ShouldBe("eq-1/temp_hi");
publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(Timeout).AlarmNodeId.ShouldBe(AlarmRawPath);
// The cluster-wide alerts publish is suppressed (a real Primary peer publishes the single copy).
alerts.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
/// <summary>Role unknown + count 1 ⇒ ForwardNativeAlarm publishes the alerts transition (single-node
/// posture preserved).</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
[Fact]
public void Unknown_role_single_driver_publishes_alerts_emit()
{
var db = NewInMemoryDbFactory();
@@ -194,7 +205,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
actor.Tell(RaiseAlarm());
publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(Timeout);
alerts.ExpectMsg<AlarmTransitionEvent>(Timeout).AlarmId.ShouldBe("eq-1/temp_hi");
alerts.ExpectMsg<AlarmTransitionEvent>(Timeout).AlarmId.ShouldBe(AlarmRawPath);
}
// ---------------- helpers ----------------
@@ -202,8 +213,8 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
private static DriverInstanceActor.AttributeAlarmPublished RaiseAlarm() =>
new("drv-1", new AlarmEventArgs(
new StubAlarmHandle(),
SourceNodeId: "Temp",
ConditionId: "Temp.HiHi",
SourceNodeId: "Temp", // bare owning object — deliberately NOT the lookup key
ConditionId: AlarmRawPath, // v3: the condition is keyed by the raw tag's RawPath
AlarmType: "OffNormalAlarm",
Message: "temperature high",
Severity: AlarmSeverity.High,
@@ -266,60 +277,66 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
probe.ExpectMsg<SubscribeAck>(Timeout);
}
/// <summary>
/// Seeds the v3 raw-tag chain a write must traverse: RawFolder "Plant" → DriverInstance "drv-1"
/// (Modbus, <b>Enabled</b> so a real child spawns and can service the write) → Device "dev1" → Tag
/// "speed" (<see cref="WriteRawPath"/>, ReadWrite), projected into equipment
/// <c>filling/line1/station1</c> by a <c>UnsTagReference</c> so <see cref="WriteUnsNodeId"/> resolves.
/// <para>The pre-v3 shape this replaced seeded <c>Namespaces</c> + equipment-bound <c>Tags</c>, which
/// v3 parses to an EMPTY tag set — the write had nothing to route to, which is why the
/// write-is-serviced cases were skipped rather than failing.</para>
/// </summary>
private static DeploymentId SeedWriteTagDeployment(IDbContextFactory<OtOpcUaConfigDbContext> db)
{
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
{
Namespaces = new[] { new { NamespaceId = "ns-eq", Kind = 0 } },
RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
DriverInstances = new[]
{
new
{
DriverInstanceRowId = Guid.NewGuid(),
DriverInstanceId = "drv-1",
Name = "drv-1",
DriverType = "Modbus",
Enabled = true,
DriverConfig = "{}",
NamespaceId = "ns-eq",
},
new { DriverInstanceId = "drv-1", RawFolderId = "rf-plant", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = true },
},
Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "dev1", DeviceConfig = "{}" } },
TagGroups = Array.Empty<object>(),
Tags = new[]
{
new
{
TagId = "tag-0",
EquipmentId = "eq-1",
DriverInstanceId = "drv-1",
Name = "speed",
FolderPath = (string?)null,
DataType = "Double",
TagConfig = JsonSerializer.Serialize(new { FullName = "40001" }),
},
new { TagId = "t-speed", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "speed", DataType = "Double", AccessLevel = 1, TagConfig = "{}" },
},
UnsAreas = new[] { new { UnsAreaId = "a1", Name = "filling", ClusterId = "c1" } },
UnsLines = new[] { new { UnsLineId = "l1", UnsAreaId = "a1", Name = "line1" } },
Equipment = new[] { new { EquipmentId = "EQ-1", UnsLineId = "l1", Name = "station1", MachineCode = "M1" } },
UnsTagReferences = new[] { new { UnsTagReferenceId = "r1", EquipmentId = "EQ-1", TagId = "t-speed", DisplayNameOverride = (string?)null } },
});
return SealArtifact(db, artifact);
}
/// <summary>
/// Seeds the same v3 chain with the tag's <c>TagConfig</c> carrying an <c>alarm</c> object, so the
/// composer makes it a Part 9 condition at <see cref="AlarmRawPath"/> rather than a value variable.
/// The driver is <b>disabled</b> — these cases exercise the ack/emit gates, not a live child.
/// </summary>
private static DeploymentId SeedAlarmTagDeployment(IDbContextFactory<OtOpcUaConfigDbContext> db)
{
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
{
Namespaces = new[] { new { NamespaceId = "ns-eq", Kind = 0 } },
DriverInstances = new[] { new { DriverInstanceId = "drv-1", NamespaceId = "ns-eq" } },
RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
DriverInstances = new[]
{
new { DriverInstanceId = "drv-1", RawFolderId = "rf-plant", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = false },
},
Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "dev1", DeviceConfig = "{}" } },
TagGroups = Array.Empty<object>(),
Tags = new[]
{
new
{
TagId = "tag-0",
EquipmentId = "eq-1",
DriverInstanceId = "drv-1",
TagId = "t-temp",
DeviceId = "dev-1",
TagGroupId = (string?)null,
Name = "temp_hi",
FolderPath = (string?)null,
DataType = "Boolean",
AccessLevel = 0,
TagConfig = JsonSerializer.Serialize(new
{
FullName = "Temp.HiHi",
alarm = new { alarmType = "OffNormalAlarm", severity = 700 },
}),
},
@@ -202,16 +202,18 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
}
/// <summary>
/// Wiring proof for per-ClusterId scoping (Task 4): a multi-cluster artifact must
/// materialise ONLY the local node's cluster slice. Mirrors the multi-cluster artifact
/// shape exercised in <c>DeploymentArtifactTests</c> (MAIN + SITE-A, one Galaxy driver +
/// one equipment tag each — Galaxy points are ordinary equipment tags now). The scoped
/// rebuild for the SITE-A node must surface the SITE-A tag (<c>t-sa</c> → folder-scoped
/// variable <c>eq-sa/F/S1</c>) and NOT MAIN's (<c>t-main</c> → <c>eq-main/F/M1</c>); the
/// mirror holds for the MAIN node. Without the production scoping edit, the unscoped parse
/// would materialise BOTH variables on every node.
/// Wiring proof for per-ClusterId scoping: a multi-cluster artifact must materialise ONLY the local
/// node's cluster slice. MAIN and SITE-A each own a raw folder → Modbus driver → device → tag; the
/// scoped rebuild on the SITE-A node must surface SITE-A's raw variable
/// (<c>SA/Modbus/dev-sa/S1</c>) and NOT MAIN's (<c>Main/Modbus/dev-main/M1</c>), and the mirror
/// must hold on the MAIN node. Without <c>DeploymentArtifact.ResolveClusterScope</c>, the unscoped
/// parse materialises BOTH on every node.
/// <para>Revived from the <c>EquipmentTagsDarkBatch4</c> skip. It was parked on equipment-tag plans
/// lighting up "at Batch 4"; Batch 4 retired them instead, so the subject moved to the raw subtree.
/// The behaviour under test — cluster scoping — never went dark, and matters more since the mesh
/// split (a site node must not materialise another cluster's tags).</para>
/// </summary>
[Fact(Skip = ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers.DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
[Fact]
public void Rebuild_materialises_only_the_nodes_cluster()
{
// --- SITE-A node: only the SITE-A tag's variable, never MAIN's. ---
@@ -228,12 +230,11 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
siteActor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
// PureAdd (equipment + tag) ⇒ no full rebuild; the materialise passes still run the cluster slice.
// t-sa (EquipmentId "eq-sa", FolderPath "F", Name "S1") → folder-scoped variable "eq-sa/F/S1".
AwaitAssert(() => sinkA.Calls.ShouldContain("EV:eq-sa/F/S1"), duration: PresenceBudget);
// PureAdd ⇒ no full rebuild; the materialise passes still run the cluster slice.
AwaitAssert(() => sinkA.Calls.ShouldContain("EV:SA/Modbus/dev-sa/S1"), duration: PresenceBudget);
sinkA.RebuildCalls.ShouldBe(0);
// t-main (MAIN cluster) must NOT leak onto the SITE-A node.
sinkA.Calls.ShouldNotContain("EV:eq-main/F/M1");
// MAIN's raw tag must NOT leak onto the SITE-A node.
sinkA.Calls.ShouldNotContain("EV:Main/Modbus/dev-main/M1");
// --- MAIN node: the mirror — only MAIN's tag's variable, never SITE-A's. ---
var dbM = NewInMemoryDbFactory();
@@ -249,16 +250,16 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
mainActor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
AwaitAssert(() => sinkM.Calls.ShouldContain("EV:eq-main/F/M1"), duration: PresenceBudget);
AwaitAssert(() => sinkM.Calls.ShouldContain("EV:Main/Modbus/dev-main/M1"), duration: PresenceBudget);
sinkM.RebuildCalls.ShouldBe(0);
sinkM.Calls.ShouldNotContain("EV:eq-sa/F/S1");
sinkM.Calls.ShouldNotContain("EV:SA/Modbus/dev-sa/S1");
}
/// <summary>
/// Seal a 2-cluster deployment (MAIN + SITE-A) whose artifact mirrors the multi-cluster
/// shape the composer emits: a <c>Clusters</c> + <c>Nodes</c> map, one Equipment namespace +
/// Galaxy driver + equipment tag per cluster (Galaxy points are ordinary equipment tags now).
/// Used by <see cref="Rebuild_materialises_only_the_nodes_cluster"/>.
/// Seal a 2-cluster deployment (MAIN + SITE-A) whose artifact mirrors the v3 multi-cluster shape:
/// a <c>Clusters</c> + <c>Nodes</c> map, and per cluster a raw folder → Modbus driver → device
/// tag chain plus its UNS area/line/equipment. Used by
/// <see cref="Rebuild_materialises_only_the_nodes_cluster"/>.
/// </summary>
private static void SeedMultiClusterDeployment(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory)
{
@@ -282,24 +283,31 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
},
Equipment = new[]
{
new { EquipmentId = "eq-main", DriverInstanceId = "main-galaxy", UnsLineId = "line-main", Name = "eq-main", MachineCode = "EQ-MAIN" },
new { EquipmentId = "eq-sa", DriverInstanceId = "sa-galaxy", UnsLineId = "line-sa", Name = "eq-sa", MachineCode = "EQ-SA" },
new { EquipmentId = "eq-main", UnsLineId = "line-main", Name = "eq-main", MachineCode = "EQ-MAIN" },
new { EquipmentId = "eq-sa", UnsLineId = "line-sa", Name = "eq-sa", MachineCode = "EQ-SA" },
},
RawFolders = new[]
{
new { RawFolderId = "rf-main", ParentRawFolderId = (string?)null, Name = "Main", ClusterId = "MAIN" },
new { RawFolderId = "rf-sa", ParentRawFolderId = (string?)null, Name = "SA", ClusterId = "SITE-A" },
},
DriverInstances = new[]
{
new { DriverInstanceId = "main-galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}", ClusterId = "MAIN", NamespaceId = "main-ns" },
new { DriverInstanceId = "sa-galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}", ClusterId = "SITE-A", NamespaceId = "sa-ns" },
new { DriverInstanceId = "main-modbus", DriverType = "Modbus", DriverConfig = "{}", Name = "Modbus", RawFolderId = "rf-main", ClusterId = "MAIN" },
new { DriverInstanceId = "sa-modbus", DriverType = "Modbus", DriverConfig = "{}", Name = "Modbus", RawFolderId = "rf-sa", ClusterId = "SITE-A" },
},
Namespaces = new[]
Devices = new[]
{
new { NamespaceId = "main-ns", ClusterId = "MAIN", Kind = 0 }, // NamespaceKind.Equipment
new { NamespaceId = "sa-ns", ClusterId = "SITE-A", Kind = 0 },
new { DeviceId = "dev-main", DriverInstanceId = "main-modbus", Name = "dev-main", DeviceConfig = "{}" },
new { DeviceId = "dev-sa", DriverInstanceId = "sa-modbus", Name = "dev-sa", DeviceConfig = "{}" },
},
TagGroups = Array.Empty<object>(),
Tags = new[]
{
new { TagId = "t-main", DriverInstanceId = "main-galaxy", EquipmentId = (string?)"eq-main", Name = "M1", FolderPath = "F", DataType = "Boolean", TagConfig = "{}" },
new { TagId = "t-sa", DriverInstanceId = "sa-galaxy", EquipmentId = (string?)"eq-sa", Name = "S1", FolderPath = "F", DataType = "Boolean", TagConfig = "{}" },
new { TagId = "t-main", DeviceId = "dev-main", TagGroupId = (string?)null, Name = "M1", DataType = "Boolean", AccessLevel = 0, TagConfig = "{}" },
new { TagId = "t-sa", DeviceId = "dev-sa", TagGroupId = (string?)null, Name = "S1", DataType = "Boolean", AccessLevel = 0, TagConfig = "{}" },
},
UnsTagReferences = Array.Empty<object>(),
ScriptedAlarms = Array.Empty<object>(),
});