Remove stepCount/stepSize; patterns own their geometry

The stepCount and stepSize knobs were two controls for one quantity users
actually care about (reach), and the number of steps is an implementation
detail nobody meaningfully tunes. Each pattern has a natural size and
resolution — a jitter is inherently small, an arc a broad curve — so those
now live as constants in each strategy rather than as global config.

- strategies.ts: each pattern defines its own step count and size; MoveContext
  drops `config` down to pure geometry (start/width/height/rng), and the
  module no longer imports Config at all (dissolving the type-only-import
  cycle workaround). line stays byte-for-byte: 250 one-pixel steps.
- executor.ts: executePath takes `config` for pacing (stepDelay); the path
  itself needs nothing from it.
- config.ts / cli.ts / move.ts / config.default.json: drop stepCount and
  stepSize from the type, seed, validation, resolver, CLI flags (-n, -s),
  and help. stepDelay stays as the one pacing lever.
- configFile.ts: tolerate the removed keys instead of rejecting them — every
  pre-1.3.0 install seeded stepCount, so a hard "unknown key" failure on
  upgrade is avoided. They're ignored with a one-line stderr notice; genuine
  unknown keys still error.

The -n/--step-count CLI flag (shipped since 1.0.0) is now an unknown option;
config files degrade gracefully, command lines don't. Stays in the unpushed
1.3.0 release. 64 tests pass.
This commit is contained in:
2026-08-14 12:56:22 -05:00
parent db3310c247
commit ec33648e74
15 changed files with 233 additions and 224 deletions
+10 -5
View File
@@ -11,8 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Pluggable movement strategies. New `-p, --pattern <name>` flag and - Pluggable movement strategies. New `-p, --pattern <name>` flag and
`pattern` config key select how the cursor moves: `line` (default, `pattern` config key select how the cursor moves: `line` (default,
unchanged behavior), `diagonal`, `jitter`, `walk`, `arc`, `figureEight`. unchanged behavior), `diagonal`, `jitter`, `walk`, `arc`, `figureEight`.
- `-s, --step-size <pixels>` decouples pixels-per-step from `stepCount` Each pattern owns its own size and step count as constants; there is no
(which is now a step *count*, not a pixel distance). Default `1`. user knob for sweep magnitude.
- Pattern names are matched leniently: case and separators are ignored, so - Pattern names are matched leniently: case and separators are ignored, so
`figureEight`, `figure-eight`, `figure_eight`, and `FIGUREEIGHT` are all `figureEight`, `figure-eight`, `figure_eight`, and `FIGUREEIGHT` are all
accepted (on the CLI and in the config file) and resolve to the canonical accepted (on the CLI and in the config file) and resolve to the canonical
@@ -36,9 +36,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `mouse.config.autoDelayMs = 0` moved from `runKeeper` into - `mouse.config.autoDelayMs = 0` moved from `runKeeper` into
`createNutDevice` — the single place nut.js is wired up. `createNutDevice` — the single place nut.js is wired up.
- `runKeeper(config, device?)` accepts an injected device for testing. - `runKeeper(config, device?)` accepts an injected device for testing.
- `jitter`'s radius now scales with the sweep length (like the other
patterns) instead of `stepSize` alone, so it produces real cursor motion
at the default `stepSize` of 1 rather than hovering within a 2px radius.
- Interrupt detection tolerates a small (2px) gap between the commanded and - Interrupt detection tolerates a small (2px) gap between the commanded and
read-back cursor position, and the `clamp`/`reflect` patterns stay a few read-back cursor position, and the `clamp`/`reflect` patterns stay a few
pixels off the screen edge. Together these avoid false "user activity" pixels off the screen edge. Together these avoid false "user activity"
@@ -46,6 +43,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
which the new edge-seeking patterns would otherwise hit. `line` (policy which the new edge-seeking patterns would otherwise hit. `line` (policy
`abort`) is unaffected. `abort`) is unaffected.
### Removed
- `-n, --step-count` flag and the `stepCount` / `stepSize` config keys. Sweep
size and step count are now intrinsic to each movement pattern, not user
knobs. Config files that still contain these keys keep working: the loader
ignores them with a one-line notice instead of rejecting them, so existing
installs (all seeded with `stepCount`) don't break on upgrade. The removed
CLI flag, however, is a hard error like any other unknown option.
## [1.2.0] - 2026-06-17 ## [1.2.0] - 2026-06-17
### Added ### Added
+24 -23
View File
@@ -90,11 +90,10 @@ Options:
-m, --move-interval <seconds> Idle time before a sweep fires. Default: 240. -m, --move-interval <seconds> Idle time before a sweep fires. Default: 240.
-c, --check-interval <seconds> Cursor poll cadence. Default: 10. -c, --check-interval <seconds> Cursor poll cadence. Default: 10.
-d, --step-delay <ms> Pause between synthetic steps. Default: 50. -d, --step-delay <ms> Pause between synthetic steps. Default: 50.
-n, --step-count <count> Steps per sweep. Default: 250.
-s, --step-size <pixels> Pixels moved per step. Default: 1.
-p, --pattern <name> Movement strategy. Default: line. -p, --pattern <name> Movement strategy. Default: line.
One of: line, diagonal, jitter, walk, arc, One of: line, diagonal, jitter, walk, arc,
figureEight. figureEight. 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).
@@ -150,8 +149,6 @@ doesn't set.
"moveInterval": 240, "moveInterval": 240,
"checkInterval": 10, "checkInterval": 10,
"stepDelay": 50, "stepDelay": 50,
"stepCount": 250,
"stepSize": 1,
"pattern": "line", "pattern": "line",
"verbose": false "verbose": false
} }
@@ -159,9 +156,13 @@ doesn't set.
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, `stepCount` is `checkInterval` are seconds, `stepDelay` is milliseconds, `pattern` is a
a step count, `stepSize` is pixels-per-step, `pattern` is a movement movement strategy name, `verbose` is a boolean.
strategy name, `verbose` is a boolean.
> The obsolete `stepCount` / `stepSize` keys (removed in 1.3.0) are
> tolerated for backward compatibility: they're ignored with a one-line
> notice rather than rejected, so a config seeded by an older install keeps
> working. Sweep size and step count are now properties of each pattern.
### Editing ### Editing
@@ -241,8 +242,6 @@ Defaults live in `src/config.ts` as `DEFAULT_CONFIG`:
| `moveInterval` | `4 * 60_000` | `-m`, `--move-interval` | Idle time (ms) required before a synthetic sweep fires. | | `moveInterval` | `4 * 60_000` | `-m`, `--move-interval` | Idle time (ms) required before a synthetic sweep fires. |
| `checkInterval` | `10_000` | `-c`, `--check-interval` | How often (ms) the main loop polls the cursor for real activity. | | `checkInterval` | `10_000` | `-c`, `--check-interval` | How often (ms) the main loop polls the cursor for real activity. |
| `stepDelay` | `50` | `-d`, `--step-delay` | Pause (ms) between individual synthetic steps in a sweep. | | `stepDelay` | `50` | `-d`, `--step-delay` | Pause (ms) between individual synthetic steps in a sweep. |
| `stepCount` | `250` | `-n`, `--step-count` | Number of steps per sweep. |
| `stepSize` | `1` | `-s`, `--step-size` | Pixels moved per step. |
| `pattern` | `"line"` | `-p`, `--pattern` | Movement strategy name (see Movement strategies below). | | `pattern` | `"line"` | `-p`, `--pattern` | Movement strategy name (see Movement strategies below). |
`-m` and `-c` are accepted in seconds at the CLI; `resolveConfig` converts `-m` and `-c` are accepted in seconds at the CLI; `resolveConfig` converts
@@ -287,20 +286,22 @@ grabbing the mouse. `line` uses the `abort` policy and is unaffected.
`config.pattern` selects one of the generators in `src/strategies.ts`: `config.pattern` selects one of the generators in `src/strategies.ts`:
| Name | Motion | Bounds | | Name | Motion | Steps | Size | Bounds |
| ------------- | ------------------------------------------------------------- | --------- | | ------------- | ------------------------------------------------------------- | ----- | -------- | --------- |
| `line` | Straight horizontal sweep (the original behavior). | `abort` | | `line` | Straight horizontal sweep (the original behavior). | 250 | 250px | `abort` |
| `diagonal` | Straight line on both axes toward the roomiest corner. | `clamp` | | `diagonal` | Straight line on both axes toward the roomiest corner. | 250 | 250px/axis | `clamp` |
| `jitter` | Small random hops within a local radius that scales with reach. | `clamp` | | `jitter` | Small random hops within a tight radius of the start. | 80 | 30px radius | `clamp` |
| `walk` | Cumulative random walk; bounces off the screen edges. | `reflect` | | `walk` | Cumulative random walk; bounces off the screen edges. | 200 | ±4px/step | `reflect` |
| `arc` | Smooth quadratic-Bézier curve to a random on-screen point. | `clamp` | | `arc` | Smooth quadratic-Bézier curve to a random on-screen point. | 120 | ~300px | `clamp` |
| `figureEight` | Traces a figure-eight (lemniscate) and returns to the start. | `clamp` | | `figureEight` | Traces a figure-eight (lemniscate) and returns to the start. | 90 | ~250px wide | `clamp` |
`stepCount` is the number of steps; `stepSize` is how many pixels each step Each pattern owns its geometry — how many steps it takes and how far it
travels (so total reach is `stepCount * stepSize`). With the default reaches — as constants in `src/strategies.ts`. Those are properties of the
`stepSize` of 1, `line` produces the identical 1px-per-step path it always pattern, not user preferences, so there is no knob for sweep size or step
has. To add a pattern, write one pure generator and register it — the count; `stepDelay` (the per-step pause) is the only pacing lever, and it
executor supplies bounds, pacing, interrupt, and restore for free. scales every pattern's total duration. To add a pattern, write one pure
generator and register it — the executor supplies bounds, pacing, interrupt,
and restore for free.
### Why `mouse.config.autoDelayMs = 0` ### Why `mouse.config.autoDelayMs = 0`
-2
View File
@@ -2,8 +2,6 @@
"moveInterval": 240, "moveInterval": 240,
"checkInterval": 10, "checkInterval": 10,
"stepDelay": 50, "stepDelay": 50,
"stepCount": 250,
"stepSize": 1,
"pattern": "line", "pattern": "line",
"verbose": false "verbose": false
} }
+2 -11
View File
@@ -16,8 +16,6 @@
* -m, --move-interval Idle time (seconds) before a sweep fires. * -m, --move-interval Idle time (seconds) before a sweep fires.
* -c, --check-interval Cursor poll cadence (seconds). * -c, --check-interval Cursor poll cadence (seconds).
* -d, --step-delay Pause between synthetic steps (ms). * -d, --step-delay Pause between synthetic steps (ms).
* -n, --step-count Steps per sweep (count).
* -s, --step-size Pixels moved per step.
* -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`.)
@@ -48,8 +46,6 @@ export interface ParsedCliArgs {
moveInterval: number | undefined; // seconds moveInterval: number | undefined; // seconds
checkInterval: number | undefined; // seconds checkInterval: number | undefined; // seconds
stepDelay: number | undefined; // milliseconds stepDelay: number | undefined; // milliseconds
stepCount: number | undefined; // count
stepSize: number | undefined; // pixels
/** Movement strategy name, validated against the registry. */ /** Movement strategy name, validated against the registry. */
pattern: string | undefined; pattern: string | undefined;
/** /**
@@ -107,8 +103,6 @@ export function parseCliArgs(): ParsedCliArgs {
"move-interval": { type: "string", short: "m" }, "move-interval": { type: "string", short: "m" },
"check-interval": { type: "string", short: "c" }, "check-interval": { type: "string", short: "c" },
"step-delay": { type: "string", short: "d" }, "step-delay": { type: "string", short: "d" },
"step-count": { type: "string", short: "n" },
"step-size": { type: "string", short: "s" },
pattern: { type: "string", short: "p" }, pattern: { type: "string", short: "p" },
verbose: { type: "boolean", short: "V" }, verbose: { type: "boolean", short: "V" },
}, },
@@ -131,8 +125,6 @@ export function parseCliArgs(): ParsedCliArgs {
moveInterval: parsePositiveNumber("move-interval", values["move-interval"] as string | undefined), moveInterval: parsePositiveNumber("move-interval", values["move-interval"] as string | undefined),
checkInterval: parsePositiveNumber("check-interval", values["check-interval"] as string | undefined), checkInterval: parsePositiveNumber("check-interval", values["check-interval"] as string | undefined),
stepDelay: parsePositiveNumber("step-delay", values["step-delay"] as string | undefined), stepDelay: parsePositiveNumber("step-delay", values["step-delay"] as string | undefined),
stepCount: parsePositiveNumber("step-count", values["step-count"] as string | undefined),
stepSize: parsePositiveNumber("step-size", values["step-size"] 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,
}; };
@@ -176,10 +168,9 @@ Options:
-m, --move-interval <seconds> Idle time before a sweep fires. Default: ${moveDefaultSec}. -m, --move-interval <seconds> Idle time before a sweep fires. Default: ${moveDefaultSec}.
-c, --check-interval <seconds> Cursor poll cadence. Default: ${checkDefaultSec}. -c, --check-interval <seconds> Cursor poll cadence. Default: ${checkDefaultSec}.
-d, --step-delay <ms> Pause between synthetic steps. Default: ${DEFAULT_CONFIG.stepDelay}. -d, --step-delay <ms> Pause between synthetic steps. Default: ${DEFAULT_CONFIG.stepDelay}.
-n, --step-count <count> Steps per sweep. Default: ${DEFAULT_CONFIG.stepCount}.
-s, --step-size <pixels> Pixels moved per step. Default: ${DEFAULT_CONFIG.stepSize}.
-p, --pattern <name> Movement strategy. Default: ${DEFAULT_CONFIG.pattern}. -p, --pattern <name> Movement strategy. Default: ${DEFAULT_CONFIG.pattern}.
One of: ${PATTERN_NAMES.join(", ")}. One of: ${PATTERN_NAMES.join(", ")}.
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).
@@ -189,7 +180,7 @@ Examples:
move move
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 --step-size 3 move --pattern arc
move --config ~/myprofile.json move --config ~/myprofile.json
`); `);
} }
+5 -20
View File
@@ -27,8 +27,8 @@ import { isPatternName, type PatternName } from "./strategies.ts";
// Single source of truth for default values. The same file ships in the // Single source of truth for default values. The same file ships in the
// install tree and is copied to $XDG_CONFIG_HOME/move/config.json on a // install tree and is copied to $XDG_CONFIG_HOME/move/config.json on a
// fresh install (only if no config exists there yet). Values use the CLI // fresh install (only if no config exists there yet). Values use the CLI
// units (seconds for time fields, ms for stepDelay, pixels for stepCount); // units (seconds for time fields, ms for stepDelay); the seconds->ms
// the seconds->ms conversion happens below where DEFAULT_CONFIG is built. // conversion happens below where DEFAULT_CONFIG is built.
import seedRaw from "../scripts/config.default.json" with { type: "json" }; import seedRaw from "../scripts/config.default.json" with { type: "json" };
/** /**
@@ -42,12 +42,9 @@ import seedRaw from "../scripts/config.default.json" with { type: "json" };
* - `stepDelay` — pause between individual synthetic mouse steps inside * - `stepDelay` — pause between individual synthetic mouse steps inside
* a sweep. Also the window in which the user can * a sweep. Also the window in which the user can
* "interrupt" by moving the cursor. Milliseconds. * "interrupt" by moving the cursor. Milliseconds.
* - `stepCount` — number of steps in a single sweep. Count.
* - `stepSize` — pixels moved per step. Decouples "how many steps"
* from "how far each step travels" so non-linear
* patterns can span meaningful distances. Pixels.
* - `pattern` — name of the movement strategy to use (see * - `pattern` — name of the movement strategy to use (see
* `strategies.ts`; e.g. `line`, `walk`, `arc`). * `strategies.ts`; e.g. `line`, `walk`, `arc`). Each
* 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.
*/ */
@@ -55,8 +52,6 @@ export interface Config {
readonly moveInterval: number; readonly moveInterval: number;
readonly checkInterval: number; readonly checkInterval: number;
readonly stepDelay: number; readonly stepDelay: number;
readonly stepCount: number;
readonly stepSize: number;
readonly pattern: PatternName; readonly pattern: PatternName;
readonly verbose: boolean; readonly verbose: boolean;
} }
@@ -71,8 +66,6 @@ interface SeedShape {
moveInterval: number; // seconds moveInterval: number; // seconds
checkInterval: number; // seconds checkInterval: number; // seconds
stepDelay: number; // milliseconds stepDelay: number; // milliseconds
stepCount: number; // count
stepSize: number; // pixels
pattern: string; // strategy name pattern: string; // strategy name
verbose: boolean; verbose: boolean;
} }
@@ -82,7 +75,7 @@ function assertSeedShape(raw: unknown): asserts raw is SeedShape {
throw new Error("scripts/config.default.json: root must be an object"); throw new Error("scripts/config.default.json: root must be an object");
} }
const r = raw as Record<string, unknown>; const r = raw as Record<string, unknown>;
for (const key of ["moveInterval", "checkInterval", "stepDelay", "stepCount", "stepSize"] as const) { for (const key of ["moveInterval", "checkInterval", "stepDelay"] as const) {
const v = r[key]; const v = r[key];
if (typeof v !== "number" || !Number.isFinite(v) || v <= 0) { if (typeof v !== "number" || !Number.isFinite(v) || v <= 0) {
throw new Error(`scripts/config.default.json: '${key}' must be a positive finite number (got ${JSON.stringify(v)})`); throw new Error(`scripts/config.default.json: '${key}' must be a positive finite number (got ${JSON.stringify(v)})`);
@@ -110,8 +103,6 @@ export const DEFAULT_CONFIG: Config = {
moveInterval: seed.moveInterval * 1000, moveInterval: seed.moveInterval * 1000,
checkInterval: seed.checkInterval * 1000, checkInterval: seed.checkInterval * 1000,
stepDelay: seed.stepDelay, stepDelay: seed.stepDelay,
stepCount: seed.stepCount,
stepSize: seed.stepSize,
pattern: seed.pattern, pattern: seed.pattern,
verbose: seed.verbose, verbose: seed.verbose,
}; };
@@ -125,8 +116,6 @@ export const DEFAULT_CONFIG: Config = {
* Numeric fields are in CLI / config-file units: * Numeric fields are in CLI / config-file units:
* moveInterval, checkInterval — seconds * moveInterval, checkInterval — seconds
* stepDelay — milliseconds * stepDelay — milliseconds
* stepCount — count
* stepSize — pixels
* *
* `pattern` is a strategy name (`string | undefined`) and `verbose` is * `pattern` is a strategy name (`string | undefined`) and `verbose` is
* `boolean | undefined`, so every field shares the same "first defined * `boolean | undefined`, so every field shares the same "first defined
@@ -142,8 +131,6 @@ export interface ConfigOverrides {
readonly moveInterval: number | undefined; readonly moveInterval: number | undefined;
readonly checkInterval: number | undefined; readonly checkInterval: number | undefined;
readonly stepDelay: number | undefined; readonly stepDelay: number | undefined;
readonly stepCount: number | undefined;
readonly stepSize: number | undefined;
readonly pattern: string | undefined; readonly pattern: string | undefined;
readonly verbose: boolean | undefined; readonly verbose: boolean | undefined;
} }
@@ -210,8 +197,6 @@ export function resolveConfig(file: ConfigOverrides | null, cli: ConfigOverrides
moveInterval: pickSeconds(cli.moveInterval, file?.moveInterval, DEFAULT_CONFIG.moveInterval), moveInterval: pickSeconds(cli.moveInterval, file?.moveInterval, DEFAULT_CONFIG.moveInterval),
checkInterval: pickSeconds(cli.checkInterval, file?.checkInterval, DEFAULT_CONFIG.checkInterval), checkInterval: pickSeconds(cli.checkInterval, file?.checkInterval, DEFAULT_CONFIG.checkInterval),
stepDelay: pickRaw(cli.stepDelay, file?.stepDelay, DEFAULT_CONFIG.stepDelay), stepDelay: pickRaw(cli.stepDelay, file?.stepDelay, DEFAULT_CONFIG.stepDelay),
stepCount: pickRaw(cli.stepCount, file?.stepCount, DEFAULT_CONFIG.stepCount),
stepSize: pickRaw(cli.stepSize, file?.stepSize, DEFAULT_CONFIG.stepSize),
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),
}; };
+34 -18
View File
@@ -10,14 +10,14 @@
* moveInterval number seconds, positive * moveInterval number seconds, positive
* checkInterval number seconds, positive * checkInterval number seconds, positive
* stepDelay number milliseconds, positive * stepDelay number milliseconds, positive
* stepCount number count, positive
* stepSize number pixels, positive
* pattern string a registered strategy name * pattern string a registered strategy name
* verbose boolean * verbose 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
* message pointing at the offending file. * message pointing at the offending file. The removed `stepCount` /
* `stepSize` keys are the exception: they're tolerated (ignored with a
* one-line notice) so an older seeded config keeps working after upgrade.
* *
* Return semantics: * Return semantics:
* - `null` when no `explicitPath` was passed and the default path does * - `null` when no `explicitPath` was passed and the default path does
@@ -37,12 +37,23 @@ const ALLOWED_KEYS: ReadonlySet<string> = new Set<string>([
"moveInterval", "moveInterval",
"checkInterval", "checkInterval",
"stepDelay", "stepDelay",
"stepCount",
"stepSize",
"pattern", "pattern",
"verbose", "verbose",
]); ]);
/**
* Keys that used to be valid but have since been removed. They're tolerated
* (not rejected like a genuine unknown key) so upgrading doesn't hard-fail a
* config that was seeded with them — every pre-1.3.0 install has `stepCount`
* in its file. They no longer do anything: sweep size and step count are now
* properties of each movement pattern. A one-line notice points the user at
* the file so they can remove them at leisure.
*/
const DEPRECATED_KEYS: ReadonlySet<string> = new Set<string>([
"stepCount",
"stepSize",
]);
function isPlainObject(value: unknown): value is Record<string, unknown> { function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value); return typeof value === "object" && value !== null && !Array.isArray(value);
} }
@@ -119,13 +130,26 @@ export function loadConfigFile(explicitPath: string | undefined): ConfigOverride
throw new CliError(`config file ${path} must contain a JSON object at the root`); throw new CliError(`config file ${path} must contain a JSON object at the root`);
} }
// Strict mode: reject any key we don't know about. Catches typos like // Strict mode: reject any key we don't know about (catches typos like
// 'movInterval' that would otherwise sail through silently. // 'movInterval'), but tolerate keys we've since removed — collect those
// and warn once, rather than hard-failing a config seeded by an older
// install.
const deprecatedFound: string[] = [];
for (const key of Object.keys(parsed)) { for (const key of Object.keys(parsed)) {
if (!ALLOWED_KEYS.has(key)) { if (ALLOWED_KEYS.has(key)) continue;
const allowed: string = [...ALLOWED_KEYS].join(", "); if (DEPRECATED_KEYS.has(key)) {
throw new CliError(`unknown key '${key}' in ${path} (allowed: ${allowed})`); deprecatedFound.push(key);
continue;
} }
const allowed: string = [...ALLOWED_KEYS].join(", ");
throw new CliError(`unknown key '${key}' in ${path} (allowed: ${allowed})`);
}
if (deprecatedFound.length > 0) {
const names: string = deprecatedFound.map((k) => `'${k}'`).join(", ");
process.stderr.write(
`move: ignoring obsolete key(s) ${names} in ${path}\n` +
` (sweep size is now defined by each movement pattern)\n`,
);
} }
return { return {
@@ -141,14 +165,6 @@ export function loadConfigFile(explicitPath: string | undefined): ConfigOverride
"stepDelay" in parsed "stepDelay" in parsed
? requirePositiveNumber("stepDelay", parsed.stepDelay, path) ? requirePositiveNumber("stepDelay", parsed.stepDelay, path)
: undefined, : undefined,
stepCount:
"stepCount" in parsed
? requirePositiveNumber("stepCount", parsed.stepCount, path)
: undefined,
stepSize:
"stepSize" in parsed
? requirePositiveNumber("stepSize", parsed.stepSize, path)
: undefined,
pattern: pattern:
"pattern" in parsed "pattern" in parsed
? requirePatternName("pattern", parsed.pattern, path) ? requirePatternName("pattern", parsed.pattern, path)
+8 -3
View File
@@ -23,6 +23,7 @@
* without every rounded step being misread as "the user moved the mouse". * without every rounded step being misread as "the user moved the mouse".
*/ */
import type { Config } from "./config.ts";
import type { Device, Point } from "./device.ts"; import type { Device, Point } from "./device.ts";
import type { BoundsPolicy, MoveContext, MovementStrategy } from "./strategies.ts"; import type { BoundsPolicy, MoveContext, MovementStrategy } from "./strategies.ts";
@@ -138,21 +139,25 @@ function timestamp(): string {
* Contract, per step: * Contract, per step:
* 1. Resolve the ideal target to an on-screen integer (bounds policy). * 1. Resolve the ideal target to an on-screen integer (bounds policy).
* An `abort`-policy out-of-bounds target ends the sweep (`aborted`). * An `abort`-policy out-of-bounds target ends the sweep (`aborted`).
* 2. Command the cursor there and sleep `stepDelay` — also the user's * 2. Command the cursor there and sleep `config.stepDelay` — also the
* interrupt window. * user's interrupt window.
* 3. Re-read the cursor. If it isn't at the point we just commanded, the * 3. Re-read the cursor. If it isn't at the point we just commanded, the
* 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.
*
* `config` supplies only the pacing (`stepDelay`); a strategy's geometry is
* entirely self-contained, so the path itself needs nothing from it.
*/ */
export async function executePath( export async function executePath(
strategy: MovementStrategy, strategy: MovementStrategy,
ctx: MoveContext, ctx: MoveContext,
device: Device, device: Device,
log: Logger, log: Logger,
config: Config,
): Promise<SweepOutcome> { ): Promise<SweepOutcome> {
const { start, width, height, config } = ctx; const { start, width, height } = ctx;
log.event(`Simulating activity (${strategy.name}) at ${timestamp()}...`); log.event(`Simulating activity (${strategy.name}) at ${timestamp()}...`);
+2 -2
View File
@@ -61,9 +61,9 @@ async function simulateActivity(config: Config, log: Logger, device: Device): Pr
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, config, rng: Math.random }; const ctx: MoveContext = { start, width, height, rng: Math.random };
await executePath(strategy, ctx, device, log); await executePath(strategy, ctx, device, log, config);
} }
/** /**
-2
View File
@@ -115,8 +115,6 @@ const cliOverrides: ConfigOverrides = {
moveInterval: cliArgs.moveInterval, moveInterval: cliArgs.moveInterval,
checkInterval: cliArgs.checkInterval, checkInterval: cliArgs.checkInterval,
stepDelay: cliArgs.stepDelay, stepDelay: cliArgs.stepDelay,
stepCount: cliArgs.stepCount,
stepSize: cliArgs.stepSize,
pattern: cliArgs.pattern, pattern: cliArgs.pattern,
verbose: cliArgs.verbose, verbose: cliArgs.verbose,
}; };
+71 -68
View File
@@ -14,13 +14,16 @@
* pixels before commanding the cursor and applies the strategy's declared * pixels before commanding the cursor and applies the strategy's declared
* `BoundsPolicy` to keep everything on-screen. * `BoundsPolicy` to keep everything on-screen.
* *
* `Config` is imported type-only so that `config.ts` can import the value * Each pattern owns its own geometry — how many steps it takes, how far it
* exports here (the registry, name list, and validator) without creating a * reaches, how tight its radius is — as module-private constants below. Those
* runtime import cycle. * are properties of the pattern, not user preferences: a jitter is inherently
* small and twitchy, an arc inherently a broad curve. There is deliberately
* no user knob for sweep size or step count; the cadence (`stepDelay`) is the
* only tunable, and it lives in the executor, not here. As a result this
* module needs nothing from `Config` and imports only `Point`.
*/ */
import type { Point } from "./device.ts"; import type { Point } from "./device.ts";
import type { Config } from "./config.ts";
/** /**
* How the executor keeps a strategy's targets on-screen: * How the executor keeps a strategy's targets on-screen:
@@ -47,8 +50,6 @@ export interface MoveContext {
readonly width: number; readonly width: number;
/** Primary-screen height in pixels. */ /** Primary-screen height in pixels. */
readonly height: number; readonly height: number;
/** Resolved runtime config (supplies `stepCount`, `stepSize`, ...). */
readonly config: Config;
/** Uniform [0, 1) source. Defaults to `Math.random`; tests inject a fake. */ /** Uniform [0, 1) source. Defaults to `Math.random`; tests inject a fake. */
readonly rng: () => number; readonly rng: () => number;
} }
@@ -75,33 +76,25 @@ function clamp(v: number, max: number): number {
return v; return v;
} }
/**
* Total pixel reach of a sweep: number of steps times pixels per step.
* Strategies use this to size themselves relative to the configured sweep
* length regardless of `stepSize`.
*/
function reachOf(config: Config): number {
return config.stepCount * config.stepSize;
}
/** /**
* `line` — the original behavior, preserved exactly. * `line` — the original behavior, preserved exactly.
* *
* Pick a horizontal direction that keeps the sweep on-screen (right if * Pick a horizontal direction that keeps the sweep on-screen (right if
* there's room, else left); walk `stepCount` steps of `stepSize` pixels * there's room, else left) and walk `LINE_STEPS` single-pixel steps with no
* with no vertical movement. With the default `stepSize` of 1 this emits * vertical movement. 250 one-pixel steps is byte-for-byte the sweep the
* the identical integer 1px-per-step path the keeper used before the * keeper produced before movement patterns existed, which is why its bounds
* strategy refactor, which is why its bounds policy is `abort` (the * policy is `abort` (the direction choice guarantees it never triggers).
* direction choice guarantees it never triggers).
*/ */
const LINE_STEPS = 250;
export const line: MovementStrategy = { export const line: MovementStrategy = {
name: "line", name: "line",
bounds: "abort", bounds: "abort",
*path(ctx: MoveContext): Generator<Point> { *path(ctx: MoveContext): Generator<Point> {
const { start, width, config } = ctx; const { start, width } = ctx;
const dx: number = start.x + reachOf(config) < width ? 1 : -1; const dx: number = start.x + LINE_STEPS < width ? 1 : -1;
for (let i = 1; i <= config.stepCount; i++) { for (let i = 1; i <= LINE_STEPS; i++) {
yield { x: start.x + i * dx * config.stepSize, y: start.y }; yield { x: start.x + i * dx, y: start.y };
} }
}, },
}; };
@@ -109,42 +102,42 @@ export const line: MovementStrategy = {
/** /**
* `diagonal` — straight line on both axes at once. Each axis's direction is * `diagonal` — straight line on both axes at once. Each axis's direction is
* 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. * roomiest corner and stays on-screen. 250 single-pixel steps per axis
* (≈250px reach), matching `line`'s magnitude.
*/ */
const DIAGONAL_STEPS = 250;
export const diagonal: MovementStrategy = { export const diagonal: MovementStrategy = {
name: "diagonal", name: "diagonal",
bounds: "clamp", bounds: "clamp",
*path(ctx: MoveContext): Generator<Point> { *path(ctx: MoveContext): Generator<Point> {
const { start, width, height, config } = ctx; const { start, width, height } = ctx;
const reach: number = reachOf(config); const dx: number = start.x + DIAGONAL_STEPS < width ? 1 : -1;
const dx: number = start.x + reach < width ? 1 : -1; const dy: number = start.y + DIAGONAL_STEPS < height ? 1 : -1;
const dy: number = start.y + reach < height ? 1 : -1; for (let i = 1; i <= DIAGONAL_STEPS; i++) {
for (let i = 1; i <= config.stepCount; i++) { yield { x: start.x + i * dx, y: start.y + i * dy };
yield {
x: start.x + i * dx * config.stepSize,
y: start.y + i * dy * config.stepSize,
};
} }
}, },
}; };
/** /**
* `jitter` — many small random hops within a local radius of the start. * `jitter` — many small random hops within a tight radius of the start.
* Subtle "fidget" activity rather than a broad sweep. The radius scales off * Subtle "fidget" activity rather than a broad sweep. The radius is large
* the sweep length (like the other patterns) so every hop is a real, * enough that every hop is a real, distinct pixel move rather than rounding
* distinct pixel move rather than rounding onto the pixel the cursor is * onto the pixel the cursor already occupies. The executor restores the
* already on. The executor restores the cursor to `start` after a clean * cursor to `start` after a clean run, so the net displacement is zero.
* run, so the net displacement is zero.
*/ */
const JITTER_STEPS = 80;
const JITTER_RADIUS = 30;
export const jitter: MovementStrategy = { export const jitter: MovementStrategy = {
name: "jitter", name: "jitter",
bounds: "clamp", bounds: "clamp",
*path(ctx: MoveContext): Generator<Point> { *path(ctx: MoveContext): Generator<Point> {
const { start, config, rng } = ctx; const { start, rng } = ctx;
const radius: number = Math.max(4, reachOf(config) / 8); for (let i = 1; i <= JITTER_STEPS; i++) {
for (let i = 1; i <= config.stepCount; i++) {
const angle: number = rng() * 2 * Math.PI; const angle: number = rng() * 2 * Math.PI;
const r: number = rng() * radius; const r: number = rng() * JITTER_RADIUS;
yield { x: start.x + Math.cos(angle) * r, y: start.y + Math.sin(angle) * r }; yield { x: start.x + Math.cos(angle) * r, y: start.y + Math.sin(angle) * r };
} }
}, },
@@ -152,20 +145,25 @@ export const jitter: MovementStrategy = {
/** /**
* `walk` — an unbounded cumulative random walk: each step adds a random * `walk` — an unbounded cumulative random walk: each step adds a random
* per-axis delta in `[-stepSize, +stepSize]`. The generator itself lets the * per-axis delta in `[-WALK_STEP, +WALK_STEP]`. The per-step magnitude is
* deliberately several pixels so the walk actually roams — a ±1px walk over
* this many steps would drift only ~√N pixels net. The generator lets the
* position drift freely; the executor's `reflect` policy mirrors it back * position drift freely; the executor's `reflect` policy mirrors it back
* on-screen, so the cursor bounces off the edges instead of escaping. * on-screen, so the cursor bounces off the edges instead of escaping.
*/ */
const WALK_STEPS = 200;
const WALK_STEP = 4;
export const walk: MovementStrategy = { export const walk: MovementStrategy = {
name: "walk", name: "walk",
bounds: "reflect", bounds: "reflect",
*path(ctx: MoveContext): Generator<Point> { *path(ctx: MoveContext): Generator<Point> {
const { start, config, rng } = ctx; const { start, rng } = ctx;
let x: number = start.x; let x: number = start.x;
let y: number = start.y; let y: number = start.y;
for (let i = 1; i <= config.stepCount; i++) { for (let i = 1; i <= WALK_STEPS; i++) {
x += (rng() * 2 - 1) * config.stepSize; x += (rng() * 2 - 1) * WALK_STEP;
y += (rng() * 2 - 1) * config.stepSize; y += (rng() * 2 - 1) * WALK_STEP;
yield { x, y }; yield { x, y };
} }
}, },
@@ -173,21 +171,23 @@ export const walk: MovementStrategy = {
/** /**
* `arc` — a smooth quadratic Bézier curve from the start to a random * `arc` — a smooth quadratic Bézier curve from the start to a random
* on-screen endpoint roughly `reach` pixels away, bowed out by a control * on-screen endpoint `ARC_REACH` pixels away, bowed out by a control point
* point offset perpendicular to the straight path. Produces natural, * offset perpendicular to the straight path. `ARC_STEPS` samples keep the
* hand-like curved motion. * curve smooth. Produces natural, hand-like curved motion.
*/ */
const ARC_STEPS = 120;
const ARC_REACH = 300;
export const arc: MovementStrategy = { export const arc: MovementStrategy = {
name: "arc", name: "arc",
bounds: "clamp", bounds: "clamp",
*path(ctx: MoveContext): Generator<Point> { *path(ctx: MoveContext): Generator<Point> {
const { start, width, height, config, rng } = ctx; const { start, width, height, rng } = ctx;
const reach: number = reachOf(config);
// Endpoint: a random direction, `reach` away, clamped on-screen. // Endpoint: a random direction, `ARC_REACH` away, clamped on-screen.
const angle: number = rng() * 2 * Math.PI; const angle: number = rng() * 2 * Math.PI;
const endX: number = clamp(start.x + Math.cos(angle) * reach, width); const endX: number = clamp(start.x + Math.cos(angle) * ARC_REACH, width);
const endY: number = clamp(start.y + Math.sin(angle) * reach, height); const endY: number = clamp(start.y + Math.sin(angle) * ARC_REACH, height);
// Control point: midpoint pushed along the perpendicular so the path // Control point: midpoint pushed along the perpendicular so the path
// bows rather than running straight. Direction/magnitude randomized. // bows rather than running straight. Direction/magnitude randomized.
@@ -196,12 +196,12 @@ export const arc: MovementStrategy = {
const perpX: number = -(endY - start.y); const perpX: number = -(endY - start.y);
const perpY: number = endX - start.x; const perpY: number = endX - start.x;
const perpLen: number = Math.hypot(perpX, perpY) || 1; const perpLen: number = Math.hypot(perpX, perpY) || 1;
const bow: number = (rng() * 2 - 1) * reach * 0.5; const bow: number = (rng() * 2 - 1) * ARC_REACH * 0.5;
const ctrlX: number = clamp(midX + (perpX / perpLen) * bow, width); const ctrlX: number = clamp(midX + (perpX / perpLen) * bow, width);
const ctrlY: number = clamp(midY + (perpY / perpLen) * bow, height); const ctrlY: number = clamp(midY + (perpY / perpLen) * bow, height);
for (let i = 1; i <= config.stepCount; i++) { for (let i = 1; i <= ARC_STEPS; i++) {
const t: number = i / config.stepCount; const t: number = i / ARC_STEPS;
const u: number = 1 - t; const u: number = 1 - t;
yield { yield {
x: u * u * start.x + 2 * u * t * ctrlX + t * t * endX, x: u * u * start.x + 2 * u * t * ctrlX + t * t * endX,
@@ -213,20 +213,23 @@ export const arc: MovementStrategy = {
/** /**
* `figureEight` — traces a Gerono lemniscate (a figure-eight) around the * `figureEight` — traces a Gerono lemniscate (a figure-eight) around the
* start point over one full period, so it returns to the origin. Amplitude * start point over one full period, so it returns to the origin.
* scales with `reach`. * `FIG8_AMP` sets its half-width (≈250px across); `FIG8_STEPS` samples keep
* the curve smooth.
*/ */
const FIG8_STEPS = 90;
const FIG8_AMP = 125;
export const figureEight: MovementStrategy = { export const figureEight: MovementStrategy = {
name: "figureEight", name: "figureEight",
bounds: "clamp", bounds: "clamp",
*path(ctx: MoveContext): Generator<Point> { *path(ctx: MoveContext): Generator<Point> {
const { start, config } = ctx; const { start } = ctx;
const amp: number = reachOf(config) / 2; for (let i = 1; i <= FIG8_STEPS; i++) {
for (let i = 1; i <= config.stepCount; i++) { const t: number = (2 * Math.PI * i) / FIG8_STEPS;
const t: number = (2 * Math.PI * i) / config.stepCount;
yield { yield {
x: start.x + amp * Math.sin(t), x: start.x + FIG8_AMP * Math.sin(t),
y: start.y + amp * Math.sin(t) * Math.cos(t), y: start.y + FIG8_AMP * Math.sin(t) * Math.cos(t),
}; };
} }
}, },
+2 -6
View File
@@ -15,8 +15,6 @@ const NONE: ConfigOverrides = {
moveInterval: undefined, moveInterval: undefined,
checkInterval: undefined, checkInterval: undefined,
stepDelay: undefined, stepDelay: undefined,
stepCount: undefined,
stepSize: undefined,
pattern: undefined, pattern: undefined,
verbose: undefined, verbose: undefined,
}; };
@@ -46,12 +44,10 @@ describe("resolveConfig", () => {
expect(cfg.checkInterval).toBe(2000); expect(cfg.checkInterval).toBe(2000);
}); });
test("stepDelay, stepCount, stepSize pass through untouched (no unit conversion)", () => { test("stepDelay passes through untouched (no unit conversion)", () => {
const cli: ConfigOverrides = { ...NONE, stepDelay: 75, stepCount: 100, stepSize: 4 }; const cli: ConfigOverrides = { ...NONE, stepDelay: 75 };
const cfg = resolveConfig(null, cli); const cfg = resolveConfig(null, cli);
expect(cfg.stepDelay).toBe(75); expect(cfg.stepDelay).toBe(75);
expect(cfg.stepCount).toBe(100);
expect(cfg.stepSize).toBe(4);
}); });
test("pattern: CLI wins over file, file wins over default", () => { test("pattern: CLI wins over file, file wins over default", () => {
+22 -9
View File
@@ -43,7 +43,7 @@ describe("loadConfigFile (explicit path)", () => {
// Fields not in the file are undefined. // Fields not in the file are undefined.
expect(result!.checkInterval).toBeUndefined(); expect(result!.checkInterval).toBeUndefined();
expect(result!.stepDelay).toBeUndefined(); expect(result!.stepDelay).toBeUndefined();
expect(result!.stepCount).toBeUndefined(); expect(result!.pattern).toBeUndefined();
}); });
test("returns all-undefined overrides for an empty object", () => { test("returns all-undefined overrides for an empty object", () => {
@@ -81,8 +81,8 @@ describe("loadConfigFile (explicit path)", () => {
}); });
test("throws on non-positive numeric values", () => { test("throws on non-positive numeric values", () => {
const negative = writeFixture("neg.json", JSON.stringify({ stepCount: -1 })); const negative = writeFixture("neg.json", JSON.stringify({ moveInterval: -1 }));
expect(() => loadConfigFile(negative)).toThrow(/'stepCount'.*positive number/); expect(() => loadConfigFile(negative)).toThrow(/'moveInterval'.*positive number/);
const zero = writeFixture("zero.json", JSON.stringify({ stepDelay: 0 })); const zero = writeFixture("zero.json", JSON.stringify({ stepDelay: 0 }));
expect(() => loadConfigFile(zero)).toThrow(/'stepDelay'.*positive number/); expect(() => loadConfigFile(zero)).toThrow(/'stepDelay'.*positive number/);
@@ -98,11 +98,10 @@ describe("loadConfigFile (explicit path)", () => {
expect(() => loadConfigFile(path)).toThrow(/'verbose'.*boolean/); expect(() => loadConfigFile(path)).toThrow(/'verbose'.*boolean/);
}); });
test("accepts a known pattern and a positive stepSize", () => { test("accepts a known pattern", () => {
const path = writeFixture("pattern.json", JSON.stringify({ pattern: "arc", stepSize: 3 })); const path = writeFixture("pattern.json", JSON.stringify({ pattern: "arc" }));
const result = loadConfigFile(path); const result = loadConfigFile(path);
expect(result!.pattern).toBe("arc"); expect(result!.pattern).toBe("arc");
expect(result!.stepSize).toBe(3);
}); });
test("normalizes a loosely-spelled pattern to its canonical name", () => { test("normalizes a loosely-spelled pattern to its canonical name", () => {
@@ -117,9 +116,23 @@ describe("loadConfigFile (explicit path)", () => {
expect(() => loadConfigFile(path)).toThrow(/line/); expect(() => loadConfigFile(path)).toThrow(/line/);
}); });
test("throws on a non-positive stepSize", () => { test("tolerates obsolete stepCount/stepSize keys, ignoring their values", () => {
const path = writeFixture("badsize.json", JSON.stringify({ stepSize: 0 })); // Seeded by pre-1.3.0 installs; must not hard-fail on upgrade. They're
expect(() => loadConfigFile(path)).toThrow(/'stepSize'.*positive number/); // accepted but not surfaced as overrides (and even an invalid value,
// like a negative, is ignored rather than rejected).
const path = writeFixture(
"obsolete.json",
JSON.stringify({ moveInterval: 60, stepCount: -1, stepSize: 3 }),
);
const result = loadConfigFile(path);
expect(result).not.toBeNull();
expect(result!.moveInterval).toBe(60);
expect(result as unknown as Record<string, unknown>).not.toHaveProperty("stepCount");
});
test("still rejects a genuinely unknown key", () => {
const path = writeFixture("unknown.json", JSON.stringify({ movInterval: 60 }));
expect(() => loadConfigFile(path)).toThrow(/unknown key 'movInterval'/);
}); });
}); });
+16 -11
View File
@@ -61,8 +61,13 @@ function fixed(points: Point[], bounds: BoundsPolicy): MovementStrategy {
}; };
} }
function ctxOf(start: Point, width: number, height: number, config?: Partial<Config>): MoveContext { function ctxOf(start: Point, width: number, height: number): MoveContext {
return { start, width, height, config: { ...DEFAULT_CONFIG, ...config }, rng: Math.random }; 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", () => { describe("executePath — outcomes", () => {
@@ -74,7 +79,7 @@ describe("executePath — outcomes", () => {
{ x: 502, y: 500 }, { x: 502, y: 500 },
{ x: 503, y: 500 }, { x: 503, y: 500 },
]; ];
const outcome = await executePath(fixed(pts, "clamp"), ctxOf(start, dev.w, dev.h), dev, noopLog); const outcome = await executePath(fixed(pts, "clamp"), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf());
expect(outcome).toBe("completed"); expect(outcome).toBe("completed");
// 3 steps + 1 restore. // 3 steps + 1 restore.
expect(dev.commanded).toEqual([...pts, start]); expect(dev.commanded).toEqual([...pts, start]);
@@ -90,7 +95,7 @@ describe("executePath — outcomes", () => {
]; ];
// 2nd getPosition call reports the user elsewhere. // 2nd getPosition call reports the user elsewhere.
dev.overrides.set(2, { x: 9, y: 9 }); dev.overrides.set(2, { x: 9, y: 9 });
const outcome = await executePath(fixed(pts, "clamp"), ctxOf(start, dev.w, dev.h), dev, noopLog); const outcome = await executePath(fixed(pts, "clamp"), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf());
expect(outcome).toBe("interrupted"); expect(outcome).toBe("interrupted");
// Commanded points 1 and 2 only; never restored to start. // Commanded points 1 and 2 only; never restored to start.
expect(dev.commanded).toEqual([pts[0]!, pts[1]!]); expect(dev.commanded).toEqual([pts[0]!, pts[1]!]);
@@ -100,7 +105,7 @@ describe("executePath — outcomes", () => {
test("abort policy stops before commanding an out-of-bounds point", async () => { test("abort policy stops before commanding an out-of-bounds point", async () => {
const dev = new FakeDevice(100, 100); const dev = new FakeDevice(100, 100);
const pts = [{ x: 150, y: 10 }]; // x >= width const pts = [{ x: 150, y: 10 }]; // x >= width
const outcome = await executePath(fixed(pts, "abort"), ctxOf({ x: 10, y: 10 }, 100, 100), dev, noopLog); const outcome = await executePath(fixed(pts, "abort"), ctxOf({ x: 10, y: 10 }, 100, 100), dev, noopLog, cfgOf());
expect(outcome).toBe("aborted"); expect(outcome).toBe("aborted");
expect(dev.commanded).toEqual([]); expect(dev.commanded).toEqual([]);
}); });
@@ -114,7 +119,7 @@ describe("executePath — bounds policies", () => {
{ x: 9999, y: 50 }, { x: 9999, y: 50 },
]; ];
// travelRange(100) is inset by EDGE_MARGIN (2) to [2, 97]. // 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); 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[0]).toEqual({ x: 2, y: 50 });
expect(dev.commanded[1]).toEqual({ x: 97, y: 50 }); expect(dev.commanded[1]).toEqual({ x: 97, y: 50 });
}); });
@@ -123,7 +128,7 @@ describe("executePath — bounds policies", () => {
const dev = new FakeDevice(100, 100); const dev = new FakeDevice(100, 100);
// Inset range [2, 97], span = 95; x=120 -> (120-2)=118, 190-118=72, +2 = 74. // Inset range [2, 97], span = 95; x=120 -> (120-2)=118, 190-118=72, +2 = 74.
const pts = [{ x: 120, y: 50 }]; const pts = [{ x: 120, y: 50 }];
await executePath(fixed(pts, "reflect"), ctxOf({ x: 50, y: 50 }, 100, 100), dev, noopLog); await executePath(fixed(pts, "reflect"), ctxOf({ x: 50, y: 50 }, 100, 100), dev, noopLog, cfgOf());
expect(dev.commanded[0]).toEqual({ x: 74, y: 50 }); expect(dev.commanded[0]).toEqual({ x: 74, y: 50 });
}); });
}); });
@@ -140,7 +145,7 @@ describe("executePath — readback tolerance", () => {
// not the user). 2px is within READBACK_TOLERANCE, so the sweep runs on. // not the user). 2px is within READBACK_TOLERANCE, so the sweep runs on.
dev.overrides.set(1, { x: 512, y: 501 }); dev.overrides.set(1, { x: 512, y: 501 });
dev.overrides.set(2, { x: 518, y: 499 }); dev.overrides.set(2, { x: 518, y: 499 });
const outcome = await executePath(fixed(pts, "clamp"), ctxOf(start, dev.w, dev.h), dev, noopLog); const outcome = await executePath(fixed(pts, "clamp"), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf());
expect(outcome).toBe("completed"); expect(outcome).toBe("completed");
expect(dev.commanded).toEqual([...pts, start]); expect(dev.commanded).toEqual([...pts, start]);
}); });
@@ -154,7 +159,7 @@ describe("executePath — readback tolerance", () => {
]; ];
// First readback is 3px off -> exceeds the 2px tolerance -> real user. // First readback is 3px off -> exceeds the 2px tolerance -> real user.
dev.overrides.set(1, { x: 513, y: 500 }); dev.overrides.set(1, { x: 513, y: 500 });
const outcome = await executePath(fixed(pts, "clamp"), ctxOf(start, dev.w, dev.h), dev, noopLog); const outcome = await executePath(fixed(pts, "clamp"), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf());
expect(outcome).toBe("interrupted"); expect(outcome).toBe("interrupted");
expect(dev.commanded).toEqual([pts[0]!]); expect(dev.commanded).toEqual([pts[0]!]);
}); });
@@ -165,7 +170,7 @@ describe("executePath — rounding & pacing", () => {
const dev = new FakeDevice(); const dev = new FakeDevice();
const start = { x: 500, y: 500 }; const start = { x: 500, y: 500 };
const pts = [{ x: 10.4, y: 20.6 }]; // -> (10, 21) 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); const outcome = await executePath(fixed(pts, "clamp"), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf());
expect(outcome).toBe("completed"); expect(outcome).toBe("completed");
expect(dev.commanded[0]).toEqual({ x: 10, y: 21 }); expect(dev.commanded[0]).toEqual({ x: 10, y: 21 });
}); });
@@ -176,7 +181,7 @@ describe("executePath — rounding & pacing", () => {
{ x: 501, y: 500 }, { x: 501, y: 500 },
{ x: 502, y: 500 }, { x: 502, y: 500 },
]; ];
await executePath(fixed(pts, "clamp"), ctxOf({ x: 500, y: 500 }, dev.w, dev.h, { stepDelay: 7 }), dev, noopLog); 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]); expect(dev.sleeps).toEqual([7, 7]);
}); });
}); });
+5 -4
View File
@@ -73,9 +73,10 @@ describe("runKeeper", () => {
// moveInterval 0 => any elapsed time counts as "idle long enough", // moveInterval 0 => any elapsed time counts as "idle long enough",
// so the first idle check triggers a sweep deterministically. // so the first idle check triggers a sweep deterministically.
const dev = new LoopDevice(50); const dev = new LoopDevice(50);
await runUntilStop(quietConfig({ moveInterval: 0, stepCount: 3, stepSize: 1, pattern: "line" }), dev); await runUntilStop(quietConfig({ moveInterval: 0, pattern: "line" }), dev);
// A sweep issued setPosition commands (3 steps + restore); an idle // A sweep issued setPosition commands (the sweep is interrupted by the
// loop with no sweep would have issued none. // sleep budget before it finishes, but many steps land); an idle loop
// with no sweep would have issued none.
expect(dev.commanded.length).toBeGreaterThanOrEqual(3); expect(dev.commanded.length).toBeGreaterThanOrEqual(3);
}); });
@@ -84,7 +85,7 @@ describe("runKeeper", () => {
// idleness clock keeps resetting and no sweep ever fires. // idleness clock keeps resetting and no sweep ever fires.
const moving: Point[] = Array.from({ length: 40 }, (_, i) => ({ x: i, y: i })); const moving: Point[] = Array.from({ length: 40 }, (_, i) => ({ x: i, y: i }));
const dev = new LoopDevice(20, { x: 0, y: 0 }, moving); const dev = new LoopDevice(20, { x: 0, y: 0 }, moving);
await runUntilStop(quietConfig({ moveInterval: 0, stepCount: 3, pattern: "line" }), dev); await runUntilStop(quietConfig({ moveInterval: 0, pattern: "line" }), dev);
expect(dev.commanded.length).toBe(0); expect(dev.commanded.length).toBe(0);
}); });
}); });
+32 -40
View File
@@ -9,8 +9,6 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { DEFAULT_CONFIG } from "../src/config.ts";
import type { Config } from "../src/config.ts";
import type { Point } from "../src/device.ts"; import type { Point } from "../src/device.ts";
import { import {
arc, arc,
@@ -42,56 +40,50 @@ function ctxOf(overrides: {
start?: Point; start?: Point;
width?: number; width?: number;
height?: number; height?: number;
config?: Partial<Config>;
rng?: () => number; rng?: () => number;
}): MoveContext { }): MoveContext {
return { return {
start: overrides.start ?? { x: 500, y: 500 }, start: overrides.start ?? { x: 500, y: 500 },
width: overrides.width ?? 1920, width: overrides.width ?? 1920,
height: overrides.height ?? 1080, height: overrides.height ?? 1080,
config: { ...DEFAULT_CONFIG, ...overrides.config },
rng: overrides.rng ?? Math.random, rng: overrides.rng ?? Math.random,
}; };
} }
describe("line", () => { describe("line", () => {
test("emits stepCount points along +x with no vertical movement", () => { test("emits its full 250-step, 250px sweep along +x with no vertical drift (preserved default)", () => {
const pts = [...line.path(ctxOf({ config: { stepCount: 5, stepSize: 1 } }))]; const pts = [...line.path(ctxOf({ start: { x: 500, y: 500 } }))];
expect(pts.length).toBe(5); expect(pts.length).toBe(250);
expect(pts.every((p) => p.y === 500)).toBe(true); expect(pts.every((p) => p.y === 500)).toBe(true);
expect(pts.map((p) => p.x)).toEqual([501, 502, 503, 504, 505]); // 1px per step: 501..750.
}); expect(pts[0]!.x).toBe(501);
expect(pts.at(-1)!.x).toBe(750);
test("honors stepSize for per-step distance", () => {
const pts = [...line.path(ctxOf({ config: { stepCount: 3, stepSize: 10 } }))];
expect(pts.map((p) => p.x)).toEqual([510, 520, 530]);
}); });
test("reverses direction when there is no room to the right", () => { test("reverses direction when there is no room to the right", () => {
const pts = [...line.path(ctxOf({ start: { x: 90, y: 10 }, width: 100, config: { stepCount: 20, stepSize: 1 } }))]; const pts = [...line.path(ctxOf({ start: { x: 90, y: 10 }, width: 100 }))];
expect(pts[0]!.x).toBe(89); expect(pts[0]!.x).toBe(89);
expect(pts.at(-1)!.x).toBe(70); // Heads left: each step decreases x by 1.
expect(pts[1]!.x).toBe(88);
expect(pts.at(-1)!.x).toBe(90 - 250);
}); });
}); });
describe("diagonal", () => { describe("diagonal", () => {
test("moves on both axes toward the roomy corner", () => { test("moves 1px on both axes toward the roomy corner for 250 steps", () => {
const pts = [...diagonal.path(ctxOf({ config: { stepCount: 4, stepSize: 2 } }))]; const pts = [...diagonal.path(ctxOf({ start: { x: 500, y: 500 } }))];
expect(pts.length).toBe(4); expect(pts.length).toBe(250);
expect(pts.map((p) => p.x)).toEqual([502, 504, 506, 508]); expect(pts[0]!).toEqual({ x: 501, y: 501 });
expect(pts.map((p) => p.y)).toEqual([502, 504, 506, 508]); expect(pts.at(-1)!).toEqual({ x: 750, y: 750 });
}); });
}); });
describe("jitter", () => { describe("jitter", () => {
test("stays within its radius of start and returns stepCount points", () => { test("stays within its fixed radius of start across its fixed step count", () => {
const size = 5; const radius = 30; // JITTER_RADIUS
const stepCount = 50;
// Radius scales off the sweep length (stepCount * stepSize) / 8, floored at 4.
const radius = Math.max(4, (stepCount * size) / 8);
const start = { x: 500, y: 500 }; const start = { x: 500, y: 500 };
const pts = [...jitter.path(ctxOf({ start, config: { stepCount, stepSize: size }, rng: mulberry32(1) }))]; const pts = [...jitter.path(ctxOf({ start, rng: mulberry32(1) }))];
expect(pts.length).toBe(stepCount); expect(pts.length).toBe(80); // JITTER_STEPS
for (const p of pts) { for (const p of pts) {
expect(Math.hypot(p.x - start.x, p.y - start.y)).toBeLessThanOrEqual(radius + 1e-9); expect(Math.hypot(p.x - start.x, p.y - start.y)).toBeLessThanOrEqual(radius + 1e-9);
} }
@@ -101,35 +93,35 @@ describe("jitter", () => {
describe("walk", () => { describe("walk", () => {
test("is a cumulative walk; a 0.5-constant rng yields zero net drift", () => { test("is a cumulative walk; a 0.5-constant rng yields zero net drift", () => {
const start = { x: 400, y: 300 }; const start = { x: 400, y: 300 };
const pts = [...walk.path(ctxOf({ start, config: { stepCount: 10, stepSize: 7 }, rng: () => 0.5 }))]; const pts = [...walk.path(ctxOf({ start, rng: () => 0.5 }))];
expect(pts.length).toBe(10); expect(pts.length).toBe(200); // WALK_STEPS
// (0.5*2 - 1) === 0, so every step delta is zero. // (0.5*2 - 1) === 0, so every step delta is zero.
expect(pts.every((p) => p.x === start.x && p.y === start.y)).toBe(true); expect(pts.every((p) => p.x === start.x && p.y === start.y)).toBe(true);
}); });
test("accumulates deltas step over step", () => { test("accumulates finite deltas step over step", () => {
const pts = [...walk.path(ctxOf({ config: { stepCount: 3, stepSize: 4 }, rng: mulberry32(42) }))]; const pts = [...walk.path(ctxOf({ rng: mulberry32(42) }))];
expect(pts.length).toBe(3); expect(pts.length).toBe(200);
expect(pts.every((p) => Number.isFinite(p.x) && Number.isFinite(p.y))).toBe(true); expect(pts.every((p) => Number.isFinite(p.x) && Number.isFinite(p.y))).toBe(true);
}); });
}); });
describe("arc", () => { describe("arc", () => {
test("emits stepCount finite points and lands on its endpoint", () => { test("emits its fixed step count of finite points, deterministic under a fixed seed", () => {
const pts = [...arc.path(ctxOf({ config: { stepCount: 8, stepSize: 20 }, rng: mulberry32(7) }))]; const pts = [...arc.path(ctxOf({ rng: mulberry32(7) }))];
expect(pts.length).toBe(8); expect(pts.length).toBe(120); // ARC_STEPS
expect(pts.every((p) => Number.isFinite(p.x) && Number.isFinite(p.y))).toBe(true); expect(pts.every((p) => Number.isFinite(p.x) && Number.isFinite(p.y))).toBe(true);
// t = 1 at the final step, so B(1) is the endpoint — a stable point. // Same seed -> same endpoint (t = 1 at the final step is a stable point).
const a = [...arc.path(ctxOf({ config: { stepCount: 8, stepSize: 20 }, rng: mulberry32(7) }))]; const again = [...arc.path(ctxOf({ rng: mulberry32(7) }))];
expect(pts.at(-1)).toEqual(a.at(-1)!); expect(pts.at(-1)).toEqual(again.at(-1)!);
}); });
}); });
describe("figureEight", () => { describe("figureEight", () => {
test("returns to the start point after one full period", () => { test("returns to the start point after one full period", () => {
const start = { x: 600, y: 400 }; const start = { x: 600, y: 400 };
const pts = [...figureEight.path(ctxOf({ start, config: { stepCount: 40, stepSize: 10 } }))]; const pts = [...figureEight.path(ctxOf({ start }))];
expect(pts.length).toBe(40); expect(pts.length).toBe(90); // FIG8_STEPS
expect(pts.at(-1)!.x).toBeCloseTo(start.x, 6); expect(pts.at(-1)!.x).toBeCloseTo(start.x, 6);
expect(pts.at(-1)!.y).toBeCloseTo(start.y, 6); expect(pts.at(-1)!.y).toBeCloseTo(start.y, 6);
}); });