oecs-sharp/OECS.Tests/EdgeCaseTests.cs

417 lines
14 KiB
C#
Raw Normal View History

using FluentAssertions;
using R3;
using Xunit;
namespace OECS.Tests;
/// <summary>
/// Tests for edge cases and previously uncovered code paths.
/// </summary>
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<Entity>();
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<Position>(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<Position>(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<Entity>();
for (int i = 0; i < 10; i++)
{
var source = world.CreateEntity();
sources.Add(source);
world.AddComponent(source, new Relationship<ChildOf, ParentOf>
{
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<Relationship<ChildOf, ParentOf>>(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);
}
// ── 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 count = 0;
foreach (var it in world.Select<Position, Velocity, Health, Frozen>())
{
count++;
it.Item1.X.Should().Be(1);
it.Item2.Y.Should().Be(4);
it.Item3.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 count = 0;
foreach (var it in world.Select<Position, Velocity, Health, Frozen, Burning>())
{
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 count = 0;
foreach (var it in world.Select<Position, Velocity, Health, Frozen, Burning, Poisoned>())
{
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<Frozen> filters out entity b which has Frozen.
var query = new Query<Position>().Without<Frozen>();
var results = new List<Entity>();
foreach (var it in world.Select(query))
{
results.Add(it.Entity);
}
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<Position>(e1);
pos1.X.Should().Be(1);
ref var pos2 = ref world2.GetComponent<Position>(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<Entity>();
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 seen = new List<Entity>();
foreach (var it in world.Select<Position>())
{
seen.Add(it.Entity);
// Add a component to the other entity during iteration.
// This should not affect the current iteration.
world.AddComponent(it.Entity, 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 });
// 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 = () =>
{
foreach (var it in world.Select<Position>())
{
world.DestroyEntity(it.Entity);
}
};
act.Should().NotThrow();
}
// ── Helpers ──────────────────────────────────────────────────────
private sealed class ChangeCollector : IObserver<EntityChange>
{
public List<EntityChange> 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 { }
}