Files
Move/tests/strategies.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

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));
});
});