oecs-sharp/OECS.Tests/CommandTests.cs

270 lines
7.2 KiB
C#

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<TestPosition>(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<TestPosition, TestHealth>())
{
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<TestPosition>(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<TestPosition>())
{
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<TestPosition>())
count++;
count.Should().Be(2);
world.Commands.Errors.Should().HaveCount(1);
world.Commands.Errors[0].Should().BeOfType<InvalidOperationException>()
.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<SpawnCommand>(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<TestPosition>())
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 RunImpl(World world)
{
_world.Commands.Enqueue(_command);
}
}
private class EntityCountObserver : ISystem
{
public int SeenCount { get; private set; }
public void RunImpl(World world)
{
foreach (var it in world.Select<TestPosition>())
SeenCount++;
}
}
}