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.
186 lines
7.4 KiB
TypeScript
186 lines
7.4 KiB
TypeScript
/**
|
|
* configFile.test.ts
|
|
* ------------------
|
|
* Unit tests for the JSON config-file loader.
|
|
* Run via `bun test` (or `bun run test`).
|
|
*/
|
|
|
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
import { loadConfigFile } from "../src/configFile.ts";
|
|
import { CliError } from "../src/errors.ts";
|
|
|
|
let TMP: string;
|
|
|
|
beforeAll(() => {
|
|
TMP = mkdtempSync(join(tmpdir(), "move-cfg-test-"));
|
|
});
|
|
|
|
afterAll(() => {
|
|
rmSync(TMP, { recursive: true, force: true });
|
|
});
|
|
|
|
function writeFixture(name: string, body: string): string {
|
|
const p = join(TMP, name);
|
|
writeFileSync(p, body);
|
|
return p;
|
|
}
|
|
|
|
describe("loadConfigFile (explicit path)", () => {
|
|
test("returns parsed overrides for a valid file", () => {
|
|
const path = writeFixture(
|
|
"valid.json",
|
|
JSON.stringify({ moveInterval: 60, verbose: true }),
|
|
);
|
|
const result = loadConfigFile(path);
|
|
expect(result).not.toBeNull();
|
|
// The bang is justified by the not-null assertion above.
|
|
expect(result!.moveInterval).toBe(60);
|
|
expect(result!.verbose).toBe(true);
|
|
// Fields not in the file are undefined.
|
|
expect(result!.checkInterval).toBeUndefined();
|
|
expect(result!.stepDelay).toBeUndefined();
|
|
expect(result!.pattern).toBeUndefined();
|
|
});
|
|
|
|
test("returns all-undefined overrides for an empty object", () => {
|
|
const path = writeFixture("empty.json", "{}");
|
|
const result = loadConfigFile(path);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.moveInterval).toBeUndefined();
|
|
expect(result!.verbose).toBeUndefined();
|
|
});
|
|
|
|
test("throws CliError when explicit path does not exist", () => {
|
|
expect(() => loadConfigFile(join(TMP, "missing.json"))).toThrow(CliError);
|
|
});
|
|
|
|
test("throws on malformed JSON, mentioning the file path", () => {
|
|
const path = writeFixture("bad-json.json", "this is not json");
|
|
expect(() => loadConfigFile(path)).toThrow(/is not valid JSON/);
|
|
expect(() => loadConfigFile(path)).toThrow(new RegExp(path.replace(/[.]/g, "\\.")));
|
|
});
|
|
|
|
test("throws when root is not an object (e.g. array)", () => {
|
|
const path = writeFixture("array.json", "[1, 2, 3]");
|
|
expect(() => loadConfigFile(path)).toThrow(/JSON object at the root/);
|
|
});
|
|
|
|
test("throws when root is not an object (e.g. string)", () => {
|
|
const path = writeFixture("string.json", "\"hello\"");
|
|
expect(() => loadConfigFile(path)).toThrow(/JSON object at the root/);
|
|
});
|
|
|
|
test("throws on an unknown key, naming the typo and the allowed set", () => {
|
|
const path = writeFixture("typo.json", JSON.stringify({ movInterval: 60 }));
|
|
expect(() => loadConfigFile(path)).toThrow(/unknown key 'movInterval'/);
|
|
expect(() => loadConfigFile(path)).toThrow(/moveInterval/);
|
|
});
|
|
|
|
test("throws on non-positive numeric values", () => {
|
|
const negative = writeFixture("neg.json", JSON.stringify({ moveInterval: -1 }));
|
|
expect(() => loadConfigFile(negative)).toThrow(/'moveInterval'.*positive number/);
|
|
|
|
const zero = writeFixture("zero.json", JSON.stringify({ stepDelay: 0 }));
|
|
expect(() => loadConfigFile(zero)).toThrow(/'stepDelay'.*positive number/);
|
|
});
|
|
|
|
test("throws when a numeric field has the wrong type", () => {
|
|
const path = writeFixture("type.json", JSON.stringify({ moveInterval: "60" }));
|
|
expect(() => loadConfigFile(path)).toThrow(/'moveInterval'.*positive number/);
|
|
});
|
|
|
|
test("throws when verbose is the wrong type", () => {
|
|
const path = writeFixture("verbose.json", JSON.stringify({ verbose: "yes" }));
|
|
expect(() => loadConfigFile(path)).toThrow(/'verbose'.*boolean/);
|
|
});
|
|
|
|
test("accepts a boolean loop", () => {
|
|
const path = writeFixture("loop.json", JSON.stringify({ loop: true }));
|
|
const result = loadConfigFile(path);
|
|
expect(result!.loop).toBe(true);
|
|
});
|
|
|
|
test("throws when loop is the wrong type", () => {
|
|
const path = writeFixture("loop-bad.json", JSON.stringify({ loop: "yes" }));
|
|
expect(() => loadConfigFile(path)).toThrow(/'loop'.*boolean/);
|
|
});
|
|
|
|
test("accepts a known pattern", () => {
|
|
const path = writeFixture("pattern.json", JSON.stringify({ pattern: "arc" }));
|
|
const result = loadConfigFile(path);
|
|
expect(result!.pattern).toBe("arc");
|
|
});
|
|
|
|
test("normalizes a loosely-spelled pattern to its canonical name", () => {
|
|
const path = writeFixture("loosepattern.json", JSON.stringify({ pattern: "figure-eight" }));
|
|
const result = loadConfigFile(path);
|
|
expect(result!.pattern).toBe("figureEight");
|
|
});
|
|
|
|
test("throws on an unknown pattern, listing the valid names", () => {
|
|
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", () => {
|
|
// Seeded by pre-1.3.0 installs; must not hard-fail on upgrade. They're
|
|
// accepted but not surfaced as overrides (and even an invalid value,
|
|
// like a negative, is ignored rather than rejected).
|
|
const path = writeFixture(
|
|
"obsolete.json",
|
|
JSON.stringify({ moveInterval: 60, stepCount: -1, stepSize: 3 }),
|
|
);
|
|
const result = loadConfigFile(path);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.moveInterval).toBe(60);
|
|
expect(result as unknown as Record<string, unknown>).not.toHaveProperty("stepCount");
|
|
});
|
|
|
|
test("still rejects a genuinely unknown key", () => {
|
|
const path = writeFixture("unknown.json", JSON.stringify({ movInterval: 60 }));
|
|
expect(() => loadConfigFile(path)).toThrow(/unknown key 'movInterval'/);
|
|
});
|
|
});
|
|
|
|
describe("loadConfigFile (default path)", () => {
|
|
let savedXdg: string | undefined;
|
|
|
|
beforeAll(() => {
|
|
savedXdg = process.env.XDG_CONFIG_HOME;
|
|
// Point the default path under the test tmpdir so a missing file is
|
|
// guaranteed (we never create $TMP/move/config.json).
|
|
process.env.XDG_CONFIG_HOME = TMP;
|
|
});
|
|
|
|
afterAll(() => {
|
|
if (savedXdg === undefined) delete process.env.XDG_CONFIG_HOME;
|
|
else process.env.XDG_CONFIG_HOME = savedXdg;
|
|
});
|
|
|
|
test("returns null when no file exists at the default path", () => {
|
|
expect(loadConfigFile(undefined)).toBeNull();
|
|
});
|
|
});
|