feat(otopcua): multi-device-per-driver FixedTree partition (follow-up E)

This commit is contained in:
Joseph Doherty
2026-06-26 15:00:11 -04:00
parent 51721df563
commit 50f08635ec
4 changed files with 392 additions and 36 deletions
@@ -161,15 +161,17 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
update.Value.ShouldBe(42.0);
}
/// <summary>Multi-equipment-per-driver still warn+skips (the multi-device partition is the NEXT follow-up
/// task). A driver that resolves to MORE THAN ONE equipment injects nothing yet: no
/// <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> is told.</summary>
/// <summary>DEGENERATE multi-equipment case (follow-up E, part 2): a driver that resolves to MORE THAN ONE
/// equipment but where NONE of the candidates carries a <see cref="EquipmentNode.DeviceHost"/> has nothing
/// to partition the FixedTree on, so the whole driver is warn-skipped (nothing grafted, no crash). Here d1
/// is bound to two equipments via two AUTHORED tags only (no Device rows ⇒ no DeviceHost), which is exactly
/// the degenerate shape: no <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> is told.</summary>
[Fact]
public void Driver_mapping_to_more_than_one_equipment_still_warn_skips()
public void Driver_mapping_to_more_than_one_equipment_with_no_device_host_warn_skips()
{
var db = NewInMemoryDbFactory();
var factory = new SubscribingDriverFactory("Modbus");
// d1 is bound to TWO equipments via two authored tags ⇒ equipmentIds.Count == 2 ⇒ warn+skip.
// d1 is bound to TWO equipments via two authored tags (no devices ⇒ no DeviceHost) ⇒ degenerate ⇒ warn-skip.
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
(Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"),
(Equip: "EQ-2", Driver: "d1", FullName: "40002", Folder: (string?)null, Name: "speed2"));
@@ -185,10 +187,113 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
Writable: false, IsHistorized: false),
}));
// Nothing is grafted — the >1-equipment branch warn+skips (replaced by the multi-device task).
// Nothing is grafted — no candidate has a DeviceHost, so there's nothing to partition on (degenerate).
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
/// <summary>Multi-device split (follow-up E, part 2): a driver that resolves to >1 equipment, each bound to
/// a DEVICE with a distinct <see cref="EquipmentNode.DeviceHost"/>, partitions its discovered FixedTree by
/// the (normalized) device-host folder segment and grafts each device's subset under the equipment whose
/// DeviceHost matches. Asserts (a) TWO <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> (one per
/// equipment), (b) the union subscription carries BOTH devices' refs, and (c) a value for each device's ref
/// routes to the right equipment's node (proving BOTH inner-map entries cached + keyed correctly). The "H1"
/// vs stored "h1" wrinkle proves the SHARED <see cref="AddressSpaceComposer.NormalizeDeviceHost"/> match.</summary>
[Fact]
public void Multi_device_driver_partitions_fixed_tree_by_device_host_under_matching_equipment()
{
var db = NewInMemoryDbFactory();
var factory = new SubscribingDriverFactory("Modbus");
// d1 fans out to EQ-A (device host "h1") + EQ-B (device host "h2"), tag-less, bound via EquipmentNode.
var deploymentId = SeedDeploymentWithMultiDeviceEquipments(db, RevA, driverId: "d1",
(Equip: "EQ-A", DeviceId: "dev-a", Host: "h1"),
(Equip: "EQ-B", DeviceId: "dev-b", Host: "h2"));
var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory);
// Discovered nodes split across two device-host folder segments. "H1" is UPPERCASE to prove the shared
// NormalizeDeviceHost lower-cases the segment to match the stored lower-cased "h1" DeviceHost.
actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[]
{
new DiscoveredNode(
FolderPathSegments: new[] { "FOCAS", "H1", "Identity" },
BrowseName: "Model", DisplayName: "Model", FullReference: "ft-h1-1",
DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false),
new DiscoveredNode(
FolderPathSegments: new[] { "FOCAS", "h2", "Status" },
BrowseName: "Run", DisplayName: "Run", FullReference: "ft-h2-1",
DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false),
}));
// (a) Each device's subset materialises under its OWN equipment — two messages, one per equipment.
var m1 = publish.ExpectMsg<OpcUaPublishActor.MaterialiseDiscoveredNodes>(Timeout);
var m2 = publish.ExpectMsg<OpcUaPublishActor.MaterialiseDiscoveredNodes>(Timeout);
var byEquipment = new[] { m1, m2 }.ToDictionary(m => m.EquipmentRootNodeId, m => m);
byEquipment.Keys.ShouldBe(new[] { "EQ-A", "EQ-B" }, ignoreOrder: true);
byEquipment["EQ-A"].Variables.ShouldHaveSingleItem().DisplayName.ShouldBe("Model");
byEquipment["EQ-B"].Variables.ShouldHaveSingleItem().DisplayName.ShouldBe("Run");
var eqANodeId = byEquipment["EQ-A"].Variables[0].NodeId;
var eqBNodeId = byEquipment["EQ-B"].Variables[0].NodeId;
// Single device per partition ⇒ the mapper collapses the host folder ⇒ the NodeId carries no host.
eqANodeId.ShouldStartWith("EQ-A/");
eqANodeId.ShouldNotContain("h1");
eqBNodeId.ShouldStartWith("EQ-B/");
eqBNodeId.ShouldNotContain("h2");
// (b) The driver subscribes the UNION of both devices' FixedTree refs (tag-less ⇒ no authored refs).
AwaitAssert(() =>
{
var refs = factory.LastSubscribedRefs;
refs.ShouldNotBeNull();
refs!.ShouldContain("ft-h1-1");
refs.ShouldContain("ft-h2-1");
}, duration: Timeout);
// (c) Routing is keyed per device: h1's ref lands on EQ-A's node, h2's on EQ-B's node.
actor.Tell(new DriverInstanceActor.AttributeValuePublished("d1", "ft-h1-1", 1.0, OpcUaQuality.Good, Ts));
publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(Timeout).NodeId.ShouldBe(eqANodeId);
actor.Tell(new DriverInstanceActor.AttributeValuePublished("d1", "ft-h2-1", 2.0, OpcUaQuality.Good, Ts));
publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(Timeout).NodeId.ShouldBe(eqBNodeId);
}
/// <summary>Multi-device unmatched warn-skip (follow-up E, part 2): a discovered device-host partition with
/// NO matching equipment is warn-skipped (NOT mis-grafted), while the matched partitions still graft. Here
/// d1 fans out to EQ-A("h1") + EQ-B("h2"), but a third discovered partition "h3" matches no equipment: only
/// EQ-A + EQ-B materialise (no third), and the unmatched ref routes nowhere (no crash).</summary>
[Fact]
public void Multi_device_unmatched_device_host_is_warn_skipped_matched_still_graft()
{
var db = NewInMemoryDbFactory();
var factory = new SubscribingDriverFactory("Modbus");
var deploymentId = SeedDeploymentWithMultiDeviceEquipments(db, RevA, driverId: "d1",
(Equip: "EQ-A", DeviceId: "dev-a", Host: "h1"),
(Equip: "EQ-B", DeviceId: "dev-b", Host: "h2"));
var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory);
actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[]
{
new DiscoveredNode(FolderPathSegments: new[] { "FOCAS", "h1", "Identity" },
BrowseName: "Model", DisplayName: "Model", FullReference: "ft-h1-1",
DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false),
new DiscoveredNode(FolderPathSegments: new[] { "FOCAS", "h2", "Status" },
BrowseName: "Run", DisplayName: "Run", FullReference: "ft-h2-1",
DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false),
new DiscoveredNode(FolderPathSegments: new[] { "FOCAS", "h3", "Identity" },
BrowseName: "Ghost", DisplayName: "Ghost", FullReference: "ft-h3-1",
DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false),
}));
// Exactly TWO materialise (EQ-A, EQ-B); the unmatched "h3" grafts nowhere ⇒ no third materialise.
var m1 = publish.ExpectMsg<OpcUaPublishActor.MaterialiseDiscoveredNodes>(Timeout);
var m2 = publish.ExpectMsg<OpcUaPublishActor.MaterialiseDiscoveredNodes>(Timeout);
new[] { m1.EquipmentRootNodeId, m2.EquipmentRootNodeId }.ShouldBe(new[] { "EQ-A", "EQ-B" }, ignoreOrder: true);
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
// The ghost ref was never materialised, so a value for it routes nowhere — dropped, not a crash.
actor.Tell(new DriverInstanceActor.AttributeValuePublished("d1", "ft-h3-1", 9.0, OpcUaQuality.Good, Ts));
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
/// <summary>Guard: a <see cref="DriverInstanceActor.DiscoveredNodesReady"/> arriving BEFORE any deployment
/// is applied (<c>_lastComposition</c> still null) is ignored — nothing is materialised on the publish
/// side (the equipment can't be resolved without a composition).</summary>
@@ -970,6 +1075,72 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
return id;
}
/// <summary>
/// Seeds a Sealed deployment whose artifact binds ONE driver to MULTIPLE equipments, each via a
/// distinct <c>Device</c> row whose <c>DeviceConfig</c> carries a <c>HostAddress</c> — so each
/// <see cref="EquipmentNode"/> resolves a distinct <see cref="EquipmentNode.DeviceHost"/> (the shape
/// the multi-device FixedTree partition keys on). No authored tags (tag-less): the equipments are
/// resolved purely from <c>EquipmentNodes</c>. The driver row is non-Windows-only ("Modbus", Enabled)
/// so a REAL (non-stubbed) <see cref="DriverInstanceActor"/> child is spawned.
/// </summary>
private static DeploymentId SeedDeploymentWithMultiDeviceEquipments(
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev, string driverId,
params (string Equip, string DeviceId, string Host)[] equipments)
{
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
{
Namespaces = new[]
{
new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0
},
DriverInstances = new[]
{
new
{
DriverInstanceRowId = Guid.NewGuid(),
DriverInstanceId = driverId,
Name = driverId,
DriverType = "Modbus", // not Windows-only ⇒ a real child is spawned (not stubbed)
Enabled = true,
DriverConfig = "{}",
NamespaceId = "ns-eq",
},
},
// Each device carries a HostAddress so EquipmentNode.DeviceHost resolves (via the shared extractor).
Devices = equipments.Select(e => new
{
DeviceId = e.DeviceId,
DriverInstanceId = driverId,
Name = e.DeviceId,
DeviceConfig = JsonSerializer.Serialize(new { HostAddress = e.Host }),
}).ToArray(),
// Each equipment binds to the driver AND a device — the multi-device resolution path.
Equipment = equipments.Select(e => new
{
EquipmentId = e.Equip,
Name = e.Equip,
UnsLineId = (string?)null,
DriverInstanceId = driverId,
DeviceId = e.DeviceId,
}).ToArray(),
Tags = Array.Empty<object>(),
});
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;
}
/// <summary>Factory producing a single shared <see cref="SubscribableStubDriver"/> for the supported
/// type, exposing its most-recent subscribed reference set for assertions (mirrors
/// <c>DriverHostActorWriteRoutingTests.RecordingDriverFactory</c>, but the driver is