ecs-observable/test/relationships.test.ts

535 lines
17 KiB
TypeScript

import { describe, it, expect, beforeEach } from "vitest";
import {
World,
defineRelationship,
type RelationshipUpdate,
type WorldEvent,
} from "../src/index";
// ── Relationships ─────────────────────────────────────
const ChildOf = defineRelationship("childOf");
const Targeting = defineRelationship("targeting");
const Inside = defineRelationship("inside");
// ── Helpers ───────────────────────────────────────────
function collectEvents(world: World): WorldEvent[] {
const log: WorldEvent[] = [];
world.events$.subscribe((e: WorldEvent) => log.push(e));
return log;
}
function collectRelUpdates(obs$: {
subscribe: Function;
}): RelationshipUpdate[] {
const log: RelationshipUpdate[] = [];
obs$.subscribe((u: RelationshipUpdate) => log.push(u));
return log;
}
// ── Basic relate / unrelate ───────────────────────────
describe("Relationships", () => {
let world: World;
beforeEach(() => {
world = new World();
});
it("relates two entities", () => {
const parent = world.spawn();
const child = world.spawn();
world.relate(child, ChildOf, parent);
expect(world.getRelated(child, ChildOf)).toBe(parent);
});
it("getRelated returns undefined when no relationship", () => {
const e = world.spawn();
expect(world.getRelated(e, ChildOf)).toBeUndefined();
});
it("getRelatedTo returns reverse lookup", () => {
const parent = world.spawn();
const a = world.spawn();
const b = world.spawn();
world.relate(a, ChildOf, parent);
world.relate(b, ChildOf, parent);
const children = [...world.getRelatedTo(parent, ChildOf)];
expect(children).toHaveLength(2);
expect(children).toContain(a);
expect(children).toContain(b);
});
it("getRelatedTo returns empty when no edges", () => {
const e = world.spawn();
expect([...world.getRelatedTo(e, ChildOf)]).toEqual([]);
});
it("unrelate removes the relationship", () => {
const parent = world.spawn();
const child = world.spawn();
world.relate(child, ChildOf, parent);
world.unrelate(child, ChildOf);
expect(world.getRelated(child, ChildOf)).toBeUndefined();
expect([...world.getRelatedTo(parent, ChildOf)]).toEqual([]);
});
it("unrelate is idempotent", () => {
const e = world.spawn();
expect(() => world.unrelate(e, ChildOf)).not.toThrow();
});
it("relate replaces existing relationship", () => {
const a = world.spawn();
const b = world.spawn();
const c = world.spawn();
world.relate(a, ChildOf, b);
expect(world.getRelated(a, ChildOf)).toBe(b);
expect([...world.getRelatedTo(b, ChildOf)]).toContain(a);
world.relate(a, ChildOf, c);
expect(world.getRelated(a, ChildOf)).toBe(c);
// a should no longer point to b
expect([...world.getRelatedTo(b, ChildOf)]).toEqual([]);
expect([...world.getRelatedTo(c, ChildOf)]).toContain(a);
});
});
// ── Events ────────────────────────────────────────────
describe("Relationship events", () => {
let world: World;
beforeEach(() => {
world = new World();
});
it("emits relationshipAdded event", () => {
const events = collectEvents(world);
const a = world.spawn();
const b = world.spawn();
world.relate(a, ChildOf, b);
const ev = events.find((e) => e.type === "relationshipAdded")!;
expect(ev).toMatchObject({
type: "relationshipAdded",
source: a,
target: b,
});
});
it("emits relationshipRemoved on unrelate", () => {
const a = world.spawn();
const b = world.spawn();
world.relate(a, ChildOf, b);
const events = collectEvents(world);
world.unrelate(a, ChildOf);
const ev = events.find((e) => e.type === "relationshipRemoved")!;
expect(ev).toMatchObject({
type: "relationshipRemoved",
source: a,
target: b,
});
});
it("emits relationshipRemoved when replacing an edge", () => {
const a = world.spawn();
const b = world.spawn();
const c = world.spawn();
world.relate(a, ChildOf, b);
const events = collectEvents(world);
world.relate(a, ChildOf, c);
const removed = events.filter((e) => e.type === "relationshipRemoved");
const added = events.filter((e) => e.type === "relationshipAdded");
expect(removed).toHaveLength(1);
expect(removed[0]).toMatchObject({ source: a, target: b });
expect(added).toHaveLength(1);
expect(added[0]).toMatchObject({ source: a, target: c });
});
});
// ── Observable relationships ──────────────────────────
describe("Observable relationships", () => {
let world: World;
beforeEach(() => {
world = new World();
});
it("emits added on relate", () => {
const log = collectRelUpdates(world.observeRelated(ChildOf));
const a = world.spawn();
const b = world.spawn();
world.relate(a, ChildOf, b);
expect(log).toHaveLength(1);
expect(log[0].added).toEqual([{ source: a, target: b }]);
expect(log[0].removed).toEqual([]);
});
it("emits removed on unrelate", () => {
const a = world.spawn();
const b = world.spawn();
world.relate(a, ChildOf, b);
const log = collectRelUpdates(world.observeRelated(ChildOf));
world.unrelate(a, ChildOf);
expect(log).toHaveLength(1);
expect(log[0].removed).toEqual([{ source: a, target: b }]);
});
it("emits removed+added on replacement", () => {
const a = world.spawn();
const b = world.spawn();
const c = world.spawn();
world.relate(a, ChildOf, b);
const log = collectRelUpdates(world.observeRelated(ChildOf));
world.relate(a, ChildOf, c);
// Should have two updates: one removed, one added
expect(log).toHaveLength(2);
expect(log[0].removed).toEqual([{ source: a, target: b }]);
expect(log[1].added).toEqual([{ source: a, target: c }]);
});
it("seeds with existing relationships", () => {
const a = world.spawn();
const b = world.spawn();
world.relate(a, ChildOf, b);
const log = collectRelUpdates(world.observeRelated(ChildOf));
// Unrelate should trigger removed — proving seed worked
world.unrelate(a, ChildOf);
expect(log).toHaveLength(1);
expect(log[0].removed).toEqual([{ source: a, target: b }]);
});
it("observers are scoped to relationship type", () => {
const childLog = collectRelUpdates(world.observeRelated(ChildOf));
const targetLog = collectRelUpdates(world.observeRelated(Targeting));
const a = world.spawn();
const b = world.spawn();
world.relate(a, ChildOf, b);
expect(childLog).toHaveLength(1);
expect(targetLog).toHaveLength(0);
});
});
// ── Destroy cleanup ───────────────────────────────────
describe("Destroy cleanup", () => {
let world: World;
beforeEach(() => {
world = new World();
});
it("removes edges when source is destroyed", () => {
const parent = world.spawn();
const child = world.spawn();
world.relate(child, ChildOf, parent);
world.destroy(child);
expect(world.getRelated(child, ChildOf)).toBeUndefined();
expect([...world.getRelatedTo(parent, ChildOf)]).toEqual([]);
});
it("removes edges when target is destroyed", () => {
const parent = world.spawn();
const child = world.spawn();
world.relate(child, ChildOf, parent);
world.destroy(parent);
expect([...world.getRelatedTo(parent, ChildOf)]).toEqual([]);
expect(world.getRelated(child, ChildOf)).toBeUndefined();
});
it("emits relationshipRemoved events on destroy", () => {
const a = world.spawn();
const b = world.spawn();
const c = world.spawn();
world.relate(a, Targeting, b);
world.relate(a, ChildOf, c);
const log = collectRelUpdates(world.observeRelated(ChildOf));
const tLog = collectRelUpdates(world.observeRelated(Targeting));
world.destroy(a);
expect(log).toHaveLength(1);
expect(log[0].removed).toEqual([{ source: a, target: c }]);
expect(tLog).toHaveLength(1);
expect(tLog[0].removed).toEqual([{ source: a, target: b }]);
});
it("detects cross-relationship observers when destroying target", () => {
const parent = world.spawn();
const child = world.spawn();
world.relate(child, ChildOf, parent);
const log = collectRelUpdates(world.observeRelated(ChildOf));
world.destroy(parent);
expect(log).toHaveLength(1);
expect(log[0].removed).toEqual([{ source: child, target: parent }]);
});
it("handles destroy when entity is source for multiple relationships", () => {
const a = world.spawn();
const b = world.spawn();
const c = world.spawn();
world.relate(a, ChildOf, b);
world.relate(a, Targeting, c);
const childLog = collectRelUpdates(world.observeRelated(ChildOf));
const targetLog = collectRelUpdates(world.observeRelated(Targeting));
world.destroy(a);
expect(childLog).toHaveLength(1);
expect(childLog[0].removed).toEqual([{ source: a, target: b }]);
expect(targetLog).toHaveLength(1);
expect(targetLog[0].removed).toEqual([{ source: a, target: c }]);
});
});
// ── Multiple relationship types ──────────────────────
describe("Multiple relationships", () => {
let world: World;
beforeEach(() => {
world = new World();
});
it("an entity can have different relationship types simultaneously", () => {
const e = world.spawn();
const a = world.spawn();
const b = world.spawn();
world.relate(e, ChildOf, a);
world.relate(e, Targeting, b);
expect(world.getRelated(e, ChildOf)).toBe(a);
expect(world.getRelated(e, Targeting)).toBe(b);
});
it("relationships of different types don't interfere", () => {
const a = world.spawn();
const b = world.spawn();
const c = world.spawn();
world.relate(a, ChildOf, b);
world.relate(a, Targeting, c);
world.unrelate(a, ChildOf);
expect(world.getRelated(a, ChildOf)).toBeUndefined();
// Targeting should still be intact
expect(world.getRelated(a, Targeting)).toBe(c);
});
});
// ── Dead entities ─────────────────────────────────────
describe("Dead entity safety", () => {
let world: World;
beforeEach(() => {
world = new World();
});
it("relate throws on dead source", () => {
const a = world.spawn();
const b = world.spawn();
world.destroy(a);
expect(() => world.relate(a, ChildOf, b)).toThrow("not alive");
});
it("relate throws on dead target", () => {
const a = world.spawn();
const b = world.spawn();
world.destroy(b);
expect(() => world.relate(a, ChildOf, b)).toThrow("not alive");
});
it("getRelated returns undefined for dead entity", () => {
const e = world.spawn();
world.destroy(e);
expect(world.getRelated(e, ChildOf)).toBeUndefined();
});
it("getRelatedTo returns empty for dead entity", () => {
const e = world.spawn();
world.destroy(e);
expect([...world.getRelatedTo(e, ChildOf)]).toEqual([]);
});
});
// ── Data-carrying relationships ───────────────────────
describe("Data-carrying relationships", () => {
let world: World;
beforeEach(() => {
world = new World();
});
it("defines a data-carrying relationship", () => {
const Health = defineRelationship("health", { hp: 100, maxHp: 100 });
expect(Health.name).toBe("health");
expect(Health.defaults).toEqual({ hp: 100, maxHp: 100 });
});
it("relate stores defaults as data", () => {
const Health = defineRelationship("health", { hp: 100 });
const player = world.spawn();
const game = world.spawn();
world.relate(player, Health, game);
const data = world.getRelData(player, Health);
expect(data).toEqual({ hp: 100 });
});
it("relate accepts data override", () => {
const Health = defineRelationship("health", { hp: 100 });
const player = world.spawn();
const game = world.spawn();
world.relate(player, Health, game, { hp: 50 });
const data = world.getRelData(player, Health);
expect(data).toEqual({ hp: 50 });
});
it("setRelData updates relationship data", () => {
const Health = defineRelationship("health", { hp: 100 });
const player = world.spawn();
const game = world.spawn();
world.relate(player, Health, game);
world.setRelData(player, Health, { hp: 75 });
expect(world.getRelData(player, Health)).toEqual({ hp: 75 });
});
it("getRelData returns defaults when no data was set", () => {
const Health = defineRelationship("health", { hp: 100 });
const player = world.spawn();
// Even without an edge, getRelData returns a copy of defaults
expect(world.getRelData(player, Health)).toEqual({ hp: 100 });
});
it("setRelData works even without prior relate", () => {
const Health = defineRelationship("health", { hp: 100 });
const player = world.spawn();
world.setRelData(player, Health, { hp: 50 });
expect(world.getRelData(player, Health)).toEqual({ hp: 50 });
});
it("data survives unrelate and re-relate", () => {
const Health = defineRelationship("health", { hp: 100 });
const player = world.spawn();
const game = world.spawn();
world.relate(player, Health, game, { hp: 50 });
world.unrelate(player, Health);
// After unrelate, stored data is gone, returns defaults
expect(world.getRelData(player, Health)).toEqual({ hp: 100 });
world.relate(player, Health, game, { hp: 80 });
// Note: this is a *new* relate with data, so stored data is { hp: 80 }
expect(world.getRelData(player, Health)).toEqual({ hp: 80 });
});
it("data is cleaned up on destroy", () => {
const Health = defineRelationship("health", { hp: 100 });
const player = world.spawn();
const game = world.spawn();
world.relate(player, Health, game, { hp: 50 });
world.destroy(player);
// After destroy the entity is dead — assertAlive throws, not the data lookup
expect(() => world.getRelData(player, Health)).toThrow("not alive");
});
it("setRelData with no prior edge stores data that getsRelated does not see", () => {
const Health = defineRelationship("health", { hp: 100 });
const player = world.spawn();
world.setRelData(player, Health, { hp: 50 });
// No edge exists yet
expect(world.getRelated(player, Health)).toBeUndefined();
// But data is stored (decoupled storage)
expect(world.getRelData(player, Health)).toEqual({ hp: 50 });
});
it("data-carrying relationships serialize and deserialize", () => {
const Health = defineRelationship("health", { hp: 100, maxHp: 100 });
const player = world.spawn();
const game = world.spawn();
world.relate(player, Health, game, { hp: 50, maxHp: 100 });
const snapshot = world.toJSON();
// Verify the snapshot has data in the relationship structure
const relSection = snapshot.relationships["health"];
expect(relSection).toBeDefined();
// Find the player source ID — it's the entity without components
const playerId = Object.keys(snapshot.entities).find(
(id) => Object.keys(snapshot.entities[id]).length === 0,
)!;
const edgeValue = relSection[playerId];
expect(typeof edgeValue).toBe("object");
expect((edgeValue as any).target).toBeDefined();
expect((edgeValue as any).data).toEqual({ hp: 50, maxHp: 100 });
// Check JSON round-trip preserves data
const parsed = JSON.parse(JSON.stringify(snapshot));
const world2 = World.fromJSON(parsed, [], [Health]);
const snap2 = world2.toJSON();
const playerId2 = Object.keys(snap2.entities).find(
(id) => Object.keys(snap2.entities[id]).length === 0,
)!;
expect(snap2.relationships["health"]).toBeDefined();
const edgeValue2 = snap2.relationships["health"][playerId2];
expect((edgeValue2 as any).data).toEqual({ hp: 50, maxHp: 100 });
});
it("pure edge-defined relationships still work alongside data relationships", () => {
const ChildOf2 = defineRelationship("childOf2");
const Score = defineRelationship("score", { points: 0 });
const parent = world.spawn();
const child = world.spawn();
const game = world.spawn();
world.relate(child, ChildOf2, parent);
world.relate(child, Score, game, { points: 42 });
// Pure edge still works
expect(world.getRelated(child, ChildOf2)).toBe(parent);
// Data edge still works
expect(world.getRelData(child, Score)).toEqual({ points: 42 });
});
});