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.
190 lines
6.9 KiB
TypeScript
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();
|
|
});
|
|
});
|