285 lines
7.7 KiB
TypeScript
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();
|
|
});
|
|
});
|