Files
Project-M/Assets/_Project/Tests/EditMode/PrepPurchaseSystemTests.cs
T
kronic be7aa8affd 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>
2026-07-07 23:50:07 -07:00

183 lines
8.3 KiB
C#

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.");
}
}
}
}