using FluentAssertions; using MessagePack; using Xunit; namespace OECS.Tests; // ── Test components for serialization ───────────────────────────────── [MessagePackObject] public struct SerialPos : IMessagePackSerializationCallbackReceiver { [Key(0)] public float X { get; set; } [Key(1)] public float Y { get; set; } public void OnBeforeSerialize() { } public void OnAfterDeserialize() { } } [MessagePackObject] public struct SerialHealth : IMessagePackSerializationCallbackReceiver { [Key(0)] public int Value { get; set; } public void OnBeforeSerialize() { } public void OnAfterDeserialize() { } } [MessagePackObject] public struct SerialConfig : IMessagePackSerializationCallbackReceiver { [Key(0)] public float Gravity { get; set; } [Key(1)] public int MaxPlayers { get; set; } public void OnBeforeSerialize() { } public void OnAfterDeserialize() { } } // ── Phantom types for relationship serialization ────────────────────── public struct SerialChildOf { } public struct SerialParentOf { } // ── Tests ──────────────────────────────────────────────────────────── public class SerializationTests { [Fact] public void SaveLoad_RoundTrip_PreservesComponents() { var world = new World(); var entity = world.CreateEntity(); world.AddComponent(entity, new SerialPos { X = 1.5f, Y = 2.5f }); world.AddComponent(entity, new SerialHealth { Value = 100 }); var stream = new MemoryStream(); WorldSerializer.Save(world, stream); // Load into a fresh world. stream.Position = 0; var world2 = new World(); WorldSerializer.Load(world2, stream); // Find the entity in the loaded world. var found = world2.FindEntity(); found.Should().NotBe(Entity.Null); ref var pos = ref world2.GetComponent(found); pos.X.Should().Be(1.5f); pos.Y.Should().Be(2.5f); ref var hp = ref world2.GetComponent(found); hp.Value.Should().Be(100); } [Fact] public void SaveLoad_RoundTrip_PreservesMultipleEntities() { var world = new World(); var a = world.CreateEntity(); var b = world.CreateEntity(); world.AddComponent(a, new SerialPos { X = 1, Y = 2 }); world.AddComponent(a, new SerialHealth { Value = 10 }); world.AddComponent(b, new SerialPos { X = 3, Y = 4 }); world.AddComponent(b, new SerialHealth { Value = 20 }); var stream = new MemoryStream(); WorldSerializer.Save(world, stream); stream.Position = 0; var world2 = new World(); WorldSerializer.Load(world2, stream); var positions = new List<(float X, float Y, int Health)>(); foreach (var it in world2.Select()) { positions.Add((it.Val1.X, it.Val1.Y, it.Val2.Value)); } positions.Should().BeEquivalentTo([(1, 2, 10), (3, 4, 20)]); } [Fact] public void SaveLoad_RoundTrip_PreservesEntityIds() { var world = new World(); var entity = world.CreateEntity(); world.AddComponent(entity, new SerialPos { X = 1, Y = 2 }); var stream = new MemoryStream(); WorldSerializer.Save(world, stream); stream.Position = 0; var world2 = new World(); WorldSerializer.Load(world2, stream); var found = world2.FindEntity(); found.Id.Should().Be(entity.Id); found.Version.Should().Be(entity.Version); } [Fact] public void SaveLoad_RoundTrip_PreservesSingletons() { var world = new World(); world.SetSingleton(new SerialConfig { Gravity = 9.8f, MaxPlayers = 4 }); var stream = new MemoryStream(); WorldSerializer.Save(world, stream); stream.Position = 0; var world2 = new World(); WorldSerializer.Load(world2, stream); world2.HasSingleton().Should().BeTrue(); ref var config = ref world2.GetSingleton(); config.Gravity.Should().Be(9.8f); config.MaxPlayers.Should().Be(4); } [Fact] public void SaveLoad_RoundTrip_PreservesRelationships() { var world = new World(); var child = world.CreateEntity(); var parent = world.CreateEntity(); world.AddComponent(child, new Relationship { Target = parent }); var stream = new MemoryStream(); WorldSerializer.Save(world, stream); stream.Position = 0; var world2 = new World(); WorldSerializer.Load(world2, stream); // Find the relationship. var found = world2.FindEntity>(); found.Should().NotBe(Entity.Null); ref var rel = ref world2.GetComponent>(found); rel.Target.Should().NotBe(Entity.Null); // The target should be alive and have the same ID as the original parent. world2.IsAlive(rel.Target).Should().BeTrue(); rel.Target.Id.Should().Be(parent.Id); } [Fact] public void SaveLoad_RoundTrip_PreservesRelationshipTargetIntegrity() { var world = new World(); var child = world.CreateEntity(); var parent = world.CreateEntity(); world.AddComponent(parent, new SerialPos { X = 10, Y = 20 }); world.AddComponent(child, new Relationship { Target = parent }); var stream = new MemoryStream(); WorldSerializer.Save(world, stream); stream.Position = 0; var world2 = new World(); WorldSerializer.Load(world2, stream); var foundChild = world2.FindEntity>(); ref var rel = ref world2.GetComponent>(foundChild); // The target entity should still have its component. world2.HasComponent(rel.Target).Should().BeTrue(); ref var pos = ref world2.GetComponent(rel.Target); pos.X.Should().Be(10); pos.Y.Should().Be(20); } [Fact] public void SaveLoad_EmptyWorld_Works() { var world = new World(); var stream = new MemoryStream(); WorldSerializer.Save(world, stream); stream.Position = 0; var world2 = new World(); WorldSerializer.Load(world2, stream); // Loading an empty world should not throw. var count = 0; foreach (var it in world2.Select()) count++; count.Should().Be(0); } [Fact] public void SaveLoad_EntityWithNoComponents_IsNotSerialized() { var world = new World(); world.CreateEntity(); // Entity with no components. var stream = new MemoryStream(); WorldSerializer.Save(world, stream); stream.Position = 0; var world2 = new World(); WorldSerializer.Load(world2, stream); // No entities should be loaded since the empty entity wasn't serialized. var count = 0; foreach (var it in world2.Select()) count++; count.Should().Be(0); } }