Hygiene B6: test coverage (fixture + missing coverage + guards)

- TestWorld: shared plain-Entities fixture (Make/Make<T>/SetTick reconciling the two SetServerTick variants + Player/Enemy builders). Additive; new tests consume it (40-file migration of existing tests deliberately deferred as pure churn).
- TestAttributeGuardTests: scans *Tests.cs and fails on any parameterless public-void method missing a runner attribute — guards the swallowed-[Test] bug (B0). Confirms the suite has no other dead tests.
- PrepPurchaseSystemTests (6): the previously-untested RPC economy — afford, reject-when-broke, once-per-run, non-Staging reject, unknown-id drop, and DR-014 same-tick atomicity.
- SystemOrderingCycleTests: registers the real run/cycle/combat system set and asserts SortSystems() has no circular dependency (invisible to single-system fixtures; only throws at Play) — also de-risks the B5 splits.

459/459 EditMode tests pass. (BossAISystemTests remains queued — the most complex to author faithfully; the boss stays Play-validated meanwhile.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-07 23:50:07 -07:00
parent ba303e5fd0
commit be7aa8affd
8 changed files with 367 additions and 0 deletions
@@ -0,0 +1,182 @@
using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Tests
{
/// <summary>
/// Plain-Entities EditMode coverage for the server-only PrepPurchaseSystem (DR-046 base prep-loadout spend).
/// Exercises the afford / soft-fail / once-per-run / non-Staging-reject paths and the DR-014 same-tick atomicity
/// (a second barely-affordable buy in the same tick can't also pass). Modeled on MetaSpendSystemTests.
/// </summary>
public class PrepPurchaseSystemTests
{
// Director carries the RunInfo singleton + the shared ResourceLedger (StorageEntry buffer), seeded with one resource.
static Entity MakeDirector(EntityManager em, byte lifecycle, byte resId, int amount)
{
var e = em.CreateEntity(typeof(RunInfo), typeof(ResourceLedger), typeof(StorageEntry));
em.SetComponentData(e, new RunInfo { Lifecycle = lifecycle });
if (amount > 0)
{
var ledger = em.GetBuffer<StorageEntry>(e);
StorageMath.Deposit(ledger, resId, amount);
}
return e;
}
// A player (PlayerTag + GhostOwner + StatModifier buffer) plus its connection entity (NetworkId).
static (Entity player, Entity conn) MakePlayer(EntityManager em, int netId)
{
var player = em.CreateEntity(typeof(PlayerTag), typeof(GhostOwner), typeof(StatModifier));
em.SetComponentData(player, new GhostOwner { NetworkId = netId });
var conn = em.CreateEntity(typeof(NetworkId));
em.SetComponentData(conn, new NetworkId { Value = netId });
return (player, conn);
}
static void SendRequest(EntityManager em, Entity conn, byte optionId)
{
var e = em.CreateEntity(typeof(PrepPurchaseRequest), typeof(ReceiveRpcCommandRequest));
em.SetComponentData(e, new PrepPurchaseRequest { OptionId = optionId });
em.SetComponentData(e, new ReceiveRpcCommandRequest { SourceConnection = conn });
}
// Count StatModifier rows in the prep SourceId band on a player.
static int PrepRowCount(EntityManager em, Entity player)
{
var mods = em.GetBuffer<StatModifier>(player);
int n = 0;
for (int i = 0; i < mods.Length; i++)
if (mods[i].SourceId >= Tuning.PrepSourceIdBase && mods[i].SourceId < Tuning.PrepSourceIdBase + 256u)
n++;
return n;
}
static int OpenRequests(EntityManager em)
{
using var q = em.CreateEntityQuery(typeof(PrepPurchaseRequest));
return q.CalculateEntityCount();
}
[Test]
public void Purchase_Appends_Prep_Modifier_Withdraws_And_Consumes_Request()
{
var (world, group) = TestWorld.Make<PrepPurchaseSystem>("Prep_Buy", tick: 100, server: true);
using (world)
{
var em = world.EntityManager;
var dir = MakeDirector(em, RunLifecycle.Staging, ResourceId.Ore, 30);
var (player, conn) = MakePlayer(em, 1);
SendRequest(em, conn, 0); // id0: Ore 30 -> MaxHealth +30 Flat
group.Update();
Assert.AreEqual(1, PrepRowCount(em, player), "One prep modifier appended.");
var mods = em.GetBuffer<StatModifier>(player);
Assert.AreEqual((byte)StatTarget.MaxHealth, mods[0].Target);
Assert.AreEqual(30f, mods[0].Value, 1e-4f);
Assert.AreEqual(Tuning.PrepSourceIdBase + 0u, mods[0].SourceId);
Assert.AreEqual(0, StorageMath.TotalOf(em.GetBuffer<StorageEntry>(dir), ResourceId.Ore), "Ore fully withdrawn.");
Assert.AreEqual(0, OpenRequests(em), "Request consumed.");
}
}
[Test]
public void Reject_When_Broke_Leaves_Ledger_Untouched_But_Consumes_Request()
{
var (world, group) = TestWorld.Make<PrepPurchaseSystem>("Prep_Broke", tick: 100, server: true);
using (world)
{
var em = world.EntityManager;
var dir = MakeDirector(em, RunLifecycle.Staging, ResourceId.Ore, 20); // < 30 cost
var (player, conn) = MakePlayer(em, 1);
SendRequest(em, conn, 0);
group.Update();
Assert.AreEqual(0, PrepRowCount(em, player), "No modifier when broke.");
Assert.AreEqual(20, StorageMath.TotalOf(em.GetBuffer<StorageEntry>(dir), ResourceId.Ore), "Pre-check skips Withdraw; ledger untouched.");
Assert.AreEqual(0, OpenRequests(em), "Request still consumed on reject.");
}
}
[Test]
public void Already_Owned_Does_Not_Rebuy()
{
var (world, group) = TestWorld.Make<PrepPurchaseSystem>("Prep_Owned", tick: 100, server: true);
using (world)
{
var em = world.EntityManager;
var dir = MakeDirector(em, RunLifecycle.Staging, ResourceId.Ore, 60);
var (player, conn) = MakePlayer(em, 1);
em.GetBuffer<StatModifier>(player).Add(new StatModifier { SourceId = Tuning.PrepSourceIdBase + 0u, Target = (byte)StatTarget.MaxHealth, Op = (byte)ModOp.Flat, Value = 30f });
SendRequest(em, conn, 0);
group.Update();
Assert.AreEqual(1, PrepRowCount(em, player), "Still exactly one row (once per run == SourceId presence).");
Assert.AreEqual(60, StorageMath.TotalOf(em.GetBuffer<StorageEntry>(dir), ResourceId.Ore), "No second withdraw.");
}
}
[Test]
public void Non_Staging_Is_Rejected()
{
var (world, group) = TestWorld.Make<PrepPurchaseSystem>("Prep_NonStaging", tick: 100, server: true);
using (world)
{
var em = world.EntityManager;
var dir = MakeDirector(em, RunLifecycle.InRoom, ResourceId.Ore, 60);
var (player, conn) = MakePlayer(em, 1);
SendRequest(em, conn, 0);
group.Update();
Assert.AreEqual(0, PrepRowCount(em, player), "No purchase outside Staging.");
Assert.AreEqual(60, StorageMath.TotalOf(em.GetBuffer<StorageEntry>(dir), ResourceId.Ore), "Ledger untouched outside Staging.");
Assert.AreEqual(0, OpenRequests(em), "Request consumed even when rejected.");
}
}
[Test]
public void Unknown_Option_Id_Is_Dropped()
{
var (world, group) = TestWorld.Make<PrepPurchaseSystem>("Prep_Unknown", tick: 100, server: true);
using (world)
{
var em = world.EntityManager;
var dir = MakeDirector(em, RunLifecycle.Staging, ResourceId.Ore, 60);
var (player, conn) = MakePlayer(em, 1);
SendRequest(em, conn, 99); // no such row
group.Update();
Assert.AreEqual(0, PrepRowCount(em, player));
Assert.AreEqual(60, StorageMath.TotalOf(em.GetBuffer<StorageEntry>(dir), ResourceId.Ore));
Assert.AreEqual(0, OpenRequests(em), "Unknown-id request still consumed.");
}
}
[Test]
public void Two_Same_Tick_Barely_Enough_Exactly_One_Succeeds()
{
var (world, group) = TestWorld.Make<PrepPurchaseSystem>("Prep_Atomic", tick: 100, server: true);
using (world)
{
var em = world.EntityManager;
var dir = MakeDirector(em, RunLifecycle.Staging, ResourceId.Aether, 25); // enough for exactly ONE 25-Aether buy
var (player, conn) = MakePlayer(em, 1);
SendRequest(em, conn, 2); // Aether 25 -> MeleeDamage
SendRequest(em, conn, 3); // Aether 25 -> Damage
group.Update();
Assert.AreEqual(1, PrepRowCount(em, player), "Exactly one of two same-tick 25-Aether buys succeeds (DR-014 in-loop pre-check).");
Assert.AreEqual(0, StorageMath.TotalOf(em.GetBuffer<StorageEntry>(dir), ResourceId.Aether), "Aether withdrawn once; never negative.");
Assert.AreEqual(0, OpenRequests(em), "Both requests consumed.");
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 02cf1a6cbcc3b804191e5e0169c1402d
@@ -0,0 +1,42 @@
using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Entities;
namespace ProjectM.Tests
{
/// <summary>
/// Guards the ComponentSystemSorter "circular dependency" hazard that is INVISIBLE to the rest of the suite:
/// every other fixture registers a single system then sorts, so a cross-system [UpdateBefore/After] cycle can
/// never surface — it only throws at world creation in Play. This registers the REAL ordered server-sim set
/// (every system that participates in an UpdateBefore/After relation, or its constraint is silently ignored)
/// into one SimulationSystemGroup and sorts, reproducing the Play-time sort headlessly. Only SortSystems runs
/// (never Update), so a bare world with no entities/singletons suffices.
/// </summary>
public class SystemOrderingCycleTests
{
[Test]
public void RunCycleCombatChain_Sorts_Without_A_Dependency_Cycle()
{
using var world = new World("OrderCycleGuard");
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
void Add<T>() where T : unmanaged, ISystem
=> group.AddSystemToUpdateList(world.GetOrCreateSystem<T>());
// RPC-receive systems ordered before the run director
Add<ReadyToggleSystem>(); Add<RouteSelectSystem>(); Add<PortalInteractReceiveSystem>();
Add<MetaSpendSystem>(); Add<ClassSelectReceiveSystem>(); Add<BoonApplySystem>(); Add<PrepPurchaseSystem>();
// Run director + the systems ordered around it and the cycle phase
Add<RunDirectorSystem>(); Add<ThreatDirectorSystem>(); Add<RoomFieldSystem>();
Add<RoomEnemyDirectorSystem>(); Add<BoonOfferSystem>(); Add<CyclePhaseSystem>();
Add<GoalReachedSystem>(); Add<WaveSystem>();
// Combat sub-chain in the same group
Add<EnemyAISystem>(); Add<BossAISystem>(); Add<CoreDamageSystem>();
Add<CoreRestoreSystem>(); Add<EnemyProjectileMoveSystem>(); Add<EnemyProjectileDamageSystem>();
Assert.DoesNotThrow(() => group.SortSystems(),
"A [UpdateBefore/After] cycle in the run/cycle/combat chain throws here instead of only at Play world-creation.");
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e160f942a69b8344fbe476a973a3e8be
@@ -0,0 +1,60 @@
using System.Collections.Generic;
using System.IO;
using System.Runtime.CompilerServices;
using System.Text.RegularExpressions;
using NUnit.Framework;
namespace ProjectM.Tests
{
/// <summary>
/// Anti-regression guard for the "an edit swallowed the [Test] attribute" hazard — a real dead test shipped at
/// EnemyAIMathTests.cs:107 (a regression guard for the enemy-stuck-on-cover fix that silently never ran). Scans
/// every *Tests.cs source file in this directory and FAILS if a parameterless public-void method (the suite's
/// test-method shape — every helper is static, so `public void` uniquely selects tests) is not immediately
/// preceded by a runner attribute. File I/O is available at EditMode-test time.
/// </summary>
public class TestAttributeGuardTests
{
static string ThisDir([CallerFilePath] string p = "") => Path.GetDirectoryName(p);
static readonly Regex TestMethod = new Regex(@"^\s*public\s+void\s+[A-Za-z_]\w*\s*\(\s*\)", RegexOptions.Compiled);
static readonly Regex RunnerAttr = new Regex(@"\[\s*(Test|TestCase|TestCaseSource|Theory|SetUp|TearDown|OneTimeSetUp|OneTimeTearDown)\b", RegexOptions.Compiled);
[Test]
public void Every_Public_Void_Test_Method_Has_A_Runner_Attribute()
{
var dir = ThisDir();
Assert.IsTrue(Directory.Exists(dir), $"Test source dir not found: {dir}");
var offenders = new List<string>();
foreach (var file in Directory.GetFiles(dir, "*Tests.cs"))
{
var lines = File.ReadAllLines(file);
for (int i = 0; i < lines.Length; i++)
{
if (!TestMethod.IsMatch(lines[i]))
continue;
// Walk upward past blank / comment lines to the nearest attribute or non-trivial line.
bool attributed = false;
for (int j = i - 1; j >= 0; j--)
{
string t = lines[j].Trim();
if (t.Length == 0 || t.StartsWith("//") || t.StartsWith("/*") || t.StartsWith("*"))
continue;
if (t.StartsWith("["))
{
if (RunnerAttr.IsMatch(t)) { attributed = true; break; }
continue; // a non-runner attribute line — keep scanning the attribute block
}
break; // hit a brace / statement: no attribute block above this method
}
if (!attributed)
offenders.Add($"{Path.GetFileName(file)}:{i + 1} {lines[i].Trim()}");
}
}
Assert.IsEmpty(offenders,
"Test methods missing a runner attribute (swallowed [Test]?):\n" + string.Join("\n", offenders));
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b4a83aae5c80ca347bbc6b9583df8b23
@@ -0,0 +1,75 @@
using ProjectM.Simulation;
using Unity.Core;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Tests
{
/// <summary>
/// Plain-Entities EditMode harness. Behaviourally identical to the hand-rolled MakeWorld/SetServerTick idioms
/// scattered across the suite (a NetworkTime-seeded SimulationSystemGroup world), consolidated so the boilerplate
/// has one home. Named NOT to end in "Tests" so the attribute-guard meta-test skips it.
/// </summary>
static class TestWorld
{
/// <summary>World + SimulationSystemGroup, NetworkTime seeded to <paramref name="tick"/>, TimeData = 1/60.
/// <paramref name="server"/> sets WorldFlags.GameServer (the IsServer() gate some systems read).</summary>
public static (World world, SimulationSystemGroup group) Make(string name = "TestWorld", uint tick = 1, bool server = false)
{
var world = server ? new World(name, WorldFlags.Game | WorldFlags.GameServer) : new World(name);
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
SetTick(world, tick);
return (world, group);
}
/// <summary>As <see cref="Make"/>, plus registers ONE ISystem and sorts. Drop-in for the per-file MakeWorld&lt;T&gt;.</summary>
public static (World world, SimulationSystemGroup group) Make<T>(string name = "TestWorld", uint tick = 1, bool server = false)
where T : unmanaged, ISystem
{
var (world, group) = Make(name, tick, server);
group.AddSystemToUpdateList(world.GetOrCreateSystem<T>());
group.SortSystems();
return (world, group);
}
/// <summary>Register an extra ISystem and re-sort (idempotent — final order is what matters).</summary>
public static void Add<T>(World world, SimulationSystemGroup group) where T : unmanaged, ISystem
{
group.AddSystemToUpdateList(world.GetOrCreateSystem<T>());
group.SortSystems();
}
/// <summary>The reconciled tick API: create NetworkTime if absent, else set it (a strict superset of both
/// hand-rolled variants — behaves like GetSingletonEntity() when present, and tolerates absence).</summary>
public static void SetTick(World world, uint tick)
{
var em = world.EntityManager;
using var q = em.CreateEntityQuery(typeof(NetworkTime));
Entity e = q.IsEmpty ? em.CreateEntity(typeof(NetworkTime)) : q.GetSingletonEntity();
em.SetComponentData(e, new NetworkTime { ServerTick = new NetworkTick(tick) });
}
// ---- domain builders (only for shapes that match exactly; per-file builders with extra components stay local) ----
public static Entity Player(EntityManager em, int netId = 1, byte region = RegionId.Base, float3 pos = default)
{
var e = em.CreateEntity(typeof(PlayerTag), typeof(GhostOwner), typeof(RegionTag), typeof(LocalTransform));
em.SetComponentData(e, new GhostOwner { NetworkId = netId });
em.SetComponentData(e, new RegionTag { Region = region });
em.SetComponentData(e, LocalTransform.FromPosition(pos));
return e;
}
public static Entity Enemy(EntityManager em, float3 pos, float hp = 50f, byte region = RegionId.Base)
{
var e = em.CreateEntity(typeof(EnemyTag), typeof(LocalTransform), typeof(RegionTag), typeof(Health));
em.SetComponentData(e, LocalTransform.FromPosition(pos));
em.SetComponentData(e, new RegionTag { Region = region });
em.SetComponentData(e, new Health { Current = hp, Max = hp });
return e;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 71d72296d0393c943a2184be97061d25