import { describe, it, expect, beforeEach } from "vitest"; import { World, defineComponent, query, type QueryUpdate, type WorldEvent, } from "../src/index"; // ── Components ────────────────────────────────────── const Position = defineComponent({ x: 0, y: 0 }); const Velocity = defineComponent({ vx: 0, vy: 0 }); const Health = defineComponent({ current: 100, max: 100 }); const Dead = defineComponent({ timestamp: 0 }); // ── Helpers ──────────────────────────────────────── function collectUpdates(obs$: { subscribe: Function }): QueryUpdate[] { const log: QueryUpdate[] = []; obs$.subscribe((u: QueryUpdate) => { if (u.added.length || u.removed.length || u.changed.length) log.push(u); }); return log; } function collectEvents(world: World): WorldEvent[] { const log: WorldEvent[] = []; world.events$.subscribe((e: WorldEvent) => log.push(e)); return log; } // ── Entity lifecycle ─────────────────────────────── describe("Entity lifecycle", () => { let world: World; beforeEach(() => { world = new World(); }); it("spawns entities", () => { const e = world.spawn(); expect(world.isAlive(e)).toBe(true); expect(world.entityCount).toBe(1); }); it("emits spawn event", () => { const events = collectEvents(world); const e = world.spawn(); expect(events).toHaveLength(1); expect(events[0]).toMatchObject({ type: "spawned", entity: e }); }); it("destroys entities", () => { const e = world.spawn(); world.destroy(e); expect(world.isAlive(e)).toBe(false); expect(world.entityCount).toBe(0); }); it("emits destroy event", () => { const events = collectEvents(world); const e = world.spawn(); world.destroy(e); expect(events).toHaveLength(2); expect(events[1]).toMatchObject({ type: "destroyed", entity: e }); }); it("recycles entity indices with generation bump", () => { const a = world.spawn(); world.destroy(a); const b = world.spawn(); expect(b).not.toBe(a); expect(world.isAlive(b)).toBe(true); expect(world.isAlive(a)).toBe(false); }); it("throws on operations with dead entity", () => { const e = world.spawn(); world.destroy(e); expect(() => world.get(e, Position)).toThrow("not alive"); expect(() => world.has(e, Position)).not.toThrow(); // has() is safe }); }); // ── Components ────────────────────────────────────── describe("Components", () => { let world: World; let entity = 0 as any; beforeEach(() => { world = new World(); entity = world.spawn(); }); it("add returns defaults", () => { const pos = world.add(entity, Position); expect(pos).toEqual({ x: 0, y: 0 }); }); it("add overrides defaults with init", () => { const pos = world.add(entity, Position, { x: 10, y: 20 }); expect(pos.x).toBe(10); expect(pos.y).toBe(20); }); it("get returns the live mutable object", () => { world.add(entity, Position, { x: 5 }); const pos = world.get(entity, Position); pos.x = 99; expect(world.get(entity, Position).x).toBe(99); }); it("tryGet returns undefined when absent", () => { expect(world.tryGet(entity, Position)).toBeUndefined(); world.add(entity, Position); expect(world.tryGet(entity, Position)).toEqual({ x: 0, y: 0 }); }); it("has checks component presence", () => { expect(world.has(entity, Position)).toBe(false); world.add(entity, Position); expect(world.has(entity, Position)).toBe(true); }); it("remove removes the component", () => { world.add(entity, Position); world.remove(entity, Position); expect(world.has(entity, Position)).toBe(false); }); it("remove is idempotent", () => { expect(() => world.remove(entity, Position)).not.toThrow(); }); it("set replaces and marks dirty", () => { world.add(entity, Position); world.set(entity, Position, { x: 42, y: 99 }); expect(world.get(entity, Position)).toEqual({ x: 42, y: 99 }); }); it("set throws if component not added first", () => { expect(() => world.set(entity, Position, { x: 1, y: 2 })).toThrow( "Use add()", ); }); it("emits componentAdded event", () => { const events = collectEvents(world); world.add(entity, Position); expect(events.find((e) => e.type === "componentAdded")).toMatchObject({ type: "componentAdded", entity, }); }); it("emits componentRemoved event", () => { world.add(entity, Position); const events = collectEvents(world); world.remove(entity, Position); expect(events.find((e) => e.type === "componentRemoved")).toMatchObject({ type: "componentRemoved", entity, }); }); }); // ── Queries ───────────────────────────────────────── describe("Sync queries", () => { let world: World; beforeEach(() => { world = new World(); }); it("returns matching entities", () => { const a = world.spawn(); world.add(a, Position); world.add(a, Velocity); const b = world.spawn(); world.add(b, Position); const result = [...world.query(query(Position, Velocity))]; expect(result).toEqual([a]); }); it("returns empty when no match", () => { const e = world.spawn(); world.add(e, Position); const result = [...world.query(query(Position, Velocity))]; expect(result).toHaveLength(0); }); it("excludes with .without()", () => { const a = world.spawn(); world.add(a, Health); world.add(a, Dead); const b = world.spawn(); world.add(b, Health); const result = [...world.query(query(Health).without(Dead))]; expect(result).toEqual([b]); }); }); // ── Observable queries ────────────────────────────── describe("Observable queries", () => { let world: World; beforeEach(() => { world = new World(); }); it("emits added when an entity later matches", () => { const log = collectUpdates(world.observe(query(Position))); const e = world.spawn(); world.add(e, Position); expect(log).toHaveLength(1); expect(log[0].added).toEqual([e]); expect(log[0].removed).toEqual([]); }); it("emits removed when an entity stops matching", () => { const e = world.spawn(); world.add(e, Position); const log = collectUpdates(world.observe(query(Position))); world.remove(e, Position); expect(log).toHaveLength(1); expect(log[0].removed).toEqual([e]); }); it("emits removed on entity destroy", () => { const e = world.spawn(); world.add(e, Position); const log = collectUpdates(world.observe(query(Position))); world.destroy(e); expect(log).toHaveLength(1); expect(log[0].removed).toEqual([e]); }); it("emits changed on matching entities after flush", () => { const e = world.spawn(); world.add(e, Position); const log = collectUpdates(world.observe(query(Position))); world.get(e, Position).x += 1; world.markDirty(e, Position); world.flush(); expect(log).toHaveLength(1); expect(log[0].changed).toEqual([e]); }); it("seeds with currently matching entities on subscribe", () => { const e = world.spawn(); world.add(e, Position); // Next subscription should know e already matches const log = collectUpdates(world.observe(query(Position))); // Remove to trigger an event world.remove(e, Position); expect(log).toHaveLength(1); expect(log[0].removed).toEqual([e]); }); it("handles .without() queries", () => { const e = world.spawn(); world.add(e, Health); const log = collectUpdates(world.observe(query(Health).without(Dead))); world.add(e, Dead); expect(log).toHaveLength(1); expect(log[0].removed).toEqual([e]); }); }); // ── Change tracking ───────────────────────────────── describe("Change tracking", () => { let world: World; beforeEach(() => { world = new World(); }); it("emits componentChanged on flush", () => { const e = world.spawn(); world.add(e, Position); const events = collectEvents(world); world.get(e, Position).x = 42; world.markDirty(e, Position); world.flush(); expect(events.some((ev) => ev.type === "componentChanged")).toBe(true); }); it("batches multiple dirty marks into one flush", () => { const a = world.spawn(); const b = world.spawn(); world.add(a, Position); world.add(b, Position); let changeCount = 0; world.observe(query(Position)).subscribe((u) => { changeCount += u.changed.length; }); world.markDirty(a, Position); world.markDirty(b, Position); world.flush(); expect(changeCount).toBe(2); }); it("set() implicitly marks dirty", () => { const e = world.spawn(); world.add(e, Position); const log = collectUpdates(world.observe(query(Position))); world.set(e, Position, { x: 1, y: 2 }); world.flush(); expect(log).toHaveLength(1); expect(log[0].changed).toEqual([e]); }); it("clears dirty after flush", () => { const e = world.spawn(); world.add(e, Position); let changeCount = 0; world.observe(query(Position)).subscribe((u) => { changeCount += u.changed.length; }); world.markDirty(e, Position); world.flush(); expect(changeCount).toBe(1); world.flush(); expect(changeCount).toBe(1); // no new emissions }); }); // ── TypeScript inference ──────────────────────────── describe("Type safety", () => { it("infers component type from defaults", () => { const Shield = defineComponent({ armor: 5, broken: false }); const s = Shield.defaults; // compile-time check: these should be the inferred types const _armor: number = s.armor; const _broken: boolean = s.broken; expect(typeof _armor).toBe("number"); expect(typeof _broken).toBe("boolean"); }); });