oecs-sharp/OECS.Tests/ReactivityTests.cs

282 lines
9.2 KiB
C#
Raw Normal View History

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 { }
/// <summary>
/// Helper that collects changes into a list and exposes an R3 Observer.
/// </summary>
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() { }
}
[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<EntityChange>(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<EntityChange>(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<Position>().Subscribe(collector.ToObserver());
world.AddComponent(entity, new Position { X = 1, Y = 2 });
world.PostChanges();
collector.Changes.Should().ContainSingle()
.Which.Should().Match<EntityChange>(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<Position>().Subscribe(collector.ToObserver());
world.RemoveComponent<Position>(entity);
world.PostChanges();
collector.Changes.Should().ContainSingle()
.Which.Should().Match<EntityChange>(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<Position>().Subscribe(collector.ToObserver());
world.MarkModified<Position>(entity);
world.PostChanges();
collector.Changes.Should().ContainSingle()
.Which.Should().Match<EntityChange>(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<Position>(entity);
world.RemoveComponent<Position>(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<Position>().Subscribe(posCollector.ToObserver());
using var sub2 = world.ObserveComponentChanges<Health>().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 = new Query<Position>().Without<Frozen>();
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<Position>().Subscribe(collector.ToObserver());
var entity = world.CreateEntity();
world.AddComponent(entity, new Position { X = 0, Y = 0 });
world.MarkModified<Position>(entity);
world.MarkModified<Position>(entity); // Duplicate
world.MarkModified<Position>(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 RunImpl(World world)
{
_world.CreateEntity();
}
}
}