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.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.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
///
/// lmxopcua#777: 's write Ask is derived from the configured write timeout
/// () rather than a fixed 8 s, so a write the child is still entitled to run
/// is never reported failed by the Ask — the node would be reverted while the device could still take the value.
///
/// Drives a real apply (a real child), then routes a held write with the
/// host and the child on ONE manual clock: the host's Ask and the child's bounds both run on it, so stepping
/// the clock shows which one answers first.
///
///
public sealed class DriverHostActorWriteAskTests : RuntimeActorTestBase
{
private static readonly NodeId TestNode = NodeId.Parse("driver-write-ask-test");
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
private const string RawNodeId = "ns=2;s=Plant/Modbus/dev1/speed";
/// The value the gated driver holds; every other value is written at once.
private const double HeldValue = 99.0;
///
/// A write still inside a 9 s write timeout is not cut off at 8 s: the host waits for the child, which
/// answers with the device's real outcome. (Mutation-checked: a fixed 8 s Ask on the same clock fails this
/// with "write timeout" at the 8.5 s step.)
///
[Fact]
public void A_write_within_a_9s_write_timeout_is_not_failed_by_the_host_Ask()
{
var clock = new ManualTimeProvider();
var driver = new GatedWritableDriver();
var host = SpawnHostAndApply(driver, new DriverWriteBounds(TimeSpan.FromSeconds(9)), clock);
var asker = RouteHeldWrite(host, driver, clock);
clock.Advance(TimeSpan.FromSeconds(8.5));
asker.ExpectNoMsg(TimeSpan.FromMilliseconds(300), TestContext.Current.CancellationToken);
driver.Release();
var result = asker.ExpectMsg(Timeout, cancellationToken: TestContext.Current.CancellationToken);
result.Success.ShouldBeTrue(result.Reason);
}
///
/// A token-ignoring write under a 9 s write timeout is answered by the CHILD (abandoned BadTimeout at
/// 9 s + its grace), not by the host's Ask: the Ask covers the child's whole bound. Under the fixed 8 s Ask
/// this read "write timeout" from the Ask at 8 s while the device still had the write.
///
[Fact]
public void A_token_ignoring_write_is_answered_by_the_child_before_the_host_Ask()
{
var clock = new ManualTimeProvider();
var driver = new GatedWritableDriver { IgnoreCancellation = true };
var bounds = new DriverWriteBounds(TimeSpan.FromSeconds(9));
var host = SpawnHostAndApply(driver, bounds, clock);
var asker = RouteHeldWrite(host, driver, clock);
clock.Advance(bounds.Timeout);
asker.ExpectNoMsg(TimeSpan.FromMilliseconds(300), TestContext.Current.CancellationToken);
clock.Advance(bounds.AbandonGrace);
var result = asker.ExpectMsg(Timeout, cancellationToken: TestContext.Current.CancellationToken);
result.Success.ShouldBeFalse();
result.Reason!.ShouldContain("800A0000", customMessage: "the child's BadTimeout, not the host Ask's \"write timeout\"");
result.Reason!.ShouldContain("within 9000 ms");
bounds.RunBound.ShouldBeLessThan(bounds.HostAsk);
}
///
/// The mechanism the host's Ask relies on: with an Infinite Akka timeout, the Ask's deadline is the write
/// clock's token alone — it does not complete a tick before , and it
/// does complete (cancelled, which the host reports as "write timeout") at it, for a child that never answers.
///
[Fact]
public void An_Ask_with_an_Infinite_timeout_ends_exactly_at_its_clock_deadline()
{
var clock = new ManualTimeProvider();
var bounds = new DriverWriteBounds(TimeSpan.FromSeconds(9));
var silentChild = CreateTestProbe();
using var deadline = new CancellationTokenSource(bounds.HostAsk, clock);
var ask = silentChild.Ref.Ask(
new DriverInstanceActor.WriteAttribute("x", 1), System.Threading.Timeout.InfiniteTimeSpan, deadline.Token);
clock.Advance(bounds.HostAsk - TimeSpan.FromMilliseconds(1));
Thread.Sleep(200);
ask.IsCompleted.ShouldBeFalse("the Ask ended before its clock deadline");
clock.Advance(TimeSpan.FromMilliseconds(1));
AwaitCondition(() => ask.IsCompleted, Timeout, TimeSpan.FromMilliseconds(20), cancellationToken: TestContext.Current.CancellationToken);
ask.IsCompletedSuccessfully.ShouldBeFalse();
}
/// Routes the held write once the host accepts writes (a warm-up write proves the child is Connected
/// and the reverse map is pushed), then waits until it is parked in the driver with every bound armed.
private Akka.TestKit.TestProbe RouteHeldWrite(IActorRef host, GatedWritableDriver driver, ManualTimeProvider clock)
{
var lastReason = "(no reply)";
AwaitAssert(
() =>
{
var warm = CreateTestProbe();
host.Tell(new DriverHostActor.RouteNodeWrite(RawNodeId, 1.0, AddressSpaceRealm.Raw), warm.Ref);
var r = warm.ExpectMsg(TimeSpan.FromSeconds(1));
lastReason = r.Reason ?? "(none)";
r.Success.ShouldBeTrue($"host kept rejecting the warm-up write; last reason: {lastReason}");
},
duration: Timeout,
interval: TimeSpan.FromMilliseconds(50));
var asker = CreateTestProbe();
host.Tell(new DriverHostActor.RouteNodeWrite(RawNodeId, HeldValue, AddressSpaceRealm.Raw), asker.Ref);
AwaitCondition(() => driver.HeldEntered, Timeout, TimeSpan.FromMilliseconds(20));
// The host's Ask deadline + the child's token cancel + the child's abandon bound.
AwaitCondition(() => clock.ArmedTimers == 3, Timeout, TimeSpan.FromMilliseconds(20));
return asker;
}
private IActorRef SpawnHostAndApply(GatedWritableDriver driver, DriverWriteBounds bounds, ManualTimeProvider clock)
{
var db = NewInMemoryDbFactory();
var deploymentId = SeedDeployment(db);
var coordinator = CreateTestProbe();
var host = Sys.ActorOf(DriverHostActor.Props(
db, TestNode, coordinator.Ref,
driverFactory: new SingleDriverFactory(driver),
localRoles: new HashSet { "driver" },
writeBounds: bounds,
writeClock: clock));
host.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
return host;
}
/// One enabled Modbus driver "drv-1" with one ReadWrite tag, RawPath Plant/Modbus/dev1/speed.
private static DeploymentId SeedDeployment(IDbContextFactory db)
{
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
{
RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
DriverInstances = new[]
{
new { DriverInstanceId = "drv-1", RawFolderId = "rf-plant", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = true },
},
Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "dev1", DeviceConfig = "{}" } },
TagGroups = Array.Empty