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:
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user