Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
403334ba6d | ||
|
|
d38949edb4 | ||
|
|
b019f25a42 | ||
|
|
c8942bb380 | ||
|
|
7e632b3e9d | ||
|
|
1ad724cd33 | ||
|
|
b9669269bc | ||
|
|
68e64eeaef | ||
|
|
d0528b4a92 | ||
|
|
c002a6d902 | ||
|
|
41903ebaf1 | ||
|
|
819cc5a5fb | ||
|
|
ec33648e74 | ||
|
|
db3310c247 | ||
|
|
7777b16540 |
+229
@@ -0,0 +1,229 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
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
|
||||||
|
- `install.sh` now installs the newest published tag by default instead of
|
||||||
|
tracking the `master` branch, so the plain `curl ... | sh` one-liner
|
||||||
|
installs a real release and reports its version (e.g. `v1.3.2`). The tag is
|
||||||
|
resolved from the Gitea tags API; if that lookup fails (offline, API
|
||||||
|
unreachable, or no tags yet) it falls back to `master`, preserving the old
|
||||||
|
behavior. Set `MOVE_VERSION` to pin an explicit branch or tag as before.
|
||||||
|
|
||||||
|
## [1.3.2] - 2026-08-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `install.sh` is now interactive. When it finds an existing install and a
|
||||||
|
controlling terminal is available, it reports what's there and asks before
|
||||||
|
replacing it, instead of leaving `MOVE_FORCE` as the only control. If a
|
||||||
|
config file already exists it asks separately whether to reseed it from the
|
||||||
|
shipped defaults. Both questions are asked before anything is downloaded or
|
||||||
|
deleted, so declining changes nothing.
|
||||||
|
- `MOVE_RESEED_CONFIG=1` overwrites the user config with the shipped defaults
|
||||||
|
without prompting, for unattended use. The previous file is kept as
|
||||||
|
`config.json.bak`; the same backup is written when reseeding is confirmed
|
||||||
|
at the prompt.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `MOVE_FORCE=1` now means "skip every prompt and reinstall unconditionally".
|
||||||
|
It deliberately does not touch the user config, so automation that
|
||||||
|
reinstalls the CLI can't take customizations down with it.
|
||||||
|
- Existing-install detection looks for the install tree and the wrapper, not
|
||||||
|
just the `.installed-version` marker, so a half-finished or hand-moved
|
||||||
|
install is caught rather than silently overwritten.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Installing a *different* version over an existing one used to wipe and
|
||||||
|
replace it with no warning; only an exact version match was ever reported.
|
||||||
|
That case now prompts. With no terminal (CI, cron, container builds) the
|
||||||
|
previous non-interactive behavior is preserved exactly: an identical
|
||||||
|
version is a no-op, a different version is replaced.
|
||||||
|
|
||||||
|
## [1.3.1] - 2026-08-14
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `docs/execution-happy-path.md`: a sequence diagram (plus invariants) tracing
|
||||||
|
a clean idle-triggered sweep end to end, linked from the README.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Stale comments corrected to match the current code: the `install.sh` /
|
||||||
|
`uninstall.sh` header curl URLs pointed at a nonexistent repo-root path
|
||||||
|
(they live under `scripts/`), so the documented command 404'd; `move.ts`'s
|
||||||
|
module list omitted `editor.ts` and the `--edit` step; and `config.ts` still
|
||||||
|
described a removed pixel unit.
|
||||||
|
|
||||||
|
## [1.3.0] - 2026-08-14
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Pluggable movement strategies. New `-p, --pattern <name>` flag and
|
||||||
|
`pattern` config key select how the cursor moves: `line` (default,
|
||||||
|
unchanged behavior), `diagonal`, `jitter`, `walk`, `arc`, `figureEight`.
|
||||||
|
Each pattern owns its own size and step count as constants; there is no
|
||||||
|
user knob for sweep magnitude.
|
||||||
|
- Pattern names are matched leniently: case and separators are ignored, so
|
||||||
|
`figureEight`, `figure-eight`, `figure_eight`, and `FIGUREEIGHT` are all
|
||||||
|
accepted (on the CLI and in the config file) and resolve to the canonical
|
||||||
|
name.
|
||||||
|
- `src/device.ts`: injectable `Device` seam over nut.js, enabling unit
|
||||||
|
tests for movement without the native binary or a real screen.
|
||||||
|
- `src/strategies.ts`: pure, per-pattern path generators plus the registry
|
||||||
|
and name validation.
|
||||||
|
- `src/executor.ts`: single `executePath` driver owning bounds policy
|
||||||
|
(`abort`/`clamp`/`reflect`), pacing, interrupt detection, and restore.
|
||||||
|
- Test suites for strategies, the executor (all bounds policies, rounding,
|
||||||
|
interrupt), and the keeper loop.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `simulateActivity` no longer hardcodes a straight-line sweep; it selects a
|
||||||
|
strategy from the registry and delegates execution to `executePath`. The
|
||||||
|
default `line` pattern is byte-for-byte the previous behavior.
|
||||||
|
- Interrupt detection now compares against the last *commanded* (rounded)
|
||||||
|
point rather than an ideal target, so fractional/curved paths don't
|
||||||
|
self-trip.
|
||||||
|
- `mouse.config.autoDelayMs = 0` moved from `runKeeper` into
|
||||||
|
`createNutDevice` — the single place nut.js is wired up.
|
||||||
|
- `runKeeper(config, device?)` accepts an injected device for testing.
|
||||||
|
- Interrupt detection tolerates a small (2px) gap between the commanded and
|
||||||
|
read-back cursor position, and the `clamp`/`reflect` patterns stay a few
|
||||||
|
pixels off the screen edge. Together these avoid false "user activity"
|
||||||
|
aborts from sub-pixel cursor placement on scaled or multi-monitor setups,
|
||||||
|
which the new edge-seeking patterns would otherwise hit. `line` (policy
|
||||||
|
`abort`) is unaffected.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- `-n, --step-count` flag and the `stepCount` / `stepSize` config keys. Sweep
|
||||||
|
size and step count are now intrinsic to each movement pattern, not user
|
||||||
|
knobs. Config files that still contain these keys keep working: the loader
|
||||||
|
ignores them with a one-line notice instead of rejecting them, so existing
|
||||||
|
installs (all seeded with `stepCount`) don't break on upgrade. The removed
|
||||||
|
CLI flag, however, is a hard error like any other unknown option.
|
||||||
|
|
||||||
|
## [1.2.0] - 2026-06-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `-e, --edit` flag opens the resolved config file in `$EDITOR`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `keeper.ts` (and `@nut-tree-fork/nut-js`) is lazy-imported, so `--help`
|
||||||
|
and `--version` skip the nut.js load and start ~10x faster.
|
||||||
|
|
||||||
|
## [1.1.1] - 2026-06-17
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Tests moved from `src/` to a top-level `tests/` directory.
|
||||||
|
- `tsconfig.json` sets `"types": ["bun"]` so VS Code resolves `bun:test`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- `package.json` version now matches the published tag.
|
||||||
|
|
||||||
|
## [1.1.0] - 2026-06-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- JSON config file support at `${XDG_CONFIG_HOME:-~/.config}/move/config.json`.
|
||||||
|
Precedence: CLI flags > config file > defaults. Strict validation.
|
||||||
|
- `-C, --config <path>` to override the default config path.
|
||||||
|
- Installer seeds `config.json` with project defaults on fresh install only.
|
||||||
|
- Bun test suite for `resolveConfig` and `loadConfigFile`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Installer scripts now live in `scripts/`.
|
||||||
|
- `verbose` is now a first-class `Config` field; `resolveVerbose` removed.
|
||||||
|
- `CliError` extracted into `src/errors.ts`.
|
||||||
|
- `defaultConfigPath()` throws when both `$XDG_CONFIG_HOME` and `$HOME` are unset.
|
||||||
|
- `mouse.config.autoDelayMs = 0` moved into `runKeeper` (no module-load side effect).
|
||||||
|
- Runtime errors go through a `failRuntime` helper that mirrors `failUser`.
|
||||||
|
- `keeper.ts` declares a named `Logger` interface.
|
||||||
|
- `dev-setup.sh` is now POSIX `sh`.
|
||||||
|
- `tsconfig.json`: enabled `noUncheckedIndexedAccess` and `resolveJsonModule`.
|
||||||
|
|
||||||
|
## [1.0.1] - 2026-06-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- End-user `install.sh` runnable via `curl ... | sh`. XDG-respecting, idempotent.
|
||||||
|
- `uninstall.sh` removes the wrapper and install tree; leaves Bun and user
|
||||||
|
config alone.
|
||||||
|
- `dev-setup.sh` for contributors.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- `DISTRIBUTION-PLAN.md` (design notes, superseded by the implementation).
|
||||||
|
|
||||||
|
## [1.0.0] - 2026-06-15
|
||||||
|
|
||||||
|
Initial release.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `move` CLI for keeping presence-tracking apps marked Available by nudging
|
||||||
|
the cursor after a configurable idle period.
|
||||||
|
- Flags: `-h/--help`, `-v/--version`, `-m/--move-interval`,
|
||||||
|
`-c/--check-interval`, `-d/--step-delay`, `-n/--step-count`, `-V/--verbose`.
|
||||||
|
- Quiet-by-default logging.
|
||||||
|
- 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
|
||||||
|
[1.3.0]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.2.0...v1.3.0
|
||||||
|
[1.2.0]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.1.1...v1.2.0
|
||||||
|
[1.1.1]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.1.0...v1.1.1
|
||||||
|
[1.1.0]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.0.1...v1.1.0
|
||||||
|
[1.0.1]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.0.0...v1.0.1
|
||||||
|
[1.0.0]: https://gitea.cahlen.com/nokeo08/Move/releases/tag/v1.0.0
|
||||||
@@ -23,8 +23,11 @@ cursor leaves the position the script just commanded.
|
|||||||
curl -fsSL https://gitea.cahlen.com/nokeo08/Move/raw/branch/master/scripts/install.sh | sh
|
curl -fsSL https://gitea.cahlen.com/nokeo08/Move/raw/branch/master/scripts/install.sh | sh
|
||||||
```
|
```
|
||||||
|
|
||||||
This fetches the latest `master` from Gitea, runs `bun install --production`
|
This installs the latest tagged release from Gitea (the installer resolves
|
||||||
under the install dir, and drops a `move` wrapper on your bin dir.
|
it automatically), runs `bun install --production` under the install dir,
|
||||||
|
and drops a `move` wrapper on your bin dir. If the latest tag can't be
|
||||||
|
determined — offline, or the API is unreachable — it falls back to the
|
||||||
|
`master` branch. Pin an exact ref with `MOVE_VERSION` (see below).
|
||||||
|
|
||||||
The installer respects the XDG Base Directory Specification:
|
The installer respects the XDG Base Directory Specification:
|
||||||
|
|
||||||
@@ -33,14 +36,38 @@ The installer respects the XDG Base Directory Specification:
|
|||||||
`XDG_BIN_HOME` is the widely-recognized de facto convention; XDG itself
|
`XDG_BIN_HOME` is the widely-recognized de facto convention; XDG itself
|
||||||
doesn't standardize a user bin dir.
|
doesn't standardize a user bin dir.
|
||||||
|
|
||||||
|
### Reinstalling over an existing install
|
||||||
|
|
||||||
|
The installer never replaces an existing install silently. When it finds
|
||||||
|
one and it can reach a terminal, it tells you what's there and asks:
|
||||||
|
|
||||||
|
```
|
||||||
|
==> Found an existing move install (v1.2.0) at /home/you/.local/share/move
|
||||||
|
Replace it with v1.3.2? [Y/n]
|
||||||
|
```
|
||||||
|
|
||||||
|
If a config file already exists, it asks separately whether to overwrite
|
||||||
|
it with the shipped defaults (default: no). Both questions come *before*
|
||||||
|
anything is downloaded or deleted, so declining costs you nothing.
|
||||||
|
|
||||||
|
This works under `curl ... | sh` too: the prompts read from `/dev/tty`
|
||||||
|
rather than stdin, which the piped script itself occupies.
|
||||||
|
|
||||||
|
With no terminal available — CI, cron, a container build — there's nobody
|
||||||
|
to ask, so the installer falls back to its long-standing behavior: an
|
||||||
|
identical version is a no-op, a different version is replaced, and your
|
||||||
|
config is left alone. Use the env vars below to drive it explicitly.
|
||||||
|
|
||||||
Env vars (all optional):
|
Env vars (all optional):
|
||||||
|
|
||||||
| Var | Default | Purpose |
|
| Var | Default | Purpose |
|
||||||
| --- | ------- | ------- |
|
| --- | ------- | ------- |
|
||||||
| `MOVE_VERSION` | `master` | Branch or tag to install. Pin with e.g. `v1.0.0`. |
|
| `MOVE_VERSION` | latest tag | Branch or tag to install; auto-resolves to the newest tag, falling back to `master`. Pin with e.g. `v1.0.0`. |
|
||||||
| `MOVE_FORCE` | unset | Set to `1` to reinstall when the same version is already present. |
|
| `MOVE_FORCE` | unset | Set to `1` to skip every prompt and reinstall unconditionally. Never touches your config. |
|
||||||
|
| `MOVE_RESEED_CONFIG` | unset | Set to `1` to overwrite your config with the shipped defaults without asking. The old file is kept as `config.json.bak`. |
|
||||||
| `XDG_DATA_HOME` | `$HOME/.local/share` | Where the source tree is installed (under `move/`). |
|
| `XDG_DATA_HOME` | `$HOME/.local/share` | Where the source tree is installed (under `move/`). |
|
||||||
| `XDG_BIN_HOME` | `$HOME/.local/bin` | Where the `move` wrapper is placed. |
|
| `XDG_BIN_HOME` | `$HOME/.local/bin` | Where the `move` wrapper is placed. |
|
||||||
|
| `XDG_CONFIG_HOME` | `$HOME/.config` | Where the config file lives (under `move/`). |
|
||||||
|
|
||||||
Bun must already be installed; the installer fails with a clear pointer
|
Bun must already be installed; the installer fails with a clear pointer
|
||||||
to <https://bun.sh> if it isn't.
|
to <https://bun.sh> if it isn't.
|
||||||
@@ -90,9 +117,19 @@ Options:
|
|||||||
-m, --move-interval <seconds> Idle time before a sweep fires. Default: 240.
|
-m, --move-interval <seconds> Idle time before a sweep fires. Default: 240.
|
||||||
-c, --check-interval <seconds> Cursor poll cadence. Default: 10.
|
-c, --check-interval <seconds> Cursor poll cadence. Default: 10.
|
||||||
-d, --step-delay <ms> Pause between synthetic steps. Default: 50.
|
-d, --step-delay <ms> Pause between synthetic steps. Default: 50.
|
||||||
-n, --step-count <pixels> Steps per sweep. Default: 250.
|
-p, --pattern <name> Movement strategy. Default: line.
|
||||||
-V, --verbose Log every sweep, interrupt, and bounds event
|
One of: line, diagonal, jitter, walk, arc,
|
||||||
|
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).
|
(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.
|
Precedence (highest wins): CLI flags > config file > built-in defaults.
|
||||||
```
|
```
|
||||||
@@ -105,8 +142,8 @@ internally.
|
|||||||
|
|
||||||
Logging is **quiet by default**: only the startup banner ("Teams Status
|
Logging is **quiet by default**: only the startup banner ("Teams Status
|
||||||
Keeper started…") and any error from an unhandled rejection print on a
|
Keeper started…") and any error from an unhandled rejection print on a
|
||||||
default run. `-V` / `--verbose` opens up per-sweep, user-interrupt, and
|
default run. `-V` / `--verbose` opens up per-sweep and user-interrupt
|
||||||
out-of-bounds events.
|
events.
|
||||||
|
|
||||||
Invalid input (unknown flag, missing value, non-positive number) prints an
|
Invalid input (unknown flag, missing value, non-positive number) prints an
|
||||||
error to `stderr` and exits with code `2`.
|
error to `stderr` and exits with code `2`.
|
||||||
@@ -120,11 +157,14 @@ ${XDG_CONFIG_HOME:-$HOME/.config}/move/config.json
|
|||||||
```
|
```
|
||||||
|
|
||||||
The installer seeds this file with the default values on a fresh install,
|
The installer seeds this file with the default values on a fresh install,
|
||||||
**only if no file already exists at that path**. Existing configs — yours
|
**only if no file already exists at that path**. An existing config —
|
||||||
or from a previous install — are never overwritten. If you remove the
|
yours or from a previous install — is never overwritten silently: the
|
||||||
file later, `move` still works: missing defaults fall back to the values
|
installer asks first, and replaces it only if you say yes (or if you set
|
||||||
baked into the binary (which match what was seeded, since both come from
|
`MOVE_RESEED_CONFIG=1`), keeping the old file as `config.json.bak` either
|
||||||
`scripts/config.default.json`).
|
way. `MOVE_FORCE=1` reinstalls the software but leaves your config alone.
|
||||||
|
If you remove the file later, `move` still works: missing defaults fall
|
||||||
|
back to the values baked into the binary (which match what was seeded,
|
||||||
|
since both come from `scripts/config.default.json`).
|
||||||
|
|
||||||
Pass `-C` / `--config <path>` to point at a different file; in that mode
|
Pass `-C` / `--config <path>` to point at a different file; in that mode
|
||||||
the file must exist.
|
the file must exist.
|
||||||
@@ -146,15 +186,21 @@ doesn't set.
|
|||||||
"moveInterval": 240,
|
"moveInterval": 240,
|
||||||
"checkInterval": 10,
|
"checkInterval": 10,
|
||||||
"stepDelay": 50,
|
"stepDelay": 50,
|
||||||
"stepCount": 250,
|
"pattern": "line",
|
||||||
"verbose": false
|
"verbose": false,
|
||||||
|
"loop": false
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
All keys are optional; supply only the ones you want to override. Keys
|
All keys are optional; supply only the ones you want to override. Keys
|
||||||
and units mirror the CLI flags exactly: `moveInterval` and
|
and units mirror the CLI flags exactly: `moveInterval` and
|
||||||
`checkInterval` are seconds, `stepDelay` is milliseconds, `stepCount` is
|
`checkInterval` are seconds, `stepDelay` is milliseconds, `pattern` is a
|
||||||
pixels, `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
|
||||||
|
> notice rather than rejected, so a config seeded by an older install keeps
|
||||||
|
> working. Sweep size and step count are now properties of each pattern.
|
||||||
|
|
||||||
### Editing
|
### Editing
|
||||||
|
|
||||||
@@ -181,21 +227,49 @@ The loader is strict:
|
|||||||
- Root must be a JSON object.
|
- Root must be a JSON object.
|
||||||
- Unknown keys are rejected (catches typos like `"movInterval"`).
|
- Unknown keys are rejected (catches typos like `"movInterval"`).
|
||||||
- Numeric values must be finite and strictly positive.
|
- Numeric values must be finite and strictly positive.
|
||||||
|
- `pattern` must resolve to a registered strategy name, 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.
|
- `verbose` must be a boolean.
|
||||||
|
- `loop` must be a boolean.
|
||||||
|
|
||||||
Any validation failure prints a message naming the file and the offending
|
Any validation failure prints a message naming the file and the offending
|
||||||
key to `stderr` and exits `2`.
|
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
|
By default a triggered sweep runs once and stops. With `-l` / `--loop` (or
|
||||||
config file sets `"verbose": true`, the CLI cannot force quiet mode in
|
`"loop": true` in the config file) the movement instead repeats until you
|
||||||
that invocation. Workarounds: edit the file, or point at a different
|
move the mouse (or press `Ctrl+C`) — a "keep moving until I'm back" mode.
|
||||||
file with `--config`.
|
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
|
## How it works
|
||||||
|
|
||||||
The source lives under `src/`, split into an entry point plus four logic
|
For a step-by-step trace of a clean sweep, see the
|
||||||
|
[execution happy-path sequence diagram](docs/execution-happy-path.md).
|
||||||
|
|
||||||
|
The source lives under `src/`, split into an entry point plus logic
|
||||||
modules:
|
modules:
|
||||||
|
|
||||||
- `src/move.ts` is a thin entry point: parses args, dispatches `--help` /
|
- `src/move.ts` is a thin entry point: parses args, dispatches `--help` /
|
||||||
@@ -207,7 +281,23 @@ modules:
|
|||||||
- `src/config.ts` exports the `Config` type (which carries every tunable
|
- `src/config.ts` exports the `Config` type (which carries every tunable
|
||||||
including `verbose`), `DEFAULT_CONFIG`, `defaultConfigPath`, and the
|
including `verbose`), `DEFAULT_CONFIG`, `defaultConfigPath`, and the
|
||||||
layered `resolveConfig` overlay function.
|
layered `resolveConfig` overlay function.
|
||||||
- `src/keeper.ts` owns the synthetic-activity sweep and the idle-watch loop.
|
- `src/keeper.ts` owns the idle-watch loop and the per-sweep glue that
|
||||||
|
wires a strategy to the executor.
|
||||||
|
|
||||||
|
Movement itself is split across three seams so patterns are easy to add
|
||||||
|
and everything but the raw nut.js call is unit-testable:
|
||||||
|
|
||||||
|
- `src/device.ts` is the I/O boundary: a `Device` interface
|
||||||
|
(`getPosition`/`setPosition`/`width`/`height`/`sleep`) plus the nut.js
|
||||||
|
implementation. It's the *only* module that imports nut.js, and it's
|
||||||
|
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, name validation, and the `random` picker. Adding a pattern is
|
||||||
|
one pure function.
|
||||||
|
- `src/executor.ts` is the single `executePath` driver: it rounds targets,
|
||||||
|
reflects any off-screen coordinate back inside, paces steps, detects
|
||||||
|
real-user interruption, and restores the cursor on a clean sweep.
|
||||||
|
|
||||||
Defaults live in `src/config.ts` as `DEFAULT_CONFIG`:
|
Defaults live in `src/config.ts` as `DEFAULT_CONFIG`:
|
||||||
|
|
||||||
@@ -216,7 +306,7 @@ Defaults live in `src/config.ts` as `DEFAULT_CONFIG`:
|
|||||||
| `moveInterval` | `4 * 60_000` | `-m`, `--move-interval` | Idle time (ms) required before a synthetic sweep fires. |
|
| `moveInterval` | `4 * 60_000` | `-m`, `--move-interval` | Idle time (ms) required before a synthetic sweep fires. |
|
||||||
| `checkInterval` | `10_000` | `-c`, `--check-interval` | How often (ms) the main loop polls the cursor for real activity. |
|
| `checkInterval` | `10_000` | `-c`, `--check-interval` | How often (ms) the main loop polls the cursor for real activity. |
|
||||||
| `stepDelay` | `50` | `-d`, `--step-delay` | Pause (ms) between individual synthetic steps in a sweep. |
|
| `stepDelay` | `50` | `-d`, `--step-delay` | Pause (ms) between individual synthetic steps in a sweep. |
|
||||||
| `stepCount` | `250` | `-n`, `--step-count` | Pixel-steps per sweep. |
|
| `pattern` | `"line"` | `-p`, `--pattern`, `-r` | Movement strategy name, or `random` (see Movement strategies below). |
|
||||||
|
|
||||||
`-m` and `-c` are accepted in seconds at the CLI; `resolveConfig` converts
|
`-m` and `-c` are accepted in seconds at the CLI; `resolveConfig` converts
|
||||||
to milliseconds before handing the resolved `Config` to `runKeeper`.
|
to milliseconds before handing the resolved `Config` to `runKeeper`.
|
||||||
@@ -231,27 +321,101 @@ to milliseconds before handing the resolved `Config` to `runKeeper`.
|
|||||||
- Otherwise, if `now - lastActivity >= config.moveInterval`, call
|
- Otherwise, if `now - lastActivity >= config.moveInterval`, call
|
||||||
`simulateActivity` and reset the idleness clock.
|
`simulateActivity` and reset the idleness clock.
|
||||||
|
|
||||||
### Synthetic sweep (`simulateActivity`)
|
### Synthetic sweep (`simulateActivity` + `executePath`)
|
||||||
|
|
||||||
1. Read the starting position and current screen dimensions.
|
1. `simulateActivity` snapshots the starting position and current screen
|
||||||
2. Pick a horizontal direction (`dx = +1` if there's room to the right,
|
dimensions (re-read every sweep so monitor changes are handled), looks
|
||||||
else `-1`) so the sweep stays on-screen. Vertical is `dy = 0` for now.
|
up `config.pattern` in the strategy registry, and builds a `MoveContext`.
|
||||||
3. For each of `config.stepCount` steps:
|
When `config.pattern` is `random` — the one name the registry doesn't
|
||||||
- Compute and bounds-check the next target.
|
contain — the strategy comes from the picker instead, once per trigger.
|
||||||
|
2. It hands the strategy and context to `executePath`, which drives the
|
||||||
|
sweep. For each target the strategy yields:
|
||||||
|
- Round to whole pixels and reflect any off-screen coordinate back inside
|
||||||
|
the travel range, so the cursor bounces off the edges and keeps moving.
|
||||||
- Move the cursor there, sleep `config.stepDelay`.
|
- Move the cursor there, sleep `config.stepDelay`.
|
||||||
- Re-read the cursor. If it isn't where we put it, the user moved it —
|
- Re-read the cursor. If it isn't at the point we *just commanded*, the
|
||||||
log (when `--verbose`) and return early without snapping back.
|
user moved it — log (when `--verbose`) and return early without
|
||||||
4. On a clean full sweep, restore the cursor to its starting position so
|
snapping back.
|
||||||
|
3. On a clean full sweep, restore the cursor to its starting position so
|
||||||
the next idle-check sees "no movement" and doesn't misread the synthetic
|
the next idle-check sees "no movement" and doesn't misread the synthetic
|
||||||
activity as real user input.
|
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 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` (or
|
||||||
|
`random`, which picks one for you):
|
||||||
|
|
||||||
|
| 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 on-screen reflection,
|
||||||
|
pacing, interrupt, and restore for free.
|
||||||
|
|
||||||
### Why `mouse.config.autoDelayMs = 0`
|
### Why `mouse.config.autoDelayMs = 0`
|
||||||
|
|
||||||
nut.js inserts a 100ms delay after every action by default. With two mouse
|
nut.js inserts a 100ms delay after every action by default. With two mouse
|
||||||
calls per step that would silently more-than-double the duration of a
|
calls per step that would silently more-than-double the duration of a
|
||||||
sweep. The script controls cadence itself via `config.stepDelay`, so the
|
sweep. The code controls cadence itself via `config.stepDelay`, so the
|
||||||
implicit delay is disabled at module load (a side effect of importing
|
implicit delay is disabled in `createNutDevice` — the single place nut.js
|
||||||
`keeper.ts`).
|
is wired up. Importing the movement modules stays side-effect-free.
|
||||||
|
|
||||||
## For contributors
|
## For contributors
|
||||||
|
|
||||||
@@ -296,7 +460,11 @@ move --help
|
|||||||
| `src/configFile.ts` | Optional JSON config-file loader with strict schema validation. |
|
| `src/configFile.ts` | Optional JSON config-file loader with strict schema validation. |
|
||||||
| `src/editor.ts` | `move --edit`: opens the active config file in `$EDITOR`. |
|
| `src/editor.ts` | `move --edit`: opens the active config file in `$EDITOR`. |
|
||||||
| `src/errors.ts` | Shared error types (`CliError`). |
|
| `src/errors.ts` | Shared error types (`CliError`). |
|
||||||
| `src/keeper.ts` | Synthetic-activity sweep and idle-watch loop. |
|
| `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, 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`. |
|
| `package.json` | Bun project manifest. Single runtime dep: `@nut-tree-fork/nut-js`. |
|
||||||
| `tsconfig.json` | Strict TypeScript config tuned for Bun (ESNext, bundler resolution). |
|
| `tsconfig.json` | Strict TypeScript config tuned for Bun (ESNext, bundler resolution). |
|
||||||
| `bun.lock` | Bun's lockfile. Commit this. |
|
| `bun.lock` | Bun's lockfile. Commit this. |
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# Execution: the happy path
|
||||||
|
|
||||||
|
This traces one full idle-triggered sweep that completes cleanly — the
|
||||||
|
"happy path" where the machine is idle long enough to fire, the configured
|
||||||
|
pattern runs to exhaustion, and no real user activity interrupts it.
|
||||||
|
|
||||||
|
For the module breakdown and the three seams (`device` / `strategies` /
|
||||||
|
`executor`), see the "How it works" section of the [README](../README.md).
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
autonumber
|
||||||
|
participant Entry as move.ts
|
||||||
|
participant Keeper as runKeeper
|
||||||
|
participant Sim as simulateActivity
|
||||||
|
participant Strat as Strategy<br/>(e.g. line)
|
||||||
|
participant Exec as executePath
|
||||||
|
participant Dev as Device<br/>(nut.js)
|
||||||
|
|
||||||
|
Note over Entry: startup (args → config)
|
||||||
|
Entry->>Entry: parseCliArgs()
|
||||||
|
Entry->>Entry: loadConfigFile()
|
||||||
|
Entry->>Entry: resolveConfig(file, cli)
|
||||||
|
Entry->>Keeper: runKeeper(config)
|
||||||
|
|
||||||
|
Keeper->>Dev: createNutDevice()
|
||||||
|
Note right of Dev: sets mouse.config.autoDelayMs = 0
|
||||||
|
Keeper->>Keeper: log.info(banner)
|
||||||
|
Keeper->>Dev: getPosition()
|
||||||
|
Dev-->>Keeper: lastPos
|
||||||
|
Note over Keeper: lastActivity = now
|
||||||
|
|
||||||
|
loop every checkInterval (until idle long enough)
|
||||||
|
Keeper->>Dev: sleep(checkInterval)
|
||||||
|
Keeper->>Dev: getPosition()
|
||||||
|
Dev-->>Keeper: pos
|
||||||
|
Note over Keeper: pos == lastPos (no user movement)<br/>now - lastActivity ≥ moveInterval → fire
|
||||||
|
end
|
||||||
|
|
||||||
|
Keeper->>Sim: simulateActivity(config, log, dev, pickRandom)
|
||||||
|
Sim->>Dev: width()
|
||||||
|
Dev-->>Sim: width
|
||||||
|
Sim->>Dev: height()
|
||||||
|
Dev-->>Sim: height
|
||||||
|
Sim->>Dev: getPosition()
|
||||||
|
Dev-->>Sim: start
|
||||||
|
Note over Sim: strategy = STRATEGIES[config.pattern]<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(target) → point (reflected on-screen)
|
||||||
|
Exec->>Dev: setPosition(point)
|
||||||
|
Exec->>Dev: sleep(stepDelay)
|
||||||
|
Exec->>Dev: getPosition()
|
||||||
|
Dev-->>Exec: current
|
||||||
|
Note over Exec: |current - point| ≤ 2px → not the user, continue
|
||||||
|
end
|
||||||
|
|
||||||
|
Note over Exec: path exhausted, no interruption
|
||||||
|
Exec->>Dev: setPosition(round(start))
|
||||||
|
Note right of Exec: restore cursor to origin
|
||||||
|
Exec-->>Sim: "completed"
|
||||||
|
Sim-->>Keeper: (done)
|
||||||
|
|
||||||
|
Keeper->>Dev: getPosition()
|
||||||
|
Dev-->>Keeper: lastPos (equals start, re-synced)
|
||||||
|
Note over Keeper: lastActivity = now<br/>loop continues
|
||||||
|
```
|
||||||
|
|
||||||
|
## Invariants this path relies on
|
||||||
|
|
||||||
|
- **`createNutDevice()` is the only nut.js touchpoint.** It disables nut.js's
|
||||||
|
100ms auto-delay so `executePath` owns cadence via `stepDelay`.
|
||||||
|
- **The strategy is pure.** `path(ctx)` yields ideal points from geometry
|
||||||
|
alone (`start` / `width` / `height` / `rng`); it never touches the device,
|
||||||
|
which is what makes every pattern unit-testable without a screen.
|
||||||
|
- **Every step re-reads the cursor** and compares it against the *commanded*
|
||||||
|
point (not the strategy's ideal, possibly fractional target) within a 2px
|
||||||
|
tolerance. On the happy path each check passes, so the loop runs to
|
||||||
|
exhaustion. A mismatch beyond tolerance is real user activity and returns
|
||||||
|
`"interrupted"` without restoring — the branch this diagram omits.
|
||||||
|
- **Clean completion restores the cursor to `round(start)`.** That is why the
|
||||||
|
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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "move",
|
"name": "move",
|
||||||
"version": "1.2.0",
|
"version": "1.4.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"moveInterval": 240,
|
"moveInterval": 240,
|
||||||
"checkInterval": 10,
|
"checkInterval": 10,
|
||||||
"stepDelay": 50,
|
"stepDelay": 50,
|
||||||
"stepCount": 250,
|
"pattern": "line",
|
||||||
"verbose": false
|
"verbose": false,
|
||||||
|
"loop": false
|
||||||
}
|
}
|
||||||
|
|||||||
+232
-36
@@ -4,31 +4,49 @@
|
|||||||
#
|
#
|
||||||
# Curl-pipe ready:
|
# Curl-pipe ready:
|
||||||
#
|
#
|
||||||
# curl -fsSL https://gitea.cahlen.com/nokeo08/Move/raw/branch/master/install.sh | sh
|
# curl -fsSL https://gitea.cahlen.com/nokeo08/Move/raw/branch/master/scripts/install.sh | sh
|
||||||
#
|
#
|
||||||
# What it does:
|
# What it does:
|
||||||
# 1. Detect platform; bail on anything @nut-tree-fork/nut-js doesn't ship.
|
# 1. Detect platform; bail on anything @nut-tree-fork/nut-js doesn't ship.
|
||||||
# 2. Require Bun; fail with a clear hint if missing (no auto-install).
|
# 2. Require Bun; fail with a clear hint if missing (no auto-install).
|
||||||
# 3. Resolve XDG-compliant install paths.
|
# 3. Resolve XDG-compliant install paths.
|
||||||
# 4. Idempotence check via a version marker file.
|
# 4. Detect an existing install and, on a terminal, ask before replacing it.
|
||||||
# 5. Download the source tarball from Gitea, extract under the install dir.
|
# 5. Download and build in a temp staging dir; swap it over the install
|
||||||
|
# dir only once it's complete, so a failed run can't destroy a working
|
||||||
|
# install.
|
||||||
# 6. `bun install --production` (skips devDependencies).
|
# 6. `bun install --production` (skips devDependencies).
|
||||||
# 7. Drop a small wrapper script as `move` on the user's bin dir.
|
# 7. Drop a small wrapper script as `move` on the user's bin dir.
|
||||||
# 8. Seed the user's config file with defaults, only if one doesn't already
|
# 8. Seed the user's config file with defaults if one doesn't already exist
|
||||||
# exist at $XDG_CONFIG_HOME/move/config.json.
|
# at $XDG_CONFIG_HOME/move/config.json; if one does, offer to reseed it.
|
||||||
# 9. Verify PATH, surface macOS Accessibility hint, print final status.
|
# 9. Verify PATH, surface macOS Accessibility hint, print final status.
|
||||||
#
|
#
|
||||||
|
# Interactivity:
|
||||||
|
# When a controlling terminal is available, an existing install is never
|
||||||
|
# replaced without asking, and an existing config is never overwritten
|
||||||
|
# without asking. Both questions are put up front, before anything is
|
||||||
|
# downloaded or deleted, so declining costs nothing. With no terminal
|
||||||
|
# (CI, cron, container build) the script falls back to its historical
|
||||||
|
# non-interactive contract: an identical version is a no-op, a different
|
||||||
|
# version is replaced, and the config is left alone.
|
||||||
|
#
|
||||||
# Env vars (all optional):
|
# Env vars (all optional):
|
||||||
# MOVE_VERSION Branch or tag to install. Default: master.
|
# MOVE_VERSION Branch or tag to install. Default: the newest tag
|
||||||
# MOVE_FORCE Set to 1 to reinstall even if the version marker matches.
|
# published to the repo, falling back to the master
|
||||||
|
# branch if that can't be determined.
|
||||||
|
# MOVE_FORCE Set to 1 to skip every prompt and reinstall
|
||||||
|
# unconditionally. Does not touch the user config.
|
||||||
|
# MOVE_RESEED_CONFIG Set to 1 to overwrite the user config with the
|
||||||
|
# shipped defaults without asking. The previous file
|
||||||
|
# is saved alongside it as config.json.bak.
|
||||||
# XDG_DATA_HOME Source install root (default $HOME/.local/share).
|
# XDG_DATA_HOME Source install root (default $HOME/.local/share).
|
||||||
# Final source location is $XDG_DATA_HOME/move.
|
# Final source location is $XDG_DATA_HOME/move.
|
||||||
# XDG_BIN_HOME Wrapper install root (default $HOME/.local/bin).
|
# XDG_BIN_HOME Wrapper install root (default $HOME/.local/bin).
|
||||||
# Final binary location is $XDG_BIN_HOME/move.
|
# Final binary location is $XDG_BIN_HOME/move.
|
||||||
# XDG_CONFIG_HOME User config root (default $HOME/.config).
|
# XDG_CONFIG_HOME User config root (default $HOME/.config).
|
||||||
# Default config file path is $XDG_CONFIG_HOME/move/config.json.
|
# Default config file is $XDG_CONFIG_HOME/move/config.json.
|
||||||
#
|
#
|
||||||
# POSIX sh; no bashisms.
|
# POSIX sh; no bashisms. Note the absence of `local`: helper functions use
|
||||||
|
# `_`-prefixed globals, which POSIX sh leaves us with.
|
||||||
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
@@ -36,13 +54,17 @@ REPO_OWNER="nokeo08"
|
|||||||
REPO_NAME="Move"
|
REPO_NAME="Move"
|
||||||
GITEA_HOST="gitea.cahlen.com"
|
GITEA_HOST="gitea.cahlen.com"
|
||||||
|
|
||||||
MOVE_VERSION="${MOVE_VERSION:-master}"
|
# Left empty when unset so it can be resolved to the latest tag once the
|
||||||
|
# prerequisite tools are confirmed present (see "Resolve version" below).
|
||||||
|
MOVE_VERSION="${MOVE_VERSION:-}"
|
||||||
MOVE_FORCE="${MOVE_FORCE:-0}"
|
MOVE_FORCE="${MOVE_FORCE:-0}"
|
||||||
|
MOVE_RESEED_CONFIG="${MOVE_RESEED_CONFIG:-0}"
|
||||||
|
|
||||||
INSTALL_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/move"
|
INSTALL_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/move"
|
||||||
BIN_DIR="${XDG_BIN_HOME:-$HOME/.local/bin}"
|
BIN_DIR="${XDG_BIN_HOME:-$HOME/.local/bin}"
|
||||||
CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/move"
|
CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/move"
|
||||||
CONFIG_FILE="$CONFIG_DIR/config.json"
|
CONFIG_FILE="$CONFIG_DIR/config.json"
|
||||||
|
WRAPPER="$BIN_DIR/move"
|
||||||
|
|
||||||
die() {
|
die() {
|
||||||
printf 'Error: %s\n' "$1" >&2
|
printf 'Error: %s\n' "$1" >&2
|
||||||
@@ -62,6 +84,70 @@ assert_safe_dir() {
|
|||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Print the newest tag name published to the repo (e.g. v1.3.2), or return
|
||||||
|
# non-zero if it can't be determined. The Gitea tags API lists newest first,
|
||||||
|
# so with ?limit=1 the sole entry is the latest tag; grep+sed pull its "name"
|
||||||
|
# without a jq dependency. Any failure -- offline, API error, no tags, a
|
||||||
|
# missing grep/sed -- collapses to a non-zero return and lets the caller fall
|
||||||
|
# back to the master branch.
|
||||||
|
resolve_latest_tag() {
|
||||||
|
_tags_url="https://$GITEA_HOST/api/v1/repos/$REPO_OWNER/$REPO_NAME/tags?limit=1"
|
||||||
|
_json=$(curl -fsSL --max-time 10 "$_tags_url" 2>/dev/null) || return 1
|
||||||
|
_tag=$(printf '%s' "$_json" | grep -o '"name":"[^"]*"' | head -n 1 | sed 's/.*:"//; s/"$//')
|
||||||
|
[ -n "$_tag" ] || return 1
|
||||||
|
printf '%s\n' "$_tag"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Interactive prompt support ----------------------------------------------
|
||||||
|
#
|
||||||
|
# The documented entry point is `curl -fsSL ... | sh`, which means stdin is
|
||||||
|
# the *script source itself*. Reading a prompt answer from stdin would
|
||||||
|
# consume the rest of the program and truncate execution mid-run, so every
|
||||||
|
# prompt reads from /dev/tty directly.
|
||||||
|
#
|
||||||
|
# Detecting whether that's possible needs a real open(2) attempt. A `[ -r
|
||||||
|
# /dev/tty ]` test is not enough: the device node exists and is mode 0666
|
||||||
|
# even in contexts with no controlling terminal (cron, CI, container
|
||||||
|
# builds), where opening it fails with ENXIO. The probe runs in a subshell
|
||||||
|
# because a redirection failure on `exec` -- a special built-in -- exits a
|
||||||
|
# non-interactive shell outright under POSIX.
|
||||||
|
|
||||||
|
if (: >/dev/tty) 2>/dev/null; then
|
||||||
|
INTERACTIVE=1
|
||||||
|
else
|
||||||
|
INTERACTIVE=0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# confirm PROMPT DEFAULT -> 0 for yes, 1 for no.
|
||||||
|
#
|
||||||
|
# DEFAULT is 'y' or 'n' and is taken on a bare Enter or on EOF (^D), so the
|
||||||
|
# loop can't spin forever against a closed terminal. Prompts are written to
|
||||||
|
# /dev/tty rather than stdout so they stay visible when the caller redirects
|
||||||
|
# our output.
|
||||||
|
confirm() {
|
||||||
|
_prompt="$1"
|
||||||
|
_default="$2"
|
||||||
|
case "$_default" in
|
||||||
|
y) _hint='[Y/n]' ;;
|
||||||
|
*) _hint='[y/N]' ;;
|
||||||
|
esac
|
||||||
|
while :; do
|
||||||
|
printf '%s %s ' "$_prompt" "$_hint" > /dev/tty
|
||||||
|
if ! IFS= read -r _reply < /dev/tty; then
|
||||||
|
printf '\n' > /dev/tty
|
||||||
|
_reply=''
|
||||||
|
fi
|
||||||
|
if [ -z "$_reply" ]; then
|
||||||
|
_reply="$_default"
|
||||||
|
fi
|
||||||
|
case "$_reply" in
|
||||||
|
[yY] | [yY][eE][sS]) return 0 ;;
|
||||||
|
[nN] | [nN][oO]) return 1 ;;
|
||||||
|
*) printf "Please answer 'y' or 'n'.\n" > /dev/tty ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
# --- Prerequisite tools ------------------------------------------------------
|
# --- Prerequisite tools ------------------------------------------------------
|
||||||
|
|
||||||
for tool in curl tar mktemp; do
|
for tool in curl tar mktemp; do
|
||||||
@@ -100,53 +186,160 @@ fi
|
|||||||
BUN_VERSION=$(bun --version)
|
BUN_VERSION=$(bun --version)
|
||||||
printf '==> Using bun %s\n' "$BUN_VERSION"
|
printf '==> Using bun %s\n' "$BUN_VERSION"
|
||||||
|
|
||||||
# --- Idempotence check -------------------------------------------------------
|
# --- Resolve version to install ----------------------------------------------
|
||||||
|
#
|
||||||
|
# With no explicit MOVE_VERSION, default to the newest published tag so the
|
||||||
|
# plain one-liner installs a real release and reports its version (e.g.
|
||||||
|
# v1.3.2) rather than tracking the moving `master` branch. If the lookup
|
||||||
|
# fails -- offline, API unreachable, or no tags yet -- fall back to master,
|
||||||
|
# preserving the historical behavior instead of aborting.
|
||||||
|
|
||||||
|
if [ -z "$MOVE_VERSION" ]; then
|
||||||
|
if MOVE_VERSION=$(resolve_latest_tag); then
|
||||||
|
printf '==> Latest release is %s\n' "$MOVE_VERSION"
|
||||||
|
else
|
||||||
|
MOVE_VERSION=master
|
||||||
|
printf '==> Could not determine latest release; installing from master\n' >&2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Existing install check --------------------------------------------------
|
||||||
|
#
|
||||||
|
# Both questions this script can ask are asked here, before anything is
|
||||||
|
# downloaded, deleted, or written. Declining therefore costs the user
|
||||||
|
# nothing, and no prompt appears minutes into a `bun install`.
|
||||||
|
|
||||||
assert_safe_dir "$INSTALL_DIR"
|
assert_safe_dir "$INSTALL_DIR"
|
||||||
|
assert_safe_dir "$CONFIG_DIR"
|
||||||
|
|
||||||
if [ "$MOVE_FORCE" != "1" ] && [ -f "$INSTALL_DIR/.installed-version" ]; then
|
INSTALLED_VERSION=''
|
||||||
CURRENT=$(cat "$INSTALL_DIR/.installed-version" 2>/dev/null || printf '')
|
if [ -f "$INSTALL_DIR/.installed-version" ]; then
|
||||||
if [ "$CURRENT" = "$MOVE_VERSION" ]; then
|
INSTALLED_VERSION=$(cat "$INSTALL_DIR/.installed-version" 2>/dev/null || printf '')
|
||||||
printf 'move %s is already installed at %s/move.\n' "$MOVE_VERSION" "$BIN_DIR"
|
fi
|
||||||
|
|
||||||
|
# Look wider than the version marker: a half-finished or hand-edited install
|
||||||
|
# can leave a tree or a wrapper behind without one, and steamrolling that
|
||||||
|
# silently is precisely what this check exists to prevent.
|
||||||
|
FOUND_EXISTING=0
|
||||||
|
if [ -d "$INSTALL_DIR" ] || [ -e "$WRAPPER" ] || [ -L "$WRAPPER" ]; then
|
||||||
|
FOUND_EXISTING=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Decided here, applied at the end -- the seed file it copies from only
|
||||||
|
# exists once the tarball has been extracted.
|
||||||
|
RESEED_CONFIG="$MOVE_RESEED_CONFIG"
|
||||||
|
|
||||||
|
if [ "$FOUND_EXISTING" = "1" ] && [ "$MOVE_FORCE" != "1" ]; then
|
||||||
|
if [ -n "$INSTALLED_VERSION" ]; then
|
||||||
|
printf '==> Found an existing move install (%s) at %s\n' \
|
||||||
|
"$INSTALLED_VERSION" "$INSTALL_DIR"
|
||||||
|
else
|
||||||
|
printf '==> Found an existing move install at %s (version unknown)\n' \
|
||||||
|
"$INSTALL_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$INTERACTIVE" = "1" ]; then
|
||||||
|
# The defaults below are chosen so that a bare Enter reproduces
|
||||||
|
# exactly what this script did before it learned to ask: skip when
|
||||||
|
# the version is identical, replace when it differs.
|
||||||
|
if [ "$INSTALLED_VERSION" = "$MOVE_VERSION" ]; then
|
||||||
|
REPLACE_PROMPT="Reinstall move $MOVE_VERSION over it?"
|
||||||
|
REPLACE_DEFAULT=n
|
||||||
|
else
|
||||||
|
REPLACE_PROMPT="Replace it with $MOVE_VERSION?"
|
||||||
|
REPLACE_DEFAULT=y
|
||||||
|
fi
|
||||||
|
if ! confirm "$REPLACE_PROMPT" "$REPLACE_DEFAULT"; then
|
||||||
|
printf 'Leaving the existing install alone. Nothing was changed.\n'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Nowhere to ask, so fall back to the historical contract.
|
||||||
|
if [ "$INSTALLED_VERSION" = "$MOVE_VERSION" ]; then
|
||||||
|
printf 'move %s is already installed at %s.\n' "$MOVE_VERSION" "$WRAPPER"
|
||||||
printf 'Set MOVE_FORCE=1 to reinstall, or set MOVE_VERSION to a different ref.\n'
|
printf 'Set MOVE_FORCE=1 to reinstall, or set MOVE_VERSION to a different ref.\n'
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
printf '==> No terminal available; replacing %s with %s\n' \
|
||||||
|
"${INSTALLED_VERSION:-unknown}" "$MOVE_VERSION"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- Clean install dir -------------------------------------------------------
|
if [ -e "$CONFIG_FILE" ] && [ "$RESEED_CONFIG" != "1" ] &&
|
||||||
|
[ "$INTERACTIVE" = "1" ] && [ "$MOVE_FORCE" != "1" ]; then
|
||||||
|
printf '==> A config file already exists at %s\n' "$CONFIG_FILE"
|
||||||
|
if confirm 'Overwrite it with the shipped defaults?' n; then
|
||||||
|
RESEED_CONFIG=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Stage, download, build --------------------------------------------------
|
||||||
|
#
|
||||||
|
# Everything is assembled in a temp staging dir first; the existing install
|
||||||
|
# is removed only once the staged tree is fully built and ready to swap in.
|
||||||
|
# A failed download, extract, or `bun install` therefore leaves a working
|
||||||
|
# install untouched -- unlike the old flow, which wiped INSTALL_DIR before
|
||||||
|
# the download even started and left nothing behind on any failure.
|
||||||
|
|
||||||
mkdir -p "$BIN_DIR"
|
mkdir -p "$BIN_DIR"
|
||||||
rm -rf "$INSTALL_DIR"
|
|
||||||
mkdir -p "$INSTALL_DIR"
|
|
||||||
|
|
||||||
# --- Download source ---------------------------------------------------------
|
DATA_ROOT=$(dirname "$INSTALL_DIR")
|
||||||
|
mkdir -p "$DATA_ROOT"
|
||||||
|
|
||||||
TARBALL_URL="https://$GITEA_HOST/$REPO_OWNER/$REPO_NAME/archive/$MOVE_VERSION.tar.gz"
|
|
||||||
TARBALL_TMP=$(mktemp) || die "could not create temp file"
|
TARBALL_TMP=$(mktemp) || die "could not create temp file"
|
||||||
|
# Stage on the same filesystem as INSTALL_DIR so the final swap is a rename,
|
||||||
|
# not a cross-device copy.
|
||||||
|
STAGE_DIR=$(mktemp -d "$DATA_ROOT/.move-stage.XXXXXX") ||
|
||||||
|
{ rm -f "$TARBALL_TMP"; die "could not create staging dir under $DATA_ROOT"; }
|
||||||
|
|
||||||
|
# On any exit, clean up the tarball and any leftover staging dir. After a
|
||||||
|
# successful swap STAGE_DIR has been renamed away, so the rm -rf is a no-op.
|
||||||
cleanup() {
|
cleanup() {
|
||||||
rm -f "$TARBALL_TMP"
|
rm -f "$TARBALL_TMP"
|
||||||
|
rm -rf "$STAGE_DIR"
|
||||||
}
|
}
|
||||||
trap cleanup EXIT INT TERM
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
TARBALL_URL="https://$GITEA_HOST/$REPO_OWNER/$REPO_NAME/archive/$MOVE_VERSION.tar.gz"
|
||||||
|
|
||||||
printf '==> Downloading %s\n' "$TARBALL_URL"
|
printf '==> Downloading %s\n' "$TARBALL_URL"
|
||||||
if ! curl -fsSL "$TARBALL_URL" -o "$TARBALL_TMP"; then
|
if ! curl -fsSL "$TARBALL_URL" -o "$TARBALL_TMP"; then
|
||||||
die "could not download $TARBALL_URL (check MOVE_VERSION='$MOVE_VERSION' and network)"
|
die "could not download $TARBALL_URL (check MOVE_VERSION='$MOVE_VERSION' and network)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
printf '==> Extracting source to %s\n' "$INSTALL_DIR"
|
printf '==> Extracting source\n'
|
||||||
if ! tar -xzf "$TARBALL_TMP" -C "$INSTALL_DIR" --strip-components=1; then
|
if ! tar -xzf "$TARBALL_TMP" -C "$STAGE_DIR" --strip-components=1; then
|
||||||
die "could not extract tarball from $TARBALL_URL"
|
die "could not extract tarball from $TARBALL_URL"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- Install runtime deps ----------------------------------------------------
|
|
||||||
|
|
||||||
printf '==> Installing runtime dependencies (bun install --production)\n'
|
printf '==> Installing runtime dependencies (bun install --production)\n'
|
||||||
(cd "$INSTALL_DIR" && bun install --production)
|
(cd "$STAGE_DIR" && bun install --production)
|
||||||
|
|
||||||
|
# Sanity-check the staged tree before we disturb the existing install: a
|
||||||
|
# truncated or wrong tarball that's missing the config seed should fail here,
|
||||||
|
# while the old install is still intact and swappable-out.
|
||||||
|
if [ ! -f "$STAGE_DIR/scripts/config.default.json" ]; then
|
||||||
|
die "downloaded tree is missing scripts/config.default.json (bad MOVE_VERSION='$MOVE_VERSION'?)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Record the version inside the staged tree so the install is self-consistent
|
||||||
|
# the instant it lands.
|
||||||
|
printf '%s\n' "$MOVE_VERSION" > "$STAGE_DIR/.installed-version"
|
||||||
|
|
||||||
|
# --- Swap staged tree into place ---------------------------------------------
|
||||||
|
#
|
||||||
|
# The only destructive step, kept as late as possible: the window where
|
||||||
|
# INSTALL_DIR is absent is just this rm + rename, not the whole build.
|
||||||
|
|
||||||
|
assert_safe_dir "$INSTALL_DIR"
|
||||||
|
printf '==> Installing to %s\n' "$INSTALL_DIR"
|
||||||
|
rm -rf "$INSTALL_DIR"
|
||||||
|
if ! mv "$STAGE_DIR" "$INSTALL_DIR"; then
|
||||||
|
die "could not move staged install into place at $INSTALL_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
# --- Drop the wrapper --------------------------------------------------------
|
# --- Drop the wrapper --------------------------------------------------------
|
||||||
|
|
||||||
WRAPPER="$BIN_DIR/move"
|
|
||||||
printf '==> Writing wrapper to %s\n' "$WRAPPER"
|
printf '==> Writing wrapper to %s\n' "$WRAPPER"
|
||||||
cat > "$WRAPPER" <<EOF
|
cat > "$WRAPPER" <<EOF
|
||||||
#!/usr/bin/env sh
|
#!/usr/bin/env sh
|
||||||
@@ -154,23 +347,21 @@ exec bun "$INSTALL_DIR/src/move.ts" "\$@"
|
|||||||
EOF
|
EOF
|
||||||
chmod +x "$WRAPPER"
|
chmod +x "$WRAPPER"
|
||||||
|
|
||||||
# --- Write version marker ----------------------------------------------------
|
# --- Seed user config file ---------------------------------------------------
|
||||||
|
|
||||||
printf '%s\n' "$MOVE_VERSION" > "$INSTALL_DIR/.installed-version"
|
|
||||||
|
|
||||||
# --- Seed user config file (only if absent) ----------------------------------
|
|
||||||
#
|
#
|
||||||
# The defaults file shipped with the source tree (scripts/config.default.json)
|
# The defaults file shipped with the source tree (scripts/config.default.json)
|
||||||
# is also the single source of truth for the runtime defaults loaded by
|
# is also the single source of truth for the runtime defaults loaded by
|
||||||
# src/config.ts, so seeding a fresh user file from the same place keeps the
|
# src/config.ts, so seeding a fresh user file from the same place keeps the
|
||||||
# CLI behavior and the user-visible config in sync.
|
# CLI behavior and the user-visible config in sync.
|
||||||
#
|
#
|
||||||
# Strict policy: never overwrite an existing user config. The uninstaller
|
# Policy: an existing user config is never overwritten *silently*. It is
|
||||||
# follows the matching policy of never removing it; together that
|
# replaced only on an explicit answer to the prompt above or an explicit
|
||||||
# preserves user customizations unconditionally across (re)installs and
|
# MOVE_RESEED_CONFIG=1, and even then the previous file is kept as a .bak
|
||||||
# uninstalls.
|
# rather than destroyed. Everything else -- MOVE_FORCE=1, a non-interactive
|
||||||
|
# run -- leaves it untouched, so automation that reinstalls the software
|
||||||
|
# can't take a user's customizations down with it. The uninstaller follows
|
||||||
|
# the matching policy of never removing the config at all.
|
||||||
|
|
||||||
assert_safe_dir "$CONFIG_DIR"
|
|
||||||
SEED_SRC="$INSTALL_DIR/scripts/config.default.json"
|
SEED_SRC="$INSTALL_DIR/scripts/config.default.json"
|
||||||
|
|
||||||
if [ ! -f "$SEED_SRC" ]; then
|
if [ ! -f "$SEED_SRC" ]; then
|
||||||
@@ -181,6 +372,11 @@ mkdir -p "$CONFIG_DIR"
|
|||||||
if [ ! -e "$CONFIG_FILE" ]; then
|
if [ ! -e "$CONFIG_FILE" ]; then
|
||||||
cp "$SEED_SRC" "$CONFIG_FILE"
|
cp "$SEED_SRC" "$CONFIG_FILE"
|
||||||
printf '==> Wrote default config to %s\n' "$CONFIG_FILE"
|
printf '==> Wrote default config to %s\n' "$CONFIG_FILE"
|
||||||
|
elif [ "$RESEED_CONFIG" = "1" ]; then
|
||||||
|
cp "$CONFIG_FILE" "$CONFIG_FILE.bak"
|
||||||
|
cp "$SEED_SRC" "$CONFIG_FILE"
|
||||||
|
printf '==> Reseeded %s (previous file saved as %s)\n' \
|
||||||
|
"$CONFIG_FILE" "$CONFIG_FILE.bak"
|
||||||
else
|
else
|
||||||
printf '==> Config already exists at %s; leaving it alone\n' "$CONFIG_FILE"
|
printf '==> Config already exists at %s; leaving it alone\n' "$CONFIG_FILE"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
#
|
#
|
||||||
# Curl-pipe ready:
|
# Curl-pipe ready:
|
||||||
#
|
#
|
||||||
# curl -fsSL https://gitea.cahlen.com/nokeo08/Move/raw/branch/master/uninstall.sh | sh
|
# curl -fsSL https://gitea.cahlen.com/nokeo08/Move/raw/branch/master/scripts/uninstall.sh | sh
|
||||||
#
|
#
|
||||||
# Removes the `move` wrapper from $XDG_BIN_HOME and the install tree from
|
# Removes the `move` wrapper from $XDG_BIN_HOME and the install tree from
|
||||||
# $XDG_DATA_HOME/move. Does NOT remove Bun — that's your runtime, not ours.
|
# $XDG_DATA_HOME/move. Does NOT remove Bun — that's your runtime, not ours.
|
||||||
|
|||||||
+88
-12
@@ -16,9 +16,15 @@
|
|||||||
* -m, --move-interval Idle time (seconds) before a sweep fires.
|
* -m, --move-interval Idle time (seconds) before a sweep fires.
|
||||||
* -c, --check-interval Cursor poll cadence (seconds).
|
* -c, --check-interval Cursor poll cadence (seconds).
|
||||||
* -d, --step-delay Pause between synthetic steps (ms).
|
* -d, --step-delay Pause between synthetic steps (ms).
|
||||||
* -n, --step-count Steps per sweep (pixels).
|
* -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`.)
|
* (`-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
|
* Numeric overrides are layered (CLI > file > DEFAULT_CONFIG) by
|
||||||
* `resolveConfig` in `config.ts`; this module only parses and validates.
|
* `resolveConfig` in `config.ts`; this module only parses and validates.
|
||||||
@@ -31,6 +37,7 @@ import { parseArgs } from "node:util";
|
|||||||
|
|
||||||
import { DEFAULT_CONFIG, defaultConfigPath } from "./config.ts";
|
import { DEFAULT_CONFIG, defaultConfigPath } from "./config.ts";
|
||||||
import { CliError } from "./errors.ts";
|
import { CliError } from "./errors.ts";
|
||||||
|
import { RANDOM_PATTERN, SELECTABLE_PATTERN_NAMES, resolvePatternName } from "./strategies.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Result of `parseCliArgs`. Numeric fields are `undefined` when the user
|
* Result of `parseCliArgs`. Numeric fields are `undefined` when the user
|
||||||
@@ -45,7 +52,14 @@ export interface ParsedCliArgs {
|
|||||||
moveInterval: number | undefined; // seconds
|
moveInterval: number | undefined; // seconds
|
||||||
checkInterval: number | undefined; // seconds
|
checkInterval: number | undefined; // seconds
|
||||||
stepDelay: number | undefined; // milliseconds
|
stepDelay: number | undefined; // milliseconds
|
||||||
stepCount: number | undefined; // pixels
|
/**
|
||||||
|
* 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.
|
* `true` when `-V`/`--verbose` was passed; `undefined` when it was not.
|
||||||
* `undefined` (not `false`) lets the layered resolver distinguish "user
|
* `undefined` (not `false`) lets the layered resolver distinguish "user
|
||||||
@@ -53,6 +67,11 @@ export interface ParsedCliArgs {
|
|||||||
* even though the CLI has no off-switch today.
|
* even though the CLI has no off-switch today.
|
||||||
*/
|
*/
|
||||||
verbose: boolean | undefined;
|
verbose: boolean | undefined;
|
||||||
|
/**
|
||||||
|
* `true` when `-l`/`--loop` was passed; `undefined` when it was not.
|
||||||
|
* Same `undefined`-not-`false` rationale as `verbose`.
|
||||||
|
*/
|
||||||
|
loop: boolean | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -70,15 +89,56 @@ function parsePositiveNumber(name: string, raw: string | undefined): number | un
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse `process.argv` into a typed `ParsedCliArgs`. Uses Node's built-in
|
* Validate a CLI-supplied movement-pattern name. Returns `undefined` when
|
||||||
* `parseArgs` in strict mode so unknown flags and missing values surface
|
* the flag was not supplied; throws `CliError` naming the valid patterns
|
||||||
* as `CliError`s that the entry point can turn into exit code 2.
|
* when the value isn't a registered strategy.
|
||||||
*/
|
*/
|
||||||
export function parseCliArgs(): ParsedCliArgs {
|
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: ${SELECTABLE_PATTERN_NAMES.join(", ")})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return canonical;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 selectPattern(rawPattern: string | undefined, random: boolean): string | undefined {
|
||||||
|
const canonical: string | undefined = parsePatternName(rawPattern);
|
||||||
|
if (!random) return canonical;
|
||||||
|
if (canonical !== undefined && canonical !== RANDOM_PATTERN) {
|
||||||
|
throw new CliError(`-r/--random conflicts with --pattern '${rawPattern}' (pick one)`);
|
||||||
|
}
|
||||||
|
return RANDOM_PATTERN;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse command-line arguments into a typed `ParsedCliArgs`. Uses Node's
|
||||||
|
* built-in `parseArgs` in strict mode so unknown flags and missing values
|
||||||
|
* surface as `CliError`s that the entry point can turn into exit code 2.
|
||||||
|
*
|
||||||
|
* @param argv - Argument list to parse, defaulting to the real command line.
|
||||||
|
* Injectable so the flag surface can be unit-tested directly.
|
||||||
|
*/
|
||||||
|
export function parseCliArgs(argv: string[] = process.argv.slice(2)): ParsedCliArgs {
|
||||||
let values: Record<string, string | boolean | undefined>;
|
let values: Record<string, string | boolean | undefined>;
|
||||||
try {
|
try {
|
||||||
const result = parseArgs({
|
const result = parseArgs({
|
||||||
args: process.argv.slice(2),
|
args: argv,
|
||||||
options: {
|
options: {
|
||||||
help: { type: "boolean", short: "h" },
|
help: { type: "boolean", short: "h" },
|
||||||
version: { type: "boolean", short: "v" },
|
version: { type: "boolean", short: "v" },
|
||||||
@@ -87,8 +147,10 @@ export function parseCliArgs(): ParsedCliArgs {
|
|||||||
"move-interval": { type: "string", short: "m" },
|
"move-interval": { type: "string", short: "m" },
|
||||||
"check-interval": { type: "string", short: "c" },
|
"check-interval": { type: "string", short: "c" },
|
||||||
"step-delay": { type: "string", short: "d" },
|
"step-delay": { type: "string", short: "d" },
|
||||||
"step-count": { type: "string", short: "n" },
|
pattern: { type: "string", short: "p" },
|
||||||
|
random: { type: "boolean", short: "r" },
|
||||||
verbose: { type: "boolean", short: "V" },
|
verbose: { type: "boolean", short: "V" },
|
||||||
|
loop: { type: "boolean", short: "l" },
|
||||||
},
|
},
|
||||||
strict: true,
|
strict: true,
|
||||||
allowPositionals: false,
|
allowPositionals: false,
|
||||||
@@ -109,8 +171,9 @@ export function parseCliArgs(): ParsedCliArgs {
|
|||||||
moveInterval: parsePositiveNumber("move-interval", values["move-interval"] as string | undefined),
|
moveInterval: parsePositiveNumber("move-interval", values["move-interval"] as string | undefined),
|
||||||
checkInterval: parsePositiveNumber("check-interval", values["check-interval"] as string | undefined),
|
checkInterval: parsePositiveNumber("check-interval", values["check-interval"] as string | undefined),
|
||||||
stepDelay: parsePositiveNumber("step-delay", values["step-delay"] as string | undefined),
|
stepDelay: parsePositiveNumber("step-delay", values["step-delay"] as string | undefined),
|
||||||
stepCount: parsePositiveNumber("step-count", values["step-count"] as string | undefined),
|
pattern: selectPattern(values.pattern as string | undefined, values.random === true),
|
||||||
verbose: values.verbose === true ? true : undefined,
|
verbose: values.verbose === true ? true : undefined,
|
||||||
|
loop: values.loop === true ? true : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,9 +215,18 @@ Options:
|
|||||||
-m, --move-interval <seconds> Idle time before a sweep fires. Default: ${moveDefaultSec}.
|
-m, --move-interval <seconds> Idle time before a sweep fires. Default: ${moveDefaultSec}.
|
||||||
-c, --check-interval <seconds> Cursor poll cadence. Default: ${checkDefaultSec}.
|
-c, --check-interval <seconds> Cursor poll cadence. Default: ${checkDefaultSec}.
|
||||||
-d, --step-delay <ms> Pause between synthetic steps. Default: ${DEFAULT_CONFIG.stepDelay}.
|
-d, --step-delay <ms> Pause between synthetic steps. Default: ${DEFAULT_CONFIG.stepDelay}.
|
||||||
-n, --step-count <pixels> Steps per sweep. Default: ${DEFAULT_CONFIG.stepCount}.
|
-p, --pattern <name> Movement strategy. Default: ${DEFAULT_CONFIG.pattern}.
|
||||||
-V, --verbose Log every sweep, interrupt, and bounds event
|
One of: ${SELECTABLE_PATTERN_NAMES.join(", ")}.
|
||||||
|
Each pattern defines its own size and speed.
|
||||||
|
-r, --random Shorthand for --pattern random. Picks a
|
||||||
|
different pattern for each sweep, never the
|
||||||
|
same one twice in a row. In loop mode the
|
||||||
|
pick holds for the whole loop run.
|
||||||
|
-V, --verbose Log every sweep and interrupt
|
||||||
(default prints only the startup banner).
|
(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.
|
Precedence (highest wins): CLI flags > config file > built-in defaults.
|
||||||
|
|
||||||
@@ -162,6 +234,10 @@ Examples:
|
|||||||
move
|
move
|
||||||
move --move-interval 180 --check-interval 5
|
move --move-interval 180 --check-interval 5
|
||||||
move -m 300 -V
|
move -m 300 -V
|
||||||
|
move --pattern arc
|
||||||
|
move --pattern diagonal --loop
|
||||||
|
move -r
|
||||||
|
move --pattern random --loop
|
||||||
move --config ~/myprofile.json
|
move --config ~/myprofile.json
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-16
@@ -10,7 +10,7 @@
|
|||||||
* `resolveConfig` rather than mutating the defaults, so the defaults stay
|
* `resolveConfig` rather than mutating the defaults, so the defaults stay
|
||||||
* genuinely constant and the resolved config stays structurally typed.
|
* genuinely constant and the resolved config stays structurally typed.
|
||||||
*
|
*
|
||||||
* All numeric `Config` fields are in their internal units (ms, pixels).
|
* All numeric `Config` fields are in their internal units (milliseconds).
|
||||||
* The CLI and config file expose the time-valued fields in seconds for
|
* The CLI and config file expose the time-valued fields in seconds for
|
||||||
* ergonomics; `resolveConfig` performs the seconds->ms conversion at the
|
* ergonomics; `resolveConfig` performs the seconds->ms conversion at the
|
||||||
* boundary so downstream code never has to think about it.
|
* boundary so downstream code never has to think about it.
|
||||||
@@ -22,12 +22,13 @@
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
import { CliError } from "./errors.ts";
|
import { CliError } from "./errors.ts";
|
||||||
|
import { isSelectablePattern, type PatternName } from "./strategies.ts";
|
||||||
|
|
||||||
// Single source of truth for default values. The same file ships in the
|
// Single source of truth for default values. The same file ships in the
|
||||||
// install tree and is copied to $XDG_CONFIG_HOME/move/config.json on a
|
// install tree and is copied to $XDG_CONFIG_HOME/move/config.json on a
|
||||||
// fresh install (only if no config exists there yet). Values use the CLI
|
// fresh install (only if no config exists there yet). Values use the CLI
|
||||||
// units (seconds for time fields, ms for stepDelay, pixels for stepCount);
|
// units (seconds for time fields, ms for stepDelay); the seconds->ms
|
||||||
// the seconds->ms conversion happens below where DEFAULT_CONFIG is built.
|
// conversion happens below where DEFAULT_CONFIG is built.
|
||||||
import seedRaw from "../scripts/config.default.json" with { type: "json" };
|
import seedRaw from "../scripts/config.default.json" with { type: "json" };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -41,16 +42,26 @@ import seedRaw from "../scripts/config.default.json" with { type: "json" };
|
|||||||
* - `stepDelay` — pause between individual synthetic mouse steps inside
|
* - `stepDelay` — pause between individual synthetic mouse steps inside
|
||||||
* a sweep. Also the window in which the user can
|
* a sweep. Also the window in which the user can
|
||||||
* "interrupt" by moving the cursor. Milliseconds.
|
* "interrupt" by moving the cursor. Milliseconds.
|
||||||
* - `stepCount` — number of pixel-steps in a single sweep. Pixels.
|
* - `pattern` — name of the movement strategy to use (see
|
||||||
* - `verbose` — whether per-sweep / interrupt / bounds events are
|
* `strategies.ts`; e.g. `line`, `walk`, `arc`). Each
|
||||||
* 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 {
|
export interface Config {
|
||||||
readonly moveInterval: number;
|
readonly moveInterval: number;
|
||||||
readonly checkInterval: number;
|
readonly checkInterval: number;
|
||||||
readonly stepDelay: number;
|
readonly stepDelay: number;
|
||||||
readonly stepCount: number;
|
readonly pattern: PatternName;
|
||||||
readonly verbose: boolean;
|
readonly verbose: boolean;
|
||||||
|
readonly loop: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -63,8 +74,9 @@ interface SeedShape {
|
|||||||
moveInterval: number; // seconds
|
moveInterval: number; // seconds
|
||||||
checkInterval: number; // seconds
|
checkInterval: number; // seconds
|
||||||
stepDelay: number; // milliseconds
|
stepDelay: number; // milliseconds
|
||||||
stepCount: number; // pixels
|
pattern: string; // strategy name, or the `random` sentinel
|
||||||
verbose: boolean;
|
verbose: boolean;
|
||||||
|
loop: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertSeedShape(raw: unknown): asserts raw is SeedShape {
|
function assertSeedShape(raw: unknown): asserts raw is SeedShape {
|
||||||
@@ -72,15 +84,21 @@ function assertSeedShape(raw: unknown): asserts raw is SeedShape {
|
|||||||
throw new Error("scripts/config.default.json: root must be an object");
|
throw new Error("scripts/config.default.json: root must be an object");
|
||||||
}
|
}
|
||||||
const r = raw as Record<string, unknown>;
|
const r = raw as Record<string, unknown>;
|
||||||
for (const key of ["moveInterval", "checkInterval", "stepDelay", "stepCount"] as const) {
|
for (const key of ["moveInterval", "checkInterval", "stepDelay"] as const) {
|
||||||
const v = r[key];
|
const v = r[key];
|
||||||
if (typeof v !== "number" || !Number.isFinite(v) || v <= 0) {
|
if (typeof v !== "number" || !Number.isFinite(v) || v <= 0) {
|
||||||
throw new Error(`scripts/config.default.json: '${key}' must be a positive finite number (got ${JSON.stringify(v)})`);
|
throw new Error(`scripts/config.default.json: '${key}' must be a positive finite number (got ${JSON.stringify(v)})`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (typeof r.pattern !== "string" || !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") {
|
if (typeof r.verbose !== "boolean") {
|
||||||
throw new Error(`scripts/config.default.json: 'verbose' must be a boolean (got ${JSON.stringify(r.verbose)})`);
|
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);
|
assertSeedShape(seedRaw);
|
||||||
@@ -97,8 +115,9 @@ export const DEFAULT_CONFIG: Config = {
|
|||||||
moveInterval: seed.moveInterval * 1000,
|
moveInterval: seed.moveInterval * 1000,
|
||||||
checkInterval: seed.checkInterval * 1000,
|
checkInterval: seed.checkInterval * 1000,
|
||||||
stepDelay: seed.stepDelay,
|
stepDelay: seed.stepDelay,
|
||||||
stepCount: seed.stepCount,
|
pattern: seed.pattern,
|
||||||
verbose: seed.verbose,
|
verbose: seed.verbose,
|
||||||
|
loop: seed.loop,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -110,23 +129,25 @@ export const DEFAULT_CONFIG: Config = {
|
|||||||
* Numeric fields are in CLI / config-file units:
|
* Numeric fields are in CLI / config-file units:
|
||||||
* moveInterval, checkInterval — seconds
|
* moveInterval, checkInterval — seconds
|
||||||
* stepDelay — milliseconds
|
* stepDelay — milliseconds
|
||||||
* stepCount — pixels
|
|
||||||
*
|
*
|
||||||
* `verbose` is `boolean | undefined` like the numeric fields, so all five
|
* `pattern` is a strategy name (`string | undefined`) and `verbose` is
|
||||||
* fields share the same "first defined value wins" precedence logic.
|
* `boolean | undefined`, so every field shares the same "first defined
|
||||||
|
* value wins" precedence logic.
|
||||||
*
|
*
|
||||||
* For the CLI specifically, `verbose` is `undefined` when `-V/--verbose`
|
* For the CLI specifically, `verbose` is `undefined` when `-V/--verbose`
|
||||||
* was not passed and `true` when it was. There is no CLI off-switch
|
* 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
|
* 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
|
* 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 {
|
export interface ConfigOverrides {
|
||||||
readonly moveInterval: number | undefined;
|
readonly moveInterval: number | undefined;
|
||||||
readonly checkInterval: number | undefined;
|
readonly checkInterval: number | undefined;
|
||||||
readonly stepDelay: number | undefined;
|
readonly stepDelay: number | undefined;
|
||||||
readonly stepCount: number | undefined;
|
readonly pattern: string | undefined;
|
||||||
readonly verbose: boolean | undefined;
|
readonly verbose: boolean | undefined;
|
||||||
|
readonly loop: boolean | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -191,7 +212,8 @@ export function resolveConfig(file: ConfigOverrides | null, cli: ConfigOverrides
|
|||||||
moveInterval: pickSeconds(cli.moveInterval, file?.moveInterval, DEFAULT_CONFIG.moveInterval),
|
moveInterval: pickSeconds(cli.moveInterval, file?.moveInterval, DEFAULT_CONFIG.moveInterval),
|
||||||
checkInterval: pickSeconds(cli.checkInterval, file?.checkInterval, DEFAULT_CONFIG.checkInterval),
|
checkInterval: pickSeconds(cli.checkInterval, file?.checkInterval, DEFAULT_CONFIG.checkInterval),
|
||||||
stepDelay: pickRaw(cli.stepDelay, file?.stepDelay, DEFAULT_CONFIG.stepDelay),
|
stepDelay: pickRaw(cli.stepDelay, file?.stepDelay, DEFAULT_CONFIG.stepDelay),
|
||||||
stepCount: pickRaw(cli.stepCount, file?.stepCount, DEFAULT_CONFIG.stepCount),
|
pattern: pickRaw(cli.pattern, file?.pattern, DEFAULT_CONFIG.pattern),
|
||||||
verbose: pickRaw(cli.verbose, file?.verbose, DEFAULT_CONFIG.verbose),
|
verbose: pickRaw(cli.verbose, file?.verbose, DEFAULT_CONFIG.verbose),
|
||||||
|
loop: pickRaw(cli.loop, file?.loop, DEFAULT_CONFIG.loop),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+58
-9
@@ -10,12 +10,19 @@
|
|||||||
* moveInterval number seconds, positive
|
* moveInterval number seconds, positive
|
||||||
* checkInterval number seconds, positive
|
* checkInterval number seconds, positive
|
||||||
* stepDelay number milliseconds, positive
|
* stepDelay number milliseconds, positive
|
||||||
* stepCount number pixels, positive
|
* pattern string a registered strategy name, or "random"
|
||||||
* verbose boolean
|
* 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
|
* Unknown keys, wrong types, and non-positive numerics are rejected with a
|
||||||
* `CliError` so the entry point can exit 2 (user error) with a clear
|
* `CliError` so the entry point can exit 2 (user error) with a clear
|
||||||
* message pointing at the offending file.
|
* message pointing at the offending file. The removed `stepCount` /
|
||||||
|
* `stepSize` keys are the exception: they're tolerated (ignored with a
|
||||||
|
* one-line notice) so an older seeded config keeps working after upgrade.
|
||||||
*
|
*
|
||||||
* Return semantics:
|
* Return semantics:
|
||||||
* - `null` when no `explicitPath` was passed and the default path does
|
* - `null` when no `explicitPath` was passed and the default path does
|
||||||
@@ -29,13 +36,28 @@ import { existsSync, readFileSync, statSync } from "node:fs";
|
|||||||
|
|
||||||
import { defaultConfigPath, type ConfigOverrides } from "./config.ts";
|
import { defaultConfigPath, type ConfigOverrides } from "./config.ts";
|
||||||
import { CliError } from "./errors.ts";
|
import { CliError } from "./errors.ts";
|
||||||
|
import { SELECTABLE_PATTERN_NAMES, resolvePatternName } from "./strategies.ts";
|
||||||
|
|
||||||
const ALLOWED_KEYS: ReadonlySet<string> = new Set<string>([
|
const ALLOWED_KEYS: ReadonlySet<string> = new Set<string>([
|
||||||
"moveInterval",
|
"moveInterval",
|
||||||
"checkInterval",
|
"checkInterval",
|
||||||
"stepDelay",
|
"stepDelay",
|
||||||
"stepCount",
|
"pattern",
|
||||||
"verbose",
|
"verbose",
|
||||||
|
"loop",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keys that used to be valid but have since been removed. They're tolerated
|
||||||
|
* (not rejected like a genuine unknown key) so upgrading doesn't hard-fail a
|
||||||
|
* config that was seeded with them — every pre-1.3.0 install has `stepCount`
|
||||||
|
* in its file. They no longer do anything: sweep size and step count are now
|
||||||
|
* properties of each movement pattern. A one-line notice points the user at
|
||||||
|
* the file so they can remove them at leisure.
|
||||||
|
*/
|
||||||
|
const DEPRECATED_KEYS: ReadonlySet<string> = new Set<string>([
|
||||||
|
"stepCount",
|
||||||
|
"stepSize",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
@@ -60,6 +82,16 @@ function requireBoolean(name: string, raw: unknown, path: string): boolean {
|
|||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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: ${SELECTABLE_PATTERN_NAMES.join(", ")})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return canonical;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load and validate the config file. See module docstring for return
|
* Load and validate the config file. See module docstring for return
|
||||||
* semantics.
|
* semantics.
|
||||||
@@ -104,13 +136,26 @@ export function loadConfigFile(explicitPath: string | undefined): ConfigOverride
|
|||||||
throw new CliError(`config file ${path} must contain a JSON object at the root`);
|
throw new CliError(`config file ${path} must contain a JSON object at the root`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strict mode: reject any key we don't know about. Catches typos like
|
// Strict mode: reject any key we don't know about (catches typos like
|
||||||
// 'movInterval' that would otherwise sail through silently.
|
// 'movInterval'), but tolerate keys we've since removed — collect those
|
||||||
|
// and warn once, rather than hard-failing a config seeded by an older
|
||||||
|
// install.
|
||||||
|
const deprecatedFound: string[] = [];
|
||||||
for (const key of Object.keys(parsed)) {
|
for (const key of Object.keys(parsed)) {
|
||||||
if (!ALLOWED_KEYS.has(key)) {
|
if (ALLOWED_KEYS.has(key)) continue;
|
||||||
|
if (DEPRECATED_KEYS.has(key)) {
|
||||||
|
deprecatedFound.push(key);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const allowed: string = [...ALLOWED_KEYS].join(", ");
|
const allowed: string = [...ALLOWED_KEYS].join(", ");
|
||||||
throw new CliError(`unknown key '${key}' in ${path} (allowed: ${allowed})`);
|
throw new CliError(`unknown key '${key}' in ${path} (allowed: ${allowed})`);
|
||||||
}
|
}
|
||||||
|
if (deprecatedFound.length > 0) {
|
||||||
|
const names: string = deprecatedFound.map((k) => `'${k}'`).join(", ");
|
||||||
|
process.stderr.write(
|
||||||
|
`move: ignoring obsolete key(s) ${names} in ${path}\n` +
|
||||||
|
` (sweep size is now defined by each movement pattern)\n`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -126,13 +171,17 @@ export function loadConfigFile(explicitPath: string | undefined): ConfigOverride
|
|||||||
"stepDelay" in parsed
|
"stepDelay" in parsed
|
||||||
? requirePositiveNumber("stepDelay", parsed.stepDelay, path)
|
? requirePositiveNumber("stepDelay", parsed.stepDelay, path)
|
||||||
: undefined,
|
: undefined,
|
||||||
stepCount:
|
pattern:
|
||||||
"stepCount" in parsed
|
"pattern" in parsed
|
||||||
? requirePositiveNumber("stepCount", parsed.stepCount, path)
|
? requirePatternName("pattern", parsed.pattern, path)
|
||||||
: undefined,
|
: undefined,
|
||||||
verbose:
|
verbose:
|
||||||
"verbose" in parsed
|
"verbose" in parsed
|
||||||
? requireBoolean("verbose", parsed.verbose, path)
|
? requireBoolean("verbose", parsed.verbose, path)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
loop:
|
||||||
|
"loop" in parsed
|
||||||
|
? requireBoolean("loop", parsed.loop, path)
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* device.ts
|
||||||
|
* ---------
|
||||||
|
* The I/O seam between the movement machinery and the outside world.
|
||||||
|
*
|
||||||
|
* Everything that actually touches `@nut-tree-fork/nut-js` lives here and
|
||||||
|
* nowhere else. The strategies (`strategies.ts`) and the execution driver
|
||||||
|
* (`executor.ts`) are written against the `Device` interface, which makes
|
||||||
|
* them pure and unit-testable without the nut.js native binary or a real
|
||||||
|
* screen — a fake `Device` is enough.
|
||||||
|
*
|
||||||
|
* `Point` is deliberately a plain `{ x, y }` structure rather than nut.js's
|
||||||
|
* `Point` class, so no module outside this one has to import nut.js just to
|
||||||
|
* describe a coordinate. `createNutDevice` converts to nut.js's `Point`
|
||||||
|
* when it commands the cursor.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A screen coordinate in pixels. Plain data (not nut.js's `Point` class) so
|
||||||
|
* strategies, the executor, and tests never need a nut.js import.
|
||||||
|
*/
|
||||||
|
export interface Point {
|
||||||
|
readonly x: number;
|
||||||
|
readonly y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The capabilities the movement machinery needs from the host system:
|
||||||
|
* read/write the cursor, learn the screen size, and wait.
|
||||||
|
*
|
||||||
|
* The production implementation (`createNutDevice`) is backed by nut.js;
|
||||||
|
* tests substitute a fake that records calls and returns scripted values.
|
||||||
|
*/
|
||||||
|
export interface Device {
|
||||||
|
/** Current cursor position. */
|
||||||
|
getPosition(): Promise<Point>;
|
||||||
|
/** Move the cursor to `p`. */
|
||||||
|
setPosition(p: Point): Promise<void>;
|
||||||
|
/** Current primary-screen width in pixels. */
|
||||||
|
width(): Promise<number>;
|
||||||
|
/** Current primary-screen height in pixels. */
|
||||||
|
height(): Promise<number>;
|
||||||
|
/** Resolve after `ms` milliseconds. */
|
||||||
|
sleep(ms: number): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Promise-based `setTimeout`. Shared default sleep used by the nut.js
|
||||||
|
* device and available for reuse.
|
||||||
|
*
|
||||||
|
* @param ms - Duration to wait, in milliseconds.
|
||||||
|
*/
|
||||||
|
export const sleep = (ms: number): Promise<void> =>
|
||||||
|
new Promise<void>((resolve: () => void): void => {
|
||||||
|
setTimeout(resolve, ms);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the production `Device` backed by nut.js.
|
||||||
|
*
|
||||||
|
* Importing nut.js dlopens a sizeable native `.node` binary, so this is a
|
||||||
|
* function (not a module-level singleton): callers that never move the
|
||||||
|
* mouse (`--help`, `--version`) never pay for it, and `move.ts` already
|
||||||
|
* defers the whole `keeper.ts` import behind those short-circuits.
|
||||||
|
*
|
||||||
|
* Side effect: sets `mouse.config.autoDelayMs = 0`. nut.js otherwise
|
||||||
|
* inserts a 100ms delay after every action, which — with two cursor calls
|
||||||
|
* per step — would silently more-than-double every sweep. We drive cadence
|
||||||
|
* ourselves via `stepDelay`, so the implicit delay is disabled here, at the
|
||||||
|
* single point where nut.js is actually wired up.
|
||||||
|
*/
|
||||||
|
export async function createNutDevice(): Promise<Device> {
|
||||||
|
const { mouse, Point: NutPoint, screen } = await import("@nut-tree-fork/nut-js");
|
||||||
|
|
||||||
|
mouse.config.autoDelayMs = 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
getPosition: async (): Promise<Point> => {
|
||||||
|
const p = await mouse.getPosition();
|
||||||
|
return { x: p.x, y: p.y };
|
||||||
|
},
|
||||||
|
setPosition: async (p: Point): Promise<void> => {
|
||||||
|
await mouse.setPosition(new NutPoint(p.x, p.y));
|
||||||
|
},
|
||||||
|
width: (): Promise<number> => screen.width(),
|
||||||
|
height: (): Promise<number> => screen.height(),
|
||||||
|
sleep,
|
||||||
|
};
|
||||||
|
}
|
||||||
+197
@@ -0,0 +1,197 @@
|
|||||||
|
/**
|
||||||
|
* executor.ts
|
||||||
|
* -----------
|
||||||
|
* The single execution driver shared by every movement strategy.
|
||||||
|
*
|
||||||
|
* A strategy (`strategies.ts`) says *where* to go; this module owns
|
||||||
|
* *everything else* about carrying a sweep out against a `Device`:
|
||||||
|
*
|
||||||
|
* - round each ideal target to whole pixels,
|
||||||
|
* - 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,
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* Interrupt detection compares the re-read cursor against the *last
|
||||||
|
* commanded (rounded) point*, never the strategy's ideal (possibly
|
||||||
|
* fractional) target. That's what lets curved/stochastic patterns work
|
||||||
|
* without every rounded step being misread as "the user moved the mouse".
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Config } from "./config.ts";
|
||||||
|
import type { Device, Point } from "./device.ts";
|
||||||
|
import type { MoveContext, MovementStrategy } from "./strategies.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal log surface used by the executor and the keeper loop.
|
||||||
|
*
|
||||||
|
* - `info(msg)` prints unconditionally (startup banner, fatal notes).
|
||||||
|
* - `event(msg)` prints only under `--verbose` / `verbose: true`.
|
||||||
|
*/
|
||||||
|
export interface Logger {
|
||||||
|
info(msg: string): void;
|
||||||
|
event(msg: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How a sweep ended:
|
||||||
|
* - `completed` — full path ran and the cursor was restored to start.
|
||||||
|
* - `interrupted` — real user activity detected mid-sweep; the sweep stopped
|
||||||
|
* without snapping back.
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
* we read back before calling it real-user activity. Absorbs the sub-pixel
|
||||||
|
* placement error the OS can introduce on scaled or multi-monitor setups; a
|
||||||
|
* genuine user movement is far larger than this.
|
||||||
|
*/
|
||||||
|
const READBACK_TOLERANCE: number = 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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:
|
||||||
|
* `[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;
|
||||||
|
if (hiEdge - 2 * EDGE_MARGIN < 1) return { lo: 0, hi: Math.max(0, hiEdge) };
|
||||||
|
return { lo: EDGE_MARGIN, hi: hiEdge - EDGE_MARGIN };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirror `v` into the inset travel range for `max` as a triangle wave, so
|
||||||
|
* 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);
|
||||||
|
const span: number = hi - lo;
|
||||||
|
if (span <= 0) return lo;
|
||||||
|
const period: number = 2 * span;
|
||||||
|
const m: number = (((Math.round(v) - lo) % period) + period) % period;
|
||||||
|
return lo + (m <= span ? m : period - m);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(p: Point, width: number, height: number): Point {
|
||||||
|
return { x: reflectInt(p.x, width), y: reflectInt(p.y, height) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format the current local time as `HH:MM:SS` for log lines.
|
||||||
|
*/
|
||||||
|
function timestamp(): string {
|
||||||
|
const d: Date = new Date();
|
||||||
|
const pad = (n: number): string => String(n).padStart(2, "0");
|
||||||
|
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run one sweep: drive `strategy.path(ctx)` to completion (or early exit)
|
||||||
|
* against `device`.
|
||||||
|
*
|
||||||
|
* Contract, per step:
|
||||||
|
* 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 — 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.
|
||||||
|
*/
|
||||||
|
export async function executePath(
|
||||||
|
strategy: MovementStrategy,
|
||||||
|
ctx: MoveContext,
|
||||||
|
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 path) {
|
||||||
|
const point: Point = resolveTarget(target, width, height);
|
||||||
|
|
||||||
|
await device.setPosition(point);
|
||||||
|
await device.sleep(config.stepDelay);
|
||||||
|
|
||||||
|
const current: Point = await device.getPosition();
|
||||||
|
if (
|
||||||
|
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. 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 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";
|
||||||
|
}
|
||||||
+117
-117
@@ -1,63 +1,45 @@
|
|||||||
/**
|
/**
|
||||||
* keeper.ts
|
* keeper.ts
|
||||||
* ---------
|
* ---------
|
||||||
* The actual "Teams Status Keeper" behavior: synthetic mouse activity with
|
* The "Teams Status Keeper" behavior: the idle-watch loop plus the
|
||||||
* real-user-wins semantics, plus the idle-watch loop that drives it.
|
* per-sweep glue that ties a movement strategy to the execution driver.
|
||||||
*
|
*
|
||||||
* Runtime: Bun (uses `@nut-tree-fork/nut-js` for cross-platform mouse +
|
* The mechanics are split across three seams so this file stays small and
|
||||||
* screen). The nut.js auto-delay is disabled inside `runKeeper`, not at
|
* the interesting parts stay testable:
|
||||||
* module load, so importing this module is side-effect-free.
|
* - `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 (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
|
||||||
|
* side-effect-free: nut.js isn't touched until `createNutDevice()` runs.
|
||||||
*
|
*
|
||||||
* Logging policy:
|
* Logging policy:
|
||||||
* - The startup banner in `runKeeper` is unconditional so the user always
|
* - The startup banner in `runKeeper` is unconditional so the user always
|
||||||
* sees confirmation that the process is alive.
|
* sees the process is alive.
|
||||||
* - Every per-sweep / interrupt / bounds log is gated by `config.verbose`
|
* - Per-sweep / interrupt lines are gated by `config.verbose` (see
|
||||||
* so the default is quiet. Errors stay on `console.error` (unconditional,
|
* `makeLogger`). Errors stay on `console.error`, raised by the entry
|
||||||
* raised by the entry point on unhandled rejection).
|
* point on unhandled rejection.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { mouse, Point, screen } from "@nut-tree-fork/nut-js";
|
import { createNutDevice, type Device, type Point } from "./device.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";
|
import type { Config } from "./config.ts";
|
||||||
|
|
||||||
/**
|
|
||||||
* Promise-based `setTimeout` wrapper. Allows `await sleep(ms)` ergonomics.
|
|
||||||
*
|
|
||||||
* @param ms - Duration to wait, in milliseconds.
|
|
||||||
*/
|
|
||||||
const sleep = (ms: number): Promise<void> =>
|
|
||||||
new Promise<void>((resolve: () => void): void => {
|
|
||||||
setTimeout(resolve, ms);
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format the current local time as `HH:MM:SS` (24-hour, zero-padded).
|
|
||||||
* Used for human-readable log lines. Date is intentionally omitted.
|
|
||||||
*/
|
|
||||||
const timestamp = (): string => {
|
|
||||||
const d: Date = new Date();
|
|
||||||
const pad = (n: number): string => String(n).padStart(2, "0");
|
|
||||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Minimal log surface used by `simulateActivity` and `runKeeper`. Named so
|
|
||||||
* it can appear directly in function signatures (clearer than
|
|
||||||
* `ReturnType<typeof makeLogger>`) and so a test could substitute a fake
|
|
||||||
* implementation if needed.
|
|
||||||
*
|
|
||||||
* - `info(msg)` prints unconditionally.
|
|
||||||
* - `event(msg)` prints only when `--verbose` / `verbose: true` is set.
|
|
||||||
*/
|
|
||||||
interface Logger {
|
|
||||||
info(msg: string): void;
|
|
||||||
event(msg: string): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a verbose-gated `Logger`. `info` is unconditional; `event` only
|
* Build a verbose-gated `Logger`. `info` is unconditional; `event` only
|
||||||
* fires when the caller asked for verbose output. Returning a small object
|
* fires when the caller asked for verbose output. Returning a small object
|
||||||
* keeps `simulateActivity` free of `if (verbose)` noise at every log site.
|
* keeps call sites free of `if (verbose)` noise at every log line.
|
||||||
*/
|
*/
|
||||||
function makeLogger(verbose: boolean): Logger {
|
function makeLogger(verbose: boolean): Logger {
|
||||||
return {
|
return {
|
||||||
@@ -71,63 +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.
|
||||||
*
|
*
|
||||||
* Behavior:
|
* Snapshots the screen (re-read every call so monitor changes are handled)
|
||||||
* 1. Snapshot the starting cursor position.
|
* and selects the configured strategy from the registry. An unknown
|
||||||
* 2. Read current screen dimensions (re-read every call so monitor changes
|
* `config.pattern` falls back to the default strategy defensively; validation
|
||||||
* are handled correctly).
|
* at the CLI / config-file boundary should prevent that from ever happening.
|
||||||
* 3. Pick a horizontal direction (`dx`) that keeps the sweep on-screen:
|
*
|
||||||
* move right if there's room, otherwise move left. Vertical movement is
|
* `pattern: "random"` isn't a registry key — it asks for a fresh pattern per
|
||||||
* currently disabled (`dy = 0`) but the framework is in place for
|
* sweep, so `pickRandom` supplies one here. The pick happens once, before the
|
||||||
* richer patterns later.
|
* loop-mode branch below, which is what makes a random selection hold for an
|
||||||
* 4. For each of `config.stepCount` steps:
|
* entire loop run rather than changing under the user mid-run; the picker's
|
||||||
* - Compute the next target position.
|
* own no-repeat memory then spans sweeps, since the keeper holds one picker
|
||||||
* - Defensive bounds check (belt-and-braces given the `dx` choice).
|
* for the life of the process. Because the pick is a real strategy, the log
|
||||||
* - Command nut.js to move the cursor there.
|
* lines below and in `executePath` name the concrete pattern, not "random".
|
||||||
* - Sleep `config.stepDelay` — also the user's interrupt window.
|
*
|
||||||
* - Re-read the cursor. If it isn't where we put it, the user
|
* Single-sweep mode (`config.loop === false`) runs exactly one sweep via
|
||||||
* touched the mouse: log (verbose) and return early, leaving the
|
* `executePath`, which owns on-screen reflection, pacing, interrupt
|
||||||
* cursor wherever the user moved it.
|
* detection, and restore-on-clean — unchanged from before loop mode existed.
|
||||||
* 5. On a clean full sweep, restore the cursor to the starting position
|
*
|
||||||
* so the next idle-check sees "no movement" and doesn't misread the
|
* Loop mode (`config.loop === true`) keeps the cursor moving until the
|
||||||
* synthetic activity as the user returning.
|
* 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): Promise<void> {
|
async function simulateActivity(
|
||||||
const start: Point = await mouse.getPosition();
|
config: Config,
|
||||||
const screenWidth: number = await screen.width();
|
log: Logger,
|
||||||
const screenHeight: number = await screen.height();
|
device: Device,
|
||||||
const dx: number = start.x + config.stepCount < screenWidth ? 1 : -1;
|
pickRandom: () => MovementStrategy,
|
||||||
const dy: number = 0;
|
): 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]!);
|
||||||
|
|
||||||
log.event(`Simulating activity at ${timestamp()}...`);
|
if (!config.loop) {
|
||||||
|
const start: Point = await device.getPosition();
|
||||||
for (let i: number = 1; i <= config.stepCount; i++) {
|
const ctx: MoveContext = { start, width, height, rng: Math.random };
|
||||||
const expected: Point = new Point(start.x + i * dx, start.y + i * dy);
|
await executePath(strategy, ctx, device, log, config);
|
||||||
|
|
||||||
if (expected.x < 0 || expected.x >= screenWidth || expected.y < 0 || expected.y >= screenHeight) {
|
|
||||||
// Safety net for future non-linear movement patterns. With the
|
|
||||||
// current straight-line sweep + `dx` selection above, this branch
|
|
||||||
// should never fire.
|
|
||||||
log.event(`Out of bounds at ${timestamp()}; aborting simulation.`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await mouse.setPosition(expected);
|
log.event(`Loop mode (${strategy.name}); repeating until you move the mouse.`);
|
||||||
await sleep(config.stepDelay);
|
const cycleLog: Logger = { info: log.info, event: (): void => {} };
|
||||||
|
const loopOpts = { restore: false, loop: true };
|
||||||
|
|
||||||
const current: Point = await mouse.getPosition();
|
let cycles = 0;
|
||||||
if (current.x !== expected.x || current.y !== expected.y) {
|
let outcome: SweepOutcome;
|
||||||
// Cursor isn't where we put it -> real user activity. Abort
|
do {
|
||||||
// without snapping back, so we don't yank the cursor out from
|
const start: Point = await device.getPosition();
|
||||||
// under the user.
|
const ctx: MoveContext = { start, width, height, rng: Math.random };
|
||||||
log.event(`User activity detected at ${timestamp()}; aborting simulation.`);
|
outcome = await executePath(strategy, ctx, device, cycleLog, config, loopOpts);
|
||||||
return;
|
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");
|
||||||
|
|
||||||
await mouse.setPosition(start);
|
log.event(`Loop run ended after ${cycles} cycle(s): ${outcome}.`);
|
||||||
log.event("Mouse moved.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -145,32 +137,34 @@ async function simulateActivity(config: Config, log: Logger): Promise<void> {
|
|||||||
* idleness clock so we wait another full `moveInterval` before
|
* idleness clock so we wait another full `moveInterval` before
|
||||||
* firing again.
|
* firing again.
|
||||||
*
|
*
|
||||||
* `simulateActivity` is designed so that its own synthetic movement never
|
* `simulateActivity` (via `executePath`) is designed so its own synthetic
|
||||||
* counts as real activity: on a clean sweep it restores the cursor (so the
|
* movement never counts as real activity: on a clean sweep it restores the
|
||||||
* next position check matches), and on a user-interrupted sweep the next
|
* cursor, and on a user-interrupted sweep the next iteration sees the
|
||||||
* iteration sees the user's new position and correctly resets the clock.
|
* user's new position and correctly resets the clock.
|
||||||
|
*
|
||||||
|
* @param config - Resolved runtime config.
|
||||||
|
* @param device - I/O device; defaults to the production nut.js device.
|
||||||
|
* @param 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): Promise<void> {
|
export async function runKeeper(
|
||||||
// nut.js inserts a configurable delay after every action (default 100ms).
|
config: Config,
|
||||||
// That default would silently more-than-double the duration of every
|
device?: Device,
|
||||||
// setPosition and getPosition call. We drive cadence ourselves via
|
pickRandom: () => MovementStrategy = createRandomPicker(),
|
||||||
// config.stepDelay, so disable nut.js's implicit delay entirely.
|
): Promise<void> {
|
||||||
//
|
const dev: Device = device ?? (await createNutDevice());
|
||||||
// Setting this here (rather than at module load) keeps `keeper.ts` free
|
|
||||||
// of import-time side effects on the shared nut.js singleton — useful
|
|
||||||
// for tests and any future code path that imports this module without
|
|
||||||
// actually running the loop.
|
|
||||||
mouse.config.autoDelayMs = 0;
|
|
||||||
|
|
||||||
const log = makeLogger(config.verbose);
|
const log = makeLogger(config.verbose);
|
||||||
log.info("Teams Status Keeper started. Press Ctrl+C to stop.");
|
log.info("Teams Status Keeper started. Press Ctrl+C to stop.");
|
||||||
|
|
||||||
let lastPos: Point = await mouse.getPosition();
|
let lastPos: Point = await dev.getPosition();
|
||||||
let lastActivity: number = Date.now();
|
let lastActivity: number = Date.now();
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
await sleep(config.checkInterval);
|
await dev.sleep(config.checkInterval);
|
||||||
const pos: Point = await mouse.getPosition();
|
const pos: Point = await dev.getPosition();
|
||||||
const now: number = Date.now();
|
const now: number = Date.now();
|
||||||
|
|
||||||
if (pos.x !== lastPos.x || pos.y !== lastPos.y) {
|
if (pos.x !== lastPos.x || pos.y !== lastPos.y) {
|
||||||
@@ -181,12 +175,18 @@ export async function runKeeper(config: Config): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (now - lastActivity >= config.moveInterval) {
|
if (now - lastActivity >= config.moveInterval) {
|
||||||
await simulateActivity(config, log);
|
await simulateActivity(config, log, dev, pickRandom);
|
||||||
// `simulateActivity` either returns the cursor to its start
|
// The sweep either restored the cursor to its start (clean) or
|
||||||
// (clean sweep) or leaves it where the user moved it (interrupt).
|
// left it where the user moved it (interrupt). Either way, reset
|
||||||
// Either way we reset the clock and require another full
|
// the clock and require another full moveInterval of inactivity
|
||||||
// moveInterval of inactivity before firing again.
|
// before firing again.
|
||||||
lastActivity = Date.now();
|
lastActivity = Date.now();
|
||||||
|
// Re-sync lastPos to where the cursor actually ended. After a
|
||||||
|
// clean sweep this is a no-op (it was restored to start). After
|
||||||
|
// an interrupt it snaps lastPos to the user's position, so the
|
||||||
|
// next poll doesn't re-read that same displacement and count it a
|
||||||
|
// second time as fresh activity.
|
||||||
|
lastPos = await dev.getPosition();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-6
@@ -4,23 +4,27 @@
|
|||||||
* -------
|
* -------
|
||||||
* Entry point for the `move` CLI.
|
* Entry point for the `move` CLI.
|
||||||
*
|
*
|
||||||
* Thin shim that ties the four logic modules together:
|
* Thin shim that ties the logic modules together:
|
||||||
* - `cli.ts` parses and validates `process.argv`.
|
* - `cli.ts` parses and validates `process.argv`.
|
||||||
* - `configFile.ts` loads and validates the JSON config file.
|
* - `configFile.ts` loads and validates the JSON config file.
|
||||||
* - `config.ts` holds defaults and the layered `resolveConfig` overlay.
|
* - `config.ts` holds defaults and the layered `resolveConfig` overlay.
|
||||||
* - `keeper.ts` owns the synthetic-activity sweep and idle-watch loop.
|
* - `editor.ts` backs `--edit` (open the config file in `$EDITOR`).
|
||||||
|
* - `keeper.ts` owns the idle-watch loop and drives the movement
|
||||||
|
* machinery (device / strategy / executor).
|
||||||
*
|
*
|
||||||
* Order of operations:
|
* Order of operations:
|
||||||
* 1. Parse CLI args. Bad input -> stderr + usage hint, exit 2.
|
* 1. Parse CLI args. Bad input -> stderr + usage hint, exit 2.
|
||||||
* 2. `--help` / `--version` short-circuit before any I/O, config load, or
|
* 2. `--help` / `--version` short-circuit before any I/O, config load, or
|
||||||
* mouse work. `keeper.ts` is also lazy-imported (see below) so these
|
* mouse work. `keeper.ts` is also lazy-imported (see below) so these
|
||||||
* flags don't pay the cost of loading the nut.js native binary.
|
* flags don't pay the cost of loading the nut.js native binary.
|
||||||
* 3. Load + validate the config file (default XDG path, or `--config
|
* 3. `--edit` opens the resolved config file in `$EDITOR` and is a
|
||||||
|
* terminal action (propagates the editor's exit code).
|
||||||
|
* 4. Load + validate the config file (default XDG path, or `--config
|
||||||
* <path>` if supplied). Validation failures share the exit-2 path.
|
* <path>` if supplied). Validation failures share the exit-2 path.
|
||||||
* 4. Resolve the full `Config` (CLI > file > DEFAULT_CONFIG) — verbose
|
* 5. Resolve the full `Config` (CLI > file > DEFAULT_CONFIG) — verbose
|
||||||
* lives inside `Config` and is layered with the same precedence as
|
* lives inside `Config` and is layered with the same precedence as
|
||||||
* the numeric fields.
|
* the numeric fields.
|
||||||
* 5. Lazy-import `keeper.ts` (dynamic import keeps nut.js out of the
|
* 6. Lazy-import `keeper.ts` (dynamic import keeps nut.js out of the
|
||||||
* `--help` / `--version` startup path) and run it. Any unhandled
|
* `--help` / `--version` startup path) and run it. Any unhandled
|
||||||
* rejection — from the import itself or from the loop — exits 1.
|
* rejection — from the import itself or from the loop — exits 1.
|
||||||
*
|
*
|
||||||
@@ -115,8 +119,9 @@ const cliOverrides: ConfigOverrides = {
|
|||||||
moveInterval: cliArgs.moveInterval,
|
moveInterval: cliArgs.moveInterval,
|
||||||
checkInterval: cliArgs.checkInterval,
|
checkInterval: cliArgs.checkInterval,
|
||||||
stepDelay: cliArgs.stepDelay,
|
stepDelay: cliArgs.stepDelay,
|
||||||
stepCount: cliArgs.stepCount,
|
pattern: cliArgs.pattern,
|
||||||
verbose: cliArgs.verbose,
|
verbose: cliArgs.verbose,
|
||||||
|
loop: cliArgs.loop,
|
||||||
};
|
};
|
||||||
|
|
||||||
const config = resolveConfig(fileOverrides, cliOverrides);
|
const config = resolveConfig(fileOverrides, cliOverrides);
|
||||||
|
|||||||
@@ -0,0 +1,392 @@
|
|||||||
|
/**
|
||||||
|
* strategies.ts
|
||||||
|
* -------------
|
||||||
|
* The movement-pattern seam: pure generators of cursor targets.
|
||||||
|
*
|
||||||
|
* A `MovementStrategy` describes *where* the cursor should go, as an
|
||||||
|
* iterable of ideal `Point`s starting from the sweep's origin. It performs
|
||||||
|
* no I/O, no timing, and no interrupt handling — that all belongs to the
|
||||||
|
* executor (`executor.ts`). This split is what makes patterns trivial to
|
||||||
|
* add (write one pure generator) and trivial to test (feed a deterministic
|
||||||
|
* `rng`, assert the emitted points).
|
||||||
|
*
|
||||||
|
* 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
|
||||||
|
* are properties of the pattern, not user preferences: a jitter is inherently
|
||||||
|
* small and twitchy, an arc inherently a broad curve. There is deliberately
|
||||||
|
* no user knob for sweep size or step count; the cadence (`stepDelay`) is the
|
||||||
|
* only tunable, and it lives in the executor, not here. As a result this
|
||||||
|
* module needs nothing from `Config` and imports only `Point`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Point } from "./device.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* stochastic strategies are deterministic under test.
|
||||||
|
*/
|
||||||
|
export interface MoveContext {
|
||||||
|
/** Cursor position at the start of the sweep. */
|
||||||
|
readonly start: Point;
|
||||||
|
/** Primary-screen width in pixels. */
|
||||||
|
readonly width: number;
|
||||||
|
/** Primary-screen height in pixels. */
|
||||||
|
readonly height: number;
|
||||||
|
/** Uniform [0, 1) source. Defaults to `Math.random`; tests inject a fake. */
|
||||||
|
readonly rng: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A named movement pattern.
|
||||||
|
*
|
||||||
|
* - `name` — registry key, also the value accepted by `--pattern` / the
|
||||||
|
* `pattern` config key.
|
||||||
|
* - `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;
|
||||||
|
path(ctx: MoveContext): Iterable<Point>;
|
||||||
|
loopPath?(ctx: MoveContext): Iterable<Point>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `line` — the original behavior, preserved exactly.
|
||||||
|
*
|
||||||
|
* 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. 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",
|
||||||
|
*path(ctx: MoveContext): Generator<Point> {
|
||||||
|
const { start, width } = ctx;
|
||||||
|
const dx: number = start.x + LINE_STEPS < width ? 1 : -1;
|
||||||
|
for (let i = 1; i <= LINE_STEPS; i++) {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `diagonal` — straight line on both axes at once. Each axis's direction is
|
||||||
|
* 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",
|
||||||
|
*path(ctx: MoveContext): Generator<Point> {
|
||||||
|
const { start, width, height } = ctx;
|
||||||
|
const dx: number = start.x + DIAGONAL_STEPS < width ? 1 : -1;
|
||||||
|
const dy: number = start.y + DIAGONAL_STEPS < height ? 1 : -1;
|
||||||
|
for (let i = 1; i <= DIAGONAL_STEPS; i++) {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `jitter` — many small random hops within a tight radius of the start.
|
||||||
|
* Subtle "fidget" activity rather than a broad sweep. The radius is large
|
||||||
|
* enough that every hop is a real, distinct pixel move rather than rounding
|
||||||
|
* onto the pixel the cursor already occupies. The executor restores the
|
||||||
|
* cursor to `start` after a clean run, so the net displacement is zero.
|
||||||
|
*/
|
||||||
|
const JITTER_STEPS = 80;
|
||||||
|
const JITTER_RADIUS = 30;
|
||||||
|
|
||||||
|
export const jitter: MovementStrategy = {
|
||||||
|
name: "jitter",
|
||||||
|
*path(ctx: MoveContext): Generator<Point> {
|
||||||
|
const { start, rng } = ctx;
|
||||||
|
for (let i = 1; i <= JITTER_STEPS; i++) {
|
||||||
|
const angle: number = rng() * 2 * Math.PI;
|
||||||
|
const r: number = rng() * JITTER_RADIUS;
|
||||||
|
yield { x: start.x + Math.cos(angle) * r, y: start.y + Math.sin(angle) * r };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `walk` — an unbounded cumulative random walk: each step adds a random
|
||||||
|
* 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 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",
|
||||||
|
*path(ctx: MoveContext): Generator<Point> {
|
||||||
|
const { start, rng } = ctx;
|
||||||
|
let x: number = start.x;
|
||||||
|
let y: number = start.y;
|
||||||
|
for (let i = 1; i <= WALK_STEPS; i++) {
|
||||||
|
x += (rng() * 2 - 1) * WALK_STEP;
|
||||||
|
y += (rng() * 2 - 1) * WALK_STEP;
|
||||||
|
yield { x, y };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `arc` — a smooth quadratic Bézier curve from the start to a random
|
||||||
|
* on-screen endpoint `ARC_REACH` pixels away, bowed out by a control point
|
||||||
|
* offset perpendicular to the straight path. `ARC_STEPS` samples keep the
|
||||||
|
* curve smooth. Produces natural, hand-like curved motion.
|
||||||
|
*/
|
||||||
|
const ARC_STEPS = 120;
|
||||||
|
const ARC_REACH = 300;
|
||||||
|
|
||||||
|
export const arc: MovementStrategy = {
|
||||||
|
name: "arc",
|
||||||
|
*path(ctx: MoveContext): Generator<Point> {
|
||||||
|
const { start, width, height, rng } = ctx;
|
||||||
|
|
||||||
|
// Endpoint: a random direction, `ARC_REACH` away, clamped on-screen.
|
||||||
|
const angle: number = rng() * 2 * Math.PI;
|
||||||
|
const endX: number = clamp(start.x + Math.cos(angle) * ARC_REACH, width);
|
||||||
|
const endY: number = clamp(start.y + Math.sin(angle) * ARC_REACH, height);
|
||||||
|
|
||||||
|
// Control point: midpoint pushed along the perpendicular so the path
|
||||||
|
// bows rather than running straight. Direction/magnitude randomized.
|
||||||
|
const midX: number = (start.x + endX) / 2;
|
||||||
|
const midY: number = (start.y + endY) / 2;
|
||||||
|
const perpX: number = -(endY - start.y);
|
||||||
|
const perpY: number = endX - start.x;
|
||||||
|
const perpLen: number = Math.hypot(perpX, perpY) || 1;
|
||||||
|
const bow: number = (rng() * 2 - 1) * ARC_REACH * 0.5;
|
||||||
|
const ctrlX: number = clamp(midX + (perpX / perpLen) * bow, width);
|
||||||
|
const ctrlY: number = clamp(midY + (perpY / perpLen) * bow, height);
|
||||||
|
|
||||||
|
for (let i = 1; i <= ARC_STEPS; i++) {
|
||||||
|
const t: number = i / ARC_STEPS;
|
||||||
|
const u: number = 1 - t;
|
||||||
|
yield {
|
||||||
|
x: u * u * start.x + 2 * u * t * ctrlX + t * t * endX,
|
||||||
|
y: u * u * start.y + 2 * u * t * ctrlY + t * t * endY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `figureEight` — traces a Gerono lemniscate (a figure-eight) around the
|
||||||
|
* start point over one full period, so it returns to the origin.
|
||||||
|
* `FIG8_AMP` sets its half-width (≈250px across); `FIG8_STEPS` samples keep
|
||||||
|
* the curve smooth.
|
||||||
|
*/
|
||||||
|
const FIG8_STEPS = 90;
|
||||||
|
const FIG8_AMP = 125;
|
||||||
|
|
||||||
|
export const figureEight: MovementStrategy = {
|
||||||
|
name: "figureEight",
|
||||||
|
*path(ctx: MoveContext): Generator<Point> {
|
||||||
|
const { start } = ctx;
|
||||||
|
for (let i = 1; i <= FIG8_STEPS; i++) {
|
||||||
|
const t: number = (2 * Math.PI * i) / FIG8_STEPS;
|
||||||
|
yield {
|
||||||
|
x: start.x + FIG8_AMP * Math.sin(t),
|
||||||
|
y: start.y + FIG8_AMP * Math.sin(t) * Math.cos(t),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The registry of every selectable movement pattern, keyed by name. Adding
|
||||||
|
* a strategy is a one-line addition here plus its definition above.
|
||||||
|
*/
|
||||||
|
export const STRATEGIES: Readonly<Record<string, MovementStrategy>> = {
|
||||||
|
line,
|
||||||
|
diagonal,
|
||||||
|
jitter,
|
||||||
|
walk,
|
||||||
|
arc,
|
||||||
|
figureEight,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Pattern used when neither the CLI nor the config file selects one. */
|
||||||
|
export const DEFAULT_PATTERN = "line";
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
* source of truth); `isPatternName` is the guard callers use.
|
||||||
|
*/
|
||||||
|
export type PatternName = string;
|
||||||
|
|
||||||
|
/** True when `name` is an exact, registered strategy key. */
|
||||||
|
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`,
|
||||||
|
* and `FIGUREEIGHT` all collapse onto the same key as `figureEight`.
|
||||||
|
*/
|
||||||
|
const normalizePattern = (s: string): string => s.toLowerCase().replace(/[-_\s]/g, "");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(
|
||||||
|
SELECTABLE_PATTERN_NAMES.map((n) => [normalizePattern(n), n]),
|
||||||
|
);
|
||||||
|
|
||||||
|
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",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve loose user input to the canonical registry key, or `null` when no
|
||||||
|
* registered strategy matches. Used at the CLI and config-file validation
|
||||||
|
* boundaries so `Config.pattern` is always a canonical key and the keeper's
|
||||||
|
* direct `STRATEGIES[pattern]` lookup needs no normalization of its own.
|
||||||
|
*/
|
||||||
|
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]!;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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'/);
|
||||||
|
});
|
||||||
|
});
|
||||||
+25
-4
@@ -15,8 +15,9 @@ const NONE: ConfigOverrides = {
|
|||||||
moveInterval: undefined,
|
moveInterval: undefined,
|
||||||
checkInterval: undefined,
|
checkInterval: undefined,
|
||||||
stepDelay: undefined,
|
stepDelay: undefined,
|
||||||
stepCount: undefined,
|
pattern: undefined,
|
||||||
verbose: undefined,
|
verbose: undefined,
|
||||||
|
loop: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("resolveConfig", () => {
|
describe("resolveConfig", () => {
|
||||||
@@ -44,11 +45,16 @@ describe("resolveConfig", () => {
|
|||||||
expect(cfg.checkInterval).toBe(2000);
|
expect(cfg.checkInterval).toBe(2000);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("stepDelay and stepCount pass through untouched (no unit conversion)", () => {
|
test("stepDelay passes through untouched (no unit conversion)", () => {
|
||||||
const cli: ConfigOverrides = { ...NONE, stepDelay: 75, stepCount: 100 };
|
const cli: ConfigOverrides = { ...NONE, stepDelay: 75 };
|
||||||
const cfg = resolveConfig(null, cli);
|
const cfg = resolveConfig(null, cli);
|
||||||
expect(cfg.stepDelay).toBe(75);
|
expect(cfg.stepDelay).toBe(75);
|
||||||
expect(cfg.stepCount).toBe(100);
|
});
|
||||||
|
|
||||||
|
test("pattern: CLI wins over file, file wins over default", () => {
|
||||||
|
expect(resolveConfig({ ...NONE, pattern: "arc" }, { ...NONE, pattern: "walk" }).pattern).toBe("walk");
|
||||||
|
expect(resolveConfig({ ...NONE, pattern: "arc" }, NONE).pattern).toBe("arc");
|
||||||
|
expect(resolveConfig(null, NONE).pattern).toBe(DEFAULT_CONFIG.pattern);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("verbose: CLI true wins over file false", () => {
|
test("verbose: CLI true wins over file false", () => {
|
||||||
@@ -73,6 +79,21 @@ describe("resolveConfig", () => {
|
|||||||
const cfg = resolveConfig(null, NONE);
|
const cfg = resolveConfig(null, NONE);
|
||||||
expect(cfg.verbose).toBe(DEFAULT_CONFIG.verbose);
|
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", () => {
|
describe("defaultConfigPath", () => {
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ describe("loadConfigFile (explicit path)", () => {
|
|||||||
// Fields not in the file are undefined.
|
// Fields not in the file are undefined.
|
||||||
expect(result!.checkInterval).toBeUndefined();
|
expect(result!.checkInterval).toBeUndefined();
|
||||||
expect(result!.stepDelay).toBeUndefined();
|
expect(result!.stepDelay).toBeUndefined();
|
||||||
expect(result!.stepCount).toBeUndefined();
|
expect(result!.pattern).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns all-undefined overrides for an empty object", () => {
|
test("returns all-undefined overrides for an empty object", () => {
|
||||||
@@ -81,8 +81,8 @@ describe("loadConfigFile (explicit path)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("throws on non-positive numeric values", () => {
|
test("throws on non-positive numeric values", () => {
|
||||||
const negative = writeFixture("neg.json", JSON.stringify({ stepCount: -1 }));
|
const negative = writeFixture("neg.json", JSON.stringify({ moveInterval: -1 }));
|
||||||
expect(() => loadConfigFile(negative)).toThrow(/'stepCount'.*positive number/);
|
expect(() => loadConfigFile(negative)).toThrow(/'moveInterval'.*positive number/);
|
||||||
|
|
||||||
const zero = writeFixture("zero.json", JSON.stringify({ stepDelay: 0 }));
|
const zero = writeFixture("zero.json", JSON.stringify({ stepDelay: 0 }));
|
||||||
expect(() => loadConfigFile(zero)).toThrow(/'stepDelay'.*positive number/);
|
expect(() => loadConfigFile(zero)).toThrow(/'stepDelay'.*positive number/);
|
||||||
@@ -97,6 +97,71 @@ describe("loadConfigFile (explicit path)", () => {
|
|||||||
const path = writeFixture("verbose.json", JSON.stringify({ verbose: "yes" }));
|
const path = writeFixture("verbose.json", JSON.stringify({ verbose: "yes" }));
|
||||||
expect(() => loadConfigFile(path)).toThrow(/'verbose'.*boolean/);
|
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);
|
||||||
|
expect(result!.pattern).toBe("arc");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizes a loosely-spelled pattern to its canonical name", () => {
|
||||||
|
const path = writeFixture("loosepattern.json", JSON.stringify({ pattern: "figure-eight" }));
|
||||||
|
const result = loadConfigFile(path);
|
||||||
|
expect(result!.pattern).toBe("figureEight");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws on an unknown pattern, listing the valid names", () => {
|
||||||
|
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", () => {
|
||||||
|
// Seeded by pre-1.3.0 installs; must not hard-fail on upgrade. They're
|
||||||
|
// accepted but not surfaced as overrides (and even an invalid value,
|
||||||
|
// like a negative, is ignored rather than rejected).
|
||||||
|
const path = writeFixture(
|
||||||
|
"obsolete.json",
|
||||||
|
JSON.stringify({ moveInterval: 60, stepCount: -1, stepSize: 3 }),
|
||||||
|
);
|
||||||
|
const result = loadConfigFile(path);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.moveInterval).toBe(60);
|
||||||
|
expect(result as unknown as Record<string, unknown>).not.toHaveProperty("stepCount");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("still rejects a genuinely unknown key", () => {
|
||||||
|
const path = writeFixture("unknown.json", JSON.stringify({ movInterval: 60 }));
|
||||||
|
expect(() => loadConfigFile(path)).toThrow(/unknown key 'movInterval'/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("loadConfigFile (default path)", () => {
|
describe("loadConfigFile (default path)", () => {
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
import { editConfig, editorCommand } from "./editor.ts";
|
import { editConfig, editorCommand } from "../src/editor.ts";
|
||||||
import { CliError } from "./errors.ts";
|
import { CliError } from "../src/errors.ts";
|
||||||
|
|
||||||
describe("editorCommand", () => {
|
describe("editorCommand", () => {
|
||||||
test("builds 'sh -c <editor> \"$@\"' argv with -- placeholder and path", () => {
|
test("builds 'sh -c <editor> \"$@\"' argv with -- placeholder and path", () => {
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
/**
|
||||||
|
* executor.test.ts
|
||||||
|
* ----------------
|
||||||
|
* Unit tests for the execution driver against a fake `Device`. Covers the
|
||||||
|
* 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";
|
||||||
|
|
||||||
|
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 { MoveContext, MovementStrategy } from "../src/strategies.ts";
|
||||||
|
|
||||||
|
const noopLog: Logger = { info: (): void => {}, event: (): void => {} };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A scriptable `Device`. `getPosition` echoes the last commanded point
|
||||||
|
* (simulating "the cursor stayed where we put it") unless `overrides` maps
|
||||||
|
* the current getPosition call index to a substitute — used to inject a
|
||||||
|
* mid-sweep user interruption.
|
||||||
|
*/
|
||||||
|
class FakeDevice implements Device {
|
||||||
|
commanded: Point[] = [];
|
||||||
|
sleeps: number[] = [];
|
||||||
|
getCalls = 0;
|
||||||
|
overrides = new Map<number, Point>();
|
||||||
|
constructor(public w = 1920, public h = 1080, public initial: Point = { x: 0, y: 0 }) {}
|
||||||
|
|
||||||
|
async getPosition(): Promise<Point> {
|
||||||
|
this.getCalls++;
|
||||||
|
const o = this.overrides.get(this.getCalls);
|
||||||
|
if (o) return o;
|
||||||
|
return this.commanded.at(-1) ?? this.initial;
|
||||||
|
}
|
||||||
|
async setPosition(p: Point): Promise<void> {
|
||||||
|
this.commanded.push(p);
|
||||||
|
}
|
||||||
|
async width(): Promise<number> {
|
||||||
|
return this.w;
|
||||||
|
}
|
||||||
|
async height(): Promise<number> {
|
||||||
|
return this.h;
|
||||||
|
}
|
||||||
|
async sleep(ms: number): Promise<void> {
|
||||||
|
this.sleeps.push(ms);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A strategy that emits a fixed list of points. */
|
||||||
|
function fixed(points: Point[]): MovementStrategy {
|
||||||
|
return {
|
||||||
|
name: "fixed",
|
||||||
|
*path(): Generator<Point> {
|
||||||
|
yield* points;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function ctxOf(start: Point, width: number, height: number): MoveContext {
|
||||||
|
return { start, width, height, rng: Math.random };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A full `Config` for the executor's pacing; only `stepDelay` matters here. */
|
||||||
|
function cfgOf(config?: Partial<Config>): Config {
|
||||||
|
return { ...DEFAULT_CONFIG, ...config };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("executePath — outcomes", () => {
|
||||||
|
test("clean sweep commands every point, restores to start, returns 'completed'", async () => {
|
||||||
|
const dev = new FakeDevice();
|
||||||
|
const start = { x: 500, y: 500 };
|
||||||
|
const pts = [
|
||||||
|
{ x: 501, y: 500 },
|
||||||
|
{ x: 502, y: 500 },
|
||||||
|
{ x: 503, y: 500 },
|
||||||
|
];
|
||||||
|
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]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("interruption mid-sweep returns 'interrupted' and does NOT restore", async () => {
|
||||||
|
const dev = new FakeDevice();
|
||||||
|
const start = { x: 500, y: 500 };
|
||||||
|
const pts = [
|
||||||
|
{ x: 501, y: 500 },
|
||||||
|
{ x: 502, y: 500 },
|
||||||
|
{ x: 503, y: 500 },
|
||||||
|
];
|
||||||
|
// 2nd getPosition call reports the user elsewhere.
|
||||||
|
dev.overrides.set(2, { x: 9, y: 9 });
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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), 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", () => {
|
||||||
|
test("a readback within tolerance is not treated as interruption", async () => {
|
||||||
|
const dev = new FakeDevice();
|
||||||
|
const start = { x: 500, y: 500 };
|
||||||
|
const pts = [
|
||||||
|
{ x: 510, y: 500 },
|
||||||
|
{ x: 520, y: 500 },
|
||||||
|
];
|
||||||
|
// Each in-sweep readback lands 2px off the commanded point (OS jitter,
|
||||||
|
// 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), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf());
|
||||||
|
expect(outcome).toBe("completed");
|
||||||
|
expect(dev.commanded).toEqual([...pts, start]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a readback beyond tolerance is treated as interruption", async () => {
|
||||||
|
const dev = new FakeDevice();
|
||||||
|
const start = { x: 500, y: 500 };
|
||||||
|
const pts = [
|
||||||
|
{ x: 510, y: 500 },
|
||||||
|
{ x: 520, y: 500 },
|
||||||
|
];
|
||||||
|
// 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), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf());
|
||||||
|
expect(outcome).toBe("interrupted");
|
||||||
|
expect(dev.commanded).toEqual([pts[0]!]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("executePath — rounding & pacing", () => {
|
||||||
|
test("fractional targets are rounded and do not read as interruption", async () => {
|
||||||
|
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), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf());
|
||||||
|
expect(outcome).toBe("completed");
|
||||||
|
expect(dev.commanded[0]).toEqual({ x: 10, y: 21 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sleeps once per step with the configured stepDelay", async () => {
|
||||||
|
const dev = new FakeDevice();
|
||||||
|
const pts = [
|
||||||
|
{ x: 501, y: 500 },
|
||||||
|
{ x: 502, y: 500 },
|
||||||
|
];
|
||||||
|
await executePath(fixed(pts), ctxOf({ x: 500, y: 500 }, dev.w, dev.h), dev, noopLog, cfgOf({ stepDelay: 7 }));
|
||||||
|
expect(dev.sleeps).toEqual([7, 7]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
/**
|
||||||
|
* keeper.test.ts
|
||||||
|
* --------------
|
||||||
|
* Loop-level tests for `runKeeper` driven by a fake `Device`. The loop runs
|
||||||
|
* forever in production, so the fake stops it by throwing a sentinel from
|
||||||
|
* `sleep` once a call budget is exhausted; the test then inspects the
|
||||||
|
* commands that were issued.
|
||||||
|
*
|
||||||
|
* These assert the two behaviors that matter: an idle cursor triggers a
|
||||||
|
* synthetic sweep, and a moving cursor never does.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
|
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 {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fake device that echoes the last commanded point (so a synthetic sweep
|
||||||
|
* completes cleanly) and aborts the loop after `budget` sleeps.
|
||||||
|
*
|
||||||
|
* `positions`, when provided, is consumed one entry per `getPosition` call
|
||||||
|
* to simulate real user movement; otherwise the cursor is reported as
|
||||||
|
* stationary at `initial`/the last commanded point (idle).
|
||||||
|
*/
|
||||||
|
class LoopDevice implements Device {
|
||||||
|
commanded: Point[] = [];
|
||||||
|
sleepCount = 0;
|
||||||
|
constructor(
|
||||||
|
public budget: number,
|
||||||
|
public initial: Point = { x: 100, y: 100 },
|
||||||
|
private positions: Point[] | null = null,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getPosition(): Promise<Point> {
|
||||||
|
if (this.positions) return this.positions.shift() ?? this.initial;
|
||||||
|
return this.commanded.at(-1) ?? this.initial;
|
||||||
|
}
|
||||||
|
async setPosition(p: Point): Promise<void> {
|
||||||
|
this.commanded.push(p);
|
||||||
|
}
|
||||||
|
async width(): Promise<number> {
|
||||||
|
return 1920;
|
||||||
|
}
|
||||||
|
async height(): Promise<number> {
|
||||||
|
return 1080;
|
||||||
|
}
|
||||||
|
async sleep(): Promise<void> {
|
||||||
|
if (++this.sleepCount > this.budget) throw new StopError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const quietConfig = (overrides: Partial<Config>): Config => ({
|
||||||
|
...DEFAULT_CONFIG,
|
||||||
|
verbose: false,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function runUntilStop(
|
||||||
|
config: Config,
|
||||||
|
device: Device,
|
||||||
|
pickRandom?: () => MovementStrategy,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await runKeeper(config, device, pickRandom);
|
||||||
|
} catch (err) {
|
||||||
|
if (!(err instanceof StopError)) throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("runKeeper", () => {
|
||||||
|
test("fires a synthetic sweep once the cursor has been idle long enough", async () => {
|
||||||
|
// moveInterval 0 => any elapsed time counts as "idle long enough",
|
||||||
|
// so the first idle check triggers a sweep deterministically.
|
||||||
|
const dev = new LoopDevice(50);
|
||||||
|
await runUntilStop(quietConfig({ moveInterval: 0, pattern: "line" }), dev);
|
||||||
|
// A sweep issued setPosition commands (the sweep is interrupted by the
|
||||||
|
// sleep budget before it finishes, but many steps land); an idle loop
|
||||||
|
// with no sweep would have issued none.
|
||||||
|
expect(dev.commanded.length).toBeGreaterThanOrEqual(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not fire while the cursor keeps moving", async () => {
|
||||||
|
// Every poll reports a new position => always "real activity", so the
|
||||||
|
// idleness clock keeps resetting and no sweep ever fires.
|
||||||
|
const moving: Point[] = Array.from({ length: 40 }, (_, i) => ({ x: i, y: i }));
|
||||||
|
const dev = new LoopDevice(20, { x: 0, y: 0 }, moving);
|
||||||
|
await runUntilStop(quietConfig({ moveInterval: 0, pattern: "line" }), dev);
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
/**
|
||||||
|
* strategies.test.ts
|
||||||
|
* ------------------
|
||||||
|
* Unit tests for the pure movement-pattern generators. No nut.js, no
|
||||||
|
* device: each strategy is exercised by feeding a `MoveContext` (with a
|
||||||
|
* deterministic `rng` where randomness matters) and asserting on the
|
||||||
|
* emitted points.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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,
|
||||||
|
} from "../src/strategies.ts";
|
||||||
|
|
||||||
|
/** Deterministic PRNG so stochastic strategies are reproducible under test. */
|
||||||
|
function mulberry32(seed: number): () => number {
|
||||||
|
let a = seed;
|
||||||
|
return (): number => {
|
||||||
|
a |= 0;
|
||||||
|
a = (a + 0x6d2b79f5) | 0;
|
||||||
|
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||||
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
height?: number;
|
||||||
|
rng?: () => number;
|
||||||
|
}): MoveContext {
|
||||||
|
return {
|
||||||
|
start: overrides.start ?? { x: 500, y: 500 },
|
||||||
|
width: overrides.width ?? 1920,
|
||||||
|
height: overrides.height ?? 1080,
|
||||||
|
rng: overrides.rng ?? Math.random,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("line", () => {
|
||||||
|
test("emits its full 250-step, 250px sweep along +x with no vertical drift (preserved default)", () => {
|
||||||
|
const pts = [...line.path(ctxOf({ start: { x: 500, y: 500 } }))];
|
||||||
|
expect(pts.length).toBe(250);
|
||||||
|
expect(pts.every((p) => p.y === 500)).toBe(true);
|
||||||
|
// 1px per step: 501..750.
|
||||||
|
expect(pts[0]!.x).toBe(501);
|
||||||
|
expect(pts.at(-1)!.x).toBe(750);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reverses direction when there is no room to the right", () => {
|
||||||
|
const pts = [...line.path(ctxOf({ start: { x: 90, y: 10 }, width: 100 }))];
|
||||||
|
expect(pts[0]!.x).toBe(89);
|
||||||
|
// Heads left: each step decreases x by 1.
|
||||||
|
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", () => {
|
||||||
|
test("moves 1px on both axes toward the roomy corner for 250 steps", () => {
|
||||||
|
const pts = [...diagonal.path(ctxOf({ start: { x: 500, y: 500 } }))];
|
||||||
|
expect(pts.length).toBe(250);
|
||||||
|
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", () => {
|
||||||
|
test("stays within its fixed radius of start across its fixed step count", () => {
|
||||||
|
const radius = 30; // JITTER_RADIUS
|
||||||
|
const start = { x: 500, y: 500 };
|
||||||
|
const pts = [...jitter.path(ctxOf({ start, rng: mulberry32(1) }))];
|
||||||
|
expect(pts.length).toBe(80); // JITTER_STEPS
|
||||||
|
for (const p of pts) {
|
||||||
|
expect(Math.hypot(p.x - start.x, p.y - start.y)).toBeLessThanOrEqual(radius + 1e-9);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("walk", () => {
|
||||||
|
test("is a cumulative walk; a 0.5-constant rng yields zero net drift", () => {
|
||||||
|
const start = { x: 400, y: 300 };
|
||||||
|
const pts = [...walk.path(ctxOf({ start, rng: () => 0.5 }))];
|
||||||
|
expect(pts.length).toBe(200); // WALK_STEPS
|
||||||
|
// (0.5*2 - 1) === 0, so every step delta is zero.
|
||||||
|
expect(pts.every((p) => p.x === start.x && p.y === start.y)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accumulates finite deltas step over step", () => {
|
||||||
|
const pts = [...walk.path(ctxOf({ rng: mulberry32(42) }))];
|
||||||
|
expect(pts.length).toBe(200);
|
||||||
|
expect(pts.every((p) => Number.isFinite(p.x) && Number.isFinite(p.y))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("arc", () => {
|
||||||
|
test("emits its fixed step count of finite points, deterministic under a fixed seed", () => {
|
||||||
|
const pts = [...arc.path(ctxOf({ rng: mulberry32(7) }))];
|
||||||
|
expect(pts.length).toBe(120); // ARC_STEPS
|
||||||
|
expect(pts.every((p) => Number.isFinite(p.x) && Number.isFinite(p.y))).toBe(true);
|
||||||
|
// Same seed -> same endpoint (t = 1 at the final step is a stable point).
|
||||||
|
const again = [...arc.path(ctxOf({ rng: mulberry32(7) }))];
|
||||||
|
expect(pts.at(-1)).toEqual(again.at(-1)!);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("figureEight", () => {
|
||||||
|
test("returns to the start point after one full period", () => {
|
||||||
|
const start = { x: 600, y: 400 };
|
||||||
|
const pts = [...figureEight.path(ctxOf({ start }))];
|
||||||
|
expect(pts.length).toBe(90); // FIG8_STEPS
|
||||||
|
expect(pts.at(-1)!.x).toBeCloseTo(start.x, 6);
|
||||||
|
expect(pts.at(-1)!.y).toBeCloseTo(start.y, 6);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("registry", () => {
|
||||||
|
test("PATTERN_NAMES matches the registry keys and includes the default", () => {
|
||||||
|
expect(new Set(PATTERN_NAMES)).toEqual(new Set(Object.keys(STRATEGIES)));
|
||||||
|
expect(PATTERN_NAMES).toContain("line");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isPatternName accepts registered names and rejects others", () => {
|
||||||
|
for (const name of PATTERN_NAMES) expect(isPatternName(name)).toBe(true);
|
||||||
|
expect(isPatternName("zigzag")).toBe(false);
|
||||||
|
expect(isPatternName("")).toBe(false);
|
||||||
|
// Must not be fooled by inherited Object.prototype members.
|
||||||
|
expect(isPatternName("toString")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolvePatternName maps every canonical name to itself", () => {
|
||||||
|
for (const name of PATTERN_NAMES) expect(resolvePatternName(name)).toBe(name);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolvePatternName normalizes case and separators", () => {
|
||||||
|
expect(resolvePatternName("figure-eight")).toBe("figureEight");
|
||||||
|
expect(resolvePatternName("figure_eight")).toBe("figureEight");
|
||||||
|
expect(resolvePatternName("FIGUREEIGHT")).toBe("figureEight");
|
||||||
|
expect(resolvePatternName(" Figure Eight ")).toBe("figureEight");
|
||||||
|
expect(resolvePatternName("LINE")).toBe("line");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolvePatternName returns null for unknown or prototype names", () => {
|
||||||
|
expect(resolvePatternName("zigzag")).toBeNull();
|
||||||
|
expect(resolvePatternName("")).toBeNull();
|
||||||
|
expect(resolvePatternName("toString")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("random (the sentinel)", () => {
|
||||||
|
test("is selectable but is not a registry entry", () => {
|
||||||
|
// The whole design rests on this: `random` is a user-facing choice
|
||||||
|
// with no path of its own, so the registry must not contain it and
|
||||||
|
// `STRATEGIES[RANDOM_PATTERN]` must not resolve.
|
||||||
|
expect(PATTERN_NAMES).not.toContain(RANDOM_PATTERN);
|
||||||
|
expect(STRATEGIES[RANDOM_PATTERN]).toBeUndefined();
|
||||||
|
expect(isPatternName(RANDOM_PATTERN)).toBe(false);
|
||||||
|
expect(isSelectablePattern(RANDOM_PATTERN)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("SELECTABLE_PATTERN_NAMES is the registry plus the sentinel", () => {
|
||||||
|
expect(new Set(SELECTABLE_PATTERN_NAMES)).toEqual(
|
||||||
|
new Set([...PATTERN_NAMES, RANDOM_PATTERN]),
|
||||||
|
);
|
||||||
|
expect(SELECTABLE_PATTERN_NAMES.length).toBe(PATTERN_NAMES.length + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isSelectablePattern still accepts every real strategy and rejects junk", () => {
|
||||||
|
for (const name of PATTERN_NAMES) expect(isSelectablePattern(name)).toBe(true);
|
||||||
|
expect(isSelectablePattern("zigzag")).toBe(false);
|
||||||
|
expect(isSelectablePattern("toString")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolvePatternName normalizes the sentinel like any other name", () => {
|
||||||
|
expect(resolvePatternName("random")).toBe(RANDOM_PATTERN);
|
||||||
|
expect(resolvePatternName("RANDOM")).toBe(RANDOM_PATTERN);
|
||||||
|
expect(resolvePatternName(" Random ")).toBe(RANDOM_PATTERN);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createRandomPicker", () => {
|
||||||
|
test("only ever returns registered strategies", () => {
|
||||||
|
const pick = createRandomPicker(mulberry32(7));
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
const s = pick();
|
||||||
|
expect(PATTERN_NAMES).toContain(s.name);
|
||||||
|
expect(STRATEGIES[s.name]).toBe(s);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("never returns the same pattern twice in a row", () => {
|
||||||
|
const pick = createRandomPicker(mulberry32(1234));
|
||||||
|
let prev: string = pick().name;
|
||||||
|
for (let i = 0; i < 500; i++) {
|
||||||
|
const name: string = pick().name;
|
||||||
|
expect(name).not.toBe(prev);
|
||||||
|
prev = name;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("alternates deterministically under a constant rng of 0", () => {
|
||||||
|
// rng()=0 always takes the first entry of the *remaining* pool, and
|
||||||
|
// the pool is the registry minus the previous pick — so this pins the
|
||||||
|
// exclusion logic exactly: first name, second name, first name, ...
|
||||||
|
const pick = createRandomPicker(() => 0);
|
||||||
|
const [first, second] = PATTERN_NAMES as [string, string];
|
||||||
|
expect(pick().name).toBe(first);
|
||||||
|
expect(pick().name).toBe(second);
|
||||||
|
expect(pick().name).toBe(first);
|
||||||
|
expect(pick().name).toBe(second);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stays in range for an rng that returns exactly 1", () => {
|
||||||
|
// Outside the documented [0, 1) contract; must clamp rather than
|
||||||
|
// index off the end and throw.
|
||||||
|
const pick = createRandomPicker(() => 1);
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
expect(PATTERN_NAMES).toContain(pick().name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("is reproducible for a given seed, and independent across pickers", () => {
|
||||||
|
const a = createRandomPicker(mulberry32(99));
|
||||||
|
const b = createRandomPicker(mulberry32(99));
|
||||||
|
const seqA = Array.from({ length: 20 }, () => a().name);
|
||||||
|
const seqB = Array.from({ length: 20 }, () => b().name);
|
||||||
|
expect(seqA).toEqual(seqB);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("each picker carries its own no-repeat memory", () => {
|
||||||
|
// The memory is per-closure, not module state: a fresh picker has no
|
||||||
|
// notion of what a previous one returned, so it may open with the
|
||||||
|
// same pattern.
|
||||||
|
const a = createRandomPicker(() => 0);
|
||||||
|
const b = createRandomPicker(() => 0);
|
||||||
|
expect(a().name).toBe(b().name);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("covers the whole registry over enough draws", () => {
|
||||||
|
// Guards against the exclusion logic accidentally pinning the pool to
|
||||||
|
// a subset (e.g. filtering by index rather than by name).
|
||||||
|
const pick = createRandomPicker(mulberry32(2024));
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (let i = 0; i < 400; i++) seen.add(pick().name);
|
||||||
|
expect(seen).toEqual(new Set(PATTERN_NAMES));
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user