feat(v3-batch4-wp3): raw-only binding + UNS fan-out + write routing

Wave B of Batch 4 — the runtime binding seam for the dual namespace.

Applier (both realms, explicit realm at every sink call site):
- MaterialiseRawSubtree: Raw containers as folders + Raw tags as variables
  keyed by RawPath (native-alarm tag → single Part 9 condition at the RawPath),
  all in AddressSpaceRealm.Raw; historian tagname = override else RawPath.
- MaterialiseUnsReferences: each UNS reference Variable under its equipment
  folder (Uns realm) + an Organizes UNS->Raw edge; inherits writable/array/
  historian tagname from the backing raw tag (both NodeIds -> one tagname).
- FeedHistorizedRefs / ProvisionHistorizedTags now source RAW tags (mux ref
  stays single, keyed by RawPath); ApplyPureRemove tears down raw tags + UNS
  refs in place (raw-container removal falls back to rebuild).

DriverHostActor (dual-NodeId, single-source fan-out):
- _nodeIdByDriverRef value gains a realm (NodeRealmRef); rebuilt from
  RawTags UNION UnsReferenceVariables so one (DriverInstanceId, RawPath) fans
  to the raw NodeId AND every referencing UNS NodeId with identical
  value/quality/timestamp. Write inverse map keyed by the bare id; the
  ns-qualified NodeId the write hook passes is normalised (BareNodeId) so a
  write to either NodeId resolves the same driver ref (-> RawPath write).
- Native raw alarm condition routing is realm-tagged (Raw); AttributeValueUpdate
  + AlarmStateUpdate carry the realm through to the sink.

Retire EquipmentNodeIds -> V3NodeIds.Uns (applier/VirtualTagHostActor) and
RawPaths.Combine (DiscoveredNodeMapper, discovered nodes are Raw now).

DeploymentArtifact.ParseComposition emits the Raw + UNS subtrees byte-parity
with the composer (reconstruct entities -> AddressSpaceComposer.Compose).

Sink surface: removed the transitional `= AddressSpaceRealm.Uns` defaults from
IOpcUaAddressSpaceSink / ISurgicalAddressSpaceSink / SdkAddressSpaceSink /
DeferredAddressSpaceSink / NullOpcUaAddressSpaceSink — every call site is now
explicit (realm reordered before the trailing optionals on EnsureVariable +
MaterialiseAlarmCondition). Node-manager convenience methods keep their
defaults (they are not the interface impl; Sdk delegates explicitly).

Tests: rewrote DriverHostActorLiveValueTests (fan-out drift, 1:N) +
DriverHostActorWriteRoutingTests (dual-NodeId raw/uns write routing) to the v3
raw+uns model; new AddressSpaceApplierRawUnsTests; migrated the EquipmentTags
provisioning/feed tests to RawTags; swept EquipmentNodeIds test callers.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
This commit is contained in:
Joseph Doherty
2026-07-16 10:56:31 -04:00
parent 8b0b627f81
commit 3efcf8014b
30 changed files with 1186 additions and 746 deletions
@@ -331,14 +331,14 @@ public sealed class DiscoveryInjectionEndToEndTests : RuntimeActorTestBase
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
/// <summary>No-op: alarm materialise is not exercised by this suite.</summary>
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
/// <summary>Records an EnsureFolder call.</summary>
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
=> _folders.Enqueue((folderNodeId, parentNodeId, displayName));
/// <summary>Records an EnsureVariable call.</summary>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
=> _variables.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable));
/// <summary>Records a raw rebuild (the apply-time fallback when no dbFactory is wired).</summary>
@@ -238,10 +238,10 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
// collapse regression — which would re-introduce the host folder (e.g. EQ-A/FOCAS/H1/Identity/Model) —
// fails here. Belt-and-suspenders: the raw discovered "H1" segment is also checked case-SENSITIVELY
// (a lowercase-only "h1" check would miss a leaked raw "H1").
eqANodeId.ShouldBe(EquipmentNodeIds.Variable("EQ-A", "FOCAS/Identity", "Model"));
eqANodeId.ShouldBe(V3NodeIds.Uns("EQ-A", "FOCAS", "Identity", "Model"));
eqANodeId.ShouldNotContain("H1");
eqANodeId.ShouldNotContain("h1");
eqBNodeId.ShouldBe(EquipmentNodeIds.Variable("EQ-B", "FOCAS/Status", "Run"));
eqBNodeId.ShouldBe(V3NodeIds.Uns("EQ-B", "FOCAS", "Status", "Run"));
eqBNodeId.ShouldNotContain("h2");
// (b) The driver subscribes the UNION of both devices' FixedTree refs (tag-less ⇒ no authored refs).
@@ -18,20 +18,20 @@ using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// Verifies the equipment-tag <b>live-value routing</b> wired into <see cref="DriverHostActor"/>:
/// a driver publishes a value keyed by its wire-ref <c>FullName</c>, but the equipment variable was
/// materialised under a FOLDER-SCOPED NodeId (<c>{equipmentId}/{folderPath}/{name}</c>). After an
/// apply, the host's <c>_nodeIdByDriverRef</c> map resolves <c>(DriverInstanceId, FullName)</c> to
/// that folder-scoped NodeId, so <c>ForwardToMux</c> lands the value on the right node (and still
/// forwards the raw value to the dependency mux for VirtualTag inputs).
///
/// v3 Batch 4 (B4-WP3) — the driver <b>single-source fan-out</b> wired into
/// <see cref="DriverHostActor"/>. The driver publishes a value keyed by its wire-ref (== the tag's
/// <c>RawPath</c>); the host's <c>_nodeIdByDriverRef</c> map — rebuilt each apply from the composition's
/// <c>RawTags</c> <c>UnsReferenceVariables</c> resolves <c>(DriverInstanceId, RawPath)</c> to the RAW
/// NodeId AND every referencing UNS NodeId, so <c>ForwardToMux</c> lands ONE publish on the raw node
/// (<see cref="AddressSpaceRealm.Raw"/>) plus every UNS node (<see cref="AddressSpaceRealm.Uns"/>) with
/// IDENTICAL value/quality/timestamp (no independent buffer ⇒ no drift). The raw value is still forwarded
/// to the dependency mux (VirtualTag inputs), keyed by the RawPath.
/// <para>
/// Drives a real apply through the existing harness: the seeded artifact carries the
/// <c>Namespaces</c> / <c>DriverInstances</c> / <c>Tags</c> arrays that
/// <c>DeploymentArtifact.BuildEquipmentTagPlans</c> needs to project equipment tags, and a
/// <c>DispatchDeployment</c> triggers the <c>ApplyAndAck → PushDesiredSubscriptions</c> pass
/// that builds the map. The OPC UA sink and the dependency mux are injected as
/// <see cref="Akka.TestKit.TestProbe"/>s.
/// Drives a real apply through the existing harness: the seeded artifact carries the v3 raw-tag chain
/// (RawFolder → DriverInstance(RawFolderId) → Device → Tag) + an optional UnsTagReference projecting
/// each raw tag into an equipment. <c>DispatchDeployment</c> triggers the
/// <c>ApplyAndAck → PushDesiredSubscriptions</c> pass that builds the map; the sink and mux are
/// injected as <see cref="Akka.TestKit.TestProbe"/>s.
/// </para>
/// </summary>
public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase
@@ -40,55 +40,95 @@ public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
private static readonly DateTime Ts = new(2026, 6, 13, 10, 0, 0, DateTimeKind.Utc);
/// <summary>A driver value published by FullName lands on the equipment variable's folder-scoped
/// NodeId (here <c>eq-1/speed</c>, no sub-folder), carrying the value/quality/timestamp.</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
public void Driver_value_routes_to_folder_scoped_equipment_NodeId()
/// <summary>A driver value published by RawPath fans to the RAW node AND the referencing UNS node, each
/// carrying identical value/quality/timestamp — the fan-out drift guard.</summary>
[Fact]
public void Driver_value_fans_out_to_raw_and_uns_NodeIds_with_identical_value()
{
var db = NewInMemoryDbFactory();
// One equipment tag: eq-1, drv-1, FullName "40001", no folder, Name "speed".
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
(Equip: "eq-1", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed"));
var deploymentId = SeedV3Deployment(db, RevA,
rawTags: new[] { (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false) },
unsRefs: new[] { (Area: "filling", Line: "line1", Equip: "station1", BackingTag: "speed", Effective: (string?)null) });
var (actor, publish, mux) = SpawnHostAndApply(db, deploymentId);
// The driver publishes by its wire-ref RawPath.
actor.Tell(new DriverInstanceActor.AttributeValuePublished(
"drv-1", "40001", 42.0, OpcUaQuality.Good, Ts));
"drv-1", "Plant/Modbus/dev1/speed", 42.0, OpcUaQuality.Good, Ts));
var update = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5));
update.NodeId.ShouldBe("eq-1/speed");
update.Value.ShouldBe(42.0);
update.Quality.ShouldBe(OpcUaQuality.Good);
update.TimestampUtc.ShouldBe(Ts);
var a = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5));
var b = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5));
var byId = new[] { a, b }.ToDictionary(u => u.NodeId);
// The raw value is still forwarded to the dependency mux (VirtualTag inputs, keyed by FullName).
byId.Keys.ShouldBe(new[] { "Plant/Modbus/dev1/speed", "filling/line1/station1/speed" }, ignoreOrder: true);
// Realm travels with each update so the sink resolves the right namespace.
byId["Plant/Modbus/dev1/speed"].Realm.ShouldBe(AddressSpaceRealm.Raw);
byId["filling/line1/station1/speed"].Realm.ShouldBe(AddressSpaceRealm.Uns);
// Fan-out drift guard: identical value + quality + timestamp on BOTH node states.
foreach (var u in byId.Values)
{
u.Value.ShouldBe(42.0);
u.Quality.ShouldBe(OpcUaQuality.Good);
u.TimestampUtc.ShouldBe(Ts);
}
// The raw publish still reaches the dependency mux (VirtualTag inputs), keyed by RawPath.
mux.ExpectMsg<DriverInstanceActor.AttributeValuePublished>(TimeSpan.FromSeconds(5))
.FullReference.ShouldBe("40001");
.FullReference.ShouldBe("Plant/Modbus/dev1/speed");
}
/// <summary>The same driver ref backing two equipments fans out: a single publish produces one
/// <see cref="OpcUaPublishActor.AttributeValueUpdate"/> per equipment variable NodeId.</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
public void Same_ref_on_two_equipments_fans_out_to_both_NodeIds()
/// <summary>A raw tag with NO UNS reference fans to ONLY its raw NodeId (Raw realm).</summary>
[Fact]
public void Unreferenced_raw_tag_fans_to_raw_node_only()
{
var db = NewInMemoryDbFactory();
// Same (drv-1, "40001") wire-ref backs eq-1/speed AND eq-2/speed (identical machines).
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
(Equip: "eq-1", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed"),
(Equip: "eq-2", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed"));
var deploymentId = SeedV3Deployment(db, RevA,
rawTags: new[] { (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false) },
unsRefs: Array.Empty<(string, string, string, string, string?)>());
var (actor, publish, _) = SpawnHostAndApply(db, deploymentId);
actor.Tell(new DriverInstanceActor.AttributeValuePublished(
"drv-1", "40001", 7.0, OpcUaQuality.Good, Ts));
"drv-1", "Plant/Modbus/dev1/speed", 5.0, OpcUaQuality.Good, Ts));
// One publish → two updates. Assert the SET of NodeIds (order is not contractual).
var first = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5));
var second = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5));
new[] { first.NodeId, second.NodeId }.ShouldBe(
new[] { "eq-1/speed", "eq-2/speed" }, ignoreOrder: true);
first.Value.ShouldBe(7.0);
second.Value.ShouldBe(7.0);
var only = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5));
only.NodeId.ShouldBe("Plant/Modbus/dev1/speed");
only.Realm.ShouldBe(AddressSpaceRealm.Raw);
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
/// <summary>A raw tag referenced by TWO equipments fans to the raw node PLUS both UNS nodes (3 updates,
/// identical value) — the 1:N fan-out.</summary>
[Fact]
public void Raw_tag_referenced_by_two_equipments_fans_to_raw_plus_both_uns()
{
var db = NewInMemoryDbFactory();
var deploymentId = SeedV3Deployment(db, RevA,
rawTags: new[] { (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false) },
unsRefs: new[]
{
(Area: "filling", Line: "line1", Equip: "station1", BackingTag: "speed", Effective: (string?)null),
(Area: "filling", Line: "line1", Equip: "station2", BackingTag: "speed", Effective: (string?)null),
});
var (actor, publish, _) = SpawnHostAndApply(db, deploymentId);
actor.Tell(new DriverInstanceActor.AttributeValuePublished(
"drv-1", "Plant/Modbus/dev1/speed", 7.0, OpcUaQuality.Good, Ts));
var updates = new[]
{
publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5)),
publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5)),
publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5)),
};
updates.Select(u => u.NodeId).ShouldBe(new[]
{
"Plant/Modbus/dev1/speed",
"filling/line1/station1/speed",
"filling/line1/station2/speed",
}, ignoreOrder: true);
updates.ShouldAllBe(u => (double)u.Value! == 7.0);
}
/// <summary>A value for a ref not in the composition is NOT pushed to the OPC UA sink (no matching
@@ -97,25 +137,23 @@ public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase
public void Unmatched_ref_is_dropped_from_sink_but_still_forwarded_to_mux()
{
var db = NewInMemoryDbFactory();
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
(Equip: "eq-1", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed"));
var deploymentId = SeedV3Deployment(db, RevA,
rawTags: new[] { (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false) },
unsRefs: Array.Empty<(string, string, string, string, string?)>());
var (actor, publish, mux) = SpawnHostAndApply(db, deploymentId);
actor.Tell(new DriverInstanceActor.AttributeValuePublished(
"drv-1", "59999", 99.0, OpcUaQuality.Good, Ts));
"drv-1", "Plant/Modbus/dev1/nope", 99.0, OpcUaQuality.Good, Ts));
// No equipment-tag NodeId for ("drv-1","59999") → nothing reaches the sink.
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
// The raw publish still reaches the dependency mux.
mux.ExpectMsg<DriverInstanceActor.AttributeValuePublished>(TimeSpan.FromSeconds(5))
.FullReference.ShouldBe("59999");
.FullReference.ShouldBe("Plant/Modbus/dev1/nope");
}
/// <summary>Spawns the host with publish + mux probes, dispatches the deployment, and waits for the
/// Applied ACK so the apply (and thus the map build in PushDesiredSubscriptions) has completed
/// before the test publishes a value. A VirtualTag-host probe is injected so the real host isn't
/// spawned (it would consume the mux-forwarded publishes otherwise — see SpawnVirtualTagHost).</summary>
/// Applied ACK so the map build in PushDesiredSubscriptions has completed before the test publishes a
/// value. A VirtualTag-host probe is injected so the real host isn't spawned.</summary>
private (IActorRef Actor, Akka.TestKit.TestProbe Publish, Akka.TestKit.TestProbe Mux) SpawnHostAndApply(
IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId)
{
@@ -134,47 +172,68 @@ public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied);
// RebuildAddressSpace also lands on the publish probe during apply; drain it so the test's
// ExpectMsg<AttributeValueUpdate> assertions see only value updates.
publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(TimeSpan.FromSeconds(5));
return (actor, publish, mux);
}
/// <summary>
/// Seeds a Sealed deployment whose artifact carries the minimal arrays
/// <c>DeploymentArtifact.BuildEquipmentTagPlans</c> needs to project equipment tags:
/// <c>Namespaces</c> (one Equipment-kind ns), <c>DriverInstances</c> (each driver bound to that
/// ns), and <c>Tags</c> (each with a non-null EquipmentId + a TagConfig blob carrying FullName).
/// </summary>
private static DeploymentId SeedDeploymentWithEquipmentTags(
/// <summary>Seeds a Sealed deployment whose artifact carries the v3 raw-tag chain (RawFolder "Plant" →
/// DriverInstance(RawFolderId) → Device → Tag) plus optional UnsTagReferences projecting a raw tag into an
/// Area/Line/Equipment. Each raw tag's RawPath is <c>Plant/{DriverName}/{Device}/{Tag}</c>; each UNS
/// reference's NodeId is <c>{Area}/{Line}/{Equip}/{Effective ?? backing tag Name}</c>. Enums serialize
/// numerically (AccessLevel: ReadWrite = 1).</summary>
private static DeploymentId SeedV3Deployment(
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev,
params (string Equip, string Driver, string FullName, string? Folder, string Name)[] tags)
(string Driver, string DriverName, string Device, string Tag, string DataType, bool Writable)[] rawTags,
(string Area, string Line, string Equip, string BackingTag, string? Effective)[] unsRefs)
{
var driverIds = tags.Select(t => t.Driver).Distinct(StringComparer.Ordinal).ToArray();
var drivers = rawTags
.Select(t => (t.Driver, t.DriverName))
.Distinct()
.Select(d => new { DriverInstanceId = d.Driver, RawFolderId = "rf-plant", Name = d.DriverName, DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = false })
.ToArray();
var devices = rawTags
.Select(t => (t.Driver, t.Device))
.Distinct()
.Select(d => new { DeviceId = $"{d.Driver}:{d.Device}", DriverInstanceId = d.Driver, Name = d.Device, DeviceConfig = "{}" })
.ToArray();
var tags = rawTags.Select(t => new
{
TagId = t.Tag, // tag id == name for test simplicity (unique per driver here)
DeviceId = $"{t.Driver}:{t.Device}",
TagGroupId = (string?)null,
Name = t.Tag,
DataType = t.DataType,
AccessLevel = t.Writable ? 1 : 0, // TagAccessLevel.ReadWrite = 1
TagConfig = "{}",
}).ToArray();
// UNS topology: one area/line per distinct (Area, Line); one equipment per (Area, Line, Equip).
var areas = unsRefs.Select(r => r.Area).Distinct(StringComparer.Ordinal)
.Select(a => new { UnsAreaId = $"a-{a}", Name = a, ClusterId = "c1" }).ToArray();
var lines = unsRefs.Select(r => (r.Area, r.Line)).Distinct()
.Select(l => new { UnsLineId = $"l-{l.Area}-{l.Line}", UnsAreaId = $"a-{l.Area}", Name = l.Line }).ToArray();
var equipment = unsRefs.Select(r => (r.Area, r.Line, r.Equip)).Distinct()
.Select(e => new { EquipmentId = $"EQ-{e.Area}-{e.Line}-{e.Equip}", UnsLineId = $"l-{e.Area}-{e.Line}", Name = e.Equip, MachineCode = e.Equip }).ToArray();
var unsTagReferences = unsRefs.Select((r, i) => new
{
UnsTagReferenceId = $"ref-{i}",
EquipmentId = $"EQ-{r.Area}-{r.Line}-{r.Equip}",
TagId = r.BackingTag,
DisplayNameOverride = r.Effective,
}).ToArray();
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
{
Namespaces = new[]
{
new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0
},
DriverInstances = driverIds.Select(d => new
{
DriverInstanceId = d,
NamespaceId = "ns-eq",
}).ToArray(),
Tags = tags.Select((t, i) => new
{
TagId = $"tag-{i}",
EquipmentId = t.Equip,
DriverInstanceId = t.Driver,
Name = t.Name,
FolderPath = t.Folder,
DataType = "Double",
TagConfig = JsonSerializer.Serialize(new { FullName = t.FullName }),
}).ToArray(),
RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
DriverInstances = drivers,
Devices = devices,
TagGroups = Array.Empty<object>(),
Tags = tags,
UnsAreas = areas,
UnsLines = lines,
Equipment = equipment,
UnsTagReferences = unsTagReferences,
});
var id = DeploymentId.NewId();
@@ -20,22 +20,17 @@ using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// Verifies the inbound operator-write routing wired into <see cref="DriverHostActor"/>: a
/// <see cref="DriverHostActor.RouteNodeWrite"/> for a materialised equipment-variable NodeId resolves
/// the <c>NodeId → (DriverInstanceId, FullName)</c> reverse map (built alongside the forward map in
/// <c>PushDesiredSubscriptions</c>), gates on this node being the driver PRIMARY (reusing the same
/// <c>RedundancyStateChanged</c> signal the alarm-emit gate uses), forwards a
/// <see cref="DriverInstanceActor.WriteAttribute"/> carrying the driver-side <c>FullName</c> to the
/// right driver child, and replies a <see cref="DriverHostActor.NodeWriteResult"/> to the asker.
///
/// v3 Batch 4 (B4-WP3) — inbound operator-write routing through EITHER NodeId. A
/// <see cref="DriverHostActor.RouteNodeWrite"/> carries the FULL ns-qualified NodeId string (the node
/// manager's write hook passes <c>node.NodeId.ToString()</c>); the host normalises it to the bare id and
/// resolves the <c>NodeId → (DriverInstanceId, RawPath)</c> reverse map — populated in
/// <c>PushDesiredSubscriptions</c> for BOTH the raw NodeId AND every referencing UNS NodeId — gates on the
/// driver PRIMARY, and forwards a <see cref="DriverInstanceActor.WriteAttribute"/> carrying the tag's
/// <c>RawPath</c> to the driver child. So a write to the raw node and a write to the referencing UNS node
/// both reach the SAME driver point (they share the driver ref).
/// <para>
/// Drives a real apply through the existing harness (same artifact shape as
/// <c>DriverHostActorLiveValueTests</c>) so the reverse map is populated authentically and a real
/// (non-stubbed) <see cref="DriverInstanceActor"/> child is spawned. The child is backed by a
/// recording <see cref="IWritable"/> driver so the test can observe the forwarded write and assert
/// the no-write case on the secondary. There is no test seam to inject a <c>TestProbe</c> as a
/// driver child, so this end-to-end approach (recording driver) is the closest faithful test the
/// harness allows.
/// Drives a real apply (Enabled Modbus driver ⇒ a real <see cref="DriverInstanceActor"/> child backed
/// by a recording driver), then routes writes and asserts the forwarded wire-ref + the primary gate.
/// </para>
/// </summary>
public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
@@ -44,48 +39,68 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
/// <summary>On the PRIMARY, a RouteNodeWrite for a mapped NodeId forwards the driver-side FullName to
/// the right driver child (observed via the recording driver) and replies NodeWriteResult(true).</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
public void Primary_routes_write_to_driver_by_full_name_and_replies_success()
// The single seeded raw tag + its UNS reference.
private const string RawPath = "Plant/Modbus/dev1/speed";
private const string UnsNodeId = "filling/line1/station1/speed";
/// <summary>On the PRIMARY, a RouteNodeWrite for the ns-qualified UNS NodeId resolves to the raw tag's
/// driver ref and forwards the write keyed by the tag's RawPath (dual-NodeId write routing).</summary>
[Fact]
public void Primary_routes_write_via_uns_nodeid_to_driver_by_rawpath()
{
var db = NewInMemoryDbFactory();
var recorder = new RecordingDriverFactory("Modbus");
// One equipment tag: eq-1, drv-1, FullName "40001", no folder, Name "speed" → NodeId "eq-1/speed".
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
(Equip: "eq-1", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed"));
var deploymentId = SeedV3Deployment(db, RevA);
var actor = SpawnHostAndApply(db, deploymentId, recorder);
// Local role unknown ⇒ treated as Primary ⇒ write allowed (default-emit semantics).
var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0), asker.Ref);
// The node manager passes the FULL ns-qualified id; the host must normalise + resolve it.
actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0), asker.Ref);
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
result.Success.ShouldBeTrue();
// The driver received exactly the write, keyed by its wire-ref FullName (not the NodeId).
asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout).Success.ShouldBeTrue();
AwaitAssert(() =>
{
recorder.Writes.Count.ShouldBe(1);
recorder.Writes[0].FullReference.ShouldBe("40001");
recorder.Writes[0].FullReference.ShouldBe(RawPath); // the driver writes by RawPath, not the UNS id
recorder.Writes[0].Value.ShouldBe(123.0);
}, duration: Timeout);
}
/// <summary>On a SECONDARY, RouteNodeWrite replies NodeWriteResult(false, "not primary") and the
/// driver receives NO write — the primary gate fires before the reverse-map lookup.</summary>
/// <summary>On the PRIMARY, a RouteNodeWrite for the ns-qualified RAW NodeId resolves to the same driver
/// ref and forwards the write keyed by the RawPath.</summary>
[Fact]
public void Primary_routes_write_via_raw_nodeid_to_driver_by_rawpath()
{
var db = NewInMemoryDbFactory();
var recorder = new RecordingDriverFactory("Modbus");
var deploymentId = SeedV3Deployment(db, RevA);
var actor = SpawnHostAndApply(db, deploymentId, recorder);
var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=2;s={RawPath}", 456.0), asker.Ref);
asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout).Success.ShouldBeTrue();
AwaitAssert(() =>
{
recorder.Writes.Count.ShouldBe(1);
recorder.Writes[0].FullReference.ShouldBe(RawPath);
recorder.Writes[0].Value.ShouldBe(456.0);
}, duration: Timeout);
}
/// <summary>On a SECONDARY, RouteNodeWrite (via the UNS NodeId) replies "not primary" and the driver
/// receives NO write — the primary gate fires before the reverse-map lookup.</summary>
[Fact]
public void Secondary_rejects_write_and_does_not_forward_to_driver()
{
var db = NewInMemoryDbFactory();
var recorder = new RecordingDriverFactory("Modbus");
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
(Equip: "eq-1", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed"));
var deploymentId = SeedV3Deployment(db, RevA);
var actor = SpawnHostAndApply(db, deploymentId, recorder);
// Force this node Secondary so the primary gate rejects.
actor.Tell(new RedundancyStateChanged(
new[]
{
@@ -95,29 +110,26 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
CorrelationId.NewId()));
var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0), asker.Ref);
actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0), asker.Ref);
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
result.Success.ShouldBeFalse();
result.Reason.ShouldBe("not primary");
// No write reached the driver — the gate short-circuited before the reverse-map lookup.
recorder.Writes.ShouldBeEmpty();
}
/// <summary>An unknown NodeId (no reverse-map entry) replies NodeWriteResult(false) and writes nothing.</summary>
/// <summary>An unknown NodeId (no reverse-map entry) replies failure and writes nothing.</summary>
[Fact]
public void Unknown_node_id_replies_failure()
{
var db = NewInMemoryDbFactory();
var recorder = new RecordingDriverFactory("Modbus");
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
(Equip: "eq-1", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed"));
var deploymentId = SeedV3Deployment(db, RevA);
var actor = SpawnHostAndApply(db, deploymentId, recorder);
var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/does-not-exist", 123.0), asker.Ref);
actor.Tell(new DriverHostActor.RouteNodeWrite("ns=3;s=filling/line1/station1/does-not-exist", 1.0), asker.Ref);
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
result.Success.ShouldBeFalse();
@@ -125,49 +137,11 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
recorder.Writes.ShouldBeEmpty();
}
/// <summary>A protocol TagConfig blob with no <c>FullName</c> key routes by the equipment NodeId, and
/// the forwarded wire-ref is the raw blob verbatim. <c>ExtractTagFullName</c> falls back to the raw
/// blob string when no top-level <c>FullName</c> property is present, so the reverse map keys on
/// <c>(DriverInstanceId, &lt;raw-blob&gt;)</c> and the driver receives that exact string as its
/// <c>WriteRequest.FullReference</c> — not a FullName value extracted from the blob.</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
public void Primary_routes_write_for_raw_protocol_blob_tag()
{
var db = NewInMemoryDbFactory();
var recorder = new RecordingDriverFactory("Modbus");
// Seed the tag with a genuine protocol blob that has NO FullName key (pure Modbus wire config).
// ExtractTagFullName falls back to returning the raw blob string verbatim, so that string IS the
// wire-ref the reverse map keys on and the driver receives as WriteRequest.FullReference.
var (deploymentId, rawBlobString) = SeedDeploymentWithRawBlobTag(db, RevA,
equip: "eq-2", driver: "drv-2", name: "torque");
var actor = SpawnHostAndApply(db, deploymentId, recorder);
// Local role unknown ⇒ treated as Primary ⇒ write allowed.
var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-2/torque", 456.0), asker.Ref);
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
result.Success.ShouldBeTrue();
// The forwarded wire-ref is the raw blob string verbatim — ExtractTagFullName fell back because
// there is no top-level "FullName" key in the blob.
AwaitAssert(() =>
{
recorder.Writes.Count.ShouldBe(1);
recorder.Writes[0].FullReference.ShouldBe(rawBlobString);
recorder.Writes[0].Value.ShouldBe(456.0);
}, duration: Timeout);
}
/// <summary>A RouteNodeWrite arriving while the host is Stale (config DB unreachable) must fast-fail
/// with an immediate negative NodeWriteResult (reason mentions "stale") instead of dead-lettering into
/// the node-manager's bounded-Ask timeout. Drives the host into Stale via a DB factory whose
/// CreateDbContext throws on bootstrap (the same fall-through to <c>Become(Stale)</c> production uses).</summary>
/// <summary>A RouteNodeWrite arriving while the host is Stale (config DB unreachable) fast-fails with an
/// immediate negative reply mentioning "stale".</summary>
[Fact]
public void Stale_host_fast_fails_route_node_write()
{
// A factory that always throws on CreateDbContext ⇒ Bootstrap's try fails ⇒ Become(Stale).
var db = new ThrowingDbFactory();
var coordinator = CreateTestProbe();
var actor = Sys.ActorOf(DriverHostActor.Props(
@@ -175,7 +149,7 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
localRoles: new HashSet<string> { "driver" }));
var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0), asker.Ref);
actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0), asker.Ref);
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(TimeSpan.FromSeconds(2));
result.Success.ShouldBeFalse();
@@ -183,10 +157,9 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
result.Reason!.ShouldContain("stale");
}
/// <summary>Spawns the host with the recording driver factory, dispatches the deployment, and waits
/// for the Applied ACK so the apply (and thus the reverse-map build in PushDesiredSubscriptions) has
/// completed before the test routes a write. No OPC UA / mux probes are wired — this test exercises
/// only the write path, which doesn't depend on the publish actor.</summary>
/// <summary>Spawns the host with the recording driver factory, dispatches the deployment, and waits for
/// the Applied ACK so the reverse-map build in PushDesiredSubscriptions has completed before routing a
/// write. No OPC UA / mux probes are wired — the write path doesn't depend on the publish actor.</summary>
private IActorRef SpawnHostAndApply(
IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId, IDriverFactory factory)
{
@@ -198,49 +171,32 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
coordinator.ExpectMsg<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
return actor;
}
/// <summary>
/// Seeds a Sealed deployment whose artifact carries the minimal arrays
/// <c>DeploymentArtifact.BuildEquipmentTagPlans</c> needs to project equipment tags. Mirrors
/// <c>DriverHostActorLiveValueTests.SeedDeploymentWithEquipmentTags</c> but also carries a
/// <c>DriverInstances</c> row with a non-Windows-only <c>DriverType</c> ("Modbus") + Enabled flag
/// so a REAL (non-stubbed) <see cref="DriverInstanceActor"/> child is spawned for the write path.
/// </summary>
private static DeploymentId SeedDeploymentWithEquipmentTags(
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev,
params (string Equip, string Driver, string FullName, string? Folder, string Name)[] tags)
/// <summary>Seeds a Sealed v3 deployment: RawFolder "Plant" → DriverInstance "drv-1" (Modbus, ENABLED so a
/// real child spawns) → Device "dev1" → Tag "speed" (RawPath <c>Plant/Modbus/dev1/speed</c>, ReadWrite),
/// projected into equipment <c>filling/line1/station1</c> by a UnsTagReference (UNS NodeId
/// <c>filling/line1/station1/speed</c>).</summary>
private static DeploymentId SeedV3Deployment(IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev)
{
var driverIds = tags.Select(t => t.Driver).Distinct(StringComparer.Ordinal).ToArray();
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
{
Namespaces = new[]
RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
DriverInstances = new[]
{
new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0
new { DriverInstanceId = "drv-1", RawFolderId = "rf-plant", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = true },
},
DriverInstances = driverIds.Select(d => new
Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "dev1", DeviceConfig = "{}" } },
TagGroups = Array.Empty<object>(),
Tags = new[]
{
DriverInstanceRowId = Guid.NewGuid(),
DriverInstanceId = d,
Name = d,
DriverType = "Modbus", // not Windows-only ⇒ a real child is spawned (not stubbed)
Enabled = true,
DriverConfig = "{}",
NamespaceId = "ns-eq",
}).ToArray(),
Tags = tags.Select((t, i) => new
{
TagId = $"tag-{i}",
EquipmentId = t.Equip,
DriverInstanceId = t.Driver,
Name = t.Name,
FolderPath = t.Folder,
DataType = "Double",
TagConfig = JsonSerializer.Serialize(new { FullName = t.FullName }),
}).ToArray(),
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 } },
});
var id = DeploymentId.NewId();
@@ -258,77 +214,8 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
return id;
}
/// <summary>
/// Seeds a single-tag Sealed deployment whose tag's <c>TagConfig</c> is a genuine protocol-driver
/// blob with <strong>no <c>FullName</c> key</strong> (pure Modbus wire config:
/// <c>{"region":"HoldingRegister","address":200,"dataType":"UInt16"}</c>). Because
/// <c>ExtractTagFullName</c> finds no top-level <c>FullName</c> property, it falls back to
/// returning the raw blob string verbatim — that raw string becomes the
/// <c>(DriverInstanceId, &lt;raw-blob&gt;)</c> reverse-map key, and the driver receives it as
/// <c>WriteRequest.FullReference</c>. Returns both the <see cref="DeploymentId"/> and the exact
/// raw blob string so the caller can assert the forwarded wire-ref precisely.
/// </summary>
private static (DeploymentId DeploymentId, string RawBlobString) SeedDeploymentWithRawBlobTag(
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev,
string equip, string driver, string name)
{
// Serialize the blob with NO FullName field — ExtractTagFullName will fall back to this verbatim.
var rawBlobString = JsonSerializer.Serialize(
new { region = "HoldingRegister", address = 200, dataType = "UInt16" });
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
{
Namespaces = new[]
{
new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0
},
DriverInstances = new[]
{
new
{
DriverInstanceRowId = Guid.NewGuid(),
DriverInstanceId = driver,
Name = driver,
DriverType = "Modbus", // not Windows-only ⇒ a real child is spawned (not stubbed)
Enabled = true,
DriverConfig = "{}",
NamespaceId = "ns-eq",
},
},
Tags = new[]
{
new
{
TagId = "tag-raw",
EquipmentId = equip,
DriverInstanceId = driver,
Name = name,
FolderPath = (string?)null,
DataType = "Double",
// Pure protocol blob: no FullName key. ExtractTagFullName falls back to raw blob.
TagConfig = rawBlobString,
},
},
});
var id = DeploymentId.NewId();
using var ctx = db.CreateDbContext();
ctx.Deployments.Add(new Deployment
{
DeploymentId = id.Value,
RevisionHash = rev.Value,
Status = DeploymentStatus.Sealed,
CreatedBy = "test",
SealedAtUtc = DateTime.UtcNow,
ArtifactBlob = artifact,
});
ctx.SaveChanges();
return (id, rawBlobString);
}
/// <summary>An <see cref="IDbContextFactory{TContext}"/> whose <c>CreateDbContext</c> always throws,
/// driving <see cref="DriverHostActor"/>'s bootstrap into the <c>catch</c> ⇒ <c>Become(Stale)</c> path
/// so a write can be routed at a Stale host.</summary>
/// <summary>An <see cref="IDbContextFactory{T}"/> whose CreateDbContext always throws — drives the host
/// into the Stale path.</summary>
private sealed class ThrowingDbFactory : IDbContextFactory<OtOpcUaConfigDbContext>
{
/// <inheritdoc />
@@ -336,15 +223,14 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
throw new InvalidOperationException("config DB unreachable (test stub)");
}
/// <summary>Factory producing a single <see cref="RecordingDriver"/> for the supported type, whose
/// recorded write list is exposed for assertions.</summary>
/// <summary>Factory producing a single <see cref="RecordingDriver"/> for the supported type.</summary>
private sealed class RecordingDriverFactory : IDriverFactory
{
private readonly string _supportedType;
private readonly RecordingDriver _driver = new();
public RecordingDriverFactory(string supportedType) { _supportedType = supportedType; }
/// <summary>The writes the spawned driver received (thread-safe — WriteAsync runs off the actor thread).</summary>
/// <summary>The writes the spawned driver received.</summary>
public IReadOnlyList<WriteRequest> Writes => _driver.Writes;
/// <inheritdoc />
@@ -389,7 +275,7 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
{
foreach (var w in writes) _writes.Enqueue(w);
return Task.FromResult<IReadOnlyList<WriteResult>>(
writes.Select(_ => new WriteResult(0u)).ToArray()); // 0x0 = Good
writes.Select(_ => new WriteResult(0u)).ToArray());
}
}
}