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; } function makeRepeat(world: World, parent?: Entity): Entity { const e = world.spawn(); world.add(e, Task, { kind: "repeat" }); if (parent) world.relate(e, ChildOf, parent); return e; } function makeSelector(world: World, parent?: Entity): Entity { const e = world.spawn(); world.add(e, Task, { kind: "selector" }); 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); }); }); // ── Repeat ────────────────────────────────────────── describe("Repeat tasks", () => { let world: World; let runner: TaskRunner; beforeEach(() => { world = new World(); runner = new TaskRunner(world); }); it("re-runs child after it succeeds", () => { const rep = makeRepeat(world); const leaf = makeLeaf(world, rep); let leafCount = 0; runner.onLeaf = () => leafCount++; runner.schedule(rep); // First run runner.tick(); // repeat schedules leaf runner.tick(); // leaf runs expect(leafCount).toBe(1); runner.succeed(leaf); // Repeat re-scheduled runner.tick(); // repeat sees leaf done, resets and schedules again runner.tick(); // leaf runs again expect(leafCount).toBe(2); runner.succeed(leaf); // And again runner.tick(); // repeat resets runner.tick(); // leaf runs expect(leafCount).toBe(3); }); it("re-runs child after it fails", () => { const rep = makeRepeat(world); const leaf = makeLeaf(world, rep); let leafCount = 0; runner.onLeaf = () => leafCount++; runner.schedule(rep); runner.tick(); // repeat schedules leaf runner.tick(); // leaf runs runner.fail(leaf); // Repeat re-scheduled, resets leaf runner.tick(); // repeat resets leaf runner.tick(); // leaf runs again expect(leafCount).toBe(2); }); it("never terminates on its own", () => { const rep = makeRepeat(world); const leaf = makeLeaf(world, rep); runner.schedule(rep); runner.tick(); // schedules leaf runner.tick(); // runs leaf runner.succeed(leaf); // After many cycles, repeat is still not terminal for (let i = 0; i < 5; i++) { runner.tick(); // repeat resets + schedules leaf runner.tick(); // leaf runs runner.succeed(leaf); } expect(world.has(rep, Succeeded)).toBe(false); expect(world.has(rep, Failed)).toBe(false); expect(world.has(rep, Cancelled)).toBe(false); }); it("can be cancelled", () => { const rep = makeRepeat(world); const leaf = makeLeaf(world, rep); runner.schedule(rep); runner.tick(); // schedules leaf runner.tick(); // runs leaf runner.cancel(rep); expect(world.has(rep, Cancelled)).toBe(true); expect(world.has(leaf, Cancelled)).toBe(true); }); it("empty repeat does nothing", () => { const rep = makeRepeat(world); runner.schedule(rep); runner.tick(); // No child, so nothing happens expect(world.has(rep, Succeeded)).toBe(false); expect(world.has(rep, Failed)).toBe(false); }); it("repeat inside sequential advances parent when cancelled", () => { const seq = makeSequential(world); const rep = makeRepeat(world, seq); const leaf = makeLeaf(world, rep); const after = makeLeaf(world, seq); runner.schedule(seq); runner.tick(); // seq schedules rep runner.tick(); // rep schedules leaf runner.tick(); // leaf runs // Cancel the repeat runner.cancel(rep); runner.tick(); // seq sees rep cancelled, seq becomes cancelled expect(world.has(seq, Cancelled)).toBe(true); expect(world.has(after, Scheduled)).toBe(false); }); }); // ── Selector ──────────────────────────────────────── describe("Selector tasks", () => { let world: World; let runner: TaskRunner; beforeEach(() => { world = new World(); runner = new TaskRunner(world); }); it("succeeds on first child that succeeds", () => { const sel = makeSelector(world); const a = makeLeaf(world, sel); const b = makeLeaf(world, sel); runner.schedule(sel); runner.tick(); // schedules a runner.tick(); // runs a runner.succeed(a); // Selector re-scheduled, sees a succeeded runner.tick(); expect(world.has(sel, Succeeded)).toBe(true); // b was never touched expect(world.has(b, Scheduled)).toBe(false); expect(world.has(b, Running)).toBe(false); }); it("tries next child when previous fails", () => { const sel = makeSelector(world); const a = makeLeaf(world, sel); const b = makeLeaf(world, sel); const c = makeLeaf(world, sel); runner.schedule(sel); runner.tick(); // schedules a runner.tick(); // runs a runner.fail(a); runner.tick(); // selector schedules b expect(world.has(b, Scheduled)).toBe(true); runner.tick(); // runs b runner.fail(b); runner.tick(); // selector schedules c expect(world.has(c, Scheduled)).toBe(true); runner.tick(); // runs c runner.succeed(c); runner.tick(); // selector succeeds expect(world.has(sel, Succeeded)).toBe(true); }); it("fails when all children fail", () => { const sel = makeSelector(world); const a = makeLeaf(world, sel); const b = makeLeaf(world, sel); runner.schedule(sel); runner.tick(); // schedules a runner.tick(); // runs a runner.fail(a); runner.tick(); // schedules b runner.tick(); // runs b runner.fail(b); runner.tick(); // all failed expect(world.has(sel, Failed)).toBe(true); }); it("empty selector fails immediately", () => { const sel = makeSelector(world); runner.schedule(sel); runner.tick(); expect(world.has(sel, Failed)).toBe(true); }); it("skips cancelled children and continues", () => { const sel = makeSelector(world); const a = makeLeaf(world, sel); const b = makeLeaf(world, sel); runner.schedule(sel); runner.tick(); // schedules a runner.tick(); // runs a runner.cancel(a); runner.tick(); // selector sees a cancelled, tries b expect(world.has(b, Scheduled)).toBe(true); }); }); // ── 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); }); });