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.
This commit is contained in:
2026-08-18 14:36:29 -05:00
parent b019f25a42
commit d38949edb4
12 changed files with 594 additions and 47 deletions
+103
View File
@@ -12,13 +12,17 @@ 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,
@@ -187,3 +191,102 @@ describe("registry", () => {
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));
});
});