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; }
}
}
}