import { describe, it, expect, beforeEach } from "vitest"; import { World, defineRelationship, type RelationshipUpdate, type WorldEvent, } from "../src/index"; // ── Relationships ───────────────────────────────────── const ChildOf = defineRelationship(); const Targeting = defineRelationship(); const Inside = defineRelationship(); // ── 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([]); }); });