162 lines
5.0 KiB
TypeScript
162 lines
5.0 KiB
TypeScript
import { describe, it, expect, beforeEach } from "vitest";
|
|
import {
|
|
World,
|
|
defineComponent,
|
|
defineRelationship,
|
|
type WorldSnapshot,
|
|
query,
|
|
} 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");
|
|
|
|
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 json = JSON.stringify(world.toJSON());
|
|
const data = JSON.parse(json) as WorldSnapshot;
|
|
const loaded = World.fromJSON(data, [Position, Velocity]);
|
|
|
|
// Find entity via query
|
|
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();
|
|
// Bare entities still appear
|
|
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 json = JSON.stringify(world.toJSON());
|
|
const data = JSON.parse(json) as WorldSnapshot;
|
|
const loaded = World.fromJSON(data, [Position], [ChildOf, ParentOf]);
|
|
|
|
// Verify via the re-serialized snapshot
|
|
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 json = JSON.stringify(world.toJSON());
|
|
const loaded = World.fromJSON(JSON.parse(json), [Position]);
|
|
|
|
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();
|
|
const b = world.spawn();
|
|
const c = world.spawn();
|
|
world.destroy(b); // creates a hole in the original world
|
|
|
|
const snap = world.toJSON();
|
|
const ids = Object.keys(snap.entities).sort();
|
|
// Serialized as e0 and e1 (hole is collapsed)
|
|
expect(ids).toHaveLength(2);
|
|
});
|
|
|
|
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 raw = JSON.stringify(world.toJSON());
|
|
const parsed = JSON.parse(raw);
|
|
|
|
const fresh = World.fromJSON(parsed, [Position, Health]);
|
|
const reSnap = fresh.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({});
|
|
});
|
|
});
|