ecs-observable/test/serialization.test.ts

517 lines
16 KiB
TypeScript

import { describe, it, expect, beforeEach } from "vitest";
import {
World,
defineComponent,
defineRelationship,
type WorldSnapshot,
query,
type RelationshipUpdate,
type WorldEvent,
} 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
const playerComps = snap.entities[playerId];
expect(playerComps).toHaveProperty("name");
expect((playerComps.name as { value: string }).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: WorldEvent[] = [];
loaded.events$.subscribe((e) => 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: QueryUpdate[] = [];
loaded.observe(query(Position)).subscribe((u) => {
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 });
});
});