using Akka.Actor;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
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 DriverInstanceActorTests : RuntimeActorTestBase
{
/// Verifies that ApplyDelta calls ReinitializeAsync when connected and replies success.
[Fact]
public async Task ApplyDelta_when_Connected_calls_ReinitializeAsync_and_replies_success()
{
var driver = new StubDriver();
var actor = Sys.ActorOf(DriverInstanceActor.Props(driver));
// Drive: Initialize → Connected.
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2));
// Issue ApplyDelta and capture the reply via Ask.
var correlation = CorrelationId.NewId();
var reply = await actor.Ask(
new DriverInstanceActor.ApplyDelta("{\"changed\":true}", correlation),
TimeSpan.FromSeconds(3));
reply.Success.ShouldBeTrue();
reply.Correlation.ShouldBe(correlation);
driver.ReinitializeCount.ShouldBe(1);
}
/// Verifies that initialize failure keeps the actor in Reconnecting state.
[Fact]
public void Initialize_failure_keeps_actor_in_Reconnecting_state()
{
var driver = new StubDriver { InitializeShouldThrow = true };
var actor = Sys.ActorOf(DriverInstanceActor.Props(driver, reconnectInterval: TimeSpan.FromMilliseconds(50)));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
// The actor should keep trying — we expect multiple Initialize calls because the
// reconnect timer fires every 50ms.
AwaitCondition(() => driver.InitializeCount >= 3, TimeSpan.FromSeconds(2));
}
/// Verifies that writing to a non-IWritable driver returns failure.
[Fact]
public async Task Write_against_non_IWritable_driver_returns_failure()
{
var driver = new StubDriver(); // IDriver only, no IWritable.
var actor = Sys.ActorOf(DriverInstanceActor.Props(driver));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2));
var reply = await actor.Ask(
new DriverInstanceActor.WriteAttribute("tag-1", 42),
TimeSpan.FromSeconds(3));
reply.Success.ShouldBeFalse();
reply.Reason!.ShouldContain("IWritable");
}
/// Verifies that writing to an IWritable driver returns success when status is Good.
[Fact]
public async Task Write_against_IWritable_returns_success_when_status_is_Good()
{
var driver = new WritableStubDriver();
var actor = Sys.ActorOf(DriverInstanceActor.Props(driver));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2));
var reply = await actor.Ask(
new DriverInstanceActor.WriteAttribute("tag-1", 42),
TimeSpan.FromSeconds(3));
reply.Success.ShouldBeTrue();
driver.Writes.Single().FullReference.ShouldBe("tag-1");
driver.Writes.Single().Value.ShouldBe(42);
}
/// Verifies that write propagates status code on Bad result.
[Fact]
public async Task Write_propagates_status_code_on_Bad_result()
{
const uint badStatus = 0x80340000; // BadOutOfService — top severity bits = 10b
var driver = new WritableStubDriver { NextStatusCode = badStatus };
var actor = Sys.ActorOf(DriverInstanceActor.Props(driver));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2));
var reply = await actor.Ask(
new DriverInstanceActor.WriteAttribute("tag-1", 42),
TimeSpan.FromSeconds(3));
reply.Success.ShouldBeFalse();
reply.Reason!.ShouldContain("80340000");
}
/// Verifies that subscribing to an ISubscribable driver forwards OnDataChange to parent.
[Fact]
public async Task Subscribe_against_ISubscribable_forwards_OnDataChange_to_parent()
{
var driver = new SubscribableStubDriver();
var parent = CreateTestProbe();
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2));
await actor.Ask(
new DriverInstanceActor.Subscribe(new[] { "tag-a", "tag-b" }, TimeSpan.FromMilliseconds(250)),
TimeSpan.FromSeconds(3));
// Driver fires an OnDataChange — actor should forward it to its parent as
// AttributeValuePublished with Quality mapped from StatusCode.
driver.FireDataChange("tag-a", value: 3.14, statusCode: 0u);
var published = parent.ExpectMsg(TimeSpan.FromSeconds(2));
published.FullReference.ShouldBe("tag-a");
published.Value.ShouldBe(3.14);
published.Quality.ShouldBe(OpcUaQuality.Good);
}
/// Verifies that subscribe translates OPC UA status severity bits to OpcUaQuality.
[Fact]
public async Task Subscribe_translates_OPC_UA_status_severity_bits_to_OpcUaQuality()
{
var driver = new SubscribableStubDriver();
var parent = CreateTestProbe();
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2));
await actor.Ask(
new DriverInstanceActor.Subscribe(new[] { "tag-1" }, TimeSpan.FromMilliseconds(100)),
TimeSpan.FromSeconds(3));
// Uncertain — severity bits 01 (top 2 bits = 01).
driver.FireDataChange("tag-1", value: 1, statusCode: 0x40000000u);
parent.ExpectMsg().Quality.ShouldBe(OpcUaQuality.Uncertain);
// Bad — severity bits 10.
driver.FireDataChange("tag-1", value: 2, statusCode: 0x80000000u);
parent.ExpectMsg().Quality.ShouldBe(OpcUaQuality.Bad);
}
///
/// Verifies the SubscribeBulk pass: SetDesiredSubscriptions retains the ref set and the actor
/// auto-subscribes when it (re)enters Connected — including a re-subscribe after a reconnect,
/// closing the F8b/#113 gap that previously left galaxy variables at BadWaitingForInitialData.
///
[Fact]
public async Task SetDesiredSubscriptions_auto_subscribes_on_connect_and_resubscribes_after_reconnect()
{
var driver = new SubscribableStubDriver();
var parent = CreateTestProbe();
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, reconnectInterval: TimeSpan.FromMilliseconds(50)));
// Desired set arrives BEFORE connect — retained, not yet applied.
actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(
new[] { "tag-a", "tag-b" }, TimeSpan.FromMilliseconds(100)));
// Connecting → Connected triggers the auto-subscribe.
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitCondition(() => driver.SubscribeCount >= 1, TimeSpan.FromSeconds(2));
driver.LastSubscribedRefs.ShouldBe(new[] { "tag-a", "tag-b" });
// The auto-subscription is live — a data change reaches the parent.
driver.FireDataChange("tag-a", value: 7, statusCode: 0u);
parent.ExpectMsg(TimeSpan.FromSeconds(2)).Value.ShouldBe(7);
// Reconnect → the desired set is re-established without any new host message.
actor.Tell(new DriverInstanceActor.DisconnectObserved("backend blip"));
AwaitCondition(() => driver.SubscribeCount >= 2, TimeSpan.FromSeconds(3));
}
/// Verifies that subscribing to a non-ISubscribable driver replies with failure.
[Fact]
public async Task Subscribe_against_non_ISubscribable_replies_with_failure()
{
var driver = new StubDriver(); // IDriver only
var actor = Sys.ActorOf(DriverInstanceActor.Props(driver));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2));
var reply = await actor.Ask(
new DriverInstanceActor.Subscribe(new[] { "tag-1" }, TimeSpan.FromMilliseconds(100)),
TimeSpan.FromSeconds(3));
reply.Reason.ShouldContain("ISubscribable");
}
/// Verifies that DisconnectObserved detaches subscription handler so late events are dropped.
[Fact]
public async Task DisconnectObserved_detaches_subscription_handler_so_late_events_are_dropped()
{
var driver = new SubscribableStubDriver();
var parent = CreateTestProbe();
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, reconnectInterval: TimeSpan.FromSeconds(30)));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2));
await actor.Ask(
new DriverInstanceActor.Subscribe(new[] { "tag-1" }, TimeSpan.FromMilliseconds(100)),
TimeSpan.FromSeconds(3));
actor.Tell(new DriverInstanceActor.DisconnectObserved("backend went away"));
// Race window — once disconnect is processed, subsequent FireDataChange calls hit a
// detached handler and don't push anything to the parent.
AwaitCondition(() => driver.OnDataChangeSubscriberCount == 0, TimeSpan.FromSeconds(2));
driver.FireDataChange("tag-1", value: 99, statusCode: 0u);
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
private class StubDriver : IDriver
{
/// Gets or sets a value indicating whether initialization should throw.
public bool InitializeShouldThrow { get; set; }
/// Gets the number of times initialization was called.
public int InitializeCount;
/// Gets the number of times reinitialization was called.
public int ReinitializeCount;
/// Gets the driver instance ID.
public string DriverInstanceId => "stub-driver-1";
/// Gets the driver type.
public string DriverType => "Stub";
/// Initializes the driver with the specified configuration JSON.
/// The driver configuration JSON.
/// Cancellation token for the operation.
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
Interlocked.Increment(ref InitializeCount);
if (InitializeShouldThrow) throw new InvalidOperationException("stub-init-fail");
return Task.CompletedTask;
}
/// Reinitializes the driver with the specified configuration JSON.
/// The driver configuration JSON.
/// Cancellation token for the operation.
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
Interlocked.Increment(ref ReinitializeCount);
return Task.CompletedTask;
}
/// Shuts down the driver.
/// Cancellation token for the operation.
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// Gets the health status of the driver.
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
/// Gets the memory footprint of the driver.
public long GetMemoryFootprint() => 0;
/// Flushes optional caches in the driver.
/// Cancellation token for the operation.
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
private sealed class WritableStubDriver : StubDriver, IWritable
{
/// Gets or sets the next status code to return from write operations.
public uint NextStatusCode { get; set; } = 0u;
/// Gets the list of write requests received.
public List Writes { get; } = new();
/// Writes the specified requests.
/// The write requests.
/// Cancellation token for the operation.
public Task> WriteAsync(
IReadOnlyList writes, CancellationToken cancellationToken)
{
Writes.AddRange(writes);
IReadOnlyList results = writes.Select(_ => new WriteResult(NextStatusCode)).ToList();
return Task.FromResult(results);
}
}
private sealed class SubscribableStubDriver : StubDriver, ISubscribable
{
/// Occurs when data changes.
public event EventHandler? OnDataChange;
private readonly StubHandle _handle = new();
/// Gets the number of subscribers to OnDataChange.
public int OnDataChangeSubscriberCount => OnDataChange?.GetInvocationList().Length ?? 0;
/// Number of times was called (re-subscribe asserts).
public int SubscribeCount;
/// The reference set passed to the most recent call.
public IReadOnlyList? LastSubscribedRefs;
/// Subscribes to the specified full references.
/// The full references to subscribe to.
/// The publishing interval.
/// Cancellation token for the operation.
public Task SubscribeAsync(
IReadOnlyList fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
{
Interlocked.Increment(ref SubscribeCount);
LastSubscribedRefs = fullReferences;
return Task.FromResult(_handle);
}
/// Unsubscribes from the specified subscription handle.
/// The subscription handle.
/// Cancellation token for the operation.
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
=> Task.CompletedTask;
/// Fires a data change event with the specified parameters.
/// The full reference of the data that changed.
/// The new value.
/// The OPC UA status code.
public void FireDataChange(string fullRef, object? value, uint statusCode)
{
var snapshot = new DataValueSnapshot(value, statusCode, DateTime.UtcNow, DateTime.UtcNow);
OnDataChange?.Invoke(this, new DataChangeEventArgs(_handle, fullRef, snapshot));
}
private sealed class StubHandle : ISubscriptionHandle
{
/// Gets the diagnostic ID of the subscription.
public string DiagnosticId => "stub-sub";
}
}
}