/** * executor.test.ts * ---------------- * Unit tests for the execution driver against a fake `Device`. Covers the * three sweep outcomes, all three bounds policies, the rounding/interrupt * contract, and step pacing — 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 { BoundsPolicy, 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 under a chosen bounds policy. */ function fixed(points: Point[], bounds: BoundsPolicy): MovementStrategy { return { name: "fixed", bounds, *path(): Generator { yield* points; }, }; } function ctxOf(start: Point, width: number, height: number, config?: Partial): MoveContext { return { start, width, height, config: { ...DEFAULT_CONFIG, ...config }, rng: Math.random }; } 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, "clamp"), ctxOf(start, dev.w, dev.h), dev, noopLog); 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, "clamp"), ctxOf(start, dev.w, dev.h), dev, noopLog); 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); }); test("abort policy stops before commanding an out-of-bounds point", async () => { const dev = new FakeDevice(100, 100); const pts = [{ x: 150, y: 10 }]; // x >= width const outcome = await executePath(fixed(pts, "abort"), ctxOf({ x: 10, y: 10 }, 100, 100), dev, noopLog); expect(outcome).toBe("aborted"); expect(dev.commanded).toEqual([]); }); }); describe("executePath — bounds policies", () => { test("clamp pins out-of-bounds coordinates to the inset edges", async () => { const dev = new FakeDevice(100, 100); const pts = [ { x: -5, y: 50 }, { x: 9999, y: 50 }, ]; // travelRange(100) is inset by EDGE_MARGIN (2) to [2, 97]. await executePath(fixed(pts, "clamp"), ctxOf({ x: 50, y: 50 }, 100, 100), dev, noopLog); expect(dev.commanded[0]).toEqual({ x: 2, y: 50 }); expect(dev.commanded[1]).toEqual({ x: 97, y: 50 }); }); test("reflect mirrors out-of-bounds coordinates 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, "reflect"), ctxOf({ x: 50, y: 50 }, 100, 100), dev, noopLog); expect(dev.commanded[0]).toEqual({ x: 74, y: 50 }); }); }); 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, "clamp"), ctxOf(start, dev.w, dev.h), dev, noopLog); 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, "clamp"), ctxOf(start, dev.w, dev.h), dev, noopLog); 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, "clamp"), ctxOf(start, dev.w, dev.h), dev, noopLog); 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, "clamp"), ctxOf({ x: 500, y: 500 }, dev.w, dev.h, { stepDelay: 7 }), dev, noopLog); expect(dev.sleeps).toEqual([7, 7]); }); });