/** * keeper.test.ts * -------------- * Loop-level tests for `runKeeper` driven by a fake `Device`. The loop runs * forever in production, so the fake stops it by throwing a sentinel from * `sleep` once a call budget is exhausted; the test then inspects the * commands that were issued. * * These assert the two behaviors that matter: an idle cursor triggers a * synthetic sweep, and a moving cursor never does. */ import { describe, expect, test } from "bun:test"; import { DEFAULT_CONFIG } from "../src/config.ts"; import type { Config } from "../src/config.ts"; import type { Device, Point } from "../src/device.ts"; import { runKeeper } from "../src/keeper.ts"; import { diagonal, figureEight, type MovementStrategy } from "../src/strategies.ts"; class StopError extends Error {} /** * Fake device that echoes the last commanded point (so a synthetic sweep * completes cleanly) and aborts the loop after `budget` sleeps. * * `positions`, when provided, is consumed one entry per `getPosition` call * to simulate real user movement; otherwise the cursor is reported as * stationary at `initial`/the last commanded point (idle). */ class LoopDevice implements Device { commanded: Point[] = []; sleepCount = 0; constructor( public budget: number, public initial: Point = { x: 100, y: 100 }, private positions: Point[] | null = null, ) {} async getPosition(): Promise { if (this.positions) return this.positions.shift() ?? this.initial; return this.commanded.at(-1) ?? this.initial; } async setPosition(p: Point): Promise { this.commanded.push(p); } async width(): Promise { return 1920; } async height(): Promise { return 1080; } async sleep(): Promise { if (++this.sleepCount > this.budget) throw new StopError(); } } const quietConfig = (overrides: Partial): Config => ({ ...DEFAULT_CONFIG, verbose: false, ...overrides, }); async function runUntilStop( config: Config, device: Device, pickRandom?: () => MovementStrategy, ): Promise { try { await runKeeper(config, device, pickRandom); } catch (err) { if (!(err instanceof StopError)) throw err; } } describe("runKeeper", () => { test("fires a synthetic sweep once the cursor has been idle long enough", async () => { // moveInterval 0 => any elapsed time counts as "idle long enough", // so the first idle check triggers a sweep deterministically. const dev = new LoopDevice(50); await runUntilStop(quietConfig({ moveInterval: 0, pattern: "line" }), dev); // A sweep issued setPosition commands (the sweep is interrupted by the // sleep budget before it finishes, but many steps land); an idle loop // with no sweep would have issued none. expect(dev.commanded.length).toBeGreaterThanOrEqual(3); }); test("does not fire while the cursor keeps moving", async () => { // Every poll reports a new position => always "real activity", so the // idleness clock keeps resetting and no sweep ever fires. const moving: Point[] = Array.from({ length: 40 }, (_, i) => ({ x: i, y: i })); const dev = new LoopDevice(20, { x: 0, y: 0 }, moving); await runUntilStop(quietConfig({ moveInterval: 0, pattern: "line" }), dev); expect(dev.commanded.length).toBe(0); }); }); /** Furthest x any commanded point reached — the signal that a path ramped. */ const maxX = (pts: Point[]): number => pts.reduce((m, p) => Math.max(m, p.x), -Infinity); describe("runKeeper — loop mode", () => { test("loop mode ramps far from the start via the infinite loopPath", async () => { // `line`'s loopPath ramps x by 4px/step from the start and never // restores, reflecting off the screen edge. From x=100 it climbs well // past a single finite sweep's reach before the budget stops it. const dev = new LoopDevice(400, { x: 100, y: 100 }); await runUntilStop(quietConfig({ moveInterval: 0, pattern: "line", loop: true }), dev); expect(maxX(dev.commanded)).toBeGreaterThan(1000); }); test("single-sweep mode restores each sweep, so x never ramps away", async () => { // Same setup without loop: `line` runs 250 one-pixel steps then snaps // back to the start, so x is bounded by start + 250 no matter how many // sweeps fire within the budget. const dev = new LoopDevice(400, { x: 100, y: 100 }); await runUntilStop(quietConfig({ moveInterval: 0, pattern: "line", loop: false }), dev); expect(maxX(dev.commanded)).toBeLessThanOrEqual(350); }); test("loop mode chains a finite pattern across multiple cycles per trigger", async () => { // `figureEight` has no loopPath, so loop mode chains its 90-step path. // A single trigger keeps chaining cycles until the budget stops it, // yielding far more than the 90 commands one cycle would. const dev = new LoopDevice(400, { x: 800, y: 500 }); await runUntilStop( quietConfig({ moveInterval: 0, pattern: "figureEight", loop: true }), dev, ); expect(dev.commanded.length).toBeGreaterThan(180); }); }); describe("runKeeper — random pattern", () => { /** * A picker that always hands back `strategy` and counts how many times the * keeper asked. The count is the observable that pins down *when* the pick * happens, which is the whole contract for `random`. */ function recordingPicker(strategy: MovementStrategy): { pick: () => MovementStrategy; calls: () => number; } { let calls = 0; return { pick: (): MovementStrategy => { calls++; return strategy; }, calls: (): number => calls, }; } test("asks the picker again on every trigger", async () => { // moveInterval 0 means each pass of the watch loop fires a sweep, so // the budget covers several triggers. A pattern chosen once for the // whole process would show exactly one call. const picker = recordingPicker(figureEight); const dev = new LoopDevice(400, { x: 800, y: 500 }); await runUntilStop( quietConfig({ moveInterval: 0, pattern: "random", loop: false }), dev, picker.pick, ); expect(picker.calls()).toBeGreaterThanOrEqual(2); }); test("never consults the picker for a concrete pattern", async () => { const picker = recordingPicker(figureEight); const dev = new LoopDevice(400, { x: 800, y: 500 }); await runUntilStop( quietConfig({ moveInterval: 0, pattern: "line", loop: false }), dev, picker.pick, ); expect(picker.calls()).toBe(0); expect(dev.commanded.length).toBeGreaterThan(0); }); test("loop mode holds a single pick for the whole loop run", async () => { // One trigger, many chained cycles: the pattern must not change under // the user mid-run, so the picker is asked exactly once. const picker = recordingPicker(figureEight); const dev = new LoopDevice(400, { x: 800, y: 500 }); await runUntilStop( quietConfig({ moveInterval: 0, pattern: "random", loop: true }), dev, picker.pick, ); expect(picker.calls()).toBe(1); // ...and those cycles really did run, so the single call isn't just // the loop never getting started. expect(dev.commanded.length).toBeGreaterThan(180); }); test("a picked strategy keeps its own loopPath behavior", async () => { // The picker returns real registry entries, so a pick with an infinite // loopPath (`diagonal`) drives that path rather than a chained finite // one — the same as selecting it explicitly. Mirrors the `line` loop // test above: x ramps far past a single finite sweep's 250px reach. const picker = recordingPicker(diagonal); const dev = new LoopDevice(400, { x: 100, y: 100 }); await runUntilStop( quietConfig({ moveInterval: 0, pattern: "random", loop: true }), dev, picker.pick, ); expect(maxX(dev.commanded)).toBeGreaterThan(1000); }); });