ecs-observable/test/commands.test.ts

285 lines
7.7 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from "vitest";
import { World, defineComponent, type Entity } from "../src/index";
import { CommandQueue } from "../src/commands/command-queue";
// ── Components ──────────────────────────────────────
const Health = defineComponent("health", { current: 100, max: 100 });
const DamageCmd = defineComponent("damageCmd", { amount: 0 });
const HealCmd = defineComponent("healCmd", { amount: 0 });
// ── Helpers ─────────────────────────────────────────
async function settled() {
await new Promise((r) => setTimeout(r, 0));
}
// ── Basic command dispatch ──────────────────────────
describe("CommandQueue", () => {
let world: World;
let queue: CommandQueue;
beforeEach(() => {
world = new World();
queue = new CommandQueue(world);
});
it("dispatches commands to registered handlers", () => {
const results: { entity: Entity; amount: number }[] = [];
queue.handle(DamageCmd, (cmd, entity) => {
results.push({ entity: entity!, amount: cmd.amount });
});
const e = world.spawn();
world.add(e, DamageCmd, { amount: 25 });
queue.execute();
expect(results).toHaveLength(1);
expect(results[0].amount).toBe(25);
});
it("removes the command component after dispatch", () => {
queue.handle(DamageCmd, () => {});
const e = world.spawn();
world.add(e, DamageCmd, { amount: 10 });
expect(world.has(e, DamageCmd)).toBe(true);
queue.execute();
expect(world.has(e, DamageCmd)).toBe(false);
});
it("destroys an entity when it becomes empty after command removal", () => {
queue.handle(DamageCmd, () => {});
const e = world.spawn();
world.add(e, DamageCmd, { amount: 10 });
queue.execute();
expect(world.isAlive(e)).toBe(false);
});
it("does NOT destroy entities that still have other components", () => {
queue.handle(DamageCmd, () => {});
const e = world.spawn();
world.add(e, Health);
world.add(e, DamageCmd, { amount: 10 });
queue.execute();
expect(world.isAlive(e)).toBe(true);
expect(world.has(e, Health)).toBe(true);
expect(world.has(e, DamageCmd)).toBe(false);
});
it("handles multiple entities with the same command type", () => {
const hits: Entity[] = [];
queue.handle(DamageCmd, (_, entity) => {
hits.push(entity!);
});
const a = world.spawn();
const b = world.spawn();
const c = world.spawn();
world.add(a, DamageCmd, { amount: 5 });
world.add(b, DamageCmd, { amount: 10 });
world.add(c, DamageCmd, { amount: 15 });
queue.execute();
expect(hits).toHaveLength(3);
});
it("handles multiple registered command types", () => {
const damages: number[] = [];
const heals: number[] = [];
queue
.handle(DamageCmd, (cmd) => damages.push(cmd.amount))
.handle(HealCmd, (cmd) => heals.push(cmd.amount));
const a = world.spawn();
const b = world.spawn();
world.add(a, DamageCmd, { amount: 5 });
world.add(b, HealCmd, { amount: 20 });
queue.execute();
expect(damages).toEqual([5]);
expect(heals).toEqual([20]);
expect(world.has(a, DamageCmd)).toBe(false);
expect(world.has(b, HealCmd)).toBe(false);
});
it("is a no-op when no command entities exist", () => {
queue.handle(DamageCmd, () => {
throw new Error("should not be called");
});
// No entities with DamageCmd
expect(() => queue.execute()).not.toThrow();
});
it("is a no-op when no handlers are registered", () => {
const e = world.spawn();
world.add(e, DamageCmd, { amount: 10 });
expect(() => queue.execute()).not.toThrow();
expect(world.has(e, DamageCmd)).toBe(true); // not consumed
});
});
// ── Interruption ────────────────────────────────────
describe("CommandQueue interruption", () => {
let world: World;
let queue: CommandQueue;
beforeEach(() => {
world = new World();
queue = new CommandQueue(world);
});
it("skips processing when interrupted", () => {
const handler = vi.fn();
queue.handle(DamageCmd, handler);
const e = world.spawn();
world.add(e, DamageCmd, { amount: 10 });
// Interrupt with a never-resolving promise
queue.interrupt(new Promise(() => {}));
expect(queue.isInterrupted).toBe(true);
queue.execute();
expect(handler).not.toHaveBeenCalled();
expect(world.has(e, DamageCmd)).toBe(true);
});
it("resumes processing after interruption promise resolves", async () => {
const handler = vi.fn();
queue.handle(DamageCmd, handler);
let resolve!: () => void;
const promise = new Promise<void>((r) => {
resolve = r;
});
queue.interrupt(promise);
expect(queue.isInterrupted).toBe(true);
queue.execute();
expect(handler).not.toHaveBeenCalled();
resolve();
await settled();
expect(queue.isInterrupted).toBe(false);
const e = world.spawn();
world.add(e, DamageCmd, { amount: 10 });
queue.execute();
expect(handler).toHaveBeenCalledTimes(1);
});
it("resumes after all interruption promises settle (including rejections)", async () => {
const handler = vi.fn();
queue.handle(DamageCmd, handler);
queue.interrupt(Promise.reject(new Error("fail")));
await settled();
expect(queue.isInterrupted).toBe(false);
const e = world.spawn();
world.add(e, DamageCmd, { amount: 10 });
queue.execute();
expect(handler).toHaveBeenCalledTimes(1);
});
it("stays interrupted while at least one of several promises is pending", async () => {
let resolveA!: () => void;
const a = new Promise<void>((r) => {
resolveA = r;
});
const b = Promise.resolve();
queue.interrupt(a);
queue.interrupt(b);
await settled(); // b resolves
expect(queue.isInterrupted).toBe(true); // a still pending
resolveA();
await settled();
expect(queue.isInterrupted).toBe(false);
});
});
// ── Edge cases ──────────────────────────────────────
describe("CommandQueue edge cases", () => {
it("handler can safely add components to surviving entity", () => {
const world = new World();
const queue = new CommandQueue(world);
const Flag = defineComponent("flag", { set: false });
queue.handle(DamageCmd, (_cmd, entity) => {
world.add(entity!, Flag, { set: true });
});
const e = world.spawn();
world.add(e, Health);
world.add(e, DamageCmd, { amount: 10 });
queue.execute();
expect(world.has(e, Flag)).toBe(true);
expect(world.isAlive(e)).toBe(true);
});
it("handler can destroy a different entity", () => {
const world = new World();
const queue = new CommandQueue(world);
const other = world.spawn();
queue.handle(DamageCmd, () => {
world.destroy(other);
});
const e = world.spawn();
world.add(e, DamageCmd, { amount: 10 });
queue.execute();
expect(world.isAlive(other)).toBe(false);
});
it("chainable .handle() calls", () => {
const world = new World();
const queue = new CommandQueue(world);
const a = vi.fn();
const b = vi.fn();
queue
.handle(DamageCmd, (_cmd, _entity) => a())
.handle(HealCmd, (_cmd, _entity) => b());
const e1 = world.spawn();
world.add(e1, DamageCmd, { amount: 5 });
const e2 = world.spawn();
world.add(e2, HealCmd, { amount: 10 });
queue.execute();
expect(a).toHaveBeenCalled();
expect(b).toHaveBeenCalled();
});
});