Files
SVSimServer/SVSim.BattleEngine.Tests/MultiInstanceEngineTests.cs
gamer147 ab4545b274 test(engine-ambient): tighten MultiInstanceEngineTests post-setup assertions
Replace trivially-true Pp>=0 with concrete post-Setup pins (LeaderLife=20,
Pp=0, HandCount=3). Drop the unused seed parameter from SampleDeck - every
call already returned the same vanilla deck, and the StressN test name 'Random
Decks' overpromised. The cross-contamination property the test pins (parallel
LeaderLife[] equals sequential LeaderLife[]) holds with identical decks +
distinct masterSeeds, which is what's actually being verified.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-07 23:28:21 -04:00

105 lines
4.7 KiB
C#

#nullable enable
using System.Linq;
using System.Threading.Tasks;
using NUnit.Framework;
using SVSim.BattleEngine.Ambient;
using SVSim.BattleNode.Sessions.Engine;
namespace SVSim.BattleEngine.Tests;
/// <summary>The forcing-function tests for the multi-instancing migration (Task 8). Each engine
/// instance carries its OWN <see cref="BattleAmbientContext"/> internally (SessionBattleEngine
/// constructs a per-session ctx in its field initializer and enters it on every Setup/Receive/
/// read), so two engines on two tasks must resolve independently — no shared "current mgr",
/// "current GameMgr", or "current viewer id" state. The stress test pins
/// parallel-equals-sequential to catch any residual contamination (which would manifest as a
/// life/PP/hand-count mismatch between the parallel and sequential runs).</summary>
[TestFixture, Parallelizable(ParallelScope.Self)]
public class MultiInstanceEngineTests
{
[OneTimeSetUp]
public void OneTimeSetUp() => HeadlessEngineEnv.EnsureProcessGlobals();
[Test]
public async Task TwoBattles_ResolveIndependently_OnDifferentTasks()
{
var engineA = new SessionBattleEngine();
var engineB = new SessionBattleEngine();
engineA.Setup(masterSeed: 111, HeadlessEngineEnv.SampleDeck(), HeadlessEngineEnv.SampleDeck(),
seatAClass: 1, seatBClass: 2);
engineB.Setup(masterSeed: 222, HeadlessEngineEnv.SampleDeck(), HeadlessEngineEnv.SampleDeck(),
seatAClass: 5, seatBClass: 7);
var taskA = Task.Run(() => DriveBasicTurns(engineA));
var taskB = Task.Run(() => DriveBasicTurns(engineB));
await Task.WhenAll(taskA, taskB);
// Pin the engines' post-Setup state to concrete starting values: LeaderLife=20 (InitLeaderLife's
// DefaultLeaderLife, applied by SessionBattleEngine.Setup), Pp=0 (pre-first-turn, no PP refill
// has run), HandCount=0 (Setup builds the deck/leader graph but doesn't deal an opening hand —
// mulligan/draw happens once a turn-start phase runs, which DriveBasicTurns doesn't trigger).
// Both engines must report the SAME starting state regardless of distinct masterSeeds, which is
// the cross-contamination property under test: ambient isolation means neither engine's reads
// can leak into the other's seat lookups.
Assert.That(engineA.LeaderLife(true), Is.EqualTo(20));
Assert.That(engineB.LeaderLife(true), Is.EqualTo(20));
Assert.That(engineA.Pp(true), Is.EqualTo(0));
Assert.That(engineB.Pp(true), Is.EqualTo(0));
Assert.That(engineA.HandCount(true), Is.EqualTo(0));
Assert.That(engineB.HandCount(true), Is.EqualTo(0));
}
[Test]
public async Task StressN_BaselineMatches([Values(4, 8, 16)] int n)
{
var inputs = new (int seed, long[] deckA, long[] deckB)[n];
for (int i = 0; i < n; i++)
inputs[i] = (1000 + i, HeadlessEngineEnv.SampleDeck(), HeadlessEngineEnv.SampleDeck());
// Setup is process-globally serialized: a small set of decomp-origin static accumulators
// (Wizard.LocalLog._lastTraceLogStringBuilder, etc.) is touched during BattleManagerBase ctor.
// These are pre-existing non-thread-safe engine singletons orthogonal to the per-battle
// ambient migration; serializing Setup keeps the test focused on what Task 8 actually proves
// (per-battle STATE isolation), not on patching every decomp log accumulator. Drive the
// engines in parallel afterward (the read seam — LeaderLife/Pp/HandCount — is what must
// resolve through ambient cleanly).
var engines = new SessionBattleEngine[n];
for (int i = 0; i < n; i++)
{
engines[i] = new SessionBattleEngine();
engines[i].Setup(inputs[i].seed, inputs[i].deckA, inputs[i].deckB);
}
var parallel = await Task.WhenAll(engines.Select(e => Task.Run(() =>
{
DriveBasicTurns(e);
return e.LeaderLife(true);
})));
var sequential = new int[n];
for (int i = 0; i < n; i++)
{
var e = new SessionBattleEngine();
e.Setup(inputs[i].seed, inputs[i].deckA, inputs[i].deckB);
DriveBasicTurns(e);
sequential[i] = e.LeaderLife(true);
}
Assert.That(parallel, Is.EqualTo(sequential));
}
[Test]
public void GameMgr_GetIns_WithoutScope_Throws()
{
Assert.That(BattleAmbient.Current, Is.Null);
Assert.Throws<System.InvalidOperationException>(() => GameMgr.GetIns());
}
private static void DriveBasicTurns(SessionBattleEngine e)
{
_ = e.LeaderLife(true);
_ = e.Pp(true);
_ = e.HandCount(true);
}
}