Add pluggable movement strategies (v1.3.0)

Turn the hardcoded straight-line sweep into a strategy system behind three
seams so new patterns are easy to add and, for the first time, testable
without nut.js or a real screen:

- src/device.ts:     injectable Device seam over nut.js (autoDelayMs lives
                     here now); the only module that touches the native lib.
- src/strategies.ts: pure per-pattern path generators + registry + lenient
                     name resolution. Ships line, diagonal, jitter, walk,
                     arc, figureEight.
- src/executor.ts:   single executePath driver owning bounds policy
                     (abort/clamp/reflect), pacing, interrupt detection, and
                     restore-on-clean.

keeper.ts's simulateActivity now selects a strategy and delegates to the
executor; the default `line` pattern is byte-for-byte the previous behavior.

New config surface, layered CLI > file > default with strict validation:
- -p/--pattern <name>   movement strategy (names matched case/-/_-insensitive)
- -s/--step-size <px>   pixels per step; stepCount is now a step *count*

Robustness for the new edge-seeking patterns: interrupt detection compares
against the last commanded (rounded) point with a 2px tolerance, and
clamp/reflect stay a couple pixels off the screen edge, so sub-pixel cursor
placement on scaled/multi-monitor displays isn't misread as user activity.
jitter's radius scales with sweep length so it moves at the default stepSize.

Tests: new suites for strategies, the executor (all bounds policies,
rounding, interrupt, tolerance, pacing), and the keeper loop; config and
configFile suites extended for pattern/stepSize. editor.test.ts moved to
tests/ for consistency. 64 pass.
This commit is contained in:
2026-08-13 15:36:22 -05:00
parent 7777b16540
commit db3310c247
18 changed files with 1319 additions and 153 deletions
+90
View File
@@ -0,0 +1,90 @@
/**
* 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<Point> {
if (this.positions) return this.positions.shift() ?? this.initial;
return this.commanded.at(-1) ?? this.initial;
}
async setPosition(p: Point): Promise<void> {
this.commanded.push(p);
}
async width(): Promise<number> {
return 1920;
}
async height(): Promise<number> {
return 1080;
}
async sleep(): Promise<void> {
if (++this.sleepCount > this.budget) throw new StopError();
}
}
const quietConfig = (overrides: Partial<Config>): Config => ({
...DEFAULT_CONFIG,
verbose: false,
...overrides,
});
async function runUntilStop(config: Config, device: Device): Promise<void> {
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);
});
});