Add pluggable movement strategies (v1.3.0)

Turn the hardcoded straight-line sweep into a strategy system behind three
seams so new patterns are easy to add and, for the first time, testable
without nut.js or a real screen:

- src/device.ts:     injectable Device seam over nut.js (autoDelayMs lives
                     here now); the only module that touches the native lib.
- src/strategies.ts: pure per-pattern path generators + registry + lenient
                     name resolution. Ships line, diagonal, jitter, walk,
                     arc, figureEight.
- src/executor.ts:   single executePath driver owning bounds policy
                     (abort/clamp/reflect), pacing, interrupt detection, and
                     restore-on-clean.

keeper.ts's simulateActivity now selects a strategy and delegates to the
executor; the default `line` pattern is byte-for-byte the previous behavior.

New config surface, layered CLI > file > default with strict validation:
- -p/--pattern <name>   movement strategy (names matched case/-/_-insensitive)
- -s/--step-size <px>   pixels per step; stepCount is now a step *count*

Robustness for the new edge-seeking patterns: interrupt detection compares
against the last commanded (rounded) point with a 2px tolerance, and
clamp/reflect stay a couple pixels off the screen edge, so sub-pixel cursor
placement on scaled/multi-monitor displays isn't misread as user activity.
jitter's radius scales with sweep length so it moves at the default stepSize.

Tests: new suites for strategies, the executor (all bounds policies,
rounding, interrupt, tolerance, pacing), and the keeper loop; config and
configFile suites extended for pattern/stepSize. editor.test.ts moved to
tests/ for consistency. 64 pass.
This commit is contained in:
2026-08-13 15:36:22 -05:00
parent 7777b16540
commit db3310c247
18 changed files with 1319 additions and 153 deletions
+42
View File
@@ -5,6 +5,47 @@ All notable changes to `move` are documented here.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.3.0] - 2026-08-06
### 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`.
- `-s, --step-size <pixels>` decouples pixels-per-step from `stepCount`
(which is now a step *count*, not a pixel distance). Default `1`.
- 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.
- `jitter`'s radius now scales with the sweep length (like the other
patterns) instead of `stepSize` alone, so it produces real cursor motion
at the default `stepSize` of 1 rather than hovering within a 2px radius.
- 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.
## [1.2.0] - 2026-06-17 ## [1.2.0] - 2026-06-17
### Added ### Added
@@ -67,6 +108,7 @@ Initial release.
- Source split into `src/{move,cli,config,keeper}.ts`. - Source split into `src/{move,cli,config,keeper}.ts`.
- `bin` entry + shebang so `bun link` registers `move` globally. - `bin` entry + shebang so `bun link` registers `move` globally.
[1.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.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.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.1.0]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.0.1...v1.1.0
+78 -18
View File
@@ -90,7 +90,11 @@ 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. -n, --step-count <count> Steps per sweep. Default: 250.
-s, --step-size <pixels> Pixels moved per step. Default: 1.
-p, --pattern <name> Movement strategy. Default: line.
One of: line, diagonal, jitter, walk, arc,
figureEight.
-V, --verbose Log every sweep, interrupt, and bounds event -V, --verbose Log every sweep, interrupt, and bounds event
(default prints only the startup banner). (default prints only the startup banner).
@@ -147,6 +151,8 @@ doesn't set.
"checkInterval": 10, "checkInterval": 10,
"stepDelay": 50, "stepDelay": 50,
"stepCount": 250, "stepCount": 250,
"stepSize": 1,
"pattern": "line",
"verbose": false "verbose": false
} }
``` ```
@@ -154,7 +160,8 @@ doesn't set.
All keys are optional; supply only the ones you want to override. Keys All keys are optional; supply only the ones you want to override. Keys
and units mirror the CLI flags exactly: `moveInterval` and and units mirror the CLI flags exactly: `moveInterval` and
`checkInterval` are seconds, `stepDelay` is milliseconds, `stepCount` is `checkInterval` are seconds, `stepDelay` is milliseconds, `stepCount` is
pixels, `verbose` is a boolean. a step count, `stepSize` is pixels-per-step, `pattern` is a movement
strategy name, `verbose` is a boolean.
### Editing ### Editing
@@ -181,6 +188,9 @@ The loader is strict:
- Root must be a JSON object. - Root must be a JSON object.
- Unknown keys are rejected (catches typos like `"movInterval"`). - Unknown keys are rejected (catches typos like `"movInterval"`).
- Numeric values must be finite and strictly positive. - Numeric values must be finite and strictly positive.
- `pattern` must resolve to a registered strategy name. Matching ignores
case and separators (`-`, `_`, spaces), so `figure-eight` and `figureEight`
are equivalent.
- `verbose` must be a boolean. - `verbose` 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
@@ -195,7 +205,7 @@ file with `--config`.
## How it works ## How it works
The source lives under `src/`, split into an entry point plus four logic 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 +217,22 @@ 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 and name validation. Adding a pattern is one pure function.
- `src/executor.ts` is the single `executePath` driver: it rounds targets,
applies the strategy's bounds policy, paces steps, detects real-user
interruption, and restores the cursor on a clean sweep.
Defaults live in `src/config.ts` as `DEFAULT_CONFIG`: Defaults live in `src/config.ts` as `DEFAULT_CONFIG`:
@@ -216,7 +241,9 @@ 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. | | `stepCount` | `250` | `-n`, `--step-count` | Number of steps per sweep. |
| `stepSize` | `1` | `-s`, `--step-size` | Pixels moved per step. |
| `pattern` | `"line"` | `-p`, `--pattern` | Movement strategy name (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 +258,57 @@ 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: 2. It hands the strategy and context to `executePath`, which drives the
- Compute and bounds-check the next target. sweep. For each target the strategy yields:
- Round to whole pixels and apply the strategy's bounds policy
(`abort` / `clamp` / `reflect`) to keep it on-screen.
- 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.
Comparing against the last commanded (rounded) point — not the strategy's
ideal, possibly fractional target — is what lets curved and stochastic
patterns run without every rounded step looking like user activity. The
comparison also allows a small (2px) tolerance, and the `clamp`/`reflect`
patterns stay a couple of pixels off the screen edge, so sub-pixel cursor
placement on scaled or multi-monitor displays isn't misread as the user
grabbing the mouse. `line` uses the `abort` policy and is unaffected.
### Movement strategies
`config.pattern` selects one of the generators in `src/strategies.ts`:
| Name | Motion | Bounds |
| ------------- | ------------------------------------------------------------- | --------- |
| `line` | Straight horizontal sweep (the original behavior). | `abort` |
| `diagonal` | Straight line on both axes toward the roomiest corner. | `clamp` |
| `jitter` | Small random hops within a local radius that scales with reach. | `clamp` |
| `walk` | Cumulative random walk; bounces off the screen edges. | `reflect` |
| `arc` | Smooth quadratic-Bézier curve to a random on-screen point. | `clamp` |
| `figureEight` | Traces a figure-eight (lemniscate) and returns to the start. | `clamp` |
`stepCount` is the number of steps; `stepSize` is how many pixels each step
travels (so total reach is `stepCount * stepSize`). With the default
`stepSize` of 1, `line` produces the identical 1px-per-step path it always
has. To add a pattern, write one pure generator and register it — the
executor supplies bounds, 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 +353,10 @@ 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, and name validation. |
| `src/executor.ts` | `executePath` driver: bounds policy, pacing, interrupt detection, restore. |
| `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. |
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "move", "name": "move",
"version": "1.2.0", "version": "1.3.0",
"private": true, "private": true,
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"type": "module", "type": "module",
+2
View File
@@ -3,5 +3,7 @@
"checkInterval": 10, "checkInterval": 10,
"stepDelay": 50, "stepDelay": 50,
"stepCount": 250, "stepCount": 250,
"stepSize": 1,
"pattern": "line",
"verbose": false "verbose": false
} }
+31 -3
View File
@@ -16,7 +16,9 @@
* -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). * -n, --step-count Steps per sweep (count).
* -s, --step-size Pixels moved per step.
* -p, --pattern Movement strategy name (see strategies.ts).
* -V, --verbose Enable per-sweep / interrupt / bounds logging. * -V, --verbose Enable per-sweep / interrupt / bounds logging.
* (`-V` capital because `-v` is `--version`.) * (`-V` capital because `-v` is `--version`.)
* *
@@ -31,6 +33,7 @@ import { parseArgs } from "node:util";
import { DEFAULT_CONFIG, defaultConfigPath } from "./config.ts"; import { DEFAULT_CONFIG, defaultConfigPath } from "./config.ts";
import { CliError } from "./errors.ts"; import { CliError } from "./errors.ts";
import { PATTERN_NAMES, resolvePatternName } from "./strategies.ts";
/** /**
* Result of `parseCliArgs`. Numeric fields are `undefined` when the user * Result of `parseCliArgs`. Numeric fields are `undefined` when the user
@@ -45,7 +48,10 @@ 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 stepCount: number | undefined; // count
stepSize: number | undefined; // pixels
/** Movement strategy name, validated against the registry. */
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
@@ -69,6 +75,20 @@ function parsePositiveNumber(name: string, raw: string | undefined): number | un
return n; return n;
} }
/**
* Validate a CLI-supplied movement-pattern name. Returns `undefined` when
* the flag was not supplied; throws `CliError` naming the valid patterns
* when the value isn't a registered strategy.
*/
function parsePatternName(raw: string | undefined): string | undefined {
if (raw === undefined) return undefined;
const canonical: string | null = resolvePatternName(raw);
if (canonical === null) {
throw new CliError(`invalid value for --pattern: '${raw}' (valid: ${PATTERN_NAMES.join(", ")})`);
}
return canonical;
}
/** /**
* Parse `process.argv` into a typed `ParsedCliArgs`. Uses Node's built-in * Parse `process.argv` into a typed `ParsedCliArgs`. Uses Node's built-in
* `parseArgs` in strict mode so unknown flags and missing values surface * `parseArgs` in strict mode so unknown flags and missing values surface
@@ -88,6 +108,8 @@ export function parseCliArgs(): ParsedCliArgs {
"check-interval": { type: "string", short: "c" }, "check-interval": { type: "string", short: "c" },
"step-delay": { type: "string", short: "d" }, "step-delay": { type: "string", short: "d" },
"step-count": { type: "string", short: "n" }, "step-count": { type: "string", short: "n" },
"step-size": { type: "string", short: "s" },
pattern: { type: "string", short: "p" },
verbose: { type: "boolean", short: "V" }, verbose: { type: "boolean", short: "V" },
}, },
strict: true, strict: true,
@@ -110,6 +132,8 @@ export function parseCliArgs(): ParsedCliArgs {
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), stepCount: parsePositiveNumber("step-count", values["step-count"] as string | undefined),
stepSize: parsePositiveNumber("step-size", values["step-size"] as string | undefined),
pattern: parsePatternName(values.pattern as string | undefined),
verbose: values.verbose === true ? true : undefined, verbose: values.verbose === true ? true : undefined,
}; };
} }
@@ -152,7 +176,10 @@ 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}. -n, --step-count <count> Steps per sweep. Default: ${DEFAULT_CONFIG.stepCount}.
-s, --step-size <pixels> Pixels moved per step. Default: ${DEFAULT_CONFIG.stepSize}.
-p, --pattern <name> Movement strategy. Default: ${DEFAULT_CONFIG.pattern}.
One of: ${PATTERN_NAMES.join(", ")}.
-V, --verbose Log every sweep, interrupt, and bounds event -V, --verbose Log every sweep, interrupt, and bounds event
(default prints only the startup banner). (default prints only the startup banner).
@@ -162,6 +189,7 @@ 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 --step-size 3
move --config ~/myprofile.json move --config ~/myprofile.json
`); `);
} }
+27 -6
View File
@@ -22,6 +22,7 @@
import { join } from "node:path"; import { join } from "node:path";
import { CliError } from "./errors.ts"; import { CliError } from "./errors.ts";
import { isPatternName, type PatternName } from "./strategies.ts";
// 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
@@ -41,7 +42,12 @@ 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. * - `stepCount` — number of steps in a single sweep. Count.
* - `stepSize` — pixels moved per step. Decouples "how many steps"
* from "how far each step travels" so non-linear
* patterns can span meaningful distances. Pixels.
* - `pattern` — name of the movement strategy to use (see
* `strategies.ts`; e.g. `line`, `walk`, `arc`).
* - `verbose` — whether per-sweep / interrupt / bounds events are * - `verbose` — whether per-sweep / interrupt / bounds events are
* logged. The startup banner is always printed. * logged. The startup banner is always printed.
*/ */
@@ -50,6 +56,8 @@ export interface Config {
readonly checkInterval: number; readonly checkInterval: number;
readonly stepDelay: number; readonly stepDelay: number;
readonly stepCount: number; readonly stepCount: number;
readonly stepSize: number;
readonly pattern: PatternName;
readonly verbose: boolean; readonly verbose: boolean;
} }
@@ -63,7 +71,9 @@ interface SeedShape {
moveInterval: number; // seconds moveInterval: number; // seconds
checkInterval: number; // seconds checkInterval: number; // seconds
stepDelay: number; // milliseconds stepDelay: number; // milliseconds
stepCount: number; // pixels stepCount: number; // count
stepSize: number; // pixels
pattern: string; // strategy name
verbose: boolean; verbose: boolean;
} }
@@ -72,12 +82,15 @@ 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", "stepCount", "stepSize"] 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" || !isPatternName(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)})`);
} }
@@ -98,6 +111,8 @@ export const DEFAULT_CONFIG: Config = {
checkInterval: seed.checkInterval * 1000, checkInterval: seed.checkInterval * 1000,
stepDelay: seed.stepDelay, stepDelay: seed.stepDelay,
stepCount: seed.stepCount, stepCount: seed.stepCount,
stepSize: seed.stepSize,
pattern: seed.pattern,
verbose: seed.verbose, verbose: seed.verbose,
}; };
@@ -110,10 +125,12 @@ 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 * stepCount — count
* stepSize — 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
@@ -126,6 +143,8 @@ export interface ConfigOverrides {
readonly checkInterval: number | undefined; readonly checkInterval: number | undefined;
readonly stepDelay: number | undefined; readonly stepDelay: number | undefined;
readonly stepCount: number | undefined; readonly stepCount: number | undefined;
readonly stepSize: number | undefined;
readonly pattern: string | undefined;
readonly verbose: boolean | undefined; readonly verbose: boolean | undefined;
} }
@@ -192,6 +211,8 @@ export function resolveConfig(file: ConfigOverrides | null, cli: ConfigOverrides
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), stepCount: pickRaw(cli.stepCount, file?.stepCount, DEFAULT_CONFIG.stepCount),
stepSize: pickRaw(cli.stepSize, file?.stepSize, DEFAULT_CONFIG.stepSize),
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),
}; };
} }
+24 -1
View File
@@ -10,7 +10,9 @@
* 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 * stepCount number count, positive
* stepSize number pixels, positive
* pattern string a registered strategy name
* verbose boolean * verbose boolean
* *
* Unknown keys, wrong types, and non-positive numerics are rejected with a * Unknown keys, wrong types, and non-positive numerics are rejected with a
@@ -29,12 +31,15 @@ import { existsSync, readFileSync, statSync } from "node:fs";
import { defaultConfigPath, type ConfigOverrides } from "./config.ts"; import { defaultConfigPath, type ConfigOverrides } from "./config.ts";
import { CliError } from "./errors.ts"; import { CliError } from "./errors.ts";
import { PATTERN_NAMES, resolvePatternName } from "./strategies.ts";
const ALLOWED_KEYS: ReadonlySet<string> = new Set<string>([ const ALLOWED_KEYS: ReadonlySet<string> = new Set<string>([
"moveInterval", "moveInterval",
"checkInterval", "checkInterval",
"stepDelay", "stepDelay",
"stepCount", "stepCount",
"stepSize",
"pattern",
"verbose", "verbose",
]); ]);
@@ -60,6 +65,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: ${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.
@@ -130,6 +145,14 @@ export function loadConfigFile(explicitPath: string | undefined): ConfigOverride
"stepCount" in parsed "stepCount" in parsed
? requirePositiveNumber("stepCount", parsed.stepCount, path) ? requirePositiveNumber("stepCount", parsed.stepCount, path)
: undefined, : undefined,
stepSize:
"stepSize" in parsed
? requirePositiveNumber("stepSize", parsed.stepSize, path)
: undefined,
pattern:
"pattern" in parsed
? requirePatternName("pattern", parsed.pattern, path)
: undefined,
verbose: verbose:
"verbose" in parsed "verbose" in parsed
? requireBoolean("verbose", parsed.verbose, path) ? requireBoolean("verbose", parsed.verbose, path)
+89
View File
@@ -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,
};
}
+192
View File
@@ -0,0 +1,192 @@
/**
* 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 per the strategy's `BoundsPolicy`,
* - command the cursor and pace it with `stepDelay`,
* - detect real-user interruption after each step,
* - restore the cursor to the origin on a clean run.
*
* Writing this once means new patterns inherit correct real-user-wins,
* bounds, and restore semantics for free. It's pure with respect to I/O —
* 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 { Device, Point } from "./device.ts";
import type { BoundsPolicy, 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; aborted without
* snapping back.
* - `aborted` — an `abort`-policy target went out of bounds.
*/
export type SweepOutcome = "completed" | "interrupted" | "aborted";
/**
* 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 `clamp` / `reflect` travel range from each screen edge.
* Keeps edge-seeking patterns off the literal first/last pixel, where DPI
* scaling and multi-monitor boundaries most often make the OS place the
* cursor a hair off what we commanded (which the readback check would then
* misread as the user). `abort` (used by `line`) is deliberately left on the
* full `[0, max - 1]` range, so its behavior is unchanged.
*/
const EDGE_MARGIN: number = 2;
/**
* The inclusive `[lo, hi]` integer range an axis of length `max` may travel
* under the `clamp` / `reflect` policies: `[0, max - 1]` inset by
* `EDGE_MARGIN` on each side. Screens too small to inset fall back to the
* full range so the math never inverts.
*/
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 };
}
/** Round to whole pixels and clamp into the inset travel range for `max`. */
function clampInt(v: number, max: number): number {
const { lo, hi } = travelRange(max);
const r: number = Math.round(v);
if (r < lo) return lo;
if (r > hi) return hi;
return r;
}
/**
* Mirror `v` into the inset travel range for `max` as a triangle wave, so
* values past an edge bounce back inside instead of clamping flat against it.
*/
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 target to an on-screen integer pixel under the
* given policy. Returns `null` when policy is `abort` and the (rounded)
* target lies outside the screen — the signal to stop the sweep.
*/
function resolveTarget(
policy: BoundsPolicy,
p: Point,
width: number,
height: number,
): Point | null {
if (policy === "reflect") {
return { x: reflectInt(p.x, width), y: reflectInt(p.y, height) };
}
if (policy === "clamp") {
return { x: clampInt(p.x, width), y: clampInt(p.y, height) };
}
// abort: round, then reject anything off-screen.
const x: number = Math.round(p.x);
const y: number = Math.round(p.y);
if (x < 0 || x >= width || y < 0 || y >= height) return null;
return { x, y };
}
/**
* Format the current local time as `HH:MM:SS` for log lines.
*/
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 (bounds policy).
* An `abort`-policy out-of-bounds target ends the sweep (`aborted`).
* 2. Command the cursor there and sleep `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.
*/
export async function executePath(
strategy: MovementStrategy,
ctx: MoveContext,
device: Device,
log: Logger,
): Promise<SweepOutcome> {
const { start, width, height, config } = ctx;
log.event(`Simulating activity (${strategy.name}) at ${timestamp()}...`);
for (const target of strategy.path(ctx)) {
const point: Point | null = resolveTarget(strategy.bounds, target, width, height);
if (point === null) {
log.event(`Out of bounds at ${timestamp()}; aborting simulation.`);
return "aborted";
}
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. Abort
// without snapping back, so we don't yank it from under the user.
//
// The comparison allows a small tolerance rather than demanding an
// exact match: on scaled (fractional-DPI) or multi-monitor setups
// the OS can place the cursor a pixel off the coordinate we
// commanded, and the edge-seeking patterns (clamp/reflect/arc)
// reach exactly the coordinates where that's most likely. A real
// user moves far more than a couple of pixels, so this doesn't
// meaningfully weaken real-user-wins.
log.event(`User activity detected at ${timestamp()}; aborting simulation.`);
return "interrupted";
}
}
await device.setPosition({ x: Math.round(start.x), y: Math.round(start.y) });
log.event("Mouse moved.");
return "completed";
}
+56 -120
View File
@@ -1,63 +1,38 @@
/** /**
* 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 (bounds, 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 / bounds lines are gated by `config.verbose`
* so the default is quiet. Errors stay on `console.error` (unconditional, * (see `makeLogger`). Errors stay on `console.error`, raised by the
* raised by the entry point on unhandled rejection). * entry 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 } from "./executor.ts";
import { DEFAULT_PATTERN, STRATEGIES, type MoveContext } 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 {
@@ -73,61 +48,22 @@ function makeLogger(verbose: boolean): Logger {
/** /**
* Perform a single synthetic mouse-activity sweep. * Perform a single synthetic mouse-activity sweep.
* *
* Behavior: * Snapshots the cursor and screen (re-read every call so monitor changes
* 1. Snapshot the starting cursor position. * are handled), selects the configured strategy from the registry, and
* 2. Read current screen dimensions (re-read every call so monitor changes * hands the resulting path to `executePath`, which owns bounds, pacing,
* are handled correctly). * interrupt detection, and restore-on-clean. An unknown `config.pattern`
* 3. Pick a horizontal direction (`dx`) that keeps the sweep on-screen: * falls back to the default strategy defensively; validation at the CLI /
* move right if there's room, otherwise move left. Vertical movement is * config-file boundary should prevent that from ever happening.
* currently disabled (`dy = 0`) but the framework is in place for
* richer patterns later.
* 4. For each of `config.stepCount` steps:
* - Compute the next target position.
* - Defensive bounds check (belt-and-braces given the `dx` choice).
* - Command nut.js to move the cursor there.
* - Sleep `config.stepDelay` — also the user's interrupt window.
* - Re-read the cursor. If it isn't where we put it, the user
* touched the mouse: log (verbose) and return early, leaving the
* cursor wherever the user moved it.
* 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
* synthetic activity as the user returning.
*/ */
async function simulateActivity(config: Config, log: Logger): Promise<void> { async function simulateActivity(config: Config, log: Logger, device: Device): Promise<void> {
const start: Point = await mouse.getPosition(); const start: Point = await device.getPosition();
const screenWidth: number = await screen.width(); const width: number = await device.width();
const screenHeight: number = await screen.height(); const height: number = await device.height();
const dx: number = start.x + config.stepCount < screenWidth ? 1 : -1;
const dy: number = 0;
log.event(`Simulating activity at ${timestamp()}...`); const strategy = STRATEGIES[config.pattern] ?? STRATEGIES[DEFAULT_PATTERN]!;
const ctx: MoveContext = { start, width, height, config, rng: Math.random };
for (let i: number = 1; i <= config.stepCount; i++) { await executePath(strategy, ctx, device, log);
const expected: Point = new Point(start.x + i * dx, start.y + i * dy);
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;
}
await mouse.setPosition(expected);
await sleep(config.stepDelay);
const current: Point = await mouse.getPosition();
if (current.x !== expected.x || current.y !== expected.y) {
// Cursor isn't where we put it -> real user activity. Abort
// without snapping back, so we don't yank the cursor out from
// under the user.
log.event(`User activity detected at ${timestamp()}; aborting simulation.`);
return;
}
}
await mouse.setPosition(start);
log.event("Mouse moved.");
} }
/** /**
@@ -145,32 +81,26 @@ 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.
*/ */
export async function runKeeper(config: Config): Promise<void> { export async function runKeeper(config: Config, device?: Device): Promise<void> {
// nut.js inserts a configurable delay after every action (default 100ms). const dev: Device = device ?? (await createNutDevice());
// That default would silently more-than-double the duration of every
// setPosition and getPosition call. We drive cadence ourselves via
// config.stepDelay, so disable nut.js's implicit delay entirely.
//
// 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 +111,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);
// `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();
} }
} }
} }
+2
View File
@@ -116,6 +116,8 @@ const cliOverrides: ConfigOverrides = {
checkInterval: cliArgs.checkInterval, checkInterval: cliArgs.checkInterval,
stepDelay: cliArgs.stepDelay, stepDelay: cliArgs.stepDelay,
stepCount: cliArgs.stepCount, stepCount: cliArgs.stepCount,
stepSize: cliArgs.stepSize,
pattern: cliArgs.pattern,
verbose: cliArgs.verbose, verbose: cliArgs.verbose,
}; };
+297
View File
@@ -0,0 +1,297 @@
/**
* 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; the executor rounds to whole
* pixels before commanding the cursor and applies the strategy's declared
* `BoundsPolicy` to keep everything on-screen.
*
* `Config` is imported type-only so that `config.ts` can import the value
* exports here (the registry, name list, and validator) without creating a
* runtime import cycle.
*/
import type { Point } from "./device.ts";
import type { Config } from "./config.ts";
/**
* How the executor keeps a strategy's targets on-screen:
*
* - `abort` — stop the sweep the moment a target falls out of bounds.
* Used by `line`, whose direction is chosen so this never
* actually fires; preserves the original straight-line
* semantics exactly.
* - `clamp` — pin each out-of-bounds coordinate to the nearest edge.
* - `reflect` — mirror out-of-bounds coordinates back inside, so a roaming
* pattern bounces off the screen edges instead of sticking.
*/
export type BoundsPolicy = "abort" | "clamp" | "reflect";
/**
* Everything a strategy needs to generate a path. Screen dimensions and the
* start point are snapshotted per sweep by the caller; `rng` is injected so
* 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;
/** Resolved runtime config (supplies `stepCount`, `stepSize`, ...). */
readonly config: Config;
/** 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.
* - `bounds` — how the executor confines this pattern to the screen.
* - `path` — pure generator of ideal (possibly fractional) targets,
* emitted in visiting order. Should not re-emit `start`.
*/
export interface MovementStrategy {
readonly name: string;
readonly bounds: BoundsPolicy;
path(ctx: MoveContext): Iterable<Point>;
}
/** Clamp `v` into the inclusive pixel range `[0, max - 1]`. */
function clamp(v: number, max: number): number {
if (v < 0) return 0;
if (v > max - 1) return max - 1;
return v;
}
/**
* Total pixel reach of a sweep: number of steps times pixels per step.
* Strategies use this to size themselves relative to the configured sweep
* length regardless of `stepSize`.
*/
function reachOf(config: Config): number {
return config.stepCount * config.stepSize;
}
/**
* `line` — the original behavior, preserved exactly.
*
* Pick a horizontal direction that keeps the sweep on-screen (right if
* there's room, else left); walk `stepCount` steps of `stepSize` pixels
* with no vertical movement. With the default `stepSize` of 1 this emits
* the identical integer 1px-per-step path the keeper used before the
* strategy refactor, which is why its bounds policy is `abort` (the
* direction choice guarantees it never triggers).
*/
export const line: MovementStrategy = {
name: "line",
bounds: "abort",
*path(ctx: MoveContext): Generator<Point> {
const { start, width, config } = ctx;
const dx: number = start.x + reachOf(config) < width ? 1 : -1;
for (let i = 1; i <= config.stepCount; i++) {
yield { x: start.x + i * dx * config.stepSize, 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.
*/
export const diagonal: MovementStrategy = {
name: "diagonal",
bounds: "clamp",
*path(ctx: MoveContext): Generator<Point> {
const { start, width, height, config } = ctx;
const reach: number = reachOf(config);
const dx: number = start.x + reach < width ? 1 : -1;
const dy: number = start.y + reach < height ? 1 : -1;
for (let i = 1; i <= config.stepCount; i++) {
yield {
x: start.x + i * dx * config.stepSize,
y: start.y + i * dy * config.stepSize,
};
}
},
};
/**
* `jitter` — many small random hops within a local radius of the start.
* Subtle "fidget" activity rather than a broad sweep. The radius scales off
* the sweep length (like the other patterns) so every hop is a real,
* distinct pixel move rather than rounding onto the pixel the cursor is
* already on. The executor restores the cursor to `start` after a clean
* run, so the net displacement is zero.
*/
export const jitter: MovementStrategy = {
name: "jitter",
bounds: "clamp",
*path(ctx: MoveContext): Generator<Point> {
const { start, config, rng } = ctx;
const radius: number = Math.max(4, reachOf(config) / 8);
for (let i = 1; i <= config.stepCount; i++) {
const angle: number = rng() * 2 * Math.PI;
const r: number = rng() * 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 `[-stepSize, +stepSize]`. The generator itself lets the
* position drift freely; the executor's `reflect` policy mirrors it back
* on-screen, so the cursor bounces off the edges instead of escaping.
*/
export const walk: MovementStrategy = {
name: "walk",
bounds: "reflect",
*path(ctx: MoveContext): Generator<Point> {
const { start, config, rng } = ctx;
let x: number = start.x;
let y: number = start.y;
for (let i = 1; i <= config.stepCount; i++) {
x += (rng() * 2 - 1) * config.stepSize;
y += (rng() * 2 - 1) * config.stepSize;
yield { x, y };
}
},
};
/**
* `arc` — a smooth quadratic Bézier curve from the start to a random
* on-screen endpoint roughly `reach` pixels away, bowed out by a control
* point offset perpendicular to the straight path. Produces natural,
* hand-like curved motion.
*/
export const arc: MovementStrategy = {
name: "arc",
bounds: "clamp",
*path(ctx: MoveContext): Generator<Point> {
const { start, width, height, config, rng } = ctx;
const reach: number = reachOf(config);
// Endpoint: a random direction, `reach` away, clamped on-screen.
const angle: number = rng() * 2 * Math.PI;
const endX: number = clamp(start.x + Math.cos(angle) * reach, width);
const endY: number = clamp(start.y + Math.sin(angle) * 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) * 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 <= config.stepCount; i++) {
const t: number = i / config.stepCount;
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. Amplitude
* scales with `reach`.
*/
export const figureEight: MovementStrategy = {
name: "figureEight",
bounds: "clamp",
*path(ctx: MoveContext): Generator<Point> {
const { start, config } = ctx;
const amp: number = reachOf(config) / 2;
for (let i = 1; i <= config.stepCount; i++) {
const t: number = (2 * Math.PI * i) / config.stepCount;
yield {
x: start.x + amp * Math.sin(t),
y: start.y + 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 valid pattern names, for validation messages and help text. */
export const PATTERN_NAMES: readonly string[] = Object.keys(STRATEGIES);
/**
* 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);
}
/**
* 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 registry key. Built once at module
* load. The assertion below guards against two registered names collapsing
* to the same normalized form (e.g. a future `"figure_eight"` alongside
* `"figureEight"`), which would otherwise let one silently shadow the other.
*/
const CANONICAL_PATTERNS: ReadonlyMap<string, string> = new Map(
PATTERN_NAMES.map((n) => [normalizePattern(n), n]),
);
if (CANONICAL_PATTERNS.size !== 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;
}
+11 -2
View File
@@ -16,6 +16,8 @@ const NONE: ConfigOverrides = {
checkInterval: undefined, checkInterval: undefined,
stepDelay: undefined, stepDelay: undefined,
stepCount: undefined, stepCount: undefined,
stepSize: undefined,
pattern: undefined,
verbose: undefined, verbose: undefined,
}; };
@@ -44,11 +46,18 @@ describe("resolveConfig", () => {
expect(cfg.checkInterval).toBe(2000); expect(cfg.checkInterval).toBe(2000);
}); });
test("stepDelay and stepCount pass through untouched (no unit conversion)", () => { test("stepDelay, stepCount, stepSize pass through untouched (no unit conversion)", () => {
const cli: ConfigOverrides = { ...NONE, stepDelay: 75, stepCount: 100 }; const cli: ConfigOverrides = { ...NONE, stepDelay: 75, stepCount: 100, stepSize: 4 };
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); expect(cfg.stepCount).toBe(100);
expect(cfg.stepSize).toBe(4);
});
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", () => {
+24
View File
@@ -97,6 +97,30 @@ 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 known pattern and a positive stepSize", () => {
const path = writeFixture("pattern.json", JSON.stringify({ pattern: "arc", stepSize: 3 }));
const result = loadConfigFile(path);
expect(result!.pattern).toBe("arc");
expect(result!.stepSize).toBe(3);
});
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/);
});
test("throws on a non-positive stepSize", () => {
const path = writeFixture("badsize.json", JSON.stringify({ stepSize: 0 }));
expect(() => loadConfigFile(path)).toThrow(/'stepSize'.*positive number/);
});
}); });
describe("loadConfigFile (default path)", () => { describe("loadConfigFile (default path)", () => {
+2 -2
View File
@@ -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", () => {
+182
View File
@@ -0,0 +1,182 @@
/**
* executor.test.ts
* ----------------
* Unit tests for the execution driver against a fake `Device`. Covers the
* three sweep outcomes, all three bounds policies, the rounding/interrupt
* contract, and step pacing — none of which was testable before the device
* seam existed.
*/
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 { BoundsPolicy, 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 under a chosen bounds policy. */
function fixed(points: Point[], bounds: BoundsPolicy): MovementStrategy {
return {
name: "fixed",
bounds,
*path(): Generator<Point> {
yield* points;
},
};
}
function ctxOf(start: Point, width: number, height: number, config?: Partial<Config>): MoveContext {
return { start, width, height, config: { ...DEFAULT_CONFIG, ...config }, rng: Math.random };
}
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, "clamp"), ctxOf(start, dev.w, dev.h), dev, noopLog);
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, "clamp"), ctxOf(start, dev.w, dev.h), dev, noopLog);
expect(outcome).toBe("interrupted");
// Commanded points 1 and 2 only; never restored to start.
expect(dev.commanded).toEqual([pts[0]!, pts[1]!]);
expect(dev.commanded.at(-1)).not.toEqual(start);
});
test("abort policy stops before commanding an out-of-bounds point", async () => {
const dev = new FakeDevice(100, 100);
const pts = [{ x: 150, y: 10 }]; // x >= width
const outcome = await executePath(fixed(pts, "abort"), ctxOf({ x: 10, y: 10 }, 100, 100), dev, noopLog);
expect(outcome).toBe("aborted");
expect(dev.commanded).toEqual([]);
});
});
describe("executePath — bounds policies", () => {
test("clamp pins out-of-bounds coordinates to the inset edges", async () => {
const dev = new FakeDevice(100, 100);
const pts = [
{ x: -5, y: 50 },
{ x: 9999, y: 50 },
];
// travelRange(100) is inset by EDGE_MARGIN (2) to [2, 97].
await executePath(fixed(pts, "clamp"), ctxOf({ x: 50, y: 50 }, 100, 100), dev, noopLog);
expect(dev.commanded[0]).toEqual({ x: 2, y: 50 });
expect(dev.commanded[1]).toEqual({ x: 97, y: 50 });
});
test("reflect mirrors out-of-bounds coordinates back inside the inset range", async () => {
const dev = new FakeDevice(100, 100);
// Inset range [2, 97], span = 95; x=120 -> (120-2)=118, 190-118=72, +2 = 74.
const pts = [{ x: 120, y: 50 }];
await executePath(fixed(pts, "reflect"), ctxOf({ x: 50, y: 50 }, 100, 100), dev, noopLog);
expect(dev.commanded[0]).toEqual({ x: 74, y: 50 });
});
});
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, "clamp"), ctxOf(start, dev.w, dev.h), dev, noopLog);
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, "clamp"), ctxOf(start, dev.w, dev.h), dev, noopLog);
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, "clamp"), ctxOf(start, dev.w, dev.h), dev, noopLog);
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, "clamp"), ctxOf({ x: 500, y: 500 }, dev.w, dev.h, { stepDelay: 7 }), dev, noopLog);
expect(dev.sleeps).toEqual([7, 7]);
});
});
+90
View File
@@ -0,0 +1,90 @@
/**
* 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";
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): Promise<void> {
try {
await runKeeper(config, device);
} 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, stepCount: 3, stepSize: 1, pattern: "line" }), dev);
// A sweep issued setPosition commands (3 steps + restore); 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, stepCount: 3, pattern: "line" }), dev);
expect(dev.commanded.length).toBe(0);
});
});
+169
View File
@@ -0,0 +1,169 @@
/**
* 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 { DEFAULT_CONFIG } from "../src/config.ts";
import type { Config } from "../src/config.ts";
import type { Point } from "../src/device.ts";
import {
arc,
diagonal,
figureEight,
isPatternName,
jitter,
line,
PATTERN_NAMES,
resolvePatternName,
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;
};
}
function ctxOf(overrides: {
start?: Point;
width?: number;
height?: number;
config?: Partial<Config>;
rng?: () => number;
}): MoveContext {
return {
start: overrides.start ?? { x: 500, y: 500 },
width: overrides.width ?? 1920,
height: overrides.height ?? 1080,
config: { ...DEFAULT_CONFIG, ...overrides.config },
rng: overrides.rng ?? Math.random,
};
}
describe("line", () => {
test("emits stepCount points along +x with no vertical movement", () => {
const pts = [...line.path(ctxOf({ config: { stepCount: 5, stepSize: 1 } }))];
expect(pts.length).toBe(5);
expect(pts.every((p) => p.y === 500)).toBe(true);
expect(pts.map((p) => p.x)).toEqual([501, 502, 503, 504, 505]);
});
test("honors stepSize for per-step distance", () => {
const pts = [...line.path(ctxOf({ config: { stepCount: 3, stepSize: 10 } }))];
expect(pts.map((p) => p.x)).toEqual([510, 520, 530]);
});
test("reverses direction when there is no room to the right", () => {
const pts = [...line.path(ctxOf({ start: { x: 90, y: 10 }, width: 100, config: { stepCount: 20, stepSize: 1 } }))];
expect(pts[0]!.x).toBe(89);
expect(pts.at(-1)!.x).toBe(70);
});
});
describe("diagonal", () => {
test("moves on both axes toward the roomy corner", () => {
const pts = [...diagonal.path(ctxOf({ config: { stepCount: 4, stepSize: 2 } }))];
expect(pts.length).toBe(4);
expect(pts.map((p) => p.x)).toEqual([502, 504, 506, 508]);
expect(pts.map((p) => p.y)).toEqual([502, 504, 506, 508]);
});
});
describe("jitter", () => {
test("stays within its radius of start and returns stepCount points", () => {
const size = 5;
const stepCount = 50;
// Radius scales off the sweep length (stepCount * stepSize) / 8, floored at 4.
const radius = Math.max(4, (stepCount * size) / 8);
const start = { x: 500, y: 500 };
const pts = [...jitter.path(ctxOf({ start, config: { stepCount, stepSize: size }, rng: mulberry32(1) }))];
expect(pts.length).toBe(stepCount);
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, config: { stepCount: 10, stepSize: 7 }, rng: () => 0.5 }))];
expect(pts.length).toBe(10);
// (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 deltas step over step", () => {
const pts = [...walk.path(ctxOf({ config: { stepCount: 3, stepSize: 4 }, rng: mulberry32(42) }))];
expect(pts.length).toBe(3);
expect(pts.every((p) => Number.isFinite(p.x) && Number.isFinite(p.y))).toBe(true);
});
});
describe("arc", () => {
test("emits stepCount finite points and lands on its endpoint", () => {
const pts = [...arc.path(ctxOf({ config: { stepCount: 8, stepSize: 20 }, rng: mulberry32(7) }))];
expect(pts.length).toBe(8);
expect(pts.every((p) => Number.isFinite(p.x) && Number.isFinite(p.y))).toBe(true);
// t = 1 at the final step, so B(1) is the endpoint — a stable point.
const a = [...arc.path(ctxOf({ config: { stepCount: 8, stepSize: 20 }, rng: mulberry32(7) }))];
expect(pts.at(-1)).toEqual(a.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, config: { stepCount: 40, stepSize: 10 } }))];
expect(pts.length).toBe(40);
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();
});
});