using System.Text.Json;
using Akka.Actor;
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
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;
///
/// Issue #485 (driver-side half) — an artifact the host could not obtain must not be mistaken for a
/// configuration that contains nothing.
///
///
///
/// ReconcileDrivers and PushDesiredSubscriptions both read the artifact as
/// …FirstOrDefault() ?? Array.Empty<byte>(). The throw path in each already
/// returns early, but a row that is absent (or carries a zero-length blob) yields empty bytes that
/// flow onwards as a real answer: zero driver specs, so DriverSpawnPlanner plans every
/// running child for StopChild, and then an empty desired-subscription set drops each
/// surviving driver's live handle. A node loses its entire field I/O and still ACKs Applied.
///
///
/// The same reasoning as the address-space half applies: nothing legitimate produces a zero-length
/// blob — an operator who really deletes every driver still deploys a JSON document with empty
/// arrays — so "no bytes" can only mean the read did not answer. Seeding a row whose
/// ArtifactBlob is empty reproduces exactly the bytes the missing-row case delivers, without
/// needing to race a delete against the two reads.
///
///
public sealed class DriverHostActorUnreadableArtifactTests : 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));
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
/// How long to let post-ACK subscription traffic settle before asserting it did NOT happen.
private static readonly TimeSpan SettleWindow = TimeSpan.FromSeconds(2);
private const string SpeedRef = "Plant/Modbus/dev1/speed";
/// A dispatch whose artifact reads back as no bytes must leave the running drivers — and their
/// live subscriptions — exactly as they were.
[Fact]
public void Dispatch_whose_artifact_reads_back_empty_keeps_the_drivers_and_their_subscriptions()
{
var db = NewInMemoryDbFactory();
var factory = new SubscribingDriverFactory("Modbus");
var (actor, coordinator) = SpawnHostAndApply(db, SeedV3Deployment(db, RevA), factory);
// Baseline: the child is up and subscribed to its one raw tag.
AwaitAssert(() => factory.LastSubscribedRefs.ShouldNotBeNull()!.ShouldContain(SpeedRef), duration: Timeout);
AskDiagnostics(actor).Drivers.Select(d => d.Name).ShouldContain("drv-1");
// The next dispatch's artifact comes back as no bytes at all.
actor.Tell(new DispatchDeployment(SeedUnreadableDeployment(db, RevB), RevB, CorrelationId.NewId()));
coordinator.ExpectMsg(Timeout);
// The ACK precedes the SubscribeBulk pass, and the child's unsubscribe is a further async self-tell,
// so settle before asserting an ABSENCE. SettleWindow is calibrated by
// , which observes the very same
// unsubscribe ARRIVING well inside it — without that control this assertion would pass on a race.
AskDiagnostics(actor); // ordering barrier through the host's mailbox
Thread.Sleep(SettleWindow);
AskDiagnostics(actor).Drivers.Select(d => d.Name).ShouldContain("drv-1"); // not stopped
factory.UnsubscribeCount.ShouldBe(0); // handle not dropped
factory.LastSubscribedRefs.ShouldNotBeNull()!.ShouldContain(SpeedRef);
}
/// Positive control for the subscription half: a READABLE artifact that keeps the driver but
/// drops its last tag genuinely does clear the live subscription — the child receives an empty desired
/// set and unsubscribes. This proves both that the unsubscribe is observable through this factory and
/// that it lands well within , so the absence asserted above is real.
[Fact]
public void Dropping_a_drivers_last_tag_does_clear_its_subscription()
{
var db = NewInMemoryDbFactory();
var factory = new SubscribingDriverFactory("Modbus");
var (actor, coordinator) = SpawnHostAndApply(db, SeedV3Deployment(db, RevA), factory);
AwaitAssert(() => factory.LastSubscribedRefs.ShouldNotBeNull()!.ShouldContain(SpeedRef), duration: Timeout);
factory.UnsubscribeCount.ShouldBe(0);
actor.Tell(new DispatchDeployment(SeedTaglessDriverDeployment(db, RevB), RevB, CorrelationId.NewId()));
coordinator.ExpectMsg(Timeout);
AwaitAssert(() => factory.UnsubscribeCount.ShouldBe(1), duration: SettleWindow);
AskDiagnostics(actor).Drivers.Select(d => d.Name).ShouldContain("drv-1"); // the driver itself stayed
}
/// Positive control: the guard keys on "the read gave us nothing", not on "the new config has
/// fewer drivers". A READABLE artifact that genuinely drops the driver still stops it — otherwise the
/// test above would pass just as happily against a host that had stopped reconciling altogether.
[Fact]
public void Dispatch_whose_readable_artifact_genuinely_drops_the_driver_still_stops_it()
{
var db = NewInMemoryDbFactory();
var factory = new SubscribingDriverFactory("Modbus");
var (actor, coordinator) = SpawnHostAndApply(db, SeedV3Deployment(db, RevA), factory);
AwaitAssert(() => factory.LastSubscribedRefs.ShouldNotBeNull()!.ShouldContain(SpeedRef), duration: Timeout);
// A real artifact that simply carries no drivers — an operator deleting the last driver.
actor.Tell(new DispatchDeployment(SeedDriverlessDeployment(db, RevB), RevB, CorrelationId.NewId()));
coordinator.ExpectMsg(Timeout);
AwaitAssert(
() => AskDiagnostics(actor).Drivers.Select(d => d.Name).ShouldNotContain("drv-1"),
duration: Timeout);
}
/// Spawns the host with the subscribing factory, dispatches and
/// waits for the Applied ACK so the child + the initial SubscribeBulk pass are in place.
private (IActorRef Actor, Akka.TestKit.TestProbe Coordinator) 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, coordinator);
}
private NodeDiagnosticsSnapshot AskDiagnostics(IActorRef actor)
{
var probe = CreateTestProbe();
actor.Tell(new GetDiagnostics(CorrelationId.NewId()), probe.Ref);
return probe.ExpectMsg(Timeout);
}
/// Seeds a Sealed v3 deployment: RawFolder "Plant" → DriverInstance "drv-1" (Modbus, ENABLED so a
/// real child spawns) → Device "dev1" → Tag "speed" (RawPath Plant/Modbus/dev1/speed).
private static DeploymentId SeedV3Deployment(IDbContextFactory db, RevisionHash rev) =>
SeedDeployment(db, rev, 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