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.
132 lines
5.7 KiB
TypeScript
132 lines
5.7 KiB
TypeScript
/**
|
|
* 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'/);
|
|
});
|
|
});
|