Add random pattern selection (-r / --pattern random)

Pick a different movement pattern every time a sweep is triggered, so the
motion varies across the day instead of repeating one shape.

- strategies.ts: add the `random` sentinel, `SELECTABLE_PATTERN_NAMES`,
  `isSelectablePattern`, and `createRandomPicker`. `random` is deliberately
  NOT a registry entry: it has no path of its own, so `STRATEGIES` stays a
  total lookup and `PATTERN_NAMES` keeps listing only real generators. The
  picker is a closure over `last`, giving a uniform draw that never returns
  the same pattern twice in a row. Building CANONICAL_PATTERNS from the
  selectable list makes both validation boundaries accept `random` (and
  loose spellings) for free, and extends the normalization-collision
  assertion to cover the sentinel.
- cli.ts: add `-r`/`--random` plus an exported `selectPattern` holding the
  conflict rule. `-r` is sugar for `--pattern random`, so the two agreeing
  is a no-op while `-r -p arc` is rejected as contradictory. The flag folds
  into `pattern`, so ConfigOverrides, resolveConfig, and move.ts are
  untouched. `parseCliArgs` now takes its argv as an optional parameter so
  the flag surface is testable without process.argv.
- keeper.ts: resolve `random` via the picker once per trigger, before the
  loop-mode branch, so a pick holds for a whole loop run rather than
  changing mid-run. runKeeper builds one picker for the process, so the
  no-repeat memory spans sweeps minutes apart. Because the pick is a real
  strategy, --verbose logs the concrete pattern name and a pick with an
  infinite loopPath still bounces edge-to-edge under --loop.
- config.ts / configFile.ts: accept the sentinel where a pattern is valid,
  and quote the selectable list in errors. No `random` boolean config key —
  the file spells it "pattern": "random".

executor.ts and move.ts needed no changes.

Tests: new tests/cli.test.ts (the file had no coverage before) covering the
flag surface and the conflict rule; picker tests pinning the no-repeat and
full-registry-coverage properties; keeper tests pinning once-per-trigger and
once-per-loop-run.
This commit is contained in:
2026-08-18 14:36:29 -05:00
parent b019f25a42
commit d38949edb4
12 changed files with 594 additions and 47 deletions
+54 -10
View File
@@ -17,6 +17,10 @@
* -c, --check-interval Cursor poll cadence (seconds).
* -d, --step-delay Pause between synthetic steps (ms).
* -p, --pattern Movement strategy name (see strategies.ts).
* -r, --random Sugar for `--pattern random`: pick a different
* pattern for each sweep. Folded into `pattern`
* here, so nothing downstream knows the flag
* exists. Conflicts with an explicit `--pattern`.
* -V, --verbose Enable per-sweep / interrupt logging.
* (`-V` capital because `-v` is `--version`.)
* -l, --loop Loop mode: once triggered, keep moving
@@ -33,7 +37,7 @@ import { parseArgs } from "node:util";
import { DEFAULT_CONFIG, defaultConfigPath } from "./config.ts";
import { CliError } from "./errors.ts";
import { PATTERN_NAMES, resolvePatternName } from "./strategies.ts";
import { RANDOM_PATTERN, SELECTABLE_PATTERN_NAMES, resolvePatternName } from "./strategies.ts";
/**
* Result of `parseCliArgs`. Numeric fields are `undefined` when the user
@@ -48,7 +52,13 @@ export interface ParsedCliArgs {
moveInterval: number | undefined; // seconds
checkInterval: number | undefined; // seconds
stepDelay: number | undefined; // milliseconds
/** Movement strategy name, validated against the registry. */
/**
* Movement strategy name, validated against the registry — or the
* `random` sentinel, which `-r/--random` also folds into this field.
* There is deliberately no separate `random` boolean: the flag's entire
* effect is the value here, so downstream layering (`ConfigOverrides`,
* `resolveConfig`) needs no knowledge of it.
*/
pattern: string | undefined;
/**
* `true` when `-V`/`--verbose` was passed; `undefined` when it was not.
@@ -87,21 +97,48 @@ function parsePatternName(raw: string | undefined): string | undefined {
if (raw === undefined) return undefined;
const canonical: string | null = resolvePatternName(raw);
if (canonical === null) {
throw new CliError(`invalid value for --pattern: '${raw}' (valid: ${PATTERN_NAMES.join(", ")})`);
throw new CliError(
`invalid value for --pattern: '${raw}' (valid: ${SELECTABLE_PATTERN_NAMES.join(", ")})`,
);
}
return canonical;
}
/**
* Parse `process.argv` into a typed `ParsedCliArgs`. Uses Node's built-in
* `parseArgs` in strict mode so unknown flags and missing values surface
* as `CliError`s that the entry point can turn into exit code 2.
* Fold `-r/--random` and `--pattern` into the single pattern selection that
* the rest of the program consumes.
*
* `-r` is defined as sugar for `--pattern random`, so passing both spellings
* of the same request (`-r --pattern random`) is a harmless no-op. Any other
* pairing states two different intentions at once, and silently honoring one
* would hide the user's mistake — so it's rejected. The message quotes the
* user's own spelling rather than the canonical name, since that's what they
* need to find and fix on their command line.
*
* Exported so the conflict rule is testable without touching `process.argv`.
*/
export function parseCliArgs(): ParsedCliArgs {
export function selectPattern(rawPattern: string | undefined, random: boolean): string | undefined {
const canonical: string | undefined = parsePatternName(rawPattern);
if (!random) return canonical;
if (canonical !== undefined && canonical !== RANDOM_PATTERN) {
throw new CliError(`-r/--random conflicts with --pattern '${rawPattern}' (pick one)`);
}
return RANDOM_PATTERN;
}
/**
* Parse command-line arguments into a typed `ParsedCliArgs`. Uses Node's
* built-in `parseArgs` in strict mode so unknown flags and missing values
* surface as `CliError`s that the entry point can turn into exit code 2.
*
* @param argv - Argument list to parse, defaulting to the real command line.
* Injectable so the flag surface can be unit-tested directly.
*/
export function parseCliArgs(argv: string[] = process.argv.slice(2)): ParsedCliArgs {
let values: Record<string, string | boolean | undefined>;
try {
const result = parseArgs({
args: process.argv.slice(2),
args: argv,
options: {
help: { type: "boolean", short: "h" },
version: { type: "boolean", short: "v" },
@@ -111,6 +148,7 @@ export function parseCliArgs(): ParsedCliArgs {
"check-interval": { type: "string", short: "c" },
"step-delay": { type: "string", short: "d" },
pattern: { type: "string", short: "p" },
random: { type: "boolean", short: "r" },
verbose: { type: "boolean", short: "V" },
loop: { type: "boolean", short: "l" },
},
@@ -133,7 +171,7 @@ export function parseCliArgs(): ParsedCliArgs {
moveInterval: parsePositiveNumber("move-interval", values["move-interval"] as string | undefined),
checkInterval: parsePositiveNumber("check-interval", values["check-interval"] as string | undefined),
stepDelay: parsePositiveNumber("step-delay", values["step-delay"] as string | undefined),
pattern: parsePatternName(values.pattern as string | undefined),
pattern: selectPattern(values.pattern as string | undefined, values.random === true),
verbose: values.verbose === true ? true : undefined,
loop: values.loop === true ? true : undefined,
};
@@ -178,8 +216,12 @@ Options:
-c, --check-interval <seconds> Cursor poll cadence. Default: ${checkDefaultSec}.
-d, --step-delay <ms> Pause between synthetic steps. Default: ${DEFAULT_CONFIG.stepDelay}.
-p, --pattern <name> Movement strategy. Default: ${DEFAULT_CONFIG.pattern}.
One of: ${PATTERN_NAMES.join(", ")}.
One of: ${SELECTABLE_PATTERN_NAMES.join(", ")}.
Each pattern defines its own size and speed.
-r, --random Shorthand for --pattern random. Picks a
different pattern for each sweep, never the
same one twice in a row. In loop mode the
pick holds for the whole loop run.
-V, --verbose Log every sweep and interrupt
(default prints only the startup banner).
-l, --loop Loop mode: once a sweep is triggered,
@@ -194,6 +236,8 @@ Examples:
move -m 300 -V
move --pattern arc
move --pattern diagonal --loop
move -r
move --pattern random --loop
move --config ~/myprofile.json
`);
}
+7 -4
View File
@@ -22,7 +22,7 @@
import { join } from "node:path";
import { CliError } from "./errors.ts";
import { isPatternName, type PatternName } from "./strategies.ts";
import { isSelectablePattern, type PatternName } from "./strategies.ts";
// 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
@@ -44,7 +44,10 @@ import seedRaw from "../scripts/config.default.json" with { type: "json" };
* "interrupt" by moving the cursor. Milliseconds.
* - `pattern` — name of the movement strategy to use (see
* `strategies.ts`; e.g. `line`, `walk`, `arc`). Each
* pattern owns its own size and step count.
* pattern owns its own size and step count. May also be
* the `random` sentinel, which is not a registry key:
* the keeper resolves it to a real strategy once per
* sweep rather than looking it up here.
* - `verbose` — whether per-sweep / interrupt events are logged. The
* startup banner is always printed.
* - `loop` — loop mode: once a sweep is triggered, keep
@@ -71,7 +74,7 @@ interface SeedShape {
moveInterval: number; // seconds
checkInterval: number; // seconds
stepDelay: number; // milliseconds
pattern: string; // strategy name
pattern: string; // strategy name, or the `random` sentinel
verbose: boolean;
loop: boolean;
}
@@ -87,7 +90,7 @@ function assertSeedShape(raw: unknown): asserts raw is SeedShape {
throw new Error(`scripts/config.default.json: '${key}' must be a positive finite number (got ${JSON.stringify(v)})`);
}
}
if (typeof r.pattern !== "string" || !isPatternName(r.pattern)) {
if (typeof r.pattern !== "string" || !isSelectablePattern(r.pattern)) {
throw new Error(`scripts/config.default.json: 'pattern' must be a known strategy name (got ${JSON.stringify(r.pattern)})`);
}
if (typeof r.verbose !== "boolean") {
+7 -3
View File
@@ -10,10 +10,14 @@
* moveInterval number seconds, positive
* checkInterval number seconds, positive
* stepDelay number milliseconds, positive
* pattern string a registered strategy name
* pattern string a registered strategy name, or "random"
* verbose boolean
* loop boolean
*
* There is no `random` boolean key: the CLI's `-r` is defined as sugar for
* `--pattern random`, so the file expresses the same request as
* `"pattern": "random"` rather than as a second, redundant switch.
*
* 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
* message pointing at the offending file. The removed `stepCount` /
@@ -32,7 +36,7 @@ import { existsSync, readFileSync, statSync } from "node:fs";
import { defaultConfigPath, type ConfigOverrides } from "./config.ts";
import { CliError } from "./errors.ts";
import { PATTERN_NAMES, resolvePatternName } from "./strategies.ts";
import { SELECTABLE_PATTERN_NAMES, resolvePatternName } from "./strategies.ts";
const ALLOWED_KEYS: ReadonlySet<string> = new Set<string>([
"moveInterval",
@@ -82,7 +86,7 @@ function requirePatternName(name: string, raw: unknown, path: string): string {
const canonical: string | null = typeof raw === "string" ? resolvePatternName(raw) : null;
if (canonical === null) {
throw new CliError(
`invalid value for '${name}' in ${path}: ${JSON.stringify(raw)} (valid: ${PATTERN_NAMES.join(", ")})`,
`invalid value for '${name}' in ${path}: ${JSON.stringify(raw)} (valid: ${SELECTABLE_PATTERN_NAMES.join(", ")})`,
);
}
return canonical;
+38 -7
View File
@@ -25,7 +25,14 @@
import { createNutDevice, type Device, type Point } from "./device.ts";
import { executePath, type Logger, type SweepOutcome } from "./executor.ts";
import { DEFAULT_PATTERN, STRATEGIES, type MoveContext } from "./strategies.ts";
import {
createRandomPicker,
DEFAULT_PATTERN,
RANDOM_PATTERN,
STRATEGIES,
type MoveContext,
type MovementStrategy,
} from "./strategies.ts";
import type { Config } from "./config.ts";
@@ -54,6 +61,14 @@ function makeLogger(verbose: boolean): Logger {
* `config.pattern` falls back to the default strategy defensively; validation
* at the CLI / config-file boundary should prevent that from ever happening.
*
* `pattern: "random"` isn't a registry key — it asks for a fresh pattern per
* sweep, so `pickRandom` supplies one here. The pick happens once, before the
* loop-mode branch below, which is what makes a random selection hold for an
* entire loop run rather than changing under the user mid-run; the picker's
* own no-repeat memory then spans sweeps, since the keeper holds one picker
* for the life of the process. Because the pick is a real strategy, the log
* lines below and in `executePath` name the concrete pattern, not "random".
*
* Single-sweep mode (`config.loop === false`) runs exactly one sweep via
* `executePath`, which owns on-screen reflection, pacing, interrupt
* detection, and restore-on-clean — unchanged from before loop mode existed.
@@ -66,10 +81,18 @@ function makeLogger(verbose: boolean): Logger {
* 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,
pickRandom: () => MovementStrategy,
): Promise<void> {
const width: number = await device.width();
const height: number = await device.height();
const strategy = STRATEGIES[config.pattern] ?? STRATEGIES[DEFAULT_PATTERN]!;
const strategy: MovementStrategy =
config.pattern === RANDOM_PATTERN
? pickRandom()
: (STRATEGIES[config.pattern] ?? STRATEGIES[DEFAULT_PATTERN]!);
if (!config.loop) {
const start: Point = await device.getPosition();
@@ -119,10 +142,18 @@ async function simulateActivity(config: Config, log: Logger, device: Device): Pr
* cursor, and on a user-interrupted sweep the next iteration sees the
* user's new position and correctly resets the clock.
*
* @param config - Resolved runtime config.
* @param device - I/O device; defaults to the production nut.js device.
* @param config - Resolved runtime config.
* @param device - I/O device; defaults to the production nut.js device.
* @param pickRandom - Supplies a strategy when `config.pattern` is `random`.
* Created once here (not per sweep) so its no-repeat
* memory spans the whole run; injectable so tests can
* drive a deterministic sequence.
*/
export async function runKeeper(config: Config, device?: Device): Promise<void> {
export async function runKeeper(
config: Config,
device?: Device,
pickRandom: () => MovementStrategy = createRandomPicker(),
): Promise<void> {
const dev: Device = device ?? (await createNutDevice());
const log = makeLogger(config.verbose);
@@ -144,7 +175,7 @@ export async function runKeeper(config: Config, device?: Device): Promise<void>
}
if (now - lastActivity >= config.moveInterval) {
await simulateActivity(config, log, dev);
await simulateActivity(config, log, dev, pickRandom);
// The sweep either restored the cursor to its start (clean) or
// left it where the user moved it (interrupt). Either way, reset
// the clock and require another full moveInterval of inactivity
+69 -7
View File
@@ -281,9 +281,28 @@ export const STRATEGIES: Readonly<Record<string, MovementStrategy>> = {
/** Pattern used when neither the CLI nor the config file selects one. */
export const DEFAULT_PATTERN = "line";
/** All valid pattern names, for validation messages and help text. */
/** All registered strategy names. Real generators only — see `RANDOM_PATTERN`. */
export const PATTERN_NAMES: readonly string[] = Object.keys(STRATEGIES);
/**
* The reserved name for "pick a different pattern each sweep".
*
* Deliberately NOT a registry entry: it has no path of its own, so there is
* nothing for `STRATEGIES` to hold and nothing for the executor to drive. It
* is a *selection* the user makes, resolved to a real strategy once per sweep
* by the keeper (see `createRandomPicker`). Keeping it out of the registry is
* what lets `STRATEGIES[name]` stay a total lookup for every key it contains.
*/
export const RANDOM_PATTERN = "random";
/**
* Everything the user may pass to `--pattern` / the `pattern` config key:
* the registry names plus the `random` sentinel. This is the list to quote in
* help text and validation errors; `PATTERN_NAMES` is the narrower "real
* generators" list that the keeper and the strategy tests care about.
*/
export const SELECTABLE_PATTERN_NAMES: readonly string[] = [...PATTERN_NAMES, RANDOM_PATTERN];
/**
* The set of valid `--pattern` / `pattern` values as a string-literal-ish
* type. Kept as `string` at the type level (the registry is the runtime
@@ -296,6 +315,16 @@ export function isPatternName(name: string): boolean {
return Object.prototype.hasOwnProperty.call(STRATEGIES, name);
}
/**
* True when `name` is something the user may legitimately select: a registered
* strategy, or the `random` sentinel. This is the check for validating user
* input; `isPatternName` remains the narrower "is this a real generator the
* registry can hand back" question.
*/
export function isSelectablePattern(name: string): boolean {
return isPatternName(name) || name === RANDOM_PATTERN;
}
/**
* Normalize a pattern name for lenient user-facing matching: lowercase and
* strip separators (`-`, `_`, whitespace) so `figure-eight`, `figure_eight`,
@@ -304,16 +333,19 @@ export function isPatternName(name: string): boolean {
const normalizePattern = (s: string): string => s.toLowerCase().replace(/[-_\s]/g, "");
/**
* Map of normalized name -> canonical registry key. Built once at module
* load. The assertion below guards against two registered names collapsing
* to the same normalized form (e.g. a future `"figure_eight"` alongside
* `"figureEight"`), which would otherwise let one silently shadow the other.
* Map of normalized name -> canonical selectable name. Built once at module
* load over `SELECTABLE_PATTERN_NAMES`, so the `random` sentinel normalizes
* like any other name and both validation boundaries accept it without
* special-casing. The assertion below guards against two selectable names
* collapsing to the same normalized form (e.g. a future `"figure_eight"`
* alongside `"figureEight"`, or a strategy named `"Random"`), which would
* otherwise let one silently shadow the other.
*/
const CANONICAL_PATTERNS: ReadonlyMap<string, string> = new Map(
PATTERN_NAMES.map((n) => [normalizePattern(n), n]),
SELECTABLE_PATTERN_NAMES.map((n) => [normalizePattern(n), n]),
);
if (CANONICAL_PATTERNS.size !== PATTERN_NAMES.length) {
if (CANONICAL_PATTERNS.size !== SELECTABLE_PATTERN_NAMES.length) {
throw new Error(
"strategies.ts: two pattern names collide after normalization; rename one so they differ by more than case/separators",
);
@@ -328,3 +360,33 @@ if (CANONICAL_PATTERNS.size !== PATTERN_NAMES.length) {
export function resolvePatternName(name: string): string | null {
return CANONICAL_PATTERNS.get(normalizePattern(name)) ?? null;
}
/**
* Build the picker that backs `--pattern random` / `-r`: a uniform draw over
* the registry that never returns the same pattern twice in a row.
*
* The `last` memory lives in the closure rather than in module scope so the
* lifetime is the caller's to choose — the keeper creates exactly one picker
* per process, which is what makes "never twice in a row" hold across sweeps
* that are minutes apart. `rng` is injected for the same reason it is on
* `MoveContext`: so tests can assert an exact sequence.
*
* Returns a `MovementStrategy`, not a name, because that's what the caller
* needs; the pick is a real registry entry, so it carries its own `loopPath`
* and drives through `executePath` exactly like an explicitly-chosen pattern.
*/
export function createRandomPicker(rng: () => number = Math.random): () => MovementStrategy {
let last: string | null = null;
return (): MovementStrategy => {
const pool: readonly string[] = PATTERN_NAMES.filter((n) => n !== last);
// A single-strategy registry leaves the filtered pool empty; fall back
// to the full list so the no-repeat rule degrades to "always repeat"
// instead of indexing off the end.
const names: readonly string[] = pool.length > 0 ? pool : PATTERN_NAMES;
// Math.min pins the index in range for an `rng` that returns exactly 1
// (outside the documented [0, 1) contract, but cheap to survive).
const name: string = names[Math.min(names.length - 1, Math.floor(rng() * names.length))]!;
last = name;
return STRATEGIES[name]!;
};
}