/** * 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'/); }); });