diff --git a/test/serialization.test.ts b/test/serialization.test.ts index 8a578de..fcaa496 100644 --- a/test/serialization.test.ts +++ b/test/serialization.test.ts @@ -5,15 +5,36 @@ import { 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 ChildOf = defineRelationship("childOf"); -const ParentOf = defineRelationship("parentOf"); +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; @@ -37,11 +58,8 @@ describe("Serialization", () => { world.add(e, Position, { x: 42, y: 99 }); world.add(e, Velocity, { vx: 2, vy: -1 }); - const json = JSON.stringify(world.toJSON()); - const data = JSON.parse(json) as WorldSnapshot; - const loaded = World.fromJSON(data, [Position, Velocity]); + const loaded = roundTrip(world); - // Find entity via query const loadedEnts = [...loaded.query(query(Position, Velocity))]; expect(loadedEnts).toHaveLength(1); @@ -55,7 +73,6 @@ describe("Serialization", () => { world.spawn(); const snap = world.toJSON(); - // Bare entities still appear const entries = Object.entries(snap.entities); expect(entries).toHaveLength(2); }); @@ -75,11 +92,8 @@ describe("Serialization", () => { const child = world.spawn(); world.relate(child, ChildOf, parent); - const json = JSON.stringify(world.toJSON()); - const data = JSON.parse(json) as WorldSnapshot; - const loaded = World.fromJSON(data, [Position], [ChildOf, ParentOf]); + const loaded = roundTrip(world); - // Verify via the re-serialized snapshot const reSnap = loaded.toJSON(); expect(Object.keys(reSnap.relationships.childOf)).toHaveLength(1); }); @@ -113,29 +127,23 @@ describe("Serialization", () => { it("preserves bare entities on round-trip", () => { world.spawn(); - - const json = JSON.stringify(world.toJSON()); - const loaded = World.fromJSON(JSON.parse(json), [Position]); - + const loaded = roundTrip(world); expect(loaded.entityCount).toBe(1); - // Entity has no Position, so query returns empty const withPos = [...loaded.query(query(Position))]; expect(withPos).toHaveLength(0); - // But it exists expect(loaded.entityCount).toBe(1); }); - it("entity IDs are re-sequential (no hole preservation)", () => { - const a = world.spawn(); + it("entity IDs are re-sequential (hole collapsed)", () => { + world.spawn(); // e0 const b = world.spawn(); - const c = world.spawn(); - world.destroy(b); // creates a hole in the original world + world.spawn(); // e2 + world.destroy(b); // hole at e1 const snap = world.toJSON(); - const ids = Object.keys(snap.entities).sort(); - // Serialized as e0 and e1 (hole is collapsed) - expect(ids).toHaveLength(2); + // Holes are collapsed during serialization + expect(sortedIds(snap)).toEqual(["e0", "e1"]); }); it("round-trips JSON stringify and parse", () => { @@ -143,11 +151,8 @@ describe("Serialization", () => { world.add(a, Position, { x: 10, y: 20 }); world.add(a, Health, { current: 75, max: 100 }); - const raw = JSON.stringify(world.toJSON()); - const parsed = JSON.parse(raw); - - const fresh = World.fromJSON(parsed, [Position, Health]); - const reSnap = fresh.toJSON(); + 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 }); @@ -159,3 +164,351 @@ describe("Serialization", () => { 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 }); + }); +});