From d38949edb47d1585f18d6c2788fdf2a95edae32c Mon Sep 17 00:00:00 2001 From: nokeo08 Date: Tue, 18 Aug 2026 14:36:29 -0500 Subject: [PATCH] Add random pattern selection (-r / --pattern random) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 29 ++++++++ README.md | 59 +++++++++++++--- docs/execution-happy-path.md | 4 +- src/cli.ts | 64 ++++++++++++++--- src/config.ts | 11 +-- src/configFile.ts | 10 ++- src/keeper.ts | 45 ++++++++++-- src/strategies.ts | 76 ++++++++++++++++++-- tests/cli.test.ts | 131 +++++++++++++++++++++++++++++++++++ tests/configFile.test.ts | 17 +++++ tests/keeper.test.ts | 92 ++++++++++++++++++++++-- tests/strategies.test.ts | 103 +++++++++++++++++++++++++++ 12 files changed, 594 insertions(+), 47 deletions(-) create mode 100644 tests/cli.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index be34f15..4507b60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,35 @@ All notable changes to `move` are documented here. 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). +## [Unreleased] + +### Added +- Random pattern selection: `-r` / `--random`, and `random` as a value for + `--pattern` and the `pattern` config key. Every time a sweep is triggered, + a different movement pattern is chosen, so the motion varies across the day + instead of repeating one shape. Two rules keep it predictable: the same + pattern is never chosen twice in a row, and the pick happens once per + trigger — in loop mode it holds for the whole loop run rather than changing + mid-run. The pick is a real strategy, so `--verbose` logs the concrete + pattern name and a pick with an infinite loop path (`line`, `diagonal`) + still bounces edge-to-edge under `--loop`. + + `-r` is defined as sugar for `--pattern random`, so passing both is + rejected (exit `2`) unless they agree: `move -r -p arc` is an error, while + `move -r -p random` is a no-op. There is no `random` boolean config key — + the file spells it `"pattern": "random"`. + + `random` is deliberately not a registry entry: it has no path of its own, + and the keeper resolves it to a real strategy per sweep. `PATTERN_NAMES` + therefore still lists only real generators, with the new + `SELECTABLE_PATTERN_NAMES` covering what a user may select. + +### Changed +- `parseCliArgs` now takes its argument list as an optional parameter + (defaulting to the real command line), so the flag surface is unit-testable + without touching `process.argv`. Adds `tests/cli.test.ts`, which previously + had no coverage. + ## [1.4.0] - 2026-08-17 ### Added diff --git a/README.md b/README.md index d02ee96..6ab851e 100644 --- a/README.md +++ b/README.md @@ -119,8 +119,12 @@ Options: -d, --step-delay Pause between synthetic steps. Default: 50. -p, --pattern Movement strategy. Default: line. One of: line, diagonal, jitter, walk, arc, - figureEight. Each pattern defines its own - size and speed. + figureEight, random. 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, @@ -191,7 +195,7 @@ doesn't set. All keys are optional; supply only the ones you want to override. Keys and units mirror the CLI flags exactly: `moveInterval` and `checkInterval` are seconds, `stepDelay` is milliseconds, `pattern` is a -movement strategy name, `verbose` and `loop` are booleans. +movement strategy name (or `"random"`), `verbose` and `loop` are booleans. > The obsolete `stepCount` / `stepSize` keys (removed in 1.3.0) are > tolerated for backward compatibility: they're ignored with a one-line @@ -223,9 +227,11 @@ The loader is strict: - Root must be a JSON object. - Unknown keys are rejected (catches typos like `"movInterval"`). - Numeric values must be finite and strictly positive. -- `pattern` must resolve to a registered strategy name. Matching ignores - case and separators (`-`, `_`, spaces), so `figure-eight` and `figureEight` - are equivalent. +- `pattern` must resolve to a registered strategy name, or to `random`. + Matching ignores case and separators (`-`, `_`, spaces), so `figure-eight` + and `figureEight` are equivalent. There is no `random` boolean key — the + CLI's `-r` is sugar for `--pattern random`, and the file spells it the + same way. - `verbose` must be a boolean. - `loop` must be a boolean. @@ -287,7 +293,8 @@ and everything but the raw nut.js call is unit-testable: injectable, so tests drive the loop and executor with a fake. - `src/strategies.ts` holds the pure movement patterns — each a generator of target points given a start, screen size, config, and RNG — plus the - registry and name validation. Adding a pattern is one pure function. + registry, name validation, and the `random` picker. Adding a pattern is + one pure function. - `src/executor.ts` is the single `executePath` driver: it rounds targets, reflects any off-screen coordinate back inside, paces steps, detects real-user interruption, and restores the cursor on a clean sweep. @@ -299,7 +306,7 @@ 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. | | `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. | -| `pattern` | `"line"` | `-p`, `--pattern` | Movement strategy name (see Movement strategies below). | +| `pattern` | `"line"` | `-p`, `--pattern`, `-r` | Movement strategy name, or `random` (see Movement strategies below). | `-m` and `-c` are accepted in seconds at the CLI; `resolveConfig` converts to milliseconds before handing the resolved `Config` to `runKeeper`. @@ -319,6 +326,8 @@ to milliseconds before handing the resolved `Config` to `runKeeper`. 1. `simulateActivity` snapshots the starting position and current screen dimensions (re-read every sweep so monitor changes are handled), looks up `config.pattern` in the strategy registry, and builds a `MoveContext`. + When `config.pattern` is `random` — the one name the registry doesn't + contain — the strategy comes from the picker instead, once per trigger. 2. It hands the strategy and context to `executePath`, which drives the sweep. For each target the strategy yields: - Round to whole pixels and reflect any off-screen coordinate back inside @@ -346,7 +355,8 @@ or multi-monitor displays isn't misread as the user grabbing the mouse. ### Movement strategies -`config.pattern` selects one of the generators in `src/strategies.ts`: +`config.pattern` selects one of the generators in `src/strategies.ts` (or +`random`, which picks one for you): | Name | Motion | Steps | Size | | ------------- | ------------------------------------------------------------- | ----- | ----------- | @@ -356,6 +366,35 @@ or multi-monitor displays isn't misread as the user grabbing the mouse. | `walk` | Cumulative random walk; bounces off the screen edges. | 200 | ±4px/step | | `arc` | Smooth quadratic-Bézier curve to a random on-screen point. | 120 | ~300px | | `figureEight` | Traces a figure-eight (lemniscate) and returns to the start. | 90 | ~250px wide | +| `random` | Meta-selection: a different one of the above per sweep. | — | — | + +### Random (`-r` / `--pattern random`) + +`random` isn't a movement pattern of its own — it's a selection that resolves +to one of the real patterns above each time a sweep fires: + +```sh +move -r # a different pattern every sweep +move --pattern random -V # verbose names the pattern each sweep picked +move -r --loop # one random pick, looped until you move the mouse +``` + +Two rules make it predictable: + +- **Never twice in a row.** Consecutive sweeps always use different patterns, + so the motion visibly varies instead of occasionally repeating itself. +- **One pick per trigger.** In loop mode a single trigger runs many cycles; + the pattern is chosen once and holds for that whole run rather than + changing mid-run. + +Because the pick is a real strategy, it behaves exactly as if you'd named it: +`--verbose` logs the concrete pattern (`Simulating activity (arc)...`), and a +pick with an infinite loop path (`line`, `diagonal`) bounces edge-to-edge +under `--loop` just as selecting it directly would. + +`-r` and `--pattern` state the same setting two ways, so passing both is +rejected (exit `2`) unless they agree — `move -r -p arc` is an error, while +`move -r -p random` is a harmless no-op. Every pattern is kept on-screen the same way: the executor reflects any coordinate that would fall past a screen edge back inside, so motion bounces @@ -423,7 +462,7 @@ move --help | `src/errors.ts` | Shared error types (`CliError`). | | `src/keeper.ts` | Idle-watch loop + per-sweep glue (selects a strategy, calls the executor). | | `src/device.ts` | `Device` I/O seam over nut.js (`Point`, `createNutDevice`); the only nut.js importer. | -| `src/strategies.ts` | Pure movement-pattern generators, the strategy registry, and name validation. | +| `src/strategies.ts` | Pure movement-pattern generators, the strategy registry, name validation, and the `random` picker. | | `src/executor.ts` | `executePath` driver: on-screen reflection, pacing, interrupt detection, restore. | | `docs/execution-happy-path.md` | Sequence diagram + invariants for a clean sweep. | | `package.json` | Bun project manifest. Single runtime dep: `@nut-tree-fork/nut-js`. | diff --git a/docs/execution-happy-path.md b/docs/execution-happy-path.md index 9aa67af..14cd487 100644 --- a/docs/execution-happy-path.md +++ b/docs/execution-happy-path.md @@ -37,14 +37,14 @@ sequenceDiagram Note over Keeper: pos == lastPos (no user movement)
now - lastActivity ≥ moveInterval → fire end - Keeper->>Sim: simulateActivity(config, log, dev) + Keeper->>Sim: simulateActivity(config, log, dev, pickRandom) Sim->>Dev: width() Dev-->>Sim: width Sim->>Dev: height() Dev-->>Sim: height Sim->>Dev: getPosition() Dev-->>Sim: start - Note over Sim: strategy = STRATEGIES[config.pattern]
ctx = { start, width, height, rng } + Note over Sim: strategy = STRATEGIES[config.pattern]
(or pickRandom() when pattern is "random")
ctx = { start, width, height, rng } Sim->>Exec: executePath(strategy, ctx, dev, log, config) Exec->>Strat: path(ctx) diff --git a/src/cli.ts b/src/cli.ts index a41e3c9..d561972 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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; 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 Cursor poll cadence. Default: ${checkDefaultSec}. -d, --step-delay Pause between synthetic steps. Default: ${DEFAULT_CONFIG.stepDelay}. -p, --pattern 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 `); } diff --git a/src/config.ts b/src/config.ts index 3831c3b..d570367 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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") { diff --git a/src/configFile.ts b/src/configFile.ts index 49c0bbe..ccec758 100644 --- a/src/configFile.ts +++ b/src/configFile.ts @@ -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 = new Set([ "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; diff --git a/src/keeper.ts b/src/keeper.ts index b09fb06..9b2549f 100644 --- a/src/keeper.ts +++ b/src/keeper.ts @@ -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 { +async function simulateActivity( + config: Config, + log: Logger, + device: Device, + pickRandom: () => MovementStrategy, +): Promise { 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 { +export async function runKeeper( + config: Config, + device?: Device, + pickRandom: () => MovementStrategy = createRandomPicker(), +): Promise { const dev: Device = device ?? (await createNutDevice()); const log = makeLogger(config.verbose); @@ -144,7 +175,7 @@ export async function runKeeper(config: Config, device?: Device): Promise } 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 diff --git a/src/strategies.ts b/src/strategies.ts index c976a17..b59477e 100644 --- a/src/strategies.ts +++ b/src/strategies.ts @@ -281,9 +281,28 @@ export const STRATEGIES: Readonly> = { /** 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 = 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]!; + }; +} diff --git a/tests/cli.test.ts b/tests/cli.test.ts new file mode 100644 index 0000000..e355bfb --- /dev/null +++ b/tests/cli.test.ts @@ -0,0 +1,131 @@ +/** + * cli.test.ts + * ----------- + * Unit tests for CLI argument parsing. `parseCliArgs` takes its argv as a + * parameter (defaulting to the real command line), so the whole flag surface + * is exercised here without touching `process.argv`. + * + * The focus is the parts that make a decision: numeric validation, pattern + * validation/normalization, and the `-r`/`--pattern` conflict rule. + */ + +import { describe, expect, test } from "bun:test"; + +import { parseCliArgs, selectPattern } from "../src/cli.ts"; +import { CliError } from "../src/errors.ts"; + +describe("parseCliArgs — general flags", () => { + test("returns all-undefined overrides for an empty argv", () => { + const args = parseCliArgs([]); + expect(args.moveInterval).toBeUndefined(); + expect(args.checkInterval).toBeUndefined(); + expect(args.stepDelay).toBeUndefined(); + expect(args.pattern).toBeUndefined(); + expect(args.verbose).toBeUndefined(); + expect(args.loop).toBeUndefined(); + expect(args.help).toBe(false); + }); + + test("parses numeric flags in both long and short form", () => { + const args = parseCliArgs(["-m", "300", "--check-interval", "5", "-d", "20"]); + expect(args.moveInterval).toBe(300); + expect(args.checkInterval).toBe(5); + expect(args.stepDelay).toBe(20); + }); + + test("boolean flags are true when present, undefined when absent", () => { + const args = parseCliArgs(["-V", "--loop"]); + expect(args.verbose).toBe(true); + expect(args.loop).toBe(true); + // `undefined` rather than `false` is what lets the resolver tell + // "not specified" from an explicit off-switch. + expect(parseCliArgs([]).verbose).toBeUndefined(); + }); + + test("rejects non-positive and non-numeric values", () => { + expect(() => parseCliArgs(["-m", "0"])).toThrow(CliError); + // A bare `-m -5` is rejected earlier, by node:util, as an ambiguous + // dash argument; `=` is the form that actually reaches our validator. + expect(() => parseCliArgs(["--move-interval=-5"])).toThrow(/positive number/); + expect(() => parseCliArgs(["-c", "abc"])).toThrow(/positive number/); + }); + + test("surfaces node:util's own parse errors as CliError", () => { + // e.g. an ambiguous dash argument — the entry point turns any CliError + // into exit 2, so the message just needs to reach the user intact. + expect(() => parseCliArgs(["-m", "-5"])).toThrow(CliError); + }); + + test("rejects unknown flags", () => { + expect(() => parseCliArgs(["--nope"])).toThrow(CliError); + }); +}); + +describe("parseCliArgs — pattern selection", () => { + test("accepts a registered pattern and normalizes loose spellings", () => { + expect(parseCliArgs(["-p", "arc"]).pattern).toBe("arc"); + expect(parseCliArgs(["--pattern", "figure-eight"]).pattern).toBe("figureEight"); + expect(parseCliArgs(["-p", "LINE"]).pattern).toBe("line"); + }); + + test("rejects an unknown pattern, listing random among the valid names", () => { + expect(() => parseCliArgs(["-p", "zigzag"])).toThrow(CliError); + expect(() => parseCliArgs(["-p", "zigzag"])).toThrow(/valid:.*random/); + }); + + test("--pattern random is accepted like any other selection", () => { + expect(parseCliArgs(["--pattern", "random"]).pattern).toBe("random"); + expect(parseCliArgs(["-p", "RANDOM"]).pattern).toBe("random"); + }); + + test("-r/--random folds into pattern", () => { + // The flag has no field of its own: its entire effect is the pattern, + // so nothing downstream needs to know it exists. + expect(parseCliArgs(["-r"]).pattern).toBe("random"); + expect(parseCliArgs(["--random"]).pattern).toBe("random"); + }); + + test("-r combined with an explicit --pattern is rejected", () => { + expect(() => parseCliArgs(["-r", "-p", "arc"])).toThrow(CliError); + expect(() => parseCliArgs(["-r", "-p", "arc"])).toThrow(/conflicts with --pattern 'arc'/); + // Order on the command line doesn't change the verdict. + expect(() => parseCliArgs(["--pattern", "walk", "--random"])).toThrow(/conflicts/); + }); + + test("-r alongside --pattern random is a harmless no-op", () => { + // Both spellings request the same thing, so there's nothing to object to. + expect(parseCliArgs(["-r", "-p", "random"]).pattern).toBe("random"); + expect(parseCliArgs(["-r", "-p", "Random"]).pattern).toBe("random"); + }); + + test("-r still validates the pattern it is paired with", () => { + // An invalid --pattern is an error in its own right, reported as such + // rather than being masked by the conflict rule. + expect(() => parseCliArgs(["-r", "-p", "zigzag"])).toThrow(/invalid value for --pattern/); + }); + + test("-r composes with the other flags", () => { + const args = parseCliArgs(["-r", "--loop", "-m", "120", "-V"]); + expect(args.pattern).toBe("random"); + expect(args.loop).toBe(true); + expect(args.moveInterval).toBe(120); + expect(args.verbose).toBe(true); + }); +}); + +describe("selectPattern", () => { + test("passes the pattern through untouched when --random is absent", () => { + expect(selectPattern("arc", false)).toBe("arc"); + expect(selectPattern(undefined, false)).toBeUndefined(); + }); + + test("yields random when --random is present and no pattern was given", () => { + expect(selectPattern(undefined, true)).toBe("random"); + }); + + test("quotes the user's own spelling in the conflict message", () => { + // Not the canonical name: the user needs to find the offending text on + // their command line. + expect(() => selectPattern("figure-eight", true)).toThrow(/--pattern 'figure-eight'/); + }); +}); diff --git a/tests/configFile.test.ts b/tests/configFile.test.ts index 21886a5..91e6480 100644 --- a/tests/configFile.test.ts +++ b/tests/configFile.test.ts @@ -125,6 +125,23 @@ describe("loadConfigFile (explicit path)", () => { const path = writeFixture("badpattern.json", JSON.stringify({ pattern: "zigzag" })); expect(() => loadConfigFile(path)).toThrow(/'pattern'.*valid:/); expect(() => loadConfigFile(path)).toThrow(/line/); + expect(() => loadConfigFile(path)).toThrow(/random/); + }); + + test("accepts the random sentinel as a pattern", () => { + // `-r` is only CLI sugar for this, so the file has to express it too. + const path = writeFixture("randompattern.json", JSON.stringify({ pattern: "random" })); + expect(loadConfigFile(path)!.pattern).toBe("random"); + }); + + test("normalizes a loosely-spelled random", () => { + const path = writeFixture("looserandom.json", JSON.stringify({ pattern: "RANDOM" })); + expect(loadConfigFile(path)!.pattern).toBe("random"); + }); + + test("rejects a 'random' boolean key — the file spells it as a pattern", () => { + const path = writeFixture("randomkey.json", JSON.stringify({ random: true })); + expect(() => loadConfigFile(path)).toThrow(/unknown key 'random'/); }); test("tolerates obsolete stepCount/stepSize keys, ignoring their values", () => { diff --git a/tests/keeper.test.ts b/tests/keeper.test.ts index e5e325e..571b126 100644 --- a/tests/keeper.test.ts +++ b/tests/keeper.test.ts @@ -16,6 +16,7 @@ import { DEFAULT_CONFIG } from "../src/config.ts"; import type { Config } from "../src/config.ts"; import type { Device, Point } from "../src/device.ts"; import { runKeeper } from "../src/keeper.ts"; +import { diagonal, figureEight, type MovementStrategy } from "../src/strategies.ts"; class StopError extends Error {} @@ -60,9 +61,13 @@ const quietConfig = (overrides: Partial): Config => ({ ...overrides, }); -async function runUntilStop(config: Config, device: Device): Promise { +async function runUntilStop( + config: Config, + device: Device, + pickRandom?: () => MovementStrategy, +): Promise { try { - await runKeeper(config, device); + await runKeeper(config, device, pickRandom); } catch (err) { if (!(err instanceof StopError)) throw err; } @@ -90,9 +95,10 @@ describe("runKeeper", () => { }); }); -describe("runKeeper — loop mode", () => { - const maxX = (pts: Point[]): number => pts.reduce((m, p) => Math.max(m, p.x), -Infinity); +/** Furthest x any commanded point reached — the signal that a path ramped. */ +const maxX = (pts: Point[]): number => pts.reduce((m, p) => Math.max(m, p.x), -Infinity); +describe("runKeeper — loop mode", () => { 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 @@ -123,3 +129,81 @@ describe("runKeeper — loop mode", () => { expect(dev.commanded.length).toBeGreaterThan(180); }); }); + +describe("runKeeper — random pattern", () => { + /** + * A picker that always hands back `strategy` and counts how many times the + * keeper asked. The count is the observable that pins down *when* the pick + * happens, which is the whole contract for `random`. + */ + function recordingPicker(strategy: MovementStrategy): { + pick: () => MovementStrategy; + calls: () => number; + } { + let calls = 0; + return { + pick: (): MovementStrategy => { + calls++; + return strategy; + }, + calls: (): number => calls, + }; + } + + test("asks the picker again on every trigger", async () => { + // moveInterval 0 means each pass of the watch loop fires a sweep, so + // the budget covers several triggers. A pattern chosen once for the + // whole process would show exactly one call. + const picker = recordingPicker(figureEight); + const dev = new LoopDevice(400, { x: 800, y: 500 }); + await runUntilStop( + quietConfig({ moveInterval: 0, pattern: "random", loop: false }), + dev, + picker.pick, + ); + expect(picker.calls()).toBeGreaterThanOrEqual(2); + }); + + test("never consults the picker for a concrete pattern", async () => { + const picker = recordingPicker(figureEight); + const dev = new LoopDevice(400, { x: 800, y: 500 }); + await runUntilStop( + quietConfig({ moveInterval: 0, pattern: "line", loop: false }), + dev, + picker.pick, + ); + expect(picker.calls()).toBe(0); + expect(dev.commanded.length).toBeGreaterThan(0); + }); + + test("loop mode holds a single pick for the whole loop run", async () => { + // One trigger, many chained cycles: the pattern must not change under + // the user mid-run, so the picker is asked exactly once. + const picker = recordingPicker(figureEight); + const dev = new LoopDevice(400, { x: 800, y: 500 }); + await runUntilStop( + quietConfig({ moveInterval: 0, pattern: "random", loop: true }), + dev, + picker.pick, + ); + expect(picker.calls()).toBe(1); + // ...and those cycles really did run, so the single call isn't just + // the loop never getting started. + expect(dev.commanded.length).toBeGreaterThan(180); + }); + + test("a picked strategy keeps its own loopPath behavior", async () => { + // The picker returns real registry entries, so a pick with an infinite + // loopPath (`diagonal`) drives that path rather than a chained finite + // one — the same as selecting it explicitly. Mirrors the `line` loop + // test above: x ramps far past a single finite sweep's 250px reach. + const picker = recordingPicker(diagonal); + const dev = new LoopDevice(400, { x: 100, y: 100 }); + await runUntilStop( + quietConfig({ moveInterval: 0, pattern: "random", loop: true }), + dev, + picker.pick, + ); + expect(maxX(dev.commanded)).toBeGreaterThan(1000); + }); +}); diff --git a/tests/strategies.test.ts b/tests/strategies.test.ts index dd6b926..85b2a31 100644 --- a/tests/strategies.test.ts +++ b/tests/strategies.test.ts @@ -12,13 +12,17 @@ import { describe, expect, test } from "bun:test"; import type { Point } from "../src/device.ts"; import { arc, + createRandomPicker, diagonal, figureEight, isPatternName, + isSelectablePattern, jitter, line, PATTERN_NAMES, + RANDOM_PATTERN, resolvePatternName, + SELECTABLE_PATTERN_NAMES, STRATEGIES, walk, type MoveContext, @@ -187,3 +191,102 @@ describe("registry", () => { expect(resolvePatternName("toString")).toBeNull(); }); }); + +describe("random (the sentinel)", () => { + test("is selectable but is not a registry entry", () => { + // The whole design rests on this: `random` is a user-facing choice + // with no path of its own, so the registry must not contain it and + // `STRATEGIES[RANDOM_PATTERN]` must not resolve. + expect(PATTERN_NAMES).not.toContain(RANDOM_PATTERN); + expect(STRATEGIES[RANDOM_PATTERN]).toBeUndefined(); + expect(isPatternName(RANDOM_PATTERN)).toBe(false); + expect(isSelectablePattern(RANDOM_PATTERN)).toBe(true); + }); + + test("SELECTABLE_PATTERN_NAMES is the registry plus the sentinel", () => { + expect(new Set(SELECTABLE_PATTERN_NAMES)).toEqual( + new Set([...PATTERN_NAMES, RANDOM_PATTERN]), + ); + expect(SELECTABLE_PATTERN_NAMES.length).toBe(PATTERN_NAMES.length + 1); + }); + + test("isSelectablePattern still accepts every real strategy and rejects junk", () => { + for (const name of PATTERN_NAMES) expect(isSelectablePattern(name)).toBe(true); + expect(isSelectablePattern("zigzag")).toBe(false); + expect(isSelectablePattern("toString")).toBe(false); + }); + + test("resolvePatternName normalizes the sentinel like any other name", () => { + expect(resolvePatternName("random")).toBe(RANDOM_PATTERN); + expect(resolvePatternName("RANDOM")).toBe(RANDOM_PATTERN); + expect(resolvePatternName(" Random ")).toBe(RANDOM_PATTERN); + }); +}); + +describe("createRandomPicker", () => { + test("only ever returns registered strategies", () => { + const pick = createRandomPicker(mulberry32(7)); + for (let i = 0; i < 100; i++) { + const s = pick(); + expect(PATTERN_NAMES).toContain(s.name); + expect(STRATEGIES[s.name]).toBe(s); + } + }); + + test("never returns the same pattern twice in a row", () => { + const pick = createRandomPicker(mulberry32(1234)); + let prev: string = pick().name; + for (let i = 0; i < 500; i++) { + const name: string = pick().name; + expect(name).not.toBe(prev); + prev = name; + } + }); + + test("alternates deterministically under a constant rng of 0", () => { + // rng()=0 always takes the first entry of the *remaining* pool, and + // the pool is the registry minus the previous pick — so this pins the + // exclusion logic exactly: first name, second name, first name, ... + const pick = createRandomPicker(() => 0); + const [first, second] = PATTERN_NAMES as [string, string]; + expect(pick().name).toBe(first); + expect(pick().name).toBe(second); + expect(pick().name).toBe(first); + expect(pick().name).toBe(second); + }); + + test("stays in range for an rng that returns exactly 1", () => { + // Outside the documented [0, 1) contract; must clamp rather than + // index off the end and throw. + const pick = createRandomPicker(() => 1); + for (let i = 0; i < 10; i++) { + expect(PATTERN_NAMES).toContain(pick().name); + } + }); + + test("is reproducible for a given seed, and independent across pickers", () => { + const a = createRandomPicker(mulberry32(99)); + const b = createRandomPicker(mulberry32(99)); + const seqA = Array.from({ length: 20 }, () => a().name); + const seqB = Array.from({ length: 20 }, () => b().name); + expect(seqA).toEqual(seqB); + }); + + test("each picker carries its own no-repeat memory", () => { + // The memory is per-closure, not module state: a fresh picker has no + // notion of what a previous one returned, so it may open with the + // same pattern. + const a = createRandomPicker(() => 0); + const b = createRandomPicker(() => 0); + expect(a().name).toBe(b().name); + }); + + test("covers the whole registry over enough draws", () => { + // Guards against the exclusion logic accidentally pinning the pool to + // a subset (e.g. filtering by index rather than by name). + const pick = createRandomPicker(mulberry32(2024)); + const seen = new Set(); + for (let i = 0; i < 400; i++) seen.add(pick().name); + expect(seen).toEqual(new Set(PATTERN_NAMES)); + }); +});