Files
Move/tests/keeper.test.ts
nokeo08 d38949edb4 Add random pattern selection (-r / --pattern random)
Pick a different movement pattern every time a sweep is triggered, so the
motion varies across the day instead of repeating one shape.

- strategies.ts: add the `random` sentinel, `SELECTABLE_PATTERN_NAMES`,
  `isSelectablePattern`, and `createRandomPicker`. `random` is deliberately
  NOT a registry entry: it has no path of its own, so `STRATEGIES` stays a
  total lookup and `PATTERN_NAMES` keeps listing only real generators. The
  picker is a closure over `last`, giving a uniform draw that never returns
  the same pattern twice in a row. Building CANONICAL_PATTERNS from the
  selectable list makes both validation boundaries accept `random` (and
  loose spellings) for free, and extends the normalization-collision
  assertion to cover the sentinel.
- cli.ts: add `-r`/`--random` plus an exported `selectPattern` holding the
  conflict rule. `-r` is sugar for `--pattern random`, so the two agreeing
  is a no-op while `-r -p arc` is rejected as contradictory. The flag folds
  into `pattern`, so ConfigOverrides, resolveConfig, and move.ts are
  untouched. `parseCliArgs` now takes its argv as an optional parameter so
  the flag surface is testable without process.argv.
- keeper.ts: resolve `random` via the picker once per trigger, before the
  loop-mode branch, so a pick holds for a whole loop run rather than
  changing mid-run. runKeeper builds one picker for the process, so the
  no-repeat memory spans sweeps minutes apart. Because the pick is a real
  strategy, --verbose logs the concrete pattern name and a pick with an
  infinite loopPath still bounces edge-to-edge under --loop.
- config.ts / configFile.ts: accept the sentinel where a pattern is valid,
  and quote the selectable list in errors. No `random` boolean config key —
  the file spells it "pattern": "random".

executor.ts and move.ts needed no changes.

Tests: new tests/cli.test.ts (the file had no coverage before) covering the
flag surface and the conflict rule; picker tests pinning the no-repeat and
full-registry-coverage properties; keeper tests pinning once-per-trigger and
once-per-loop-run.
2026-08-18 14:36:29 -05:00

210 lines
8.3 KiB
TypeScript

/**
* keeper.test.ts
* --------------
* Loop-level tests for `runKeeper` driven by a fake `Device`. The loop runs
* forever in production, so the fake stops it by throwing a sentinel from
* `sleep` once a call budget is exhausted; the test then inspects the
* commands that were issued.
*
* These assert the two behaviors that matter: an idle cursor triggers a
* synthetic sweep, and a moving cursor never does.
*/
import { describe, expect, test } from "bun:test";
import { DEFAULT_CONFIG } from "../src/config.ts";
import type { Config } from "../src/config.ts";
import type { Device, Point } from "../src/device.ts";
import { runKeeper } from "../src/keeper.ts";
import { diagonal, figureEight, type MovementStrategy } from "../src/strategies.ts";
class StopError extends Error {}
/**
* Fake device that echoes the last commanded point (so a synthetic sweep
* completes cleanly) and aborts the loop after `budget` sleeps.
*
* `positions`, when provided, is consumed one entry per `getPosition` call
* to simulate real user movement; otherwise the cursor is reported as
* stationary at `initial`/the last commanded point (idle).
*/
class LoopDevice implements Device {
commanded: Point[] = [];
sleepCount = 0;
constructor(
public budget: number,
public initial: Point = { x: 100, y: 100 },
private positions: Point[] | null = null,
) {}
async getPosition(): Promise<Point> {
if (this.positions) return this.positions.shift() ?? this.initial;
return this.commanded.at(-1) ?? this.initial;
}
async setPosition(p: Point): Promise<void> {
this.commanded.push(p);
}
async width(): Promise<number> {
return 1920;
}
async height(): Promise<number> {
return 1080;
}
async sleep(): Promise<void> {
if (++this.sleepCount > this.budget) throw new StopError();
}
}
const quietConfig = (overrides: Partial<Config>): Config => ({
...DEFAULT_CONFIG,
verbose: false,
...overrides,
});
async function runUntilStop(
config: Config,
device: Device,
pickRandom?: () => MovementStrategy,
): Promise<void> {
try {
await runKeeper(config, device, pickRandom);
} catch (err) {
if (!(err instanceof StopError)) throw err;
}
}
describe("runKeeper", () => {
test("fires a synthetic sweep once the cursor has been idle long enough", async () => {
// moveInterval 0 => any elapsed time counts as "idle long enough",
// so the first idle check triggers a sweep deterministically.
const dev = new LoopDevice(50);
await runUntilStop(quietConfig({ moveInterval: 0, pattern: "line" }), dev);
// A sweep issued setPosition commands (the sweep is interrupted by the
// sleep budget before it finishes, but many steps land); an idle loop
// with no sweep would have issued none.
expect(dev.commanded.length).toBeGreaterThanOrEqual(3);
});
test("does not fire while the cursor keeps moving", async () => {
// Every poll reports a new position => always "real activity", so the
// idleness clock keeps resetting and no sweep ever fires.
const moving: Point[] = Array.from({ length: 40 }, (_, i) => ({ x: i, y: i }));
const dev = new LoopDevice(20, { x: 0, y: 0 }, moving);
await runUntilStop(quietConfig({ moveInterval: 0, pattern: "line" }), dev);
expect(dev.commanded.length).toBe(0);
});
});
/** Furthest x any commanded point reached — the signal that a path ramped. */
const maxX = (pts: Point[]): number => pts.reduce((m, p) => Math.max(m, p.x), -Infinity);
describe("runKeeper — loop mode", () => {
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);
});
});
describe("runKeeper — random pattern", () => {
/**
* A picker that always hands back `strategy` and counts how many times the
* keeper asked. The count is the observable that pins down *when* the pick
* happens, which is the whole contract for `random`.
*/
function recordingPicker(strategy: MovementStrategy): {
pick: () => MovementStrategy;
calls: () => number;
} {
let calls = 0;
return {
pick: (): MovementStrategy => {
calls++;
return strategy;
},
calls: (): number => calls,
};
}
test("asks the picker again on every trigger", async () => {
// moveInterval 0 means each pass of the watch loop fires a sweep, so
// the budget covers several triggers. A pattern chosen once for the
// whole process would show exactly one call.
const picker = recordingPicker(figureEight);
const dev = new LoopDevice(400, { x: 800, y: 500 });
await runUntilStop(
quietConfig({ moveInterval: 0, pattern: "random", loop: false }),
dev,
picker.pick,
);
expect(picker.calls()).toBeGreaterThanOrEqual(2);
});
test("never consults the picker for a concrete pattern", async () => {
const picker = recordingPicker(figureEight);
const dev = new LoopDevice(400, { x: 800, y: 500 });
await runUntilStop(
quietConfig({ moveInterval: 0, pattern: "line", loop: false }),
dev,
picker.pick,
);
expect(picker.calls()).toBe(0);
expect(dev.commanded.length).toBeGreaterThan(0);
});
test("loop mode holds a single pick for the whole loop run", async () => {
// One trigger, many chained cycles: the pattern must not change under
// the user mid-run, so the picker is asked exactly once.
const picker = recordingPicker(figureEight);
const dev = new LoopDevice(400, { x: 800, y: 500 });
await runUntilStop(
quietConfig({ moveInterval: 0, pattern: "random", loop: true }),
dev,
picker.pick,
);
expect(picker.calls()).toBe(1);
// ...and those cycles really did run, so the single call isn't just
// the loop never getting started.
expect(dev.commanded.length).toBeGreaterThan(180);
});
test("a picked strategy keeps its own loopPath behavior", async () => {
// The picker returns real registry entries, so a pick with an infinite
// loopPath (`diagonal`) drives that path rather than a chained finite
// one — the same as selecting it explicitly. Mirrors the `line` loop
// test above: x ramps far past a single finite sweep's 250px reach.
const picker = recordingPicker(diagonal);
const dev = new LoopDevice(400, { x: 100, y: 100 });
await runUntilStop(
quietConfig({ moveInterval: 0, pattern: "random", loop: true }),
dev,
picker.pick,
);
expect(maxX(dev.commanded)).toBeGreaterThan(1000);
});
});