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:
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* cli.test.ts
|
||||
* -----------
|
||||
* Unit tests for CLI argument parsing. `parseCliArgs` takes its argv as a
|
||||
* parameter (defaulting to the real command line), so the whole flag surface
|
||||
* is exercised here without touching `process.argv`.
|
||||
*
|
||||
* The focus is the parts that make a decision: numeric validation, pattern
|
||||
* validation/normalization, and the `-r`/`--pattern` conflict rule.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { parseCliArgs, selectPattern } from "../src/cli.ts";
|
||||
import { CliError } from "../src/errors.ts";
|
||||
|
||||
describe("parseCliArgs — general flags", () => {
|
||||
test("returns all-undefined overrides for an empty argv", () => {
|
||||
const args = parseCliArgs([]);
|
||||
expect(args.moveInterval).toBeUndefined();
|
||||
expect(args.checkInterval).toBeUndefined();
|
||||
expect(args.stepDelay).toBeUndefined();
|
||||
expect(args.pattern).toBeUndefined();
|
||||
expect(args.verbose).toBeUndefined();
|
||||
expect(args.loop).toBeUndefined();
|
||||
expect(args.help).toBe(false);
|
||||
});
|
||||
|
||||
test("parses numeric flags in both long and short form", () => {
|
||||
const args = parseCliArgs(["-m", "300", "--check-interval", "5", "-d", "20"]);
|
||||
expect(args.moveInterval).toBe(300);
|
||||
expect(args.checkInterval).toBe(5);
|
||||
expect(args.stepDelay).toBe(20);
|
||||
});
|
||||
|
||||
test("boolean flags are true when present, undefined when absent", () => {
|
||||
const args = parseCliArgs(["-V", "--loop"]);
|
||||
expect(args.verbose).toBe(true);
|
||||
expect(args.loop).toBe(true);
|
||||
// `undefined` rather than `false` is what lets the resolver tell
|
||||
// "not specified" from an explicit off-switch.
|
||||
expect(parseCliArgs([]).verbose).toBeUndefined();
|
||||
});
|
||||
|
||||
test("rejects non-positive and non-numeric values", () => {
|
||||
expect(() => parseCliArgs(["-m", "0"])).toThrow(CliError);
|
||||
// A bare `-m -5` is rejected earlier, by node:util, as an ambiguous
|
||||
// dash argument; `=` is the form that actually reaches our validator.
|
||||
expect(() => parseCliArgs(["--move-interval=-5"])).toThrow(/positive number/);
|
||||
expect(() => parseCliArgs(["-c", "abc"])).toThrow(/positive number/);
|
||||
});
|
||||
|
||||
test("surfaces node:util's own parse errors as CliError", () => {
|
||||
// e.g. an ambiguous dash argument — the entry point turns any CliError
|
||||
// into exit 2, so the message just needs to reach the user intact.
|
||||
expect(() => parseCliArgs(["-m", "-5"])).toThrow(CliError);
|
||||
});
|
||||
|
||||
test("rejects unknown flags", () => {
|
||||
expect(() => parseCliArgs(["--nope"])).toThrow(CliError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseCliArgs — pattern selection", () => {
|
||||
test("accepts a registered pattern and normalizes loose spellings", () => {
|
||||
expect(parseCliArgs(["-p", "arc"]).pattern).toBe("arc");
|
||||
expect(parseCliArgs(["--pattern", "figure-eight"]).pattern).toBe("figureEight");
|
||||
expect(parseCliArgs(["-p", "LINE"]).pattern).toBe("line");
|
||||
});
|
||||
|
||||
test("rejects an unknown pattern, listing random among the valid names", () => {
|
||||
expect(() => parseCliArgs(["-p", "zigzag"])).toThrow(CliError);
|
||||
expect(() => parseCliArgs(["-p", "zigzag"])).toThrow(/valid:.*random/);
|
||||
});
|
||||
|
||||
test("--pattern random is accepted like any other selection", () => {
|
||||
expect(parseCliArgs(["--pattern", "random"]).pattern).toBe("random");
|
||||
expect(parseCliArgs(["-p", "RANDOM"]).pattern).toBe("random");
|
||||
});
|
||||
|
||||
test("-r/--random folds into pattern", () => {
|
||||
// The flag has no field of its own: its entire effect is the pattern,
|
||||
// so nothing downstream needs to know it exists.
|
||||
expect(parseCliArgs(["-r"]).pattern).toBe("random");
|
||||
expect(parseCliArgs(["--random"]).pattern).toBe("random");
|
||||
});
|
||||
|
||||
test("-r combined with an explicit --pattern is rejected", () => {
|
||||
expect(() => parseCliArgs(["-r", "-p", "arc"])).toThrow(CliError);
|
||||
expect(() => parseCliArgs(["-r", "-p", "arc"])).toThrow(/conflicts with --pattern 'arc'/);
|
||||
// Order on the command line doesn't change the verdict.
|
||||
expect(() => parseCliArgs(["--pattern", "walk", "--random"])).toThrow(/conflicts/);
|
||||
});
|
||||
|
||||
test("-r alongside --pattern random is a harmless no-op", () => {
|
||||
// Both spellings request the same thing, so there's nothing to object to.
|
||||
expect(parseCliArgs(["-r", "-p", "random"]).pattern).toBe("random");
|
||||
expect(parseCliArgs(["-r", "-p", "Random"]).pattern).toBe("random");
|
||||
});
|
||||
|
||||
test("-r still validates the pattern it is paired with", () => {
|
||||
// An invalid --pattern is an error in its own right, reported as such
|
||||
// rather than being masked by the conflict rule.
|
||||
expect(() => parseCliArgs(["-r", "-p", "zigzag"])).toThrow(/invalid value for --pattern/);
|
||||
});
|
||||
|
||||
test("-r composes with the other flags", () => {
|
||||
const args = parseCliArgs(["-r", "--loop", "-m", "120", "-V"]);
|
||||
expect(args.pattern).toBe("random");
|
||||
expect(args.loop).toBe(true);
|
||||
expect(args.moveInterval).toBe(120);
|
||||
expect(args.verbose).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectPattern", () => {
|
||||
test("passes the pattern through untouched when --random is absent", () => {
|
||||
expect(selectPattern("arc", false)).toBe("arc");
|
||||
expect(selectPattern(undefined, false)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("yields random when --random is present and no pattern was given", () => {
|
||||
expect(selectPattern(undefined, true)).toBe("random");
|
||||
});
|
||||
|
||||
test("quotes the user's own spelling in the conflict message", () => {
|
||||
// Not the canonical name: the user needs to find the offending text on
|
||||
// their command line.
|
||||
expect(() => selectPattern("figure-eight", true)).toThrow(/--pattern 'figure-eight'/);
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
+88
-4
@@ -16,6 +16,7 @@ import { DEFAULT_CONFIG } from "../src/config.ts";
|
||||
import type { Config } from "../src/config.ts";
|
||||
import type { Device, Point } from "../src/device.ts";
|
||||
import { runKeeper } from "../src/keeper.ts";
|
||||
import { diagonal, figureEight, type MovementStrategy } from "../src/strategies.ts";
|
||||
|
||||
class StopError extends Error {}
|
||||
|
||||
@@ -60,9 +61,13 @@ const quietConfig = (overrides: Partial<Config>): Config => ({
|
||||
...overrides,
|
||||
});
|
||||
|
||||
async function runUntilStop(config: Config, device: Device): Promise<void> {
|
||||
async function runUntilStop(
|
||||
config: Config,
|
||||
device: Device,
|
||||
pickRandom?: () => MovementStrategy,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await runKeeper(config, device);
|
||||
await runKeeper(config, device, pickRandom);
|
||||
} catch (err) {
|
||||
if (!(err instanceof StopError)) throw err;
|
||||
}
|
||||
@@ -90,9 +95,10 @@ describe("runKeeper", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("runKeeper — loop mode", () => {
|
||||
const maxX = (pts: Point[]): number => pts.reduce((m, p) => Math.max(m, p.x), -Infinity);
|
||||
/** Furthest x any commanded point reached — the signal that a path ramped. */
|
||||
const maxX = (pts: Point[]): number => pts.reduce((m, p) => Math.max(m, p.x), -Infinity);
|
||||
|
||||
describe("runKeeper — loop mode", () => {
|
||||
test("loop mode ramps far from the start via the infinite loopPath", async () => {
|
||||
// `line`'s loopPath ramps x by 4px/step from the start and never
|
||||
// restores, reflecting off the screen edge. From x=100 it climbs well
|
||||
@@ -123,3 +129,81 @@ describe("runKeeper — loop mode", () => {
|
||||
expect(dev.commanded.length).toBeGreaterThan(180);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runKeeper — random pattern", () => {
|
||||
/**
|
||||
* A picker that always hands back `strategy` and counts how many times the
|
||||
* keeper asked. The count is the observable that pins down *when* the pick
|
||||
* happens, which is the whole contract for `random`.
|
||||
*/
|
||||
function recordingPicker(strategy: MovementStrategy): {
|
||||
pick: () => MovementStrategy;
|
||||
calls: () => number;
|
||||
} {
|
||||
let calls = 0;
|
||||
return {
|
||||
pick: (): MovementStrategy => {
|
||||
calls++;
|
||||
return strategy;
|
||||
},
|
||||
calls: (): number => calls,
|
||||
};
|
||||
}
|
||||
|
||||
test("asks the picker again on every trigger", async () => {
|
||||
// moveInterval 0 means each pass of the watch loop fires a sweep, so
|
||||
// the budget covers several triggers. A pattern chosen once for the
|
||||
// whole process would show exactly one call.
|
||||
const picker = recordingPicker(figureEight);
|
||||
const dev = new LoopDevice(400, { x: 800, y: 500 });
|
||||
await runUntilStop(
|
||||
quietConfig({ moveInterval: 0, pattern: "random", loop: false }),
|
||||
dev,
|
||||
picker.pick,
|
||||
);
|
||||
expect(picker.calls()).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("never consults the picker for a concrete pattern", async () => {
|
||||
const picker = recordingPicker(figureEight);
|
||||
const dev = new LoopDevice(400, { x: 800, y: 500 });
|
||||
await runUntilStop(
|
||||
quietConfig({ moveInterval: 0, pattern: "line", loop: false }),
|
||||
dev,
|
||||
picker.pick,
|
||||
);
|
||||
expect(picker.calls()).toBe(0);
|
||||
expect(dev.commanded.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("loop mode holds a single pick for the whole loop run", async () => {
|
||||
// One trigger, many chained cycles: the pattern must not change under
|
||||
// the user mid-run, so the picker is asked exactly once.
|
||||
const picker = recordingPicker(figureEight);
|
||||
const dev = new LoopDevice(400, { x: 800, y: 500 });
|
||||
await runUntilStop(
|
||||
quietConfig({ moveInterval: 0, pattern: "random", loop: true }),
|
||||
dev,
|
||||
picker.pick,
|
||||
);
|
||||
expect(picker.calls()).toBe(1);
|
||||
// ...and those cycles really did run, so the single call isn't just
|
||||
// the loop never getting started.
|
||||
expect(dev.commanded.length).toBeGreaterThan(180);
|
||||
});
|
||||
|
||||
test("a picked strategy keeps its own loopPath behavior", async () => {
|
||||
// The picker returns real registry entries, so a pick with an infinite
|
||||
// loopPath (`diagonal`) drives that path rather than a chained finite
|
||||
// one — the same as selecting it explicitly. Mirrors the `line` loop
|
||||
// test above: x ramps far past a single finite sweep's 250px reach.
|
||||
const picker = recordingPicker(diagonal);
|
||||
const dev = new LoopDevice(400, { x: 100, y: 100 });
|
||||
await runUntilStop(
|
||||
quietConfig({ moveInterval: 0, pattern: "random", loop: true }),
|
||||
dev,
|
||||
picker.pick,
|
||||
);
|
||||
expect(maxX(dev.commanded)).toBeGreaterThan(1000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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