Add loop mode (--loop): repeat movement until user activity

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.
This commit is contained in:
2026-08-17 14:36:18 -05:00
parent 1ad724cd33
commit 7e632b3e9d
15 changed files with 397 additions and 31 deletions
+79
View File
@@ -133,6 +133,85 @@ describe("executePath — bounds policies", () => {
});
});
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();