Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs
T

344 lines
18 KiB
C#

using System.Text.Json;
using Akka.Actor;
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// Verifies the discovered-node injection wired into <see cref="DriverHostActor"/> (Task 7): when a
/// driver child reports a captured FixedTree via <see cref="DriverInstanceActor.DiscoveredNodesReady"/>,
/// the host resolves the bound equipment from the authored composition, maps the nodes under it via
/// <see cref="DiscoveredNodeMapper"/>, materialises them on the OPC UA publish side
/// (<see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/>), extends the live-value routing map
/// (<c>_nodeIdByDriverRef</c>), and merges the FixedTree refs into the driver's desired subscription set
/// (<see cref="DriverInstanceActor.SetDesiredSubscriptions"/>).
///
/// <para>
/// Drives a real apply through the existing harness (same artifact shape as
/// <c>DriverHostActorLiveValueTests</c> / <c>DriverHostActorWriteRoutingTests</c>) so
/// <c>_lastComposition</c> is set and a real (non-stubbed) <see cref="DriverInstanceActor"/> child
/// is spawned for <c>d1</c>. The child is backed by the shared <see cref="SubscribableStubDriver"/>
/// (records <c>LastSubscribedRefs</c>/<c>SubscribeCount</c>, exactly as
/// <c>DriverInstanceActorTests</c> asserts) so the merged subscription is observable; the OPC UA
/// publish actor is a <see cref="Akka.TestKit.TestProbe"/> (as in
/// <c>DriverHostActorLiveValueTests</c>) so the materialise + the post-injection value route are
/// observable. There is no test seam to inject a probe AS a driver child, so this is the faithful
/// end-to-end approach the harness allows.
/// </para>
/// </summary>
[Trait("Category", "Unit")]
public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
{
private static readonly NodeId TestNode = NodeId.Parse("driver-disc-test");
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
private static readonly DateTime Ts = new(2026, 6, 26, 10, 0, 0, DateTimeKind.Utc);
/// <summary>A driver's discovered FixedTree (refs differing from the authored tag) is grafted under the
/// bound equipment: (a) the publish side receives <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/>
/// rooted at the equipment NodeId; (b) the driver re-subscribes the UNION of the authored ref + the
/// FixedTree refs; (c) a value published for a FixedTree ref now routes to its mapped NodeId (proving the
/// live-value routing map was extended).</summary>
[Fact]
public void DiscoveredNodes_materialise_extend_routing_and_merge_subscription()
{
var db = NewInMemoryDbFactory();
var factory = new SubscribingDriverFactory("Modbus");
// One authored value tag: equipment EQ-1, driver d1, FullName "40001" — this both sets
// _lastComposition AND binds d1 → EQ-1 (the only way the equipment is resolved, since EquipmentNode
// carries no DriverInstanceId).
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
(Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
var (actor, publish) = SpawnHostAndApply(db, deploymentId, factory);
// A FixedTree discovered node whose FullReference DIFFERS from the authored tag's FullName, so the
// mapper keeps it (it does not shadow an authored ref).
var discovered = 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),
};
actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", discovered));
// (a) The publish side materialises the discovered folders + variables UNDER the equipment root "EQ-1".
var materialise = publish.ExpectMsg<OpcUaPublishActor.MaterialiseDiscoveredNodes>(Timeout);
materialise.EquipmentRootNodeId.ShouldBe("EQ-1");
materialise.Variables.Count.ShouldBe(1);
materialise.Folders.Count.ShouldBeGreaterThan(0);
var fixedTreeNodeId = materialise.Variables[0].NodeId;
// (b) The driver re-subscribed the UNION of the authored value ref AND the FixedTree ref. The union
// push is the LAST SetDesiredSubscriptions, so the most recent subscribe carries both.
AwaitAssert(() =>
{
var refs = factory.LastSubscribedRefs;
refs.ShouldNotBeNull();
refs!.ShouldContain("40001");
refs.ShouldContain("ft-ref-1");
}, duration: Timeout);
// (c) A value published for the FixedTree ref now routes to the mapped FixedTree NodeId — proving the
// _nodeIdByDriverRef live-value map was extended by the injection.
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);
update.Quality.ShouldBe(OpcUaQuality.Good);
update.TimestampUtc.ShouldBe(Ts);
}
/// <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>
[Fact]
public void DiscoveredNodes_before_any_apply_are_ignored()
{
var db = NewInMemoryDbFactory();
var coordinator = CreateTestProbe();
var publish = CreateTestProbe();
var vtHost = CreateTestProbe();
// No deployment dispatched ⇒ Bootstrap enters Steady with no composition ⇒ _lastComposition is null.
var actor = Sys.ActorOf(DriverHostActor.Props(
db, TestNode, coordinator.Ref,
driverFactory: new SubscribingDriverFactory("Modbus"),
localRoles: new HashSet<string> { "driver" },
opcUaPublishActor: publish.Ref,
virtualTagHostOverride: vtHost.Ref));
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),
}));
// No composition ⇒ no materialise (and no RebuildAddressSpace either, since nothing was applied).
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
/// <summary>Dedup: a discovered node whose <c>FullReference</c> equals an authored equipment tag's
/// FullName is NOT injected (it would shadow the authored node) — only the genuinely-new FixedTree refs
/// are materialised.</summary>
[Fact]
public void Discovered_node_shadowing_an_authored_ref_is_not_injected()
{
var db = NewInMemoryDbFactory();
var factory = new SubscribingDriverFactory("Modbus");
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
(Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
var (actor, publish) = SpawnHostAndApply(db, deploymentId, factory);
// Two captured nodes: one SHADOWS the authored ref "40001" (must be dropped), one is genuinely new.
actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[]
{
new DiscoveredNode(
FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Registers" },
BrowseName: "speed", DisplayName: "Speed", FullReference: "40001",
DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null,
Writable: false, IsHistorized: false),
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),
}));
// Exactly ONE variable materialised — the new "Model", not the authored-shadow "Speed".
var materialise = publish.ExpectMsg<OpcUaPublishActor.MaterialiseDiscoveredNodes>(Timeout);
materialise.Variables.Count.ShouldBe(1);
materialise.Variables[0].DisplayName.ShouldBe("Model");
}
/// <summary>Idempotency / the unchanged-plan short-circuit: re-sending the SAME discovered set (the driver
/// re-discovers each ~2s pass) is a no-op — it materialises ONCE and does not force the child to
/// re-subscribe. A GROWN set, however, DOES re-apply (materialise again + re-subscribe), so a stabilising
/// FixedTree still converges.</summary>
[Fact]
public void Repeated_identical_discovery_does_not_reapply_but_a_grown_set_does()
{
var db = NewInMemoryDbFactory();
var factory = new SubscribingDriverFactory("Modbus");
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
(Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
var (actor, publish) = SpawnHostAndApply(db, deploymentId, factory);
var node1 = 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);
var node2 = new DiscoveredNode(
FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Status" },
BrowseName: "Run", DisplayName: "Run", FullReference: "ft-ref-2",
DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null,
Writable: false, IsHistorized: false);
// Pass 1: a new set ⇒ one materialise + a union re-subscribe.
actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] { node1 }));
publish.ExpectMsg<OpcUaPublishActor.MaterialiseDiscoveredNodes>(Timeout);
AwaitAssert(() =>
{
var refs = factory.LastSubscribedRefs;
refs.ShouldNotBeNull();
refs!.ShouldContain("ft-ref-1");
}, duration: Timeout);
var subscribeCountAfterFirst = factory.SubscribeCount;
// Pass 2: the IDENTICAL set ⇒ short-circuited (no materialise, no re-subscribe).
actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] { node1 }));
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
factory.SubscribeCount.ShouldBe(subscribeCountAfterFirst);
// Pass 3: a GROWN set (superset) ⇒ re-applies (materialise again + re-subscribe with both refs).
actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] { node1, node2 }));
publish.ExpectMsg<OpcUaPublishActor.MaterialiseDiscoveredNodes>(Timeout).Variables.Count.ShouldBe(2);
AwaitAssert(() =>
{
factory.SubscribeCount.ShouldBeGreaterThan(subscribeCountAfterFirst);
var refs = factory.LastSubscribedRefs;
refs.ShouldNotBeNull();
refs!.ShouldContain("ft-ref-1");
refs.ShouldContain("ft-ref-2");
}, duration: Timeout);
}
/// <summary>Spawns the host with the subscribing driver factory + a publish probe, dispatches the
/// deployment, and waits for the Applied ACK so the apply (and thus <c>_lastComposition</c> + the live
/// child + the initial SubscribeBulk pass) has completed before the test injects discovered nodes. A
/// VirtualTag-host probe is injected so the real host isn't spawned. The <see cref="OpcUaPublishActor.RebuildAddressSpace"/>
/// that lands on the publish probe during apply is drained so the test's materialise / value-update
/// assertions see only post-apply traffic.</summary>
private (IActorRef Actor, Akka.TestKit.TestProbe Publish) SpawnHostAndApply(
IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId, IDriverFactory factory)
{
var coordinator = CreateTestProbe();
var publish = CreateTestProbe();
var vtHost = CreateTestProbe();
var actor = Sys.ActorOf(DriverHostActor.Props(
db, TestNode, coordinator.Ref,
driverFactory: factory,
localRoles: new HashSet<string> { "driver" },
opcUaPublishActor: publish.Ref,
virtualTagHostOverride: vtHost.Ref));
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
coordinator.ExpectMsg<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(Timeout);
return (actor, publish);
}
/// <summary>
/// Seeds a Sealed deployment whose artifact carries the minimal arrays
/// <c>DeploymentArtifact.BuildEquipmentTagPlans</c> needs to project equipment tags, plus 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 (mirrors
/// <c>DriverHostActorWriteRoutingTests.SeedDeploymentWithEquipmentTags</c>).
/// </summary>
private static DeploymentId SeedDeploymentWithEquipmentTags(
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev,
params (string Equip, string Driver, string FullName, string? Folder, string Name)[] tags)
{
var driverIds = tags.Select(t => t.Driver).Distinct(StringComparer.Ordinal).ToArray();
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
{
Namespaces = new[]
{
new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0
},
DriverInstances = driverIds.Select(d => 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(),
});
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
/// <see cref="ISubscribable"/> so the merged subscription is observable).</summary>
private sealed class SubscribingDriverFactory : IDriverFactory
{
private readonly string _supportedType;
private readonly SubscribableStubDriver _driver = new();
public SubscribingDriverFactory(string supportedType) { _supportedType = supportedType; }
/// <summary>The reference set passed to the driver's most recent <c>SubscribeAsync</c> call.</summary>
public IReadOnlyList<string>? LastSubscribedRefs => _driver.LastSubscribedRefs;
/// <summary>Number of <c>SubscribeAsync</c> calls so far — lets a test prove a redundant re-apply did
/// NOT force a (drop-then-)re-subscribe of the whole handle.</summary>
public int SubscribeCount => _driver.SubscribeCount;
/// <inheritdoc />
public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson) =>
string.Equals(driverType, _supportedType, StringComparison.Ordinal) ? _driver : null;
/// <inheritdoc />
public IReadOnlyCollection<string> SupportedTypes => new[] { _supportedType };
}
}