using Akka.Cluster;
using Akka.TestKit;
using Akka.TestKit.Xunit;
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Configuration;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
///
/// Mirrors the ControlPlane test harness: single-node Akka cluster (self-join) + in-memory
/// EF Core via an .
///
public abstract class RuntimeActorTestBase : TestKit
{
///
/// Shared upper bound for a presence wait (AwaitAssert / AwaitCondition).
///
///
/// A budget, not a delay. These helpers poll and return within one poll interval of the
/// condition holding (ExpectMsg the instant the message arrives; AwaitAssert every 100 ms,
/// Akka's default; AwaitCondition every through this class's overloads —
/// Akka's own default there is max / 10 of the dilated budget, 4.5 s in CI, lmxopcua#775), so the
/// value is how long to wait before giving up. Raising it costs nothing on the happy path and
/// cannot make a genuinely failing assertion pass — it only changes how quickly a real
/// breakage is reported. That is the opposite of an absence window (ExpectNoMsg,
/// settle-then-assert), where the elapsed time IS the assertion and every millisecond is spent on
/// every run. Absence windows keep their own short, individually-calibrated literals and must not be
/// routed through here.
/// Why it exists (Gitea #500). This assembly runs 44 Akka test classes roughly 14-way
/// parallel, each with its own ActorSystem. Budgets of 300–500 ms sized on an
/// idle machine are comfortable in isolation and marginal under that contention: three consecutive
/// 30-run verification rounds each surfaced a different test failing on one, in three
/// different files. Fixing them one at a time was chasing a distribution rather than a defect.
/// Public rather than protected so Harness helpers that are not themselves
/// subclasses — — budget their waits from the
/// same constant instead of re-deriving one.
///
public static readonly TimeSpan PresenceBudget = TimeSpan.FromSeconds(15);
///
/// Poll interval for every AwaitCondition in this class's subclasses that does not pass its own
/// (lmxopcua#775). Akka's own default is max / 10 of the DILATED budget — 1.5 s for a 15 s budget
/// locally, 4.5 s under CI's AKKA_TEST_TIMEFACTOR=3 — so a wait could overshoot the moment its
/// condition held by seconds. That wasted CI time everywhere, and failed tests where an undilated
/// deadline in the code under test elapsed during the overshoot (lmxopcua 342a825d). AwaitAssert
/// is unaffected: its default interval is a fixed 100 ms. Public, like , for
/// test classes that are not subclasses (a bare ) to pass explicitly.
///
public static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(20);
///
/// with
/// defaulting to instead of Akka's max / 10.
/// Hides the TestKit overloads, so every subclass call without an interval binds here.
///
/// The condition to poll.
/// The budget (dilated by Akka); null takes the enclosing Within or the default.
/// The poll interval; null means .
/// The failure message.
/// Cancels the wait.
protected new void AwaitCondition(Func conditionIsFulfilled, TimeSpan? max = null, TimeSpan? interval = null,
string? message = null, CancellationToken cancellationToken = default) =>
base.AwaitCondition(conditionIsFulfilled, max, interval ?? PollInterval, message!, cancellationToken);
/// The message-carrying overload of ,
/// polled every .
/// The condition to poll.
/// The budget (dilated by Akka).
/// The failure message.
/// Cancels the wait.
protected new void AwaitCondition(Func conditionIsFulfilled, TimeSpan? max, string message,
CancellationToken cancellationToken = default) =>
base.AwaitCondition(conditionIsFulfilled, max, PollInterval, message, cancellationToken);
/// Gets the Akka test HOCON configuration string for single-node cluster setup.
///
/// akka.test.single-expect-default is raised from Akka's 3 s to
/// (#606): it is the budget of every duration-less ExpectMsg/ExpectTerminated/
/// AwaitAssert, all presence waits. No test here calls a duration-less ExpectNoMsg(),
/// the one absence wait that would also read it.
/// akka.test.timefactor comes from .
///
protected static string AkkaTestHocon => BaseHocon + TestTimeFactor.Hocon;
private const string BaseHocon = @"
akka {
loglevel = ""WARNING""
test.single-expect-default = 15s
extensions = [
""Akka.Cluster.Tools.PublishSubscribe.DistributedPubSubExtensionProvider, Akka.Cluster.Tools""
]
actor {
provider = ""Akka.Cluster.ClusterActorRefProvider, Akka.Cluster""
}
remote.dot-netty.tcp {
hostname = ""127.0.0.1""
port = 0
}
cluster {
seed-nodes = []
roles = [""driver""]
min-nr-of-members = 1
run-coordinated-shutdown-when-down = off
}
}";
///
/// The same single-node HOCON at a caller-chosen loglevel.
///
///
/// A log event below akka.loglevel is never published to the EventStream at all, so
/// neither EventFilter nor an EventStream-subscribed probe can see it. A test whose
/// subject IS a log line (e.g. "this Debug line must be emitted exactly once, not in a spin")
/// therefore has to raise the level for its own ActorSystem; the default stays
/// WARNING so the other 40-odd classes keep their quiet output.
///
/// An Akka log level — DEBUG, INFO, WARNING, ERROR.
/// The base test HOCON with its loglevel replaced.
protected static string AkkaTestHoconAtLevel(string level) =>
AkkaTestHocon.Replace(@"loglevel = ""WARNING""", $@"loglevel = ""{level}""", StringComparison.Ordinal);
/// Initializes a new instance of the RuntimeActorTestBase class with Akka test setup.
protected RuntimeActorTestBase() : this(AkkaTestHocon)
{
}
/// Initializes a new instance of the RuntimeActorTestBase class with caller-supplied HOCON.
/// The Akka configuration for this class's ActorSystem — usually
/// .
protected RuntimeActorTestBase(string hocon) : base(hocon)
{
var cluster = Akka.Cluster.Cluster.Get(Sys);
cluster.Join(cluster.SelfAddress);
AwaitCondition(() => cluster.State.Members.Any(m => m.Status == MemberStatus.Up),
TimeSpan.FromSeconds(5));
}
/// Creates a new in-memory database factory for testing.
/// The database name, or null to generate a unique name.
/// An IDbContextFactory for the in-memory database.
protected static IDbContextFactory NewInMemoryDbFactory(string? dbName = null)
{
dbName ??= Guid.NewGuid().ToString("N");
return new InMemoryConfigDbFactory(dbName);
}
private sealed class InMemoryConfigDbFactory(string dbName) : IDbContextFactory
{
/// Creates a new in-memory database context.
/// A new OtOpcUaConfigDbContext instance.
public OtOpcUaConfigDbContext CreateDbContext()
{
var opts = new DbContextOptionsBuilder()
.UseInMemoryDatabase(dbName)
.Options;
return new OtOpcUaConfigDbContext(opts);
}
}
}