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.
267 lines
10 KiB
TypeScript
267 lines
10 KiB
TypeScript
/**
|
|
* 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<number, Point>();
|
|
constructor(public w = 1920, public h = 1080, public initial: Point = { x: 0, y: 0 }) {}
|
|
|
|
async getPosition(): Promise<Point> {
|
|
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<void> {
|
|
this.commanded.push(p);
|
|
}
|
|
async width(): Promise<number> {
|
|
return this.w;
|
|
}
|
|
async height(): Promise<number> {
|
|
return this.h;
|
|
}
|
|
async sleep(ms: number): Promise<void> {
|
|
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<Point> {
|
|
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>): 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, "clamp"), 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, "clamp"), 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);
|
|
});
|
|
|
|
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, cfgOf());
|
|
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, cfgOf());
|
|
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, cfgOf());
|
|
expect(dev.commanded[0]).toEqual({ x: 74, y: 50 });
|
|
});
|
|
});
|
|
|
|
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, "clamp"),
|
|
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, "clamp"), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf());
|
|
expect(dev.commanded).toEqual([...pts, start]);
|
|
});
|
|
|
|
test("bounds override supersedes the strategy's declared policy", async () => {
|
|
const dev = new FakeDevice(100, 100);
|
|
// Declared 'abort' would stop before this out-of-bounds point; the
|
|
// 'reflect' override folds it back inside instead (span [2,97]:
|
|
// x=120 -> 74) and the sweep completes.
|
|
const strat = fixed([{ x: 120, y: 50 }], "abort");
|
|
const outcome = await executePath(
|
|
strat,
|
|
ctxOf({ x: 50, y: 50 }, 100, 100),
|
|
dev,
|
|
noopLog,
|
|
cfgOf(),
|
|
{ bounds: "reflect", restore: false },
|
|
);
|
|
expect(outcome).toBe("completed");
|
|
expect(dev.commanded[0]).toEqual({ x: 74, y: 50 });
|
|
});
|
|
|
|
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",
|
|
bounds: "clamp",
|
|
*path(): Generator<Point> {
|
|
yield { x: 1, y: 1 };
|
|
},
|
|
*loopPath(): Generator<Point> {
|
|
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 }], "clamp");
|
|
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, "clamp"), 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, "clamp"), 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, "clamp"), 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, "clamp"), ctxOf({ x: 500, y: 500 }, dev.w, dev.h), dev, noopLog, cfgOf({ stepDelay: 7 }));
|
|
expect(dev.sleeps).toEqual([7, 7]);
|
|
});
|
|
});
|