feat(otopcua): multi-device-per-driver FixedTree partition (follow-up E)
This commit is contained in:
+177
-6
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user