5 Commits
Author SHA1 Message Date
nokeo08 403334ba6d Release 1.4.1 2026-08-18 14:36:51 -05:00
nokeo08 d38949edb4 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.
2026-08-18 14:36:29 -05:00
nokeo08 b019f25a42 Release 1.4.0 2026-08-17 16:15:44 -05:00
nokeo08 c8942bb380 Collapse bounds policies to reflect-only; drop abort and clamp
The executor kept every commanded point on-screen via a per-strategy
BoundsPolicy of abort / clamp / reflect. Measured against the real
strategies, the other two earned nothing: abort truncated a sweep at the
first edge (line on a narrow screen ran only 90 of 250 steps), and clamp
could park the cursor against an edge (a monotonic ramp stalled 162 steps
in a row) -- both counter to the program's whole purpose of keeping the
cursor moving. reflect bounces off the edge and keeps going, and is
already what line/diagonal need in loop mode. arc's declared clamp was
provably dead code (it clamps its own endpoint, so no sample ever leaves
the screen).

Collapse to reflect-only:
- strategies.ts: remove the BoundsPolicy type and the `bounds` field from
  the interface and all six strategies. Keep the local clamp() helper --
  it's arc's endpoint geometry, not an on-screen policy; docstring says so.
- executor.ts: resolveTarget loses its policy parameter and its null
  return and just reflects both axes; delete clampInt; SweepOutcome drops
  "aborted"; ExecuteOptions drops `bounds`; remove the Out of bounds log.
- keeper.ts: loopOpts is now { restore: false, loop: true } -- the
  reflect override added with loop mode is redundant.
- tests: drop the abort-outcome, clamp, and bounds-override tests; simplify
  fixed() to take no policy; add a regression test that a monotonic ramp
  past an edge never yields two identical points in a row (the guarantee
  that motivated removing clamp).

Behavior is unchanged for every pattern at normal cursor positions
(verified: line's normal sweep is byte-identical). The only differences
are at a screen edge, where motion now bounces instead of stopping. No
config keys, flags, or pattern names changed.

Docs updated to match, including in-code comments, the README strategies
table (Bounds column removed) and verbose description, the sequence
diagram (resolveTarget signature + getPosition/width ordering + a loop-mode
note), and a CHANGELOG Changed entry.
2026-08-17 15:53:49 -05:00
nokeo08 7e632b3e9d Add loop mode (--loop): repeat movement until user activity
Introduce a continuous "loop" setting so a triggered sweep keeps the
cursor moving until the user moves the mouse (or Ctrl+C), instead of
firing a single sweep.

- strategies.ts: add optional `loopPath` to MovementStrategy; give `line`
  and `diagonal` infinite loop generators that pick a direction once and
  ramp forever (4px/step). Their finite `path` and declared `bounds` are
  unchanged, so single-sweep behavior is identical.
- executor.ts: add ExecuteOptions { restore?, bounds?, loop? }. Omitting
  options reproduces the original single-sweep contract exactly.
- keeper.ts: in loop mode, run an infinite loopPath once (stopped only by
  interruption) or chain a finite path cycle after cycle; force `reflect`
  bounds for every pattern and suppress the between-cycle restore, so
  line/diagonal bounce edge-to-edge instead of stopping at the first edge.
- config plumbing: new boolean `loop` through config.default.json,
  config.ts, configFile.ts, cli.ts (-l/--loop), and move.ts, mirroring
  the existing `verbose` precedence.
- docs: README loop-mode section + usage/validation updates; CHANGELOG
  Unreleased entry.
- tests: loopPath generators, executor options (bounds override, loop
  selection, restore suppression), config/configFile loop plumbing, and
  keeper-level loop behavior (ramps far vs. bounded single-sweep, chained
  cycles). 79 pass.
2026-08-17 14:36:18 -05:00
18 changed files with 1111 additions and 232 deletions
+53
View File
@@ -5,6 +5,57 @@ 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).
## [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
### Added
- Loop mode: `-l` / `--loop` (and the `loop` config key) keep the mouse
moving after a sweep is triggered until real user activity is detected,
instead of firing a single sweep. In loop mode the cursor is never restored
between iterations, so `line` and `diagonal` bounce edge-to-edge across the
screen (a roaming-DVD effect) rather than stopping at the first edge.
Patterns with a finite path (`jitter`, `walk`, `arc`, `figureEight`) chain
that path cycle after cycle. Interruption remains mouse-movement only.
### Changed
- Simplified on-screen confinement to a single policy: the executor now
reflects every pattern's out-of-range coordinates back inside the screen.
The `abort` and `clamp` bounds policies (and the per-strategy `bounds`
field) were removed. `abort` truncated a sweep at the first edge and `clamp`
could park the cursor against an edge — both counter to keeping the cursor
moving — while `reflect` bounces and keeps going. Behavior is unchanged for
every pattern at normal cursor positions; the only differences are at a
screen edge, where motion now bounces instead of stopping. No config keys,
flags, or pattern names changed.
## [1.3.3] - 2026-08-17
### Changed
@@ -165,6 +216,8 @@ Initial release.
- Source split into `src/{move,cli,config,keeper}.ts`.
- `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.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.1]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.3.0...v1.3.1
+111 -38
View File
@@ -119,10 +119,17 @@ Options:
-d, --step-delay <ms> Pause between synthetic steps. Default: 50.
-p, --pattern <name> Movement strategy. Default: line.
One of: line, diagonal, jitter, walk, arc,
figureEight. Each pattern defines its own
size and speed.
-V, --verbose Log every sweep, interrupt, and bounds event
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,
keep moving until you move the mouse (or
Ctrl+C), instead of firing a single sweep.
Precedence (highest wins): CLI flags > config file > built-in defaults.
```
@@ -135,8 +142,8 @@ internally.
Logging is **quiet by default**: only the startup banner ("Teams Status
Keeper started…") and any error from an unhandled rejection print on a
default run. `-V` / `--verbose` opens up per-sweep, user-interrupt, and
out-of-bounds events.
default run. `-V` / `--verbose` opens up per-sweep and user-interrupt
events.
Invalid input (unknown flag, missing value, non-positive number) prints an
error to `stderr` and exits with code `2`.
@@ -180,14 +187,15 @@ doesn't set.
"checkInterval": 10,
"stepDelay": 50,
"pattern": "line",
"verbose": false
"verbose": false,
"loop": false
}
```
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` is a boolean.
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
@@ -219,20 +227,42 @@ 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.
Any validation failure prints a message naming the file and the offending
key to `stderr` and exits `2`.
### Known limitation: `verbose` can be turned on but not off from the CLI
### Loop mode (`--loop`)
`--verbose` is a presence-only flag (there is no `--no-verbose`). If the
config file sets `"verbose": true`, the CLI cannot force quiet mode in
that invocation. Workarounds: edit the file, or point at a different
file with `--config`.
By default a triggered sweep runs once and stops. With `-l` / `--loop` (or
`"loop": true` in the config file) the movement instead repeats until you
move the mouse (or press `Ctrl+C`) — a "keep moving until I'm back" mode.
It pairs naturally with the roaming patterns:
```sh
move --pattern diagonal --loop # roaming-DVD bounce around the screen
move --pattern figureEight --loop # traces the eight over and over
```
In loop mode the cursor is never restored between iterations, so `line` and
`diagonal` bounce edge-to-edge across the whole screen (the executor keeps
every pattern on-screen by reflecting off the edges) instead of ending at
the first edge. Interruption is detected via mouse movement only — there is
no keyboard hook — so if you resume by typing without touching the mouse,
the cursor keeps cycling until you nudge it or stop the process.
### Known limitation: `verbose` and `loop` can be turned on but not off from the CLI
`--verbose` and `--loop` are presence-only flags (there is no
`--no-verbose` / `--no-loop`). If the config file sets `"verbose": true` or
`"loop": true`, the CLI cannot force it back off in that invocation.
Workarounds: edit the file, or point at a different file with `--config`.
## How it works
@@ -263,10 +293,11 @@ 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,
applies the strategy's bounds policy, paces steps, detects real-user
interruption, and restores the cursor on a clean sweep.
reflects any off-screen coordinate back inside, paces steps, detects
real-user interruption, and restores the cursor on a clean sweep.
Defaults live in `src/config.ts` as `DEFAULT_CONFIG`:
@@ -275,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`.
@@ -295,10 +326,12 @@ 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 apply the strategy's bounds policy
(`abort` / `clamp` / `reflect`) to keep it on-screen.
- Round to whole pixels and reflect any off-screen coordinate back inside
the travel range, so the cursor bounces off the edges and keeps moving.
- Move the cursor there, sleep `config.stepDelay`.
- Re-read the cursor. If it isn't at the point we *just commanded*, the
user moved it — log (when `--verbose`) and return early without
@@ -307,34 +340,74 @@ to milliseconds before handing the resolved `Config` to `runKeeper`.
the next idle-check sees "no movement" and doesn't misread the synthetic
activity as real user input.
In loop mode (`--loop`) step 2 repeats until the user interrupts: a
pattern with an infinite `loopPath` (`line`, `diagonal`) runs that single
never-ending path, while the others chain their finite path cycle after
cycle. The restore in step 3 is skipped so successive cycles flow from where
the last left off.
Comparing against the last commanded (rounded) point — not the strategy's
ideal, possibly fractional target — is what lets curved and stochastic
patterns run without every rounded step looking like user activity. The
comparison also allows a small (2px) tolerance, and the `clamp`/`reflect`
patterns stay a couple of pixels off the screen edge, so sub-pixel cursor
placement on scaled or multi-monitor displays isn't misread as the user
grabbing the mouse. `line` uses the `abort` policy and is unaffected.
comparison also allows a small (2px) tolerance, and the travel range stays a
couple of pixels off the screen edge, so sub-pixel cursor placement on scaled
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 | Bounds |
| ------------- | ------------------------------------------------------------- | ----- | -------- | --------- |
| `line` | Straight horizontal sweep (the original behavior). | 250 | 250px | `abort` |
| `diagonal` | Straight line on both axes toward the roomiest corner. | 250 | 250px/axis | `clamp` |
| `jitter` | Small random hops within a tight radius of the start. | 80 | 30px radius | `clamp` |
| `walk` | Cumulative random walk; bounces off the screen edges. | 200 | ±4px/step | `reflect` |
| `arc` | Smooth quadratic-Bézier curve to a random on-screen point. | 120 | ~300px | `clamp` |
| `figureEight` | Traces a figure-eight (lemniscate) and returns to the start. | 90 | ~250px wide | `clamp` |
| Name | Motion | Steps | Size |
| ------------- | ------------------------------------------------------------- | ----- | ----------- |
| `line` | Straight horizontal sweep (the original behavior). | 250 | 250px |
| `diagonal` | Straight line on both axes toward the roomiest corner. | 250 | 250px/axis |
| `jitter` | Small random hops within a tight radius of the start. | 80 | 30px radius |
| `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
instead of stopping. Strategies therefore never bound their own output —
they emit ideal geometry and let the executor confine it.
Each pattern owns its geometry — how many steps it takes and how far it
reaches — as constants in `src/strategies.ts`. Those are properties of the
pattern, not user preferences, so there is no knob for sweep size or step
count; `stepDelay` (the per-step pause) is the only pacing lever, and it
scales every pattern's total duration. To add a pattern, write one pure
generator and register it — the executor supplies bounds, pacing, interrupt,
and restore for free.
generator and register it — the executor supplies on-screen reflection,
pacing, interrupt, and restore for free.
### Why `mouse.config.autoDelayMs = 0`
@@ -389,8 +462,8 @@ 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/executor.ts` | `executePath` driver: bounds policy, pacing, interrupt detection, restore. |
| `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`. |
| `tsconfig.json` | Strict TypeScript config tuned for Bun (ESNext, bundler resolution). |
+20 -5
View File
@@ -37,21 +37,21 @@ sequenceDiagram
Note over Keeper: pos == lastPos (no user movement)<br/>now - lastActivity ≥ moveInterval → fire
end
Keeper->>Sim: simulateActivity(config, log, dev)
Sim->>Dev: getPosition()
Dev-->>Sim: start
Keeper->>Sim: simulateActivity(config, log, dev, pickRandom)
Sim->>Dev: width()
Dev-->>Sim: width
Sim->>Dev: height()
Dev-->>Sim: height
Note over Sim: strategy = STRATEGIES[config.pattern]<br/>ctx = { start, width, height, rng }
Sim->>Dev: getPosition()
Dev-->>Sim: start
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)
Exec->>Strat: path(ctx)
Strat-->>Exec: iterable of Points
loop for each target point (clean run)
Exec->>Exec: resolveTarget(bounds, target) → point
Exec->>Exec: resolveTarget(target) → point (reflected on-screen)
Exec->>Dev: setPosition(point)
Exec->>Dev: sleep(stepDelay)
Exec->>Dev: getPosition()
@@ -86,3 +86,18 @@ sequenceDiagram
follow-up `getPosition()` in `runKeeper` re-syncs `lastPos` to the origin as
a no-op, and the next idle check sees no net movement (so the synthetic
sweep is never mistaken for the user returning).
- **On-screen confinement is uniform.** `resolveTarget` reflects any
coordinate past a screen edge back inside the travel range — the sole,
per-pattern-independent policy. A strategy emits ideal geometry and never
bounds its own output.
## Loop mode (`--loop`)
This diagram is the single-sweep path (`config.loop === false`). Under
`--loop`, `simulateActivity` instead repeats the step loop until the user
interrupts: a pattern with an infinite `loopPath` (`line`, `diagonal`) runs
that one never-ending path, while the others chain their finite `path` cycle
after cycle, re-reading the cursor as the next `start` each time. The restore
in the final step is skipped (`restore: false`), so successive cycles flow
from where the last left off. Everything else — reflection, pacing, and the
per-step interrupt check — is identical to the sweep traced above.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "move",
"version": "1.3.3",
"version": "1.4.1",
"private": true,
"license": "GPL-3.0-only",
"type": "module",
+2 -1
View File
@@ -3,5 +3,6 @@
"checkInterval": 10,
"stepDelay": 50,
"pattern": "line",
"verbose": false
"verbose": false,
"loop": false
}
+69 -12
View File
@@ -17,8 +17,14 @@
* -c, --check-interval Cursor poll cadence (seconds).
* -d, --step-delay Pause between synthetic steps (ms).
* -p, --pattern Movement strategy name (see strategies.ts).
* -V, --verbose Enable per-sweep / interrupt / bounds logging.
* -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
* until the user moves the mouse (or Ctrl+C).
*
* Numeric overrides are layered (CLI > file > DEFAULT_CONFIG) by
* `resolveConfig` in `config.ts`; this module only parses and validates.
@@ -31,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
@@ -46,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.
@@ -55,6 +67,11 @@ export interface ParsedCliArgs {
* even though the CLI has no off-switch today.
*/
verbose: boolean | undefined;
/**
* `true` when `-l`/`--loop` was passed; `undefined` when it was not.
* Same `undefined`-not-`false` rationale as `verbose`.
*/
loop: boolean | undefined;
}
/**
@@ -80,21 +97,48 @@ function parsePatternName(raw: string | undefined): string | undefined {
if (raw === undefined) return undefined;
const canonical: string | null = resolvePatternName(raw);
if (canonical === null) {
throw new CliError(`invalid value for --pattern: '${raw}' (valid: ${PATTERN_NAMES.join(", ")})`);
throw new CliError(
`invalid value for --pattern: '${raw}' (valid: ${SELECTABLE_PATTERN_NAMES.join(", ")})`,
);
}
return canonical;
}
/**
* Parse `process.argv` into a typed `ParsedCliArgs`. Uses Node's built-in
* `parseArgs` in strict mode so unknown flags and missing values surface
* as `CliError`s that the entry point can turn into exit code 2.
* Fold `-r/--random` and `--pattern` into the single pattern selection that
* the rest of the program consumes.
*
* `-r` is defined as sugar for `--pattern random`, so passing both spellings
* of the same request (`-r --pattern random`) is a harmless no-op. Any other
* pairing states two different intentions at once, and silently honoring one
* would hide the user's mistake — so it's rejected. The message quotes the
* user's own spelling rather than the canonical name, since that's what they
* need to find and fix on their command line.
*
* Exported so the conflict rule is testable without touching `process.argv`.
*/
export function parseCliArgs(): ParsedCliArgs {
export function selectPattern(rawPattern: string | undefined, random: boolean): string | undefined {
const canonical: string | undefined = parsePatternName(rawPattern);
if (!random) return canonical;
if (canonical !== undefined && canonical !== RANDOM_PATTERN) {
throw new CliError(`-r/--random conflicts with --pattern '${rawPattern}' (pick one)`);
}
return RANDOM_PATTERN;
}
/**
* Parse command-line arguments into a typed `ParsedCliArgs`. Uses Node's
* built-in `parseArgs` in strict mode so unknown flags and missing values
* surface as `CliError`s that the entry point can turn into exit code 2.
*
* @param argv - Argument list to parse, defaulting to the real command line.
* Injectable so the flag surface can be unit-tested directly.
*/
export function parseCliArgs(argv: string[] = process.argv.slice(2)): ParsedCliArgs {
let values: Record<string, string | boolean | undefined>;
try {
const result = parseArgs({
args: process.argv.slice(2),
args: argv,
options: {
help: { type: "boolean", short: "h" },
version: { type: "boolean", short: "v" },
@@ -104,7 +148,9 @@ 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" },
},
strict: true,
allowPositionals: false,
@@ -125,8 +171,9 @@ 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,
};
}
@@ -169,10 +216,17 @@ Options:
-c, --check-interval <seconds> Cursor poll cadence. Default: ${checkDefaultSec}.
-d, --step-delay <ms> Pause between synthetic steps. Default: ${DEFAULT_CONFIG.stepDelay}.
-p, --pattern <name> Movement strategy. Default: ${DEFAULT_CONFIG.pattern}.
One of: ${PATTERN_NAMES.join(", ")}.
One of: ${SELECTABLE_PATTERN_NAMES.join(", ")}.
Each pattern defines its own size and speed.
-V, --verbose Log every sweep, interrupt, and bounds event
-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,
keep moving until you move the mouse (or
Ctrl+C), instead of firing a single sweep.
Precedence (highest wins): CLI flags > config file > built-in defaults.
@@ -181,6 +235,9 @@ Examples:
move --move-interval 180 --check-interval 5
move -m 300 -V
move --pattern arc
move --pattern diagonal --loop
move -r
move --pattern random --loop
move --config ~/myprofile.json
`);
}
+23 -7
View File
@@ -22,7 +22,7 @@
import { join } from "node:path";
import { CliError } from "./errors.ts";
import { isPatternName, type PatternName } from "./strategies.ts";
import { isSelectablePattern, type PatternName } from "./strategies.ts";
// Single source of truth for default values. The same file ships in the
// install tree and is copied to $XDG_CONFIG_HOME/move/config.json on a
@@ -44,9 +44,16 @@ 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.
* - `verbose` — whether per-sweep / interrupt / bounds events are
* logged. The startup banner is always printed.
* 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
* repeating the movement until the user moves the mouse
* (or Ctrl+C), rather than firing a single sweep. See
* `keeper.ts` for how the pattern is repeated.
*/
export interface Config {
readonly moveInterval: number;
@@ -54,6 +61,7 @@ export interface Config {
readonly stepDelay: number;
readonly pattern: PatternName;
readonly verbose: boolean;
readonly loop: boolean;
}
/**
@@ -66,8 +74,9 @@ 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;
}
function assertSeedShape(raw: unknown): asserts raw is SeedShape {
@@ -81,12 +90,15 @@ 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") {
throw new Error(`scripts/config.default.json: 'verbose' must be a boolean (got ${JSON.stringify(r.verbose)})`);
}
if (typeof r.loop !== "boolean") {
throw new Error(`scripts/config.default.json: 'loop' must be a boolean (got ${JSON.stringify(r.loop)})`);
}
}
assertSeedShape(seedRaw);
@@ -105,6 +117,7 @@ export const DEFAULT_CONFIG: Config = {
stepDelay: seed.stepDelay,
pattern: seed.pattern,
verbose: seed.verbose,
loop: seed.loop,
};
/**
@@ -125,7 +138,8 @@ export const DEFAULT_CONFIG: Config = {
* was not passed and `true` when it was. There is no CLI off-switch
* today, so CLI `false` doesn't occur — a file-set `verbose: true` cannot
* be overridden back to false from the command line (see the Configuration
* section of the README).
* section of the README). `loop` behaves identically: `-l/--loop` sets it
* `true`, and a file-set `loop: true` can't be switched off from the CLI.
*/
export interface ConfigOverrides {
readonly moveInterval: number | undefined;
@@ -133,6 +147,7 @@ export interface ConfigOverrides {
readonly stepDelay: number | undefined;
readonly pattern: string | undefined;
readonly verbose: boolean | undefined;
readonly loop: boolean | undefined;
}
/**
@@ -199,5 +214,6 @@ export function resolveConfig(file: ConfigOverrides | null, cli: ConfigOverrides
stepDelay: pickRaw(cli.stepDelay, file?.stepDelay, DEFAULT_CONFIG.stepDelay),
pattern: pickRaw(cli.pattern, file?.pattern, DEFAULT_CONFIG.pattern),
verbose: pickRaw(cli.verbose, file?.verbose, DEFAULT_CONFIG.verbose),
loop: pickRaw(cli.loop, file?.loop, DEFAULT_CONFIG.loop),
};
}
+13 -3
View File
@@ -10,8 +10,13 @@
* 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
@@ -31,7 +36,7 @@ import { existsSync, readFileSync, statSync } from "node:fs";
import { defaultConfigPath, type ConfigOverrides } from "./config.ts";
import { CliError } from "./errors.ts";
import { PATTERN_NAMES, resolvePatternName } from "./strategies.ts";
import { SELECTABLE_PATTERN_NAMES, resolvePatternName } from "./strategies.ts";
const ALLOWED_KEYS: ReadonlySet<string> = new Set<string>([
"moveInterval",
@@ -39,6 +44,7 @@ const ALLOWED_KEYS: ReadonlySet<string> = new Set<string>([
"stepDelay",
"pattern",
"verbose",
"loop",
]);
/**
@@ -80,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;
@@ -173,5 +179,9 @@ export function loadConfigFile(explicitPath: string | undefined): ConfigOverride
"verbose" in parsed
? requireBoolean("verbose", parsed.verbose, path)
: undefined,
loop:
"loop" in parsed
? requireBoolean("loop", parsed.loop, path)
: undefined,
};
}
+61 -61
View File
@@ -7,13 +7,13 @@
* *everything else* about carrying a sweep out against a `Device`:
*
* - round each ideal target to whole pixels,
* - keep it on-screen per the strategy's `BoundsPolicy`,
* - keep it on-screen by reflecting coordinates that fall past an edge,
* - command the cursor and pace it with `stepDelay`,
* - detect real-user interruption after each step,
* - restore the cursor to the origin on a clean run.
*
* Writing this once means new patterns inherit correct real-user-wins,
* bounds, and restore semantics for free. It's pure with respect to I/O —
* on-screen, and restore semantics for free. It's pure with respect to I/O —
* all side effects go through the injected `Device`, so it's unit-testable
* with a fake.
*
@@ -25,7 +25,7 @@
import type { Config } from "./config.ts";
import type { Device, Point } from "./device.ts";
import type { BoundsPolicy, MoveContext, MovementStrategy } from "./strategies.ts";
import type { MoveContext, MovementStrategy } from "./strategies.ts";
/**
* Minimal log surface used by the executor and the keeper loop.
@@ -41,11 +41,29 @@ export interface Logger {
/**
* How a sweep ended:
* - `completed` — full path ran and the cursor was restored to start.
* - `interrupted` — real user activity detected mid-sweep; aborted without
* snapping back.
* - `aborted` — an `abort`-policy target went out of bounds.
* - `interrupted` — real user activity detected mid-sweep; the sweep stopped
* without snapping back.
*/
export type SweepOutcome = "completed" | "interrupted" | "aborted";
export type SweepOutcome = "completed" | "interrupted";
/**
* Per-call knobs for `executePath`. All optional; the defaults reproduce the
* original single-sweep behavior exactly, so every existing caller and test
* is unaffected.
*
* - `restore` — restore the cursor to `ctx.start` after a clean sweep.
* Default `true`. Loop (`--loop`) mode passes `false`:
* chained cycles must not snap back between iterations, and an
* infinite `loopPath` never reaches the restore anyway.
* - `loop` — prefer the strategy's infinite `loopPath` when it defines
* one. Falls back to `path` when the strategy has no
* `loopPath`, so a plain chained-repeat caller can pass this
* unconditionally.
*/
export interface ExecuteOptions {
readonly restore?: boolean;
readonly loop?: boolean;
}
/**
* Slack, in pixels, allowed between the coordinate we commanded and the one
@@ -56,20 +74,17 @@ export type SweepOutcome = "completed" | "interrupted" | "aborted";
const READBACK_TOLERANCE: number = 2;
/**
* Pixels to inset the `clamp` / `reflect` travel range from each screen edge.
* Keeps edge-seeking patterns off the literal first/last pixel, where DPI
* scaling and multi-monitor boundaries most often make the OS place the
* cursor a hair off what we commanded (which the readback check would then
* misread as the user). `abort` (used by `line`) is deliberately left on the
* full `[0, max - 1]` range, so its behavior is unchanged.
* Pixels to inset the travel range from each screen edge. Keeps edge-seeking
* patterns off the literal first/last pixel, where DPI scaling and
* multi-monitor boundaries most often make the OS place the cursor a hair off
* what we commanded (which the readback check would then misread as the user).
*/
const EDGE_MARGIN: number = 2;
/**
* The inclusive `[lo, hi]` integer range an axis of length `max` may travel
* under the `clamp` / `reflect` policies: `[0, max - 1]` inset by
* `EDGE_MARGIN` on each side. Screens too small to inset fall back to the
* full range so the math never inverts.
* The inclusive `[lo, hi]` integer range an axis of length `max` may travel:
* `[0, max - 1]` inset by `EDGE_MARGIN` on each side. Screens too small to
* inset fall back to the full range so the math never inverts.
*/
function travelRange(max: number): { lo: number; hi: number } {
const hiEdge: number = max - 1;
@@ -77,18 +92,11 @@ function travelRange(max: number): { lo: number; hi: number } {
return { lo: EDGE_MARGIN, hi: hiEdge - EDGE_MARGIN };
}
/** Round to whole pixels and clamp into the inset travel range for `max`. */
function clampInt(v: number, max: number): number {
const { lo, hi } = travelRange(max);
const r: number = Math.round(v);
if (r < lo) return lo;
if (r > hi) return hi;
return r;
}
/**
* Mirror `v` into the inset travel range for `max` as a triangle wave, so
* values past an edge bounce back inside instead of clamping flat against it.
* values past an edge bounce back inside instead of running off it. This is
* the sole on-screen policy: a coordinate that overshoots an edge reflects
* back in, so a pattern keeps moving instead of parking against the boundary.
*/
function reflectInt(v: number, max: number): number {
const { lo, hi } = travelRange(max);
@@ -100,28 +108,12 @@ function reflectInt(v: number, max: number): number {
}
/**
* Resolve a strategy's ideal target to an on-screen integer pixel under the
* given policy. Returns `null` when policy is `abort` and the (rounded)
* target lies outside the screen — the signal to stop the sweep.
* Resolve a strategy's ideal (possibly fractional, possibly off-screen) target
* to an on-screen integer pixel by reflecting each axis into its travel range.
*/
function resolveTarget(
policy: BoundsPolicy,
p: Point,
width: number,
height: number,
): Point | null {
if (policy === "reflect") {
function resolveTarget(p: Point, width: number, height: number): Point {
return { x: reflectInt(p.x, width), y: reflectInt(p.y, height) };
}
if (policy === "clamp") {
return { x: clampInt(p.x, width), y: clampInt(p.y, height) };
}
// abort: round, then reject anything off-screen.
const x: number = Math.round(p.x);
const y: number = Math.round(p.y);
if (x < 0 || x >= width || y < 0 || y >= height) return null;
return { x, y };
}
/**
* Format the current local time as `HH:MM:SS` for log lines.
@@ -137,15 +129,22 @@ function timestamp(): string {
* against `device`.
*
* Contract, per step:
* 1. Resolve the ideal target to an on-screen integer (bounds policy).
* An `abort`-policy out-of-bounds target ends the sweep (`aborted`).
* 1. Resolve the ideal target to an on-screen integer by reflecting it
* into the travel range.
* 2. Command the cursor there and sleep `config.stepDelay` — also the
* user's interrupt window.
* 3. Re-read the cursor. If it isn't at the point we just commanded, the
* user moved it: return `interrupted` without restoring.
*
* On a clean run the cursor is restored to `ctx.start` so the next
* idle-check sees no net movement, and `completed` is returned.
* idle-check sees no net movement, and `completed` is returned — unless
* `options.restore === false` (loop mode), in which case the cursor is
* left where the last step put it.
*
* `options` (all optional, see `ExecuteOptions`) let loop mode reuse this
* same driver: `loop` selects the strategy's infinite `loopPath`, and
* `restore` suppresses the snap-back. Omitting `options` reproduces the
* original single-sweep contract exactly.
*
* `config` supplies only the pacing (`stepDelay`); a strategy's geometry is
* entirely self-contained, so the path itself needs nothing from it.
@@ -156,17 +155,16 @@ export async function executePath(
device: Device,
log: Logger,
config: Config,
options?: ExecuteOptions,
): Promise<SweepOutcome> {
const { start, width, height } = ctx;
const path: Iterable<Point> =
options?.loop && strategy.loopPath ? strategy.loopPath(ctx) : strategy.path(ctx);
log.event(`Simulating activity (${strategy.name}) at ${timestamp()}...`);
for (const target of strategy.path(ctx)) {
const point: Point | null = resolveTarget(strategy.bounds, target, width, height);
if (point === null) {
log.event(`Out of bounds at ${timestamp()}; aborting simulation.`);
return "aborted";
}
for (const target of path) {
const point: Point = resolveTarget(target, width, height);
await device.setPosition(point);
await device.sleep(config.stepDelay);
@@ -176,22 +174,24 @@ export async function executePath(
Math.abs(current.x - point.x) > READBACK_TOLERANCE ||
Math.abs(current.y - point.y) > READBACK_TOLERANCE
) {
// Cursor isn't where we last put it -> real user activity. Abort
// Cursor isn't where we last put it -> real user activity. Stop
// without snapping back, so we don't yank it from under the user.
//
// The comparison allows a small tolerance rather than demanding an
// exact match: on scaled (fractional-DPI) or multi-monitor setups
// the OS can place the cursor a pixel off the coordinate we
// commanded, and the edge-seeking patterns (clamp/reflect/arc)
// reach exactly the coordinates where that's most likely. A real
// user moves far more than a couple of pixels, so this doesn't
// meaningfully weaken real-user-wins.
log.event(`User activity detected at ${timestamp()}; aborting simulation.`);
// commanded, and edge-seeking patterns reach exactly the
// coordinates where that's most likely. A real user moves far more
// than a couple of pixels, so this doesn't meaningfully weaken
// real-user-wins.
log.event(`User activity detected at ${timestamp()}; stopping simulation.`);
return "interrupted";
}
}
if (options?.restore !== false) {
await device.setPosition({ x: Math.round(start.x), y: Math.round(start.y) });
log.event("Mouse moved.");
}
return "completed";
}
+84 -20
View File
@@ -8,8 +8,8 @@
* the interesting parts stay testable:
* - `device.ts` — the nut.js I/O boundary (injected here).
* - `strategies.ts` — pure "where to move" pattern generators.
* - `executor.ts` — the "how to move" driver (bounds, timing,
* interrupt detection, restore).
* - `executor.ts` — the "how to move" driver (on-screen reflection,
* timing, interrupt detection, restore).
*
* `runKeeper` takes an optional `Device` so tests can drive the loop with a
* fake; production supplies the nut.js device. Importing this module is
@@ -18,14 +18,21 @@
* Logging policy:
* - The startup banner in `runKeeper` is unconditional so the user always
* sees the process is alive.
* - Per-sweep / interrupt / bounds lines are gated by `config.verbose`
* (see `makeLogger`). Errors stay on `console.error`, raised by the
* entry point on unhandled rejection.
* - Per-sweep / interrupt lines are gated by `config.verbose` (see
* `makeLogger`). Errors stay on `console.error`, raised by the entry
* point on unhandled rejection.
*/
import { createNutDevice, type Device, type Point } from "./device.ts";
import { executePath, type Logger } from "./executor.ts";
import { DEFAULT_PATTERN, STRATEGIES, type MoveContext } from "./strategies.ts";
import { executePath, type Logger, type SweepOutcome } from "./executor.ts";
import {
createRandomPicker,
DEFAULT_PATTERN,
RANDOM_PATTERN,
STRATEGIES,
type MoveContext,
type MovementStrategy,
} from "./strategies.ts";
import type { Config } from "./config.ts";
@@ -46,24 +53,73 @@ function makeLogger(verbose: boolean): Logger {
}
/**
* Perform a single synthetic mouse-activity sweep.
* Perform synthetic mouse activity once the keeper decides the cursor is
* idle.
*
* Snapshots the cursor and screen (re-read every call so monitor changes
* are handled), selects the configured strategy from the registry, and
* hands the resulting path to `executePath`, which owns bounds, pacing,
* interrupt detection, and restore-on-clean. An unknown `config.pattern`
* falls back to the default strategy defensively; validation at the CLI /
* config-file boundary should prevent that from ever happening.
* Snapshots the screen (re-read every call so monitor changes are handled)
* and selects the configured strategy from the registry. An unknown
* `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.
*
* Loop mode (`config.loop === true`) keeps the cursor moving until the
* user moves the mouse (or Ctrl+C). The cursor is never restored between
* iterations (`restore: false`). Patterns that define an infinite `loopPath`
* (`line`, `diagonal`) run it once and are stopped only by interruption; the
* rest have their finite `path` chained, re-read from the cursor's current
* 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> {
const start: Point = await device.getPosition();
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: MovementStrategy =
config.pattern === RANDOM_PATTERN
? pickRandom()
: (STRATEGIES[config.pattern] ?? STRATEGIES[DEFAULT_PATTERN]!);
const strategy = STRATEGIES[config.pattern] ?? STRATEGIES[DEFAULT_PATTERN]!;
if (!config.loop) {
const start: Point = await device.getPosition();
const ctx: MoveContext = { start, width, height, rng: Math.random };
await executePath(strategy, ctx, device, log, config);
return;
}
log.event(`Loop mode (${strategy.name}); repeating until you move the mouse.`);
const cycleLog: Logger = { info: log.info, event: (): void => {} };
const loopOpts = { restore: false, loop: true };
let cycles = 0;
let outcome: SweepOutcome;
do {
const start: Point = await device.getPosition();
const ctx: MoveContext = { start, width, height, rng: Math.random };
outcome = await executePath(strategy, ctx, device, cycleLog, config, loopOpts);
cycles++;
// Spin guard for the chained-repeat path: a finite strategy that
// yielded nothing would otherwise return "completed" instantly in a
// tight loop. Sleeping one stepDelay makes that harmless. An infinite
// loopPath never returns "completed", so this branch is skipped there.
if (outcome === "completed") await device.sleep(config.stepDelay);
} while (outcome === "completed");
log.event(`Loop run ended after ${cycles} cycle(s): ${outcome}.`);
}
/**
@@ -88,8 +144,16 @@ async function simulateActivity(config: Config, log: Logger, device: Device): Pr
*
* @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);
@@ -111,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
+1
View File
@@ -121,6 +121,7 @@ const cliOverrides: ConfigOverrides = {
stepDelay: cliArgs.stepDelay,
pattern: cliArgs.pattern,
verbose: cliArgs.verbose,
loop: cliArgs.loop,
};
const config = resolveConfig(fileOverrides, cliOverrides);
+128 -36
View File
@@ -10,9 +10,10 @@
* add (write one pure generator) and trivial to test (feed a deterministic
* `rng`, assert the emitted points).
*
* Coordinates emitted here may be fractional; the executor rounds to whole
* pixels before commanding the cursor and applies the strategy's declared
* `BoundsPolicy` to keep everything on-screen.
* Coordinates emitted here may be fractional and may fall past a screen
* edge; the executor rounds to whole pixels and reflects any out-of-range
* coordinate back inside, so a pattern bounces off the edges and keeps
* moving. Strategies never need to bound their own output.
*
* Each pattern owns its own geometry — how many steps it takes, how far it
* reaches, how tight its radius is — as module-private constants below. Those
@@ -25,19 +26,6 @@
import type { Point } from "./device.ts";
/**
* How the executor keeps a strategy's targets on-screen:
*
* - `abort` — stop the sweep the moment a target falls out of bounds.
* Used by `line`, whose direction is chosen so this never
* actually fires; preserves the original straight-line
* semantics exactly.
* - `clamp` — pin each out-of-bounds coordinate to the nearest edge.
* - `reflect` — mirror out-of-bounds coordinates back inside, so a roaming
* pattern bounces off the screen edges instead of sticking.
*/
export type BoundsPolicy = "abort" | "clamp" | "reflect";
/**
* Everything a strategy needs to generate a path. Screen dimensions and the
* start point are snapshotted per sweep by the caller; `rng` is injected so
@@ -59,17 +47,34 @@ export interface MoveContext {
*
* - `name` — registry key, also the value accepted by `--pattern` / the
* `pattern` config key.
* - `bounds` — how the executor confines this pattern to the screen.
* - `path` — pure generator of ideal (possibly fractional) targets,
* emitted in visiting order. Should not re-emit `start`.
* - `loopPath` — optional infinite variant for loop mode (`--loop`).
* A pattern defines it when its finite `path` doesn't chain
* cleanly under repetition: `line`/`diagonal` re-derive their
* direction from the cursor's position every cycle, so chained
* repetition oscillates in a band near an edge instead of
* crossing the screen. An infinite generator picks its
* direction once and ramps forever; the executor reflects the
* monotonic ramp into an edge-to-edge bounce. Absent this,
* loop mode simply chains `path` — correct for patterns whose
* finite path is a self-contained cyclic unit (`jitter`,
* `walk`, `arc`, `figureEight`). The executor stops either
* kind on real user activity; an infinite `loopPath` therefore
* only ever ends by interruption.
*/
export interface MovementStrategy {
readonly name: string;
readonly bounds: BoundsPolicy;
path(ctx: MoveContext): Iterable<Point>;
loopPath?(ctx: MoveContext): Iterable<Point>;
}
/** Clamp `v` into the inclusive pixel range `[0, max - 1]`. */
/**
* Clamp `v` into the inclusive pixel range `[0, max - 1]`. This is a geometry
* helper for `arc` (choosing a well-formed on-screen endpoint and control
* point), NOT an on-screen bounds policy — the executor keeps every commanded
* point on-screen by reflecting, uniformly for all patterns.
*/
function clamp(v: number, max: number): number {
if (v < 0) return 0;
if (v > max - 1) return max - 1;
@@ -82,14 +87,20 @@ function clamp(v: number, max: number): number {
* Pick a horizontal direction that keeps the sweep on-screen (right if
* there's room, else left) and walk `LINE_STEPS` single-pixel steps with no
* vertical movement. 250 one-pixel steps is byte-for-byte the sweep the
* keeper produced before movement patterns existed, which is why its bounds
* policy is `abort` (the direction choice guarantees it never triggers).
* keeper produced before movement patterns existed. The direction choice
* keeps the finite sweep on-screen, so the executor's reflection never
* actually engages for it.
*
* In loop mode `loopPath` ramps x in one direction forever; the direction
* never matters because the executor reflects the ramp edge to edge.
* `LINE_LOOP_STEP` is several pixels per step rather than one so a screen
* crossing takes seconds, not minutes, at the default cadence.
*/
const LINE_STEPS = 250;
const LINE_LOOP_STEP = 4;
export const line: MovementStrategy = {
name: "line",
bounds: "abort",
*path(ctx: MoveContext): Generator<Point> {
const { start, width } = ctx;
const dx: number = start.x + LINE_STEPS < width ? 1 : -1;
@@ -97,6 +108,14 @@ export const line: MovementStrategy = {
yield { x: start.x + i * dx, y: start.y };
}
},
*loopPath(ctx: MoveContext): Generator<Point> {
const { start } = ctx;
let x: number = start.x;
for (;;) {
x += LINE_LOOP_STEP;
yield { x, y: start.y };
}
},
};
/**
@@ -104,12 +123,17 @@ export const line: MovementStrategy = {
* chosen independently by available room, so the sweep heads toward the
* roomiest corner and stays on-screen. 250 single-pixel steps per axis
* (≈250px reach), matching `line`'s magnitude.
*
* In loop mode `loopPath` ramps both axes forever, and the executor reflects
* them. Because the x and y travel ranges have different spans, their
* triangle waves have different periods, so the path precesses across the
* whole screen — the roaming-DVD bounce — rather than retracing one 45° line.
*/
const DIAGONAL_STEPS = 250;
const DIAGONAL_LOOP_STEP = 4;
export const diagonal: MovementStrategy = {
name: "diagonal",
bounds: "clamp",
*path(ctx: MoveContext): Generator<Point> {
const { start, width, height } = ctx;
const dx: number = start.x + DIAGONAL_STEPS < width ? 1 : -1;
@@ -118,6 +142,16 @@ export const diagonal: MovementStrategy = {
yield { x: start.x + i * dx, y: start.y + i * dy };
}
},
*loopPath(ctx: MoveContext): Generator<Point> {
const { start } = ctx;
let x: number = start.x;
let y: number = start.y;
for (;;) {
x += DIAGONAL_LOOP_STEP;
y += DIAGONAL_LOOP_STEP;
yield { x, y };
}
},
};
/**
@@ -132,7 +166,6 @@ const JITTER_RADIUS = 30;
export const jitter: MovementStrategy = {
name: "jitter",
bounds: "clamp",
*path(ctx: MoveContext): Generator<Point> {
const { start, rng } = ctx;
for (let i = 1; i <= JITTER_STEPS; i++) {
@@ -148,15 +181,14 @@ export const jitter: MovementStrategy = {
* per-axis delta in `[-WALK_STEP, +WALK_STEP]`. The per-step magnitude is
* deliberately several pixels so the walk actually roams — a ±1px walk over
* this many steps would drift only ~√N pixels net. The generator lets the
* position drift freely; the executor's `reflect` policy mirrors it back
* on-screen, so the cursor bounces off the edges instead of escaping.
* position drift freely; the executor mirrors it back on-screen, so the
* cursor bounces off the edges instead of escaping.
*/
const WALK_STEPS = 200;
const WALK_STEP = 4;
export const walk: MovementStrategy = {
name: "walk",
bounds: "reflect",
*path(ctx: MoveContext): Generator<Point> {
const { start, rng } = ctx;
let x: number = start.x;
@@ -180,7 +212,6 @@ const ARC_REACH = 300;
export const arc: MovementStrategy = {
name: "arc",
bounds: "clamp",
*path(ctx: MoveContext): Generator<Point> {
const { start, width, height, rng } = ctx;
@@ -222,7 +253,6 @@ const FIG8_AMP = 125;
export const figureEight: MovementStrategy = {
name: "figureEight",
bounds: "clamp",
*path(ctx: MoveContext): Generator<Point> {
const { start } = ctx;
for (let i = 1; i <= FIG8_STEPS; i++) {
@@ -251,9 +281,28 @@ export const STRATEGIES: Readonly<Record<string, MovementStrategy>> = {
/** Pattern used when neither the CLI nor the config file selects one. */
export const DEFAULT_PATTERN = "line";
/** All valid pattern names, for validation messages and help text. */
/** All registered strategy names. Real generators only — see `RANDOM_PATTERN`. */
export const PATTERN_NAMES: readonly string[] = Object.keys(STRATEGIES);
/**
* The reserved name for "pick a different pattern each sweep".
*
* Deliberately NOT a registry entry: it has no path of its own, so there is
* nothing for `STRATEGIES` to hold and nothing for the executor to drive. It
* is a *selection* the user makes, resolved to a real strategy once per sweep
* by the keeper (see `createRandomPicker`). Keeping it out of the registry is
* what lets `STRATEGIES[name]` stay a total lookup for every key it contains.
*/
export const RANDOM_PATTERN = "random";
/**
* Everything the user may pass to `--pattern` / the `pattern` config key:
* the registry names plus the `random` sentinel. This is the list to quote in
* help text and validation errors; `PATTERN_NAMES` is the narrower "real
* generators" list that the keeper and the strategy tests care about.
*/
export const SELECTABLE_PATTERN_NAMES: readonly string[] = [...PATTERN_NAMES, RANDOM_PATTERN];
/**
* The set of valid `--pattern` / `pattern` values as a string-literal-ish
* type. Kept as `string` at the type level (the registry is the runtime
@@ -266,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`,
@@ -274,16 +333,19 @@ export function isPatternName(name: string): boolean {
const normalizePattern = (s: string): string => s.toLowerCase().replace(/[-_\s]/g, "");
/**
* Map of normalized name -> canonical registry key. Built once at module
* load. The assertion below guards against two registered names collapsing
* to the same normalized form (e.g. a future `"figure_eight"` alongside
* `"figureEight"`), which would otherwise let one silently shadow the other.
* Map of normalized name -> canonical selectable name. Built once at module
* load over `SELECTABLE_PATTERN_NAMES`, so the `random` sentinel normalizes
* like any other name and both validation boundaries accept it without
* special-casing. The assertion below guards against two selectable names
* collapsing to the same normalized form (e.g. a future `"figure_eight"`
* alongside `"figureEight"`, or a strategy named `"Random"`), which would
* otherwise let one silently shadow the other.
*/
const CANONICAL_PATTERNS: ReadonlyMap<string, string> = new Map(
PATTERN_NAMES.map((n) => [normalizePattern(n), n]),
SELECTABLE_PATTERN_NAMES.map((n) => [normalizePattern(n), n]),
);
if (CANONICAL_PATTERNS.size !== PATTERN_NAMES.length) {
if (CANONICAL_PATTERNS.size !== SELECTABLE_PATTERN_NAMES.length) {
throw new Error(
"strategies.ts: two pattern names collide after normalization; rename one so they differ by more than case/separators",
);
@@ -298,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]!;
};
}
+131
View File
@@ -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'/);
});
});
+16
View File
@@ -17,6 +17,7 @@ const NONE: ConfigOverrides = {
stepDelay: undefined,
pattern: undefined,
verbose: undefined,
loop: undefined,
};
describe("resolveConfig", () => {
@@ -78,6 +79,21 @@ describe("resolveConfig", () => {
const cfg = resolveConfig(null, NONE);
expect(cfg.verbose).toBe(DEFAULT_CONFIG.verbose);
});
test("loop: CLI true wins over file false", () => {
const cfg = resolveConfig({ ...NONE, loop: false }, { ...NONE, loop: true });
expect(cfg.loop).toBe(true);
});
test("loop: file true wins over default (no CLI)", () => {
const cfg = resolveConfig({ ...NONE, loop: true }, NONE);
expect(cfg.loop).toBe(true);
});
test("loop: falls back to DEFAULT_CONFIG.loop when neither set", () => {
const cfg = resolveConfig(null, NONE);
expect(cfg.loop).toBe(DEFAULT_CONFIG.loop);
});
});
describe("defaultConfigPath", () => {
+28
View File
@@ -98,6 +98,17 @@ describe("loadConfigFile (explicit path)", () => {
expect(() => loadConfigFile(path)).toThrow(/'verbose'.*boolean/);
});
test("accepts a boolean loop", () => {
const path = writeFixture("loop.json", JSON.stringify({ loop: true }));
const result = loadConfigFile(path);
expect(result!.loop).toBe(true);
});
test("throws when loop is the wrong type", () => {
const path = writeFixture("loop-bad.json", JSON.stringify({ loop: "yes" }));
expect(() => loadConfigFile(path)).toThrow(/'loop'.*boolean/);
});
test("accepts a known pattern", () => {
const path = writeFixture("pattern.json", JSON.stringify({ pattern: "arc" }));
const result = loadConfigFile(path);
@@ -114,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", () => {
+109 -36
View File
@@ -2,9 +2,9 @@
* executor.test.ts
* ----------------
* Unit tests for the execution driver against a fake `Device`. Covers the
* three sweep outcomes, all three bounds policies, the rounding/interrupt
* contract, and step pacing — none of which was testable before the device
* seam existed.
* two sweep outcomes, on-screen reflection, the rounding/interrupt contract,
* step pacing, and the loop/restore options — none of which was testable
* before the device seam existed.
*/
import { describe, expect, test } from "bun:test";
@@ -13,7 +13,7 @@ import { DEFAULT_CONFIG } from "../src/config.ts";
import type { Config } from "../src/config.ts";
import type { Device, Point } from "../src/device.ts";
import { executePath, type Logger } from "../src/executor.ts";
import type { BoundsPolicy, MoveContext, MovementStrategy } from "../src/strategies.ts";
import type { MoveContext, MovementStrategy } from "../src/strategies.ts";
const noopLog: Logger = { info: (): void => {}, event: (): void => {} };
@@ -50,11 +50,10 @@ class FakeDevice implements Device {
}
}
/** A strategy that emits a fixed list of points under a chosen bounds policy. */
function fixed(points: Point[], bounds: BoundsPolicy): MovementStrategy {
/** A strategy that emits a fixed list of points. */
function fixed(points: Point[]): MovementStrategy {
return {
name: "fixed",
bounds,
*path(): Generator<Point> {
yield* points;
},
@@ -79,7 +78,7 @@ describe("executePath — outcomes", () => {
{ x: 502, y: 500 },
{ x: 503, y: 500 },
];
const outcome = await executePath(fixed(pts, "clamp"), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf());
const outcome = await executePath(fixed(pts), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf());
expect(outcome).toBe("completed");
// 3 steps + 1 restore.
expect(dev.commanded).toEqual([...pts, start]);
@@ -95,42 +94,116 @@ describe("executePath — outcomes", () => {
];
// 2nd getPosition call reports the user elsewhere.
dev.overrides.set(2, { x: 9, y: 9 });
const outcome = await executePath(fixed(pts, "clamp"), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf());
const outcome = await executePath(fixed(pts), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf());
expect(outcome).toBe("interrupted");
// Commanded points 1 and 2 only; never restored to start.
expect(dev.commanded).toEqual([pts[0]!, pts[1]!]);
expect(dev.commanded.at(-1)).not.toEqual(start);
});
test("abort policy stops before commanding an out-of-bounds point", async () => {
const dev = new FakeDevice(100, 100);
const pts = [{ x: 150, y: 10 }]; // x >= width
const outcome = await executePath(fixed(pts, "abort"), ctxOf({ x: 10, y: 10 }, 100, 100), dev, noopLog, cfgOf());
expect(outcome).toBe("aborted");
expect(dev.commanded).toEqual([]);
});
});
describe("executePath — bounds policies", () => {
test("clamp pins out-of-bounds coordinates to the inset edges", async () => {
const dev = new FakeDevice(100, 100);
const pts = [
{ x: -5, y: 50 },
{ x: 9999, y: 50 },
];
// travelRange(100) is inset by EDGE_MARGIN (2) to [2, 97].
await executePath(fixed(pts, "clamp"), ctxOf({ x: 50, y: 50 }, 100, 100), dev, noopLog, cfgOf());
expect(dev.commanded[0]).toEqual({ x: 2, y: 50 });
expect(dev.commanded[1]).toEqual({ x: 97, y: 50 });
});
test("reflect mirrors out-of-bounds coordinates back inside the inset range", async () => {
describe("executePath — on-screen reflection", () => {
test("mirrors an out-of-range coordinate back inside the inset range", async () => {
const dev = new FakeDevice(100, 100);
// Inset range [2, 97], span = 95; x=120 -> (120-2)=118, 190-118=72, +2 = 74.
const pts = [{ x: 120, y: 50 }];
await executePath(fixed(pts, "reflect"), ctxOf({ x: 50, y: 50 }, 100, 100), dev, noopLog, cfgOf());
await executePath(fixed(pts), ctxOf({ x: 50, y: 50 }, 100, 100), dev, noopLog, cfgOf());
expect(dev.commanded[0]).toEqual({ x: 74, y: 50 });
});
test("negative and far-past-edge coordinates both fold inside", async () => {
const dev = new FakeDevice(100, 100);
// Inset [2, 97]. x=-5 -> reflects to 9; x=99 -> 95 (period 190).
const pts = [
{ x: -5, y: 50 },
{ x: 99, y: 50 },
];
await executePath(fixed(pts), ctxOf({ x: 50, y: 50 }, 100, 100), dev, noopLog, cfgOf());
for (const p of dev.commanded.slice(0, 2)) {
expect(p.x).toBeGreaterThanOrEqual(2);
expect(p.x).toBeLessThanOrEqual(97);
}
});
test("a monotonic ramp past an edge keeps moving — never two identical points in a row", async () => {
// This is the guarantee that motivated removing `clamp`: a clamp would
// pin every over-the-edge point to the same edge pixel, stalling the
// cursor. Reflection folds the ramp into a triangle wave, so the cursor
// both rises and falls and never repeats a pixel step to step.
const dev = new FakeDevice(40, 40);
// Ramp x well past the right edge and back's worth of travel.
const pts = Array.from({ length: 60 }, (_, i) => ({ x: 10 + i, y: 20 }));
await executePath(fixed(pts), ctxOf({ x: 10, y: 20 }, 40, 40), dev, noopLog, cfgOf({ stepDelay: 0 }));
const xs = dev.commanded.slice(0, 60).map((p) => p.x);
// No stall: consecutive commanded points always differ.
for (let i = 1; i < xs.length; i++) {
expect(xs[i]).not.toBe(xs[i - 1]);
}
// It bounced: the ramp both increased and decreased at some point.
const rose = xs.some((x, i) => i > 0 && x > xs[i - 1]!);
const fell = xs.some((x, i) => i > 0 && x < xs[i - 1]!);
expect(rose && fell).toBe(true);
});
});
describe("executePath — options", () => {
test("restore:false leaves the cursor at the last step, no snap-back", async () => {
const dev = new FakeDevice();
const start = { x: 500, y: 500 };
const pts = [
{ x: 501, y: 500 },
{ x: 502, y: 500 },
];
const outcome = await executePath(
fixed(pts),
ctxOf(start, dev.w, dev.h),
dev,
noopLog,
cfgOf(),
{ restore: false },
);
expect(outcome).toBe("completed");
// No trailing restore-to-start command.
expect(dev.commanded).toEqual(pts);
});
test("the default (no options) still restores to start", async () => {
const dev = new FakeDevice();
const start = { x: 500, y: 500 };
const pts = [{ x: 501, y: 500 }];
await executePath(fixed(pts), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf());
expect(dev.commanded).toEqual([...pts, start]);
});
test("loop:true runs loopPath when present, path otherwise", async () => {
const dev = new FakeDevice();
// A strategy whose loopPath differs from its path, both finite here.
const strat: MovementStrategy = {
name: "dual",
*path(): Generator<Point> {
yield { x: 1, y: 1 };
},
*loopPath(): Generator<Point> {
yield { x: 10, y: 10 };
yield { x: 20, y: 20 };
},
};
await executePath(strat, ctxOf({ x: 0, y: 0 }, dev.w, dev.h), dev, noopLog, cfgOf(), {
loop: true,
restore: false,
});
expect(dev.commanded).toEqual([{ x: 10, y: 10 }, { x: 20, y: 20 }]);
});
test("loop:true falls back to path when the strategy has no loopPath", async () => {
const dev = new FakeDevice();
const strat = fixed([{ x: 3, y: 3 }]);
await executePath(strat, ctxOf({ x: 0, y: 0 }, dev.w, dev.h), dev, noopLog, cfgOf(), {
loop: true,
restore: false,
});
expect(dev.commanded).toEqual([{ x: 3, y: 3 }]);
});
});
describe("executePath — readback tolerance", () => {
@@ -145,7 +218,7 @@ describe("executePath — readback tolerance", () => {
// not the user). 2px is within READBACK_TOLERANCE, so the sweep runs on.
dev.overrides.set(1, { x: 512, y: 501 });
dev.overrides.set(2, { x: 518, y: 499 });
const outcome = await executePath(fixed(pts, "clamp"), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf());
const outcome = await executePath(fixed(pts), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf());
expect(outcome).toBe("completed");
expect(dev.commanded).toEqual([...pts, start]);
});
@@ -159,7 +232,7 @@ describe("executePath — readback tolerance", () => {
];
// First readback is 3px off -> exceeds the 2px tolerance -> real user.
dev.overrides.set(1, { x: 513, y: 500 });
const outcome = await executePath(fixed(pts, "clamp"), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf());
const outcome = await executePath(fixed(pts), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf());
expect(outcome).toBe("interrupted");
expect(dev.commanded).toEqual([pts[0]!]);
});
@@ -170,7 +243,7 @@ describe("executePath — rounding & pacing", () => {
const dev = new FakeDevice();
const start = { x: 500, y: 500 };
const pts = [{ x: 10.4, y: 20.6 }]; // -> (10, 21)
const outcome = await executePath(fixed(pts, "clamp"), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf());
const outcome = await executePath(fixed(pts), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf());
expect(outcome).toBe("completed");
expect(dev.commanded[0]).toEqual({ x: 10, y: 21 });
});
@@ -181,7 +254,7 @@ describe("executePath — rounding & pacing", () => {
{ x: 501, y: 500 },
{ x: 502, y: 500 },
];
await executePath(fixed(pts, "clamp"), ctxOf({ x: 500, y: 500 }, dev.w, dev.h), dev, noopLog, cfgOf({ stepDelay: 7 }));
await executePath(fixed(pts), ctxOf({ x: 500, y: 500 }, dev.w, dev.h), dev, noopLog, cfgOf({ stepDelay: 7 }));
expect(dev.sleeps).toEqual([7, 7]);
});
});
+120 -2
View File
@@ -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>): Config => ({
...overrides,
});
async function runUntilStop(config: Config, device: Device): Promise<void> {
async function runUntilStop(
config: Config,
device: Device,
pickRandom?: () => MovementStrategy,
): Promise<void> {
try {
await runKeeper(config, device);
await runKeeper(config, device, pickRandom);
} catch (err) {
if (!(err instanceof StopError)) throw err;
}
@@ -89,3 +94,116 @@ describe("runKeeper", () => {
expect(dev.commanded.length).toBe(0);
});
});
/** 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
// past a single finite sweep's reach before the budget stops it.
const dev = new LoopDevice(400, { x: 100, y: 100 });
await runUntilStop(quietConfig({ moveInterval: 0, pattern: "line", loop: true }), dev);
expect(maxX(dev.commanded)).toBeGreaterThan(1000);
});
test("single-sweep mode restores each sweep, so x never ramps away", async () => {
// Same setup without loop: `line` runs 250 one-pixel steps then snaps
// back to the start, so x is bounded by start + 250 no matter how many
// sweeps fire within the budget.
const dev = new LoopDevice(400, { x: 100, y: 100 });
await runUntilStop(quietConfig({ moveInterval: 0, pattern: "line", loop: false }), dev);
expect(maxX(dev.commanded)).toBeLessThanOrEqual(350);
});
test("loop mode chains a finite pattern across multiple cycles per trigger", async () => {
// `figureEight` has no loopPath, so loop mode chains its 90-step path.
// A single trigger keeps chaining cycles until the budget stops it,
// yielding far more than the 90 commands one cycle would.
const dev = new LoopDevice(400, { x: 800, y: 500 });
await runUntilStop(
quietConfig({ moveInterval: 0, pattern: "figureEight", loop: true }),
dev,
);
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);
});
});
+131
View File
@@ -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,
@@ -36,6 +40,16 @@ function mulberry32(seed: number): () => number {
};
}
/** Pull the first `n` points from a (possibly infinite) point iterable. */
function take(iter: Iterable<Point>, n: number): Point[] {
const out: Point[] = [];
for (const p of iter) {
out.push(p);
if (out.length >= n) break;
}
return out;
}
function ctxOf(overrides: {
start?: Point;
width?: number;
@@ -67,6 +81,14 @@ describe("line", () => {
expect(pts[1]!.x).toBe(88);
expect(pts.at(-1)!.x).toBe(90 - 250);
});
test("loopPath ramps x forever at a fixed step, y held constant", () => {
const start = { x: 500, y: 300 };
const pts = take(line.loopPath!(ctxOf({ start })), 5);
// Monotonic +4 per step (LINE_LOOP_STEP), no vertical drift.
expect(pts.map((p) => p.x)).toEqual([504, 508, 512, 516, 520]);
expect(pts.every((p) => p.y === 300)).toBe(true);
});
});
describe("diagonal", () => {
@@ -76,6 +98,16 @@ describe("diagonal", () => {
expect(pts[0]!).toEqual({ x: 501, y: 501 });
expect(pts.at(-1)!).toEqual({ x: 750, y: 750 });
});
test("loopPath ramps both axes forever at a fixed step", () => {
const pts = take(diagonal.loopPath!(ctxOf({ start: { x: 100, y: 200 } })), 3);
// Both axes advance by DIAGONAL_LOOP_STEP (4) each step.
expect(pts).toEqual([
{ x: 104, y: 204 },
{ x: 108, y: 208 },
{ x: 112, y: 212 },
]);
});
});
describe("jitter", () => {
@@ -159,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<string>();
for (let i = 0; i < 400; i++) seen.add(pick().name);
expect(seen).toEqual(new Set(PATTERN_NAMES));
});
});