feat(otopcua): driver-level equipment resolution + per-equipment discovered-plan cache (follow-up E)
This commit is contained in:
+135
@@ -113,6 +113,81 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
|
||||
update.TimestampUtc.ShouldBe(Ts);
|
||||
}
|
||||
|
||||
/// <summary>NEW capability (follow-up E): a driver bound to an equipment via
|
||||
/// <see cref="EquipmentNode.DriverInstanceId"/> with ZERO authored equipment tags can still graft its
|
||||
/// discovered FixedTree. The equipment is resolved from the composition's <c>EquipmentNodes</c> (not just
|
||||
/// the authored <c>EquipmentTags</c>), so a tag-less equipment receives
|
||||
/// <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> rooted at its NodeId and the driver
|
||||
/// subscribes the discovered refs (no authored ref exists to union). Previously this was skipped with
|
||||
/// "no equipment/authored tags".</summary>
|
||||
[Fact]
|
||||
public void Tag_less_equipment_resolved_via_EquipmentNode_grafts_discovered_nodes()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var factory = new SubscribingDriverFactory("Modbus");
|
||||
// EQ-1 bound to driver d1 via EquipmentNode.DriverInstanceId, with NO authored equipment tags for d1.
|
||||
var deploymentId = SeedDeploymentWithTagLessEquipment(db, RevA, equipmentId: "EQ-1", driverId: "d1");
|
||||
|
||||
var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory);
|
||||
|
||||
actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[]
|
||||
{
|
||||
new DiscoveredNode(
|
||||
FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" },
|
||||
BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1",
|
||||
DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null,
|
||||
Writable: false, IsHistorized: false),
|
||||
}));
|
||||
|
||||
// (a) The discovered nodes materialise UNDER EQ-1 even though no authored tag binds d1 → EQ-1.
|
||||
var materialise = publish.ExpectMsg<OpcUaPublishActor.MaterialiseDiscoveredNodes>(Timeout);
|
||||
materialise.EquipmentRootNodeId.ShouldBe("EQ-1");
|
||||
materialise.Variables.Count.ShouldBe(1);
|
||||
var fixedTreeNodeId = materialise.Variables[0].NodeId;
|
||||
|
||||
// (b) The driver subscribes the discovered ref (the union is just the FixedTree ref — no authored ref).
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
var refs = factory.LastSubscribedRefs;
|
||||
refs.ShouldNotBeNull();
|
||||
refs!.ShouldContain("ft-ref-1");
|
||||
}, duration: Timeout);
|
||||
|
||||
// (c) A value published for the FixedTree ref routes to its mapped NodeId (routing map was extended).
|
||||
actor.Tell(new DriverInstanceActor.AttributeValuePublished("d1", "ft-ref-1", 42.0, OpcUaQuality.Good, Ts));
|
||||
var update = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(Timeout);
|
||||
update.NodeId.ShouldBe(fixedTreeNodeId);
|
||||
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>
|
||||
[Fact]
|
||||
public void Driver_mapping_to_more_than_one_equipment_still_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.
|
||||
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"));
|
||||
|
||||
var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory);
|
||||
|
||||
actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[]
|
||||
{
|
||||
new DiscoveredNode(
|
||||
FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" },
|
||||
BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1",
|
||||
DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null,
|
||||
Writable: false, IsHistorized: false),
|
||||
}));
|
||||
|
||||
// Nothing is grafted — the >1-equipment branch warn+skips (replaced by the multi-device task).
|
||||
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
/// <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>
|
||||
@@ -469,6 +544,66 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
|
||||
return id;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds a Sealed deployment whose artifact binds an equipment to a driver via the
|
||||
/// <c>Equipment</c> row's <c>DriverInstanceId</c> (the <see cref="EquipmentNode.DriverInstanceId"/>
|
||||
/// projection) but carries NO authored equipment tags — so the equipment can only be resolved from
|
||||
/// <c>EquipmentNodes</c>, not <c>EquipmentTags</c>. A <c>DriverInstances</c> row (non-Windows-only
|
||||
/// "Modbus", Enabled) is included so a REAL (non-stubbed) <see cref="DriverInstanceActor"/> child is
|
||||
/// spawned for the driver even though it has zero tags.
|
||||
/// </summary>
|
||||
private static DeploymentId SeedDeploymentWithTagLessEquipment(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev, string equipmentId, string driverId)
|
||||
{
|
||||
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",
|
||||
},
|
||||
},
|
||||
// The equipment binds to the driver via DriverInstanceId — the NEW resolution path — with no tags.
|
||||
Equipment = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
EquipmentId = equipmentId,
|
||||
Name = equipmentId,
|
||||
UnsLineId = (string?)null,
|
||||
DriverInstanceId = driverId,
|
||||
DeviceId = (string?)null,
|
||||
},
|
||||
},
|
||||
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