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.
This commit is contained in:
+2
-1
@@ -11,7 +11,8 @@
|
|||||||
"bun": ">=1.0.0"
|
"bun": ">=1.0.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "bun run src/move.ts"
|
"start": "bun run src/move.ts",
|
||||||
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nut-tree-fork/nut-js": "^4.2.2"
|
"@nut-tree-fork/nut-js": "^4.2.2"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user