Files
Move/tests/configFile.test.ts
T
nokeo08 db3310c247 Add pluggable movement strategies (v1.3.0)
Turn the hardcoded straight-line sweep into a strategy system behind three
seams so new patterns are easy to add and, for the first time, testable
without nut.js or a real screen:

- src/device.ts:     injectable Device seam over nut.js (autoDelayMs lives
                     here now); the only module that touches the native lib.
- src/strategies.ts: pure per-pattern path generators + registry + lenient
                     name resolution. Ships line, diagonal, jitter, walk,
                     arc, figureEight.
- src/executor.ts:   single executePath driver owning bounds policy
                     (abort/clamp/reflect), pacing, interrupt detection, and
                     restore-on-clean.

keeper.ts's simulateActivity now selects a strategy and delegates to the
executor; the default `line` pattern is byte-for-byte the previous behavior.

New config surface, layered CLI > file > default with strict validation:
- -p/--pattern <name>   movement strategy (names matched case/-/_-insensitive)
- -s/--step-size <px>   pixels per step; stepCount is now a step *count*

Robustness for the new edge-seeking patterns: interrupt detection compares
against the last commanded (rounded) point with a 2px tolerance, and
clamp/reflect stay a couple pixels off the screen edge, so sub-pixel cursor
placement on scaled/multi-monitor displays isn't misread as user activity.
jitter's radius scales with sweep length so it moves at the default stepSize.

Tests: new suites for strategies, the executor (all bounds policies,
rounding, interrupt, tolerance, pacing), and the keeper loop; config and
configFile suites extended for pattern/stepSize. editor.test.ts moved to
tests/ for consistency. 64 pass.
2026-08-13 15:36:22 -05:00

145 lines
5.6 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!.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/);
});
test("accepts a known pattern and a positive stepSize", () => {
const path = writeFixture("pattern.json", JSON.stringify({ pattern: "arc", stepSize: 3 }));
const result = loadConfigFile(path);
expect(result!.pattern).toBe("arc");
expect(result!.stepSize).toBe(3);
});
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("throws on a non-positive stepSize", () => {
const path = writeFixture("badsize.json", JSON.stringify({ stepSize: 0 }));
expect(() => loadConfigFile(path)).toThrow(/'stepSize'.*positive number/);
});
});
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();
});
});