using FluentAssertions; using MessagePack; using Xunit; namespace OECS.Tests; // ── Test components ────────────────────────────────────────────────── [MessagePackObject] public struct TestPosition : IMessagePackSerializationCallbackReceiver { [Key(0)] public float X { get; set; } [Key(1)] public float Y { get; set; } public void OnBeforeSerialize() { } public void OnAfterDeserialize() { } } [MessagePackObject] public struct TestHealth : IMessagePackSerializationCallbackReceiver { [Key(0)] public int Value { get; set; } public void OnBeforeSerialize() { } public void OnAfterDeserialize() { } } // ── Test commands ──────────────────────────────────────────────────── [MessagePackObject] public struct SpawnCommand : ICommand { [Key(0)] public float X; [Key(1)] public float Y; [Key(2)] public int Health; public void Execute(World world) { var entity = world.CreateEntity(); world.AddComponent(entity, new TestPosition { X = X, Y = Y }); world.AddComponent(entity, new TestHealth { Value = Health }); } } [MessagePackObject] public struct MoveCommand : ICommand { [Key(0)] public Entity Entity; [Key(1)] public float Dx; [Key(2)] public float Dy; public void Execute(World world) { ref var pos = ref world.GetComponent(Entity); pos.X += Dx; pos.Y += Dy; } } [MessagePackObject] public struct DestroyCommand : ICommand { [Key(0)] public Entity Entity; public void Execute(World world) { world.DestroyEntity(Entity); } } [MessagePackObject] public struct FailingCommand : ICommand { [Key(0)] public string Message; public void Execute(World world) { throw new InvalidOperationException(Message); } } [MessagePackObject] public struct ChainedCommand : ICommand { [Key(0)] public float X; [Key(1)] public float Y; public void Execute(World world) { var entity = world.CreateEntity(); world.AddComponent(entity, new TestPosition { X = X, Y = Y }); // Enqueue a follow-up command during execution. world.Commands.Enqueue(new MoveCommand { Entity = entity, Dx = 10, Dy = 20 }); } } // ── Test class ─────────────────────────────────────────────────────── public class CommandTests { [Fact] public void Commands_ExecuteInFifoOrder() { var world = new World(); world.Commands.Enqueue(new SpawnCommand { X = 1, Y = 2, Health = 100 }); world.Commands.Enqueue(new SpawnCommand { X = 3, Y = 4, Health = 200 }); world.ExecuteCommands(); var positions = new List<(float X, float Y, int Health)>(); foreach (var it in world.Select()) { positions.Add((it.Item1.X, it.Item1.Y, it.Item2.Value)); } positions.Should().BeEquivalentTo([ (1, 2, 100), (3, 4, 200) ]); } [Fact] public void Command_CanReadWriteEcsState() { var world = new World(); var entity = world.CreateEntity(); world.AddComponent(entity, new TestPosition { X = 0, Y = 0 }); world.Commands.Enqueue(new MoveCommand { Entity = entity, Dx = 5, Dy = 10 }); world.ExecuteCommands(); ref var pos = ref world.GetComponent(entity); pos.X.Should().Be(5); pos.Y.Should().Be(10); } [Fact] public void Command_EnqueuedDuringExecuteAll_RunsInSameDrainCycle() { var world = new World(); world.Commands.Enqueue(new ChainedCommand { X = 0, Y = 0 }); world.ExecuteCommands(); var positions = new List<(float X, float Y)>(); foreach (var it in world.Select()) { positions.Add((it.Item1.X, it.Item1.Y)); } positions.Should().BeEquivalentTo([(10, 20)]); } [Fact] public void Exceptions_AreCollected_NotLost() { var world = new World(); world.Commands.Enqueue(new SpawnCommand { X = 1, Y = 2, Health = 100 }); world.Commands.Enqueue(new FailingCommand { Message = "boom" }); world.Commands.Enqueue(new SpawnCommand { X = 3, Y = 4, Health = 200 }); world.ExecuteCommands(); var count = 0; foreach (var it in world.Select()) count++; count.Should().Be(2); world.Commands.Errors.Should().HaveCount(1); world.Commands.Errors[0].Should().BeOfType() .Which.Message.Should().Be("boom"); } [Fact] public void Serialization_RoundTrip_PreservesCommandData() { var original = new SpawnCommand { X = 42, Y = 99, Health = 500 }; var bytes = MessagePackSerializer.Serialize(original); var deserialized = MessagePackSerializer.Deserialize(bytes); deserialized.X.Should().Be(42); deserialized.Y.Should().Be(99); deserialized.Health.Should().Be(500); } [Fact] public void Command_CanDestroyEntity() { var world = new World(); var entity = world.CreateEntity(); world.AddComponent(entity, new TestPosition { X = 1, Y = 2 }); world.Commands.Enqueue(new DestroyCommand { Entity = entity }); world.ExecuteCommands(); world.IsAlive(entity).Should().BeFalse(); } [Fact] public void Clear_DiscardsPendingCommands() { var world = new World(); world.Commands.Enqueue(new SpawnCommand { X = 1, Y = 2, Health = 100 }); world.Commands.Count.Should().Be(1); world.Commands.Clear(); world.Commands.Count.Should().Be(0); world.ExecuteCommands(); var count = 0; foreach (var it in world.Select()) count++; count.Should().Be(0); } [Fact] public void Commands_AreDrainedAfterEachSystem() { var world = new World(); var group = new SystemGroup(world); group.Add(new CommandEnqueueSystem(world, new SpawnCommand { X = 1, Y = 2, Health = 100 })); var observer = new EntityCountObserver(); group.Add(observer); group.RunLogical(); observer.SeenCount.Should().Be(1); } private class CommandEnqueueSystem : ISystem { private readonly World _world; private readonly SpawnCommand _command; public CommandEnqueueSystem(World world, SpawnCommand command) { _world = world; _command = command; } public void Run(World world) { _world.Commands.Enqueue(_command); } } private class EntityCountObserver : ISystem { public int SeenCount { get; private set; } public void Run(World world) { foreach (var it in world.Select()) SeenCount++; } } }