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
+17
View File
@@ -125,6 +125,23 @@ describe("loadConfigFile (explicit path)", () => {
const path = writeFixture("badpattern.json", JSON.stringify({ pattern: "zigzag" }));
expect(() => loadConfigFile(path)).toThrow(/'pattern'.*valid:/);
expect(() => loadConfigFile(path)).toThrow(/line/);
expect(() => loadConfigFile(path)).toThrow(/random/);
});
test("accepts the random sentinel as a pattern", () => {
// `-r` is only CLI sugar for this, so the file has to express it too.
const path = writeFixture("randompattern.json", JSON.stringify({ pattern: "random" }));
expect(loadConfigFile(path)!.pattern).toBe("random");
});
test("normalizes a loosely-spelled random", () => {
const path = writeFixture("looserandom.json", JSON.stringify({ pattern: "RANDOM" }));
expect(loadConfigFile(path)!.pattern).toBe("random");
});
test("rejects a 'random' boolean key — the file spells it as a pattern", () => {
const path = writeFixture("randomkey.json", JSON.stringify({ random: true }));
expect(() => loadConfigFile(path)).toThrow(/unknown key 'random'/);
});
test("tolerates obsolete stepCount/stepSize keys, ignoring their values", () => {