import { describe, it, expect, beforeEach } from "vitest"; import { World, defineComponent, defineRelationship, type WorldSnapshot, query, type RelationshipUpdate, } from "../src/index"; // ── Definitions ───────────────────────────────────── const Position = defineComponent("position", { x: 0, y: 0 }); const Velocity = defineComponent("velocity", { vx: 0, vy: 0 }); const Health = defineComponent("health", { current: 100, max: 100 }); const Shield = defineComponent("shield", { armor: 5, broken: false }); const Name = defineComponent("name", { value: "" }); const Team = defineComponent("team", { id: 0, color: "#fff" }); const ChildOf = defineRelationship("childOf"); const Targeting = defineRelationship("targeting"); const OwnedBy = defineRelationship("ownedBy"); // ── Serialization helpers ──────────────────────────── function roundTrip( world: World, components = [Position, Velocity, Health, Shield, Name, Team], rels = [ChildOf, Targeting, OwnedBy], ): World { const json = JSON.stringify(world.toJSON()); return World.fromJSON(JSON.parse(json), components, rels); } function sortedIds(snap: WorldSnapshot): string[] { return Object.keys(snap.entities).sort(); } // ── Tests ──────────────────────────────────────────── describe("Serialization", () => { let world: World; beforeEach(() => { world = new World(); }); it("serializes components by name", () => { const e = world.spawn(); world.add(e, Position, { x: 10, y: 20 }); world.add(e, Velocity, { vx: 1, vy: 0 }); const snap = world.toJSON(); expect(snap.entities).toHaveProperty("e0"); expect(snap.entities.e0.position).toEqual({ x: 10, y: 20 }); expect(snap.entities.e0.velocity).toEqual({ vx: 1, vy: 0 }); }); it("round-trips components through JSON", () => { const e = world.spawn(); world.add(e, Position, { x: 42, y: 99 }); world.add(e, Velocity, { vx: 2, vy: -1 }); const loaded = roundTrip(world); const loadedEnts = [...loaded.query(query(Position, Velocity))]; expect(loadedEnts).toHaveLength(1); const loadedE = loadedEnts[0]; expect(loaded.get(loadedE, Position)).toEqual({ x: 42, y: 99 }); expect(loaded.get(loadedE, Velocity)).toEqual({ vx: 2, vy: -1 }); }); it("handles multiple entities", () => { world.spawn(); world.spawn(); const snap = world.toJSON(); const entries = Object.entries(snap.entities); expect(entries).toHaveLength(2); }); it("serializes relationships", () => { const parent = world.spawn(); const child = world.spawn(); world.relate(child, ChildOf, parent); const snap = world.toJSON(); expect(snap.relationships.childOf).toHaveProperty("e1"); expect(snap.relationships.childOf.e1).toBe("e0"); }); it("round-trips relationships", () => { const parent = world.spawn(); const child = world.spawn(); world.relate(child, ChildOf, parent); const loaded = roundTrip(world); const reSnap = loaded.toJSON(); expect(Object.keys(reSnap.relationships.childOf)).toHaveLength(1); }); it("throws on unknown component in snapshot", () => { const snap: WorldSnapshot = { entities: { e0: { unknownComp: { x: 1 } } }, relationships: {}, }; expect(() => World.fromJSON(snap, [Position])).toThrow( 'Unknown component "unknownComp"', ); }); it("throws on unknown relationship in snapshot", () => { const snap: WorldSnapshot = { entities: { e0: { position: { x: 0, y: 0 } } }, relationships: { unknownRel: { e0: "e1" } }, }; expect(() => World.fromJSON(snap, [Position], [ChildOf])).toThrow( 'Unknown relationship "unknownRel"', ); }); it("preserves entities with no components", () => { world.spawn(); const snap = world.toJSON(); expect(snap.entities).toHaveProperty("e0"); expect(snap.entities.e0).toEqual({}); }); it("preserves bare entities on round-trip", () => { world.spawn(); const loaded = roundTrip(world); expect(loaded.entityCount).toBe(1); const withPos = [...loaded.query(query(Position))]; expect(withPos).toHaveLength(0); expect(loaded.entityCount).toBe(1); }); it("entity IDs are re-sequential (hole collapsed)", () => { world.spawn(); // e0 const b = world.spawn(); world.spawn(); // e2 world.destroy(b); // hole at e1 const snap = world.toJSON(); // Holes are collapsed during serialization expect(sortedIds(snap)).toEqual(["e0", "e1"]); }); it("round-trips JSON stringify and parse", () => { const a = world.spawn(); world.add(a, Position, { x: 10, y: 20 }); world.add(a, Health, { current: 75, max: 100 }); const loaded = roundTrip(world); const reSnap = loaded.toJSON(); expect(reSnap.entities.e0.position).toEqual({ x: 10, y: 20 }); expect(reSnap.entities.e0.health).toEqual({ current: 75, max: 100 }); }); it("empty world serializes to empty snapshot", () => { const snap = world.toJSON(); expect(snap.entities).toEqual({}); expect(snap.relationships).toEqual({}); }); }); // ── Extended: rich mixed state ─────────────────────── describe("Serialization — complex state", () => { let world: World; function setupRichWorld() { const w = new World(); // Player with many components const player = w.spawn(); w.add(player, Position, { x: 100, y: 200 }); w.add(player, Velocity, { vx: 0, vy: 0 }); w.add(player, Health, { current: 85, max: 100 }); w.add(player, Shield, { armor: 20, broken: false }); w.add(player, Name, { value: "Hero" }); w.add(player, Team, { id: 1, color: "#ff0000" }); // Enemy const enemy = w.spawn(); w.add(enemy, Position, { x: 500, y: 300 }); w.add(enemy, Health, { current: 50, max: 50 }); w.add(enemy, Team, { id: 2, color: "#0000ff" }); // Bullet (few components) const bullet = w.spawn(); w.add(bullet, Position, { x: 100, y: 200 }); w.add(bullet, Velocity, { vx: 10, vy: 0 }); // Bare entity w.spawn(); // Relationships w.relate(bullet, OwnedBy, player); w.relate(enemy, Targeting, player); return { w, player, enemy, bullet }; } it("round-trips a rich world", () => { const { w, player, enemy, bullet } = setupRichWorld(); const loaded = roundTrip(w); expect(loaded.entityCount).toBe(4); // Find player by Name component const players = [...loaded.query(query(Name))]; expect(players).toHaveLength(1); const p = players[0]; expect(loaded.get(p, Position)).toEqual({ x: 100, y: 200 }); expect(loaded.get(p, Velocity)).toEqual({ vx: 0, vy: 0 }); expect(loaded.get(p, Health)).toEqual({ current: 85, max: 100 }); expect(loaded.get(p, Shield)).toEqual({ armor: 20, broken: false }); expect(loaded.get(p, Name)).toEqual({ value: "Hero" }); expect(loaded.get(p, Team)).toEqual({ id: 1, color: "#ff0000" }); // Find enemy const enemies = [...loaded.query(query(Health, Team))].filter( (e) => !loaded.has(e, Name), ); expect(enemies).toHaveLength(1); const en = enemies[0]; expect(loaded.get(en, Health)).toEqual({ current: 50, max: 50 }); // Bullets const bullets = [...loaded.query(query(Position, Velocity))].filter( (e) => !loaded.has(e, Health) && !loaded.has(e, Name) && !loaded.has(e, Shield), ); expect(bullets).toHaveLength(1); }); it("preserves relationships in rich world", () => { const { w } = setupRichWorld(); const loaded = roundTrip(w); const snap = loaded.toJSON(); // Verify relationship structure exists expect(snap.relationships).toHaveProperty("ownedBy"); expect(snap.relationships).toHaveProperty("targeting"); const ownedByEdges = snap.relationships.ownedBy; const targetingEdges = snap.relationships.targeting; // OwnedBy: exactly one edge expect(Object.keys(ownedByEdges)).toHaveLength(1); // Targeting: exactly one edge expect(Object.keys(targetingEdges)).toHaveLength(1); }); it("relationships reference correct entities after round-trip", () => { const { w } = setupRichWorld(); const snap = w.toJSON(); // Let's trace: bullet has Position+Velocity, is OwnedBy // Find bullet's string id const bulletId = Object.keys(snap.entities).find((id) => { const comps = snap.entities[id]; return comps.position && comps.velocity && !comps.health; })!; const playerId = snap.relationships.ownedBy[bulletId]; // Player should have a name expect(snap.entities[playerId]).toHaveProperty("name"); expect((snap.entities[playerId].name as any).value).toBe("Hero"); }); }); // ── Extended: multiple relationship types ──────────── describe("Serialization — multiple relationships", () => { it("round-trips multiple relationship types on same entity", () => { const w = new World(); const a = w.spawn(); const b = w.spawn(); const c = w.spawn(); w.relate(a, ChildOf, b); w.relate(a, Targeting, c); const loaded = roundTrip(w); const snap = loaded.toJSON(); expect(Object.keys(snap.relationships.childOf)).toHaveLength(1); expect(Object.keys(snap.relationships.targeting)).toHaveLength(1); }); it("round-trips many-to-one relationships", () => { const w = new World(); const parent = w.spawn(); const c1 = w.spawn(); const c2 = w.spawn(); const c3 = w.spawn(); w.relate(c1, ChildOf, parent); w.relate(c2, ChildOf, parent); w.relate(c3, ChildOf, parent); const loaded = roundTrip(w); const snap = loaded.toJSON(); const edges = snap.relationships.childOf; const children = Object.keys(edges); expect(children).toHaveLength(3); // All three should point to the same parent const parentId = edges[children[0]]; expect(edges[children[1]]).toBe(parentId); expect(edges[children[2]]).toBe(parentId); }); it("round-trips replaced relationships correctly", () => { const w = new World(); const a = w.spawn(); const b = w.spawn(); const c = w.spawn(); w.relate(a, ChildOf, b); w.relate(a, ChildOf, c); // replace b → c const loaded = roundTrip(w); const snap = loaded.toJSON(); const edges = snap.relationships.childOf; expect(Object.keys(edges)).toHaveLength(1); // Child pointer resolves forward const aId = Object.keys(snap.entities).find( (id) => !snap.relationships.childOf[id] && !Object.values(snap.relationships.childOf).includes(id), ); // Actually, find a via exclusion: a has no components, b and c are targets. // Let's just check the edge count is right. expect(Object.keys(edges)).toHaveLength(1); }); }); // ── Extended: nested / array data ──────────────────── describe("Serialization — nested data", () => { const Inventory = defineComponent("inventory", { items: [] as string[], gold: 0, }); const Transform = defineComponent("transform", { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, }); it("round-trips components with array values", () => { const w = new World(); const e = w.spawn(); w.add(e, Inventory, { items: ["sword", "shield", "potion"], gold: 42 }); const loaded = roundTrip(w, [Inventory]); const loadedE = [...loaded.query(query(Inventory))][0]; const inv = loaded.get(loadedE, Inventory); expect(inv.items).toEqual(["sword", "shield", "potion"]); expect(inv.gold).toBe(42); }); it("round-trips components with nested object values", () => { const w = new World(); const e = w.spawn(); w.add(e, Transform, { position: { x: 10, y: 20 }, scale: { x: 2, y: 2 }, }); const loaded = roundTrip(w, [Transform]); const loadedE = [...loaded.query(query(Transform))][0]; const t = loaded.get(loadedE, Transform); expect(t.position).toEqual({ x: 10, y: 20 }); expect(t.scale).toEqual({ x: 2, y: 2 }); }); it("empty arrays survive round-trip", () => { const w = new World(); const e = w.spawn(); w.add(e, Inventory, { items: [], gold: 0 }); const loaded = roundTrip(w, [Inventory]); const loadedE = [...loaded.query(query(Inventory))][0]; expect(loaded.get(loadedE, Inventory).items).toEqual([]); }); }); // ── Extended: observables still work after load ────── describe("Serialization — observables after load", () => { it("loaded world emits events on mutation", () => { const w = new World(); w.spawn(); const loaded = roundTrip(w); const events: any[] = []; loaded.events$.subscribe((e: any) => events.push(e)); const e = loaded.spawn(); expect(events).toHaveLength(1); expect(events[0].type).toBe("spawned"); }); it("loaded world query observables work", () => { const w = new World(); const e = w.spawn(); w.add(e, Position, { x: 1, y: 2 }); const loaded = roundTrip(w); const updates: any[] = []; loaded.observe(query(Position)).subscribe((u: any) => { if (u.added.length || u.removed.length || u.changed.length) { updates.push(u); } }); // Add a new Position entity const e2 = loaded.spawn(); loaded.add(e2, Position, { x: 3, y: 4 }); expect(updates).toHaveLength(1); expect(updates[0].added).toHaveLength(1); }); it("loaded world relationship observables work", () => { const w = new World(); const a = w.spawn(); const b = w.spawn(); w.relate(a, ChildOf, b); const loaded = roundTrip(w); const relUpdates: RelationshipUpdate[] = []; loaded.observeRelated(ChildOf).subscribe((u) => relUpdates.push(u)); const c = loaded.spawn(); const d = loaded.spawn(); loaded.relate(c, ChildOf, d); expect(relUpdates).toHaveLength(1); expect(relUpdates[0].added).toHaveLength(1); }); }); // ── Extended: stress ───────────────────────────────── describe("Serialization — stress", () => { it("round-trips 500 entities with mixed components", () => { const w = new World(); for (let i = 0; i < 500; i++) { const e = w.spawn(); w.add(e, Position, { x: i, y: i * 2 }); if (i % 2 === 0) { w.add(e, Velocity, { vx: 1, vy: 0 }); } if (i % 3 === 0) { w.add(e, Health, { current: i, max: 1000 }); } if (i % 5 === 0) { w.add(e, Name, { value: `entity_${i}` }); } } // Add some relationships const all = [...w.query(query(Position))]; for (let i = 0; i < 100; i++) { const src = all[i * 2]; const tgt = all[i * 2 + 1]; if (src && tgt) { w.relate(src, ChildOf, tgt); } } const loaded = roundTrip(w); expect(loaded.entityCount).toBe(500); const withPos = [...loaded.query(query(Position))]; const withVel = [...loaded.query(query(Velocity))]; const withHealth = [...loaded.query(query(Health))]; expect(withPos).toHaveLength(500); expect(withVel).toHaveLength(250); // every 2nd expect(withHealth).toHaveLength(167); // every 3rd ≈ floor(499/3)+1 // Verify a few random entities const e0 = withPos[0]; expect(loaded.get(e0, Position).x).toBe(0); }); it("repeated round-trips are idempotent", () => { const w = new World(); const e = w.spawn(); w.add(e, Position, { x: 10, y: 20 }); w.add(e, Health, { current: 75, max: 100 }); const loaded1 = roundTrip(w); const loaded2 = roundTrip(loaded1); expect(loaded2.entityCount).toBe(loaded1.entityCount); const e2 = [...loaded2.query(query(Position))][0]; expect(loaded2.get(e2, Position)).toEqual({ x: 10, y: 20 }); expect(loaded2.get(e2, Health)).toEqual({ current: 75, max: 100 }); }); });