test: add vitest and implement unit tests
This commit is contained in:
parent
4ede2d7f3b
commit
ba4a688f57
File diff suppressed because it is too large
Load Diff
|
|
@ -19,6 +19,7 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsup",
|
"build": "tsup",
|
||||||
"dev": "tsup --watch",
|
"dev": "tsup --watch",
|
||||||
|
"test": "vitest run",
|
||||||
"prepublishOnly": "npm run build"
|
"prepublishOnly": "npm run build"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
|
@ -27,7 +28,8 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"tsup": "^8.3.5",
|
"tsup": "^8.3.5",
|
||||||
"typescript": "^5.6.0"
|
"typescript": "^5.6.0",
|
||||||
|
"vitest": "^4.1.7"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"ecs",
|
"ecs",
|
||||||
|
|
|
||||||
132
test/smoke.ts
132
test/smoke.ts
|
|
@ -1,132 +0,0 @@
|
||||||
import {
|
|
||||||
World,
|
|
||||||
defineComponent,
|
|
||||||
query,
|
|
||||||
QueryUpdate,
|
|
||||||
WorldEvent,
|
|
||||||
} from "../src/index";
|
|
||||||
|
|
||||||
// ── Define 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 });
|
|
||||||
|
|
||||||
// Type inference check
|
|
||||||
const _p: { x: number; y: number } = Position.defaults;
|
|
||||||
|
|
||||||
// ── World setup ──────────────────────────────────────
|
|
||||||
const world = new World();
|
|
||||||
|
|
||||||
let events: WorldEvent[] = [];
|
|
||||||
world.events$.subscribe((e) => events.push(e));
|
|
||||||
|
|
||||||
// ── Entity lifecycle ─────────────────────────────────
|
|
||||||
const player = world.spawn();
|
|
||||||
const enemy = world.spawn();
|
|
||||||
|
|
||||||
console.assert(events.length === 2, "spawn events");
|
|
||||||
console.assert(events[0].type === "spawned" && events[0].entity === player);
|
|
||||||
console.assert(events[1].type === "spawned" && events[1].entity === enemy);
|
|
||||||
|
|
||||||
console.assert(world.isAlive(player), "player alive");
|
|
||||||
console.assert(world.isAlive(enemy), "enemy alive");
|
|
||||||
console.assert(world.entityCount === 2, "two entities");
|
|
||||||
|
|
||||||
// ── Add components ───────────────────────────────────
|
|
||||||
const pos = world.add(player, Position, { x: 10, y: 20 });
|
|
||||||
world.add(player, Velocity, { vx: 1, vy: 0 });
|
|
||||||
world.add(enemy, Position, { x: 50, y: 0 });
|
|
||||||
world.add(enemy, Health, { current: 50 });
|
|
||||||
|
|
||||||
console.assert(pos.x === 10 && pos.y === 20, "add with init");
|
|
||||||
console.assert(world.has(player, Position), "has Position");
|
|
||||||
console.assert(world.has(player, Velocity), "has Velocity");
|
|
||||||
console.assert(!world.has(player, Health), "no Health");
|
|
||||||
console.assert(events.length === 6, "component add events");
|
|
||||||
|
|
||||||
// ── Sync query ───────────────────────────────────────
|
|
||||||
const movable = [...world.query(query(Position, Velocity))];
|
|
||||||
console.assert(movable.length === 1, "player only in movable");
|
|
||||||
console.assert(movable[0] === player);
|
|
||||||
|
|
||||||
const allPos = [...world.query(query(Position))];
|
|
||||||
console.assert(allPos.length === 2, "both have Position");
|
|
||||||
|
|
||||||
// ── Observable query ─────────────────────────────────
|
|
||||||
const queryLog: QueryUpdate[] = [];
|
|
||||||
world.observe(query(Position, Velocity)).subscribe((u) => {
|
|
||||||
if (u.added.length || u.removed.length || u.changed.length) {
|
|
||||||
queryLog.push(u);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Mutation + change tracking ───────────────────────
|
|
||||||
world.get(player, Position).x += 5;
|
|
||||||
world.markDirty(player, Position);
|
|
||||||
world.get(player, Velocity).vx *= 2;
|
|
||||||
world.markDirty(player, Velocity);
|
|
||||||
|
|
||||||
// flush should emit componentChanged events and update queries
|
|
||||||
world.flush();
|
|
||||||
|
|
||||||
console.assert(
|
|
||||||
events.some((e) => e.type === "componentChanged"),
|
|
||||||
"change events",
|
|
||||||
);
|
|
||||||
|
|
||||||
// The query observer should have received changed: [player]
|
|
||||||
const lastUpdate = queryLog[queryLog.length - 1];
|
|
||||||
console.assert(
|
|
||||||
lastUpdate.changed.length === 1 && lastUpdate.changed[0] === player,
|
|
||||||
"player in changed",
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Remove component ─────────────────────────────────
|
|
||||||
world.remove(player, Velocity);
|
|
||||||
|
|
||||||
console.assert(!world.has(player, Velocity), "Velocity removed");
|
|
||||||
const movableAfter = [...world.query(query(Position, Velocity))];
|
|
||||||
console.assert(movableAfter.length === 0, "no one movable after remove");
|
|
||||||
|
|
||||||
// The observer should have emitted {removed: [player]}
|
|
||||||
const remUpdate = queryLog[queryLog.length - 1];
|
|
||||||
console.assert(
|
|
||||||
remUpdate.removed.length === 1 && remUpdate.removed[0] === player,
|
|
||||||
"player removed from query",
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Destroy ──────────────────────────────────────────
|
|
||||||
world.destroy(enemy);
|
|
||||||
console.assert(!world.isAlive(enemy), "enemy destroyed");
|
|
||||||
console.assert(world.entityCount === 1, "one entity left");
|
|
||||||
|
|
||||||
// ── componentChanged query update ────────────────────
|
|
||||||
// Add enemy back, observe query(Health).without(Dead)
|
|
||||||
const enemy2 = world.spawn();
|
|
||||||
world.add(enemy2, Health, { current: 75 });
|
|
||||||
|
|
||||||
const healthLog: QueryUpdate[] = [];
|
|
||||||
world.observe(query(Health).without(Dead)).subscribe((u) => {
|
|
||||||
healthLog.push(u);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Enemy won't be in the initial seed yet (subscribe happened after spawn)
|
|
||||||
// Let's add Dead to trigger the removal
|
|
||||||
world.add(enemy2, Dead, { timestamp: 123 });
|
|
||||||
world.flush();
|
|
||||||
|
|
||||||
console.assert(
|
|
||||||
healthLog.some((u) => u.removed[0] === enemy2),
|
|
||||||
"enemy removed from health-not-dead query after gaining Dead",
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Entity recycling ─────────────────────────────────
|
|
||||||
world.destroy(player);
|
|
||||||
const recycled = world.spawn();
|
|
||||||
|
|
||||||
console.assert(recycled !== player, "recycled entity has new generation");
|
|
||||||
console.assert(world.isAlive(recycled), "recycled entity is alive");
|
|
||||||
console.assert(!world.isAlive(player), "old handle is dead");
|
|
||||||
|
|
||||||
console.log("✅ All smoke tests passed.");
|
|
||||||
|
|
@ -0,0 +1,360 @@
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ["test/**/*.test.ts"],
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue