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
+31 -70
View File
@@ -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<SweepOutcome> {
const { start, width, height } = ctx;
const policy: BoundsPolicy = options?.bounds ?? strategy.bounds;
const path: Iterable<Point> =
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";
}
}