/** * 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!.stepCount).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({ stepCount: -1 })); expect(() => loadConfigFile(negative)).toThrow(/'stepCount'.*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/); }); }); 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(); }); });