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:
+44
-5
@@ -47,6 +47,31 @@ export interface Logger {
|
||||
*/
|
||||
export type SweepOutcome = "completed" | "interrupted" | "aborted";
|
||||
|
||||
/**
|
||||
* Per-call knobs for `executePath`. All optional; the defaults reproduce the
|
||||
* original single-sweep behavior exactly, so every existing caller and test
|
||||
* is unaffected.
|
||||
*
|
||||
* - `restore` — restore the cursor to `ctx.start` after a clean sweep.
|
||||
* Default `true`. Loop (`--loop`) mode passes `false`:
|
||||
* chained cycles must not snap back between iterations, and an
|
||||
* infinite `loopPath` never reaches the restore anyway.
|
||||
* - `bounds` — override the strategy's declared `BoundsPolicy`. Loop mode
|
||||
* forces `"reflect"` for every pattern so edge-seeking paths
|
||||
* bounce off the screen instead of aborting (`line`) or
|
||||
* sticking in a corner (`clamp`). Absent, the strategy's own
|
||||
* `bounds` is used, so single-sweep behavior is unchanged.
|
||||
* - `loop` — prefer the strategy's infinite `loopPath` when it defines
|
||||
* one. Falls back to `path` when the strategy has no
|
||||
* `loopPath`, so a plain chained-repeat caller can pass this
|
||||
* unconditionally.
|
||||
*/
|
||||
export interface ExecuteOptions {
|
||||
readonly restore?: boolean;
|
||||
readonly bounds?: BoundsPolicy;
|
||||
readonly loop?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slack, in pixels, allowed between the coordinate we commanded and the one
|
||||
* we read back before calling it real-user activity. Absorbs the sub-pixel
|
||||
@@ -145,7 +170,15 @@ function timestamp(): string {
|
||||
* user moved it: return `interrupted` without restoring.
|
||||
*
|
||||
* On a clean run the cursor is restored to `ctx.start` so the next
|
||||
* idle-check sees no net movement, and `completed` is returned.
|
||||
* idle-check sees no net movement, and `completed` is returned — unless
|
||||
* `options.restore === false` (loop mode), in which case the cursor is
|
||||
* left where the last step put it.
|
||||
*
|
||||
* `options` (all optional, see `ExecuteOptions`) let loop mode reuse
|
||||
* this same driver: `bounds` overrides the strategy's policy (loop mode
|
||||
* forces `reflect`), `loop` selects the strategy's infinite `loopPath`, and
|
||||
* `restore` suppresses the snap-back. Omitting `options` reproduces the
|
||||
* original single-sweep contract exactly.
|
||||
*
|
||||
* `config` supplies only the pacing (`stepDelay`); a strategy's geometry is
|
||||
* entirely self-contained, so the path itself needs nothing from it.
|
||||
@@ -156,13 +189,17 @@ export async function executePath(
|
||||
device: Device,
|
||||
log: Logger,
|
||||
config: Config,
|
||||
options?: ExecuteOptions,
|
||||
): Promise<SweepOutcome> {
|
||||
const { start, width, height } = ctx;
|
||||
const policy: BoundsPolicy = options?.bounds ?? strategy.bounds;
|
||||
const path: Iterable<Point> =
|
||||
options?.loop && strategy.loopPath ? strategy.loopPath(ctx) : strategy.path(ctx);
|
||||
|
||||
log.event(`Simulating activity (${strategy.name}) at ${timestamp()}...`);
|
||||
|
||||
for (const target of strategy.path(ctx)) {
|
||||
const point: Point | null = resolveTarget(strategy.bounds, target, width, height);
|
||||
for (const target of path) {
|
||||
const point: Point | null = resolveTarget(policy, target, width, height);
|
||||
if (point === null) {
|
||||
log.event(`Out of bounds at ${timestamp()}; aborting simulation.`);
|
||||
return "aborted";
|
||||
@@ -191,7 +228,9 @@ export async function executePath(
|
||||
}
|
||||
}
|
||||
|
||||
await device.setPosition({ x: Math.round(start.x), y: Math.round(start.y) });
|
||||
log.event("Mouse moved.");
|
||||
if (options?.restore !== false) {
|
||||
await device.setPosition({ x: Math.round(start.x), y: Math.round(start.y) });
|
||||
log.event("Mouse moved.");
|
||||
}
|
||||
return "completed";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user