2026-07-18 19:42:59 +08:00
|
|
|
using FluentAssertions;
|
|
|
|
|
using Xunit;
|
|
|
|
|
|
|
|
|
|
namespace OECS.Tests;
|
|
|
|
|
|
|
|
|
|
// ── Phantom types for relationship differentiation ───────────────────
|
|
|
|
|
|
|
|
|
|
public struct ChildOf { }
|
|
|
|
|
public struct ParentOf { }
|
|
|
|
|
public struct OwnedBy { }
|
|
|
|
|
public struct MemberOf { }
|
2026-07-18 22:05:29 +08:00
|
|
|
public struct Thing { }
|
2026-07-18 19:42:59 +08:00
|
|
|
|
|
|
|
|
// ── Tests ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
public class RelationshipTests
|
|
|
|
|
{
|
|
|
|
|
[Fact]
|
|
|
|
|
public void AddRelationship_UpdatesReverseIndex()
|
|
|
|
|
{
|
|
|
|
|
var world = new World();
|
|
|
|
|
var child = world.CreateEntity();
|
|
|
|
|
var parent = world.CreateEntity();
|
|
|
|
|
|
|
|
|
|
world.AddComponent(child, new Relationship<ChildOf, ParentOf>
|
|
|
|
|
{
|
|
|
|
|
Target = parent
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
var sources = world.GetSources<Relationship<ChildOf, ParentOf>>(parent);
|
|
|
|
|
sources.Should().BeEquivalentTo([child]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public void RemoveRelationship_UpdatesReverseIndex()
|
|
|
|
|
{
|
|
|
|
|
var world = new World();
|
|
|
|
|
var child = world.CreateEntity();
|
|
|
|
|
var parent = world.CreateEntity();
|
|
|
|
|
|
|
|
|
|
world.AddComponent(child, new Relationship<ChildOf, ParentOf>
|
|
|
|
|
{
|
|
|
|
|
Target = parent
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
world.RemoveComponent<Relationship<ChildOf, ParentOf>>(child);
|
|
|
|
|
|
|
|
|
|
var sources = world.GetSources<Relationship<ChildOf, ParentOf>>(parent);
|
|
|
|
|
sources.Should().BeEmpty();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public void ReplaceRelationship_UpdatesReverseIndex()
|
|
|
|
|
{
|
|
|
|
|
var world = new World();
|
|
|
|
|
var child = world.CreateEntity();
|
|
|
|
|
var parent1 = world.CreateEntity();
|
|
|
|
|
var parent2 = world.CreateEntity();
|
|
|
|
|
|
|
|
|
|
world.AddComponent(child, new Relationship<ChildOf, ParentOf>
|
|
|
|
|
{
|
|
|
|
|
Target = parent1
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Replace with a new target.
|
|
|
|
|
world.AddComponent(child, new Relationship<ChildOf, ParentOf>
|
|
|
|
|
{
|
|
|
|
|
Target = parent2
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
world.GetSources<Relationship<ChildOf, ParentOf>>(parent1).Should().BeEmpty();
|
|
|
|
|
world.GetSources<Relationship<ChildOf, ParentOf>>(parent2).Should().BeEquivalentTo([child]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public void DestroySourceEntity_CleansUpRelationships()
|
|
|
|
|
{
|
|
|
|
|
var world = new World();
|
|
|
|
|
var child = world.CreateEntity();
|
|
|
|
|
var parent = world.CreateEntity();
|
|
|
|
|
|
|
|
|
|
world.AddComponent(child, new Relationship<ChildOf, ParentOf>
|
|
|
|
|
{
|
|
|
|
|
Target = parent
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
world.DestroyEntity(child);
|
|
|
|
|
|
|
|
|
|
// The relationship should be gone from the reverse index.
|
|
|
|
|
world.GetSources<Relationship<ChildOf, ParentOf>>(parent).Should().BeEmpty();
|
|
|
|
|
|
|
|
|
|
// The destroyed entity should not be alive.
|
|
|
|
|
world.IsAlive(child).Should().BeFalse();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public void DestroyTargetEntity_CleansUpIncomingRelationships()
|
|
|
|
|
{
|
|
|
|
|
var world = new World();
|
|
|
|
|
var child = world.CreateEntity();
|
|
|
|
|
var parent = world.CreateEntity();
|
|
|
|
|
|
|
|
|
|
world.AddComponent(child, new Relationship<ChildOf, ParentOf>
|
|
|
|
|
{
|
|
|
|
|
Target = parent
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
world.DestroyEntity(parent);
|
|
|
|
|
|
|
|
|
|
// The relationship component should be removed from the source entity.
|
|
|
|
|
world.HasComponent<Relationship<ChildOf, ParentOf>>(child).Should().BeFalse();
|
|
|
|
|
|
|
|
|
|
// The reverse index should be empty for the destroyed target.
|
|
|
|
|
world.GetSources<Relationship<ChildOf, ParentOf>>(parent).Should().BeEmpty();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public void MultipleSources_ForSameTarget()
|
|
|
|
|
{
|
|
|
|
|
var world = new World();
|
|
|
|
|
var child1 = world.CreateEntity();
|
|
|
|
|
var child2 = world.CreateEntity();
|
|
|
|
|
var child3 = world.CreateEntity();
|
|
|
|
|
var parent = world.CreateEntity();
|
|
|
|
|
|
|
|
|
|
world.AddComponent(child1, new Relationship<ChildOf, ParentOf>
|
|
|
|
|
{
|
2026-07-20 22:59:27 +08:00
|
|
|
Target = parent
|
2026-07-18 19:42:59 +08:00
|
|
|
});
|
|
|
|
|
world.AddComponent(child2, new Relationship<ChildOf, ParentOf>
|
|
|
|
|
{
|
2026-07-20 22:59:27 +08:00
|
|
|
Target = parent
|
2026-07-18 19:42:59 +08:00
|
|
|
});
|
|
|
|
|
world.AddComponent(child3, new Relationship<ChildOf, ParentOf>
|
|
|
|
|
{
|
2026-07-20 22:59:27 +08:00
|
|
|
Target = parent
|
2026-07-18 19:42:59 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
var sources = world.GetSources<Relationship<ChildOf, ParentOf>>(parent);
|
|
|
|
|
sources.Should().BeEquivalentTo([child1, child2, child3]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public void DifferentRelationshipTypes_AreIndependent()
|
|
|
|
|
{
|
|
|
|
|
var world = new World();
|
|
|
|
|
var entity = world.CreateEntity();
|
|
|
|
|
var owner = world.CreateEntity();
|
|
|
|
|
var group = world.CreateEntity();
|
|
|
|
|
|
2026-07-18 22:05:29 +08:00
|
|
|
world.AddComponent(entity, new Relationship<OwnedBy, Thing>
|
2026-07-18 19:42:59 +08:00
|
|
|
{
|
2026-07-20 22:59:27 +08:00
|
|
|
Target = owner
|
2026-07-18 19:42:59 +08:00
|
|
|
});
|
2026-07-18 22:05:29 +08:00
|
|
|
world.AddComponent(entity, new Relationship<MemberOf, Thing>
|
2026-07-18 19:42:59 +08:00
|
|
|
{
|
2026-07-20 22:59:27 +08:00
|
|
|
Target = group
|
2026-07-18 19:42:59 +08:00
|
|
|
});
|
|
|
|
|
|
2026-07-18 22:05:29 +08:00
|
|
|
world.GetSources<Relationship<OwnedBy, Thing>>(owner).Should().BeEquivalentTo([entity]);
|
|
|
|
|
world.GetSources<Relationship<MemberOf, Thing>>(group).Should().BeEquivalentTo([entity]);
|
2026-07-18 19:42:59 +08:00
|
|
|
|
|
|
|
|
// Cross-check: OwnedBy sources should not appear in MemberOf lookups.
|
2026-07-18 22:05:29 +08:00
|
|
|
world.GetSources<Relationship<MemberOf, Thing>>(owner).Should().BeEmpty();
|
2026-07-18 19:42:59 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public void ReverseLookup_ReturnsEmpty_WhenNoRelationships()
|
|
|
|
|
{
|
|
|
|
|
var world = new World();
|
|
|
|
|
var entity = world.CreateEntity();
|
|
|
|
|
|
|
|
|
|
var sources = world.GetSources<Relationship<ChildOf, ParentOf>>(entity);
|
|
|
|
|
sources.Should().BeEmpty();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public void Relationship_CanBeQueried_LikeNormalComponent()
|
|
|
|
|
{
|
|
|
|
|
var world = new World();
|
|
|
|
|
var child = world.CreateEntity();
|
|
|
|
|
var parent = world.CreateEntity();
|
|
|
|
|
|
|
|
|
|
world.AddComponent(child, new Relationship<ChildOf, ParentOf>
|
|
|
|
|
{
|
2026-07-20 22:59:27 +08:00
|
|
|
Target = parent
|
2026-07-18 19:42:59 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
var query = world.Query().With<Relationship<ChildOf, ParentOf>>().Build();
|
|
|
|
|
var results = new List<Entity>();
|
|
|
|
|
|
|
|
|
|
world.ForEach(query, (Entity e, ref Relationship<ChildOf, ParentOf> rel) =>
|
|
|
|
|
{
|
|
|
|
|
results.Add(e);
|
|
|
|
|
rel.Target.Should().Be(parent);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
results.Should().BeEquivalentTo([child]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public void DestroyEntity_WithBothIncomingAndOutgoingRelationships()
|
|
|
|
|
{
|
|
|
|
|
var world = new World();
|
|
|
|
|
var a = world.CreateEntity();
|
|
|
|
|
var b = world.CreateEntity();
|
|
|
|
|
var c = world.CreateEntity();
|
|
|
|
|
|
|
|
|
|
// a → b (a is child of b)
|
|
|
|
|
world.AddComponent(a, new Relationship<ChildOf, ParentOf>
|
|
|
|
|
{
|
2026-07-20 22:59:27 +08:00
|
|
|
Target = b
|
2026-07-18 19:42:59 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// c → a (c is child of a)
|
|
|
|
|
world.AddComponent(c, new Relationship<ChildOf, ParentOf>
|
|
|
|
|
{
|
2026-07-20 22:59:27 +08:00
|
|
|
Target = a
|
2026-07-18 19:42:59 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Destroy a. This should:
|
|
|
|
|
// 1. Remove the a→b relationship (a is source).
|
|
|
|
|
// 2. Remove the c→a relationship (a is target, so c's component is removed).
|
|
|
|
|
world.DestroyEntity(a);
|
|
|
|
|
|
|
|
|
|
world.IsAlive(a).Should().BeFalse();
|
|
|
|
|
world.HasComponent<Relationship<ChildOf, ParentOf>>(c).Should().BeFalse();
|
|
|
|
|
world.GetSources<Relationship<ChildOf, ParentOf>>(b).Should().BeEmpty();
|
|
|
|
|
}
|
2026-07-20 22:59:27 +08:00
|
|
|
}
|