b211911f98
v2-ci / unit-tests (push) Failing after 13m22s
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
134 lines
7.1 KiB
C#
134 lines
7.1 KiB
C#
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);
|
||
}
|
||
}
|
||
}
|