diff --git a/CHANGELOG.md b/CHANGELOG.md index 056c4be..9f24b98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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`. + Each pattern owns its own size and step count as constants; there is no + user knob for sweep magnitude. - Pattern names are matched leniently: case and separators are ignored, so `figureEight`, `figure-eight`, `figure_eight`, and `FIGUREEIGHT` are all accepted (on the CLI and in the config file) and resolve to the canonical @@ -36,9 +36,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `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" @@ -46,6 +43,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 which the new edge-seeking patterns would otherwise hit. `line` (policy `abort`) is unaffected. +### Removed +- `-n, --step-count` flag and the `stepCount` / `stepSize` config keys. Sweep + size and step count are now intrinsic to each movement pattern, not user + knobs. Config files that still contain these keys keep working: the loader + ignores them with a one-line notice instead of rejecting them, so existing + installs (all seeded with `stepCount`) don't break on upgrade. The removed + CLI flag, however, is a hard error like any other unknown option. + ## [1.2.0] - 2026-06-17 ### Added diff --git a/README.md b/README.md index b9fb767..6b24359 100644 --- a/README.md +++ b/README.md @@ -90,11 +90,10 @@ 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. - -s, --step-size Pixels moved per step. Default: 1. -p, --pattern Movement strategy. Default: line. One of: line, diagonal, jitter, walk, arc, - figureEight. + figureEight. Each pattern defines its own + size and speed. -V, --verbose Log every sweep, interrupt, and bounds event (default prints only the startup banner). @@ -150,8 +149,6 @@ doesn't set. "moveInterval": 240, "checkInterval": 10, "stepDelay": 50, - "stepCount": 250, - "stepSize": 1, "pattern": "line", "verbose": false } @@ -159,9 +156,13 @@ 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 -a step count, `stepSize` is pixels-per-step, `pattern` is a movement -strategy name, `verbose` is a boolean. +`checkInterval` are seconds, `stepDelay` is milliseconds, `pattern` is a +movement strategy name, `verbose` is a boolean. + +> The obsolete `stepCount` / `stepSize` keys (removed in 1.3.0) are +> tolerated for backward compatibility: they're ignored with a one-line +> notice rather than rejected, so a config seeded by an older install keeps +> working. Sweep size and step count are now properties of each pattern. ### Editing @@ -241,8 +242,6 @@ 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` | 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 @@ -287,20 +286,22 @@ grabbing the mouse. `line` uses the `abort` policy and is unaffected. `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` | +| Name | Motion | Steps | Size | Bounds | +| ------------- | ------------------------------------------------------------- | ----- | -------- | --------- | +| `line` | Straight horizontal sweep (the original behavior). | 250 | 250px | `abort` | +| `diagonal` | Straight line on both axes toward the roomiest corner. | 250 | 250px/axis | `clamp` | +| `jitter` | Small random hops within a tight radius of the start. | 80 | 30px radius | `clamp` | +| `walk` | Cumulative random walk; bounces off the screen edges. | 200 | ±4px/step | `reflect` | +| `arc` | Smooth quadratic-Bézier curve to a random on-screen point. | 120 | ~300px | `clamp` | +| `figureEight` | Traces a figure-eight (lemniscate) and returns to the start. | 90 | ~250px wide | `clamp` | -`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. +Each pattern owns its geometry — how many steps it takes and how far it +reaches — as constants in `src/strategies.ts`. Those are properties of the +pattern, not user preferences, so there is no knob for sweep size or step +count; `stepDelay` (the per-step pause) is the only pacing lever, and it +scales every pattern's total duration. To add a pattern, write one pure +generator and register it — the executor supplies bounds, pacing, interrupt, +and restore for free. ### Why `mouse.config.autoDelayMs = 0` diff --git a/scripts/config.default.json b/scripts/config.default.json index b2aef6c..3ff002e 100644 --- a/scripts/config.default.json +++ b/scripts/config.default.json @@ -2,8 +2,6 @@ "moveInterval": 240, "checkInterval": 10, "stepDelay": 50, - "stepCount": 250, - "stepSize": 1, "pattern": "line", "verbose": false } diff --git a/src/cli.ts b/src/cli.ts index a36ef6b..d1a66aa 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -16,8 +16,6 @@ * -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 (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`.) @@ -48,8 +46,6 @@ export interface ParsedCliArgs { moveInterval: number | undefined; // seconds checkInterval: number | undefined; // seconds stepDelay: number | undefined; // milliseconds - stepCount: number | undefined; // count - stepSize: number | undefined; // pixels /** Movement strategy name, validated against the registry. */ pattern: string | undefined; /** @@ -107,8 +103,6 @@ export function parseCliArgs(): ParsedCliArgs { "move-interval": { type: "string", short: "m" }, "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" }, }, @@ -131,8 +125,6 @@ export function parseCliArgs(): ParsedCliArgs { moveInterval: parsePositiveNumber("move-interval", values["move-interval"] as string | undefined), checkInterval: parsePositiveNumber("check-interval", values["check-interval"] as string | undefined), stepDelay: parsePositiveNumber("step-delay", values["step-delay"] as string | undefined), - 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, }; @@ -176,10 +168,9 @@ 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}. - -s, --step-size Pixels moved per step. Default: ${DEFAULT_CONFIG.stepSize}. -p, --pattern Movement strategy. Default: ${DEFAULT_CONFIG.pattern}. One of: ${PATTERN_NAMES.join(", ")}. + Each pattern defines its own size and speed. -V, --verbose Log every sweep, interrupt, and bounds event (default prints only the startup banner). @@ -189,7 +180,7 @@ Examples: move move --move-interval 180 --check-interval 5 move -m 300 -V - move --pattern arc --step-size 3 + move --pattern arc move --config ~/myprofile.json `); } diff --git a/src/config.ts b/src/config.ts index 1443fd7..fe4d5c5 100644 --- a/src/config.ts +++ b/src/config.ts @@ -27,8 +27,8 @@ 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 // fresh install (only if no config exists there yet). Values use the CLI -// units (seconds for time fields, ms for stepDelay, pixels for stepCount); -// the seconds->ms conversion happens below where DEFAULT_CONFIG is built. +// units (seconds for time fields, ms for stepDelay); the seconds->ms +// conversion happens below where DEFAULT_CONFIG is built. import seedRaw from "../scripts/config.default.json" with { type: "json" }; /** @@ -42,12 +42,9 @@ 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 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`). + * `strategies.ts`; e.g. `line`, `walk`, `arc`). Each + * pattern owns its own size and step count. * - `verbose` — whether per-sweep / interrupt / bounds events are * logged. The startup banner is always printed. */ @@ -55,8 +52,6 @@ export interface Config { readonly moveInterval: number; readonly checkInterval: number; readonly stepDelay: number; - readonly stepCount: number; - readonly stepSize: number; readonly pattern: PatternName; readonly verbose: boolean; } @@ -71,8 +66,6 @@ interface SeedShape { moveInterval: number; // seconds checkInterval: number; // seconds stepDelay: number; // milliseconds - stepCount: number; // count - stepSize: number; // pixels pattern: string; // strategy name verbose: boolean; } @@ -82,7 +75,7 @@ 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", "stepSize"] as const) { + for (const key of ["moveInterval", "checkInterval", "stepDelay"] 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)})`); @@ -110,8 +103,6 @@ export const DEFAULT_CONFIG: Config = { moveInterval: seed.moveInterval * 1000, checkInterval: seed.checkInterval * 1000, stepDelay: seed.stepDelay, - stepCount: seed.stepCount, - stepSize: seed.stepSize, pattern: seed.pattern, verbose: seed.verbose, }; @@ -125,8 +116,6 @@ export const DEFAULT_CONFIG: Config = { * Numeric fields are in CLI / config-file units: * moveInterval, checkInterval — seconds * stepDelay — milliseconds - * stepCount — count - * stepSize — pixels * * `pattern` is a strategy name (`string | undefined`) and `verbose` is * `boolean | undefined`, so every field shares the same "first defined @@ -142,8 +131,6 @@ export interface ConfigOverrides { readonly moveInterval: number | undefined; readonly checkInterval: number | undefined; readonly stepDelay: number | undefined; - readonly stepCount: number | undefined; - readonly stepSize: number | undefined; readonly pattern: string | undefined; readonly verbose: boolean | undefined; } @@ -210,8 +197,6 @@ export function resolveConfig(file: ConfigOverrides | null, cli: ConfigOverrides moveInterval: pickSeconds(cli.moveInterval, file?.moveInterval, DEFAULT_CONFIG.moveInterval), 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 5320b26..78d780a 100644 --- a/src/configFile.ts +++ b/src/configFile.ts @@ -10,14 +10,14 @@ * moveInterval number seconds, positive * checkInterval number seconds, positive * stepDelay number milliseconds, 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 * `CliError` so the entry point can exit 2 (user error) with a clear - * message pointing at the offending file. + * message pointing at the offending file. The removed `stepCount` / + * `stepSize` keys are the exception: they're tolerated (ignored with a + * one-line notice) so an older seeded config keeps working after upgrade. * * Return semantics: * - `null` when no `explicitPath` was passed and the default path does @@ -37,12 +37,23 @@ const ALLOWED_KEYS: ReadonlySet = new Set([ "moveInterval", "checkInterval", "stepDelay", - "stepCount", - "stepSize", "pattern", "verbose", ]); +/** + * Keys that used to be valid but have since been removed. They're tolerated + * (not rejected like a genuine unknown key) so upgrading doesn't hard-fail a + * config that was seeded with them — every pre-1.3.0 install has `stepCount` + * in its file. They no longer do anything: sweep size and step count are now + * properties of each movement pattern. A one-line notice points the user at + * the file so they can remove them at leisure. + */ +const DEPRECATED_KEYS: ReadonlySet = new Set([ + "stepCount", + "stepSize", +]); + function isPlainObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -119,13 +130,26 @@ export function loadConfigFile(explicitPath: string | undefined): ConfigOverride throw new CliError(`config file ${path} must contain a JSON object at the root`); } - // Strict mode: reject any key we don't know about. Catches typos like - // 'movInterval' that would otherwise sail through silently. + // Strict mode: reject any key we don't know about (catches typos like + // 'movInterval'), but tolerate keys we've since removed — collect those + // and warn once, rather than hard-failing a config seeded by an older + // install. + const deprecatedFound: string[] = []; for (const key of Object.keys(parsed)) { - if (!ALLOWED_KEYS.has(key)) { - const allowed: string = [...ALLOWED_KEYS].join(", "); - throw new CliError(`unknown key '${key}' in ${path} (allowed: ${allowed})`); + if (ALLOWED_KEYS.has(key)) continue; + if (DEPRECATED_KEYS.has(key)) { + deprecatedFound.push(key); + continue; } + const allowed: string = [...ALLOWED_KEYS].join(", "); + throw new CliError(`unknown key '${key}' in ${path} (allowed: ${allowed})`); + } + if (deprecatedFound.length > 0) { + const names: string = deprecatedFound.map((k) => `'${k}'`).join(", "); + process.stderr.write( + `move: ignoring obsolete key(s) ${names} in ${path}\n` + + ` (sweep size is now defined by each movement pattern)\n`, + ); } return { @@ -141,14 +165,6 @@ export function loadConfigFile(explicitPath: string | undefined): ConfigOverride "stepDelay" in parsed ? requirePositiveNumber("stepDelay", parsed.stepDelay, path) : undefined, - stepCount: - "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) diff --git a/src/executor.ts b/src/executor.ts index 6d4f2a1..490af61 100644 --- a/src/executor.ts +++ b/src/executor.ts @@ -23,6 +23,7 @@ * without every rounded step being misread as "the user moved the mouse". */ +import type { Config } from "./config.ts"; import type { Device, Point } from "./device.ts"; import type { BoundsPolicy, MoveContext, MovementStrategy } from "./strategies.ts"; @@ -138,21 +139,25 @@ function timestamp(): string { * 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. + * 2. Command the cursor there and sleep `config.stepDelay` — also the + * user's interrupt window. * 3. Re-read the cursor. If it isn't at the point we just commanded, the * user moved it: return `interrupted` without restoring. * * On a clean run the cursor is restored to `ctx.start` so the next * idle-check sees no net movement, and `completed` is returned. + * + * `config` supplies only the pacing (`stepDelay`); a strategy's geometry is + * entirely self-contained, so the path itself needs nothing from it. */ export async function executePath( strategy: MovementStrategy, ctx: MoveContext, device: Device, log: Logger, + config: Config, ): Promise { - const { start, width, height, config } = ctx; + const { start, width, height } = ctx; log.event(`Simulating activity (${strategy.name}) at ${timestamp()}...`); diff --git a/src/keeper.ts b/src/keeper.ts index a097774..e06711c 100644 --- a/src/keeper.ts +++ b/src/keeper.ts @@ -61,9 +61,9 @@ async function simulateActivity(config: Config, log: Logger, device: Device): Pr const height: number = await device.height(); const strategy = STRATEGIES[config.pattern] ?? STRATEGIES[DEFAULT_PATTERN]!; - const ctx: MoveContext = { start, width, height, config, rng: Math.random }; + const ctx: MoveContext = { start, width, height, rng: Math.random }; - await executePath(strategy, ctx, device, log); + await executePath(strategy, ctx, device, log, config); } /** diff --git a/src/move.ts b/src/move.ts index 636acfe..55335bc 100755 --- a/src/move.ts +++ b/src/move.ts @@ -115,8 +115,6 @@ const cliOverrides: ConfigOverrides = { moveInterval: cliArgs.moveInterval, 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 index 54b2dec..a01ec53 100644 --- a/src/strategies.ts +++ b/src/strategies.ts @@ -14,13 +14,16 @@ * 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. + * Each pattern owns its own geometry — how many steps it takes, how far it + * reaches, how tight its radius is — as module-private constants below. Those + * are properties of the pattern, not user preferences: a jitter is inherently + * small and twitchy, an arc inherently a broad curve. There is deliberately + * no user knob for sweep size or step count; the cadence (`stepDelay`) is the + * only tunable, and it lives in the executor, not here. As a result this + * module needs nothing from `Config` and imports only `Point`. */ import type { Point } from "./device.ts"; -import type { Config } from "./config.ts"; /** * How the executor keeps a strategy's targets on-screen: @@ -47,8 +50,6 @@ export interface MoveContext { 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; } @@ -75,33 +76,25 @@ function clamp(v: number, max: number): number { 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). + * there's room, else left) and walk `LINE_STEPS` single-pixel steps with no + * vertical movement. 250 one-pixel steps is byte-for-byte the sweep the + * keeper produced before movement patterns existed, which is why its bounds + * policy is `abort` (the direction choice guarantees it never triggers). */ +const LINE_STEPS = 250; + 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 }; + const { start, width } = ctx; + const dx: number = start.x + LINE_STEPS < width ? 1 : -1; + for (let i = 1; i <= LINE_STEPS; i++) { + yield { x: start.x + i * dx, y: start.y }; } }, }; @@ -109,42 +102,42 @@ export const line: MovementStrategy = { /** * `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. + * roomiest corner and stays on-screen. 250 single-pixel steps per axis + * (≈250px reach), matching `line`'s magnitude. */ +const DIAGONAL_STEPS = 250; + 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, - }; + const { start, width, height } = ctx; + const dx: number = start.x + DIAGONAL_STEPS < width ? 1 : -1; + const dy: number = start.y + DIAGONAL_STEPS < height ? 1 : -1; + for (let i = 1; i <= DIAGONAL_STEPS; i++) { + yield { x: start.x + i * dx, y: start.y + i * dy }; } }, }; /** - * `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. + * `jitter` — many small random hops within a tight radius of the start. + * Subtle "fidget" activity rather than a broad sweep. The radius is large + * enough that every hop is a real, distinct pixel move rather than rounding + * onto the pixel the cursor already occupies. The executor restores the + * cursor to `start` after a clean run, so the net displacement is zero. */ +const JITTER_STEPS = 80; +const JITTER_RADIUS = 30; + export const jitter: MovementStrategy = { name: "jitter", 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 { start, rng } = ctx; + for (let i = 1; i <= JITTER_STEPS; i++) { const angle: number = rng() * 2 * Math.PI; - const r: number = rng() * radius; + const r: number = rng() * JITTER_RADIUS; yield { x: start.x + Math.cos(angle) * r, y: start.y + Math.sin(angle) * r }; } }, @@ -152,20 +145,25 @@ export const jitter: MovementStrategy = { /** * `walk` — an unbounded cumulative random walk: each step adds a random - * per-axis delta in `[-stepSize, +stepSize]`. The generator itself lets the + * per-axis delta in `[-WALK_STEP, +WALK_STEP]`. The per-step magnitude is + * deliberately several pixels so the walk actually roams — a ±1px walk over + * this many steps would drift only ~√N pixels net. The generator lets the * position drift freely; the executor's `reflect` policy mirrors it back * on-screen, so the cursor bounces off the edges instead of escaping. */ +const WALK_STEPS = 200; +const WALK_STEP = 4; + export const walk: MovementStrategy = { name: "walk", bounds: "reflect", *path(ctx: MoveContext): Generator { - const { start, config, rng } = ctx; + const { start, 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; + for (let i = 1; i <= WALK_STEPS; i++) { + x += (rng() * 2 - 1) * WALK_STEP; + y += (rng() * 2 - 1) * WALK_STEP; yield { x, y }; } }, @@ -173,21 +171,23 @@ export const walk: MovementStrategy = { /** * `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. + * on-screen endpoint `ARC_REACH` pixels away, bowed out by a control point + * offset perpendicular to the straight path. `ARC_STEPS` samples keep the + * curve smooth. Produces natural, hand-like curved motion. */ +const ARC_STEPS = 120; +const ARC_REACH = 300; + export const arc: MovementStrategy = { name: "arc", bounds: "clamp", *path(ctx: MoveContext): Generator { - const { start, width, height, config, rng } = ctx; - const reach: number = reachOf(config); + const { start, width, height, rng } = ctx; - // Endpoint: a random direction, `reach` away, clamped on-screen. + // Endpoint: a random direction, `ARC_REACH` away, clamped on-screen. const angle: number = rng() * 2 * Math.PI; - const endX: number = clamp(start.x + Math.cos(angle) * reach, width); - const endY: number = clamp(start.y + Math.sin(angle) * reach, height); + const endX: number = clamp(start.x + Math.cos(angle) * ARC_REACH, width); + const endY: number = clamp(start.y + Math.sin(angle) * ARC_REACH, height); // Control point: midpoint pushed along the perpendicular so the path // bows rather than running straight. Direction/magnitude randomized. @@ -196,12 +196,12 @@ export const arc: MovementStrategy = { 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 bow: number = (rng() * 2 - 1) * ARC_REACH * 0.5; const ctrlX: number = clamp(midX + (perpX / perpLen) * bow, width); const ctrlY: number = clamp(midY + (perpY / perpLen) * bow, height); - for (let i = 1; i <= config.stepCount; i++) { - const t: number = i / config.stepCount; + for (let i = 1; i <= ARC_STEPS; i++) { + const t: number = i / ARC_STEPS; const u: number = 1 - t; yield { x: u * u * start.x + 2 * u * t * ctrlX + t * t * endX, @@ -213,20 +213,23 @@ export const arc: MovementStrategy = { /** * `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`. + * start point over one full period, so it returns to the origin. + * `FIG8_AMP` sets its half-width (≈250px across); `FIG8_STEPS` samples keep + * the curve smooth. */ +const FIG8_STEPS = 90; +const FIG8_AMP = 125; + export const figureEight: MovementStrategy = { name: "figureEight", 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; + const { start } = ctx; + for (let i = 1; i <= FIG8_STEPS; i++) { + const t: number = (2 * Math.PI * i) / FIG8_STEPS; yield { - x: start.x + amp * Math.sin(t), - y: start.y + amp * Math.sin(t) * Math.cos(t), + x: start.x + FIG8_AMP * Math.sin(t), + y: start.y + FIG8_AMP * Math.sin(t) * Math.cos(t), }; } }, diff --git a/tests/config.test.ts b/tests/config.test.ts index 8bf903e..6ce811d 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -15,8 +15,6 @@ const NONE: ConfigOverrides = { moveInterval: undefined, checkInterval: undefined, stepDelay: undefined, - stepCount: undefined, - stepSize: undefined, pattern: undefined, verbose: undefined, }; @@ -46,12 +44,10 @@ describe("resolveConfig", () => { expect(cfg.checkInterval).toBe(2000); }); - test("stepDelay, stepCount, stepSize pass through untouched (no unit conversion)", () => { - const cli: ConfigOverrides = { ...NONE, stepDelay: 75, stepCount: 100, stepSize: 4 }; + test("stepDelay passes through untouched (no unit conversion)", () => { + const cli: ConfigOverrides = { ...NONE, stepDelay: 75 }; 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", () => { diff --git a/tests/configFile.test.ts b/tests/configFile.test.ts index 4e07dd6..b4ffe28 100644 --- a/tests/configFile.test.ts +++ b/tests/configFile.test.ts @@ -43,7 +43,7 @@ describe("loadConfigFile (explicit path)", () => { // Fields not in the file are undefined. expect(result!.checkInterval).toBeUndefined(); expect(result!.stepDelay).toBeUndefined(); - expect(result!.stepCount).toBeUndefined(); + expect(result!.pattern).toBeUndefined(); }); test("returns all-undefined overrides for an empty object", () => { @@ -81,8 +81,8 @@ describe("loadConfigFile (explicit path)", () => { }); test("throws on non-positive numeric values", () => { - const negative = writeFixture("neg.json", JSON.stringify({ stepCount: -1 })); - expect(() => loadConfigFile(negative)).toThrow(/'stepCount'.*positive number/); + const negative = writeFixture("neg.json", JSON.stringify({ moveInterval: -1 })); + expect(() => loadConfigFile(negative)).toThrow(/'moveInterval'.*positive number/); const zero = writeFixture("zero.json", JSON.stringify({ stepDelay: 0 })); expect(() => loadConfigFile(zero)).toThrow(/'stepDelay'.*positive number/); @@ -98,11 +98,10 @@ describe("loadConfigFile (explicit path)", () => { 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 })); + test("accepts a known pattern", () => { + const path = writeFixture("pattern.json", JSON.stringify({ pattern: "arc" })); const result = loadConfigFile(path); expect(result!.pattern).toBe("arc"); - expect(result!.stepSize).toBe(3); }); test("normalizes a loosely-spelled pattern to its canonical name", () => { @@ -117,9 +116,23 @@ describe("loadConfigFile (explicit path)", () => { 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/); + test("tolerates obsolete stepCount/stepSize keys, ignoring their values", () => { + // Seeded by pre-1.3.0 installs; must not hard-fail on upgrade. They're + // accepted but not surfaced as overrides (and even an invalid value, + // like a negative, is ignored rather than rejected). + const path = writeFixture( + "obsolete.json", + JSON.stringify({ moveInterval: 60, stepCount: -1, stepSize: 3 }), + ); + const result = loadConfigFile(path); + expect(result).not.toBeNull(); + expect(result!.moveInterval).toBe(60); + expect(result as unknown as Record).not.toHaveProperty("stepCount"); + }); + + test("still rejects a genuinely unknown key", () => { + const path = writeFixture("unknown.json", JSON.stringify({ movInterval: 60 })); + expect(() => loadConfigFile(path)).toThrow(/unknown key 'movInterval'/); }); }); diff --git a/tests/executor.test.ts b/tests/executor.test.ts index 07fade9..53de661 100644 --- a/tests/executor.test.ts +++ b/tests/executor.test.ts @@ -61,8 +61,13 @@ function fixed(points: Point[], bounds: BoundsPolicy): MovementStrategy { }; } -function ctxOf(start: Point, width: number, height: number, config?: Partial): MoveContext { - return { start, width, height, config: { ...DEFAULT_CONFIG, ...config }, rng: Math.random }; +function ctxOf(start: Point, width: number, height: number): MoveContext { + return { start, width, height, rng: Math.random }; +} + +/** A full `Config` for the executor's pacing; only `stepDelay` matters here. */ +function cfgOf(config?: Partial): Config { + return { ...DEFAULT_CONFIG, ...config }; } describe("executePath — outcomes", () => { @@ -74,7 +79,7 @@ describe("executePath — outcomes", () => { { x: 502, y: 500 }, { x: 503, y: 500 }, ]; - const outcome = await executePath(fixed(pts, "clamp"), ctxOf(start, dev.w, dev.h), dev, noopLog); + const outcome = await executePath(fixed(pts, "clamp"), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf()); expect(outcome).toBe("completed"); // 3 steps + 1 restore. expect(dev.commanded).toEqual([...pts, start]); @@ -90,7 +95,7 @@ describe("executePath — outcomes", () => { ]; // 2nd getPosition call reports the user elsewhere. dev.overrides.set(2, { x: 9, y: 9 }); - const outcome = await executePath(fixed(pts, "clamp"), ctxOf(start, dev.w, dev.h), dev, noopLog); + const outcome = await executePath(fixed(pts, "clamp"), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf()); expect(outcome).toBe("interrupted"); // Commanded points 1 and 2 only; never restored to start. expect(dev.commanded).toEqual([pts[0]!, pts[1]!]); @@ -100,7 +105,7 @@ describe("executePath — outcomes", () => { 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); + const outcome = await executePath(fixed(pts, "abort"), ctxOf({ x: 10, y: 10 }, 100, 100), dev, noopLog, cfgOf()); expect(outcome).toBe("aborted"); expect(dev.commanded).toEqual([]); }); @@ -114,7 +119,7 @@ describe("executePath — bounds policies", () => { { 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); + await executePath(fixed(pts, "clamp"), ctxOf({ x: 50, y: 50 }, 100, 100), dev, noopLog, cfgOf()); expect(dev.commanded[0]).toEqual({ x: 2, y: 50 }); expect(dev.commanded[1]).toEqual({ x: 97, y: 50 }); }); @@ -123,7 +128,7 @@ describe("executePath — bounds policies", () => { 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); + await executePath(fixed(pts, "reflect"), ctxOf({ x: 50, y: 50 }, 100, 100), dev, noopLog, cfgOf()); expect(dev.commanded[0]).toEqual({ x: 74, y: 50 }); }); }); @@ -140,7 +145,7 @@ describe("executePath — readback tolerance", () => { // not the user). 2px is within READBACK_TOLERANCE, so the sweep runs on. dev.overrides.set(1, { x: 512, y: 501 }); dev.overrides.set(2, { x: 518, y: 499 }); - const outcome = await executePath(fixed(pts, "clamp"), ctxOf(start, dev.w, dev.h), dev, noopLog); + const outcome = await executePath(fixed(pts, "clamp"), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf()); expect(outcome).toBe("completed"); expect(dev.commanded).toEqual([...pts, start]); }); @@ -154,7 +159,7 @@ describe("executePath — readback tolerance", () => { ]; // First readback is 3px off -> exceeds the 2px tolerance -> real user. dev.overrides.set(1, { x: 513, y: 500 }); - const outcome = await executePath(fixed(pts, "clamp"), ctxOf(start, dev.w, dev.h), dev, noopLog); + const outcome = await executePath(fixed(pts, "clamp"), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf()); expect(outcome).toBe("interrupted"); expect(dev.commanded).toEqual([pts[0]!]); }); @@ -165,7 +170,7 @@ describe("executePath — rounding & pacing", () => { const dev = new FakeDevice(); const start = { x: 500, y: 500 }; const pts = [{ x: 10.4, y: 20.6 }]; // -> (10, 21) - const outcome = await executePath(fixed(pts, "clamp"), ctxOf(start, dev.w, dev.h), dev, noopLog); + const outcome = await executePath(fixed(pts, "clamp"), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf()); expect(outcome).toBe("completed"); expect(dev.commanded[0]).toEqual({ x: 10, y: 21 }); }); @@ -176,7 +181,7 @@ describe("executePath — rounding & pacing", () => { { x: 501, y: 500 }, { x: 502, y: 500 }, ]; - await executePath(fixed(pts, "clamp"), ctxOf({ x: 500, y: 500 }, dev.w, dev.h, { stepDelay: 7 }), dev, noopLog); + await executePath(fixed(pts, "clamp"), ctxOf({ x: 500, y: 500 }, dev.w, dev.h), dev, noopLog, cfgOf({ stepDelay: 7 })); expect(dev.sleeps).toEqual([7, 7]); }); }); diff --git a/tests/keeper.test.ts b/tests/keeper.test.ts index 9090814..420b7be 100644 --- a/tests/keeper.test.ts +++ b/tests/keeper.test.ts @@ -73,9 +73,10 @@ describe("runKeeper", () => { // 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. + await runUntilStop(quietConfig({ moveInterval: 0, pattern: "line" }), dev); + // A sweep issued setPosition commands (the sweep is interrupted by the + // sleep budget before it finishes, but many steps land); an idle loop + // with no sweep would have issued none. expect(dev.commanded.length).toBeGreaterThanOrEqual(3); }); @@ -84,7 +85,7 @@ describe("runKeeper", () => { // 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); + await runUntilStop(quietConfig({ moveInterval: 0, pattern: "line" }), dev); expect(dev.commanded.length).toBe(0); }); }); diff --git a/tests/strategies.test.ts b/tests/strategies.test.ts index 33f1932..4caee7c 100644 --- a/tests/strategies.test.ts +++ b/tests/strategies.test.ts @@ -9,8 +9,6 @@ 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, @@ -42,56 +40,50 @@ 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); + test("emits its full 250-step, 250px sweep along +x with no vertical drift (preserved default)", () => { + const pts = [...line.path(ctxOf({ start: { x: 500, y: 500 } }))]; + expect(pts.length).toBe(250); expect(pts.every((p) => p.y === 500)).toBe(true); - 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]); + // 1px per step: 501..750. + expect(pts[0]!.x).toBe(501); + expect(pts.at(-1)!.x).toBe(750); }); test("reverses direction when there is no room to the right", () => { - const pts = [...line.path(ctxOf({ start: { x: 90, y: 10 }, width: 100, config: { stepCount: 20, stepSize: 1 } }))]; + const pts = [...line.path(ctxOf({ start: { x: 90, y: 10 }, width: 100 }))]; expect(pts[0]!.x).toBe(89); - expect(pts.at(-1)!.x).toBe(70); + // Heads left: each step decreases x by 1. + expect(pts[1]!.x).toBe(88); + expect(pts.at(-1)!.x).toBe(90 - 250); }); }); 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]); + test("moves 1px on both axes toward the roomy corner for 250 steps", () => { + const pts = [...diagonal.path(ctxOf({ start: { x: 500, y: 500 } }))]; + expect(pts.length).toBe(250); + expect(pts[0]!).toEqual({ x: 501, y: 501 }); + expect(pts.at(-1)!).toEqual({ x: 750, y: 750 }); }); }); 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); + test("stays within its fixed radius of start across its fixed step count", () => { + const radius = 30; // JITTER_RADIUS const start = { x: 500, y: 500 }; - const pts = [...jitter.path(ctxOf({ start, config: { stepCount, stepSize: size }, rng: mulberry32(1) }))]; - expect(pts.length).toBe(stepCount); + const pts = [...jitter.path(ctxOf({ start, rng: mulberry32(1) }))]; + expect(pts.length).toBe(80); // JITTER_STEPS for (const p of pts) { expect(Math.hypot(p.x - start.x, p.y - start.y)).toBeLessThanOrEqual(radius + 1e-9); } @@ -101,35 +93,35 @@ describe("jitter", () => { 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); + const pts = [...walk.path(ctxOf({ start, rng: () => 0.5 }))]; + expect(pts.length).toBe(200); // WALK_STEPS // (0.5*2 - 1) === 0, so every step delta is zero. expect(pts.every((p) => p.x === start.x && p.y === start.y)).toBe(true); }); - test("accumulates deltas step over step", () => { - const pts = [...walk.path(ctxOf({ config: { stepCount: 3, stepSize: 4 }, rng: mulberry32(42) }))]; - expect(pts.length).toBe(3); + test("accumulates finite deltas step over step", () => { + const pts = [...walk.path(ctxOf({ rng: mulberry32(42) }))]; + expect(pts.length).toBe(200); expect(pts.every((p) => Number.isFinite(p.x) && Number.isFinite(p.y))).toBe(true); }); }); describe("arc", () => { - test("emits 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); + test("emits its fixed step count of finite points, deterministic under a fixed seed", () => { + const pts = [...arc.path(ctxOf({ rng: mulberry32(7) }))]; + expect(pts.length).toBe(120); // ARC_STEPS expect(pts.every((p) => Number.isFinite(p.x) && Number.isFinite(p.y))).toBe(true); - // 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)!); + // Same seed -> same endpoint (t = 1 at the final step is a stable point). + const again = [...arc.path(ctxOf({ rng: mulberry32(7) }))]; + expect(pts.at(-1)).toEqual(again.at(-1)!); }); }); describe("figureEight", () => { test("returns to the start point after one full period", () => { const start = { x: 600, y: 400 }; - const pts = [...figureEight.path(ctxOf({ start, config: { stepCount: 40, stepSize: 10 } }))]; - expect(pts.length).toBe(40); + const pts = [...figureEight.path(ctxOf({ start }))]; + expect(pts.length).toBe(90); // FIG8_STEPS expect(pts.at(-1)!.x).toBeCloseTo(start.x, 6); expect(pts.at(-1)!.y).toBeCloseTo(start.y, 6); });