Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/Harness/ControlPlaneTestHarness.cs
T
Joseph Doherty b211911f98
v2-ci / unit-tests (push) Failing after 13m22s
test: migrate AdminUI/ControlPlane/Runtime/Host tests to xunit.v3 (#756)
Akka.TestKit.Xunit2 -> Akka.TestKit.Xunit 1.5.62 (xunit.v3 TestKit; needs xunit.v3 3.2.2, set via
VersionOverride on the four projects). Removes the xunit 2.9.3 pin. IAsyncLifetime returns ValueTask,
SqlServerFactAttribute gains the source-info ctor, and xUnit1051 call sites pass
TestContext.Current.CancellationToken. Test counts unchanged.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-10 08:57:33 -05:00

134 lines
7.1 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Akka.Cluster;
using Akka.TestKit;
using Akka.TestKit.Xunit;
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Configuration;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
/// <summary>
/// Akka TestKit fixture for ControlPlane actor tests. Provides:
/// - A test ActorSystem (xunit.v3 TestKit) wired with PubSub/Cluster extensions.
/// - A fresh in-memory <see cref="OtOpcUaConfigDbContext"/> per harness instance.
/// - An <see cref="IDbContextFactory{TContext}"/> the actors can hold.
///
/// One harness per test fact — InMemory provider gives strong isolation when the database
/// name is unique (random Guid).
/// </summary>
public abstract class ControlPlaneActorTestBase : TestKit
{
/// <summary>
/// Shared upper bound for a <b>presence</b> wait (<c>ExpectMsg</c> / <c>ExpectTerminated</c> /
/// <c>AwaitAssert</c>) — the same doctrine as <c>RuntimeActorTestBase.PresenceBudget</c>.
/// </summary>
/// <remarks>
/// A budget, not a delay: the helpers return within one poll interval of the condition holding
/// (<c>AwaitCondition</c> polls every <see cref="PollInterval"/> through this class's overloads, not Akka's
/// <c>max / 10</c> 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 (<c>ExpectNoMsg</c>) keep their own short literals and must not use this.
/// </remarks>
protected static readonly TimeSpan PresenceBudget = TimeSpan.FromSeconds(15);
/// <summary>
/// Poll interval for every <c>AwaitCondition</c> in this class's subclasses that does not pass its own
/// (lmxopcua#775). Akka's own default is <c>max / 10</c> of the DILATED budget — 1.5 s for a 15 s budget
/// locally, 4.5 s under CI's <c>AKKA_TEST_TIMEFACTOR=3</c> — 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). <c>AwaitAssert</c>
/// is unaffected: its default interval is a fixed 100 ms.
/// </summary>
protected static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(20);
/// <summary>
/// <see cref="TestKitBase.AwaitCondition(Func{bool}, TimeSpan?, TimeSpan?, string, CancellationToken)"/> with
/// <paramref name="interval"/> defaulting to <see cref="PollInterval"/> instead of Akka's <c>max / 10</c>.
/// Hides the TestKit overloads, so every subclass call without an interval binds here.
/// </summary>
/// <param name="conditionIsFulfilled">The condition to poll.</param>
/// <param name="max">The budget (dilated by Akka); null takes the enclosing <c>Within</c> or the default.</param>
/// <param name="interval">The poll interval; null means <see cref="PollInterval"/>.</param>
/// <param name="message">The failure message.</param>
/// <param name="cancellationToken">Cancels the wait.</param>
protected new void AwaitCondition(Func<bool> conditionIsFulfilled, TimeSpan? max = null, TimeSpan? interval = null,
string? message = null, CancellationToken cancellationToken = default) =>
base.AwaitCondition(conditionIsFulfilled, max, interval ?? PollInterval, message!, cancellationToken);
/// <summary>The message-carrying overload of <see cref="AwaitCondition(Func{bool}, TimeSpan?, TimeSpan?, string?, CancellationToken)"/>,
/// polled every <see cref="PollInterval"/>.</summary>
/// <param name="conditionIsFulfilled">The condition to poll.</param>
/// <param name="max">The budget (dilated by Akka).</param>
/// <param name="message">The failure message.</param>
/// <param name="cancellationToken">Cancels the wait.</param>
protected new void AwaitCondition(Func<bool> conditionIsFulfilled, TimeSpan? max, string message,
CancellationToken cancellationToken = default) =>
base.AwaitCondition(conditionIsFulfilled, max, PollInterval, message, cancellationToken);
/// <summary>Gets the Akka configuration for test actors, including cluster and pub-sub extensions.</summary>
/// <remarks>
/// <c>akka.test.single-expect-default</c> is raised from Akka's 3 s to <see cref="PresenceBudget"/>:
/// it is the budget every duration-less <c>ExpectMsg</c>/<c>ExpectTerminated</c>/<c>AwaitAssert</c>
/// uses, all of which are presence waits. Nothing in this assembly calls a duration-less
/// <c>ExpectNoMsg()</c>, the one absence wait that would also read it.
/// <para><c>akka.test.timefactor</c> comes from <see cref="TestTimeFactor"/>.</para>
/// </remarks>
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 = """"
}
}";
/// <summary>Initializes a new instance of the test harness; joins the cluster and waits for up status.</summary>
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));
}
/// <summary>Creates a new in-memory database context factory with an optional database name.</summary>
/// <param name="dbName">Optional database name; defaults to a random GUID if not provided.</param>
/// <returns>A context factory for creating in-memory config database contexts.</returns>
protected static IDbContextFactory<OtOpcUaConfigDbContext> NewInMemoryDbFactory(string? dbName = null)
{
dbName ??= Guid.NewGuid().ToString("N");
return new InMemoryConfigDbFactory(dbName);
}
private sealed class InMemoryConfigDbFactory(string dbName) : IDbContextFactory<OtOpcUaConfigDbContext>
{
/// <summary>Creates a new in-memory database context.</summary>
/// <returns>A new OtOpcUaConfigDbContext instance.</returns>
public OtOpcUaConfigDbContext CreateDbContext()
{
var opts = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseInMemoryDatabase(dbName)
.Options;
return new OtOpcUaConfigDbContext(opts);
}
}
}