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.
This commit is contained in:
2026-08-17 15:53:49 -05:00
parent 7e632b3e9d
commit c8942bb380
9 changed files with 197 additions and 233 deletions
+15 -5
View File
@@ -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 - Loop mode: `-l` / `--loop` (and the `loop` config key) keep the mouse
moving after a sweep is triggered until real user activity is detected, 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 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`, between iterations, so `line` and `diagonal` bounce edge-to-edge across the
so `line` and `diagonal` bounce edge-to-edge across the screen (a screen (a roaming-DVD effect) rather than stopping at the first edge.
roaming-DVD effect) rather than stopping at the first edge. Patterns with a Patterns with a finite path (`jitter`, `walk`, `arc`, `figureEight`) chain
finite path (`jitter`, `walk`, `arc`, `figureEight`) chain that path cycle that path cycle after cycle. Interruption remains mouse-movement only.
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 ## [1.3.3] - 2026-08-17
+33 -30
View File
@@ -121,7 +121,7 @@ Options:
One of: line, diagonal, jitter, walk, arc, One of: line, diagonal, jitter, walk, arc,
figureEight. Each pattern defines its own figureEight. Each pattern defines its own
size and speed. 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). (default prints only the startup banner).
-l, --loop Loop mode: once a sweep is triggered, -l, --loop Loop mode: once a sweep is triggered,
keep moving until you move the mouse (or keep moving until you move the mouse (or
@@ -138,8 +138,8 @@ internally.
Logging is **quiet by default**: only the startup banner ("Teams Status Logging is **quiet by default**: only the startup banner ("Teams Status
Keeper started…") and any error from an unhandled rejection print on a Keeper started…") and any error from an unhandled rejection print on a
default run. `-V` / `--verbose` opens up per-sweep, user-interrupt, and default run. `-V` / `--verbose` opens up per-sweep and user-interrupt
out-of-bounds events. events.
Invalid input (unknown flag, missing value, non-positive number) prints an Invalid input (unknown flag, missing value, non-positive number) prints an
error to `stderr` and exits with code `2`. 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 move --pattern figureEight --loop # traces the eight over and over
``` ```
In loop mode the cursor is never restored between iterations, and every In loop mode the cursor is never restored between iterations, so `line` and
pattern's bounds policy is forced to `reflect`, so `line` and `diagonal` `diagonal` bounce edge-to-edge across the whole screen (the executor keeps
bounce edge-to-edge across the whole screen instead of ending at the first every pattern on-screen by reflecting off the edges) instead of ending at
edge. Interruption is detected via mouse movement only — there is no the first edge. Interruption is detected via mouse movement only — there is
keyboard hook — so if you resume by typing without touching the mouse, the no keyboard hook — so if you resume by typing without touching the mouse,
cursor keeps cycling until you nudge it or stop the process. 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 ### 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 of target points given a start, screen size, config, and RNG — plus the
registry and name validation. Adding a pattern is one pure function. registry and name validation. Adding a pattern is one pure function.
- `src/executor.ts` is the single `executePath` driver: it rounds targets, - `src/executor.ts` is the single `executePath` driver: it rounds targets,
applies the strategy's bounds policy, paces steps, detects real-user reflects any off-screen coordinate back inside, paces steps, detects
interruption, and restores the cursor on a clean sweep. real-user interruption, and restores the cursor on a clean sweep.
Defaults live in `src/config.ts` as `DEFAULT_CONFIG`: 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`. up `config.pattern` in the strategy registry, and builds a `MoveContext`.
2. It hands the strategy and context to `executePath`, which drives the 2. It hands the strategy and context to `executePath`, which drives the
sweep. For each target the strategy yields: sweep. For each target the strategy yields:
- Round to whole pixels and apply the strategy's bounds policy - Round to whole pixels and reflect any off-screen coordinate back inside
(`abort` / `clamp` / `reflect`) to keep it on-screen. the travel range, so the cursor bounces off the edges and keeps moving.
- Move the cursor there, sleep `config.stepDelay`. - Move the cursor there, sleep `config.stepDelay`.
- Re-read the cursor. If it isn't at the point we *just commanded*, the - 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 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 pattern with an infinite `loopPath` (`line`, `diagonal`) runs that single
never-ending path, while the others chain their finite path cycle after 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 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 the last left off.
pattern so edge-seeking motion bounces instead of stopping.
Comparing against the last commanded (rounded) point — not the strategy's Comparing against the last commanded (rounded) point — not the strategy's
ideal, possibly fractional target — is what lets curved and stochastic ideal, possibly fractional target — is what lets curved and stochastic
patterns run without every rounded step looking like user activity. The patterns run without every rounded step looking like user activity. The
comparison also allows a small (2px) tolerance, and the `clamp`/`reflect` comparison also allows a small (2px) tolerance, and the travel range stays a
patterns stay a couple of pixels off the screen edge, so sub-pixel cursor couple of pixels off the screen edge, so sub-pixel cursor placement on scaled
placement on scaled or multi-monitor displays isn't misread as the user or multi-monitor displays isn't misread as the user grabbing the mouse.
grabbing the mouse. `line` uses the `abort` policy and is unaffected.
### Movement strategies ### Movement strategies
`config.pattern` selects one of the generators in `src/strategies.ts`: `config.pattern` selects one of the generators in `src/strategies.ts`:
| Name | Motion | Steps | Size | Bounds | | Name | Motion | Steps | Size |
| ------------- | ------------------------------------------------------------- | ----- | -------- | --------- | | ------------- | ------------------------------------------------------------- | ----- | ----------- |
| `line` | Straight horizontal sweep (the original behavior). | 250 | 250px | `abort` | | `line` | Straight horizontal sweep (the original behavior). | 250 | 250px |
| `diagonal` | Straight line on both axes toward the roomiest corner. | 250 | 250px/axis | `clamp` | | `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 | `clamp` | | `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 | `reflect` | | `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 | `clamp` | | `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 | `clamp` | | `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 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 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 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 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 scales every pattern's total duration. To add a pattern, write one pure
generator and register it — the executor supplies bounds, pacing, interrupt, generator and register it — the executor supplies on-screen reflection,
and restore for free. pacing, interrupt, and restore for free.
### Why `mouse.config.autoDelayMs = 0` ### 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/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/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/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. | | `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`. | | `package.json` | Bun project manifest. Single runtime dep: `@nut-tree-fork/nut-js`. |
| `tsconfig.json` | Strict TypeScript config tuned for Bun (ESNext, bundler resolution). | | `tsconfig.json` | Strict TypeScript config tuned for Bun (ESNext, bundler resolution). |
+18 -3
View File
@@ -38,12 +38,12 @@ sequenceDiagram
end end
Keeper->>Sim: simulateActivity(config, log, dev) Keeper->>Sim: simulateActivity(config, log, dev)
Sim->>Dev: getPosition()
Dev-->>Sim: start
Sim->>Dev: width() Sim->>Dev: width()
Dev-->>Sim: width Dev-->>Sim: width
Sim->>Dev: height() Sim->>Dev: height()
Dev-->>Sim: height Dev-->>Sim: height
Sim->>Dev: getPosition()
Dev-->>Sim: start
Note over Sim: strategy = STRATEGIES[config.pattern]<br/>ctx = { start, width, height, rng } Note over Sim: strategy = STRATEGIES[config.pattern]<br/>ctx = { start, width, height, rng }
Sim->>Exec: executePath(strategy, ctx, dev, log, config) Sim->>Exec: executePath(strategy, ctx, dev, log, config)
@@ -51,7 +51,7 @@ sequenceDiagram
Strat-->>Exec: iterable of Points Strat-->>Exec: iterable of Points
loop for each target point (clean run) 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: setPosition(point)
Exec->>Dev: sleep(stepDelay) Exec->>Dev: sleep(stepDelay)
Exec->>Dev: getPosition() Exec->>Dev: getPosition()
@@ -86,3 +86,18 @@ sequenceDiagram
follow-up `getPosition()` in `runKeeper` re-syncs `lastPos` to the origin as 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 a no-op, and the next idle check sees no net movement (so the synthetic
sweep is never mistaken for the user returning). 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.
+2 -2
View File
@@ -17,7 +17,7 @@
* -c, --check-interval Cursor poll cadence (seconds). * -c, --check-interval Cursor poll cadence (seconds).
* -d, --step-delay Pause between synthetic steps (ms). * -d, --step-delay Pause between synthetic steps (ms).
* -p, --pattern Movement strategy name (see strategies.ts). * -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`.) * (`-V` capital because `-v` is `--version`.)
* -l, --loop Loop mode: once triggered, keep moving * -l, --loop Loop mode: once triggered, keep moving
* until the user moves the mouse (or Ctrl+C). * until the user moves the mouse (or Ctrl+C).
@@ -180,7 +180,7 @@ Options:
-p, --pattern <name> Movement strategy. Default: ${DEFAULT_CONFIG.pattern}. -p, --pattern <name> Movement strategy. Default: ${DEFAULT_CONFIG.pattern}.
One of: ${PATTERN_NAMES.join(", ")}. One of: ${PATTERN_NAMES.join(", ")}.
Each pattern defines its own size and speed. 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). (default prints only the startup banner).
-l, --loop Loop mode: once a sweep is triggered, -l, --loop Loop mode: once a sweep is triggered,
keep moving until you move the mouse (or keep moving until you move the mouse (or
+2 -2
View File
@@ -45,8 +45,8 @@ import seedRaw from "../scripts/config.default.json" with { type: "json" };
* - `pattern` — name of the movement strategy to use (see * - `pattern` — name of the movement strategy to use (see
* `strategies.ts`; e.g. `line`, `walk`, `arc`). Each * `strategies.ts`; e.g. `line`, `walk`, `arc`). Each
* pattern owns its own size and step count. * pattern owns its own size and step count.
* - `verbose` — whether per-sweep / interrupt / bounds events are * - `verbose` — whether per-sweep / interrupt events are logged. The
* logged. The startup banner is always printed. * startup banner is always printed.
* - `loop` — loop mode: once a sweep is triggered, keep * - `loop` — loop mode: once a sweep is triggered, keep
* repeating the movement until the user moves the mouse * repeating the movement until the user moves the mouse
* (or Ctrl+C), rather than firing a single sweep. See * (or Ctrl+C), rather than firing a single sweep. See
+30 -69
View File
@@ -7,13 +7,13 @@
* *everything else* about carrying a sweep out against a `Device`: * *everything else* about carrying a sweep out against a `Device`:
* *
* - round each ideal target to whole pixels, * - 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`, * - command the cursor and pace it with `stepDelay`,
* - detect real-user interruption after each step, * - detect real-user interruption after each step,
* - restore the cursor to the origin on a clean run. * - restore the cursor to the origin on a clean run.
* *
* Writing this once means new patterns inherit correct real-user-wins, * 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 * all side effects go through the injected `Device`, so it's unit-testable
* with a fake. * with a fake.
* *
@@ -25,7 +25,7 @@
import type { Config } from "./config.ts"; import type { Config } from "./config.ts";
import type { Device, Point } from "./device.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. * Minimal log surface used by the executor and the keeper loop.
@@ -41,11 +41,10 @@ export interface Logger {
/** /**
* How a sweep ended: * How a sweep ended:
* - `completed` — full path ran and the cursor was restored to start. * - `completed` — full path ran and the cursor was restored to start.
* - `interrupted` — real user activity detected mid-sweep; aborted without * - `interrupted` — real user activity detected mid-sweep; the sweep stopped
* snapping back. * without snapping back.
* - `aborted` — an `abort`-policy target went out of bounds.
*/ */
export type SweepOutcome = "completed" | "interrupted" | "aborted"; export type SweepOutcome = "completed" | "interrupted";
/** /**
* Per-call knobs for `executePath`. All optional; the defaults reproduce the * 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`: * Default `true`. Loop (`--loop`) mode passes `false`:
* chained cycles must not snap back between iterations, and an * chained cycles must not snap back between iterations, and an
* infinite `loopPath` never reaches the restore anyway. * 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 * - `loop` — prefer the strategy's infinite `loopPath` when it defines
* one. Falls back to `path` when the strategy has no * one. Falls back to `path` when the strategy has no
* `loopPath`, so a plain chained-repeat caller can pass this * `loopPath`, so a plain chained-repeat caller can pass this
@@ -68,7 +62,6 @@ export type SweepOutcome = "completed" | "interrupted" | "aborted";
*/ */
export interface ExecuteOptions { export interface ExecuteOptions {
readonly restore?: boolean; readonly restore?: boolean;
readonly bounds?: BoundsPolicy;
readonly loop?: boolean; readonly loop?: boolean;
} }
@@ -81,20 +74,17 @@ export interface ExecuteOptions {
const READBACK_TOLERANCE: number = 2; const READBACK_TOLERANCE: number = 2;
/** /**
* Pixels to inset the `clamp` / `reflect` travel range from each screen edge. * Pixels to inset the travel range from each screen edge. Keeps edge-seeking
* Keeps edge-seeking patterns off the literal first/last pixel, where DPI * patterns off the literal first/last pixel, where DPI scaling and
* scaling and multi-monitor boundaries most often make the OS place the * multi-monitor boundaries most often make the OS place the cursor a hair off
* cursor a hair off what we commanded (which the readback check would then * what we commanded (which the readback check would then misread as the user).
* misread as the user). `abort` (used by `line`) is deliberately left on the
* full `[0, max - 1]` range, so its behavior is unchanged.
*/ */
const EDGE_MARGIN: number = 2; const EDGE_MARGIN: number = 2;
/** /**
* The inclusive `[lo, hi]` integer range an axis of length `max` may travel * The inclusive `[lo, hi]` integer range an axis of length `max` may travel:
* under the `clamp` / `reflect` policies: `[0, max - 1]` inset by * `[0, max - 1]` inset by `EDGE_MARGIN` on each side. Screens too small to
* `EDGE_MARGIN` on each side. Screens too small to inset fall back to the * inset fall back to the full range so the math never inverts.
* full range so the math never inverts.
*/ */
function travelRange(max: number): { lo: number; hi: number } { function travelRange(max: number): { lo: number; hi: number } {
const hiEdge: number = max - 1; 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 }; 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 * 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 { function reflectInt(v: number, max: number): number {
const { lo, hi } = travelRange(max); const { lo, hi } = travelRange(max);
@@ -125,28 +108,12 @@ function reflectInt(v: number, max: number): number {
} }
/** /**
* Resolve a strategy's ideal target to an on-screen integer pixel under the * Resolve a strategy's ideal (possibly fractional, possibly off-screen) target
* given policy. Returns `null` when policy is `abort` and the (rounded) * to an on-screen integer pixel by reflecting each axis into its travel range.
* target lies outside the screen — the signal to stop the sweep.
*/ */
function resolveTarget( function resolveTarget(p: Point, width: number, height: number): Point {
policy: BoundsPolicy,
p: Point,
width: number,
height: number,
): Point | null {
if (policy === "reflect") {
return { x: reflectInt(p.x, width), y: reflectInt(p.y, height) }; return { x: reflectInt(p.x, width), y: reflectInt(p.y, height) };
} }
if (policy === "clamp") {
return { x: clampInt(p.x, width), y: clampInt(p.y, height) };
}
// abort: round, then reject anything off-screen.
const x: number = Math.round(p.x);
const y: number = Math.round(p.y);
if (x < 0 || x >= width || y < 0 || y >= height) return null;
return { x, y };
}
/** /**
* Format the current local time as `HH:MM:SS` for log lines. * Format the current local time as `HH:MM:SS` for log lines.
@@ -162,8 +129,8 @@ function timestamp(): string {
* against `device`. * against `device`.
* *
* Contract, per step: * Contract, per step:
* 1. Resolve the ideal target to an on-screen integer (bounds policy). * 1. Resolve the ideal target to an on-screen integer by reflecting it
* An `abort`-policy out-of-bounds target ends the sweep (`aborted`). * into the travel range.
* 2. Command the cursor there and sleep `config.stepDelay` — also the * 2. Command the cursor there and sleep `config.stepDelay` — also the
* user's interrupt window. * user's interrupt window.
* 3. Re-read the cursor. If it isn't at the point we just commanded, the * 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 * `options.restore === false` (loop mode), in which case the cursor is
* left where the last step put it. * left where the last step put it.
* *
* `options` (all optional, see `ExecuteOptions`) let loop mode reuse * `options` (all optional, see `ExecuteOptions`) let loop mode reuse this
* this same driver: `bounds` overrides the strategy's policy (loop mode * same driver: `loop` selects the strategy's infinite `loopPath`, and
* forces `reflect`), `loop` selects the strategy's infinite `loopPath`, and
* `restore` suppresses the snap-back. Omitting `options` reproduces the * `restore` suppresses the snap-back. Omitting `options` reproduces the
* original single-sweep contract exactly. * original single-sweep contract exactly.
* *
@@ -192,18 +158,13 @@ export async function executePath(
options?: ExecuteOptions, options?: ExecuteOptions,
): Promise<SweepOutcome> { ): Promise<SweepOutcome> {
const { start, width, height } = ctx; const { start, width, height } = ctx;
const policy: BoundsPolicy = options?.bounds ?? strategy.bounds;
const path: Iterable<Point> = const path: Iterable<Point> =
options?.loop && strategy.loopPath ? strategy.loopPath(ctx) : strategy.path(ctx); options?.loop && strategy.loopPath ? strategy.loopPath(ctx) : strategy.path(ctx);
log.event(`Simulating activity (${strategy.name}) at ${timestamp()}...`); log.event(`Simulating activity (${strategy.name}) at ${timestamp()}...`);
for (const target of path) { for (const target of path) {
const point: Point | null = resolveTarget(policy, target, width, height); const point: Point = resolveTarget(target, width, height);
if (point === null) {
log.event(`Out of bounds at ${timestamp()}; aborting simulation.`);
return "aborted";
}
await device.setPosition(point); await device.setPosition(point);
await device.sleep(config.stepDelay); await device.sleep(config.stepDelay);
@@ -213,17 +174,17 @@ export async function executePath(
Math.abs(current.x - point.x) > READBACK_TOLERANCE || Math.abs(current.x - point.x) > READBACK_TOLERANCE ||
Math.abs(current.y - point.y) > 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. // without snapping back, so we don't yank it from under the user.
// //
// The comparison allows a small tolerance rather than demanding an // The comparison allows a small tolerance rather than demanding an
// exact match: on scaled (fractional-DPI) or multi-monitor setups // exact match: on scaled (fractional-DPI) or multi-monitor setups
// the OS can place the cursor a pixel off the coordinate we // the OS can place the cursor a pixel off the coordinate we
// commanded, and the edge-seeking patterns (clamp/reflect/arc) // commanded, and edge-seeking patterns reach exactly the
// reach exactly the coordinates where that's most likely. A real // coordinates where that's most likely. A real user moves far more
// user moves far more than a couple of pixels, so this doesn't // than a couple of pixels, so this doesn't meaningfully weaken
// meaningfully weaken real-user-wins. // real-user-wins.
log.event(`User activity detected at ${timestamp()}; aborting simulation.`); log.event(`User activity detected at ${timestamp()}; stopping simulation.`);
return "interrupted"; return "interrupted";
} }
} }
+14 -17
View File
@@ -8,8 +8,8 @@
* the interesting parts stay testable: * the interesting parts stay testable:
* - `device.ts` — the nut.js I/O boundary (injected here). * - `device.ts` — the nut.js I/O boundary (injected here).
* - `strategies.ts` — pure "where to move" pattern generators. * - `strategies.ts` — pure "where to move" pattern generators.
* - `executor.ts` — the "how to move" driver (bounds, timing, * - `executor.ts` — the "how to move" driver (on-screen reflection,
* interrupt detection, restore). * timing, interrupt detection, restore).
* *
* `runKeeper` takes an optional `Device` so tests can drive the loop with a * `runKeeper` takes an optional `Device` so tests can drive the loop with a
* fake; production supplies the nut.js device. Importing this module is * fake; production supplies the nut.js device. Importing this module is
@@ -18,9 +18,9 @@
* Logging policy: * Logging policy:
* - The startup banner in `runKeeper` is unconditional so the user always * - The startup banner in `runKeeper` is unconditional so the user always
* sees the process is alive. * sees the process is alive.
* - Per-sweep / interrupt / bounds lines are gated by `config.verbose` * - Per-sweep / interrupt lines are gated by `config.verbose` (see
* (see `makeLogger`). Errors stay on `console.error`, raised by the * `makeLogger`). Errors stay on `console.error`, raised by the entry
* entry point on unhandled rejection. * point on unhandled rejection.
*/ */
import { createNutDevice, type Device, type Point } from "./device.ts"; 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. * at the CLI / config-file boundary should prevent that from ever happening.
* *
* Single-sweep mode (`config.loop === false`) runs exactly one sweep via * Single-sweep mode (`config.loop === false`) runs exactly one sweep via
* `executePath`, which owns bounds, pacing, interrupt detection, and * `executePath`, which owns on-screen reflection, pacing, interrupt
* restore-on-clean — unchanged from before loop mode existed. * detection, and restore-on-clean — unchanged from before loop mode existed.
* *
* Loop mode (`config.loop === true`) keeps the cursor moving until the * Loop mode (`config.loop === true`) keeps the cursor moving until the
* user moves the mouse (or Ctrl+C). Two things change for every pattern: * user moves the mouse (or Ctrl+C). The cursor is never restored between
* the cursor is never restored between iterations (`restore: false`), and the * iterations (`restore: false`). Patterns that define an infinite `loopPath`
* bounds policy is forced to `reflect` so edge-seeking paths bounce off the * (`line`, `diagonal`) run it once and are stopped only by interruption; the
* screen instead of aborting (`line`) or sticking in a corner (`clamp`). * rest have their finite `path` chained, re-read from the cursor's current
* Patterns that define an infinite `loopPath` (`line`, `diagonal`) run it once * position each cycle. Per-cycle event logs are suppressed to avoid unbounded
* and are stopped only by interruption; the rest have their finite `path` * output — one line brackets the run at each end.
* 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<void> { async function simulateActivity(config: Config, log: Logger, device: Device): Promise<void> {
const width: number = await device.width(); 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.`); log.event(`Loop mode (${strategy.name}); repeating until you move the mouse.`);
const cycleLog: Logger = { info: log.info, event: (): void => {} }; 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 cycles = 0;
let outcome: SweepOutcome; let outcome: SweepOutcome;
+30 -46
View File
@@ -10,9 +10,10 @@
* add (write one pure generator) and trivial to test (feed a deterministic * add (write one pure generator) and trivial to test (feed a deterministic
* `rng`, assert the emitted points). * `rng`, assert the emitted points).
* *
* Coordinates emitted here may be fractional; the executor rounds to whole * Coordinates emitted here may be fractional and may fall past a screen
* pixels before commanding the cursor and applies the strategy's declared * edge; the executor rounds to whole pixels and reflects any out-of-range
* `BoundsPolicy` to keep everything on-screen. * 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 * 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 * reaches, how tight its radius is — as module-private constants below. Those
@@ -25,19 +26,6 @@
import type { Point } from "./device.ts"; 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 * 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 * 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 * - `name` — registry key, also the value accepted by `--pattern` / the
* `pattern` config key. * `pattern` config key.
* - `bounds` — how the executor confines this pattern to the screen.
* - `path` — pure generator of ideal (possibly fractional) targets, * - `path` — pure generator of ideal (possibly fractional) targets,
* emitted in visiting order. Should not re-emit `start`. * emitted in visiting order. Should not re-emit `start`.
* - `loopPath` — optional infinite variant for loop mode (`--loop`). * - `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 * direction from the cursor's position every cycle, so chained
* repetition oscillates in a band near an edge instead of * repetition oscillates in a band near an edge instead of
* crossing the screen. An infinite generator picks its * crossing the screen. An infinite generator picks its
* direction once and ramps forever; the executor's `reflect` * direction once and ramps forever; the executor reflects the
* policy (forced on in loop mode) folds the monotonic ramp * monotonic ramp into an edge-to-edge bounce. Absent this,
* into an edge-to-edge bounce. Absent this, loop mode simply * loop mode simply chains `path` — correct for patterns whose
* chains `path` — correct for patterns whose finite path is a * finite path is a self-contained cyclic unit (`jitter`,
* self-contained cyclic unit (`jitter`, `walk`, `arc`, * `walk`, `arc`, `figureEight`). The executor stops either
* `figureEight`). The executor stops either kind on real user * kind on real user activity; an infinite `loopPath` therefore
* activity; an infinite `loopPath` therefore only ever ends * only ever ends by interruption.
* by interruption.
*/ */
export interface MovementStrategy { export interface MovementStrategy {
readonly name: string; readonly name: string;
readonly bounds: BoundsPolicy;
path(ctx: MoveContext): Iterable<Point>; path(ctx: MoveContext): Iterable<Point>;
loopPath?(ctx: MoveContext): Iterable<Point>; loopPath?(ctx: MoveContext): Iterable<Point>;
} }
/** 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 { function clamp(v: number, max: number): number {
if (v < 0) return 0; if (v < 0) return 0;
if (v > max - 1) return max - 1; 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 * 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 * 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 * vertical movement. 250 one-pixel steps is byte-for-byte the sweep the
* keeper produced before movement patterns existed, which is why its bounds * keeper produced before movement patterns existed. The direction choice
* policy is `abort` (the direction choice guarantees it never triggers). * 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 * In loop mode `loopPath` ramps x in one direction forever; the direction
* forces `reflect`, so the direction never matters and the ramp bounces edge * never matters because the executor reflects the ramp edge to edge.
* to edge). `LINE_LOOP_STEP` is several pixels per step rather than one so a * `LINE_LOOP_STEP` is several pixels per step rather than one so a screen
* screen crossing takes seconds, not minutes, at the default cadence. * crossing takes seconds, not minutes, at the default cadence.
*/ */
const LINE_STEPS = 250; const LINE_STEPS = 250;
const LINE_LOOP_STEP = 4; const LINE_LOOP_STEP = 4;
export const line: MovementStrategy = { export const line: MovementStrategy = {
name: "line", name: "line",
bounds: "abort",
*path(ctx: MoveContext): Generator<Point> { *path(ctx: MoveContext): Generator<Point> {
const { start, width } = ctx; const { start, width } = ctx;
const dx: number = start.x + LINE_STEPS < width ? 1 : -1; 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 * roomiest corner and stays on-screen. 250 single-pixel steps per axis
* (≈250px reach), matching `line`'s magnitude. * (≈250px reach), matching `line`'s magnitude.
* *
* In loop mode `loopPath` ramps both axes forever under the forced * In loop mode `loopPath` ramps both axes forever, and the executor reflects
* `reflect` policy. Because the x and y travel ranges have different spans, * them. Because the x and y travel ranges have different spans, their
* their triangle waves have different periods, so the path precesses across * triangle waves have different periods, so the path precesses across the
* the whole screen — the roaming-DVD bounce — rather than retracing one 45° * whole screen — the roaming-DVD bounce — rather than retracing one 45° line.
* line.
*/ */
const DIAGONAL_STEPS = 250; const DIAGONAL_STEPS = 250;
const DIAGONAL_LOOP_STEP = 4; const DIAGONAL_LOOP_STEP = 4;
export const diagonal: MovementStrategy = { export const diagonal: MovementStrategy = {
name: "diagonal", name: "diagonal",
bounds: "clamp",
*path(ctx: MoveContext): Generator<Point> { *path(ctx: MoveContext): Generator<Point> {
const { start, width, height } = ctx; const { start, width, height } = ctx;
const dx: number = start.x + DIAGONAL_STEPS < width ? 1 : -1; const dx: number = start.x + DIAGONAL_STEPS < width ? 1 : -1;
@@ -178,7 +166,6 @@ const JITTER_RADIUS = 30;
export const jitter: MovementStrategy = { export const jitter: MovementStrategy = {
name: "jitter", name: "jitter",
bounds: "clamp",
*path(ctx: MoveContext): Generator<Point> { *path(ctx: MoveContext): Generator<Point> {
const { start, rng } = ctx; const { start, rng } = ctx;
for (let i = 1; i <= JITTER_STEPS; i++) { 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 * 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 * 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 * this many steps would drift only ~√N pixels net. The generator lets the
* position drift freely; the executor's `reflect` policy mirrors it back * position drift freely; the executor mirrors it back on-screen, so the
* on-screen, so the cursor bounces off the edges instead of escaping. * cursor bounces off the edges instead of escaping.
*/ */
const WALK_STEPS = 200; const WALK_STEPS = 200;
const WALK_STEP = 4; const WALK_STEP = 4;
export const walk: MovementStrategy = { export const walk: MovementStrategy = {
name: "walk", name: "walk",
bounds: "reflect",
*path(ctx: MoveContext): Generator<Point> { *path(ctx: MoveContext): Generator<Point> {
const { start, rng } = ctx; const { start, rng } = ctx;
let x: number = start.x; let x: number = start.x;
@@ -226,7 +212,6 @@ const ARC_REACH = 300;
export const arc: MovementStrategy = { export const arc: MovementStrategy = {
name: "arc", name: "arc",
bounds: "clamp",
*path(ctx: MoveContext): Generator<Point> { *path(ctx: MoveContext): Generator<Point> {
const { start, width, height, rng } = ctx; const { start, width, height, rng } = ctx;
@@ -268,7 +253,6 @@ const FIG8_AMP = 125;
export const figureEight: MovementStrategy = { export const figureEight: MovementStrategy = {
name: "figureEight", name: "figureEight",
bounds: "clamp",
*path(ctx: MoveContext): Generator<Point> { *path(ctx: MoveContext): Generator<Point> {
const { start } = ctx; const { start } = ctx;
for (let i = 1; i <= FIG8_STEPS; i++) { for (let i = 1; i <= FIG8_STEPS; i++) {
+52 -58
View File
@@ -2,9 +2,9 @@
* executor.test.ts * executor.test.ts
* ---------------- * ----------------
* Unit tests for the execution driver against a fake `Device`. Covers the * Unit tests for the execution driver against a fake `Device`. Covers the
* three sweep outcomes, all three bounds policies, the rounding/interrupt * two sweep outcomes, on-screen reflection, the rounding/interrupt contract,
* contract, and step pacing — none of which was testable before the device * step pacing, and the loop/restore options — none of which was testable
* seam existed. * before the device seam existed.
*/ */
import { describe, expect, test } from "bun:test"; 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 { Config } from "../src/config.ts";
import type { Device, Point } from "../src/device.ts"; import type { Device, Point } from "../src/device.ts";
import { executePath, type Logger } from "../src/executor.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 => {} }; 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. */ /** A strategy that emits a fixed list of points. */
function fixed(points: Point[], bounds: BoundsPolicy): MovementStrategy { function fixed(points: Point[]): MovementStrategy {
return { return {
name: "fixed", name: "fixed",
bounds,
*path(): Generator<Point> { *path(): Generator<Point> {
yield* points; yield* points;
}, },
@@ -79,7 +78,7 @@ describe("executePath — outcomes", () => {
{ x: 502, y: 500 }, { x: 502, y: 500 },
{ x: 503, 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"); expect(outcome).toBe("completed");
// 3 steps + 1 restore. // 3 steps + 1 restore.
expect(dev.commanded).toEqual([...pts, start]); expect(dev.commanded).toEqual([...pts, start]);
@@ -95,42 +94,56 @@ describe("executePath — outcomes", () => {
]; ];
// 2nd getPosition call reports the user elsewhere. // 2nd getPosition call reports the user elsewhere.
dev.overrides.set(2, { x: 9, y: 9 }); 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"); expect(outcome).toBe("interrupted");
// Commanded points 1 and 2 only; never restored to start. // Commanded points 1 and 2 only; never restored to start.
expect(dev.commanded).toEqual([pts[0]!, pts[1]!]); expect(dev.commanded).toEqual([pts[0]!, pts[1]!]);
expect(dev.commanded.at(-1)).not.toEqual(start); 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", () => { describe("executePath — on-screen reflection", () => {
test("clamp pins out-of-bounds coordinates to the inset edges", async () => { test("mirrors an out-of-range coordinate back inside the inset range", 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 () => {
const dev = new FakeDevice(100, 100); const dev = new FakeDevice(100, 100);
// Inset range [2, 97], span = 95; x=120 -> (120-2)=118, 190-118=72, +2 = 74. // Inset range [2, 97], span = 95; x=120 -> (120-2)=118, 190-118=72, +2 = 74.
const pts = [{ x: 120, y: 50 }]; 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 }); 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", () => { describe("executePath — options", () => {
@@ -142,7 +155,7 @@ describe("executePath — options", () => {
{ x: 502, y: 500 }, { x: 502, y: 500 },
]; ];
const outcome = await executePath( const outcome = await executePath(
fixed(pts, "clamp"), fixed(pts),
ctxOf(start, dev.w, dev.h), ctxOf(start, dev.w, dev.h),
dev, dev,
noopLog, noopLog,
@@ -158,34 +171,15 @@ describe("executePath — options", () => {
const dev = new FakeDevice(); const dev = new FakeDevice();
const start = { x: 500, y: 500 }; const start = { x: 500, y: 500 };
const pts = [{ x: 501, 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]); 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 () => { test("loop:true runs loopPath when present, path otherwise", async () => {
const dev = new FakeDevice(); const dev = new FakeDevice();
// A strategy whose loopPath differs from its path, both finite here. // A strategy whose loopPath differs from its path, both finite here.
const strat: MovementStrategy = { const strat: MovementStrategy = {
name: "dual", name: "dual",
bounds: "clamp",
*path(): Generator<Point> { *path(): Generator<Point> {
yield { x: 1, y: 1 }; 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 () => { test("loop:true falls back to path when the strategy has no loopPath", async () => {
const dev = new FakeDevice(); 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(), { await executePath(strat, ctxOf({ x: 0, y: 0 }, dev.w, dev.h), dev, noopLog, cfgOf(), {
loop: true, loop: true,
restore: false, restore: false,
@@ -224,7 +218,7 @@ describe("executePath — readback tolerance", () => {
// not the user). 2px is within READBACK_TOLERANCE, so the sweep runs on. // not the user). 2px is within READBACK_TOLERANCE, so the sweep runs on.
dev.overrides.set(1, { x: 512, y: 501 }); dev.overrides.set(1, { x: 512, y: 501 });
dev.overrides.set(2, { x: 518, y: 499 }); 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(outcome).toBe("completed");
expect(dev.commanded).toEqual([...pts, start]); 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. // First readback is 3px off -> exceeds the 2px tolerance -> real user.
dev.overrides.set(1, { x: 513, y: 500 }); 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(outcome).toBe("interrupted");
expect(dev.commanded).toEqual([pts[0]!]); expect(dev.commanded).toEqual([pts[0]!]);
}); });
@@ -249,7 +243,7 @@ describe("executePath — rounding & pacing", () => {
const dev = new FakeDevice(); const dev = new FakeDevice();
const start = { x: 500, y: 500 }; const start = { x: 500, y: 500 };
const pts = [{ x: 10.4, y: 20.6 }]; // -> (10, 21) 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(outcome).toBe("completed");
expect(dev.commanded[0]).toEqual({ x: 10, y: 21 }); expect(dev.commanded[0]).toEqual({ x: 10, y: 21 });
}); });
@@ -260,7 +254,7 @@ describe("executePath — rounding & pacing", () => {
{ x: 501, y: 500 }, { x: 501, y: 500 },
{ x: 502, 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]); expect(dev.sleeps).toEqual([7, 7]);
}); });
}); });