From 7e632b3e9df215e36dbf5e20b82a80895a911a7c Mon Sep 17 00:00:00 2001 From: nokeo08 Date: Mon, 17 Aug 2026 14:36:18 -0500 Subject: [PATCH] Add loop mode (--loop): repeat movement until user activity Introduce a continuous "loop" setting so a triggered sweep keeps the cursor moving until the user moves the mouse (or Ctrl+C), instead of firing a single sweep. - strategies.ts: add optional `loopPath` to MovementStrategy; give `line` and `diagonal` infinite loop generators that pick a direction once and ramp forever (4px/step). Their finite `path` and declared `bounds` are unchanged, so single-sweep behavior is identical. - executor.ts: add ExecuteOptions { restore?, bounds?, loop? }. Omitting options reproduces the original single-sweep contract exactly. - keeper.ts: in loop mode, run an infinite loopPath once (stopped only by interruption) or chain a finite path cycle after cycle; force `reflect` bounds for every pattern and suppress the between-cycle restore, so line/diagonal bounce edge-to-edge instead of stopping at the first edge. - config plumbing: new boolean `loop` through config.default.json, config.ts, configFile.ts, cli.ts (-l/--loop), and move.ts, mirroring the existing `verbose` precedence. - docs: README loop-mode section + usage/validation updates; CHANGELOG Unreleased entry. - tests: loopPath generators, executor options (bounds override, loop selection, restore suppression), config/configFile loop plumbing, and keeper-level loop behavior (ramps far vs. bounded single-sweep, chained cycles). 79 pass. --- CHANGELOG.md | 12 ++++++ README.md | 45 +++++++++++++++++---- scripts/config.default.json | 3 +- src/cli.ts | 13 ++++++ src/config.ts | 15 ++++++- src/configFile.ts | 6 +++ src/executor.ts | 49 ++++++++++++++++++++--- src/keeper.ts | 60 ++++++++++++++++++++++------ src/move.ts | 1 + src/strategies.ts | 56 +++++++++++++++++++++++--- tests/config.test.ts | 16 ++++++++ tests/configFile.test.ts | 11 ++++++ tests/executor.test.ts | 79 +++++++++++++++++++++++++++++++++++++ tests/keeper.test.ts | 34 ++++++++++++++++ tests/strategies.test.ts | 28 +++++++++++++ 15 files changed, 397 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c249a3..3ce8a1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to `move` are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- Loop mode: `-l` / `--loop` (and the `loop` config key) keep the mouse + moving after a sweep is triggered until real user activity is detected, + instead of firing a single sweep. In loop mode the cursor is never restored + between iterations and every pattern's bounds policy is forced to `reflect`, + so `line` and `diagonal` bounce edge-to-edge across the screen (a + roaming-DVD effect) rather than stopping at the first edge. Patterns with a + finite path (`jitter`, `walk`, `arc`, `figureEight`) chain that path cycle + after cycle. Interruption remains mouse-movement only. + ## [1.3.3] - 2026-08-17 ### Changed diff --git a/README.md b/README.md index ac1991c..7d3e73d 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,9 @@ Options: size and speed. -V, --verbose Log every sweep, interrupt, and bounds event (default prints only the startup banner). + -l, --loop Loop mode: once a sweep is triggered, + keep moving until you move the mouse (or + Ctrl+C), instead of firing a single sweep. Precedence (highest wins): CLI flags > config file > built-in defaults. ``` @@ -180,14 +183,15 @@ doesn't set. "checkInterval": 10, "stepDelay": 50, "pattern": "line", - "verbose": false + "verbose": false, + "loop": false } ``` 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, `pattern` is a -movement strategy name, `verbose` is a boolean. +movement strategy name, `verbose` and `loop` are booleans. > The obsolete `stepCount` / `stepSize` keys (removed in 1.3.0) are > tolerated for backward compatibility: they're ignored with a one-line @@ -223,16 +227,36 @@ The loader is strict: case and separators (`-`, `_`, spaces), so `figure-eight` and `figureEight` are equivalent. - `verbose` must be a boolean. +- `loop` must be a boolean. Any validation failure prints a message naming the file and the offending key to `stderr` and exits `2`. -### Known limitation: `verbose` can be turned on but not off from the CLI +### Loop mode (`--loop`) -`--verbose` is a presence-only flag (there is no `--no-verbose`). If the -config file sets `"verbose": true`, the CLI cannot force quiet mode in -that invocation. Workarounds: edit the file, or point at a different -file with `--config`. +By default a triggered sweep runs once and stops. With `-l` / `--loop` (or +`"loop": true` in the config file) the movement instead repeats until you +move the mouse (or press `Ctrl+C`) — a "keep moving until I'm back" mode. +It pairs naturally with the roaming patterns: + +```sh +move --pattern diagonal --loop # roaming-DVD bounce around the screen +move --pattern figureEight --loop # traces the eight over and over +``` + +In loop mode the cursor is never restored between iterations, and every +pattern's bounds policy is forced to `reflect`, so `line` and `diagonal` +bounce edge-to-edge across the whole screen instead of ending at the first +edge. Interruption is detected via mouse movement only — there is no +keyboard hook — so if you resume by typing without touching the mouse, the +cursor keeps cycling until you nudge it or stop the process. + +### Known limitation: `verbose` and `loop` can be turned on but not off from the CLI + +`--verbose` and `--loop` are presence-only flags (there is no +`--no-verbose` / `--no-loop`). If the config file sets `"verbose": true` or +`"loop": true`, the CLI cannot force it back off in that invocation. +Workarounds: edit the file, or point at a different file with `--config`. ## How it works @@ -307,6 +331,13 @@ to milliseconds before handing the resolved `Config` to `runKeeper`. the next idle-check sees "no movement" and doesn't misread the synthetic activity as real user input. +In loop mode (`--loop`) step 2 repeats until the user interrupts: a +pattern with an infinite `loopPath` (`line`, `diagonal`) runs that single +never-ending path, while the others chain their finite path cycle after +cycle. The restore in step 3 is skipped so successive cycles flow from where +the last left off, and the bounds policy is forced to `reflect` for every +pattern so edge-seeking motion bounces instead of stopping. + Comparing against the last commanded (rounded) point — not the strategy's ideal, possibly fractional target — is what lets curved and stochastic patterns run without every rounded step looking like user activity. The diff --git a/scripts/config.default.json b/scripts/config.default.json index 3ff002e..d57ea95 100644 --- a/scripts/config.default.json +++ b/scripts/config.default.json @@ -3,5 +3,6 @@ "checkInterval": 10, "stepDelay": 50, "pattern": "line", - "verbose": false + "verbose": false, + "loop": false } diff --git a/src/cli.ts b/src/cli.ts index d1a66aa..bd4eb26 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -19,6 +19,8 @@ * -p, --pattern Movement strategy name (see strategies.ts). * -V, --verbose Enable per-sweep / interrupt / bounds logging. * (`-V` capital because `-v` is `--version`.) + * -l, --loop Loop mode: once triggered, keep moving + * until the user moves the mouse (or Ctrl+C). * * Numeric overrides are layered (CLI > file > DEFAULT_CONFIG) by * `resolveConfig` in `config.ts`; this module only parses and validates. @@ -55,6 +57,11 @@ export interface ParsedCliArgs { * even though the CLI has no off-switch today. */ verbose: boolean | undefined; + /** + * `true` when `-l`/`--loop` was passed; `undefined` when it was not. + * Same `undefined`-not-`false` rationale as `verbose`. + */ + loop: boolean | undefined; } /** @@ -105,6 +112,7 @@ export function parseCliArgs(): ParsedCliArgs { "step-delay": { type: "string", short: "d" }, pattern: { type: "string", short: "p" }, verbose: { type: "boolean", short: "V" }, + loop: { type: "boolean", short: "l" }, }, strict: true, allowPositionals: false, @@ -127,6 +135,7 @@ export function parseCliArgs(): ParsedCliArgs { stepDelay: parsePositiveNumber("step-delay", values["step-delay"] as string | undefined), pattern: parsePatternName(values.pattern as string | undefined), verbose: values.verbose === true ? true : undefined, + loop: values.loop === true ? true : undefined, }; } @@ -173,6 +182,9 @@ Options: Each pattern defines its own size and speed. -V, --verbose Log every sweep, interrupt, and bounds event (default prints only the startup banner). + -l, --loop Loop mode: once a sweep is triggered, + keep moving until you move the mouse (or + Ctrl+C), instead of firing a single sweep. Precedence (highest wins): CLI flags > config file > built-in defaults. @@ -181,6 +193,7 @@ Examples: move --move-interval 180 --check-interval 5 move -m 300 -V move --pattern arc + move --pattern diagonal --loop move --config ~/myprofile.json `); } diff --git a/src/config.ts b/src/config.ts index 9951285..6854dc0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -47,6 +47,10 @@ import seedRaw from "../scripts/config.default.json" with { type: "json" }; * pattern owns its own size and step count. * - `verbose` — whether per-sweep / interrupt / bounds events are * logged. The startup banner is always printed. + * - `loop` — loop mode: once a sweep is triggered, keep + * repeating the movement until the user moves the mouse + * (or Ctrl+C), rather than firing a single sweep. See + * `keeper.ts` for how the pattern is repeated. */ export interface Config { readonly moveInterval: number; @@ -54,6 +58,7 @@ export interface Config { readonly stepDelay: number; readonly pattern: PatternName; readonly verbose: boolean; + readonly loop: boolean; } /** @@ -68,6 +73,7 @@ interface SeedShape { stepDelay: number; // milliseconds pattern: string; // strategy name verbose: boolean; + loop: boolean; } function assertSeedShape(raw: unknown): asserts raw is SeedShape { @@ -87,6 +93,9 @@ function assertSeedShape(raw: unknown): asserts raw is SeedShape { if (typeof r.verbose !== "boolean") { throw new Error(`scripts/config.default.json: 'verbose' must be a boolean (got ${JSON.stringify(r.verbose)})`); } + if (typeof r.loop !== "boolean") { + throw new Error(`scripts/config.default.json: 'loop' must be a boolean (got ${JSON.stringify(r.loop)})`); + } } assertSeedShape(seedRaw); @@ -105,6 +114,7 @@ export const DEFAULT_CONFIG: Config = { stepDelay: seed.stepDelay, pattern: seed.pattern, verbose: seed.verbose, + loop: seed.loop, }; /** @@ -125,7 +135,8 @@ export const DEFAULT_CONFIG: Config = { * was not passed and `true` when it was. There is no CLI off-switch * today, so CLI `false` doesn't occur — a file-set `verbose: true` cannot * be overridden back to false from the command line (see the Configuration - * section of the README). + * section of the README). `loop` behaves identically: `-l/--loop` sets it + * `true`, and a file-set `loop: true` can't be switched off from the CLI. */ export interface ConfigOverrides { readonly moveInterval: number | undefined; @@ -133,6 +144,7 @@ export interface ConfigOverrides { readonly stepDelay: number | undefined; readonly pattern: string | undefined; readonly verbose: boolean | undefined; + readonly loop: boolean | undefined; } /** @@ -199,5 +211,6 @@ export function resolveConfig(file: ConfigOverrides | null, cli: ConfigOverrides stepDelay: pickRaw(cli.stepDelay, file?.stepDelay, DEFAULT_CONFIG.stepDelay), pattern: pickRaw(cli.pattern, file?.pattern, DEFAULT_CONFIG.pattern), verbose: pickRaw(cli.verbose, file?.verbose, DEFAULT_CONFIG.verbose), + loop: pickRaw(cli.loop, file?.loop, DEFAULT_CONFIG.loop), }; } diff --git a/src/configFile.ts b/src/configFile.ts index 78d780a..49c0bbe 100644 --- a/src/configFile.ts +++ b/src/configFile.ts @@ -12,6 +12,7 @@ * stepDelay number milliseconds, positive * pattern string a registered strategy name * verbose boolean + * loop 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 @@ -39,6 +40,7 @@ const ALLOWED_KEYS: ReadonlySet = new Set([ "stepDelay", "pattern", "verbose", + "loop", ]); /** @@ -173,5 +175,9 @@ export function loadConfigFile(explicitPath: string | undefined): ConfigOverride "verbose" in parsed ? requireBoolean("verbose", parsed.verbose, path) : undefined, + loop: + "loop" in parsed + ? requireBoolean("loop", parsed.loop, path) + : undefined, }; } diff --git a/src/executor.ts b/src/executor.ts index 490af61..17c68a5 100644 --- a/src/executor.ts +++ b/src/executor.ts @@ -47,6 +47,31 @@ export interface Logger { */ export type SweepOutcome = "completed" | "interrupted" | "aborted"; +/** + * Per-call knobs for `executePath`. All optional; the defaults reproduce the + * original single-sweep behavior exactly, so every existing caller and test + * is unaffected. + * + * - `restore` — restore the cursor to `ctx.start` after a clean sweep. + * Default `true`. Loop (`--loop`) mode passes `false`: + * chained cycles must not snap back between iterations, and an + * infinite `loopPath` never reaches the restore anyway. + * - `bounds` — override the strategy's declared `BoundsPolicy`. Loop mode + * forces `"reflect"` for every pattern so edge-seeking paths + * bounce off the screen instead of aborting (`line`) or + * sticking in a corner (`clamp`). Absent, the strategy's own + * `bounds` is used, so single-sweep behavior is unchanged. + * - `loop` — prefer the strategy's infinite `loopPath` when it defines + * one. Falls back to `path` when the strategy has no + * `loopPath`, so a plain chained-repeat caller can pass this + * unconditionally. + */ +export interface ExecuteOptions { + readonly restore?: boolean; + readonly bounds?: BoundsPolicy; + readonly loop?: boolean; +} + /** * Slack, in pixels, allowed between the coordinate we commanded and the one * we read back before calling it real-user activity. Absorbs the sub-pixel @@ -145,7 +170,15 @@ function timestamp(): string { * 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. + * idle-check sees no net movement, and `completed` is returned — unless + * `options.restore === false` (loop mode), in which case the cursor is + * left where the last step put it. + * + * `options` (all optional, see `ExecuteOptions`) let loop mode reuse + * this same driver: `bounds` overrides the strategy's policy (loop mode + * forces `reflect`), `loop` selects the strategy's infinite `loopPath`, and + * `restore` suppresses the snap-back. Omitting `options` reproduces the + * original single-sweep contract exactly. * * `config` supplies only the pacing (`stepDelay`); a strategy's geometry is * entirely self-contained, so the path itself needs nothing from it. @@ -156,13 +189,17 @@ export async function executePath( device: Device, log: Logger, config: Config, + options?: ExecuteOptions, ): Promise { const { start, width, height } = ctx; + const policy: BoundsPolicy = options?.bounds ?? strategy.bounds; + const path: Iterable = + options?.loop && strategy.loopPath ? strategy.loopPath(ctx) : strategy.path(ctx); log.event(`Simulating activity (${strategy.name}) at ${timestamp()}...`); - for (const target of strategy.path(ctx)) { - const point: Point | null = resolveTarget(strategy.bounds, target, width, height); + for (const target of path) { + const point: Point | null = resolveTarget(policy, target, width, height); if (point === null) { log.event(`Out of bounds at ${timestamp()}; aborting simulation.`); return "aborted"; @@ -191,7 +228,9 @@ export async function executePath( } } - await device.setPosition({ x: Math.round(start.x), y: Math.round(start.y) }); - log.event("Mouse moved."); + if (options?.restore !== false) { + await device.setPosition({ x: Math.round(start.x), y: Math.round(start.y) }); + log.event("Mouse moved."); + } return "completed"; } diff --git a/src/keeper.ts b/src/keeper.ts index e06711c..7d3448f 100644 --- a/src/keeper.ts +++ b/src/keeper.ts @@ -24,7 +24,7 @@ */ import { createNutDevice, type Device, type Point } from "./device.ts"; -import { executePath, type Logger } from "./executor.ts"; +import { executePath, type Logger, type SweepOutcome } from "./executor.ts"; import { DEFAULT_PATTERN, STRATEGIES, type MoveContext } from "./strategies.ts"; import type { Config } from "./config.ts"; @@ -46,24 +46,60 @@ function makeLogger(verbose: boolean): Logger { } /** - * Perform a single synthetic mouse-activity sweep. + * Perform synthetic mouse activity once the keeper decides the cursor is + * idle. * - * Snapshots the cursor and screen (re-read every call so monitor changes - * are handled), selects the configured strategy from the registry, and - * hands the resulting path to `executePath`, which owns bounds, pacing, - * interrupt detection, and restore-on-clean. An unknown `config.pattern` - * falls back to the default strategy defensively; validation at the CLI / - * config-file boundary should prevent that from ever happening. + * Snapshots the screen (re-read every call so monitor changes are handled) + * and selects the configured strategy from the registry. An unknown + * `config.pattern` falls back to the default strategy defensively; validation + * at the CLI / config-file boundary should prevent that from ever happening. + * + * Single-sweep mode (`config.loop === false`) runs exactly one sweep via + * `executePath`, which owns bounds, pacing, interrupt detection, and + * restore-on-clean — unchanged from before loop mode existed. + * + * Loop mode (`config.loop === true`) keeps the cursor moving until the + * user moves the mouse (or Ctrl+C). Two things change for every pattern: + * the cursor is never restored between iterations (`restore: false`), and the + * bounds policy is forced to `reflect` so edge-seeking paths bounce off the + * screen instead of aborting (`line`) or sticking in a corner (`clamp`). + * Patterns that define an infinite `loopPath` (`line`, `diagonal`) run it once + * and are stopped only by interruption; the rest have their finite `path` + * chained, re-read from the cursor's current position each cycle. Per-cycle + * event logs are suppressed to avoid unbounded output — one line brackets the + * run at each end. */ async function simulateActivity(config: Config, log: Logger, device: Device): Promise { - const start: Point = await device.getPosition(); const width: number = await device.width(); const height: number = await device.height(); - const strategy = STRATEGIES[config.pattern] ?? STRATEGIES[DEFAULT_PATTERN]!; - const ctx: MoveContext = { start, width, height, rng: Math.random }; - await executePath(strategy, ctx, device, log, config); + if (!config.loop) { + const start: Point = await device.getPosition(); + const ctx: MoveContext = { start, width, height, rng: Math.random }; + await executePath(strategy, ctx, device, log, config); + return; + } + + log.event(`Loop mode (${strategy.name}); repeating until you move the mouse.`); + const cycleLog: Logger = { info: log.info, event: (): void => {} }; + const loopOpts = { restore: false, bounds: "reflect" as const, loop: true }; + + let cycles = 0; + let outcome: SweepOutcome; + do { + const start: Point = await device.getPosition(); + const ctx: MoveContext = { start, width, height, rng: Math.random }; + outcome = await executePath(strategy, ctx, device, cycleLog, config, loopOpts); + cycles++; + // Spin guard for the chained-repeat path: a finite strategy that + // yielded nothing would otherwise return "completed" instantly in a + // tight loop. Sleeping one stepDelay makes that harmless. An infinite + // loopPath never returns "completed", so this branch is skipped there. + if (outcome === "completed") await device.sleep(config.stepDelay); + } while (outcome === "completed"); + + log.event(`Loop run ended after ${cycles} cycle(s): ${outcome}.`); } /** diff --git a/src/move.ts b/src/move.ts index ba44615..3659845 100755 --- a/src/move.ts +++ b/src/move.ts @@ -121,6 +121,7 @@ const cliOverrides: ConfigOverrides = { stepDelay: cliArgs.stepDelay, pattern: cliArgs.pattern, verbose: cliArgs.verbose, + loop: cliArgs.loop, }; const config = resolveConfig(fileOverrides, cliOverrides); diff --git a/src/strategies.ts b/src/strategies.ts index a01ec53..427ddf3 100644 --- a/src/strategies.ts +++ b/src/strategies.ts @@ -57,16 +57,31 @@ export interface MoveContext { /** * A named movement pattern. * - * - `name` — registry key, also the value accepted by `--pattern` / the - * `pattern` config key. - * - `bounds` — how the executor confines this pattern to the screen. - * - `path` — pure generator of ideal (possibly fractional) targets, - * emitted in visiting order. Should not re-emit `start`. + * - `name` — registry key, also the value accepted by `--pattern` / the + * `pattern` config key. + * - `bounds` — how the executor confines this pattern to the screen. + * - `path` — pure generator of ideal (possibly fractional) targets, + * emitted in visiting order. Should not re-emit `start`. + * - `loopPath` — optional infinite variant for loop mode (`--loop`). + * A pattern defines it when its finite `path` doesn't chain + * cleanly under repetition: `line`/`diagonal` re-derive their + * direction from the cursor's position every cycle, so chained + * repetition oscillates in a band near an edge instead of + * crossing the screen. An infinite generator picks its + * direction once and ramps forever; the executor's `reflect` + * policy (forced on in loop mode) folds the monotonic ramp + * into an edge-to-edge bounce. Absent this, loop mode simply + * chains `path` — correct for patterns whose finite path is a + * self-contained cyclic unit (`jitter`, `walk`, `arc`, + * `figureEight`). The executor stops either kind on real user + * activity; an infinite `loopPath` therefore only ever ends + * by interruption. */ export interface MovementStrategy { readonly name: string; readonly bounds: BoundsPolicy; path(ctx: MoveContext): Iterable; + loopPath?(ctx: MoveContext): Iterable; } /** Clamp `v` into the inclusive pixel range `[0, max - 1]`. */ @@ -84,8 +99,14 @@ function clamp(v: number, max: number): number { * 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). + * + * In loop mode `loopPath` ramps x in one direction forever (loop mode + * forces `reflect`, so the direction never matters and the ramp bounces edge + * to edge). `LINE_LOOP_STEP` is several pixels per step rather than one so a + * screen crossing takes seconds, not minutes, at the default cadence. */ const LINE_STEPS = 250; +const LINE_LOOP_STEP = 4; export const line: MovementStrategy = { name: "line", @@ -97,6 +118,14 @@ export const line: MovementStrategy = { yield { x: start.x + i * dx, y: start.y }; } }, + *loopPath(ctx: MoveContext): Generator { + const { start } = ctx; + let x: number = start.x; + for (;;) { + x += LINE_LOOP_STEP; + yield { x, y: start.y }; + } + }, }; /** @@ -104,8 +133,15 @@ export const line: MovementStrategy = { * chosen independently by available room, so the sweep heads toward the * roomiest corner and stays on-screen. 250 single-pixel steps per axis * (≈250px reach), matching `line`'s magnitude. + * + * In loop mode `loopPath` ramps both axes forever under the forced + * `reflect` policy. Because the x and y travel ranges have different spans, + * their triangle waves have different periods, so the path precesses across + * the whole screen — the roaming-DVD bounce — rather than retracing one 45° + * line. */ const DIAGONAL_STEPS = 250; +const DIAGONAL_LOOP_STEP = 4; export const diagonal: MovementStrategy = { name: "diagonal", @@ -118,6 +154,16 @@ export const diagonal: MovementStrategy = { yield { x: start.x + i * dx, y: start.y + i * dy }; } }, + *loopPath(ctx: MoveContext): Generator { + const { start } = ctx; + let x: number = start.x; + let y: number = start.y; + for (;;) { + x += DIAGONAL_LOOP_STEP; + y += DIAGONAL_LOOP_STEP; + yield { x, y }; + } + }, }; /** diff --git a/tests/config.test.ts b/tests/config.test.ts index 6ce811d..0792bce 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -17,6 +17,7 @@ const NONE: ConfigOverrides = { stepDelay: undefined, pattern: undefined, verbose: undefined, + loop: undefined, }; describe("resolveConfig", () => { @@ -78,6 +79,21 @@ describe("resolveConfig", () => { const cfg = resolveConfig(null, NONE); expect(cfg.verbose).toBe(DEFAULT_CONFIG.verbose); }); + + test("loop: CLI true wins over file false", () => { + const cfg = resolveConfig({ ...NONE, loop: false }, { ...NONE, loop: true }); + expect(cfg.loop).toBe(true); + }); + + test("loop: file true wins over default (no CLI)", () => { + const cfg = resolveConfig({ ...NONE, loop: true }, NONE); + expect(cfg.loop).toBe(true); + }); + + test("loop: falls back to DEFAULT_CONFIG.loop when neither set", () => { + const cfg = resolveConfig(null, NONE); + expect(cfg.loop).toBe(DEFAULT_CONFIG.loop); + }); }); describe("defaultConfigPath", () => { diff --git a/tests/configFile.test.ts b/tests/configFile.test.ts index b4ffe28..21886a5 100644 --- a/tests/configFile.test.ts +++ b/tests/configFile.test.ts @@ -98,6 +98,17 @@ describe("loadConfigFile (explicit path)", () => { expect(() => loadConfigFile(path)).toThrow(/'verbose'.*boolean/); }); + test("accepts a boolean loop", () => { + const path = writeFixture("loop.json", JSON.stringify({ loop: true })); + const result = loadConfigFile(path); + expect(result!.loop).toBe(true); + }); + + test("throws when loop is the wrong type", () => { + const path = writeFixture("loop-bad.json", JSON.stringify({ loop: "yes" })); + expect(() => loadConfigFile(path)).toThrow(/'loop'.*boolean/); + }); + test("accepts a known pattern", () => { const path = writeFixture("pattern.json", JSON.stringify({ pattern: "arc" })); const result = loadConfigFile(path); diff --git a/tests/executor.test.ts b/tests/executor.test.ts index 53de661..a784441 100644 --- a/tests/executor.test.ts +++ b/tests/executor.test.ts @@ -133,6 +133,85 @@ describe("executePath — bounds policies", () => { }); }); +describe("executePath — options", () => { + test("restore:false leaves the cursor at the last step, no snap-back", async () => { + const dev = new FakeDevice(); + const start = { x: 500, y: 500 }; + const pts = [ + { x: 501, y: 500 }, + { x: 502, y: 500 }, + ]; + const outcome = await executePath( + fixed(pts, "clamp"), + ctxOf(start, dev.w, dev.h), + dev, + noopLog, + cfgOf(), + { restore: false }, + ); + expect(outcome).toBe("completed"); + // No trailing restore-to-start command. + expect(dev.commanded).toEqual(pts); + }); + + test("the default (no options) still restores to start", async () => { + const dev = new FakeDevice(); + const start = { x: 500, y: 500 }; + const pts = [{ x: 501, y: 500 }]; + await executePath(fixed(pts, "clamp"), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf()); + expect(dev.commanded).toEqual([...pts, start]); + }); + + test("bounds override supersedes the strategy's declared policy", async () => { + const dev = new FakeDevice(100, 100); + // Declared 'abort' would stop before this out-of-bounds point; the + // 'reflect' override folds it back inside instead (span [2,97]: + // x=120 -> 74) and the sweep completes. + const strat = fixed([{ x: 120, y: 50 }], "abort"); + const outcome = await executePath( + strat, + ctxOf({ x: 50, y: 50 }, 100, 100), + dev, + noopLog, + cfgOf(), + { bounds: "reflect", restore: false }, + ); + expect(outcome).toBe("completed"); + expect(dev.commanded[0]).toEqual({ x: 74, y: 50 }); + }); + + test("loop:true runs loopPath when present, path otherwise", async () => { + const dev = new FakeDevice(); + // A strategy whose loopPath differs from its path, both finite here. + const strat: MovementStrategy = { + name: "dual", + bounds: "clamp", + *path(): Generator { + yield { x: 1, y: 1 }; + }, + *loopPath(): Generator { + yield { x: 10, y: 10 }; + yield { x: 20, y: 20 }; + }, + }; + await executePath(strat, ctxOf({ x: 0, y: 0 }, dev.w, dev.h), dev, noopLog, cfgOf(), { + loop: true, + restore: false, + }); + expect(dev.commanded).toEqual([{ x: 10, y: 10 }, { x: 20, y: 20 }]); + }); + + test("loop:true falls back to path when the strategy has no loopPath", async () => { + const dev = new FakeDevice(); + const strat = fixed([{ x: 3, y: 3 }], "clamp"); + await executePath(strat, ctxOf({ x: 0, y: 0 }, dev.w, dev.h), dev, noopLog, cfgOf(), { + loop: true, + restore: false, + }); + expect(dev.commanded).toEqual([{ x: 3, y: 3 }]); + }); +}); + describe("executePath — readback tolerance", () => { test("a readback within tolerance is not treated as interruption", async () => { const dev = new FakeDevice(); diff --git a/tests/keeper.test.ts b/tests/keeper.test.ts index 420b7be..e5e325e 100644 --- a/tests/keeper.test.ts +++ b/tests/keeper.test.ts @@ -89,3 +89,37 @@ describe("runKeeper", () => { expect(dev.commanded.length).toBe(0); }); }); + +describe("runKeeper — loop mode", () => { + const maxX = (pts: Point[]): number => pts.reduce((m, p) => Math.max(m, p.x), -Infinity); + + test("loop mode ramps far from the start via the infinite loopPath", async () => { + // `line`'s loopPath ramps x by 4px/step from the start and never + // restores, reflecting off the screen edge. From x=100 it climbs well + // past a single finite sweep's reach before the budget stops it. + const dev = new LoopDevice(400, { x: 100, y: 100 }); + await runUntilStop(quietConfig({ moveInterval: 0, pattern: "line", loop: true }), dev); + expect(maxX(dev.commanded)).toBeGreaterThan(1000); + }); + + test("single-sweep mode restores each sweep, so x never ramps away", async () => { + // Same setup without loop: `line` runs 250 one-pixel steps then snaps + // back to the start, so x is bounded by start + 250 no matter how many + // sweeps fire within the budget. + const dev = new LoopDevice(400, { x: 100, y: 100 }); + await runUntilStop(quietConfig({ moveInterval: 0, pattern: "line", loop: false }), dev); + expect(maxX(dev.commanded)).toBeLessThanOrEqual(350); + }); + + test("loop mode chains a finite pattern across multiple cycles per trigger", async () => { + // `figureEight` has no loopPath, so loop mode chains its 90-step path. + // A single trigger keeps chaining cycles until the budget stops it, + // yielding far more than the 90 commands one cycle would. + const dev = new LoopDevice(400, { x: 800, y: 500 }); + await runUntilStop( + quietConfig({ moveInterval: 0, pattern: "figureEight", loop: true }), + dev, + ); + expect(dev.commanded.length).toBeGreaterThan(180); + }); +}); diff --git a/tests/strategies.test.ts b/tests/strategies.test.ts index 4caee7c..dd6b926 100644 --- a/tests/strategies.test.ts +++ b/tests/strategies.test.ts @@ -36,6 +36,16 @@ function mulberry32(seed: number): () => number { }; } +/** Pull the first `n` points from a (possibly infinite) point iterable. */ +function take(iter: Iterable, n: number): Point[] { + const out: Point[] = []; + for (const p of iter) { + out.push(p); + if (out.length >= n) break; + } + return out; +} + function ctxOf(overrides: { start?: Point; width?: number; @@ -67,6 +77,14 @@ describe("line", () => { expect(pts[1]!.x).toBe(88); expect(pts.at(-1)!.x).toBe(90 - 250); }); + + test("loopPath ramps x forever at a fixed step, y held constant", () => { + const start = { x: 500, y: 300 }; + const pts = take(line.loopPath!(ctxOf({ start })), 5); + // Monotonic +4 per step (LINE_LOOP_STEP), no vertical drift. + expect(pts.map((p) => p.x)).toEqual([504, 508, 512, 516, 520]); + expect(pts.every((p) => p.y === 300)).toBe(true); + }); }); describe("diagonal", () => { @@ -76,6 +94,16 @@ describe("diagonal", () => { expect(pts[0]!).toEqual({ x: 501, y: 501 }); expect(pts.at(-1)!).toEqual({ x: 750, y: 750 }); }); + + test("loopPath ramps both axes forever at a fixed step", () => { + const pts = take(diagonal.loopPath!(ctxOf({ start: { x: 100, y: 200 } })), 3); + // Both axes advance by DIAGONAL_LOOP_STEP (4) each step. + expect(pts).toEqual([ + { x: 104, y: 204 }, + { x: 108, y: 208 }, + { x: 112, y: 212 }, + ]); + }); }); describe("jitter", () => {