Files
Move/tests/configFile.test.ts
T
nokeo08 ec33648e74 Remove stepCount/stepSize; patterns own their geometry
The stepCount and stepSize knobs were two controls for one quantity users
actually care about (reach), and the number of steps is an implementation
detail nobody meaningfully tunes. Each pattern has a natural size and
resolution — a jitter is inherently small, an arc a broad curve — so those
now live as constants in each strategy rather than as global config.

- strategies.ts: each pattern defines its own step count and size; MoveContext
  drops `config` down to pure geometry (start/width/height/rng), and the
  module no longer imports Config at all (dissolving the type-only-import
  cycle workaround). line stays byte-for-byte: 250 one-pixel steps.
- executor.ts: executePath takes `config` for pacing (stepDelay); the path
  itself needs nothing from it.
- config.ts / cli.ts / move.ts / config.default.json: drop stepCount and
  stepSize from the type, seed, validation, resolver, CLI flags (-n, -s),
  and help. stepDelay stays as the one pacing lever.
- configFile.ts: tolerate the removed keys instead of rejecting them — every
  pre-1.3.0 install seeded stepCount, so a hard "unknown key" failure on
  upgrade is avoided. They're ignored with a one-line stderr notice; genuine
  unknown keys still error.

The -n/--step-count CLI flag (shipped since 1.0.0) is now an unknown option;
config files degrade gracefully, command lines don't. Stays in the unpushed
1.3.0 release. 64 tests pass.
2026-08-14 12:56:22 -05:00

158 lines
6.2 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 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/);
});
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();
});
});