using Akka.Hosting; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.Interfaces; using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Core.Abstractions.Historian; using ZB.MOM.WW.OtOpcUa.Runtime.Historian; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests; /// /// Verifies WithOtOpcUaRuntimeActors spawns DriverHostActor + DbHealthProbeActor /// on the host's ActorSystem and registers both under their marker keys. This is the /// driver-role mirror of the admin-role WithOtOpcUaControlPlaneSingletons bootstrap. /// public sealed class ServiceCollectionExtensionsTests { /// Verifies that WithOtOpcUaRuntimeActors spawns driver host and DB health probe actors. [Fact] public async Task WithOtOpcUaRuntimeActors_spawns_driver_host_and_db_health_probe() { using var host = Host.CreateDefaultBuilder() .ConfigureServices((_, services) => { services.AddSingleton>( new InMemoryConfigDbFactory(Guid.NewGuid().ToString("N"))); services.AddSingleton(new FakeClusterRoleInfo()); services.AddAkka("otopcua-test", (ab, _) => { ab.AddHocon(@" akka.actor.provider = ""Akka.Cluster.ClusterActorRefProvider, Akka.Cluster"" akka.remote.dot-netty.tcp.hostname = ""127.0.0.1"" akka.remote.dot-netty.tcp.port = 0 akka.cluster.seed-nodes = [] akka.cluster.roles = [""driver""] ", HoconAddMode.Prepend); ab.WithOtOpcUaRuntimeActors(); }); }) .Build(); await host.StartAsync(); try { var driverHost = host.Services.GetRequiredService>(); var dbHealth = host.Services.GetRequiredService>(); var historian = host.Services.GetRequiredService>(); var mux = host.Services.GetRequiredService>(); var publish = host.Services.GetRequiredService>(); var peerProbes = host.Services.GetRequiredService>(); driverHost.ActorRef.ShouldNotBeNull(); dbHealth.ActorRef.ShouldNotBeNull(); historian.ActorRef.ShouldNotBeNull(); mux.ActorRef.ShouldNotBeNull(); publish.ActorRef.ShouldNotBeNull(); peerProbes.ActorRef.ShouldNotBeNull(); driverHost.ActorRef.Path.Name.ShouldBe(ServiceCollectionExtensions.DriverHostActorName); dbHealth.ActorRef.Path.Name.ShouldBe(ServiceCollectionExtensions.DbHealthProbeActorName); historian.ActorRef.Path.Name.ShouldBe(ServiceCollectionExtensions.HistorianAdapterActorName); mux.ActorRef.Path.Name.ShouldBe(ServiceCollectionExtensions.DependencyMuxActorName); publish.ActorRef.Path.Name.ShouldBe(ServiceCollectionExtensions.OpcUaPublishActorName); peerProbes.ActorRef.Path.Name.ShouldBe(ServiceCollectionExtensions.PeerProbeSupervisorName); } finally { await host.StopAsync(); } } /// /// When ContinuousHistorization is not enabled (no options registered), the recorder /// actor is NOT spawned — its key does not resolve in the registry. /// [Fact] public async Task Recorder_not_spawned_when_continuous_historization_disabled() { using var host = BuildRuntimeActorHost(extra: null); await host.StartAsync(); try { var registry = host.Services.GetRequiredService(); registry.TryGet(out _).ShouldBeFalse(); } finally { await host.StopAsync(); } } /// /// When ContinuousHistorization is enabled and a value-writer + outbox are registered, /// the recorder actor IS spawned and its key resolves under the expected actor name. /// [Fact] public async Task Recorder_spawned_when_enabled_with_writer_and_outbox() { using var host = BuildRuntimeActorHost(extra: services => { services.AddSingleton(new ContinuousHistorizationOptions { Enabled = true, OutboxPath = "x" }); services.AddSingleton(new FakeValueWriter()); services.AddSingleton(new FakeOutbox()); }); await host.StartAsync(); try { var recorder = host.Services.GetRequiredService>(); recorder.ActorRef.ShouldNotBeNull(); recorder.ActorRef.Path.Name.ShouldBe(ServiceCollectionExtensions.ContinuousHistorizationRecorderActorName); } finally { await host.StopAsync(); } } /// Builds a driver-role host that runs WithOtOpcUaRuntimeActors, with optional /// extra DI registrations applied before AddAkka. private static IHost BuildRuntimeActorHost(Action? extra) => Host.CreateDefaultBuilder() .ConfigureServices((_, services) => { services.AddSingleton>( new InMemoryConfigDbFactory(Guid.NewGuid().ToString("N"))); services.AddSingleton(new FakeClusterRoleInfo()); extra?.Invoke(services); services.AddAkka("otopcua-test", (ab, _) => { ab.AddHocon(@" akka.actor.provider = ""Akka.Cluster.ClusterActorRefProvider, Akka.Cluster"" akka.remote.dot-netty.tcp.hostname = ""127.0.0.1"" akka.remote.dot-netty.tcp.port = 0 akka.cluster.seed-nodes = [] akka.cluster.roles = [""driver""] ", HoconAddMode.Prepend); ab.WithOtOpcUaRuntimeActors(); }); }) .Build(); /// Non-throwing fake value writer that acks every batch. private sealed class FakeValueWriter : IHistorianValueWriter { /// Acks the write unconditionally. public Task WriteLiveValuesAsync( string tag, IReadOnlyList values, CancellationToken ct) => Task.FromResult(true); } /// Empty in-memory outbox fake — the spawn test only needs construction, not draining. private sealed class FakeOutbox : IHistorizationOutbox { /// Never drops (unbounded). public long DroppedCount => 0; /// No-op append. public ValueTask AppendAsync(HistorizationOutboxEntry entry, CancellationToken ct) => ValueTask.CompletedTask; /// Always returns an empty batch. public ValueTask> PeekBatchAsync(int max, CancellationToken ct) => ValueTask.FromResult>(Array.Empty()); /// No-op remove. public ValueTask RemoveAsync(Guid id, CancellationToken ct) => ValueTask.CompletedTask; /// Always empty. public ValueTask CountAsync(CancellationToken ct) => ValueTask.FromResult(0); /// No-op dispose. public void Dispose() { } } /// In-memory database factory for testing. private sealed class InMemoryConfigDbFactory(string dbName) : IDbContextFactory { /// Creates a new in-memory database context. public OtOpcUaConfigDbContext CreateDbContext() { var opts = new DbContextOptionsBuilder() .UseInMemoryDatabase(dbName) .Options; return new OtOpcUaConfigDbContext(opts); } } /// Fake cluster role information for testing. private sealed class FakeClusterRoleInfo : IClusterRoleInfo { /// Gets the local node ID. public NodeId LocalNode { get; } = NodeId.Parse("test-node"); /// Gets the local roles. public IReadOnlySet LocalRoles { get; } = new HashSet(["driver"]); /// Determines whether the local node has the specified role. /// The role to check. public bool HasRole(string role) => LocalRoles.Contains(role); /// Gets the members with the specified role. /// The role to query. public IReadOnlyList MembersWithRole(string role) => Array.Empty(); /// Gets the leader node for the specified role. /// The role to query. public NodeId? RoleLeader(string role) => null; /// Raised when the role leader changes. public event EventHandler? RoleLeaderChanged { add { _ = value; } remove { _ = value; } } } }