Introduce a continuous "loop" setting so a triggered sweep keeps the
cursor moving until the user moves the mouse (or Ctrl+C), instead of
firing a single sweep.
- strategies.ts: add optional `loopPath` to MovementStrategy; give `line`
and `diagonal` infinite loop generators that pick a direction once and
ramp forever (4px/step). Their finite `path` and declared `bounds` are
unchanged, so single-sweep behavior is identical.
- executor.ts: add ExecuteOptions { restore?, bounds?, loop? }. Omitting
options reproduces the original single-sweep contract exactly.
- keeper.ts: in loop mode, run an infinite loopPath once (stopped only by
interruption) or chain a finite path cycle after cycle; force `reflect`
bounds for every pattern and suppress the between-cycle restore, so
line/diagonal bounce edge-to-edge instead of stopping at the first edge.
- config plumbing: new boolean `loop` through config.default.json,
config.ts, configFile.ts, cli.ts (-l/--loop), and move.ts, mirroring
the existing `verbose` precedence.
- docs: README loop-mode section + usage/validation updates; CHANGELOG
Unreleased entry.
- tests: loopPath generators, executor options (bounds override, loop
selection, restore suppression), config/configFile loop plumbing, and
keeper-level loop behavior (ramps far vs. bounded single-sweep, chained
cycles). 79 pass.
126 lines
5.0 KiB
TypeScript
126 lines
5.0 KiB
TypeScript
/**
|
|
* 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, 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);
|
|
});
|
|
});
|
|
|
|
describe("runKeeper — loop mode", () => {
|
|
const maxX = (pts: Point[]): number => pts.reduce((m, p) => Math.max(m, p.x), -Infinity);
|
|
|
|
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);
|
|
});
|
|
});
|