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.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.Tests.Harness; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; public sealed class DriverHostActorReconcileTests : RuntimeActorTestBase { private static readonly NodeId TestNode = NodeId.Parse("driver-test"); private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64)); private static readonly RevisionHash RevB = RevisionHash.Parse(new string('b', 64)); /// Verifies that applying a deployment with driver instances spawns one child per enabled row. [Fact] public void Apply_with_driver_instances_in_artifact_spawns_one_child_per_enabled_row() { var db = NewInMemoryDbFactory(); var factory = new CountingDriverFactory("Modbus"); var deploymentId = SeedDeploymentWithDrivers(db, RevA, ("DI-1", "Modbus", "{}", true), ("DI-2", "Modbus", "{}", true), ("DI-3", "Modbus", "{}", false)); // disabled — not spawned var coordinator = CreateTestProbe(); var actor = Sys.ActorOf(DriverHostActor.Props( db, TestNode, coordinator.Ref, driverFactory: factory, localRoles: new HashSet { "driver" })); actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); coordinator.ExpectMsg(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied); AwaitAssert(() => factory.CreateCount.ShouldBe(2), duration: TimeSpan.FromSeconds(3)); } /// Verifies that applying a deployment with unsupported driver type falls back to stub. [Fact] public void Apply_with_unsupported_driver_type_falls_back_to_stub() { var db = NewInMemoryDbFactory(); // Factory only supports "Modbus" — the Galaxy row should boot stubbed. var factory = new CountingDriverFactory("Modbus"); var deploymentId = SeedDeploymentWithDrivers(db, RevA, ("DI-galaxy", "Galaxy", "{}", true)); var coordinator = CreateTestProbe(); var actor = Sys.ActorOf(DriverHostActor.Props( db, TestNode, coordinator.Ref, driverFactory: factory, localRoles: new HashSet { "driver" })); actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); coordinator.ExpectMsg(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied); // No real driver was constructed — stubbing took over. factory.CreateCount.ShouldBe(0); // GetDiagnostics should still report the (stubbed) child. actor.Tell(new GetDiagnostics(CorrelationId.NewId()), coordinator.Ref); var snap = coordinator.ExpectMsg(TimeSpan.FromSeconds(2)); snap.Drivers.Count.ShouldBe(1); snap.Drivers[0].State.ShouldBe("Stubbed"); } /// Verifies that Galaxy driver on non-Windows is stubbed by ShouldStub check. [Fact] public void Galaxy_on_non_windows_is_stubbed_by_ShouldStub_check() { // Even if the factory could create it, ShouldStub('Galaxy', ...) returns true on macOS/Linux — // the factory should never be called. var db = NewInMemoryDbFactory(); var factory = new CountingDriverFactory("Galaxy"); var deploymentId = SeedDeploymentWithDrivers(db, RevA, ("DI-galaxy", "Galaxy", "{}", true)); var coordinator = CreateTestProbe(); var actor = Sys.ActorOf(DriverHostActor.Props( db, TestNode, coordinator.Ref, driverFactory: factory, localRoles: new HashSet { "driver" })); actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); coordinator.ExpectMsg(TimeSpan.FromSeconds(5)); if (OperatingSystem.IsWindows()) { factory.CreateCount.ShouldBe(1); } else { factory.CreateCount.ShouldBe(0); // ShouldStub forced the stub path } } /// Verifies that a second apply with removed driver stops the child. [Fact] public void Second_apply_with_removed_driver_stops_the_child() { var db = NewInMemoryDbFactory(); var factory = new CountingDriverFactory("Modbus"); var d1 = SeedDeploymentWithDrivers(db, RevA, ("DI-1", "Modbus", "{}", true), ("DI-2", "Modbus", "{}", true)); var d2 = SeedDeploymentWithDrivers(db, RevB, ("DI-1", "Modbus", "{}", true)); var coordinator = CreateTestProbe(); var actor = Sys.ActorOf(DriverHostActor.Props( db, TestNode, coordinator.Ref, driverFactory: factory, localRoles: new HashSet { "driver" })); actor.Tell(new DispatchDeployment(d1, RevA, CorrelationId.NewId())); coordinator.ExpectMsg(TimeSpan.FromSeconds(5)); AwaitAssert(() => factory.CreateCount.ShouldBe(2), duration: TimeSpan.FromSeconds(3)); actor.Tell(new DispatchDeployment(d2, RevB, CorrelationId.NewId())); coordinator.ExpectMsg(TimeSpan.FromSeconds(5)); actor.Tell(new GetDiagnostics(CorrelationId.NewId()), coordinator.Ref); var snap = coordinator.ExpectMsg(TimeSpan.FromSeconds(2)); snap.Drivers.Count.ShouldBe(1); } /// /// Verifies per-ClusterId scoping at the actor level: a 2-cluster artifact (MAIN + SITE-A, /// one driver each) dispatched to a node whose ClusterNode row puts it in SITE-A spawns ONLY /// the SITE-A driver — and the node still reaches Applied (the ack fires unconditionally even /// when a node's cluster slice is empty). /// [Fact] public void DriverHostActor_spawns_only_its_clusters_drivers() { var db = NewInMemoryDbFactory(); var factory = new CountingDriverFactory("Modbus"); // Both drivers are Modbus so the factory could create either — scoping, not type support, // must be what excludes the MAIN driver. var deploymentId = SeedMultiClusterDeployment(db, RevA, ("main-modbus", "Modbus", "{}", true, "MAIN"), ("sa-modbus", "Modbus", "{}", true, "SITE-A")); // This node belongs to SITE-A per the Nodes (ClusterNode) rows. var siteANode = NodeId.Parse("site-a-1:4053"); var coordinator = CreateTestProbe(); var actor = Sys.ActorOf(DriverHostActor.Props( db, siteANode, coordinator.Ref, driverFactory: factory, localRoles: new HashSet { "driver" })); actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); // The node still reaches Applied even though it hosts only its own cluster's slice. coordinator.ExpectMsg(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied); // Only the SITE-A driver was constructed — the MAIN driver was filtered out by scoping. AwaitAssert(() => factory.CreateCount.ShouldBe(1), duration: TimeSpan.FromSeconds(3)); actor.Tell(new GetDiagnostics(CorrelationId.NewId()), coordinator.Ref); var snap = coordinator.ExpectMsg(TimeSpan.FromSeconds(2)); snap.Drivers.Count.ShouldBe(1); snap.Drivers[0].Name.ShouldBe("sa-modbus"); } private static DeploymentId SeedMultiClusterDeployment( IDbContextFactory db, RevisionHash rev, params (string Id, string Type, string Config, bool Enabled, string ClusterId)[] drivers) { var artifact = JsonSerializer.SerializeToUtf8Bytes(new { // >1 cluster + matching Nodes rows triggers ScopeTo (single-cluster would resolve to None). Clusters = new[] { new { ClusterId = "MAIN" }, new { ClusterId = "SITE-A" } }, Nodes = new[] { new { NodeId = "central-1:4053", ClusterId = "MAIN" }, new { NodeId = "site-a-1:4053", ClusterId = "SITE-A" }, }, DriverInstances = drivers.Select(d => new { DriverInstanceRowId = Guid.NewGuid(), DriverInstanceId = d.Id, Name = d.Id, DriverType = d.Type, Enabled = d.Enabled, DriverConfig = d.Config, ClusterId = d.ClusterId, }).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; } private static DeploymentId SeedDeploymentWithDrivers( IDbContextFactory db, RevisionHash rev, params (string Id, string Type, string Config, bool Enabled)[] drivers) { var artifact = JsonSerializer.SerializeToUtf8Bytes(new { DriverInstances = drivers.Select(d => new { DriverInstanceRowId = Guid.NewGuid(), DriverInstanceId = d.Id, Name = d.Id, DriverType = d.Type, Enabled = d.Enabled, DriverConfig = d.Config, }).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; } /// Test double for IDriverFactory that counts driver creation attempts. private sealed class CountingDriverFactory : IDriverFactory { private readonly string _supportedType; /// Gets the number of times TryCreate was called and returned a driver. public int CreateCount; /// Initializes a new instance with the specified supported driver type. /// The driver type this factory supports. public CountingDriverFactory(string supportedType) { _supportedType = supportedType; } /// Attempts to create a driver if the type is supported. /// The driver type to create. /// The unique identifier for the driver instance. /// The driver configuration in JSON format. public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson) { if (!string.Equals(driverType, _supportedType, StringComparison.Ordinal)) return null; Interlocked.Increment(ref CreateCount); return new TestDriver(driverInstanceId, driverType); } /// public IReadOnlyCollection SupportedTypes => new[] { _supportedType }; } /// Test double for IDriver with minimal implementation. private sealed class TestDriver : IDriver { /// public string DriverInstanceId { get; } /// public string DriverType { get; } /// Initializes a new test driver with the specified ID and type. /// The driver instance ID. /// The driver type. public TestDriver(string id, string type) { DriverInstanceId = id; DriverType = type; } /// public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask; /// public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask; /// public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask; /// public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, LastError: null); /// public long GetMemoryFootprint() => 0; /// public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask; } }