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 }); }); });