/** * executor.test.ts * ---------------- * Unit tests for the execution driver against a fake `Device`. Covers the * two sweep outcomes, on-screen reflection, the rounding/interrupt contract, * step pacing, and the loop/restore options — none of which was testable * before the device seam existed. */ 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 { executePath, type Logger } from "../src/executor.ts"; import type { MoveContext, MovementStrategy } from "../src/strategies.ts"; const noopLog: Logger = { info: (): void => {}, event: (): void => {} }; /** * A scriptable `Device`. `getPosition` echoes the last commanded point * (simulating "the cursor stayed where we put it") unless `overrides` maps * the current getPosition call index to a substitute — used to inject a * mid-sweep user interruption. */ class FakeDevice implements Device { commanded: Point[] = []; sleeps: number[] = []; getCalls = 0; overrides = new Map(); constructor(public w = 1920, public h = 1080, public initial: Point = { x: 0, y: 0 }) {} async getPosition(): Promise { this.getCalls++; const o = this.overrides.get(this.getCalls); if (o) return o; return this.commanded.at(-1) ?? this.initial; } async setPosition(p: Point): Promise { this.commanded.push(p); } async width(): Promise { return this.w; } async height(): Promise { return this.h; } async sleep(ms: number): Promise { this.sleeps.push(ms); } } /** A strategy that emits a fixed list of points. */ function fixed(points: Point[]): MovementStrategy { return { name: "fixed", *path(): Generator { yield* points; }, }; } function ctxOf(start: Point, width: number, height: number): MoveContext { return { start, width, height, rng: Math.random }; } /** A full `Config` for the executor's pacing; only `stepDelay` matters here. */ function cfgOf(config?: Partial): Config { return { ...DEFAULT_CONFIG, ...config }; } describe("executePath — outcomes", () => { test("clean sweep commands every point, restores to start, returns 'completed'", async () => { const dev = new FakeDevice(); const start = { x: 500, y: 500 }; const pts = [ { x: 501, y: 500 }, { x: 502, y: 500 }, { x: 503, y: 500 }, ]; const outcome = await executePath(fixed(pts), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf()); expect(outcome).toBe("completed"); // 3 steps + 1 restore. expect(dev.commanded).toEqual([...pts, start]); }); test("interruption mid-sweep returns 'interrupted' and does NOT restore", async () => { const dev = new FakeDevice(); const start = { x: 500, y: 500 }; const pts = [ { x: 501, y: 500 }, { x: 502, y: 500 }, { x: 503, y: 500 }, ]; // 2nd getPosition call reports the user elsewhere. dev.overrides.set(2, { x: 9, y: 9 }); const outcome = await executePath(fixed(pts), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf()); expect(outcome).toBe("interrupted"); // Commanded points 1 and 2 only; never restored to start. expect(dev.commanded).toEqual([pts[0]!, pts[1]!]); expect(dev.commanded.at(-1)).not.toEqual(start); }); }); describe("executePath — on-screen reflection", () => { test("mirrors an out-of-range coordinate back inside the inset range", async () => { const dev = new FakeDevice(100, 100); // Inset range [2, 97], span = 95; x=120 -> (120-2)=118, 190-118=72, +2 = 74. const pts = [{ x: 120, y: 50 }]; await executePath(fixed(pts), ctxOf({ x: 50, y: 50 }, 100, 100), dev, noopLog, cfgOf()); expect(dev.commanded[0]).toEqual({ x: 74, y: 50 }); }); test("negative and far-past-edge coordinates both fold inside", async () => { const dev = new FakeDevice(100, 100); // Inset [2, 97]. x=-5 -> reflects to 9; x=99 -> 95 (period 190). const pts = [ { x: -5, y: 50 }, { x: 99, y: 50 }, ]; await executePath(fixed(pts), ctxOf({ x: 50, y: 50 }, 100, 100), dev, noopLog, cfgOf()); for (const p of dev.commanded.slice(0, 2)) { expect(p.x).toBeGreaterThanOrEqual(2); expect(p.x).toBeLessThanOrEqual(97); } }); test("a monotonic ramp past an edge keeps moving — never two identical points in a row", async () => { // This is the guarantee that motivated removing `clamp`: a clamp would // pin every over-the-edge point to the same edge pixel, stalling the // cursor. Reflection folds the ramp into a triangle wave, so the cursor // both rises and falls and never repeats a pixel step to step. const dev = new FakeDevice(40, 40); // Ramp x well past the right edge and back's worth of travel. const pts = Array.from({ length: 60 }, (_, i) => ({ x: 10 + i, y: 20 })); await executePath(fixed(pts), ctxOf({ x: 10, y: 20 }, 40, 40), dev, noopLog, cfgOf({ stepDelay: 0 })); const xs = dev.commanded.slice(0, 60).map((p) => p.x); // No stall: consecutive commanded points always differ. for (let i = 1; i < xs.length; i++) { expect(xs[i]).not.toBe(xs[i - 1]); } // It bounced: the ramp both increased and decreased at some point. const rose = xs.some((x, i) => i > 0 && x > xs[i - 1]!); const fell = xs.some((x, i) => i > 0 && x < xs[i - 1]!); expect(rose && fell).toBe(true); }); }); describe("executePath — options", () => { test("restore:false leaves the cursor at the last step, no snap-back", async () => { const dev = new FakeDevice(); const start = { x: 500, y: 500 }; const pts = [ { x: 501, y: 500 }, { x: 502, y: 500 }, ]; const outcome = await executePath( fixed(pts), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf(), { restore: false }, ); expect(outcome).toBe("completed"); // No trailing restore-to-start command. expect(dev.commanded).toEqual(pts); }); test("the default (no options) still restores to start", async () => { const dev = new FakeDevice(); const start = { x: 500, y: 500 }; const pts = [{ x: 501, y: 500 }]; await executePath(fixed(pts), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf()); expect(dev.commanded).toEqual([...pts, start]); }); test("loop:true runs loopPath when present, path otherwise", async () => { const dev = new FakeDevice(); // A strategy whose loopPath differs from its path, both finite here. const strat: MovementStrategy = { name: "dual", *path(): Generator { yield { x: 1, y: 1 }; }, *loopPath(): Generator { yield { x: 10, y: 10 }; yield { x: 20, y: 20 }; }, }; await executePath(strat, ctxOf({ x: 0, y: 0 }, dev.w, dev.h), dev, noopLog, cfgOf(), { loop: true, restore: false, }); expect(dev.commanded).toEqual([{ x: 10, y: 10 }, { x: 20, y: 20 }]); }); test("loop:true falls back to path when the strategy has no loopPath", async () => { const dev = new FakeDevice(); const strat = fixed([{ x: 3, y: 3 }]); await executePath(strat, ctxOf({ x: 0, y: 0 }, dev.w, dev.h), dev, noopLog, cfgOf(), { loop: true, restore: false, }); expect(dev.commanded).toEqual([{ x: 3, y: 3 }]); }); }); describe("executePath — readback tolerance", () => { test("a readback within tolerance is not treated as interruption", async () => { const dev = new FakeDevice(); const start = { x: 500, y: 500 }; const pts = [ { x: 510, y: 500 }, { x: 520, y: 500 }, ]; // Each in-sweep readback lands 2px off the commanded point (OS jitter, // not the user). 2px is within READBACK_TOLERANCE, so the sweep runs on. dev.overrides.set(1, { x: 512, y: 501 }); dev.overrides.set(2, { x: 518, y: 499 }); const outcome = await executePath(fixed(pts), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf()); expect(outcome).toBe("completed"); expect(dev.commanded).toEqual([...pts, start]); }); test("a readback beyond tolerance is treated as interruption", async () => { const dev = new FakeDevice(); const start = { x: 500, y: 500 }; const pts = [ { x: 510, y: 500 }, { x: 520, y: 500 }, ]; // First readback is 3px off -> exceeds the 2px tolerance -> real user. dev.overrides.set(1, { x: 513, y: 500 }); const outcome = await executePath(fixed(pts), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf()); expect(outcome).toBe("interrupted"); expect(dev.commanded).toEqual([pts[0]!]); }); }); describe("executePath — rounding & pacing", () => { test("fractional targets are rounded and do not read as interruption", async () => { const dev = new FakeDevice(); const start = { x: 500, y: 500 }; const pts = [{ x: 10.4, y: 20.6 }]; // -> (10, 21) const outcome = await executePath(fixed(pts), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf()); expect(outcome).toBe("completed"); expect(dev.commanded[0]).toEqual({ x: 10, y: 21 }); }); test("sleeps once per step with the configured stepDelay", async () => { const dev = new FakeDevice(); const pts = [ { x: 501, y: 500 }, { x: 502, y: 500 }, ]; await executePath(fixed(pts), ctxOf({ x: 500, y: 500 }, dev.w, dev.h), dev, noopLog, cfgOf({ stepDelay: 7 })); expect(dev.sleeps).toEqual([7, 7]); }); });