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:
+15
-5
@@ -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
|
||||
|
||||
|
||||
@@ -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). |
|
||||
|
||||
@@ -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]<br/>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.
|
||||
|
||||
+2
-2
@@ -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 <name> 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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+30
-69
@@ -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,28 +108,12 @@ 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") {
|
||||
function resolveTarget(p: Point, width: number, height: number): Point {
|
||||
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.
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
+14
-17
@@ -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<void> {
|
||||
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;
|
||||
|
||||
+30
-46
@@ -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<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 {
|
||||
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<Point> {
|
||||
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<Point> {
|
||||
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<Point> {
|
||||
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<Point> {
|
||||
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<Point> {
|
||||
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<Point> {
|
||||
const { start } = ctx;
|
||||
for (let i = 1; i <= FIG8_STEPS; i++) {
|
||||
|
||||
+52
-58
@@ -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<Point> {
|
||||
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<Point> {
|
||||
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]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user