ecs-observable/test/world.test.ts

362 lines
10 KiB
TypeScript

import { describe, it, expect, beforeEach } from "vitest";
import {
World,
defineComponent,
query,
type QueryUpdate,
type WorldEvent,
type Entity,
} from "../src/index";
// ── Components ──────────────────────────────────────
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 Dead = defineComponent("dead", { 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: Entity;
beforeEach(() => {
world = new World();
entity = world.spawn() as Entity;
});
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("shield", { 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");
});
});