/** * 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"; 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): Promise { try { await runKeeper(config, device); } 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, stepCount: 3, stepSize: 1, pattern: "line" }), dev); // A sweep issued setPosition commands (3 steps + restore); 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, stepCount: 3, pattern: "line" }), dev); expect(dev.commanded.length).toBe(0); }); });