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 { [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); } [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)); } [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"); } [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); } [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"); } [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); } [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); } [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"); } [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 { public bool InitializeShouldThrow { get; set; } public int InitializeCount; public int ReinitializeCount; public string DriverInstanceId => "stub-driver-1"; public string DriverType => "Stub"; public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) { Interlocked.Increment(ref InitializeCount); if (InitializeShouldThrow) throw new InvalidOperationException("stub-init-fail"); return Task.CompletedTask; } public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) { Interlocked.Increment(ref ReinitializeCount); return Task.CompletedTask; } public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask; public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null); public long GetMemoryFootprint() => 0; public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask; } private sealed class WritableStubDriver : StubDriver, IWritable { public uint NextStatusCode { get; set; } = 0u; public List Writes { get; } = new(); 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 { public event EventHandler? OnDataChange; private readonly StubHandle _handle = new(); public int OnDataChangeSubscriberCount => OnDataChange?.GetInvocationList().Length ?? 0; public Task SubscribeAsync( IReadOnlyList fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) => Task.FromResult(_handle); public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken) => Task.CompletedTask; 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 { public string DiagnosticId => "stub-sub"; } } }