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:
@@ -17,6 +17,7 @@ const NONE: ConfigOverrides = {
|
||||
stepDelay: undefined,
|
||||
pattern: undefined,
|
||||
verbose: undefined,
|
||||
loop: undefined,
|
||||
};
|
||||
|
||||
describe("resolveConfig", () => {
|
||||
@@ -78,6 +79,21 @@ describe("resolveConfig", () => {
|
||||
const cfg = resolveConfig(null, NONE);
|
||||
expect(cfg.verbose).toBe(DEFAULT_CONFIG.verbose);
|
||||
});
|
||||
|
||||
test("loop: CLI true wins over file false", () => {
|
||||
const cfg = resolveConfig({ ...NONE, loop: false }, { ...NONE, loop: true });
|
||||
expect(cfg.loop).toBe(true);
|
||||
});
|
||||
|
||||
test("loop: file true wins over default (no CLI)", () => {
|
||||
const cfg = resolveConfig({ ...NONE, loop: true }, NONE);
|
||||
expect(cfg.loop).toBe(true);
|
||||
});
|
||||
|
||||
test("loop: falls back to DEFAULT_CONFIG.loop when neither set", () => {
|
||||
const cfg = resolveConfig(null, NONE);
|
||||
expect(cfg.loop).toBe(DEFAULT_CONFIG.loop);
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultConfigPath", () => {
|
||||
|
||||
@@ -98,6 +98,17 @@ describe("loadConfigFile (explicit path)", () => {
|
||||
expect(() => loadConfigFile(path)).toThrow(/'verbose'.*boolean/);
|
||||
});
|
||||
|
||||
test("accepts a boolean loop", () => {
|
||||
const path = writeFixture("loop.json", JSON.stringify({ loop: true }));
|
||||
const result = loadConfigFile(path);
|
||||
expect(result!.loop).toBe(true);
|
||||
});
|
||||
|
||||
test("throws when loop is the wrong type", () => {
|
||||
const path = writeFixture("loop-bad.json", JSON.stringify({ loop: "yes" }));
|
||||
expect(() => loadConfigFile(path)).toThrow(/'loop'.*boolean/);
|
||||
});
|
||||
|
||||
test("accepts a known pattern", () => {
|
||||
const path = writeFixture("pattern.json", JSON.stringify({ pattern: "arc" }));
|
||||
const result = loadConfigFile(path);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -89,3 +89,37 @@ describe("runKeeper", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,6 +36,16 @@ function mulberry32(seed: number): () => number {
|
||||
};
|
||||
}
|
||||
|
||||
/** Pull the first `n` points from a (possibly infinite) point iterable. */
|
||||
function take(iter: Iterable<Point>, n: number): Point[] {
|
||||
const out: Point[] = [];
|
||||
for (const p of iter) {
|
||||
out.push(p);
|
||||
if (out.length >= n) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function ctxOf(overrides: {
|
||||
start?: Point;
|
||||
width?: number;
|
||||
@@ -67,6 +77,14 @@ describe("line", () => {
|
||||
expect(pts[1]!.x).toBe(88);
|
||||
expect(pts.at(-1)!.x).toBe(90 - 250);
|
||||
});
|
||||
|
||||
test("loopPath ramps x forever at a fixed step, y held constant", () => {
|
||||
const start = { x: 500, y: 300 };
|
||||
const pts = take(line.loopPath!(ctxOf({ start })), 5);
|
||||
// Monotonic +4 per step (LINE_LOOP_STEP), no vertical drift.
|
||||
expect(pts.map((p) => p.x)).toEqual([504, 508, 512, 516, 520]);
|
||||
expect(pts.every((p) => p.y === 300)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("diagonal", () => {
|
||||
@@ -76,6 +94,16 @@ describe("diagonal", () => {
|
||||
expect(pts[0]!).toEqual({ x: 501, y: 501 });
|
||||
expect(pts.at(-1)!).toEqual({ x: 750, y: 750 });
|
||||
});
|
||||
|
||||
test("loopPath ramps both axes forever at a fixed step", () => {
|
||||
const pts = take(diagonal.loopPath!(ctxOf({ start: { x: 100, y: 200 } })), 3);
|
||||
// Both axes advance by DIAGONAL_LOOP_STEP (4) each step.
|
||||
expect(pts).toEqual([
|
||||
{ x: 104, y: 204 },
|
||||
{ x: 108, y: 208 },
|
||||
{ x: 112, y: 212 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("jitter", () => {
|
||||
|
||||
Reference in New Issue
Block a user