diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f47b36..056c4be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/), 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 ` flag and + `pattern` config key select how the cursor moves: `line` (default, + unchanged behavior), `diagonal`, `jitter`, `walk`, `arc`, `figureEight`. +- `-s, --step-size ` 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 ### Added @@ -67,6 +108,7 @@ Initial release. - Source split into `src/{move,cli,config,keeper}.ts`. - `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.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 diff --git a/README.md b/README.md index 8a4ecc4..b9fb767 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,11 @@ Options: -m, --move-interval Idle time before a sweep fires. Default: 240. -c, --check-interval Cursor poll cadence. Default: 10. -d, --step-delay Pause between synthetic steps. Default: 50. - -n, --step-count Steps per sweep. Default: 250. + -n, --step-count Steps per sweep. Default: 250. + -s, --step-size Pixels moved per step. Default: 1. + -p, --pattern Movement strategy. Default: line. + One of: line, diagonal, jitter, walk, arc, + figureEight. -V, --verbose Log every sweep, interrupt, and bounds event (default prints only the startup banner). @@ -147,6 +151,8 @@ doesn't set. "checkInterval": 10, "stepDelay": 50, "stepCount": 250, + "stepSize": 1, + "pattern": "line", "verbose": false } ``` @@ -154,7 +160,8 @@ doesn't set. All keys are optional; supply only the ones you want to override. Keys and units mirror the CLI flags exactly: `moveInterval` and `checkInterval` are seconds, `stepDelay` is milliseconds, `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 @@ -181,6 +188,9 @@ The loader is strict: - Root must be a JSON object. - Unknown keys are rejected (catches typos like `"movInterval"`). - Numeric values must be finite and strictly positive. +- `pattern` must resolve to a registered strategy name. Matching ignores + case and separators (`-`, `_`, spaces), so `figure-eight` and `figureEight` + are equivalent. - `verbose` must be a boolean. Any validation failure prints a message naming the file and the offending @@ -195,7 +205,7 @@ file with `--config`. ## 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: - `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 including `verbose`), `DEFAULT_CONFIG`, `defaultConfigPath`, and the 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`: @@ -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. | | `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. | -| `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 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 `simulateActivity` and reset the idleness clock. -### Synthetic sweep (`simulateActivity`) +### Synthetic sweep (`simulateActivity` + `executePath`) -1. Read the starting position and current screen dimensions. -2. Pick a horizontal direction (`dx = +1` if there's room to the right, - else `-1`) so the sweep stays on-screen. Vertical is `dy = 0` for now. -3. For each of `config.stepCount` steps: - - Compute and bounds-check the next target. +1. `simulateActivity` snapshots the starting position and current screen + dimensions (re-read every sweep so monitor changes are handled), looks + up `config.pattern` in the strategy registry, and builds a `MoveContext`. +2. It hands the strategy and context to `executePath`, which drives the + sweep. For each target the strategy yields: + - Round to whole pixels and apply the strategy's bounds policy + (`abort` / `clamp` / `reflect`) to keep it on-screen. - Move the cursor there, sleep `config.stepDelay`. - - Re-read the cursor. If it isn't where we put it, the user moved it — - log (when `--verbose`) and return early without snapping back. -4. On a clean full sweep, restore the cursor to its starting position so + - Re-read the cursor. If it isn't at the point we *just commanded*, the + user moved it — log (when `--verbose`) and return early without + 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 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` 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 -sweep. The script controls cadence itself via `config.stepDelay`, so the -implicit delay is disabled at module load (a side effect of importing -`keeper.ts`). +sweep. The code controls cadence itself via `config.stepDelay`, so the +implicit delay is disabled in `createNutDevice` — the single place nut.js +is wired up. Importing the movement modules stays side-effect-free. ## For contributors @@ -296,7 +353,10 @@ move --help | `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/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`. | | `tsconfig.json` | Strict TypeScript config tuned for Bun (ESNext, bundler resolution). | | `bun.lock` | Bun's lockfile. Commit this. | diff --git a/package.json b/package.json index ee84b97..0de2d36 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "move", - "version": "1.2.0", + "version": "1.3.0", "private": true, "license": "GPL-3.0-only", "type": "module", diff --git a/scripts/config.default.json b/scripts/config.default.json index 673dcce..b2aef6c 100644 --- a/scripts/config.default.json +++ b/scripts/config.default.json @@ -3,5 +3,7 @@ "checkInterval": 10, "stepDelay": 50, "stepCount": 250, + "stepSize": 1, + "pattern": "line", "verbose": false } diff --git a/src/cli.ts b/src/cli.ts index 5ca9ae0..a36ef6b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -16,7 +16,9 @@ * -m, --move-interval Idle time (seconds) before a sweep fires. * -c, --check-interval Cursor poll cadence (seconds). * -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` capital because `-v` is `--version`.) * @@ -31,6 +33,7 @@ import { parseArgs } from "node:util"; import { DEFAULT_CONFIG, defaultConfigPath } from "./config.ts"; import { CliError } from "./errors.ts"; +import { PATTERN_NAMES, resolvePatternName } from "./strategies.ts"; /** * Result of `parseCliArgs`. Numeric fields are `undefined` when the user @@ -45,7 +48,10 @@ export interface ParsedCliArgs { moveInterval: number | undefined; // seconds checkInterval: number | undefined; // seconds 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. * `undefined` (not `false`) lets the layered resolver distinguish "user @@ -69,6 +75,20 @@ function parsePositiveNumber(name: string, raw: string | undefined): number | un 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 * `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" }, "step-delay": { type: "string", short: "d" }, "step-count": { type: "string", short: "n" }, + "step-size": { type: "string", short: "s" }, + pattern: { type: "string", short: "p" }, verbose: { type: "boolean", short: "V" }, }, strict: true, @@ -110,6 +132,8 @@ export function parseCliArgs(): ParsedCliArgs { checkInterval: parsePositiveNumber("check-interval", values["check-interval"] as string | undefined), stepDelay: parsePositiveNumber("step-delay", values["step-delay"] 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, }; } @@ -152,7 +176,10 @@ Options: -m, --move-interval Idle time before a sweep fires. Default: ${moveDefaultSec}. -c, --check-interval Cursor poll cadence. Default: ${checkDefaultSec}. -d, --step-delay Pause between synthetic steps. Default: ${DEFAULT_CONFIG.stepDelay}. - -n, --step-count Steps per sweep. Default: ${DEFAULT_CONFIG.stepCount}. + -n, --step-count Steps per sweep. Default: ${DEFAULT_CONFIG.stepCount}. + -s, --step-size Pixels moved per step. Default: ${DEFAULT_CONFIG.stepSize}. + -p, --pattern Movement strategy. Default: ${DEFAULT_CONFIG.pattern}. + One of: ${PATTERN_NAMES.join(", ")}. -V, --verbose Log every sweep, interrupt, and bounds event (default prints only the startup banner). @@ -162,6 +189,7 @@ Examples: move move --move-interval 180 --check-interval 5 move -m 300 -V + move --pattern arc --step-size 3 move --config ~/myprofile.json `); } diff --git a/src/config.ts b/src/config.ts index 616dab7..1443fd7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -22,6 +22,7 @@ import { join } from "node:path"; 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 // 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 * a sweep. Also the window in which the user can * "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 * logged. The startup banner is always printed. */ @@ -50,6 +56,8 @@ export interface Config { readonly checkInterval: number; readonly stepDelay: number; readonly stepCount: number; + readonly stepSize: number; + readonly pattern: PatternName; readonly verbose: boolean; } @@ -63,7 +71,9 @@ interface SeedShape { moveInterval: number; // seconds checkInterval: number; // seconds stepDelay: number; // milliseconds - stepCount: number; // pixels + stepCount: number; // count + stepSize: number; // pixels + pattern: string; // strategy name 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"); } const r = raw as Record; - 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]; 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)})`); } } + 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") { 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, stepDelay: seed.stepDelay, stepCount: seed.stepCount, + stepSize: seed.stepSize, + pattern: seed.pattern, verbose: seed.verbose, }; @@ -110,10 +125,12 @@ export const DEFAULT_CONFIG: Config = { * Numeric fields are in CLI / config-file units: * moveInterval, checkInterval — seconds * stepDelay — milliseconds - * stepCount — pixels + * stepCount — count + * stepSize — pixels * - * `verbose` is `boolean | undefined` like the numeric fields, so all five - * fields share the same "first defined value wins" precedence logic. + * `pattern` is a strategy name (`string | undefined`) and `verbose` is + * `boolean | undefined`, so every field shares the same "first defined + * value wins" precedence logic. * * For the CLI specifically, `verbose` is `undefined` when `-V/--verbose` * 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 stepDelay: number | undefined; readonly stepCount: number | undefined; + readonly stepSize: number | undefined; + readonly pattern: string | 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), stepDelay: pickRaw(cli.stepDelay, file?.stepDelay, DEFAULT_CONFIG.stepDelay), 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), }; } diff --git a/src/configFile.ts b/src/configFile.ts index 3a47c66..5320b26 100644 --- a/src/configFile.ts +++ b/src/configFile.ts @@ -10,7 +10,9 @@ * moveInterval number seconds, positive * checkInterval number seconds, 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 * * 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 { CliError } from "./errors.ts"; +import { PATTERN_NAMES, resolvePatternName } from "./strategies.ts"; const ALLOWED_KEYS: ReadonlySet = new Set([ "moveInterval", "checkInterval", "stepDelay", "stepCount", + "stepSize", + "pattern", "verbose", ]); @@ -60,6 +65,16 @@ function requireBoolean(name: string, raw: unknown, path: string): boolean { 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 * semantics. @@ -130,6 +145,14 @@ export function loadConfigFile(explicitPath: string | undefined): ConfigOverride "stepCount" in parsed ? requirePositiveNumber("stepCount", parsed.stepCount, path) : undefined, + stepSize: + "stepSize" in parsed + ? requirePositiveNumber("stepSize", parsed.stepSize, path) + : undefined, + pattern: + "pattern" in parsed + ? requirePatternName("pattern", parsed.pattern, path) + : undefined, verbose: "verbose" in parsed ? requireBoolean("verbose", parsed.verbose, path) diff --git a/src/device.ts b/src/device.ts new file mode 100644 index 0000000..f6710ae --- /dev/null +++ b/src/device.ts @@ -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; + /** Move the cursor to `p`. */ + setPosition(p: Point): Promise; + /** Current primary-screen width in pixels. */ + width(): Promise; + /** Current primary-screen height in pixels. */ + height(): Promise; + /** Resolve after `ms` milliseconds. */ + sleep(ms: number): Promise; +} + +/** + * 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 => + new Promise((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 { + const { mouse, Point: NutPoint, screen } = await import("@nut-tree-fork/nut-js"); + + mouse.config.autoDelayMs = 0; + + return { + getPosition: async (): Promise => { + const p = await mouse.getPosition(); + return { x: p.x, y: p.y }; + }, + setPosition: async (p: Point): Promise => { + await mouse.setPosition(new NutPoint(p.x, p.y)); + }, + width: (): Promise => screen.width(), + height: (): Promise => screen.height(), + sleep, + }; +} diff --git a/src/executor.ts b/src/executor.ts new file mode 100644 index 0000000..6d4f2a1 --- /dev/null +++ b/src/executor.ts @@ -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 { + 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"; +} diff --git a/src/keeper.ts b/src/keeper.ts index b47e81e..a097774 100644 --- a/src/keeper.ts +++ b/src/keeper.ts @@ -1,63 +1,38 @@ /** * keeper.ts * --------- - * The actual "Teams Status Keeper" behavior: synthetic mouse activity with - * real-user-wins semantics, plus the idle-watch loop that drives it. + * The "Teams Status Keeper" behavior: the idle-watch loop plus the + * per-sweep glue that ties a movement strategy to the execution driver. * - * Runtime: Bun (uses `@nut-tree-fork/nut-js` for cross-platform mouse + - * screen). The nut.js auto-delay is disabled inside `runKeeper`, not at - * module load, so importing this module is side-effect-free. + * The mechanics are split across three seams so this file stays small and + * the interesting parts stay testable: + * - `device.ts` — the nut.js I/O boundary (injected here). + * - `strategies.ts` — pure "where to move" pattern generators. + * - `executor.ts` — the "how to move" driver (bounds, timing, + * interrupt detection, restore). + * + * `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: * - The startup banner in `runKeeper` is unconditional so the user always - * sees confirmation that the process is alive. - * - Every per-sweep / interrupt / bounds log is gated by `config.verbose` - * so the default is quiet. Errors stay on `console.error` (unconditional, - * raised by the entry point on unhandled rejection). + * sees the process is alive. + * - Per-sweep / interrupt / bounds lines are gated by `config.verbose` + * (see `makeLogger`). Errors stay on `console.error`, raised by the + * entry point on unhandled rejection. */ -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"; -/** - * Promise-based `setTimeout` wrapper. Allows `await sleep(ms)` ergonomics. - * - * @param ms - Duration to wait, in milliseconds. - */ -const sleep = (ms: number): Promise => - new Promise((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`) 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 * 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 { return { @@ -73,61 +48,22 @@ function makeLogger(verbose: boolean): Logger { /** * Perform a single synthetic mouse-activity sweep. * - * Behavior: - * 1. Snapshot the starting cursor position. - * 2. Read current screen dimensions (re-read every call so monitor changes - * are handled correctly). - * 3. Pick a horizontal direction (`dx`) that keeps the sweep on-screen: - * move right if there's room, otherwise move left. Vertical movement is - * 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. + * Snapshots the cursor and screen (re-read every call so monitor changes + * are handled), selects the configured strategy from the registry, and + * hands the resulting path to `executePath`, which owns bounds, pacing, + * interrupt detection, and restore-on-clean. An unknown `config.pattern` + * falls back to the default strategy defensively; validation at the CLI / + * config-file boundary should prevent that from ever happening. */ -async function simulateActivity(config: Config, log: Logger): Promise { - const start: Point = await mouse.getPosition(); - const screenWidth: number = await screen.width(); - const screenHeight: number = await screen.height(); - const dx: number = start.x + config.stepCount < screenWidth ? 1 : -1; - const dy: number = 0; +async function simulateActivity(config: Config, log: Logger, device: Device): Promise { + const start: Point = await device.getPosition(); + const width: number = await device.width(); + const height: number = await device.height(); - 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++) { - 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."); + await executePath(strategy, ctx, device, log); } /** @@ -145,32 +81,26 @@ async function simulateActivity(config: Config, log: Logger): Promise { * idleness clock so we wait another full `moveInterval` before * firing again. * - * `simulateActivity` is designed so that its own synthetic movement never - * counts as real activity: on a clean sweep it restores the cursor (so the - * next position check matches), and on a user-interrupted sweep the next - * iteration sees the user's new position and correctly resets the clock. + * `simulateActivity` (via `executePath`) is designed so its own synthetic + * movement never counts as real activity: on a clean sweep it restores the + * cursor, and on a user-interrupted sweep the next iteration sees the + * user's new position and correctly resets the clock. + * + * @param config - Resolved runtime config. + * @param device - I/O device; defaults to the production nut.js device. */ -export async function runKeeper(config: Config): Promise { - // nut.js inserts a configurable delay after every action (default 100ms). - // 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; +export async function runKeeper(config: Config, device?: Device): Promise { + const dev: Device = device ?? (await createNutDevice()); const log = makeLogger(config.verbose); 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(); while (true) { - await sleep(config.checkInterval); - const pos: Point = await mouse.getPosition(); + await dev.sleep(config.checkInterval); + const pos: Point = await dev.getPosition(); const now: number = Date.now(); if (pos.x !== lastPos.x || pos.y !== lastPos.y) { @@ -181,12 +111,18 @@ export async function runKeeper(config: Config): Promise { } if (now - lastActivity >= config.moveInterval) { - await simulateActivity(config, log); - // `simulateActivity` either returns the cursor to its start - // (clean sweep) or leaves it where the user moved it (interrupt). - // Either way we reset the clock and require another full - // moveInterval of inactivity before firing again. + await simulateActivity(config, log, dev); + // The sweep either restored the cursor to its start (clean) or + // left it where the user moved it (interrupt). Either way, reset + // the clock and require another full moveInterval of inactivity + // before firing again. 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(); } } } diff --git a/src/move.ts b/src/move.ts index c63bd71..636acfe 100755 --- a/src/move.ts +++ b/src/move.ts @@ -116,6 +116,8 @@ const cliOverrides: ConfigOverrides = { checkInterval: cliArgs.checkInterval, stepDelay: cliArgs.stepDelay, stepCount: cliArgs.stepCount, + stepSize: cliArgs.stepSize, + pattern: cliArgs.pattern, verbose: cliArgs.verbose, }; diff --git a/src/strategies.ts b/src/strategies.ts new file mode 100644 index 0000000..54b2dec --- /dev/null +++ b/src/strategies.ts @@ -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; +} + +/** 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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> = { + 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 = 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; +} diff --git a/tests/config.test.ts b/tests/config.test.ts index 1c999c7..8bf903e 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -16,6 +16,8 @@ const NONE: ConfigOverrides = { checkInterval: undefined, stepDelay: undefined, stepCount: undefined, + stepSize: undefined, + pattern: undefined, verbose: undefined, }; @@ -44,11 +46,18 @@ describe("resolveConfig", () => { expect(cfg.checkInterval).toBe(2000); }); - test("stepDelay and stepCount pass through untouched (no unit conversion)", () => { - const cli: ConfigOverrides = { ...NONE, stepDelay: 75, stepCount: 100 }; + test("stepDelay, stepCount, stepSize pass through untouched (no unit conversion)", () => { + const cli: ConfigOverrides = { ...NONE, stepDelay: 75, stepCount: 100, stepSize: 4 }; const cfg = resolveConfig(null, cli); expect(cfg.stepDelay).toBe(75); 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", () => { diff --git a/tests/configFile.test.ts b/tests/configFile.test.ts index 53a890a..4e07dd6 100644 --- a/tests/configFile.test.ts +++ b/tests/configFile.test.ts @@ -97,6 +97,30 @@ describe("loadConfigFile (explicit path)", () => { const path = writeFixture("verbose.json", JSON.stringify({ verbose: "yes" })); 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)", () => { diff --git a/src/editor.test.ts b/tests/editor.test.ts similarity index 97% rename from src/editor.test.ts rename to tests/editor.test.ts index 88ad54a..41c8646 100644 --- a/src/editor.test.ts +++ b/tests/editor.test.ts @@ -14,8 +14,8 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { editConfig, editorCommand } from "./editor.ts"; -import { CliError } from "./errors.ts"; +import { editConfig, editorCommand } from "../src/editor.ts"; +import { CliError } from "../src/errors.ts"; describe("editorCommand", () => { test("builds 'sh -c \"$@\"' argv with -- placeholder and path", () => { diff --git a/tests/executor.test.ts b/tests/executor.test.ts new file mode 100644 index 0000000..07fade9 --- /dev/null +++ b/tests/executor.test.ts @@ -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(); + constructor(public w = 1920, public h = 1080, public initial: Point = { x: 0, y: 0 }) {} + + async getPosition(): Promise { + 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 { + this.commanded.push(p); + } + async width(): Promise { + return this.w; + } + async height(): Promise { + return this.h; + } + async sleep(ms: number): Promise { + 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 { + yield* points; + }, + }; +} + +function ctxOf(start: Point, width: number, height: number, config?: Partial): 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]); + }); +}); diff --git a/tests/keeper.test.ts b/tests/keeper.test.ts new file mode 100644 index 0000000..9090814 --- /dev/null +++ b/tests/keeper.test.ts @@ -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 { + if (this.positions) return this.positions.shift() ?? this.initial; + return this.commanded.at(-1) ?? this.initial; + } + async setPosition(p: Point): Promise { + this.commanded.push(p); + } + async width(): Promise { + return 1920; + } + async height(): Promise { + return 1080; + } + async sleep(): Promise { + if (++this.sleepCount > this.budget) throw new StopError(); + } +} + +const quietConfig = (overrides: Partial): Config => ({ + ...DEFAULT_CONFIG, + verbose: false, + ...overrides, +}); + +async function runUntilStop(config: Config, device: Device): Promise { + 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); + }); +}); diff --git a/tests/strategies.test.ts b/tests/strategies.test.ts new file mode 100644 index 0000000..33f1932 --- /dev/null +++ b/tests/strategies.test.ts @@ -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; + 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(); + }); +});