diff --git a/Assets/_Project/Tests/EditMode/PrepPurchaseSystemTests.cs b/Assets/_Project/Tests/EditMode/PrepPurchaseSystemTests.cs new file mode 100644 index 000000000..33a667f85 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/PrepPurchaseSystemTests.cs @@ -0,0 +1,182 @@ +using NUnit.Framework; +using ProjectM.Server; +using ProjectM.Simulation; +using Unity.Entities; +using Unity.NetCode; + +namespace ProjectM.Tests +{ + /// + /// 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. + /// + 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(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(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("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(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(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("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(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("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(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(dir), ResourceId.Ore), "No second withdraw."); + } + } + + [Test] + public void Non_Staging_Is_Rejected() + { + var (world, group) = TestWorld.Make("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(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("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(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("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(dir), ResourceId.Aether), "Aether withdrawn once; never negative."); + Assert.AreEqual(0, OpenRequests(em), "Both requests consumed."); + } + } + } +} diff --git a/Assets/_Project/Tests/EditMode/PrepPurchaseSystemTests.cs.meta b/Assets/_Project/Tests/EditMode/PrepPurchaseSystemTests.cs.meta new file mode 100644 index 000000000..34d922c0c --- /dev/null +++ b/Assets/_Project/Tests/EditMode/PrepPurchaseSystemTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 02cf1a6cbcc3b804191e5e0169c1402d \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/SystemOrderingCycleTests.cs b/Assets/_Project/Tests/EditMode/SystemOrderingCycleTests.cs new file mode 100644 index 000000000..5bad504bf --- /dev/null +++ b/Assets/_Project/Tests/EditMode/SystemOrderingCycleTests.cs @@ -0,0 +1,42 @@ +using NUnit.Framework; +using ProjectM.Server; +using ProjectM.Simulation; +using Unity.Entities; + +namespace ProjectM.Tests +{ + /// + /// 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. + /// + public class SystemOrderingCycleTests + { + [Test] + public void RunCycleCombatChain_Sorts_Without_A_Dependency_Cycle() + { + using var world = new World("OrderCycleGuard"); + var group = world.GetOrCreateSystemManaged(); + + void Add() where T : unmanaged, ISystem + => group.AddSystemToUpdateList(world.GetOrCreateSystem()); + + // RPC-receive systems ordered before the run director + Add(); Add(); Add(); + Add(); Add(); Add(); Add(); + // Run director + the systems ordered around it and the cycle phase + Add(); Add(); Add(); + Add(); Add(); Add(); + Add(); Add(); + // Combat sub-chain in the same group + Add(); Add(); Add(); + Add(); Add(); Add(); + + Assert.DoesNotThrow(() => group.SortSystems(), + "A [UpdateBefore/After] cycle in the run/cycle/combat chain throws here instead of only at Play world-creation."); + } + } +} diff --git a/Assets/_Project/Tests/EditMode/SystemOrderingCycleTests.cs.meta b/Assets/_Project/Tests/EditMode/SystemOrderingCycleTests.cs.meta new file mode 100644 index 000000000..d6cd6ea17 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/SystemOrderingCycleTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e160f942a69b8344fbe476a973a3e8be \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/TestAttributeGuardTests.cs b/Assets/_Project/Tests/EditMode/TestAttributeGuardTests.cs new file mode 100644 index 000000000..400cd24b2 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/TestAttributeGuardTests.cs @@ -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 +{ + /// + /// 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. + /// + 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(); + 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)); + } + } +} diff --git a/Assets/_Project/Tests/EditMode/TestAttributeGuardTests.cs.meta b/Assets/_Project/Tests/EditMode/TestAttributeGuardTests.cs.meta new file mode 100644 index 000000000..314c99817 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/TestAttributeGuardTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b4a83aae5c80ca347bbc6b9583df8b23 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/TestWorld.cs b/Assets/_Project/Tests/EditMode/TestWorld.cs new file mode 100644 index 000000000..adf665aa5 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/TestWorld.cs @@ -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 +{ + /// + /// 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. + /// + static class TestWorld + { + /// World + SimulationSystemGroup, NetworkTime seeded to , TimeData = 1/60. + /// sets WorldFlags.GameServer (the IsServer() gate some systems read). + 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(); + world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); + SetTick(world, tick); + return (world, group); + } + + /// As , plus registers ONE ISystem and sorts. Drop-in for the per-file MakeWorld<T>. + public static (World world, SimulationSystemGroup group) Make(string name = "TestWorld", uint tick = 1, bool server = false) + where T : unmanaged, ISystem + { + var (world, group) = Make(name, tick, server); + group.AddSystemToUpdateList(world.GetOrCreateSystem()); + group.SortSystems(); + return (world, group); + } + + /// Register an extra ISystem and re-sort (idempotent — final order is what matters). + public static void Add(World world, SimulationSystemGroup group) where T : unmanaged, ISystem + { + group.AddSystemToUpdateList(world.GetOrCreateSystem()); + group.SortSystems(); + } + + /// 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). + 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; + } + } +} diff --git a/Assets/_Project/Tests/EditMode/TestWorld.cs.meta b/Assets/_Project/Tests/EditMode/TestWorld.cs.meta new file mode 100644 index 000000000..ff1c94bea --- /dev/null +++ b/Assets/_Project/Tests/EditMode/TestWorld.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 71d72296d0393c943a2184be97061d25 \ No newline at end of file