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.
293 lines
11 KiB
TypeScript
293 lines
11 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,
|
|
createRandomPicker,
|
|
diagonal,
|
|
figureEight,
|
|
isPatternName,
|
|
isSelectablePattern,
|
|
jitter,
|
|
line,
|
|
PATTERN_NAMES,
|
|
RANDOM_PATTERN,
|
|
resolvePatternName,
|
|
SELECTABLE_PATTERN_NAMES,
|
|
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();
|
|
});
|
|
});
|
|
|
|
describe("random (the sentinel)", () => {
|
|
test("is selectable but is not a registry entry", () => {
|
|
// The whole design rests on this: `random` is a user-facing choice
|
|
// with no path of its own, so the registry must not contain it and
|
|
// `STRATEGIES[RANDOM_PATTERN]` must not resolve.
|
|
expect(PATTERN_NAMES).not.toContain(RANDOM_PATTERN);
|
|
expect(STRATEGIES[RANDOM_PATTERN]).toBeUndefined();
|
|
expect(isPatternName(RANDOM_PATTERN)).toBe(false);
|
|
expect(isSelectablePattern(RANDOM_PATTERN)).toBe(true);
|
|
});
|
|
|
|
test("SELECTABLE_PATTERN_NAMES is the registry plus the sentinel", () => {
|
|
expect(new Set(SELECTABLE_PATTERN_NAMES)).toEqual(
|
|
new Set([...PATTERN_NAMES, RANDOM_PATTERN]),
|
|
);
|
|
expect(SELECTABLE_PATTERN_NAMES.length).toBe(PATTERN_NAMES.length + 1);
|
|
});
|
|
|
|
test("isSelectablePattern still accepts every real strategy and rejects junk", () => {
|
|
for (const name of PATTERN_NAMES) expect(isSelectablePattern(name)).toBe(true);
|
|
expect(isSelectablePattern("zigzag")).toBe(false);
|
|
expect(isSelectablePattern("toString")).toBe(false);
|
|
});
|
|
|
|
test("resolvePatternName normalizes the sentinel like any other name", () => {
|
|
expect(resolvePatternName("random")).toBe(RANDOM_PATTERN);
|
|
expect(resolvePatternName("RANDOM")).toBe(RANDOM_PATTERN);
|
|
expect(resolvePatternName(" Random ")).toBe(RANDOM_PATTERN);
|
|
});
|
|
});
|
|
|
|
describe("createRandomPicker", () => {
|
|
test("only ever returns registered strategies", () => {
|
|
const pick = createRandomPicker(mulberry32(7));
|
|
for (let i = 0; i < 100; i++) {
|
|
const s = pick();
|
|
expect(PATTERN_NAMES).toContain(s.name);
|
|
expect(STRATEGIES[s.name]).toBe(s);
|
|
}
|
|
});
|
|
|
|
test("never returns the same pattern twice in a row", () => {
|
|
const pick = createRandomPicker(mulberry32(1234));
|
|
let prev: string = pick().name;
|
|
for (let i = 0; i < 500; i++) {
|
|
const name: string = pick().name;
|
|
expect(name).not.toBe(prev);
|
|
prev = name;
|
|
}
|
|
});
|
|
|
|
test("alternates deterministically under a constant rng of 0", () => {
|
|
// rng()=0 always takes the first entry of the *remaining* pool, and
|
|
// the pool is the registry minus the previous pick — so this pins the
|
|
// exclusion logic exactly: first name, second name, first name, ...
|
|
const pick = createRandomPicker(() => 0);
|
|
const [first, second] = PATTERN_NAMES as [string, string];
|
|
expect(pick().name).toBe(first);
|
|
expect(pick().name).toBe(second);
|
|
expect(pick().name).toBe(first);
|
|
expect(pick().name).toBe(second);
|
|
});
|
|
|
|
test("stays in range for an rng that returns exactly 1", () => {
|
|
// Outside the documented [0, 1) contract; must clamp rather than
|
|
// index off the end and throw.
|
|
const pick = createRandomPicker(() => 1);
|
|
for (let i = 0; i < 10; i++) {
|
|
expect(PATTERN_NAMES).toContain(pick().name);
|
|
}
|
|
});
|
|
|
|
test("is reproducible for a given seed, and independent across pickers", () => {
|
|
const a = createRandomPicker(mulberry32(99));
|
|
const b = createRandomPicker(mulberry32(99));
|
|
const seqA = Array.from({ length: 20 }, () => a().name);
|
|
const seqB = Array.from({ length: 20 }, () => b().name);
|
|
expect(seqA).toEqual(seqB);
|
|
});
|
|
|
|
test("each picker carries its own no-repeat memory", () => {
|
|
// The memory is per-closure, not module state: a fresh picker has no
|
|
// notion of what a previous one returned, so it may open with the
|
|
// same pattern.
|
|
const a = createRandomPicker(() => 0);
|
|
const b = createRandomPicker(() => 0);
|
|
expect(a().name).toBe(b().name);
|
|
});
|
|
|
|
test("covers the whole registry over enough draws", () => {
|
|
// Guards against the exclusion logic accidentally pinning the pool to
|
|
// a subset (e.g. filtering by index rather than by name).
|
|
const pick = createRandomPicker(mulberry32(2024));
|
|
const seen = new Set<string>();
|
|
for (let i = 0; i < 400; i++) seen.add(pick().name);
|
|
expect(seen).toEqual(new Set(PATTERN_NAMES));
|
|
});
|
|
});
|