Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b019f25a42 | ||
|
|
c8942bb380 | ||
|
|
7e632b3e9d |
@@ -5,6 +5,28 @@ All notable changes to `move` are documented here.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.4.0] - 2026-08-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Loop mode: `-l` / `--loop` (and the `loop` config key) keep the mouse
|
||||||
|
moving after a sweep is triggered until real user activity is detected,
|
||||||
|
instead of firing a single sweep. In loop mode the cursor is never restored
|
||||||
|
between iterations, 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
|
## [1.3.3] - 2026-08-17
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
@@ -165,6 +187,7 @@ Initial release.
|
|||||||
- Source split into `src/{move,cli,config,keeper}.ts`.
|
- Source split into `src/{move,cli,config,keeper}.ts`.
|
||||||
- `bin` entry + shebang so `bun link` registers `move` globally.
|
- `bin` entry + shebang so `bun link` registers `move` globally.
|
||||||
|
|
||||||
|
[1.4.0]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.3.3...v1.4.0
|
||||||
[1.3.3]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.3.2...v1.3.3
|
[1.3.3]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.3.2...v1.3.3
|
||||||
[1.3.2]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.3.1...v1.3.2
|
[1.3.2]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.3.1...v1.3.2
|
||||||
[1.3.1]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.3.0...v1.3.1
|
[1.3.1]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.3.0...v1.3.1
|
||||||
|
|||||||
@@ -121,8 +121,11 @@ 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,
|
||||||
|
keep moving until you move the mouse (or
|
||||||
|
Ctrl+C), instead of firing a single sweep.
|
||||||
|
|
||||||
Precedence (highest wins): CLI flags > config file > built-in defaults.
|
Precedence (highest wins): CLI flags > config file > built-in defaults.
|
||||||
```
|
```
|
||||||
@@ -135,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`.
|
||||||
@@ -180,14 +183,15 @@ doesn't set.
|
|||||||
"checkInterval": 10,
|
"checkInterval": 10,
|
||||||
"stepDelay": 50,
|
"stepDelay": 50,
|
||||||
"pattern": "line",
|
"pattern": "line",
|
||||||
"verbose": false
|
"verbose": false,
|
||||||
|
"loop": false
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
All keys are optional; supply only the ones you want to override. Keys
|
All keys are optional; supply only the ones you want to override. Keys
|
||||||
and units mirror the CLI flags exactly: `moveInterval` and
|
and units mirror the CLI flags exactly: `moveInterval` and
|
||||||
`checkInterval` are seconds, `stepDelay` is milliseconds, `pattern` is a
|
`checkInterval` are seconds, `stepDelay` is milliseconds, `pattern` is a
|
||||||
movement strategy name, `verbose` is a boolean.
|
movement strategy name, `verbose` and `loop` are booleans.
|
||||||
|
|
||||||
> The obsolete `stepCount` / `stepSize` keys (removed in 1.3.0) are
|
> The obsolete `stepCount` / `stepSize` keys (removed in 1.3.0) are
|
||||||
> tolerated for backward compatibility: they're ignored with a one-line
|
> tolerated for backward compatibility: they're ignored with a one-line
|
||||||
@@ -223,16 +227,36 @@ The loader is strict:
|
|||||||
case and separators (`-`, `_`, spaces), so `figure-eight` and `figureEight`
|
case and separators (`-`, `_`, spaces), so `figure-eight` and `figureEight`
|
||||||
are equivalent.
|
are equivalent.
|
||||||
- `verbose` must be a boolean.
|
- `verbose` must be a boolean.
|
||||||
|
- `loop` must be a boolean.
|
||||||
|
|
||||||
Any validation failure prints a message naming the file and the offending
|
Any validation failure prints a message naming the file and the offending
|
||||||
key to `stderr` and exits `2`.
|
key to `stderr` and exits `2`.
|
||||||
|
|
||||||
### Known limitation: `verbose` can be turned on but not off from the CLI
|
### Loop mode (`--loop`)
|
||||||
|
|
||||||
`--verbose` is a presence-only flag (there is no `--no-verbose`). If the
|
By default a triggered sweep runs once and stops. With `-l` / `--loop` (or
|
||||||
config file sets `"verbose": true`, the CLI cannot force quiet mode in
|
`"loop": true` in the config file) the movement instead repeats until you
|
||||||
that invocation. Workarounds: edit the file, or point at a different
|
move the mouse (or press `Ctrl+C`) — a "keep moving until I'm back" mode.
|
||||||
file with `--config`.
|
It pairs naturally with the roaming patterns:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
move --pattern diagonal --loop # roaming-DVD bounce around the screen
|
||||||
|
move --pattern figureEight --loop # traces the eight over and over
|
||||||
|
```
|
||||||
|
|
||||||
|
In loop mode the cursor is never restored between iterations, 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
|
||||||
|
|
||||||
|
`--verbose` and `--loop` are presence-only flags (there is no
|
||||||
|
`--no-verbose` / `--no-loop`). If the config file sets `"verbose": true` or
|
||||||
|
`"loop": true`, the CLI cannot force it back off in that invocation.
|
||||||
|
Workarounds: edit the file, or point at a different file with `--config`.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
@@ -265,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`:
|
||||||
|
|
||||||
@@ -297,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
|
||||||
@@ -307,34 +331,44 @@ to milliseconds before handing the resolved `Config` to `runKeeper`.
|
|||||||
the next idle-check sees "no movement" and doesn't misread the synthetic
|
the next idle-check sees "no movement" and doesn't misread the synthetic
|
||||||
activity as real user input.
|
activity as real user input.
|
||||||
|
|
||||||
|
In loop mode (`--loop`) step 2 repeats until the user interrupts: a
|
||||||
|
pattern with an infinite `loopPath` (`line`, `diagonal`) runs that single
|
||||||
|
never-ending path, while the others chain their finite path cycle after
|
||||||
|
cycle. The restore in step 3 is skipped so successive cycles flow from where
|
||||||
|
the last left off.
|
||||||
|
|
||||||
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`
|
||||||
|
|
||||||
@@ -390,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). |
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "move",
|
"name": "move",
|
||||||
"version": "1.3.3",
|
"version": "1.4.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -3,5 +3,6 @@
|
|||||||
"checkInterval": 10,
|
"checkInterval": 10,
|
||||||
"stepDelay": 50,
|
"stepDelay": 50,
|
||||||
"pattern": "line",
|
"pattern": "line",
|
||||||
"verbose": false
|
"verbose": false,
|
||||||
|
"loop": false
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-2
@@ -17,8 +17,10 @@
|
|||||||
* -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
|
||||||
|
* until the user moves the mouse (or Ctrl+C).
|
||||||
*
|
*
|
||||||
* Numeric overrides are layered (CLI > file > DEFAULT_CONFIG) by
|
* Numeric overrides are layered (CLI > file > DEFAULT_CONFIG) by
|
||||||
* `resolveConfig` in `config.ts`; this module only parses and validates.
|
* `resolveConfig` in `config.ts`; this module only parses and validates.
|
||||||
@@ -55,6 +57,11 @@ export interface ParsedCliArgs {
|
|||||||
* even though the CLI has no off-switch today.
|
* even though the CLI has no off-switch today.
|
||||||
*/
|
*/
|
||||||
verbose: boolean | undefined;
|
verbose: boolean | undefined;
|
||||||
|
/**
|
||||||
|
* `true` when `-l`/`--loop` was passed; `undefined` when it was not.
|
||||||
|
* Same `undefined`-not-`false` rationale as `verbose`.
|
||||||
|
*/
|
||||||
|
loop: boolean | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -105,6 +112,7 @@ export function parseCliArgs(): ParsedCliArgs {
|
|||||||
"step-delay": { type: "string", short: "d" },
|
"step-delay": { type: "string", short: "d" },
|
||||||
pattern: { type: "string", short: "p" },
|
pattern: { type: "string", short: "p" },
|
||||||
verbose: { type: "boolean", short: "V" },
|
verbose: { type: "boolean", short: "V" },
|
||||||
|
loop: { type: "boolean", short: "l" },
|
||||||
},
|
},
|
||||||
strict: true,
|
strict: true,
|
||||||
allowPositionals: false,
|
allowPositionals: false,
|
||||||
@@ -127,6 +135,7 @@ export function parseCliArgs(): ParsedCliArgs {
|
|||||||
stepDelay: parsePositiveNumber("step-delay", values["step-delay"] as string | undefined),
|
stepDelay: parsePositiveNumber("step-delay", values["step-delay"] as string | undefined),
|
||||||
pattern: parsePatternName(values.pattern as string | undefined),
|
pattern: parsePatternName(values.pattern as string | undefined),
|
||||||
verbose: values.verbose === true ? true : undefined,
|
verbose: values.verbose === true ? true : undefined,
|
||||||
|
loop: values.loop === true ? true : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,8 +180,11 @@ 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,
|
||||||
|
keep moving until you move the mouse (or
|
||||||
|
Ctrl+C), instead of firing a single sweep.
|
||||||
|
|
||||||
Precedence (highest wins): CLI flags > config file > built-in defaults.
|
Precedence (highest wins): CLI flags > config file > built-in defaults.
|
||||||
|
|
||||||
@@ -181,6 +193,7 @@ Examples:
|
|||||||
move --move-interval 180 --check-interval 5
|
move --move-interval 180 --check-interval 5
|
||||||
move -m 300 -V
|
move -m 300 -V
|
||||||
move --pattern arc
|
move --pattern arc
|
||||||
|
move --pattern diagonal --loop
|
||||||
move --config ~/myprofile.json
|
move --config ~/myprofile.json
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-3
@@ -45,8 +45,12 @@ 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
|
||||||
|
* repeating the movement until the user moves the mouse
|
||||||
|
* (or Ctrl+C), rather than firing a single sweep. See
|
||||||
|
* `keeper.ts` for how the pattern is repeated.
|
||||||
*/
|
*/
|
||||||
export interface Config {
|
export interface Config {
|
||||||
readonly moveInterval: number;
|
readonly moveInterval: number;
|
||||||
@@ -54,6 +58,7 @@ export interface Config {
|
|||||||
readonly stepDelay: number;
|
readonly stepDelay: number;
|
||||||
readonly pattern: PatternName;
|
readonly pattern: PatternName;
|
||||||
readonly verbose: boolean;
|
readonly verbose: boolean;
|
||||||
|
readonly loop: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -68,6 +73,7 @@ interface SeedShape {
|
|||||||
stepDelay: number; // milliseconds
|
stepDelay: number; // milliseconds
|
||||||
pattern: string; // strategy name
|
pattern: string; // strategy name
|
||||||
verbose: boolean;
|
verbose: boolean;
|
||||||
|
loop: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertSeedShape(raw: unknown): asserts raw is SeedShape {
|
function assertSeedShape(raw: unknown): asserts raw is SeedShape {
|
||||||
@@ -87,6 +93,9 @@ function assertSeedShape(raw: unknown): asserts raw is SeedShape {
|
|||||||
if (typeof r.verbose !== "boolean") {
|
if (typeof r.verbose !== "boolean") {
|
||||||
throw new Error(`scripts/config.default.json: 'verbose' must be a boolean (got ${JSON.stringify(r.verbose)})`);
|
throw new Error(`scripts/config.default.json: 'verbose' must be a boolean (got ${JSON.stringify(r.verbose)})`);
|
||||||
}
|
}
|
||||||
|
if (typeof r.loop !== "boolean") {
|
||||||
|
throw new Error(`scripts/config.default.json: 'loop' must be a boolean (got ${JSON.stringify(r.loop)})`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
assertSeedShape(seedRaw);
|
assertSeedShape(seedRaw);
|
||||||
@@ -105,6 +114,7 @@ export const DEFAULT_CONFIG: Config = {
|
|||||||
stepDelay: seed.stepDelay,
|
stepDelay: seed.stepDelay,
|
||||||
pattern: seed.pattern,
|
pattern: seed.pattern,
|
||||||
verbose: seed.verbose,
|
verbose: seed.verbose,
|
||||||
|
loop: seed.loop,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -125,7 +135,8 @@ export const DEFAULT_CONFIG: Config = {
|
|||||||
* was not passed and `true` when it was. There is no CLI off-switch
|
* was not passed and `true` when it was. There is no CLI off-switch
|
||||||
* today, so CLI `false` doesn't occur — a file-set `verbose: true` cannot
|
* today, so CLI `false` doesn't occur — a file-set `verbose: true` cannot
|
||||||
* be overridden back to false from the command line (see the Configuration
|
* be overridden back to false from the command line (see the Configuration
|
||||||
* section of the README).
|
* section of the README). `loop` behaves identically: `-l/--loop` sets it
|
||||||
|
* `true`, and a file-set `loop: true` can't be switched off from the CLI.
|
||||||
*/
|
*/
|
||||||
export interface ConfigOverrides {
|
export interface ConfigOverrides {
|
||||||
readonly moveInterval: number | undefined;
|
readonly moveInterval: number | undefined;
|
||||||
@@ -133,6 +144,7 @@ export interface ConfigOverrides {
|
|||||||
readonly stepDelay: number | undefined;
|
readonly stepDelay: number | undefined;
|
||||||
readonly pattern: string | undefined;
|
readonly pattern: string | undefined;
|
||||||
readonly verbose: boolean | undefined;
|
readonly verbose: boolean | undefined;
|
||||||
|
readonly loop: boolean | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -199,5 +211,6 @@ export function resolveConfig(file: ConfigOverrides | null, cli: ConfigOverrides
|
|||||||
stepDelay: pickRaw(cli.stepDelay, file?.stepDelay, DEFAULT_CONFIG.stepDelay),
|
stepDelay: pickRaw(cli.stepDelay, file?.stepDelay, DEFAULT_CONFIG.stepDelay),
|
||||||
pattern: pickRaw(cli.pattern, file?.pattern, DEFAULT_CONFIG.pattern),
|
pattern: pickRaw(cli.pattern, file?.pattern, DEFAULT_CONFIG.pattern),
|
||||||
verbose: pickRaw(cli.verbose, file?.verbose, DEFAULT_CONFIG.verbose),
|
verbose: pickRaw(cli.verbose, file?.verbose, DEFAULT_CONFIG.verbose),
|
||||||
|
loop: pickRaw(cli.loop, file?.loop, DEFAULT_CONFIG.loop),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
* stepDelay number milliseconds, positive
|
* stepDelay number milliseconds, positive
|
||||||
* pattern string a registered strategy name
|
* pattern string a registered strategy name
|
||||||
* verbose boolean
|
* verbose boolean
|
||||||
|
* loop boolean
|
||||||
*
|
*
|
||||||
* Unknown keys, wrong types, and non-positive numerics are rejected with a
|
* Unknown keys, wrong types, and non-positive numerics are rejected with a
|
||||||
* `CliError` so the entry point can exit 2 (user error) with a clear
|
* `CliError` so the entry point can exit 2 (user error) with a clear
|
||||||
@@ -39,6 +40,7 @@ const ALLOWED_KEYS: ReadonlySet<string> = new Set<string>([
|
|||||||
"stepDelay",
|
"stepDelay",
|
||||||
"pattern",
|
"pattern",
|
||||||
"verbose",
|
"verbose",
|
||||||
|
"loop",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -173,5 +175,9 @@ export function loadConfigFile(explicitPath: string | undefined): ConfigOverride
|
|||||||
"verbose" in parsed
|
"verbose" in parsed
|
||||||
? requireBoolean("verbose", parsed.verbose, path)
|
? requireBoolean("verbose", parsed.verbose, path)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
loop:
|
||||||
|
"loop" in parsed
|
||||||
|
? requireBoolean("loop", parsed.loop, path)
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+61
-61
@@ -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,29 @@ 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
|
||||||
|
* original single-sweep behavior exactly, so every existing caller and test
|
||||||
|
* is unaffected.
|
||||||
|
*
|
||||||
|
* - `restore` — restore the cursor to `ctx.start` after a clean sweep.
|
||||||
|
* Default `true`. Loop (`--loop`) mode passes `false`:
|
||||||
|
* chained cycles must not snap back between iterations, and an
|
||||||
|
* infinite `loopPath` never reaches the restore anyway.
|
||||||
|
* - `loop` — prefer the strategy's infinite `loopPath` when it defines
|
||||||
|
* one. Falls back to `path` when the strategy has no
|
||||||
|
* `loopPath`, so a plain chained-repeat caller can pass this
|
||||||
|
* unconditionally.
|
||||||
|
*/
|
||||||
|
export interface ExecuteOptions {
|
||||||
|
readonly restore?: boolean;
|
||||||
|
readonly loop?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Slack, in pixels, allowed between the coordinate we commanded and the one
|
* Slack, in pixels, allowed between the coordinate we commanded and the one
|
||||||
@@ -56,20 +74,17 @@ export type SweepOutcome = "completed" | "interrupted" | "aborted";
|
|||||||
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;
|
||||||
@@ -77,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);
|
||||||
@@ -100,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.
|
||||||
@@ -137,15 +129,22 @@ 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
|
||||||
* user moved it: return `interrupted` without restoring.
|
* user moved it: return `interrupted` without restoring.
|
||||||
*
|
*
|
||||||
* On a clean run the cursor is restored to `ctx.start` so the next
|
* On a clean run the cursor is restored to `ctx.start` so the next
|
||||||
* idle-check sees no net movement, and `completed` is returned.
|
* idle-check sees no net movement, and `completed` is returned — unless
|
||||||
|
* `options.restore === false` (loop mode), in which case the cursor is
|
||||||
|
* left where the last step put it.
|
||||||
|
*
|
||||||
|
* `options` (all optional, see `ExecuteOptions`) let loop mode reuse this
|
||||||
|
* same driver: `loop` selects the strategy's infinite `loopPath`, and
|
||||||
|
* `restore` suppresses the snap-back. Omitting `options` reproduces the
|
||||||
|
* original single-sweep contract exactly.
|
||||||
*
|
*
|
||||||
* `config` supplies only the pacing (`stepDelay`); a strategy's geometry is
|
* `config` supplies only the pacing (`stepDelay`); a strategy's geometry is
|
||||||
* entirely self-contained, so the path itself needs nothing from it.
|
* entirely self-contained, so the path itself needs nothing from it.
|
||||||
@@ -156,17 +155,16 @@ export async function executePath(
|
|||||||
device: Device,
|
device: Device,
|
||||||
log: Logger,
|
log: Logger,
|
||||||
config: Config,
|
config: Config,
|
||||||
|
options?: ExecuteOptions,
|
||||||
): Promise<SweepOutcome> {
|
): Promise<SweepOutcome> {
|
||||||
const { start, width, height } = ctx;
|
const { start, width, height } = ctx;
|
||||||
|
const path: Iterable<Point> =
|
||||||
|
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 strategy.path(ctx)) {
|
for (const target of path) {
|
||||||
const point: Point | null = resolveTarget(strategy.bounds, 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);
|
||||||
@@ -176,22 +174,24 @@ 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";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options?.restore !== false) {
|
||||||
await device.setPosition({ x: Math.round(start.x), y: Math.round(start.y) });
|
await device.setPosition({ x: Math.round(start.x), y: Math.round(start.y) });
|
||||||
log.event("Mouse moved.");
|
log.event("Mouse moved.");
|
||||||
|
}
|
||||||
return "completed";
|
return "completed";
|
||||||
}
|
}
|
||||||
|
|||||||
+49
-16
@@ -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,13 +18,13 @@
|
|||||||
* 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";
|
||||||
import { executePath, type Logger } from "./executor.ts";
|
import { executePath, type Logger, type SweepOutcome } from "./executor.ts";
|
||||||
import { DEFAULT_PATTERN, STRATEGIES, type MoveContext } from "./strategies.ts";
|
import { DEFAULT_PATTERN, STRATEGIES, type MoveContext } from "./strategies.ts";
|
||||||
|
|
||||||
import type { Config } from "./config.ts";
|
import type { Config } from "./config.ts";
|
||||||
@@ -46,24 +46,57 @@ function makeLogger(verbose: boolean): Logger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Perform a single synthetic mouse-activity sweep.
|
* Perform synthetic mouse activity once the keeper decides the cursor is
|
||||||
|
* idle.
|
||||||
*
|
*
|
||||||
* Snapshots the cursor and screen (re-read every call so monitor changes
|
* Snapshots the screen (re-read every call so monitor changes are handled)
|
||||||
* are handled), selects the configured strategy from the registry, and
|
* and selects the configured strategy from the registry. An unknown
|
||||||
* hands the resulting path to `executePath`, which owns bounds, pacing,
|
* `config.pattern` falls back to the default strategy defensively; validation
|
||||||
* interrupt detection, and restore-on-clean. An unknown `config.pattern`
|
* at the CLI / config-file boundary should prevent that from ever happening.
|
||||||
* falls back to the default strategy defensively; validation at the CLI /
|
*
|
||||||
* config-file boundary should prevent that from ever happening.
|
* Single-sweep mode (`config.loop === false`) runs exactly one sweep via
|
||||||
|
* `executePath`, which owns 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). 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> {
|
async function simulateActivity(config: Config, log: Logger, device: Device): Promise<void> {
|
||||||
const start: Point = await device.getPosition();
|
|
||||||
const width: number = await device.width();
|
const width: number = await device.width();
|
||||||
const height: number = await device.height();
|
const height: number = await device.height();
|
||||||
|
|
||||||
const strategy = STRATEGIES[config.pattern] ?? STRATEGIES[DEFAULT_PATTERN]!;
|
const strategy = STRATEGIES[config.pattern] ?? STRATEGIES[DEFAULT_PATTERN]!;
|
||||||
const ctx: MoveContext = { start, width, height, rng: Math.random };
|
|
||||||
|
|
||||||
|
if (!config.loop) {
|
||||||
|
const start: Point = await device.getPosition();
|
||||||
|
const ctx: MoveContext = { start, width, height, rng: Math.random };
|
||||||
await executePath(strategy, ctx, device, log, config);
|
await executePath(strategy, ctx, device, log, config);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.event(`Loop mode (${strategy.name}); repeating until you move the mouse.`);
|
||||||
|
const cycleLog: Logger = { info: log.info, event: (): void => {} };
|
||||||
|
const loopOpts = { restore: false, loop: true };
|
||||||
|
|
||||||
|
let cycles = 0;
|
||||||
|
let outcome: SweepOutcome;
|
||||||
|
do {
|
||||||
|
const start: Point = await device.getPosition();
|
||||||
|
const ctx: MoveContext = { start, width, height, rng: Math.random };
|
||||||
|
outcome = await executePath(strategy, ctx, device, cycleLog, config, loopOpts);
|
||||||
|
cycles++;
|
||||||
|
// Spin guard for the chained-repeat path: a finite strategy that
|
||||||
|
// yielded nothing would otherwise return "completed" instantly in a
|
||||||
|
// tight loop. Sleeping one stepDelay makes that harmless. An infinite
|
||||||
|
// loopPath never returns "completed", so this branch is skipped there.
|
||||||
|
if (outcome === "completed") await device.sleep(config.stepDelay);
|
||||||
|
} while (outcome === "completed");
|
||||||
|
|
||||||
|
log.event(`Loop run ended after ${cycles} cycle(s): ${outcome}.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ const cliOverrides: ConfigOverrides = {
|
|||||||
stepDelay: cliArgs.stepDelay,
|
stepDelay: cliArgs.stepDelay,
|
||||||
pattern: cliArgs.pattern,
|
pattern: cliArgs.pattern,
|
||||||
verbose: cliArgs.verbose,
|
verbose: cliArgs.verbose,
|
||||||
|
loop: cliArgs.loop,
|
||||||
};
|
};
|
||||||
|
|
||||||
const config = resolveConfig(fileOverrides, cliOverrides);
|
const config = resolveConfig(fileOverrides, cliOverrides);
|
||||||
|
|||||||
+59
-29
@@ -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,17 +47,34 @@ 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`).
|
||||||
|
* A pattern defines it when its finite `path` doesn't chain
|
||||||
|
* cleanly under repetition: `line`/`diagonal` re-derive their
|
||||||
|
* direction from the cursor's position every cycle, so chained
|
||||||
|
* repetition oscillates in a band near an edge instead of
|
||||||
|
* crossing the screen. An infinite generator picks its
|
||||||
|
* direction once and ramps forever; the executor 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 {
|
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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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;
|
||||||
@@ -82,14 +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; 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_STEPS = 250;
|
||||||
|
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;
|
||||||
@@ -97,6 +108,14 @@ export const line: MovementStrategy = {
|
|||||||
yield { x: start.x + i * dx, y: start.y };
|
yield { x: start.x + i * dx, y: start.y };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
*loopPath(ctx: MoveContext): Generator<Point> {
|
||||||
|
const { start } = ctx;
|
||||||
|
let x: number = start.x;
|
||||||
|
for (;;) {
|
||||||
|
x += LINE_LOOP_STEP;
|
||||||
|
yield { x, y: start.y };
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -104,12 +123,17 @@ export const line: MovementStrategy = {
|
|||||||
* chosen independently by available room, so the sweep heads toward the
|
* chosen independently by available room, so the sweep heads toward the
|
||||||
* 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, 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_STEPS = 250;
|
||||||
|
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;
|
||||||
@@ -118,6 +142,16 @@ export const diagonal: MovementStrategy = {
|
|||||||
yield { x: start.x + i * dx, y: start.y + i * dy };
|
yield { x: start.x + i * dx, y: start.y + i * dy };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
*loopPath(ctx: MoveContext): Generator<Point> {
|
||||||
|
const { start } = ctx;
|
||||||
|
let x: number = start.x;
|
||||||
|
let y: number = start.y;
|
||||||
|
for (;;) {
|
||||||
|
x += DIAGONAL_LOOP_STEP;
|
||||||
|
y += DIAGONAL_LOOP_STEP;
|
||||||
|
yield { x, y };
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -132,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++) {
|
||||||
@@ -148,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;
|
||||||
@@ -180,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;
|
||||||
|
|
||||||
@@ -222,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++) {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const NONE: ConfigOverrides = {
|
|||||||
stepDelay: undefined,
|
stepDelay: undefined,
|
||||||
pattern: undefined,
|
pattern: undefined,
|
||||||
verbose: undefined,
|
verbose: undefined,
|
||||||
|
loop: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("resolveConfig", () => {
|
describe("resolveConfig", () => {
|
||||||
@@ -78,6 +79,21 @@ describe("resolveConfig", () => {
|
|||||||
const cfg = resolveConfig(null, NONE);
|
const cfg = resolveConfig(null, NONE);
|
||||||
expect(cfg.verbose).toBe(DEFAULT_CONFIG.verbose);
|
expect(cfg.verbose).toBe(DEFAULT_CONFIG.verbose);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("loop: CLI true wins over file false", () => {
|
||||||
|
const cfg = resolveConfig({ ...NONE, loop: false }, { ...NONE, loop: true });
|
||||||
|
expect(cfg.loop).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loop: file true wins over default (no CLI)", () => {
|
||||||
|
const cfg = resolveConfig({ ...NONE, loop: true }, NONE);
|
||||||
|
expect(cfg.loop).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loop: falls back to DEFAULT_CONFIG.loop when neither set", () => {
|
||||||
|
const cfg = resolveConfig(null, NONE);
|
||||||
|
expect(cfg.loop).toBe(DEFAULT_CONFIG.loop);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("defaultConfigPath", () => {
|
describe("defaultConfigPath", () => {
|
||||||
|
|||||||
@@ -98,6 +98,17 @@ describe("loadConfigFile (explicit path)", () => {
|
|||||||
expect(() => loadConfigFile(path)).toThrow(/'verbose'.*boolean/);
|
expect(() => loadConfigFile(path)).toThrow(/'verbose'.*boolean/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("accepts a boolean loop", () => {
|
||||||
|
const path = writeFixture("loop.json", JSON.stringify({ loop: true }));
|
||||||
|
const result = loadConfigFile(path);
|
||||||
|
expect(result!.loop).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws when loop is the wrong type", () => {
|
||||||
|
const path = writeFixture("loop-bad.json", JSON.stringify({ loop: "yes" }));
|
||||||
|
expect(() => loadConfigFile(path)).toThrow(/'loop'.*boolean/);
|
||||||
|
});
|
||||||
|
|
||||||
test("accepts a known pattern", () => {
|
test("accepts a known pattern", () => {
|
||||||
const path = writeFixture("pattern.json", JSON.stringify({ pattern: "arc" }));
|
const path = writeFixture("pattern.json", JSON.stringify({ pattern: "arc" }));
|
||||||
const result = loadConfigFile(path);
|
const result = loadConfigFile(path);
|
||||||
|
|||||||
+109
-36
@@ -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,116 @@ 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", () => {
|
||||||
|
test("restore:false leaves the cursor at the last step, no snap-back", async () => {
|
||||||
|
const dev = new FakeDevice();
|
||||||
|
const start = { x: 500, y: 500 };
|
||||||
|
const pts = [
|
||||||
|
{ x: 501, y: 500 },
|
||||||
|
{ x: 502, y: 500 },
|
||||||
|
];
|
||||||
|
const outcome = await executePath(
|
||||||
|
fixed(pts),
|
||||||
|
ctxOf(start, dev.w, dev.h),
|
||||||
|
dev,
|
||||||
|
noopLog,
|
||||||
|
cfgOf(),
|
||||||
|
{ restore: false },
|
||||||
|
);
|
||||||
|
expect(outcome).toBe("completed");
|
||||||
|
// No trailing restore-to-start command.
|
||||||
|
expect(dev.commanded).toEqual(pts);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the default (no options) still restores to start", async () => {
|
||||||
|
const dev = new FakeDevice();
|
||||||
|
const start = { x: 500, y: 500 };
|
||||||
|
const pts = [{ x: 501, y: 500 }];
|
||||||
|
await executePath(fixed(pts), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf());
|
||||||
|
expect(dev.commanded).toEqual([...pts, start]);
|
||||||
|
});
|
||||||
|
|
||||||
|
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",
|
||||||
|
*path(): Generator<Point> {
|
||||||
|
yield { x: 1, y: 1 };
|
||||||
|
},
|
||||||
|
*loopPath(): Generator<Point> {
|
||||||
|
yield { x: 10, y: 10 };
|
||||||
|
yield { x: 20, y: 20 };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await executePath(strat, ctxOf({ x: 0, y: 0 }, dev.w, dev.h), dev, noopLog, cfgOf(), {
|
||||||
|
loop: true,
|
||||||
|
restore: false,
|
||||||
|
});
|
||||||
|
expect(dev.commanded).toEqual([{ x: 10, y: 10 }, { x: 20, y: 20 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loop:true falls back to path when the strategy has no loopPath", async () => {
|
||||||
|
const dev = new FakeDevice();
|
||||||
|
const strat = fixed([{ x: 3, y: 3 }]);
|
||||||
|
await executePath(strat, ctxOf({ x: 0, y: 0 }, dev.w, dev.h), dev, noopLog, cfgOf(), {
|
||||||
|
loop: true,
|
||||||
|
restore: false,
|
||||||
|
});
|
||||||
|
expect(dev.commanded).toEqual([{ x: 3, y: 3 }]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("executePath — readback tolerance", () => {
|
describe("executePath — readback tolerance", () => {
|
||||||
@@ -145,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]);
|
||||||
});
|
});
|
||||||
@@ -159,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]!]);
|
||||||
});
|
});
|
||||||
@@ -170,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 });
|
||||||
});
|
});
|
||||||
@@ -181,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]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -89,3 +89,37 @@ describe("runKeeper", () => {
|
|||||||
expect(dev.commanded.length).toBe(0);
|
expect(dev.commanded.length).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("runKeeper — loop mode", () => {
|
||||||
|
const maxX = (pts: Point[]): number => pts.reduce((m, p) => Math.max(m, p.x), -Infinity);
|
||||||
|
|
||||||
|
test("loop mode ramps far from the start via the infinite loopPath", async () => {
|
||||||
|
// `line`'s loopPath ramps x by 4px/step from the start and never
|
||||||
|
// restores, reflecting off the screen edge. From x=100 it climbs well
|
||||||
|
// past a single finite sweep's reach before the budget stops it.
|
||||||
|
const dev = new LoopDevice(400, { x: 100, y: 100 });
|
||||||
|
await runUntilStop(quietConfig({ moveInterval: 0, pattern: "line", loop: true }), dev);
|
||||||
|
expect(maxX(dev.commanded)).toBeGreaterThan(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("single-sweep mode restores each sweep, so x never ramps away", async () => {
|
||||||
|
// Same setup without loop: `line` runs 250 one-pixel steps then snaps
|
||||||
|
// back to the start, so x is bounded by start + 250 no matter how many
|
||||||
|
// sweeps fire within the budget.
|
||||||
|
const dev = new LoopDevice(400, { x: 100, y: 100 });
|
||||||
|
await runUntilStop(quietConfig({ moveInterval: 0, pattern: "line", loop: false }), dev);
|
||||||
|
expect(maxX(dev.commanded)).toBeLessThanOrEqual(350);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loop mode chains a finite pattern across multiple cycles per trigger", async () => {
|
||||||
|
// `figureEight` has no loopPath, so loop mode chains its 90-step path.
|
||||||
|
// A single trigger keeps chaining cycles until the budget stops it,
|
||||||
|
// yielding far more than the 90 commands one cycle would.
|
||||||
|
const dev = new LoopDevice(400, { x: 800, y: 500 });
|
||||||
|
await runUntilStop(
|
||||||
|
quietConfig({ moveInterval: 0, pattern: "figureEight", loop: true }),
|
||||||
|
dev,
|
||||||
|
);
|
||||||
|
expect(dev.commanded.length).toBeGreaterThan(180);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -36,6 +36,16 @@ function mulberry32(seed: number): () => number {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Pull the first `n` points from a (possibly infinite) point iterable. */
|
||||||
|
function take(iter: Iterable<Point>, n: number): Point[] {
|
||||||
|
const out: Point[] = [];
|
||||||
|
for (const p of iter) {
|
||||||
|
out.push(p);
|
||||||
|
if (out.length >= n) break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function ctxOf(overrides: {
|
function ctxOf(overrides: {
|
||||||
start?: Point;
|
start?: Point;
|
||||||
width?: number;
|
width?: number;
|
||||||
@@ -67,6 +77,14 @@ describe("line", () => {
|
|||||||
expect(pts[1]!.x).toBe(88);
|
expect(pts[1]!.x).toBe(88);
|
||||||
expect(pts.at(-1)!.x).toBe(90 - 250);
|
expect(pts.at(-1)!.x).toBe(90 - 250);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("loopPath ramps x forever at a fixed step, y held constant", () => {
|
||||||
|
const start = { x: 500, y: 300 };
|
||||||
|
const pts = take(line.loopPath!(ctxOf({ start })), 5);
|
||||||
|
// Monotonic +4 per step (LINE_LOOP_STEP), no vertical drift.
|
||||||
|
expect(pts.map((p) => p.x)).toEqual([504, 508, 512, 516, 520]);
|
||||||
|
expect(pts.every((p) => p.y === 300)).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("diagonal", () => {
|
describe("diagonal", () => {
|
||||||
@@ -76,6 +94,16 @@ describe("diagonal", () => {
|
|||||||
expect(pts[0]!).toEqual({ x: 501, y: 501 });
|
expect(pts[0]!).toEqual({ x: 501, y: 501 });
|
||||||
expect(pts.at(-1)!).toEqual({ x: 750, y: 750 });
|
expect(pts.at(-1)!).toEqual({ x: 750, y: 750 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("loopPath ramps both axes forever at a fixed step", () => {
|
||||||
|
const pts = take(diagonal.loopPath!(ctxOf({ start: { x: 100, y: 200 } })), 3);
|
||||||
|
// Both axes advance by DIAGONAL_LOOP_STEP (4) each step.
|
||||||
|
expect(pts).toEqual([
|
||||||
|
{ x: 104, y: 204 },
|
||||||
|
{ x: 108, y: 208 },
|
||||||
|
{ x: 112, y: 212 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("jitter", () => {
|
describe("jitter", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user