Files
Move/tests/config.test.ts
T
nokeo08 7e632b3e9d Add loop mode (--loop): repeat movement until user activity
Introduce a continuous "loop" setting so a triggered sweep keeps the
cursor moving until the user moves the mouse (or Ctrl+C), instead of
firing a single sweep.

- strategies.ts: add optional `loopPath` to MovementStrategy; give `line`
  and `diagonal` infinite loop generators that pick a direction once and
  ramp forever (4px/step). Their finite `path` and declared `bounds` are
  unchanged, so single-sweep behavior is identical.
- executor.ts: add ExecuteOptions { restore?, bounds?, loop? }. Omitting
  options reproduces the original single-sweep contract exactly.
- keeper.ts: in loop mode, run an infinite loopPath once (stopped only by
  interruption) or chain a finite path cycle after cycle; force `reflect`
  bounds for every pattern and suppress the between-cycle restore, so
  line/diagonal bounce edge-to-edge instead of stopping at the first edge.
- config plumbing: new boolean `loop` through config.default.json,
  config.ts, configFile.ts, cli.ts (-l/--loop), and move.ts, mirroring
  the existing `verbose` precedence.
- docs: README loop-mode section + usage/validation updates; CHANGELOG
  Unreleased entry.
- tests: loopPath generators, executor options (bounds override, loop
  selection, restore suppression), config/configFile loop plumbing, and
  keeper-level loop behavior (ramps far vs. bounded single-sweep, chained
  cycles). 79 pass.
2026-08-17 14:36:18 -05:00

145 lines
5.2 KiB
TypeScript

/**
* 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 "../src/config.ts";
import type { ConfigOverrides } from "../src/config.ts";
import { CliError } from "../src/errors.ts";
const NONE: ConfigOverrides = {
moveInterval: undefined,
checkInterval: undefined,
stepDelay: undefined,
pattern: undefined,
verbose: undefined,
loop: 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 passes through untouched (no unit conversion)", () => {
const cli: ConfigOverrides = { ...NONE, stepDelay: 75 };
const cfg = resolveConfig(null, cli);
expect(cfg.stepDelay).toBe(75);
});
test("pattern: CLI wins over file, file wins over default", () => {
expect(resolveConfig({ ...NONE, pattern: "arc" }, { ...NONE, pattern: "walk" }).pattern).toBe("walk");
expect(resolveConfig({ ...NONE, pattern: "arc" }, NONE).pattern).toBe("arc");
expect(resolveConfig(null, NONE).pattern).toBe(DEFAULT_CONFIG.pattern);
});
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);
});
test("loop: CLI true wins over file false", () => {
const cfg = resolveConfig({ ...NONE, loop: false }, { ...NONE, loop: true });
expect(cfg.loop).toBe(true);
});
test("loop: file true wins over default (no CLI)", () => {
const cfg = resolveConfig({ ...NONE, loop: true }, NONE);
expect(cfg.loop).toBe(true);
});
test("loop: falls back to DEFAULT_CONFIG.loop when neither set", () => {
const cfg = resolveConfig(null, NONE);
expect(cfg.loop).toBe(DEFAULT_CONFIG.loop);
});
});
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);
});
});