782 lines
21 KiB
TypeScript
782 lines
21 KiB
TypeScript
import { describe, it, expect, beforeEach } from "vitest";
|
|
import { World, defineComponent, entity, type Entity } from "../src/index";
|
|
import {
|
|
Task,
|
|
Scheduled,
|
|
Running,
|
|
Succeeded,
|
|
Failed,
|
|
Cancelled,
|
|
ChildOf,
|
|
TaskRunner,
|
|
buildTree,
|
|
leaf,
|
|
cycle,
|
|
sequential,
|
|
} 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 makeCycle(world: World, parent?: Entity): Entity {
|
|
const e = world.spawn();
|
|
world.add(e, Task, { kind: "cycle" });
|
|
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;
|
|
}
|
|
|
|
// ── Entity task factories ───────────────────────────
|
|
describe("Entity task factories", () => {
|
|
it("materializes non-task child entities and ignores them during execution", () => {
|
|
const Label = defineComponent("testLabel", { value: "" });
|
|
const world = new World();
|
|
const calls: string[] = [];
|
|
|
|
const runner = buildTree(
|
|
world,
|
|
sequential([
|
|
entity(Label, { value: "sequence metadata" }),
|
|
leaf(() => calls.push("a")),
|
|
entity(Label, { value: "between leaves" }),
|
|
leaf(() => calls.push("b")),
|
|
]),
|
|
);
|
|
|
|
const root = runner.root!;
|
|
const children = [...world.getRelatedTo(root, ChildOf)];
|
|
const labels = children
|
|
.filter((child) => world.has(child, Label))
|
|
.map((child) => world.get(child, Label).value);
|
|
|
|
expect(labels).toEqual(["sequence metadata", "between leaves"]);
|
|
|
|
runner.schedule(root);
|
|
for (let i = 0; i < 5; i++) runner.tick();
|
|
|
|
expect(calls).toEqual(["a", "b"]);
|
|
expect(world.has(root, Succeeded)).toBe(true);
|
|
});
|
|
|
|
it("cycle works with component child entities beside its task child", () => {
|
|
const Label = defineComponent("cycleLabel", { value: "" });
|
|
const world = new World();
|
|
let calls = 0;
|
|
|
|
const runner = buildTree(
|
|
world,
|
|
cycle([
|
|
entity(Label, { value: "cycle metadata" }),
|
|
leaf(() => {
|
|
calls++;
|
|
}),
|
|
]),
|
|
);
|
|
|
|
runner.schedule(runner.root!);
|
|
for (let i = 0; i < 5; i++) runner.tick();
|
|
|
|
expect(calls).toBeGreaterThan(1);
|
|
});
|
|
});
|
|
|
|
// ── 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();
|
|
expect(world.has(a, Running)).toBe(true);
|
|
expect(world.has(b, Running)).toBe(false);
|
|
expect(world.has(c, Running)).toBe(false);
|
|
|
|
runner.succeed(a);
|
|
expect(world.has(b, Running)).toBe(true);
|
|
|
|
runner.succeed(b);
|
|
expect(world.has(c, Running)).toBe(true);
|
|
|
|
runner.succeed(c);
|
|
expect(world.has(seq, Succeeded)).toBe(true);
|
|
expect(leafCalls).toEqual([a, b, c]);
|
|
});
|
|
|
|
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("starts 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, Running)).toBe(true);
|
|
expect(world.has(b, Running)).toBe(true);
|
|
expect(world.has(c, Running)).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 running
|
|
const running = [world.has(a, Running), world.has(b, Running)];
|
|
expect(running.filter(Boolean)).toHaveLength(1);
|
|
|
|
const picked = world.has(a, Running) ? a : b;
|
|
runner.succeed(picked);
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
// ── Cycle ──────────────────────────────────────────
|
|
describe("Cycle tasks", () => {
|
|
let world: World;
|
|
let runner: TaskRunner;
|
|
|
|
beforeEach(() => {
|
|
world = new World();
|
|
runner = new TaskRunner(world);
|
|
});
|
|
|
|
it("re-runs child after it succeeds", () => {
|
|
const cyc = makeCycle(world);
|
|
const leaf = makeLeaf(world, cyc);
|
|
|
|
let leafCount = 0;
|
|
runner.onLeaf = () => leafCount++;
|
|
|
|
runner.schedule(cyc);
|
|
|
|
runner.tick();
|
|
expect(leafCount).toBe(1);
|
|
runner.succeed(leaf);
|
|
|
|
// Completion schedules the next cycle at a tick boundary.
|
|
expect(world.has(leaf, Scheduled)).toBe(true);
|
|
runner.tick();
|
|
expect(leafCount).toBe(2);
|
|
runner.succeed(leaf);
|
|
|
|
runner.tick();
|
|
expect(leafCount).toBe(3);
|
|
});
|
|
|
|
it("re-runs child after it fails", () => {
|
|
const cyc = makeCycle(world);
|
|
const leaf = makeLeaf(world, cyc);
|
|
|
|
let leafCount = 0;
|
|
runner.onLeaf = () => leafCount++;
|
|
|
|
runner.schedule(cyc);
|
|
runner.tick();
|
|
runner.fail(leaf);
|
|
|
|
expect(world.has(leaf, Scheduled)).toBe(true);
|
|
runner.tick();
|
|
expect(leafCount).toBe(2);
|
|
});
|
|
|
|
it("never terminates on its own", () => {
|
|
const cyc = makeCycle(world);
|
|
const leaf = makeLeaf(world, cyc);
|
|
|
|
runner.schedule(cyc);
|
|
runner.tick();
|
|
runner.succeed(leaf);
|
|
|
|
// After many cycles, cycle is still not terminal
|
|
for (let i = 0; i < 5; i++) {
|
|
runner.tick();
|
|
runner.succeed(leaf);
|
|
}
|
|
|
|
expect(world.has(cyc, Succeeded)).toBe(false);
|
|
expect(world.has(cyc, Failed)).toBe(false);
|
|
expect(world.has(cyc, Cancelled)).toBe(false);
|
|
});
|
|
|
|
it("can be cancelled", () => {
|
|
const cyc = makeCycle(world);
|
|
const leaf = makeLeaf(world, cyc);
|
|
|
|
runner.schedule(cyc);
|
|
runner.tick();
|
|
|
|
runner.cancel(cyc);
|
|
|
|
expect(world.has(cyc, Cancelled)).toBe(true);
|
|
expect(world.has(leaf, Cancelled)).toBe(true);
|
|
});
|
|
|
|
it("empty cycle does nothing", () => {
|
|
const cyc = makeCycle(world);
|
|
|
|
runner.schedule(cyc);
|
|
runner.tick();
|
|
|
|
// No child, so nothing happens
|
|
expect(world.has(cyc, Succeeded)).toBe(false);
|
|
expect(world.has(cyc, Failed)).toBe(false);
|
|
});
|
|
|
|
it("cycle inside sequential advances parent when cancelled", () => {
|
|
const seq = makeSequential(world);
|
|
const cyc = makeCycle(world, seq);
|
|
const leaf = makeLeaf(world, cyc);
|
|
const after = makeLeaf(world, seq);
|
|
|
|
runner.schedule(seq);
|
|
runner.tick();
|
|
|
|
// Cancel the cycle
|
|
runner.cancel(cyc);
|
|
|
|
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);
|
|
|
|
expect(world.has(b, Running)).toBe(true);
|
|
|
|
runner.fail(b);
|
|
expect(world.has(c, Running)).toBe(true);
|
|
|
|
runner.succeed(c);
|
|
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);
|
|
|
|
expect(world.has(b, Running)).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; parent propagation starts b immediately.
|
|
runner.succeed(a);
|
|
expect(world.has(b, Running)).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);
|
|
|
|
runner.tick();
|
|
expect(world.has(leaf, Running)).toBe(true);
|
|
|
|
runner.succeed(leaf);
|
|
expect(world.has(mid, Succeeded)).toBe(true);
|
|
expect(world.has(root, Succeeded)).toBe(true);
|
|
});
|
|
});
|