Files
Move/tests/strategies.test.ts
nokeo08 7e632b3e9d Add loop mode (--loop): repeat movement until user activity
Introduce a continuous "loop" setting so a triggered sweep keeps the
cursor moving until the user moves the mouse (or Ctrl+C), instead of
firing a single sweep.

- strategies.ts: add optional `loopPath` to MovementStrategy; give `line`
  and `diagonal` infinite loop generators that pick a direction once and
  ramp forever (4px/step). Their finite `path` and declared `bounds` are
  unchanged, so single-sweep behavior is identical.
- executor.ts: add ExecuteOptions { restore?, bounds?, loop? }. Omitting
  options reproduces the original single-sweep contract exactly.
- keeper.ts: in loop mode, run an infinite loopPath once (stopped only by
  interruption) or chain a finite path cycle after cycle; force `reflect`
  bounds for every pattern and suppress the between-cycle restore, so
  line/diagonal bounce edge-to-edge instead of stopping at the first edge.
- config plumbing: new boolean `loop` through config.default.json,
  config.ts, configFile.ts, cli.ts (-l/--loop), and move.ts, mirroring
  the existing `verbose` precedence.
- docs: README loop-mode section + usage/validation updates; CHANGELOG
  Unreleased entry.
- tests: loopPath generators, executor options (bounds override, loop
  selection, restore suppression), config/configFile loop plumbing, and
  keeper-level loop behavior (ramps far vs. bounded single-sweep, chained
  cycles). 79 pass.
2026-08-17 14:36:18 -05:00

190 lines
6.9 KiB
TypeScript

/**
* strategies.test.ts
* ------------------
* Unit tests for the pure movement-pattern generators. No nut.js, no
* device: each strategy is exercised by feeding a `MoveContext` (with a
* deterministic `rng` where randomness matters) and asserting on the
* emitted points.
*/
import { describe, expect, test } from "bun:test";
import type { Point } from "../src/device.ts";
import {
arc,
diagonal,
figureEight,
isPatternName,
jitter,
line,
PATTERN_NAMES,
resolvePatternName,
STRATEGIES,
walk,
type MoveContext,
} from "../src/strategies.ts";
/** Deterministic PRNG so stochastic strategies are reproducible under test. */
function mulberry32(seed: number): () => number {
let a = seed;
return (): number => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** 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: {
start?: Point;
width?: number;
height?: number;
rng?: () => number;
}): MoveContext {
return {
start: overrides.start ?? { x: 500, y: 500 },
width: overrides.width ?? 1920,
height: overrides.height ?? 1080,
rng: overrides.rng ?? Math.random,
};
}
describe("line", () => {
test("emits its full 250-step, 250px sweep along +x with no vertical drift (preserved default)", () => {
const pts = [...line.path(ctxOf({ start: { x: 500, y: 500 } }))];
expect(pts.length).toBe(250);
expect(pts.every((p) => p.y === 500)).toBe(true);
// 1px per step: 501..750.
expect(pts[0]!.x).toBe(501);
expect(pts.at(-1)!.x).toBe(750);
});
test("reverses direction when there is no room to the right", () => {
const pts = [...line.path(ctxOf({ start: { x: 90, y: 10 }, width: 100 }))];
expect(pts[0]!.x).toBe(89);
// Heads left: each step decreases x by 1.
expect(pts[1]!.x).toBe(88);
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", () => {
test("moves 1px on both axes toward the roomy corner for 250 steps", () => {
const pts = [...diagonal.path(ctxOf({ start: { x: 500, y: 500 } }))];
expect(pts.length).toBe(250);
expect(pts[0]!).toEqual({ x: 501, y: 501 });
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", () => {
test("stays within its fixed radius of start across its fixed step count", () => {
const radius = 30; // JITTER_RADIUS
const start = { x: 500, y: 500 };
const pts = [...jitter.path(ctxOf({ start, rng: mulberry32(1) }))];
expect(pts.length).toBe(80); // JITTER_STEPS
for (const p of pts) {
expect(Math.hypot(p.x - start.x, p.y - start.y)).toBeLessThanOrEqual(radius + 1e-9);
}
});
});
describe("walk", () => {
test("is a cumulative walk; a 0.5-constant rng yields zero net drift", () => {
const start = { x: 400, y: 300 };
const pts = [...walk.path(ctxOf({ start, rng: () => 0.5 }))];
expect(pts.length).toBe(200); // WALK_STEPS
// (0.5*2 - 1) === 0, so every step delta is zero.
expect(pts.every((p) => p.x === start.x && p.y === start.y)).toBe(true);
});
test("accumulates finite deltas step over step", () => {
const pts = [...walk.path(ctxOf({ rng: mulberry32(42) }))];
expect(pts.length).toBe(200);
expect(pts.every((p) => Number.isFinite(p.x) && Number.isFinite(p.y))).toBe(true);
});
});
describe("arc", () => {
test("emits its fixed step count of finite points, deterministic under a fixed seed", () => {
const pts = [...arc.path(ctxOf({ rng: mulberry32(7) }))];
expect(pts.length).toBe(120); // ARC_STEPS
expect(pts.every((p) => Number.isFinite(p.x) && Number.isFinite(p.y))).toBe(true);
// Same seed -> same endpoint (t = 1 at the final step is a stable point).
const again = [...arc.path(ctxOf({ rng: mulberry32(7) }))];
expect(pts.at(-1)).toEqual(again.at(-1)!);
});
});
describe("figureEight", () => {
test("returns to the start point after one full period", () => {
const start = { x: 600, y: 400 };
const pts = [...figureEight.path(ctxOf({ start }))];
expect(pts.length).toBe(90); // FIG8_STEPS
expect(pts.at(-1)!.x).toBeCloseTo(start.x, 6);
expect(pts.at(-1)!.y).toBeCloseTo(start.y, 6);
});
});
describe("registry", () => {
test("PATTERN_NAMES matches the registry keys and includes the default", () => {
expect(new Set(PATTERN_NAMES)).toEqual(new Set(Object.keys(STRATEGIES)));
expect(PATTERN_NAMES).toContain("line");
});
test("isPatternName accepts registered names and rejects others", () => {
for (const name of PATTERN_NAMES) expect(isPatternName(name)).toBe(true);
expect(isPatternName("zigzag")).toBe(false);
expect(isPatternName("")).toBe(false);
// Must not be fooled by inherited Object.prototype members.
expect(isPatternName("toString")).toBe(false);
});
test("resolvePatternName maps every canonical name to itself", () => {
for (const name of PATTERN_NAMES) expect(resolvePatternName(name)).toBe(name);
});
test("resolvePatternName normalizes case and separators", () => {
expect(resolvePatternName("figure-eight")).toBe("figureEight");
expect(resolvePatternName("figure_eight")).toBe("figureEight");
expect(resolvePatternName("FIGUREEIGHT")).toBe("figureEight");
expect(resolvePatternName(" Figure Eight ")).toBe("figureEight");
expect(resolvePatternName("LINE")).toBe("line");
});
test("resolvePatternName returns null for unknown or prototype names", () => {
expect(resolvePatternName("zigzag")).toBeNull();
expect(resolvePatternName("")).toBeNull();
expect(resolvePatternName("toString")).toBeNull();
});
});