Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
403334ba6d | ||
|
|
d38949edb4 |
@@ -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/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.4.1] - 2026-08-18
|
||||||
|
|
||||||
|
### 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
|
## [1.4.0] - 2026-08-17
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -187,6 +216,7 @@ Initial release.
|
|||||||
- Source split into `src/{move,cli,config,keeper}.ts`.
|
- Source split into `src/{move,cli,config,keeper}.ts`.
|
||||||
- `bin` entry + shebang so `bun link` registers `move` globally.
|
- `bin` entry + shebang so `bun link` registers `move` globally.
|
||||||
|
|
||||||
|
[1.4.1]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.4.0...v1.4.1
|
||||||
[1.4.0]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.3.3...v1.4.0
|
[1.4.0]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.3.3...v1.4.0
|
||||||
[1.3.3]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.3.2...v1.3.3
|
[1.3.3]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.3.2...v1.3.3
|
||||||
[1.3.2]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.3.1...v1.3.2
|
[1.3.2]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.3.1...v1.3.2
|
||||||
|
|||||||
@@ -119,8 +119,12 @@ Options:
|
|||||||
-d, --step-delay <ms> Pause between synthetic steps. Default: 50.
|
-d, --step-delay <ms> Pause between synthetic steps. Default: 50.
|
||||||
-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. Each pattern defines its own
|
figureEight, random. Each pattern defines
|
||||||
size and speed.
|
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
|
-V, --verbose Log every sweep and interrupt
|
||||||
(default prints only the startup banner).
|
(default prints only the startup banner).
|
||||||
-l, --loop Loop mode: once a sweep is triggered,
|
-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
|
All keys are optional; supply only the ones you want to override. Keys
|
||||||
and units mirror the CLI flags exactly: `moveInterval` and
|
and units mirror the CLI flags exactly: `moveInterval` and
|
||||||
`checkInterval` are seconds, `stepDelay` is milliseconds, `pattern` is a
|
`checkInterval` are seconds, `stepDelay` is milliseconds, `pattern` is a
|
||||||
movement strategy name, `verbose` 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
|
> The obsolete `stepCount` / `stepSize` keys (removed in 1.3.0) are
|
||||||
> tolerated for backward compatibility: they're ignored with a one-line
|
> tolerated for backward compatibility: they're ignored with a one-line
|
||||||
@@ -223,9 +227,11 @@ The loader is strict:
|
|||||||
- Root must be a JSON object.
|
- Root must be a JSON object.
|
||||||
- Unknown keys are rejected (catches typos like `"movInterval"`).
|
- Unknown keys are rejected (catches typos like `"movInterval"`).
|
||||||
- Numeric values must be finite and strictly positive.
|
- Numeric values must be finite and strictly positive.
|
||||||
- `pattern` must resolve to a registered strategy name. Matching ignores
|
- `pattern` must resolve to a registered strategy name, or to `random`.
|
||||||
case and separators (`-`, `_`, spaces), so `figure-eight` and `figureEight`
|
Matching ignores case and separators (`-`, `_`, spaces), so `figure-eight`
|
||||||
are equivalent.
|
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.
|
- `verbose` must be a boolean.
|
||||||
- `loop` 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.
|
injectable, so tests drive the loop and executor with a fake.
|
||||||
- `src/strategies.ts` holds the pure movement patterns — each a generator
|
- `src/strategies.ts` holds the pure movement patterns — each a generator
|
||||||
of target points given a start, screen size, config, and RNG — plus the
|
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,
|
- `src/executor.ts` is the single `executePath` driver: it rounds targets,
|
||||||
reflects any off-screen coordinate back inside, paces steps, detects
|
reflects any off-screen coordinate back inside, paces steps, detects
|
||||||
real-user interruption, and restores the cursor on a clean sweep.
|
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. |
|
| `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. |
|
||||||
| `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
|
`-m` and `-c` are accepted in seconds at the CLI; `resolveConfig` converts
|
||||||
to milliseconds before handing the resolved `Config` to `runKeeper`.
|
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
|
1. `simulateActivity` snapshots the starting position and current screen
|
||||||
dimensions (re-read every sweep so monitor changes are handled), looks
|
dimensions (re-read every sweep so monitor changes are handled), looks
|
||||||
up `config.pattern` in the strategy registry, and builds a `MoveContext`.
|
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
|
2. It hands the strategy and context to `executePath`, which drives the
|
||||||
sweep. For each target the strategy yields:
|
sweep. For each target the strategy yields:
|
||||||
- Round to whole pixels and reflect any off-screen coordinate back inside
|
- 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
|
### 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 |
|
| 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 |
|
| `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 |
|
| `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 |
|
| `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
|
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
|
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/errors.ts` | Shared error types (`CliError`). |
|
||||||
| `src/keeper.ts` | Idle-watch loop + per-sweep glue (selects a strategy, calls the executor). |
|
| `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/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. |
|
| `src/executor.ts` | `executePath` driver: on-screen reflection, pacing, interrupt detection, restore. |
|
||||||
| `docs/execution-happy-path.md` | Sequence diagram + invariants for a clean sweep. |
|
| `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`. |
|
| `package.json` | Bun project manifest. Single runtime dep: `@nut-tree-fork/nut-js`. |
|
||||||
|
|||||||
@@ -37,14 +37,14 @@ sequenceDiagram
|
|||||||
Note over Keeper: pos == lastPos (no user movement)<br/>now - lastActivity ≥ moveInterval → fire
|
Note over Keeper: pos == lastPos (no user movement)<br/>now - lastActivity ≥ moveInterval → fire
|
||||||
end
|
end
|
||||||
|
|
||||||
Keeper->>Sim: simulateActivity(config, log, dev)
|
Keeper->>Sim: simulateActivity(config, log, dev, pickRandom)
|
||||||
Sim->>Dev: width()
|
Sim->>Dev: width()
|
||||||
Dev-->>Sim: width
|
Dev-->>Sim: width
|
||||||
Sim->>Dev: height()
|
Sim->>Dev: height()
|
||||||
Dev-->>Sim: height
|
Dev-->>Sim: height
|
||||||
Sim->>Dev: getPosition()
|
Sim->>Dev: getPosition()
|
||||||
Dev-->>Sim: start
|
Dev-->>Sim: start
|
||||||
Note over Sim: strategy = STRATEGIES[config.pattern]<br/>ctx = { start, width, height, rng }
|
Note over Sim: strategy = STRATEGIES[config.pattern]<br/>(or pickRandom() when pattern is "random")<br/>ctx = { start, width, height, rng }
|
||||||
Sim->>Exec: executePath(strategy, ctx, dev, log, config)
|
Sim->>Exec: executePath(strategy, ctx, dev, log, config)
|
||||||
|
|
||||||
Exec->>Strat: path(ctx)
|
Exec->>Strat: path(ctx)
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "move",
|
"name": "move",
|
||||||
"version": "1.4.0",
|
"version": "1.4.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
+54
-10
@@ -17,6 +17,10 @@
|
|||||||
* -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).
|
||||||
* -p, --pattern Movement strategy name (see strategies.ts).
|
* -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, --verbose Enable per-sweep / interrupt logging.
|
||||||
* (`-V` capital because `-v` is `--version`.)
|
* (`-V` capital because `-v` is `--version`.)
|
||||||
* -l, --loop Loop mode: once triggered, keep moving
|
* -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 { DEFAULT_CONFIG, defaultConfigPath } from "./config.ts";
|
||||||
import { CliError } from "./errors.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
|
* Result of `parseCliArgs`. Numeric fields are `undefined` when the user
|
||||||
@@ -48,7 +52,13 @@ 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
|
||||||
/** 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;
|
pattern: string | undefined;
|
||||||
/**
|
/**
|
||||||
* `true` when `-V`/`--verbose` was passed; `undefined` when it was not.
|
* `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;
|
if (raw === undefined) return undefined;
|
||||||
const canonical: string | null = resolvePatternName(raw);
|
const canonical: string | null = resolvePatternName(raw);
|
||||||
if (canonical === null) {
|
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;
|
return canonical;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse `process.argv` into a typed `ParsedCliArgs`. Uses Node's built-in
|
* Fold `-r/--random` and `--pattern` into the single pattern selection that
|
||||||
* `parseArgs` in strict mode so unknown flags and missing values surface
|
* the rest of the program consumes.
|
||||||
* as `CliError`s that the entry point can turn into exit code 2.
|
*
|
||||||
|
* `-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>;
|
let values: Record<string, string | boolean | undefined>;
|
||||||
try {
|
try {
|
||||||
const result = parseArgs({
|
const result = parseArgs({
|
||||||
args: process.argv.slice(2),
|
args: argv,
|
||||||
options: {
|
options: {
|
||||||
help: { type: "boolean", short: "h" },
|
help: { type: "boolean", short: "h" },
|
||||||
version: { type: "boolean", short: "v" },
|
version: { type: "boolean", short: "v" },
|
||||||
@@ -111,6 +148,7 @@ export function parseCliArgs(): ParsedCliArgs {
|
|||||||
"check-interval": { type: "string", short: "c" },
|
"check-interval": { type: "string", short: "c" },
|
||||||
"step-delay": { type: "string", short: "d" },
|
"step-delay": { type: "string", short: "d" },
|
||||||
pattern: { type: "string", short: "p" },
|
pattern: { type: "string", short: "p" },
|
||||||
|
random: { type: "boolean", short: "r" },
|
||||||
verbose: { type: "boolean", short: "V" },
|
verbose: { type: "boolean", short: "V" },
|
||||||
loop: { type: "boolean", short: "l" },
|
loop: { type: "boolean", short: "l" },
|
||||||
},
|
},
|
||||||
@@ -133,7 +171,7 @@ 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),
|
||||||
pattern: parsePatternName(values.pattern as string | undefined),
|
pattern: selectPattern(values.pattern as string | undefined, values.random === true),
|
||||||
verbose: values.verbose === true ? true : undefined,
|
verbose: values.verbose === true ? true : undefined,
|
||||||
loop: values.loop === true ? true : undefined,
|
loop: values.loop === true ? true : undefined,
|
||||||
};
|
};
|
||||||
@@ -178,8 +216,12 @@ Options:
|
|||||||
-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}.
|
||||||
-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: ${SELECTABLE_PATTERN_NAMES.join(", ")}.
|
||||||
Each pattern defines its own size and speed.
|
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
|
-V, --verbose Log every sweep and interrupt
|
||||||
(default prints only the startup banner).
|
(default prints only the startup banner).
|
||||||
-l, --loop Loop mode: once a sweep is triggered,
|
-l, --loop Loop mode: once a sweep is triggered,
|
||||||
@@ -194,6 +236,8 @@ Examples:
|
|||||||
move -m 300 -V
|
move -m 300 -V
|
||||||
move --pattern arc
|
move --pattern arc
|
||||||
move --pattern diagonal --loop
|
move --pattern diagonal --loop
|
||||||
|
move -r
|
||||||
|
move --pattern random --loop
|
||||||
move --config ~/myprofile.json
|
move --config ~/myprofile.json
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-4
@@ -22,7 +22,7 @@
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
import { CliError } from "./errors.ts";
|
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
|
// 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
|
||||||
@@ -44,7 +44,10 @@ import seedRaw from "../scripts/config.default.json" with { type: "json" };
|
|||||||
* "interrupt" by moving the cursor. Milliseconds.
|
* "interrupt" by moving the cursor. Milliseconds.
|
||||||
* - `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`). Each
|
* `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
|
* - `verbose` — whether per-sweep / interrupt events are logged. The
|
||||||
* startup banner is always printed.
|
* startup banner is always printed.
|
||||||
* - `loop` — loop mode: once a sweep is triggered, keep
|
* - `loop` — loop mode: once a sweep is triggered, keep
|
||||||
@@ -71,7 +74,7 @@ interface SeedShape {
|
|||||||
moveInterval: number; // seconds
|
moveInterval: number; // seconds
|
||||||
checkInterval: number; // seconds
|
checkInterval: number; // seconds
|
||||||
stepDelay: number; // milliseconds
|
stepDelay: number; // milliseconds
|
||||||
pattern: string; // strategy name
|
pattern: string; // strategy name, or the `random` sentinel
|
||||||
verbose: boolean;
|
verbose: boolean;
|
||||||
loop: 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)})`);
|
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)})`);
|
throw new Error(`scripts/config.default.json: 'pattern' must be a known strategy name (got ${JSON.stringify(r.pattern)})`);
|
||||||
}
|
}
|
||||||
if (typeof r.verbose !== "boolean") {
|
if (typeof r.verbose !== "boolean") {
|
||||||
|
|||||||
+7
-3
@@ -10,10 +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
|
||||||
* pattern string a registered strategy name
|
* pattern string a registered strategy name, or "random"
|
||||||
* verbose boolean
|
* verbose boolean
|
||||||
* loop 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
|
* 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. The removed `stepCount` /
|
* 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 { defaultConfigPath, type ConfigOverrides } from "./config.ts";
|
||||||
import { CliError } from "./errors.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>([
|
const ALLOWED_KEYS: ReadonlySet<string> = new Set<string>([
|
||||||
"moveInterval",
|
"moveInterval",
|
||||||
@@ -82,7 +86,7 @@ function requirePatternName(name: string, raw: unknown, path: string): string {
|
|||||||
const canonical: string | null = typeof raw === "string" ? resolvePatternName(raw) : null;
|
const canonical: string | null = typeof raw === "string" ? resolvePatternName(raw) : null;
|
||||||
if (canonical === null) {
|
if (canonical === null) {
|
||||||
throw new CliError(
|
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;
|
return canonical;
|
||||||
|
|||||||
+36
-5
@@ -25,7 +25,14 @@
|
|||||||
|
|
||||||
import { createNutDevice, type Device, type Point } from "./device.ts";
|
import { createNutDevice, type Device, type Point } from "./device.ts";
|
||||||
import { executePath, type Logger, type SweepOutcome } from "./executor.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";
|
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
|
* `config.pattern` falls back to the default strategy defensively; validation
|
||||||
* at the CLI / config-file boundary should prevent that from ever happening.
|
* 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
|
* Single-sweep mode (`config.loop === false`) runs exactly one sweep via
|
||||||
* `executePath`, which owns on-screen reflection, pacing, interrupt
|
* `executePath`, which owns on-screen reflection, pacing, interrupt
|
||||||
* detection, and restore-on-clean — unchanged from before loop mode existed.
|
* 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
|
* position each cycle. Per-cycle event logs are suppressed to avoid unbounded
|
||||||
* output — one line brackets the run at each end.
|
* 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 width: number = await device.width();
|
||||||
const height: number = await device.height();
|
const height: number = await device.height();
|
||||||
const strategy = STRATEGIES[config.pattern] ?? STRATEGIES[DEFAULT_PATTERN]!;
|
const strategy: MovementStrategy =
|
||||||
|
config.pattern === RANDOM_PATTERN
|
||||||
|
? pickRandom()
|
||||||
|
: (STRATEGIES[config.pattern] ?? STRATEGIES[DEFAULT_PATTERN]!);
|
||||||
|
|
||||||
if (!config.loop) {
|
if (!config.loop) {
|
||||||
const start: Point = await device.getPosition();
|
const start: Point = await device.getPosition();
|
||||||
@@ -121,8 +144,16 @@ async function simulateActivity(config: Config, log: Logger, device: Device): Pr
|
|||||||
*
|
*
|
||||||
* @param config - Resolved runtime config.
|
* @param config - Resolved runtime config.
|
||||||
* @param device - I/O device; defaults to the production nut.js device.
|
* @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 dev: Device = device ?? (await createNutDevice());
|
||||||
|
|
||||||
const log = makeLogger(config.verbose);
|
const log = makeLogger(config.verbose);
|
||||||
@@ -144,7 +175,7 @@ export async function runKeeper(config: Config, device?: Device): Promise<void>
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (now - lastActivity >= config.moveInterval) {
|
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
|
// The sweep either restored the cursor to its start (clean) or
|
||||||
// left it where the user moved it (interrupt). Either way, reset
|
// left it where the user moved it (interrupt). Either way, reset
|
||||||
// the clock and require another full moveInterval of inactivity
|
// the clock and require another full moveInterval of inactivity
|
||||||
|
|||||||
+69
-7
@@ -281,9 +281,28 @@ export const STRATEGIES: Readonly<Record<string, MovementStrategy>> = {
|
|||||||
/** Pattern used when neither the CLI nor the config file selects one. */
|
/** Pattern used when neither the CLI nor the config file selects one. */
|
||||||
export const DEFAULT_PATTERN = "line";
|
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);
|
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
|
* 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
|
* 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);
|
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
|
* Normalize a pattern name for lenient user-facing matching: lowercase and
|
||||||
* strip separators (`-`, `_`, whitespace) so `figure-eight`, `figure_eight`,
|
* 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, "");
|
const normalizePattern = (s: string): string => s.toLowerCase().replace(/[-_\s]/g, "");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map of normalized name -> canonical registry key. Built once at module
|
* Map of normalized name -> canonical selectable name. Built once at module
|
||||||
* load. The assertion below guards against two registered names collapsing
|
* load over `SELECTABLE_PATTERN_NAMES`, so the `random` sentinel normalizes
|
||||||
* to the same normalized form (e.g. a future `"figure_eight"` alongside
|
* like any other name and both validation boundaries accept it without
|
||||||
* `"figureEight"`), which would otherwise let one silently shadow the other.
|
* 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(
|
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(
|
throw new Error(
|
||||||
"strategies.ts: two pattern names collide after normalization; rename one so they differ by more than case/separators",
|
"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 {
|
export function resolvePatternName(name: string): string | null {
|
||||||
return CANONICAL_PATTERNS.get(normalizePattern(name)) ?? 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]!;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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'/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -125,6 +125,23 @@ describe("loadConfigFile (explicit path)", () => {
|
|||||||
const path = writeFixture("badpattern.json", JSON.stringify({ pattern: "zigzag" }));
|
const path = writeFixture("badpattern.json", JSON.stringify({ pattern: "zigzag" }));
|
||||||
expect(() => loadConfigFile(path)).toThrow(/'pattern'.*valid:/);
|
expect(() => loadConfigFile(path)).toThrow(/'pattern'.*valid:/);
|
||||||
expect(() => loadConfigFile(path)).toThrow(/line/);
|
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", () => {
|
test("tolerates obsolete stepCount/stepSize keys, ignoring their values", () => {
|
||||||
|
|||||||
+87
-3
@@ -16,6 +16,7 @@ import { DEFAULT_CONFIG } from "../src/config.ts";
|
|||||||
import type { Config } from "../src/config.ts";
|
import type { Config } from "../src/config.ts";
|
||||||
import type { Device, Point } from "../src/device.ts";
|
import type { Device, Point } from "../src/device.ts";
|
||||||
import { runKeeper } from "../src/keeper.ts";
|
import { runKeeper } from "../src/keeper.ts";
|
||||||
|
import { diagonal, figureEight, type MovementStrategy } from "../src/strategies.ts";
|
||||||
|
|
||||||
class StopError extends Error {}
|
class StopError extends Error {}
|
||||||
|
|
||||||
@@ -60,9 +61,13 @@ const quietConfig = (overrides: Partial<Config>): Config => ({
|
|||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
async function runUntilStop(config: Config, device: Device): Promise<void> {
|
async function runUntilStop(
|
||||||
|
config: Config,
|
||||||
|
device: Device,
|
||||||
|
pickRandom?: () => MovementStrategy,
|
||||||
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await runKeeper(config, device);
|
await runKeeper(config, device, pickRandom);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!(err instanceof StopError)) throw err;
|
if (!(err instanceof StopError)) throw err;
|
||||||
}
|
}
|
||||||
@@ -90,9 +95,10 @@ describe("runKeeper", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("runKeeper — loop mode", () => {
|
/** 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);
|
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 () => {
|
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
|
// `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
|
// 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);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,13 +12,17 @@ import { describe, expect, test } from "bun:test";
|
|||||||
import type { Point } from "../src/device.ts";
|
import type { Point } from "../src/device.ts";
|
||||||
import {
|
import {
|
||||||
arc,
|
arc,
|
||||||
|
createRandomPicker,
|
||||||
diagonal,
|
diagonal,
|
||||||
figureEight,
|
figureEight,
|
||||||
isPatternName,
|
isPatternName,
|
||||||
|
isSelectablePattern,
|
||||||
jitter,
|
jitter,
|
||||||
line,
|
line,
|
||||||
PATTERN_NAMES,
|
PATTERN_NAMES,
|
||||||
|
RANDOM_PATTERN,
|
||||||
resolvePatternName,
|
resolvePatternName,
|
||||||
|
SELECTABLE_PATTERN_NAMES,
|
||||||
STRATEGIES,
|
STRATEGIES,
|
||||||
walk,
|
walk,
|
||||||
type MoveContext,
|
type MoveContext,
|
||||||
@@ -187,3 +191,102 @@ describe("registry", () => {
|
|||||||
expect(resolvePatternName("toString")).toBeNull();
|
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<string>();
|
||||||
|
for (let i = 0; i < 400; i++) seen.add(pick().name);
|
||||||
|
expect(seen).toEqual(new Set(PATTERN_NAMES));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user