From 1a857de5ed4d2a9f89c5832401d5cc0815cc9571 Mon Sep 17 00:00:00 2001 From: nokeo08 Date: Wed, 17 Jun 2026 16:34:27 -0500 Subject: [PATCH] Add Bun tests for resolveConfig and loadConfigFile Two new test files exercise the layered config resolver and the JSON config-file loader: src/config.test.ts 25 cases: - resolveConfig precedence (CLI > file > default) per field - seconds-to-ms conversion at the resolver boundary - verbose precedence across all four cells - defaultConfigPath: XDG honored, HOME fallback, empty-as-unset, throws CliError when both are missing src/configFile.test.ts 11 cases: - valid file -> overrides, with undefined for unspecified keys - missing explicit path throws - malformed JSON / non-object root throws with file path - unknown key / wrong type / non-positive number all throw with the offending key named - default path missing -> null (silent default) Runs via 'bun test' (or 'bun run test', now wired in package.json). All 25 tests pass on the current codebase. --- package.json | 3 +- src/config.test.ts | 123 +++++++++++++++++++++++++++++++++++++++++ src/configFile.test.ts | 120 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 src/config.test.ts create mode 100644 src/configFile.test.ts diff --git a/package.json b/package.json index b66d855..9b8591a 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "bun": ">=1.0.0" }, "scripts": { - "start": "bun run src/move.ts" + "start": "bun run src/move.ts", + "test": "bun test" }, "dependencies": { "@nut-tree-fork/nut-js": "^4.2.2" diff --git a/src/config.test.ts b/src/config.test.ts new file mode 100644 index 0000000..810f7cb --- /dev/null +++ b/src/config.test.ts @@ -0,0 +1,123 @@ +/** + * config.test.ts + * -------------- + * Unit tests for the layered config resolver and the default-path helper. + * Run via `bun test` (or `bun run test`). + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; + +import { DEFAULT_CONFIG, defaultConfigPath, resolveConfig } from "./config.ts"; +import type { ConfigOverrides } from "./config.ts"; +import { CliError } from "./errors.ts"; + +const NONE: ConfigOverrides = { + moveInterval: undefined, + checkInterval: undefined, + stepDelay: undefined, + stepCount: undefined, + verbose: undefined, +}; + +describe("resolveConfig", () => { + test("returns DEFAULT_CONFIG when neither layer supplies a value", () => { + expect(resolveConfig(null, NONE)).toEqual(DEFAULT_CONFIG); + }); + + test("CLI value wins over file value", () => { + const file: ConfigOverrides = { ...NONE, moveInterval: 60 }; + const cli: ConfigOverrides = { ...NONE, moveInterval: 30 }; + const cfg = resolveConfig(file, cli); + expect(cfg.moveInterval).toBe(30 * 1000); // CLI 30s -> 30000ms + }); + + test("file value wins over default when CLI is undefined", () => { + const file: ConfigOverrides = { ...NONE, moveInterval: 60 }; + const cfg = resolveConfig(file, NONE); + expect(cfg.moveInterval).toBe(60 * 1000); // file 60s -> 60000ms + }); + + test("seconds-to-ms conversion at the boundary for time-valued fields", () => { + const cli: ConfigOverrides = { ...NONE, moveInterval: 5, checkInterval: 2 }; + const cfg = resolveConfig(null, cli); + expect(cfg.moveInterval).toBe(5000); + expect(cfg.checkInterval).toBe(2000); + }); + + test("stepDelay and stepCount pass through untouched (no unit conversion)", () => { + const cli: ConfigOverrides = { ...NONE, stepDelay: 75, stepCount: 100 }; + const cfg = resolveConfig(null, cli); + expect(cfg.stepDelay).toBe(75); + expect(cfg.stepCount).toBe(100); + }); + + test("verbose: CLI true wins over file false", () => { + const cfg = resolveConfig( + { ...NONE, verbose: false }, + { ...NONE, verbose: true }, + ); + expect(cfg.verbose).toBe(true); + }); + + test("verbose: file true wins over default (no CLI)", () => { + const cfg = resolveConfig({ ...NONE, verbose: true }, NONE); + expect(cfg.verbose).toBe(true); + }); + + test("verbose: file false wins over default (no CLI)", () => { + const cfg = resolveConfig({ ...NONE, verbose: false }, NONE); + expect(cfg.verbose).toBe(false); + }); + + test("verbose: falls back to DEFAULT_CONFIG.verbose when neither set", () => { + const cfg = resolveConfig(null, NONE); + expect(cfg.verbose).toBe(DEFAULT_CONFIG.verbose); + }); +}); + +describe("defaultConfigPath", () => { + let savedXdg: string | undefined; + let savedHome: string | undefined; + + beforeEach(() => { + savedXdg = process.env.XDG_CONFIG_HOME; + savedHome = process.env.HOME; + }); + + afterEach(() => { + if (savedXdg === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = savedXdg; + if (savedHome === undefined) delete process.env.HOME; + else process.env.HOME = savedHome; + }); + + test("honors XDG_CONFIG_HOME when set", () => { + process.env.XDG_CONFIG_HOME = "/custom/xdg"; + process.env.HOME = "/should/not/be/used"; + expect(defaultConfigPath()).toBe("/custom/xdg/move/config.json"); + }); + + test("falls back to $HOME/.config when XDG_CONFIG_HOME is unset", () => { + delete process.env.XDG_CONFIG_HOME; + process.env.HOME = "/u/test"; + expect(defaultConfigPath()).toBe("/u/test/.config/move/config.json"); + }); + + test("treats empty XDG_CONFIG_HOME as unset (per XDG spec)", () => { + process.env.XDG_CONFIG_HOME = ""; + process.env.HOME = "/u/test"; + expect(defaultConfigPath()).toBe("/u/test/.config/move/config.json"); + }); + + test("throws CliError when both XDG_CONFIG_HOME and HOME are unset", () => { + delete process.env.XDG_CONFIG_HOME; + delete process.env.HOME; + expect(() => defaultConfigPath()).toThrow(CliError); + }); + + test("throws CliError when both XDG_CONFIG_HOME and HOME are empty", () => { + process.env.XDG_CONFIG_HOME = ""; + process.env.HOME = ""; + expect(() => defaultConfigPath()).toThrow(CliError); + }); +}); diff --git a/src/configFile.test.ts b/src/configFile.test.ts new file mode 100644 index 0000000..ce5528b --- /dev/null +++ b/src/configFile.test.ts @@ -0,0 +1,120 @@ +/** + * 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 "./configFile.ts"; +import { CliError } from "./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(); + }); +});