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
+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