using FluentAssertions; using R3; using Xunit; namespace OECS.Tests; public class ReactivityTests { private struct Position { public float X; public float Y; } private struct Health { public int Value; } private struct Frozen { } /// /// Helper that collects changes into a list and exposes an R3 Observer. /// 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() { } } [Fact] public void EntityCreation_PostsEntityAdded() { var world = new World(); var collector = new ChangeCollector(); using var sub = world.ObserveEntityChanges().Subscribe(collector.ToObserver()); var entity = world.CreateEntity(); world.PostChanges(); collector.Changes.Should().ContainSingle() .Which.Should().Match(c => c.Entity == entity && c.Kind == ChangeKind.EntityAdded); } [Fact] public void EntityDestruction_PostsEntityRemoved() { var world = new World(); var entity = world.CreateEntity(); world.PostChanges(); // Clear pending var collector = new ChangeCollector(); using var sub = world.ObserveEntityChanges().Subscribe(collector.ToObserver()); world.DestroyEntity(entity); world.PostChanges(); collector.Changes.Should().ContainSingle() .Which.Should().Match(c => c.Entity == entity && c.Kind == ChangeKind.EntityRemoved); } [Fact] public void ComponentAdd_PostsComponentAdded() { var world = new World(); var entity = world.CreateEntity(); world.PostChanges(); // Clear pending var collector = new ChangeCollector(); using var sub = world.ObserveComponentChanges().Subscribe(collector.ToObserver()); world.AddComponent(entity, new Position { X = 1, Y = 2 }); world.PostChanges(); collector.Changes.Should().ContainSingle() .Which.Should().Match(c => c.Entity == entity && c.Kind == ChangeKind.ComponentAdded && c.ComponentType == typeof(Position)); } [Fact] public void ComponentRemove_PostsComponentRemoved() { var world = new World(); var entity = world.CreateEntity(); world.AddComponent(entity, new Position { X = 1, Y = 2 }); world.PostChanges(); // Clear pending var collector = new ChangeCollector(); using var sub = world.ObserveComponentChanges().Subscribe(collector.ToObserver()); world.RemoveComponent(entity); world.PostChanges(); collector.Changes.Should().ContainSingle() .Which.Should().Match(c => c.Entity == entity && c.Kind == ChangeKind.ComponentRemoved && c.ComponentType == typeof(Position)); } [Fact] public void MarkModified_PostsComponentModified() { var world = new World(); var entity = world.CreateEntity(); world.AddComponent(entity, new Position { X = 0, Y = 0 }); world.PostChanges(); // Clear pending var collector = new ChangeCollector(); using var sub = world.ObserveComponentChanges().Subscribe(collector.ToObserver()); world.MarkModified(entity); world.PostChanges(); collector.Changes.Should().ContainSingle() .Which.Should().Match(c => c.Entity == entity && c.Kind == ChangeKind.ComponentModified && c.ComponentType == typeof(Position)); } [Fact] public void Changes_AreBatchedPerPost() { var world = new World(); var collector = new ChangeCollector(); using var sub = world.ObserveEntityChanges().Subscribe(collector.ToObserver()); var a = world.CreateEntity(); var b = world.CreateEntity(); world.AddComponent(a, new Position { X = 1, Y = 2 }); world.AddComponent(b, new Health { Value = 100 }); // No Post yet — subscribers should not have received anything. collector.Changes.Should().BeEmpty(); world.PostChanges(); // All changes arrive in one batch. collector.Changes.Should().HaveCount(4); } [Fact] public void Subscribers_ReceiveChangesInOrder() { var world = new World(); var collector = new ChangeCollector(); using var sub = world.ObserveEntityChanges().Subscribe(collector.ToObserver()); var entity = world.CreateEntity(); world.AddComponent(entity, new Position { X = 0, Y = 0 }); world.MarkModified(entity); world.RemoveComponent(entity); world.DestroyEntity(entity); world.PostChanges(); collector.Changes.Select(c => c.Kind).Should().Equal( ChangeKind.EntityAdded, ChangeKind.ComponentAdded, ChangeKind.ComponentModified, ChangeKind.ComponentRemoved, ChangeKind.EntityRemoved ); } [Fact] public void DisposingSubscription_StopsNotifications() { var world = new World(); var collector = new ChangeCollector(); var sub = world.ObserveEntityChanges().Subscribe(collector.ToObserver()); var entity = world.CreateEntity(); world.PostChanges(); collector.Changes.Should().HaveCount(1); sub.Dispose(); world.DestroyEntity(entity); world.PostChanges(); // No new changes after dispose. collector.Changes.Should().HaveCount(1); } [Fact] public void ObserveComponentChanges_OnlyReceivesMatchingType() { var world = new World(); var posCollector = new ChangeCollector(); var hpCollector = new ChangeCollector(); using var sub1 = world.ObserveComponentChanges().Subscribe(posCollector.ToObserver()); using var sub2 = world.ObserveComponentChanges().Subscribe(hpCollector.ToObserver()); var entity = world.CreateEntity(); world.AddComponent(entity, new Position { X = 1, Y = 2 }); world.AddComponent(entity, new Health { Value = 100 }); world.PostChanges(); posCollector.Changes.Should().HaveCount(1); posCollector.Changes[0].ComponentType.Should().Be(typeof(Position)); hpCollector.Changes.Should().HaveCount(1); hpCollector.Changes[0].ComponentType.Should().Be(typeof(Health)); } [Fact] public void ObserveQuery_OnlyReceivesMatchingChanges() { var world = new World(); var query = world.Query().With().Without().Build(); var collector = new ChangeCollector(); using var sub = world.ObserveQuery(query).Subscribe(collector.ToObserver()); var entity = world.CreateEntity(); world.AddComponent(entity, new Position { X = 1, Y = 2 }); // Should match world.AddComponent(entity, new Frozen()); // Should NOT match (not in With) world.AddComponent(entity, new Health { Value = 100 }); // Should NOT match (not in With) world.PostChanges(); collector.Changes.Should().HaveCount(1); collector.Changes[0].ComponentType.Should().Be(typeof(Position)); } [Fact] public void Deduplication_PreventsDuplicateChanges() { var world = new World(); var collector = new ChangeCollector(); using var sub = world.ObserveComponentChanges().Subscribe(collector.ToObserver()); var entity = world.CreateEntity(); world.AddComponent(entity, new Position { X = 0, Y = 0 }); world.MarkModified(entity); world.MarkModified(entity); // Duplicate world.MarkModified(entity); // Duplicate world.PostChanges(); // Should have exactly 2 changes: ComponentAdded + ComponentModified (deduplicated). collector.Changes.Should().HaveCount(2); collector.Changes[0].Kind.Should().Be(ChangeKind.ComponentAdded); collector.Changes[1].Kind.Should().Be(ChangeKind.ComponentModified); } [Fact] public void Changes_ArePostedAfterEachSystem_InSystemGroup() { var world = new World(); var group = new SystemGroup(world); var collector = new ChangeCollector(); using var sub = world.ObserveEntityChanges().Subscribe(collector.ToObserver()); // System that creates an entity. group.Add(new EntityCreatorSystem(world)); // Before running, no changes should be posted. collector.Changes.Should().BeEmpty(); group.RunLogical(); // After the system group runs, changes should be posted. collector.Changes.Should().Contain(c => c.Kind == ChangeKind.EntityAdded); } private class EntityCreatorSystem : ISystem { private readonly World _world; public EntityCreatorSystem(World world) { _world = world; } public void Run(World world) { _world.CreateEntity(); } } }