using Akka.Cluster; using Akka.TestKit; using Akka.TestKit.Xunit2; using Microsoft.EntityFrameworkCore; using ZB.MOM.WW.OtOpcUa.Configuration; namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness; /// /// Akka TestKit fixture for ControlPlane actor tests. Provides: /// - A test ActorSystem (xunit2 TestKit) wired with PubSub/Cluster extensions. /// - A fresh in-memory per harness instance. /// - An the actors can hold. /// /// One harness per test fact — InMemory provider gives strong isolation when the database /// name is unique (random Guid). /// public abstract class ControlPlaneActorTestBase : TestKit { /// /// Shared upper bound for a presence wait (ExpectMsg / ExpectTerminated / /// AwaitAssert) — the same doctrine as RuntimeActorTestBase.PresenceBudget. /// /// /// A budget, not a delay: the helpers return within one poll interval of the condition holding /// (AwaitCondition polls every through this class's overloads, not Akka's /// max / 10 of the dilated budget — lmxopcua#775), so raising it costs /// nothing on the happy path and cannot make a genuinely failing assertion pass. Several actors here /// do their first EF Core (InMemory) work inside the waited-for step — model build is a multi-second /// cold cost on a loaded CI runner — and 3–5 s budgets sized on an idle machine failed there (#606). /// Absence windows (ExpectNoMsg) keep their own short literals and must not use this. /// protected 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. /// protected 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 configuration for test actors, including cluster and pub-sub extensions. /// /// akka.test.single-expect-default is raised from Akka's 3 s to : /// it is the budget every duration-less ExpectMsg/ExpectTerminated/AwaitAssert /// uses, all of which are presence waits. Nothing in this assembly 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 = [""admin""] min-nr-of-members = 1 run-coordinated-shutdown-when-down = off pub-sub.role = """" } }"; /// Initializes a new instance of the test harness; joins the cluster and waits for up status. protected ControlPlaneActorTestBase() : base(AkkaTestHocon) { // Self-join so the cluster transitions to Up and DistributedPubSub forms. 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 context factory with an optional database name. /// Optional database name; defaults to a random GUID if not provided. /// A context factory for creating in-memory config database contexts. 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); } } }