From c8942bb3806417dbda45cf826bc479548cdf3e8e Mon Sep 17 00:00:00 2001 From: nokeo08 Date: Mon, 17 Aug 2026 15:53:49 -0500 Subject: [PATCH] Collapse bounds policies to reflect-only; drop abort and clamp The executor kept every commanded point on-screen via a per-strategy BoundsPolicy of abort / clamp / reflect. Measured against the real strategies, the other two earned nothing: abort truncated a sweep at the first edge (line on a narrow screen ran only 90 of 250 steps), and clamp could park the cursor against an edge (a monotonic ramp stalled 162 steps in a row) -- both counter to the program's whole purpose of keeping the cursor moving. reflect bounces off the edge and keeps going, and is already what line/diagonal need in loop mode. arc's declared clamp was provably dead code (it clamps its own endpoint, so no sample ever leaves the screen). Collapse to reflect-only: - strategies.ts: remove the BoundsPolicy type and the `bounds` field from the interface and all six strategies. Keep the local clamp() helper -- it's arc's endpoint geometry, not an on-screen policy; docstring says so. - executor.ts: resolveTarget loses its policy parameter and its null return and just reflects both axes; delete clampInt; SweepOutcome drops "aborted"; ExecuteOptions drops `bounds`; remove the Out of bounds log. - keeper.ts: loopOpts is now { restore: false, loop: true } -- the reflect override added with loop mode is redundant. - tests: drop the abort-outcome, clamp, and bounds-override tests; simplify fixed() to take no policy; add a regression test that a monotonic ramp past an edge never yields two identical points in a row (the guarantee that motivated removing clamp). Behavior is unchanged for every pattern at normal cursor positions (verified: line's normal sweep is byte-identical). The only differences are at a screen edge, where motion now bounces instead of stopping. No config keys, flags, or pattern names changed. Docs updated to match, including in-code comments, the README strategies table (Bounds column removed) and verbose description, the sequence diagram (resolveTarget signature + getPosition/width ordering + a loop-mode note), and a CHANGELOG Changed entry. --- CHANGELOG.md | 20 +++++-- README.md | 63 ++++++++++---------- docs/execution-happy-path.md | 21 ++++++- src/cli.ts | 4 +- src/config.ts | 4 +- src/executor.ts | 101 ++++++++++---------------------- src/keeper.ts | 31 +++++----- src/strategies.ts | 76 ++++++++++-------------- tests/executor.test.ts | 110 +++++++++++++++++------------------ 9 files changed, 197 insertions(+), 233 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ce8a1c..2fca3d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,11 +11,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. + between iterations, 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. + +### Changed +- Simplified on-screen confinement to a single policy: the executor now + reflects every pattern's out-of-range coordinates back inside the screen. + The `abort` and `clamp` bounds policies (and the per-strategy `bounds` + field) were removed. `abort` truncated a sweep at the first edge and `clamp` + could park the cursor against an edge — both counter to keeping the cursor + moving — while `reflect` bounces and keeps going. Behavior is unchanged for + every pattern at normal cursor positions; the only differences are at a + screen edge, where motion now bounces instead of stopping. No config keys, + flags, or pattern names changed. ## [1.3.3] - 2026-08-17 diff --git a/README.md b/README.md index 7d3e73d..d02ee96 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ Options: One of: line, diagonal, jitter, walk, arc, figureEight. Each pattern defines its own size and speed. - -V, --verbose Log every sweep, interrupt, and bounds event + -V, --verbose Log every sweep and interrupt (default prints only the startup banner). -l, --loop Loop mode: once a sweep is triggered, keep moving until you move the mouse (or @@ -138,8 +138,8 @@ internally. Logging is **quiet by default**: only the startup banner ("Teams Status Keeper started…") and any error from an unhandled rejection print on a -default run. `-V` / `--verbose` opens up per-sweep, user-interrupt, and -out-of-bounds events. +default run. `-V` / `--verbose` opens up per-sweep and user-interrupt +events. Invalid input (unknown flag, missing value, non-positive number) prints an error to `stderr` and exits with code `2`. @@ -244,12 +244,12 @@ 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. +In loop mode the cursor is never restored between iterations, so `line` and +`diagonal` bounce edge-to-edge across the whole screen (the executor keeps +every pattern on-screen by reflecting off the edges) 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 @@ -289,8 +289,8 @@ and everything but the raw nut.js call is unit-testable: of target points given a start, screen size, config, and RNG — plus the registry and name validation. Adding a pattern is one pure function. - `src/executor.ts` is the single `executePath` driver: it rounds targets, - applies the strategy's bounds policy, paces steps, detects real-user - interruption, and restores the cursor on a clean sweep. + reflects any off-screen coordinate back inside, paces steps, detects + real-user interruption, and restores the cursor on a clean sweep. Defaults live in `src/config.ts` as `DEFAULT_CONFIG`: @@ -321,8 +321,8 @@ to milliseconds before handing the resolved `Config` to `runKeeper`. up `config.pattern` in the strategy registry, and builds a `MoveContext`. 2. It hands the strategy and context to `executePath`, which drives the sweep. For each target the strategy yields: - - Round to whole pixels and apply the strategy's bounds policy - (`abort` / `clamp` / `reflect`) to keep it on-screen. + - Round to whole pixels and reflect any off-screen coordinate back inside + the travel range, so the cursor bounces off the edges and keeps moving. - Move the cursor there, sleep `config.stepDelay`. - Re-read the cursor. If it isn't at the point we *just commanded*, the user moved it — log (when `--verbose`) and return early without @@ -335,37 +335,40 @@ 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. +the last left off. Comparing against the last commanded (rounded) point — not the strategy's ideal, possibly fractional target — is what lets curved and stochastic patterns run without every rounded step looking like user activity. The -comparison also allows a small (2px) tolerance, and the `clamp`/`reflect` -patterns stay a couple of pixels off the screen edge, so sub-pixel cursor -placement on scaled or multi-monitor displays isn't misread as the user -grabbing the mouse. `line` uses the `abort` policy and is unaffected. +comparison also allows a small (2px) tolerance, and the travel range stays a +couple of pixels off the screen edge, so sub-pixel cursor placement on scaled +or multi-monitor displays isn't misread as the user grabbing the mouse. ### Movement strategies `config.pattern` selects one of the generators in `src/strategies.ts`: -| 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` | +| Name | Motion | Steps | Size | +| ------------- | ------------------------------------------------------------- | ----- | ----------- | +| `line` | Straight horizontal sweep (the original behavior). | 250 | 250px | +| `diagonal` | Straight line on both axes toward the roomiest corner. | 250 | 250px/axis | +| `jitter` | Small random hops within a tight radius of the start. | 80 | 30px radius | +| `walk` | Cumulative random walk; bounces off the screen edges. | 200 | ±4px/step | +| `arc` | Smooth quadratic-Bézier curve to a random on-screen point. | 120 | ~300px | +| `figureEight` | Traces a figure-eight (lemniscate) and returns to the start. | 90 | ~250px wide | + +Every pattern is kept on-screen the same way: the executor reflects any +coordinate that would fall past a screen edge back inside, so motion bounces +instead of stopping. Strategies therefore never bound their own output — +they emit ideal geometry and let the executor confine it. 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. +generator and register it — the executor supplies on-screen reflection, +pacing, interrupt, and restore for free. ### Why `mouse.config.autoDelayMs = 0` @@ -421,7 +424,7 @@ move --help | `src/keeper.ts` | Idle-watch loop + per-sweep glue (selects a strategy, calls the executor). | | `src/device.ts` | `Device` I/O seam over nut.js (`Point`, `createNutDevice`); the only nut.js importer. | | `src/strategies.ts` | Pure movement-pattern generators, the strategy registry, and name validation. | -| `src/executor.ts` | `executePath` driver: bounds policy, pacing, interrupt detection, restore. | +| `src/executor.ts` | `executePath` driver: on-screen reflection, pacing, interrupt detection, restore. | | `docs/execution-happy-path.md` | Sequence diagram + invariants for a clean sweep. | | `package.json` | Bun project manifest. Single runtime dep: `@nut-tree-fork/nut-js`. | | `tsconfig.json` | Strict TypeScript config tuned for Bun (ESNext, bundler resolution). | diff --git a/docs/execution-happy-path.md b/docs/execution-happy-path.md index dbe48f7..9aa67af 100644 --- a/docs/execution-happy-path.md +++ b/docs/execution-happy-path.md @@ -38,12 +38,12 @@ sequenceDiagram end Keeper->>Sim: simulateActivity(config, log, dev) - Sim->>Dev: getPosition() - Dev-->>Sim: start Sim->>Dev: width() Dev-->>Sim: width Sim->>Dev: height() Dev-->>Sim: height + Sim->>Dev: getPosition() + Dev-->>Sim: start Note over Sim: strategy = STRATEGIES[config.pattern]
ctx = { start, width, height, rng } Sim->>Exec: executePath(strategy, ctx, dev, log, config) @@ -51,7 +51,7 @@ sequenceDiagram Strat-->>Exec: iterable of Points loop for each target point (clean run) - Exec->>Exec: resolveTarget(bounds, target) → point + Exec->>Exec: resolveTarget(target) → point (reflected on-screen) Exec->>Dev: setPosition(point) Exec->>Dev: sleep(stepDelay) Exec->>Dev: getPosition() @@ -86,3 +86,18 @@ sequenceDiagram follow-up `getPosition()` in `runKeeper` re-syncs `lastPos` to the origin as a no-op, and the next idle check sees no net movement (so the synthetic sweep is never mistaken for the user returning). +- **On-screen confinement is uniform.** `resolveTarget` reflects any + coordinate past a screen edge back inside the travel range — the sole, + per-pattern-independent policy. A strategy emits ideal geometry and never + bounds its own output. + +## Loop mode (`--loop`) + +This diagram is the single-sweep path (`config.loop === false`). Under +`--loop`, `simulateActivity` instead repeats the step loop until the user +interrupts: a pattern with an infinite `loopPath` (`line`, `diagonal`) runs +that one never-ending path, while the others chain their finite `path` cycle +after cycle, re-reading the cursor as the next `start` each time. The restore +in the final step is skipped (`restore: false`), so successive cycles flow +from where the last left off. Everything else — reflection, pacing, and the +per-step interrupt check — is identical to the sweep traced above. diff --git a/src/cli.ts b/src/cli.ts index bd4eb26..a41e3c9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -17,7 +17,7 @@ * -c, --check-interval Cursor poll cadence (seconds). * -d, --step-delay Pause between synthetic steps (ms). * -p, --pattern Movement strategy name (see strategies.ts). - * -V, --verbose Enable per-sweep / interrupt / bounds logging. + * -V, --verbose Enable per-sweep / interrupt logging. * (`-V` capital because `-v` is `--version`.) * -l, --loop Loop mode: once triggered, keep moving * until the user moves the mouse (or Ctrl+C). @@ -180,7 +180,7 @@ Options: -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 + -V, --verbose Log every sweep and interrupt (default prints only the startup banner). -l, --loop Loop mode: once a sweep is triggered, keep moving until you move the mouse (or diff --git a/src/config.ts b/src/config.ts index 6854dc0..3831c3b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -45,8 +45,8 @@ import seedRaw from "../scripts/config.default.json" with { type: "json" }; * - `pattern` — name of the movement strategy to use (see * `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. + * - `verbose` — whether per-sweep / interrupt 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 diff --git a/src/executor.ts b/src/executor.ts index 17c68a5..d83c309 100644 --- a/src/executor.ts +++ b/src/executor.ts @@ -7,13 +7,13 @@ * *everything else* about carrying a sweep out against a `Device`: * * - round each ideal target to whole pixels, - * - keep it on-screen per the strategy's `BoundsPolicy`, + * - keep it on-screen by reflecting coordinates that fall past an edge, * - command the cursor and pace it with `stepDelay`, * - detect real-user interruption after each step, * - restore the cursor to the origin on a clean run. * * Writing this once means new patterns inherit correct real-user-wins, - * bounds, and restore semantics for free. It's pure with respect to I/O — + * on-screen, and restore semantics for free. It's pure with respect to I/O — * all side effects go through the injected `Device`, so it's unit-testable * with a fake. * @@ -25,7 +25,7 @@ import type { Config } from "./config.ts"; import type { Device, Point } from "./device.ts"; -import type { BoundsPolicy, MoveContext, MovementStrategy } from "./strategies.ts"; +import type { MoveContext, MovementStrategy } from "./strategies.ts"; /** * Minimal log surface used by the executor and the keeper loop. @@ -41,11 +41,10 @@ export interface Logger { /** * How a sweep ended: * - `completed` — full path ran and the cursor was restored to start. - * - `interrupted` — real user activity detected mid-sweep; aborted without - * snapping back. - * - `aborted` — an `abort`-policy target went out of bounds. + * - `interrupted` — real user activity detected mid-sweep; the sweep stopped + * without snapping back. */ -export type SweepOutcome = "completed" | "interrupted" | "aborted"; +export type SweepOutcome = "completed" | "interrupted"; /** * Per-call knobs for `executePath`. All optional; the defaults reproduce the @@ -56,11 +55,6 @@ export type SweepOutcome = "completed" | "interrupted" | "aborted"; * 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 @@ -68,7 +62,6 @@ export type SweepOutcome = "completed" | "interrupted" | "aborted"; */ export interface ExecuteOptions { readonly restore?: boolean; - readonly bounds?: BoundsPolicy; readonly loop?: boolean; } @@ -81,20 +74,17 @@ export interface ExecuteOptions { const READBACK_TOLERANCE: number = 2; /** - * Pixels to inset the `clamp` / `reflect` travel range from each screen edge. - * Keeps edge-seeking patterns off the literal first/last pixel, where DPI - * scaling and multi-monitor boundaries most often make the OS place the - * cursor a hair off what we commanded (which the readback check would then - * misread as the user). `abort` (used by `line`) is deliberately left on the - * full `[0, max - 1]` range, so its behavior is unchanged. + * Pixels to inset the travel range from each screen edge. Keeps edge-seeking + * patterns off the literal first/last pixel, where DPI scaling and + * multi-monitor boundaries most often make the OS place the cursor a hair off + * what we commanded (which the readback check would then misread as the user). */ const EDGE_MARGIN: number = 2; /** - * The inclusive `[lo, hi]` integer range an axis of length `max` may travel - * under the `clamp` / `reflect` policies: `[0, max - 1]` inset by - * `EDGE_MARGIN` on each side. Screens too small to inset fall back to the - * full range so the math never inverts. + * The inclusive `[lo, hi]` integer range an axis of length `max` may travel: + * `[0, max - 1]` inset by `EDGE_MARGIN` on each side. Screens too small to + * inset fall back to the full range so the math never inverts. */ function travelRange(max: number): { lo: number; hi: number } { const hiEdge: number = max - 1; @@ -102,18 +92,11 @@ function travelRange(max: number): { lo: number; hi: number } { return { lo: EDGE_MARGIN, hi: hiEdge - EDGE_MARGIN }; } -/** Round to whole pixels and clamp into the inset travel range for `max`. */ -function clampInt(v: number, max: number): number { - const { lo, hi } = travelRange(max); - const r: number = Math.round(v); - if (r < lo) return lo; - if (r > hi) return hi; - return r; -} - /** * Mirror `v` into the inset travel range for `max` as a triangle wave, so - * values past an edge bounce back inside instead of clamping flat against it. + * values past an edge bounce back inside instead of running off it. This is + * the sole on-screen policy: a coordinate that overshoots an edge reflects + * back in, so a pattern keeps moving instead of parking against the boundary. */ function reflectInt(v: number, max: number): number { const { lo, hi } = travelRange(max); @@ -125,27 +108,11 @@ function reflectInt(v: number, max: number): number { } /** - * Resolve a strategy's ideal target to an on-screen integer pixel under the - * given policy. Returns `null` when policy is `abort` and the (rounded) - * target lies outside the screen — the signal to stop the sweep. + * Resolve a strategy's ideal (possibly fractional, possibly off-screen) target + * to an on-screen integer pixel by reflecting each axis into its travel range. */ -function resolveTarget( - policy: BoundsPolicy, - p: Point, - width: number, - height: number, -): Point | null { - if (policy === "reflect") { - return { x: reflectInt(p.x, width), y: reflectInt(p.y, height) }; - } - if (policy === "clamp") { - return { x: clampInt(p.x, width), y: clampInt(p.y, height) }; - } - // abort: round, then reject anything off-screen. - const x: number = Math.round(p.x); - const y: number = Math.round(p.y); - if (x < 0 || x >= width || y < 0 || y >= height) return null; - return { x, y }; +function resolveTarget(p: Point, width: number, height: number): Point { + return { x: reflectInt(p.x, width), y: reflectInt(p.y, height) }; } /** @@ -162,8 +129,8 @@ function timestamp(): string { * against `device`. * * Contract, per step: - * 1. Resolve the ideal target to an on-screen integer (bounds policy). - * An `abort`-policy out-of-bounds target ends the sweep (`aborted`). + * 1. Resolve the ideal target to an on-screen integer by reflecting it + * into the travel range. * 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 @@ -174,9 +141,8 @@ function timestamp(): string { * `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 + * `options` (all optional, see `ExecuteOptions`) let loop mode reuse this + * same driver: `loop` selects the strategy's infinite `loopPath`, and * `restore` suppresses the snap-back. Omitting `options` reproduces the * original single-sweep contract exactly. * @@ -192,18 +158,13 @@ export async function executePath( 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 path) { - const point: Point | null = resolveTarget(policy, target, width, height); - if (point === null) { - log.event(`Out of bounds at ${timestamp()}; aborting simulation.`); - return "aborted"; - } + const point: Point = resolveTarget(target, width, height); await device.setPosition(point); await device.sleep(config.stepDelay); @@ -213,17 +174,17 @@ export async function executePath( Math.abs(current.x - point.x) > READBACK_TOLERANCE || Math.abs(current.y - point.y) > READBACK_TOLERANCE ) { - // Cursor isn't where we last put it -> real user activity. Abort + // Cursor isn't where we last put it -> real user activity. Stop // without snapping back, so we don't yank it from under the user. // // The comparison allows a small tolerance rather than demanding an // exact match: on scaled (fractional-DPI) or multi-monitor setups // the OS can place the cursor a pixel off the coordinate we - // commanded, and the edge-seeking patterns (clamp/reflect/arc) - // reach exactly the coordinates where that's most likely. A real - // user moves far more than a couple of pixels, so this doesn't - // meaningfully weaken real-user-wins. - log.event(`User activity detected at ${timestamp()}; aborting simulation.`); + // commanded, and edge-seeking patterns reach exactly the + // coordinates where that's most likely. A real user moves far more + // than a couple of pixels, so this doesn't meaningfully weaken + // real-user-wins. + log.event(`User activity detected at ${timestamp()}; stopping simulation.`); return "interrupted"; } } diff --git a/src/keeper.ts b/src/keeper.ts index 7d3448f..b09fb06 100644 --- a/src/keeper.ts +++ b/src/keeper.ts @@ -8,8 +8,8 @@ * the interesting parts stay testable: * - `device.ts` — the nut.js I/O boundary (injected here). * - `strategies.ts` — pure "where to move" pattern generators. - * - `executor.ts` — the "how to move" driver (bounds, timing, - * interrupt detection, restore). + * - `executor.ts` — the "how to move" driver (on-screen reflection, + * timing, interrupt detection, restore). * * `runKeeper` takes an optional `Device` so tests can drive the loop with a * fake; production supplies the nut.js device. Importing this module is @@ -18,9 +18,9 @@ * Logging policy: * - The startup banner in `runKeeper` is unconditional so the user always * sees the process is alive. - * - Per-sweep / interrupt / bounds lines are gated by `config.verbose` - * (see `makeLogger`). Errors stay on `console.error`, raised by the - * entry point on unhandled rejection. + * - Per-sweep / interrupt lines are gated by `config.verbose` (see + * `makeLogger`). Errors stay on `console.error`, raised by the entry + * point on unhandled rejection. */ import { createNutDevice, type Device, type Point } from "./device.ts"; @@ -55,19 +55,16 @@ function makeLogger(verbose: boolean): Logger { * 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. + * `executePath`, which owns on-screen reflection, 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. + * user moves the mouse (or Ctrl+C). The cursor is never restored between + * iterations (`restore: false`). 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 width: number = await device.width(); @@ -83,7 +80,7 @@ async function simulateActivity(config: Config, log: Logger, device: Device): Pr 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 }; + const loopOpts = { restore: false, loop: true }; let cycles = 0; let outcome: SweepOutcome; diff --git a/src/strategies.ts b/src/strategies.ts index 427ddf3..c976a17 100644 --- a/src/strategies.ts +++ b/src/strategies.ts @@ -10,9 +10,10 @@ * add (write one pure generator) and trivial to test (feed a deterministic * `rng`, assert the emitted points). * - * Coordinates emitted here may be fractional; the executor rounds to whole - * pixels before commanding the cursor and applies the strategy's declared - * `BoundsPolicy` to keep everything on-screen. + * Coordinates emitted here may be fractional and may fall past a screen + * edge; the executor rounds to whole pixels and reflects any out-of-range + * coordinate back inside, so a pattern bounces off the edges and keeps + * moving. Strategies never need to bound their own output. * * 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 @@ -25,19 +26,6 @@ import type { Point } from "./device.ts"; -/** - * How the executor keeps a strategy's targets on-screen: - * - * - `abort` — stop the sweep the moment a target falls out of bounds. - * Used by `line`, whose direction is chosen so this never - * actually fires; preserves the original straight-line - * semantics exactly. - * - `clamp` — pin each out-of-bounds coordinate to the nearest edge. - * - `reflect` — mirror out-of-bounds coordinates back inside, so a roaming - * pattern bounces off the screen edges instead of sticking. - */ -export type BoundsPolicy = "abort" | "clamp" | "reflect"; - /** * Everything a strategy needs to generate a path. Screen dimensions and the * start point are snapshotted per sweep by the caller; `rng` is injected so @@ -59,7 +47,6 @@ export interface MoveContext { * * - `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`). @@ -68,23 +55,26 @@ export interface MoveContext { * 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. + * direction once and ramps forever; the executor reflects 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]`. */ +/** + * Clamp `v` into the inclusive pixel range `[0, max - 1]`. This is a geometry + * helper for `arc` (choosing a well-formed on-screen endpoint and control + * point), NOT an on-screen bounds policy — the executor keeps every commanded + * point on-screen by reflecting, uniformly for all patterns. + */ function clamp(v: number, max: number): number { if (v < 0) return 0; if (v > max - 1) return max - 1; @@ -97,20 +87,20 @@ function clamp(v: number, max: number): number { * Pick a horizontal direction that keeps the sweep on-screen (right if * 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). + * keeper produced before movement patterns existed. The direction choice + * keeps the finite sweep on-screen, so the executor's reflection never + * actually engages for it. * - * 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. + * In loop mode `loopPath` ramps x in one direction forever; the direction + * never matters because the executor reflects the ramp 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", - bounds: "abort", *path(ctx: MoveContext): Generator { const { start, width } = ctx; const dx: number = start.x + LINE_STEPS < width ? 1 : -1; @@ -134,18 +124,16 @@ export const line: MovementStrategy = { * 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. + * In loop mode `loopPath` ramps both axes forever, and the executor reflects + * them. 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", - bounds: "clamp", *path(ctx: MoveContext): Generator { const { start, width, height } = ctx; const dx: number = start.x + DIAGONAL_STEPS < width ? 1 : -1; @@ -178,7 +166,6 @@ const JITTER_RADIUS = 30; export const jitter: MovementStrategy = { name: "jitter", - bounds: "clamp", *path(ctx: MoveContext): Generator { const { start, rng } = ctx; for (let i = 1; i <= JITTER_STEPS; i++) { @@ -194,15 +181,14 @@ export const jitter: MovementStrategy = { * 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. + * position drift freely; the executor 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, rng } = ctx; let x: number = start.x; @@ -226,7 +212,6 @@ const ARC_REACH = 300; export const arc: MovementStrategy = { name: "arc", - bounds: "clamp", *path(ctx: MoveContext): Generator { const { start, width, height, rng } = ctx; @@ -268,7 +253,6 @@ const FIG8_AMP = 125; export const figureEight: MovementStrategy = { name: "figureEight", - bounds: "clamp", *path(ctx: MoveContext): Generator { const { start } = ctx; for (let i = 1; i <= FIG8_STEPS; i++) { diff --git a/tests/executor.test.ts b/tests/executor.test.ts index a784441..14e58e0 100644 --- a/tests/executor.test.ts +++ b/tests/executor.test.ts @@ -2,9 +2,9 @@ * executor.test.ts * ---------------- * Unit tests for the execution driver against a fake `Device`. Covers the - * three sweep outcomes, all three bounds policies, the rounding/interrupt - * contract, and step pacing — none of which was testable before the device - * seam existed. + * two sweep outcomes, on-screen reflection, the rounding/interrupt contract, + * step pacing, and the loop/restore options — none of which was testable + * before the device seam existed. */ import { describe, expect, test } from "bun:test"; @@ -13,7 +13,7 @@ import { DEFAULT_CONFIG } from "../src/config.ts"; import type { Config } from "../src/config.ts"; import type { Device, Point } from "../src/device.ts"; import { executePath, type Logger } from "../src/executor.ts"; -import type { BoundsPolicy, MoveContext, MovementStrategy } from "../src/strategies.ts"; +import type { MoveContext, MovementStrategy } from "../src/strategies.ts"; const noopLog: Logger = { info: (): void => {}, event: (): void => {} }; @@ -50,11 +50,10 @@ class FakeDevice implements Device { } } -/** A strategy that emits a fixed list of points under a chosen bounds policy. */ -function fixed(points: Point[], bounds: BoundsPolicy): MovementStrategy { +/** A strategy that emits a fixed list of points. */ +function fixed(points: Point[]): MovementStrategy { return { name: "fixed", - bounds, *path(): Generator { yield* points; }, @@ -79,7 +78,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, cfgOf()); + const outcome = await executePath(fixed(pts), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf()); expect(outcome).toBe("completed"); // 3 steps + 1 restore. expect(dev.commanded).toEqual([...pts, start]); @@ -95,42 +94,56 @@ 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, cfgOf()); + const outcome = await executePath(fixed(pts), 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]!]); expect(dev.commanded.at(-1)).not.toEqual(start); }); - - test("abort policy stops before commanding an out-of-bounds point", async () => { - const dev = new FakeDevice(100, 100); - const pts = [{ x: 150, y: 10 }]; // x >= width - const outcome = await executePath(fixed(pts, "abort"), ctxOf({ x: 10, y: 10 }, 100, 100), dev, noopLog, cfgOf()); - expect(outcome).toBe("aborted"); - expect(dev.commanded).toEqual([]); - }); }); -describe("executePath — bounds policies", () => { - test("clamp pins out-of-bounds coordinates to the inset edges", async () => { - const dev = new FakeDevice(100, 100); - const pts = [ - { x: -5, y: 50 }, - { x: 9999, y: 50 }, - ]; - // travelRange(100) is inset by EDGE_MARGIN (2) to [2, 97]. - await executePath(fixed(pts, "clamp"), ctxOf({ x: 50, y: 50 }, 100, 100), dev, noopLog, cfgOf()); - expect(dev.commanded[0]).toEqual({ x: 2, y: 50 }); - expect(dev.commanded[1]).toEqual({ x: 97, y: 50 }); - }); - - test("reflect mirrors out-of-bounds coordinates back inside the inset range", async () => { +describe("executePath — on-screen reflection", () => { + test("mirrors an out-of-range coordinate back inside the inset range", async () => { const dev = new FakeDevice(100, 100); // Inset range [2, 97], span = 95; x=120 -> (120-2)=118, 190-118=72, +2 = 74. const pts = [{ x: 120, y: 50 }]; - await executePath(fixed(pts, "reflect"), ctxOf({ x: 50, y: 50 }, 100, 100), dev, noopLog, cfgOf()); + await executePath(fixed(pts), ctxOf({ x: 50, y: 50 }, 100, 100), dev, noopLog, cfgOf()); expect(dev.commanded[0]).toEqual({ x: 74, y: 50 }); }); + + test("negative and far-past-edge coordinates both fold inside", async () => { + const dev = new FakeDevice(100, 100); + // Inset [2, 97]. x=-5 -> reflects to 9; x=99 -> 95 (period 190). + const pts = [ + { x: -5, y: 50 }, + { x: 99, y: 50 }, + ]; + await executePath(fixed(pts), ctxOf({ x: 50, y: 50 }, 100, 100), dev, noopLog, cfgOf()); + for (const p of dev.commanded.slice(0, 2)) { + expect(p.x).toBeGreaterThanOrEqual(2); + expect(p.x).toBeLessThanOrEqual(97); + } + }); + + test("a monotonic ramp past an edge keeps moving — never two identical points in a row", async () => { + // This is the guarantee that motivated removing `clamp`: a clamp would + // pin every over-the-edge point to the same edge pixel, stalling the + // cursor. Reflection folds the ramp into a triangle wave, so the cursor + // both rises and falls and never repeats a pixel step to step. + const dev = new FakeDevice(40, 40); + // Ramp x well past the right edge and back's worth of travel. + const pts = Array.from({ length: 60 }, (_, i) => ({ x: 10 + i, y: 20 })); + await executePath(fixed(pts), ctxOf({ x: 10, y: 20 }, 40, 40), dev, noopLog, cfgOf({ stepDelay: 0 })); + const xs = dev.commanded.slice(0, 60).map((p) => p.x); + // No stall: consecutive commanded points always differ. + for (let i = 1; i < xs.length; i++) { + expect(xs[i]).not.toBe(xs[i - 1]); + } + // It bounced: the ramp both increased and decreased at some point. + const rose = xs.some((x, i) => i > 0 && x > xs[i - 1]!); + const fell = xs.some((x, i) => i > 0 && x < xs[i - 1]!); + expect(rose && fell).toBe(true); + }); }); describe("executePath — options", () => { @@ -142,7 +155,7 @@ describe("executePath — options", () => { { x: 502, y: 500 }, ]; const outcome = await executePath( - fixed(pts, "clamp"), + fixed(pts), ctxOf(start, dev.w, dev.h), dev, noopLog, @@ -158,34 +171,15 @@ describe("executePath — options", () => { 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()); + await executePath(fixed(pts), 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 }; }, @@ -203,7 +197,7 @@ describe("executePath — options", () => { 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"); + const strat = fixed([{ x: 3, y: 3 }]); await executePath(strat, ctxOf({ x: 0, y: 0 }, dev.w, dev.h), dev, noopLog, cfgOf(), { loop: true, restore: false, @@ -224,7 +218,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, cfgOf()); + const outcome = await executePath(fixed(pts), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf()); expect(outcome).toBe("completed"); expect(dev.commanded).toEqual([...pts, start]); }); @@ -238,7 +232,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, cfgOf()); + const outcome = await executePath(fixed(pts), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf()); expect(outcome).toBe("interrupted"); expect(dev.commanded).toEqual([pts[0]!]); }); @@ -249,7 +243,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, cfgOf()); + const outcome = await executePath(fixed(pts), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf()); expect(outcome).toBe("completed"); expect(dev.commanded[0]).toEqual({ x: 10, y: 21 }); }); @@ -260,7 +254,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), dev, noopLog, cfgOf({ stepDelay: 7 })); + await executePath(fixed(pts), ctxOf({ x: 500, y: 500 }, dev.w, dev.h), dev, noopLog, cfgOf({ stepDelay: 7 })); expect(dev.sleeps).toEqual([7, 7]); }); });