import { describe, it, expect, beforeEach } from "vitest"; import { World, type Entity } from "../src/index"; import { Task, Scheduled, Running, Succeeded, Failed, Cancelled, ChildOf, TaskRunner, } from "../src/bt/index"; // ── Helpers ───────────────────────────────────────── function makeLeaf(world: World, parent?: Entity): Entity { const e = world.spawn(); world.add(e, Task, { kind: "leaf" }); if (parent) world.relate(e, ChildOf, parent); return e; } function makeSequential(world: World, parent?: Entity): Entity { const e = world.spawn(); world.add(e, Task, { kind: "sequential" }); if (parent) world.relate(e, ChildOf, parent); return e; } function makeParallel(world: World, parent?: Entity): Entity { const e = world.spawn(); world.add(e, Task, { kind: "parallel" }); if (parent) world.relate(e, ChildOf, parent); return e; } function makeRandom(world: World, parent?: Entity): Entity { const e = world.spawn(); world.add(e, Task, { kind: "random" }); if (parent) world.relate(e, ChildOf, parent); return e; } // ── Leaf tasks ────────────────────────────────────── describe("Leaf tasks", () => { let world: World; let runner: TaskRunner; beforeEach(() => { world = new World(); runner = new TaskRunner(world); }); it("calls onLeaf when a leaf is scheduled and ticked", () => { const leaf = makeLeaf(world); const calls: Entity[] = []; runner.onLeaf = (_w, e) => calls.push(e); runner.schedule(leaf); runner.tick(); expect(calls).toEqual([leaf]); }); it("marks leaf as Running after tick", () => { const leaf = makeLeaf(world); runner.schedule(leaf); runner.tick(); expect(world.has(leaf, Running)).toBe(true); expect(world.has(leaf, Scheduled)).toBe(false); }); it("succeed() marks leaf as Succeeded", () => { const leaf = makeLeaf(world); runner.schedule(leaf); runner.tick(); runner.succeed(leaf); expect(world.has(leaf, Succeeded)).toBe(true); expect(world.has(leaf, Running)).toBe(false); }); it("fail() marks leaf as Failed", () => { const leaf = makeLeaf(world); runner.schedule(leaf); runner.tick(); runner.fail(leaf); expect(world.has(leaf, Failed)).toBe(true); expect(world.has(leaf, Running)).toBe(false); }); it("cancel() marks leaf as Cancelled", () => { const leaf = makeLeaf(world); runner.schedule(leaf); runner.tick(); runner.cancel(leaf); expect(world.has(leaf, Cancelled)).toBe(true); expect(world.has(leaf, Running)).toBe(false); }); it("onTerminal is called when leaf finishes", () => { const leaf = makeLeaf(world); const terminals: { entity: Entity; status: string }[] = []; runner.onTerminal = (_w, e, s) => terminals.push({ entity: e, status: s }); runner.schedule(leaf); runner.tick(); runner.succeed(leaf); expect(terminals).toEqual([{ entity: leaf, status: "succeeded" }]); }); it("reset() clears all status tags", () => { const leaf = makeLeaf(world); runner.schedule(leaf); runner.tick(); runner.succeed(leaf); runner.reset(leaf); expect(world.has(leaf, Succeeded)).toBe(false); expect(world.has(leaf, Running)).toBe(false); expect(world.has(leaf, Scheduled)).toBe(false); }); }); // ── Sequential ────────────────────────────────────── describe("Sequential tasks", () => { let world: World; let runner: TaskRunner; beforeEach(() => { world = new World(); runner = new TaskRunner(world); }); it("runs children one at a time in order", () => { const seq = makeSequential(world); const a = makeLeaf(world, seq); const b = makeLeaf(world, seq); const c = makeLeaf(world, seq); const leafCalls: Entity[] = []; runner.onLeaf = (_w, e) => leafCalls.push(e); runner.schedule(seq); runner.tick(); // schedules first child expect(world.has(a, Scheduled)).toBe(true); expect(world.has(b, Scheduled)).toBe(false); expect(world.has(c, Scheduled)).toBe(false); runner.tick(); // runs a runner.succeed(a); // parent should be re-scheduled runner.tick(); // schedules next child expect(world.has(b, Scheduled)).toBe(true); runner.tick(); // runs b runner.succeed(b); runner.tick(); // schedules c expect(world.has(c, Scheduled)).toBe(true); runner.tick(); // runs c runner.succeed(c); // parent should now be scheduled and succeed runner.tick(); expect(world.has(seq, Succeeded)).toBe(true); }); it("fails immediately when a child fails", () => { const seq = makeSequential(world); const a = makeLeaf(world, seq); const b = makeLeaf(world, seq); runner.schedule(seq); runner.tick(); // schedules a runner.tick(); // runs a runner.fail(a); // parent re-scheduled runner.tick(); // sees a failed → seq fails expect(world.has(seq, Failed)).toBe(true); // b was never touched expect(world.has(b, Scheduled)).toBe(false); expect(world.has(b, Running)).toBe(false); }); it("succeeds when all children succeed", () => { const seq = makeSequential(world); const a = makeLeaf(world, seq); const b = makeLeaf(world, seq); runner.schedule(seq); runner.tick(); // schedules a runner.tick(); // runs a runner.succeed(a); runner.tick(); // schedules b runner.tick(); // runs b runner.succeed(b); runner.tick(); // seq succeeds expect(world.has(seq, Succeeded)).toBe(true); }); it("propagates terminal to grandparent", () => { const root = makeSequential(world); const child = makeSequential(world, root); const leaf = makeLeaf(world, child); const terminals: Entity[] = []; runner.onTerminal = (_w, e) => terminals.push(e); runner.schedule(root); runner.tick(); // schedules child runner.tick(); // schedules leaf runner.tick(); // runs leaf runner.succeed(leaf); runner.tick(); // child succeeds runner.tick(); // root succeeds expect(terminals).toEqual([leaf, child, root]); expect(world.has(root, Succeeded)).toBe(true); }); it("empty sequential succeeds immediately", () => { const seq = makeSequential(world); runner.schedule(seq); runner.tick(); expect(world.has(seq, Succeeded)).toBe(true); }); }); // ── Parallel ──────────────────────────────────────── describe("Parallel tasks", () => { let world: World; let runner: TaskRunner; beforeEach(() => { world = new World(); runner = new TaskRunner(world); }); it("schedules all children at once", () => { const par = makeParallel(world); const a = makeLeaf(world, par); const b = makeLeaf(world, par); const c = makeLeaf(world, par); runner.schedule(par); runner.tick(); expect(world.has(a, Scheduled)).toBe(true); expect(world.has(b, Scheduled)).toBe(true); expect(world.has(c, Scheduled)).toBe(true); }); it("succeeds when all children succeed", () => { const par = makeParallel(world); const a = makeLeaf(world, par); const b = makeLeaf(world, par); runner.schedule(par); runner.tick(); // schedules both runner.tick(); // runs a runner.succeed(a); // par is re-scheduled, but b is still running runner.tick(); // par sees a done, b still needs scheduling // b should be scheduled (it was removed from Scheduled when ticked) // Actually: b was scheduled in first tick, then ticked in second tick // Let me trace more carefully... // After first tick: a=Scheduled, b=Scheduled, par=no status // Second tick processes Scheduled: a and b both get ticked // a runs, b runs. Both are Running. // We succeed a → par gets Scheduled // Third tick: par sees a=Succeeded, b=Running → waits // We need to succeed b too runner.succeed(b); // par gets Scheduled again runner.tick(); // par sees both done → succeeds expect(world.has(par, Succeeded)).toBe(true); }); it("fails immediately when any child fails", () => { const par = makeParallel(world); const a = makeLeaf(world, par); const b = makeLeaf(world, par); runner.schedule(par); runner.tick(); // schedules both runner.tick(); // runs a runner.tick(); // runs b runner.fail(a); // par re-scheduled runner.tick(); // sees a failed → par fails expect(world.has(par, Failed)).toBe(true); }); it("empty parallel succeeds immediately", () => { const par = makeParallel(world); runner.schedule(par); runner.tick(); expect(world.has(par, Succeeded)).toBe(true); }); }); // ── Random ────────────────────────────────────────── describe("Random tasks", () => { let world: World; let runner: TaskRunner; beforeEach(() => { world = new World(); runner = new TaskRunner(world); }); it("picks one child and succeeds/fails with it", () => { const rand = makeRandom(world); const a = makeLeaf(world, rand); const b = makeLeaf(world, rand); runner.schedule(rand); runner.tick(); // Exactly one child should be scheduled const scheduled = [world.has(a, Scheduled), world.has(b, Scheduled)]; expect(scheduled.filter(Boolean)).toHaveLength(1); const picked = world.has(a, Scheduled) ? a : b; runner.tick(); // runs picked leaf runner.succeed(picked); runner.tick(); // random sees child done → succeeds expect(world.has(rand, Succeeded)).toBe(true); }); it("fails when picked child fails", () => { const rand = makeRandom(world); const a = makeLeaf(world, rand); runner.schedule(rand); runner.tick(); // schedules a (only child) runner.tick(); // runs a runner.fail(a); runner.tick(); // random fails expect(world.has(rand, Failed)).toBe(true); }); it("empty random does nothing (no children to pick)", () => { const rand = makeRandom(world); runner.schedule(rand); runner.tick(); // No children, so no status change expect(world.has(rand, Succeeded)).toBe(false); expect(world.has(rand, Failed)).toBe(false); }); }); // ── Cancel ────────────────────────────────────────── describe("Cancel", () => { let world: World; let runner: TaskRunner; beforeEach(() => { world = new World(); runner = new TaskRunner(world); }); it("cancels all descendants", () => { const root = makeSequential(world); const child = makeParallel(world, root); const a = makeLeaf(world, child); const b = makeLeaf(world, child); runner.schedule(root); runner.tick(); // schedules child runner.tick(); // schedules a, b runner.tick(); // runs a runner.tick(); // runs b runner.cancel(root); expect(world.has(root, Cancelled)).toBe(true); expect(world.has(child, Cancelled)).toBe(true); expect(world.has(a, Cancelled)).toBe(true); expect(world.has(b, Cancelled)).toBe(true); }); it("cancel propagates to parent", () => { const root = makeSequential(world); const child = makeSequential(world, root); const leaf = makeLeaf(world, child); runner.schedule(root); runner.tick(); // schedules child runner.tick(); // schedules leaf runner.tick(); // runs leaf runner.cancel(leaf); // leaf cancelled → child re-scheduled → child sees leaf cancelled → child cancelled runner.tick(); // child processes cancelled leaf expect(world.has(child, Cancelled)).toBe(true); // child cancelled → root re-scheduled → root sees child cancelled → root cancelled runner.tick(); expect(world.has(root, Cancelled)).toBe(true); }); }); // ── Multi-frame leaves ────────────────────────────── describe("Multi-frame leaves", () => { let world: World; let runner: TaskRunner; beforeEach(() => { world = new World(); runner = new TaskRunner(world); }); it("leaf stays Running across ticks until explicitly finished", () => { const leaf = makeLeaf(world); runner.schedule(leaf); runner.tick(); expect(world.has(leaf, Running)).toBe(true); // Tick again — leaf is Running, not Scheduled, so nothing happens runner.tick(); expect(world.has(leaf, Running)).toBe(true); // External system finishes it runner.succeed(leaf); expect(world.has(leaf, Succeeded)).toBe(true); expect(world.has(leaf, Running)).toBe(false); }); it("sequential waits for multi-frame leaf before advancing", () => { const seq = makeSequential(world); const a = makeLeaf(world, seq); const b = makeLeaf(world, seq); runner.schedule(seq); runner.tick(); // schedules a runner.tick(); // runs a → Running // Tick several times — seq should not advance runner.tick(); runner.tick(); expect(world.has(b, Scheduled)).toBe(false); expect(world.has(b, Running)).toBe(false); // Finish a runner.succeed(a); runner.tick(); // seq schedules b expect(world.has(b, Scheduled)).toBe(true); }); }); // ── Edge cases ────────────────────────────────────── describe("Edge cases", () => { it("schedule() is a no-op on non-task entities", () => { const world = new World(); const runner = new TaskRunner(world); const e = world.spawn(); runner.schedule(e); expect(world.has(e, Scheduled)).toBe(false); }); it("succeed/fail/cancel are no-ops on non-task entities", () => { const world = new World(); const runner = new TaskRunner(world); const e = world.spawn(); expect(() => runner.succeed(e)).not.toThrow(); expect(() => runner.fail(e)).not.toThrow(); expect(() => runner.cancel(e)).not.toThrow(); }); it("tick is a no-op when nothing is scheduled", () => { const world = new World(); const runner = new TaskRunner(world); const leaf = makeLeaf(world); // Leaf exists but is not Scheduled expect(() => runner.tick()).not.toThrow(); expect(world.has(leaf, Running)).toBe(false); }); it("deeply nested tree works correctly", () => { const world = new World(); const runner = new TaskRunner(world); const root = makeSequential(world); const mid = makeSequential(world, root); const leaf = makeLeaf(world, mid); runner.schedule(root); // Tick 1: root schedules mid runner.tick(); expect(world.has(mid, Scheduled)).toBe(true); // Tick 2: mid schedules leaf runner.tick(); expect(world.has(leaf, Scheduled)).toBe(true); // Tick 3: leaf runs runner.tick(); expect(world.has(leaf, Running)).toBe(true); runner.succeed(leaf); // leaf done → mid scheduled runner.tick(); // mid sees leaf done → mid succeeds expect(world.has(mid, Succeeded)).toBe(true); runner.tick(); // root sees mid done → root succeeds expect(world.has(root, Succeeded)).toBe(true); }); });