using System.Collections.Concurrent;
using System.Text.Json;
using Akka.Actor;
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy;
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;
///
/// Verifies the inbound operator-write routing wired into : a
/// for a materialised equipment-variable NodeId resolves
/// the NodeId → (DriverInstanceId, FullName) reverse map (built alongside the forward map in
/// PushDesiredSubscriptions), gates on this node being the driver PRIMARY (reusing the same
/// RedundancyStateChanged signal the alarm-emit gate uses), forwards a
/// carrying the driver-side FullName to the
/// right driver child, and replies a to the asker.
///
///
/// Drives a real apply through the existing harness (same artifact shape as
/// DriverHostActorLiveValueTests) so the reverse map is populated authentically and a real
/// (non-stubbed) child is spawned. The child is backed by a
/// recording driver so the test can observe the forwarded write and assert
/// the no-write case on the secondary. There is no test seam to inject a TestProbe as a
/// driver child, so this end-to-end approach (recording driver) is the closest faithful test the
/// harness allows.
///
///
public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
{
private static readonly NodeId TestNode = NodeId.Parse("driver-wr-test");
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
/// On the PRIMARY, a RouteNodeWrite for a mapped NodeId forwards the driver-side FullName to
/// the right driver child (observed via the recording driver) and replies NodeWriteResult(true).
[Fact]
public void Primary_routes_write_to_driver_by_full_name_and_replies_success()
{
var db = NewInMemoryDbFactory();
var recorder = new RecordingDriverFactory("Modbus");
// One equipment tag: eq-1, drv-1, FullName "40001", no folder, Name "speed" → NodeId "eq-1/speed".
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
(Equip: "eq-1", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed"));
var actor = SpawnHostAndApply(db, deploymentId, recorder);
// Local role unknown ⇒ treated as Primary ⇒ write allowed (default-emit semantics).
var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0), asker.Ref);
var result = asker.ExpectMsg(Timeout);
result.Success.ShouldBeTrue();
// The driver received exactly the write, keyed by its wire-ref FullName (not the NodeId).
AwaitAssert(() =>
{
recorder.Writes.Count.ShouldBe(1);
recorder.Writes[0].FullReference.ShouldBe("40001");
recorder.Writes[0].Value.ShouldBe(123.0);
}, duration: Timeout);
}
/// On a SECONDARY, RouteNodeWrite replies NodeWriteResult(false, "not primary") and the
/// driver receives NO write — the primary gate fires before the reverse-map lookup.
[Fact]
public void Secondary_rejects_write_and_does_not_forward_to_driver()
{
var db = NewInMemoryDbFactory();
var recorder = new RecordingDriverFactory("Modbus");
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
(Equip: "eq-1", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed"));
var actor = SpawnHostAndApply(db, deploymentId, recorder);
// Force this node Secondary so the primary gate rejects.
actor.Tell(new RedundancyStateChanged(
new[]
{
new NodeRedundancyState(TestNode, RedundancyRole.Secondary,
IsClusterLeader: false, IsRoleLeaderForDriver: false, AsOfUtc: DateTime.UtcNow),
},
CorrelationId.NewId()));
var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0), asker.Ref);
var result = asker.ExpectMsg(Timeout);
result.Success.ShouldBeFalse();
result.Reason.ShouldBe("not primary");
// No write reached the driver — the gate short-circuited before the reverse-map lookup.
recorder.Writes.ShouldBeEmpty();
}
/// An unknown NodeId (no reverse-map entry) replies NodeWriteResult(false) and writes nothing.
[Fact]
public void Unknown_node_id_replies_failure()
{
var db = NewInMemoryDbFactory();
var recorder = new RecordingDriverFactory("Modbus");
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
(Equip: "eq-1", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed"));
var actor = SpawnHostAndApply(db, deploymentId, recorder);
var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/does-not-exist", 123.0), asker.Ref);
var result = asker.ExpectMsg(Timeout);
result.Success.ShouldBeFalse();
result.Reason.ShouldNotBeNull();
recorder.Writes.ShouldBeEmpty();
}
/// A protocol TagConfig blob with no FullName key routes by the equipment NodeId, and
/// the forwarded wire-ref is the raw blob verbatim. ExtractTagFullName falls back to the raw
/// blob string when no top-level FullName property is present, so the reverse map keys on
/// (DriverInstanceId, <raw-blob>) and the driver receives that exact string as its
/// WriteRequest.FullReference — not a FullName value extracted from the blob.
[Fact]
public void Primary_routes_write_for_raw_protocol_blob_tag()
{
var db = NewInMemoryDbFactory();
var recorder = new RecordingDriverFactory("Modbus");
// Seed the tag with a genuine protocol blob that has NO FullName key (pure Modbus wire config).
// ExtractTagFullName falls back to returning the raw blob string verbatim, so that string IS the
// wire-ref the reverse map keys on and the driver receives as WriteRequest.FullReference.
var (deploymentId, rawBlobString) = SeedDeploymentWithRawBlobTag(db, RevA,
equip: "eq-2", driver: "drv-2", name: "torque");
var actor = SpawnHostAndApply(db, deploymentId, recorder);
// Local role unknown ⇒ treated as Primary ⇒ write allowed.
var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-2/torque", 456.0), asker.Ref);
var result = asker.ExpectMsg(Timeout);
result.Success.ShouldBeTrue();
// The forwarded wire-ref is the raw blob string verbatim — ExtractTagFullName fell back because
// there is no top-level "FullName" key in the blob.
AwaitAssert(() =>
{
recorder.Writes.Count.ShouldBe(1);
recorder.Writes[0].FullReference.ShouldBe(rawBlobString);
recorder.Writes[0].Value.ShouldBe(456.0);
}, duration: Timeout);
}
/// A RouteNodeWrite arriving while the host is Stale (config DB unreachable) must fast-fail
/// with an immediate negative NodeWriteResult (reason mentions "stale") instead of dead-lettering into
/// the node-manager's bounded-Ask timeout. Drives the host into Stale via a DB factory whose
/// CreateDbContext throws on bootstrap (the same fall-through to Become(Stale) production uses).
[Fact]
public void Stale_host_fast_fails_route_node_write()
{
// A factory that always throws on CreateDbContext ⇒ Bootstrap's try fails ⇒ Become(Stale).
var db = new ThrowingDbFactory();
var coordinator = CreateTestProbe();
var actor = Sys.ActorOf(DriverHostActor.Props(
db, TestNode, coordinator.Ref,
localRoles: new HashSet { "driver" }));
var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0), asker.Ref);
var result = asker.ExpectMsg(TimeSpan.FromSeconds(2));
result.Success.ShouldBeFalse();
result.Reason.ShouldNotBeNull();
result.Reason!.ShouldContain("stale");
}
/// Spawns the host with the recording driver factory, dispatches the deployment, and waits
/// for the Applied ACK so the apply (and thus the reverse-map build in PushDesiredSubscriptions) has
/// completed before the test routes a write. No OPC UA / mux probes are wired — this test exercises
/// only the write path, which doesn't depend on the publish actor.
private IActorRef SpawnHostAndApply(
IDbContextFactory db, DeploymentId deploymentId, IDriverFactory factory)
{
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(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
return actor;
}
///
/// Seeds a Sealed deployment whose artifact carries the minimal arrays
/// DeploymentArtifact.BuildEquipmentTagPlans needs to project equipment tags. Mirrors
/// DriverHostActorLiveValueTests.SeedDeploymentWithEquipmentTags but also carries a
/// DriverInstances row with a non-Windows-only DriverType ("Modbus") + Enabled flag
/// so a REAL (non-stubbed) child is spawned for the write path.
///
private static DeploymentId SeedDeploymentWithEquipmentTags(
IDbContextFactory 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;
}
///
/// Seeds a single-tag Sealed deployment whose tag's TagConfig is a genuine protocol-driver
/// blob with no FullName key (pure Modbus wire config:
/// {"region":"HoldingRegister","address":200,"dataType":"UInt16"}). Because
/// ExtractTagFullName finds no top-level FullName property, it falls back to
/// returning the raw blob string verbatim — that raw string becomes the
/// (DriverInstanceId, <raw-blob>) reverse-map key, and the driver receives it as
/// WriteRequest.FullReference. Returns both the and the exact
/// raw blob string so the caller can assert the forwarded wire-ref precisely.
///
private static (DeploymentId DeploymentId, string RawBlobString) SeedDeploymentWithRawBlobTag(
IDbContextFactory db, RevisionHash rev,
string equip, string driver, string name)
{
// Serialize the blob with NO FullName field — ExtractTagFullName will fall back to this verbatim.
var rawBlobString = JsonSerializer.Serialize(
new { region = "HoldingRegister", address = 200, dataType = "UInt16" });
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
{
Namespaces = new[]
{
new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0
},
DriverInstances = new[]
{
new
{
DriverInstanceRowId = Guid.NewGuid(),
DriverInstanceId = driver,
Name = driver,
DriverType = "Modbus", // not Windows-only ⇒ a real child is spawned (not stubbed)
Enabled = true,
DriverConfig = "{}",
NamespaceId = "ns-eq",
},
},
Tags = new[]
{
new
{
TagId = "tag-raw",
EquipmentId = equip,
DriverInstanceId = driver,
Name = name,
FolderPath = (string?)null,
DataType = "Double",
// Pure protocol blob: no FullName key. ExtractTagFullName falls back to raw blob.
TagConfig = rawBlobString,
},
},
});
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, rawBlobString);
}
/// An whose CreateDbContext always throws,
/// driving 's bootstrap into the catch ⇒ Become(Stale) path
/// so a write can be routed at a Stale host.
private sealed class ThrowingDbFactory : IDbContextFactory
{
///
public OtOpcUaConfigDbContext CreateDbContext() =>
throw new InvalidOperationException("config DB unreachable (test stub)");
}
/// Factory producing a single for the supported type, whose
/// recorded write list is exposed for assertions.
private sealed class RecordingDriverFactory : IDriverFactory
{
private readonly string _supportedType;
private readonly RecordingDriver _driver = new();
public RecordingDriverFactory(string supportedType) { _supportedType = supportedType; }
/// The writes the spawned driver received (thread-safe — WriteAsync runs off the actor thread).
public IReadOnlyList Writes => _driver.Writes;
///
public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson)
{
if (!string.Equals(driverType, _supportedType, StringComparison.Ordinal)) return null;
_driver.Bind(driverInstanceId, driverType);
return _driver;
}
///
public IReadOnlyCollection SupportedTypes => new[] { _supportedType };
}
/// An + that records every write and returns Good.
private sealed class RecordingDriver : IDriver, IWritable
{
private readonly ConcurrentQueue _writes = new();
///
public string DriverInstanceId { get; private set; } = string.Empty;
///
public string DriverType { get; private set; } = string.Empty;
/// The writes received so far.
public IReadOnlyList Writes => _writes.ToArray();
/// Sets the identity once the factory is asked to create it.
public void Bind(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;
///
public Task> WriteAsync(
IReadOnlyList writes, CancellationToken cancellationToken)
{
foreach (var w in writes) _writes.Enqueue(w);
return Task.FromResult>(
writes.Select(_ => new WriteResult(0u)).ToArray()); // 0x0 = Good
}
}
}