ecs-observable/test/bt.test.ts

771 lines
21 KiB
TypeScript

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);
});
});