using FluentAssertions; using R3; using Xunit; namespace OECS.Tests; /// /// Tests for edge cases and previously uncovered code paths. /// public class EdgeCaseTests { // ── Test components ────────────────────────────────────────────── private struct Position { public float X; public float Y; } private struct Velocity { public float X; public float Y; } private struct Health { public int Value; } private struct Frozen { } private struct Burning { } private struct Poisoned { } private struct Cursed { } // ── SparseSet resize correctness ───────────────────────────────── [Fact] public void SparseSet_Resize_InitializesNewSlotsToMinusOne() { // This test verifies that when the sparse array grows, new slots // are filled with -1 (not left as default 0, which would falsely // indicate the entity has a component). var world = new World(); // Create many entities to force sparse array resizes. var entities = new List(); for (int i = 0; i < 2000; i++) { entities.Add(world.CreateEntity()); } // Add Position to every other entity. for (int i = 0; i < entities.Count; i += 2) { world.AddComponent(entities[i], new Position { X = i, Y = i }); } // Entities without Position should correctly report false. for (int i = 1; i < entities.Count; i += 2) { world.HasComponent(entities[i]).Should().BeFalse( $"entity {entities[i]} should not have Position"); } } [Fact] public void SparseSet_Resize_PreservesExistingData() { var world = new World(); // Create entities before any component adds to trigger resizes // after data is already present. var first = world.CreateEntity(); world.AddComponent(first, new Position { X = 1, Y = 2 }); // Create many more to force resize. for (int i = 0; i < 2000; i++) { var e = world.CreateEntity(); world.AddComponent(e, new Position { X = i, Y = i }); } // Original entity should still have its data. ref var pos = ref world.GetComponent(first); pos.X.Should().Be(1); pos.Y.Should().Be(2); } // ── Entity version wrapping ────────────────────────────────────── [Fact] public void Entity_VersionWraps_From255To1() { var world = new World(); var original = world.CreateEntity(); uint id = original.Id; // Destroy and recreate the entity 255 times to wrap the version. for (int i = 0; i < 255; i++) { world.DestroyEntity(original); original = world.CreateEntity(); original.Id.Should().Be(id); } // After 255 recycles, version should have wrapped from 255 to 1. original.Version.Should().Be(1); world.IsAlive(original).Should().BeTrue(); } // ── DestroyEntity with multiple incoming relationships ─────────── [Fact] public void DestroyEntity_WithMultipleIncomingRelationships_DoesNotThrow() { var world = new World(); var target = world.CreateEntity(); // Create multiple sources pointing to the same target. var sources = new List(); for (int i = 0; i < 10; i++) { var source = world.CreateEntity(); sources.Add(source); world.AddComponent(source, new Relationship { Source = source, Target = target }); } // Destroying the target should cascade-remove all incoming // relationships without throwing. var act = () => world.DestroyEntity(target); act.Should().NotThrow(); // All source entities should have had their relationship removed. foreach (var source in sources) { world.HasComponent>(source).Should().BeFalse(); } } // ── Component removal change tracking on destroy ───────────────── [Fact] public void DestroyEntity_PostsComponentRemoved_ForEachComponent() { var world = new World(); var entity = world.CreateEntity(); world.AddComponent(entity, new Position { X = 1, Y = 2 }); world.AddComponent(entity, new Health { Value = 100 }); world.PostChanges(); // Clear pending var collector = new ChangeCollector(); using var sub = world.ObserveEntityChanges().Subscribe(collector.ToObserver()); world.DestroyEntity(entity); world.PostChanges(); // Should see: ComponentRemoved(Position), ComponentRemoved(Health), EntityRemoved collector.Changes.Should().Contain(c => c.Kind == ChangeKind.ComponentRemoved && c.ComponentType == typeof(Position)); collector.Changes.Should().Contain(c => c.Kind == ChangeKind.ComponentRemoved && c.ComponentType == typeof(Health)); collector.Changes.Should().Contain(c => c.Kind == ChangeKind.EntityRemoved && c.Entity == entity); } // ── QueryDescriptor equality ───────────────────────────────────── [Fact] public void QueryDescriptor_EqualQueries_AreEqual() { var q1 = new QueryBuilder().With().With().Without().Build(); var q2 = new QueryBuilder().With().With().Without().Build(); q1.Should().Be(q2); q1.GetHashCode().Should().Be(q2.GetHashCode()); } [Fact] public void QueryDescriptor_DifferentWith_AreNotEqual() { var q1 = new QueryBuilder().With().Build(); var q2 = new QueryBuilder().With().Build(); q1.Should().NotBe(q2); } [Fact] public void QueryDescriptor_DifferentWithout_AreNotEqual() { var q1 = new QueryBuilder().With().Without().Build(); var q2 = new QueryBuilder().With().Without().Build(); q1.Should().NotBe(q2); } [Fact] public void QueryDescriptor_DifferentCount_AreNotEqual() { var q1 = new QueryBuilder().With().Build(); var q2 = new QueryBuilder().With().With().Build(); q1.Should().NotBe(q2); } // ── Query with 4, 5, 6 components ──────────────────────────────── [Fact] public void Query_FourComponents_Works() { var world = new World(); var entity = world.CreateEntity(); world.AddComponent(entity, new Position { X = 1, Y = 2 }); world.AddComponent(entity, new Velocity { X = 3, Y = 4 }); world.AddComponent(entity, new Health { Value = 100 }); world.AddComponent(entity, new Frozen()); var query = world.Query() .With().With().With().With() .Build(); var count = 0; world.ForEach(query, (Entity e, ref Position pos, ref Velocity vel, ref Health hp, ref Frozen f) => { count++; pos.X.Should().Be(1); vel.Y.Should().Be(4); hp.Value.Should().Be(100); }); count.Should().Be(1); } [Fact] public void Query_FiveComponents_Works() { var world = new World(); var entity = world.CreateEntity(); world.AddComponent(entity, new Position { X = 1, Y = 2 }); world.AddComponent(entity, new Velocity { X = 3, Y = 4 }); world.AddComponent(entity, new Health { Value = 100 }); world.AddComponent(entity, new Frozen()); world.AddComponent(entity, new Burning()); var query = world.Query() .With().With().With().With().With() .Build(); var count = 0; world.ForEach(query, (Entity e, ref Position pos, ref Velocity vel, ref Health hp, ref Frozen f, ref Burning b) => { count++; }); count.Should().Be(1); } [Fact] public void Query_SixComponents_Works() { var world = new World(); var entity = world.CreateEntity(); world.AddComponent(entity, new Position { X = 1, Y = 2 }); world.AddComponent(entity, new Velocity { X = 3, Y = 4 }); world.AddComponent(entity, new Health { Value = 100 }); world.AddComponent(entity, new Frozen()); world.AddComponent(entity, new Burning()); world.AddComponent(entity, new Poisoned()); var query = world.Query() .With().With().With() .With().With().With() .Build(); var count = 0; world.ForEach(query, (Entity e, ref Position pos, ref Velocity vel, ref Health hp, ref Frozen f, ref Burning b, ref Poisoned p) => { count++; }); count.Should().Be(1); } // ── Query with only Without ────────────────────────────────────── [Fact] public void Query_OnlyWithout_FiltersCorrectly() { var world = new World(); var a = world.CreateEntity(); var b = world.CreateEntity(); world.AddComponent(a, new Position { X = 1, Y = 2 }); world.AddComponent(b, new Position { X = 3, Y = 4 }); world.AddComponent(b, new Frozen()); // Without filters out entity b which has Frozen. var query = world.Query().Without().Build(); var results = new List(); world.ForEach(query, (Entity e, ref Position pos) => { results.Add(e); }); results.Should().BeEquivalentTo([a]); } // ── World.Dispose ──────────────────────────────────────────────── [Fact] public void World_Dispose_DoesNotThrow() { var world = new World(); var entity = world.CreateEntity(); world.AddComponent(entity, new Position { X = 1, Y = 2 }); var act = () => world.Dispose(); act.Should().NotThrow(); } [Fact] public void World_Dispose_CanBeCalledMultipleTimes() { var world = new World(); world.Dispose(); var act = () => world.Dispose(); act.Should().NotThrow(); } // ── CommandQueue error accumulation ────────────────────────────── [Fact] public void CommandQueue_Errors_AccumulateAcrossDrainCycles() { var world = new World(); world.Commands.Enqueue(new FailingCommand { Message = "first" }); world.ExecuteCommands(); world.Commands.Errors.Should().HaveCount(1); world.Commands.Enqueue(new FailingCommand { Message = "second" }); world.ExecuteCommands(); world.Commands.Errors.Should().HaveCount(2); world.Commands.Errors[0].Message.Should().Be("first"); world.Commands.Errors[1].Message.Should().Be("second"); } [Fact] public void CommandQueue_ClearErrors_ResetsErrorList() { var world = new World(); world.Commands.Enqueue(new FailingCommand { Message = "boom" }); world.ExecuteCommands(); world.Commands.Errors.Should().HaveCount(1); world.Commands.ClearErrors(); world.Commands.Errors.Should().BeEmpty(); } // ── Multiple World instances ────────────────────────────────────── [Fact] public void MultipleWorlds_AreIndependent() { var world1 = new World(); var world2 = new World(); var e1 = world1.CreateEntity(); var e2 = world2.CreateEntity(); world1.AddComponent(e1, new Position { X = 1, Y = 2 }); world2.AddComponent(e2, new Position { X = 3, Y = 4 }); // Each world sees its own entity's components. ref var pos1 = ref world1.GetComponent(e1); pos1.X.Should().Be(1); ref var pos2 = ref world2.GetComponent(e2); pos2.X.Should().Be(3); // Worlds are independent: destroying in one doesn't affect the other. world1.DestroyEntity(e1); world1.IsAlive(e1).Should().BeFalse(); world2.IsAlive(e2).Should().BeTrue(); } // ── EntityAllocator large scale ────────────────────────────────── [Fact] public void EntityAllocator_HandlesManyEntities() { var world = new World(); var entities = new List(); for (int i = 0; i < 5000; i++) { var e = world.CreateEntity(); entities.Add(e); world.IsAlive(e).Should().BeTrue(); } // Destroy half. for (int i = 0; i < 2500; i++) { world.DestroyEntity(entities[i]); world.IsAlive(entities[i]).Should().BeFalse(); } // Create more — should reuse freed IDs. for (int i = 0; i < 2500; i++) { var e = world.CreateEntity(); world.IsAlive(e).Should().BeTrue(); } } // ── Structural mutation during ForEach ──────────────────────────── [Fact] public void ForEach_AddingComponent_DoesNotAffectCurrentIteration() { var world = new World(); var a = world.CreateEntity(); var b = world.CreateEntity(); world.AddComponent(a, new Position { X = 1, Y = 2 }); world.AddComponent(b, new Position { X = 3, Y = 4 }); var query = world.Query().With().Build(); var seen = new List(); world.ForEach(query, (Entity e, ref Position pos) => { seen.Add(e); // Add a component to the other entity during iteration. // This should not affect the current iteration. world.AddComponent(e, new Velocity { X = 1, Y = 1 }); }); seen.Should().BeEquivalentTo([a, b]); } [Fact] public void ForEach_DestroyingEntity_DoesNotThrow() { var world = new World(); var a = world.CreateEntity(); var b = world.CreateEntity(); world.AddComponent(a, new Position { X = 1, Y = 2 }); world.AddComponent(b, new Position { X = 3, Y = 4 }); var query = world.Query().With().Build(); // Destroying an entity during iteration: the sparse set uses // swap-remove, so the destroyed entity's slot is replaced by // the last element. This is safe as long as we don't re-iterate // the destroyed entity (which we won't since it's swap-removed). var act = () => { world.ForEach(query, (Entity e, ref Position pos) => { world.DestroyEntity(e); }); }; act.Should().NotThrow(); } // ── Helpers ────────────────────────────────────────────────────── private sealed class ChangeCollector : IObserver { public List Changes = new(); public void OnNext(EntityChange value) => Changes.Add(value); public void OnError(Exception error) { } public void OnCompleted() { } } // Phantom types for relationship tests. public struct ChildOf { } public struct ParentOf { } }