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
+12
View File
@@ -5,6 +5,18 @@ All notable changes to `move` are documented here.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- Loop mode: `-l` / `--loop` (and the `loop` config key) keep the mouse
moving after a sweep is triggered until real user activity is detected,
instead of firing a single sweep. In loop mode the cursor is never restored
between iterations and every pattern's bounds policy is forced to `reflect`,
so `line` and `diagonal` bounce edge-to-edge across the screen (a
roaming-DVD effect) rather than stopping at the first edge. Patterns with a
finite path (`jitter`, `walk`, `arc`, `figureEight`) chain that path cycle
after cycle. Interruption remains mouse-movement only.
## [1.3.3] - 2026-08-17 ## [1.3.3] - 2026-08-17
### Changed ### Changed
+38 -7
View File
@@ -123,6 +123,9 @@ Options:
size and speed. size and speed.
-V, --verbose Log every sweep, interrupt, and bounds event -V, --verbose Log every sweep, interrupt, and bounds event
(default prints only the startup banner). (default prints only the startup banner).
-l, --loop Loop mode: once a sweep is triggered,
keep moving until you move the mouse (or
Ctrl+C), instead of firing a single sweep.
Precedence (highest wins): CLI flags > config file > built-in defaults. Precedence (highest wins): CLI flags > config file > built-in defaults.
``` ```
@@ -180,14 +183,15 @@ doesn't set.
"checkInterval": 10, "checkInterval": 10,
"stepDelay": 50, "stepDelay": 50,
"pattern": "line", "pattern": "line",
"verbose": false "verbose": false,
"loop": false
} }
``` ```
All keys are optional; supply only the ones you want to override. Keys All keys are optional; supply only the ones you want to override. Keys
and units mirror the CLI flags exactly: `moveInterval` and and units mirror the CLI flags exactly: `moveInterval` and
`checkInterval` are seconds, `stepDelay` is milliseconds, `pattern` is a `checkInterval` are seconds, `stepDelay` is milliseconds, `pattern` is a
movement strategy name, `verbose` is a boolean. movement strategy name, `verbose` and `loop` are booleans.
> The obsolete `stepCount` / `stepSize` keys (removed in 1.3.0) are > The obsolete `stepCount` / `stepSize` keys (removed in 1.3.0) are
> tolerated for backward compatibility: they're ignored with a one-line > tolerated for backward compatibility: they're ignored with a one-line
@@ -223,16 +227,36 @@ The loader is strict:
case and separators (`-`, `_`, spaces), so `figure-eight` and `figureEight` case and separators (`-`, `_`, spaces), so `figure-eight` and `figureEight`
are equivalent. are equivalent.
- `verbose` must be a boolean. - `verbose` must be a boolean.
- `loop` must be a boolean.
Any validation failure prints a message naming the file and the offending Any validation failure prints a message naming the file and the offending
key to `stderr` and exits `2`. key to `stderr` and exits `2`.
### Known limitation: `verbose` can be turned on but not off from the CLI ### Loop mode (`--loop`)
`--verbose` is a presence-only flag (there is no `--no-verbose`). If the By default a triggered sweep runs once and stops. With `-l` / `--loop` (or
config file sets `"verbose": true`, the CLI cannot force quiet mode in `"loop": true` in the config file) the movement instead repeats until you
that invocation. Workarounds: edit the file, or point at a different move the mouse (or press `Ctrl+C`) — a "keep moving until I'm back" mode.
file with `--config`. It pairs naturally with the roaming patterns:
```sh
move --pattern diagonal --loop # roaming-DVD bounce around the screen
move --pattern figureEight --loop # traces the eight over and over
```
In loop mode the cursor is never restored between iterations, and every
pattern's bounds policy is forced to `reflect`, so `line` and `diagonal`
bounce edge-to-edge across the whole screen instead of ending at the first
edge. Interruption is detected via mouse movement only — there is no
keyboard hook — so if you resume by typing without touching the mouse, the
cursor keeps cycling until you nudge it or stop the process.
### Known limitation: `verbose` and `loop` can be turned on but not off from the CLI
`--verbose` and `--loop` are presence-only flags (there is no
`--no-verbose` / `--no-loop`). If the config file sets `"verbose": true` or
`"loop": true`, the CLI cannot force it back off in that invocation.
Workarounds: edit the file, or point at a different file with `--config`.
## How it works ## How it works
@@ -307,6 +331,13 @@ to milliseconds before handing the resolved `Config` to `runKeeper`.
the next idle-check sees "no movement" and doesn't misread the synthetic the next idle-check sees "no movement" and doesn't misread the synthetic
activity as real user input. activity as real user input.
In loop mode (`--loop`) step 2 repeats until the user interrupts: a
pattern with an infinite `loopPath` (`line`, `diagonal`) runs that single
never-ending path, while the others chain their finite path cycle after
cycle. The restore in step 3 is skipped so successive cycles flow from where
the last left off, and the bounds policy is forced to `reflect` for every
pattern so edge-seeking motion bounces instead of stopping.
Comparing against the last commanded (rounded) point — not the strategy's Comparing against the last commanded (rounded) point — not the strategy's
ideal, possibly fractional target — is what lets curved and stochastic ideal, possibly fractional target — is what lets curved and stochastic
patterns run without every rounded step looking like user activity. The patterns run without every rounded step looking like user activity. The
+2 -1
View File
@@ -3,5 +3,6 @@
"checkInterval": 10, "checkInterval": 10,
"stepDelay": 50, "stepDelay": 50,
"pattern": "line", "pattern": "line",
"verbose": false "verbose": false,
"loop": false
} }
+13
View File
@@ -19,6 +19,8 @@
* -p, --pattern Movement strategy name (see strategies.ts). * -p, --pattern Movement strategy name (see strategies.ts).
* -V, --verbose Enable per-sweep / interrupt / bounds logging. * -V, --verbose Enable per-sweep / interrupt / bounds logging.
* (`-V` capital because `-v` is `--version`.) * (`-V` capital because `-v` is `--version`.)
* -l, --loop Loop mode: once triggered, keep moving
* until the user moves the mouse (or Ctrl+C).
* *
* Numeric overrides are layered (CLI > file > DEFAULT_CONFIG) by * Numeric overrides are layered (CLI > file > DEFAULT_CONFIG) by
* `resolveConfig` in `config.ts`; this module only parses and validates. * `resolveConfig` in `config.ts`; this module only parses and validates.
@@ -55,6 +57,11 @@ export interface ParsedCliArgs {
* even though the CLI has no off-switch today. * even though the CLI has no off-switch today.
*/ */
verbose: boolean | undefined; verbose: boolean | undefined;
/**
* `true` when `-l`/`--loop` was passed; `undefined` when it was not.
* Same `undefined`-not-`false` rationale as `verbose`.
*/
loop: boolean | undefined;
} }
/** /**
@@ -105,6 +112,7 @@ export function parseCliArgs(): ParsedCliArgs {
"step-delay": { type: "string", short: "d" }, "step-delay": { type: "string", short: "d" },
pattern: { type: "string", short: "p" }, pattern: { type: "string", short: "p" },
verbose: { type: "boolean", short: "V" }, verbose: { type: "boolean", short: "V" },
loop: { type: "boolean", short: "l" },
}, },
strict: true, strict: true,
allowPositionals: false, allowPositionals: false,
@@ -127,6 +135,7 @@ export function parseCliArgs(): ParsedCliArgs {
stepDelay: parsePositiveNumber("step-delay", values["step-delay"] as string | undefined), stepDelay: parsePositiveNumber("step-delay", values["step-delay"] as string | undefined),
pattern: parsePatternName(values.pattern as string | undefined), pattern: parsePatternName(values.pattern as string | undefined),
verbose: values.verbose === true ? true : undefined, verbose: values.verbose === true ? true : undefined,
loop: values.loop === true ? true : undefined,
}; };
} }
@@ -173,6 +182,9 @@ Options:
Each pattern defines its own size and speed. Each pattern defines its own size and speed.
-V, --verbose Log every sweep, interrupt, and bounds event -V, --verbose Log every sweep, interrupt, and bounds event
(default prints only the startup banner). (default prints only the startup banner).
-l, --loop Loop mode: once a sweep is triggered,
keep moving until you move the mouse (or
Ctrl+C), instead of firing a single sweep.
Precedence (highest wins): CLI flags > config file > built-in defaults. Precedence (highest wins): CLI flags > config file > built-in defaults.
@@ -181,6 +193,7 @@ Examples:
move --move-interval 180 --check-interval 5 move --move-interval 180 --check-interval 5
move -m 300 -V move -m 300 -V
move --pattern arc move --pattern arc
move --pattern diagonal --loop
move --config ~/myprofile.json move --config ~/myprofile.json
`); `);
} }
+14 -1
View File
@@ -47,6 +47,10 @@ import seedRaw from "../scripts/config.default.json" with { type: "json" };
* pattern owns its own size and step count. * pattern owns its own size and step count.
* - `verbose` — whether per-sweep / interrupt / bounds events are * - `verbose` — whether per-sweep / interrupt / bounds events are
* logged. The startup banner is always printed. * logged. The startup banner is always printed.
* - `loop` — loop mode: once a sweep is triggered, keep
* repeating the movement until the user moves the mouse
* (or Ctrl+C), rather than firing a single sweep. See
* `keeper.ts` for how the pattern is repeated.
*/ */
export interface Config { export interface Config {
readonly moveInterval: number; readonly moveInterval: number;
@@ -54,6 +58,7 @@ export interface Config {
readonly stepDelay: number; readonly stepDelay: number;
readonly pattern: PatternName; readonly pattern: PatternName;
readonly verbose: boolean; readonly verbose: boolean;
readonly loop: boolean;
} }
/** /**
@@ -68,6 +73,7 @@ interface SeedShape {
stepDelay: number; // milliseconds stepDelay: number; // milliseconds
pattern: string; // strategy name pattern: string; // strategy name
verbose: boolean; verbose: boolean;
loop: boolean;
} }
function assertSeedShape(raw: unknown): asserts raw is SeedShape { function assertSeedShape(raw: unknown): asserts raw is SeedShape {
@@ -87,6 +93,9 @@ function assertSeedShape(raw: unknown): asserts raw is SeedShape {
if (typeof r.verbose !== "boolean") { if (typeof r.verbose !== "boolean") {
throw new Error(`scripts/config.default.json: 'verbose' must be a boolean (got ${JSON.stringify(r.verbose)})`); throw new Error(`scripts/config.default.json: 'verbose' must be a boolean (got ${JSON.stringify(r.verbose)})`);
} }
if (typeof r.loop !== "boolean") {
throw new Error(`scripts/config.default.json: 'loop' must be a boolean (got ${JSON.stringify(r.loop)})`);
}
} }
assertSeedShape(seedRaw); assertSeedShape(seedRaw);
@@ -105,6 +114,7 @@ export const DEFAULT_CONFIG: Config = {
stepDelay: seed.stepDelay, stepDelay: seed.stepDelay,
pattern: seed.pattern, pattern: seed.pattern,
verbose: seed.verbose, verbose: seed.verbose,
loop: seed.loop,
}; };
/** /**
@@ -125,7 +135,8 @@ export const DEFAULT_CONFIG: Config = {
* was not passed and `true` when it was. There is no CLI off-switch * was not passed and `true` when it was. There is no CLI off-switch
* today, so CLI `false` doesn't occur — a file-set `verbose: true` cannot * today, so CLI `false` doesn't occur — a file-set `verbose: true` cannot
* be overridden back to false from the command line (see the Configuration * be overridden back to false from the command line (see the Configuration
* section of the README). * section of the README). `loop` behaves identically: `-l/--loop` sets it
* `true`, and a file-set `loop: true` can't be switched off from the CLI.
*/ */
export interface ConfigOverrides { export interface ConfigOverrides {
readonly moveInterval: number | undefined; readonly moveInterval: number | undefined;
@@ -133,6 +144,7 @@ export interface ConfigOverrides {
readonly stepDelay: number | undefined; readonly stepDelay: number | undefined;
readonly pattern: string | undefined; readonly pattern: string | undefined;
readonly verbose: boolean | undefined; readonly verbose: boolean | undefined;
readonly loop: boolean | undefined;
} }
/** /**
@@ -199,5 +211,6 @@ export function resolveConfig(file: ConfigOverrides | null, cli: ConfigOverrides
stepDelay: pickRaw(cli.stepDelay, file?.stepDelay, DEFAULT_CONFIG.stepDelay), stepDelay: pickRaw(cli.stepDelay, file?.stepDelay, DEFAULT_CONFIG.stepDelay),
pattern: pickRaw(cli.pattern, file?.pattern, DEFAULT_CONFIG.pattern), pattern: pickRaw(cli.pattern, file?.pattern, DEFAULT_CONFIG.pattern),
verbose: pickRaw(cli.verbose, file?.verbose, DEFAULT_CONFIG.verbose), verbose: pickRaw(cli.verbose, file?.verbose, DEFAULT_CONFIG.verbose),
loop: pickRaw(cli.loop, file?.loop, DEFAULT_CONFIG.loop),
}; };
} }
+6
View File
@@ -12,6 +12,7 @@
* stepDelay number milliseconds, positive * stepDelay number milliseconds, positive
* pattern string a registered strategy name * pattern string a registered strategy name
* verbose boolean * verbose boolean
* loop boolean
* *
* Unknown keys, wrong types, and non-positive numerics are rejected with a * Unknown keys, wrong types, and non-positive numerics are rejected with a
* `CliError` so the entry point can exit 2 (user error) with a clear * `CliError` so the entry point can exit 2 (user error) with a clear
@@ -39,6 +40,7 @@ const ALLOWED_KEYS: ReadonlySet<string> = new Set<string>([
"stepDelay", "stepDelay",
"pattern", "pattern",
"verbose", "verbose",
"loop",
]); ]);
/** /**
@@ -173,5 +175,9 @@ export function loadConfigFile(explicitPath: string | undefined): ConfigOverride
"verbose" in parsed "verbose" in parsed
? requireBoolean("verbose", parsed.verbose, path) ? requireBoolean("verbose", parsed.verbose, path)
: undefined, : undefined,
loop:
"loop" in parsed
? requireBoolean("loop", parsed.loop, path)
: undefined,
}; };
} }
+44 -5
View File
@@ -47,6 +47,31 @@ export interface Logger {
*/ */
export type SweepOutcome = "completed" | "interrupted" | "aborted"; 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 * 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 * 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. * user moved it: return `interrupted` without restoring.
* *
* On a clean run the cursor is restored to `ctx.start` so the next * 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 * `config` supplies only the pacing (`stepDelay`); a strategy's geometry is
* entirely self-contained, so the path itself needs nothing from it. * entirely self-contained, so the path itself needs nothing from it.
@@ -156,13 +189,17 @@ export async function executePath(
device: Device, device: Device,
log: Logger, log: Logger,
config: Config, config: Config,
options?: ExecuteOptions,
): Promise<SweepOutcome> { ): Promise<SweepOutcome> {
const { start, width, height } = ctx; 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()}...`); log.event(`Simulating activity (${strategy.name}) at ${timestamp()}...`);
for (const target of strategy.path(ctx)) { for (const target of path) {
const point: Point | null = resolveTarget(strategy.bounds, target, width, height); const point: Point | null = resolveTarget(policy, target, width, height);
if (point === null) { if (point === null) {
log.event(`Out of bounds at ${timestamp()}; aborting simulation.`); log.event(`Out of bounds at ${timestamp()}; aborting simulation.`);
return "aborted"; return "aborted";
@@ -191,7 +228,9 @@ export async function executePath(
} }
} }
await device.setPosition({ x: Math.round(start.x), y: Math.round(start.y) }); if (options?.restore !== false) {
log.event("Mouse moved."); await device.setPosition({ x: Math.round(start.x), y: Math.round(start.y) });
log.event("Mouse moved.");
}
return "completed"; return "completed";
} }
+48 -12
View File
@@ -24,7 +24,7 @@
*/ */
import { createNutDevice, type Device, type Point } from "./device.ts"; import { createNutDevice, type Device, type Point } from "./device.ts";
import { executePath, type Logger } from "./executor.ts"; import { executePath, type Logger, type SweepOutcome } from "./executor.ts";
import { DEFAULT_PATTERN, STRATEGIES, type MoveContext } from "./strategies.ts"; import { DEFAULT_PATTERN, STRATEGIES, type MoveContext } from "./strategies.ts";
import type { Config } from "./config.ts"; import type { Config } from "./config.ts";
@@ -46,24 +46,60 @@ function makeLogger(verbose: boolean): Logger {
} }
/** /**
* Perform a single synthetic mouse-activity sweep. * Perform synthetic mouse activity once the keeper decides the cursor is
* idle.
* *
* Snapshots the cursor and screen (re-read every call so monitor changes * Snapshots the screen (re-read every call so monitor changes are handled)
* are handled), selects the configured strategy from the registry, and * and selects the configured strategy from the registry. An unknown
* hands the resulting path to `executePath`, which owns bounds, pacing, * `config.pattern` falls back to the default strategy defensively; validation
* interrupt detection, and restore-on-clean. An unknown `config.pattern` * at the CLI / config-file boundary should prevent that from ever happening.
* falls back to the default strategy defensively; validation at the CLI / *
* config-file boundary should prevent that from ever happening. * Single-sweep mode (`config.loop === false`) runs exactly one sweep via
* `executePath`, which owns bounds, pacing, interrupt detection, and
* restore-on-clean — unchanged from before loop mode existed.
*
* Loop mode (`config.loop === true`) keeps the cursor moving until the
* user moves the mouse (or Ctrl+C). Two things change for every pattern:
* the cursor is never restored between iterations (`restore: false`), and the
* bounds policy is forced to `reflect` so edge-seeking paths bounce off the
* screen instead of aborting (`line`) or sticking in a corner (`clamp`).
* Patterns that define an infinite `loopPath` (`line`, `diagonal`) run it once
* and are stopped only by interruption; the rest have their finite `path`
* chained, re-read from the cursor's current position each cycle. Per-cycle
* event logs are suppressed to avoid unbounded output — one line brackets the
* run at each end.
*/ */
async function simulateActivity(config: Config, log: Logger, device: Device): Promise<void> { async function simulateActivity(config: Config, log: Logger, device: Device): Promise<void> {
const start: Point = await device.getPosition();
const width: number = await device.width(); const width: number = await device.width();
const height: number = await device.height(); const height: number = await device.height();
const strategy = STRATEGIES[config.pattern] ?? STRATEGIES[DEFAULT_PATTERN]!; const strategy = STRATEGIES[config.pattern] ?? STRATEGIES[DEFAULT_PATTERN]!;
const ctx: MoveContext = { start, width, height, rng: Math.random };
await executePath(strategy, ctx, device, log, config); if (!config.loop) {
const start: Point = await device.getPosition();
const ctx: MoveContext = { start, width, height, rng: Math.random };
await executePath(strategy, ctx, device, log, config);
return;
}
log.event(`Loop mode (${strategy.name}); repeating until you move the mouse.`);
const cycleLog: Logger = { info: log.info, event: (): void => {} };
const loopOpts = { restore: false, bounds: "reflect" as const, loop: true };
let cycles = 0;
let outcome: SweepOutcome;
do {
const start: Point = await device.getPosition();
const ctx: MoveContext = { start, width, height, rng: Math.random };
outcome = await executePath(strategy, ctx, device, cycleLog, config, loopOpts);
cycles++;
// Spin guard for the chained-repeat path: a finite strategy that
// yielded nothing would otherwise return "completed" instantly in a
// tight loop. Sleeping one stepDelay makes that harmless. An infinite
// loopPath never returns "completed", so this branch is skipped there.
if (outcome === "completed") await device.sleep(config.stepDelay);
} while (outcome === "completed");
log.event(`Loop run ended after ${cycles} cycle(s): ${outcome}.`);
} }
/** /**
+1
View File
@@ -121,6 +121,7 @@ const cliOverrides: ConfigOverrides = {
stepDelay: cliArgs.stepDelay, stepDelay: cliArgs.stepDelay,
pattern: cliArgs.pattern, pattern: cliArgs.pattern,
verbose: cliArgs.verbose, verbose: cliArgs.verbose,
loop: cliArgs.loop,
}; };
const config = resolveConfig(fileOverrides, cliOverrides); const config = resolveConfig(fileOverrides, cliOverrides);
+51 -5
View File
@@ -57,16 +57,31 @@ export interface MoveContext {
/** /**
* A named movement pattern. * A named movement pattern.
* *
* - `name` — registry key, also the value accepted by `--pattern` / the * - `name` — registry key, also the value accepted by `--pattern` / the
* `pattern` config key. * `pattern` config key.
* - `bounds` — how the executor confines this pattern to the screen. * - `bounds` — how the executor confines this pattern to the screen.
* - `path` — pure generator of ideal (possibly fractional) targets, * - `path` — pure generator of ideal (possibly fractional) targets,
* emitted in visiting order. Should not re-emit `start`. * emitted in visiting order. Should not re-emit `start`.
* - `loopPath` — optional infinite variant for loop mode (`--loop`).
* A pattern defines it when its finite `path` doesn't chain
* cleanly under repetition: `line`/`diagonal` re-derive their
* direction from the cursor's position every cycle, so chained
* repetition oscillates in a band near an edge instead of
* crossing the screen. An infinite generator picks its
* direction once and ramps forever; the executor's `reflect`
* policy (forced on in loop mode) folds the monotonic ramp
* into an edge-to-edge bounce. Absent this, loop mode simply
* chains `path` — correct for patterns whose finite path is a
* self-contained cyclic unit (`jitter`, `walk`, `arc`,
* `figureEight`). The executor stops either kind on real user
* activity; an infinite `loopPath` therefore only ever ends
* by interruption.
*/ */
export interface MovementStrategy { export interface MovementStrategy {
readonly name: string; readonly name: string;
readonly bounds: BoundsPolicy; readonly bounds: BoundsPolicy;
path(ctx: MoveContext): Iterable<Point>; path(ctx: MoveContext): Iterable<Point>;
loopPath?(ctx: MoveContext): Iterable<Point>;
} }
/** Clamp `v` into the inclusive pixel range `[0, max - 1]`. */ /** Clamp `v` into the inclusive pixel range `[0, max - 1]`. */
@@ -84,8 +99,14 @@ function clamp(v: number, max: number): number {
* vertical movement. 250 one-pixel steps is byte-for-byte the sweep the * vertical movement. 250 one-pixel steps is byte-for-byte the sweep the
* keeper produced before movement patterns existed, which is why its bounds * keeper produced before movement patterns existed, which is why its bounds
* policy is `abort` (the direction choice guarantees it never triggers). * policy is `abort` (the direction choice guarantees it never triggers).
*
* In loop mode `loopPath` ramps x in one direction forever (loop mode
* forces `reflect`, so the direction never matters and the ramp bounces edge
* to edge). `LINE_LOOP_STEP` is several pixels per step rather than one so a
* screen crossing takes seconds, not minutes, at the default cadence.
*/ */
const LINE_STEPS = 250; const LINE_STEPS = 250;
const LINE_LOOP_STEP = 4;
export const line: MovementStrategy = { export const line: MovementStrategy = {
name: "line", name: "line",
@@ -97,6 +118,14 @@ export const line: MovementStrategy = {
yield { x: start.x + i * dx, y: start.y }; yield { x: start.x + i * dx, y: start.y };
} }
}, },
*loopPath(ctx: MoveContext): Generator<Point> {
const { start } = ctx;
let x: number = start.x;
for (;;) {
x += LINE_LOOP_STEP;
yield { x, y: start.y };
}
},
}; };
/** /**
@@ -104,8 +133,15 @@ export const line: MovementStrategy = {
* chosen independently by available room, so the sweep heads toward the * chosen independently by available room, so the sweep heads toward the
* roomiest corner and stays on-screen. 250 single-pixel steps per axis * roomiest corner and stays on-screen. 250 single-pixel steps per axis
* (≈250px reach), matching `line`'s magnitude. * (≈250px reach), matching `line`'s magnitude.
*
* In loop mode `loopPath` ramps both axes forever under the forced
* `reflect` policy. Because the x and y travel ranges have different spans,
* their triangle waves have different periods, so the path precesses across
* the whole screen — the roaming-DVD bounce — rather than retracing one 45°
* line.
*/ */
const DIAGONAL_STEPS = 250; const DIAGONAL_STEPS = 250;
const DIAGONAL_LOOP_STEP = 4;
export const diagonal: MovementStrategy = { export const diagonal: MovementStrategy = {
name: "diagonal", name: "diagonal",
@@ -118,6 +154,16 @@ export const diagonal: MovementStrategy = {
yield { x: start.x + i * dx, y: start.y + i * dy }; yield { x: start.x + i * dx, y: start.y + i * dy };
} }
}, },
*loopPath(ctx: MoveContext): Generator<Point> {
const { start } = ctx;
let x: number = start.x;
let y: number = start.y;
for (;;) {
x += DIAGONAL_LOOP_STEP;
y += DIAGONAL_LOOP_STEP;
yield { x, y };
}
},
}; };
/** /**
+16
View File
@@ -17,6 +17,7 @@ const NONE: ConfigOverrides = {
stepDelay: undefined, stepDelay: undefined,
pattern: undefined, pattern: undefined,
verbose: undefined, verbose: undefined,
loop: undefined,
}; };
describe("resolveConfig", () => { describe("resolveConfig", () => {
@@ -78,6 +79,21 @@ describe("resolveConfig", () => {
const cfg = resolveConfig(null, NONE); const cfg = resolveConfig(null, NONE);
expect(cfg.verbose).toBe(DEFAULT_CONFIG.verbose); 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", () => { describe("defaultConfigPath", () => {
+11
View File
@@ -98,6 +98,17 @@ describe("loadConfigFile (explicit path)", () => {
expect(() => loadConfigFile(path)).toThrow(/'verbose'.*boolean/); 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", () => { test("accepts a known pattern", () => {
const path = writeFixture("pattern.json", JSON.stringify({ pattern: "arc" })); const path = writeFixture("pattern.json", JSON.stringify({ pattern: "arc" }));
const result = loadConfigFile(path); const result = loadConfigFile(path);
+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", () => { describe("executePath — readback tolerance", () => {
test("a readback within tolerance is not treated as interruption", async () => { test("a readback within tolerance is not treated as interruption", async () => {
const dev = new FakeDevice(); const dev = new FakeDevice();
+34
View File
@@ -89,3 +89,37 @@ describe("runKeeper", () => {
expect(dev.commanded.length).toBe(0); 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);
});
});
+28
View File
@@ -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: { function ctxOf(overrides: {
start?: Point; start?: Point;
width?: number; width?: number;
@@ -67,6 +77,14 @@ describe("line", () => {
expect(pts[1]!.x).toBe(88); expect(pts[1]!.x).toBe(88);
expect(pts.at(-1)!.x).toBe(90 - 250); 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", () => { describe("diagonal", () => {
@@ -76,6 +94,16 @@ describe("diagonal", () => {
expect(pts[0]!).toEqual({ x: 501, y: 501 }); expect(pts[0]!).toEqual({ x: 501, y: 501 });
expect(pts.at(-1)!).toEqual({ x: 750, y: 750 }); 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", () => { describe("jitter", () => {