From ec33648e74454e703519f115778e5e1930873681 Mon Sep 17 00:00:00 2001 From: nokeo08 Date: Fri, 14 Aug 2026 12:56:22 -0500 Subject: [PATCH] Remove stepCount/stepSize; patterns own their geometry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stepCount and stepSize knobs were two controls for one quantity users actually care about (reach), and the number of steps is an implementation detail nobody meaningfully tunes. Each pattern has a natural size and resolution — a jitter is inherently small, an arc a broad curve — so those now live as constants in each strategy rather than as global config. - strategies.ts: each pattern defines its own step count and size; MoveContext drops `config` down to pure geometry (start/width/height/rng), and the module no longer imports Config at all (dissolving the type-only-import cycle workaround). line stays byte-for-byte: 250 one-pixel steps. - executor.ts: executePath takes `config` for pacing (stepDelay); the path itself needs nothing from it. - config.ts / cli.ts / move.ts / config.default.json: drop stepCount and stepSize from the type, seed, validation, resolver, CLI flags (-n, -s), and help. stepDelay stays as the one pacing lever. - configFile.ts: tolerate the removed keys instead of rejecting them — every pre-1.3.0 install seeded stepCount, so a hard "unknown key" failure on upgrade is avoided. They're ignored with a one-line stderr notice; genuine unknown keys still error. The -n/--step-count CLI flag (shipped since 1.0.0) is now an unknown option; config files degrade gracefully, command lines don't. Stays in the unpushed 1.3.0 release. 64 tests pass. --- CHANGELOG.md | 15 ++-- README.md | 47 ++++++------ scripts/config.default.json | 2 - src/cli.ts | 13 +--- src/config.ts | 25 ++----- src/configFile.ts | 52 +++++++++----- src/executor.ts | 11 ++- src/keeper.ts | 4 +- src/move.ts | 2 - src/strategies.ts | 139 ++++++++++++++++++------------------ tests/config.test.ts | 8 +-- tests/configFile.test.ts | 31 +++++--- tests/executor.test.ts | 27 ++++--- tests/keeper.test.ts | 9 +-- tests/strategies.test.ts | 72 +++++++++---------- 15 files changed, 233 insertions(+), 224 deletions(-) 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); });