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.
This commit is contained in:
+11
-2
@@ -16,6 +16,8 @@ const NONE: ConfigOverrides = {
|
||||
checkInterval: undefined,
|
||||
stepDelay: undefined,
|
||||
stepCount: undefined,
|
||||
stepSize: undefined,
|
||||
pattern: undefined,
|
||||
verbose: undefined,
|
||||
};
|
||||
|
||||
@@ -44,11 +46,18 @@ describe("resolveConfig", () => {
|
||||
expect(cfg.checkInterval).toBe(2000);
|
||||
});
|
||||
|
||||
test("stepDelay and stepCount pass through untouched (no unit conversion)", () => {
|
||||
const cli: ConfigOverrides = { ...NONE, stepDelay: 75, stepCount: 100 };
|
||||
test("stepDelay, stepCount, stepSize pass through untouched (no unit conversion)", () => {
|
||||
const cli: ConfigOverrides = { ...NONE, stepDelay: 75, stepCount: 100, stepSize: 4 };
|
||||
const cfg = resolveConfig(null, cli);
|
||||
expect(cfg.stepDelay).toBe(75);
|
||||
expect(cfg.stepCount).toBe(100);
|
||||
expect(cfg.stepSize).toBe(4);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
|
||||
@@ -97,6 +97,30 @@ describe("loadConfigFile (explicit path)", () => {
|
||||
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)", () => {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* editor.test.ts
|
||||
* --------------
|
||||
* Unit tests for the `--edit` helper. The spawn path is not exercised
|
||||
* (would actually launch $EDITOR); instead we test:
|
||||
* - the pure argv-construction helper, and
|
||||
* - the two refusal paths ($EDITOR unset, file missing).
|
||||
*
|
||||
* Run via `bun test`.
|
||||
*/
|
||||
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { editConfig, editorCommand } from "../src/editor.ts";
|
||||
import { CliError } from "../src/errors.ts";
|
||||
|
||||
describe("editorCommand", () => {
|
||||
test("builds 'sh -c <editor> \"$@\"' argv with -- placeholder and path", () => {
|
||||
const argv = editorCommand("vim", "/tmp/x.json");
|
||||
expect(argv).toEqual(["sh", "-c", 'vim "$@"', "--", "/tmp/x.json"]);
|
||||
});
|
||||
|
||||
test("interpolates the editor verbatim so shell word-splits multi-word values", () => {
|
||||
const argv = editorCommand("code --wait", "/path with space.json");
|
||||
expect(argv).toEqual([
|
||||
"sh",
|
||||
"-c",
|
||||
'code --wait "$@"',
|
||||
"--",
|
||||
"/path with space.json",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("editConfig", () => {
|
||||
let TMP: string;
|
||||
let savedEditor: string | undefined;
|
||||
|
||||
beforeAll(() => {
|
||||
TMP = mkdtempSync(join(tmpdir(), "move-edit-test-"));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
savedEditor = process.env.EDITOR;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (savedEditor === undefined) delete process.env.EDITOR;
|
||||
else process.env.EDITOR = savedEditor;
|
||||
});
|
||||
|
||||
test("throws CliError when $EDITOR is unset", () => {
|
||||
delete process.env.EDITOR;
|
||||
expect(() => editConfig(join(TMP, "any.json"))).toThrow(CliError);
|
||||
});
|
||||
|
||||
test("throws CliError when $EDITOR is empty", () => {
|
||||
process.env.EDITOR = "";
|
||||
expect(() => editConfig(join(TMP, "any.json"))).toThrow(CliError);
|
||||
});
|
||||
|
||||
test("throws CliError when the config file does not exist", () => {
|
||||
// Use a benign editor command that we never actually reach (the
|
||||
// existence check fires first).
|
||||
process.env.EDITOR = "true";
|
||||
const missing = join(TMP, "no-such-file.json");
|
||||
expect(() => editConfig(missing)).toThrow(/no config file at/);
|
||||
});
|
||||
|
||||
test("error message names the missing path", () => {
|
||||
process.env.EDITOR = "true";
|
||||
const missing = join(TMP, "missing.json");
|
||||
expect(() => editConfig(missing)).toThrow(new RegExp(missing.replace(/[.]/g, "\\.")));
|
||||
});
|
||||
|
||||
test("error message mentions 'reinstall' as a recovery hint", () => {
|
||||
process.env.EDITOR = "true";
|
||||
expect(() => editConfig(join(TMP, "x.json"))).toThrow(/reinstall/);
|
||||
});
|
||||
|
||||
test("$EDITOR unset error explicitly mentions setting it", () => {
|
||||
delete process.env.EDITOR;
|
||||
expect(() => editConfig(join(TMP, "x.json"))).toThrow(/export EDITOR/);
|
||||
});
|
||||
|
||||
// Success path: $EDITOR set, file exists. The editor IS spawned and we
|
||||
// then call process.exit() — which kills the test process. So we don't
|
||||
// exercise this code path in unit tests; the manual smoke test in
|
||||
// dev-setup verifies end-to-end behavior instead.
|
||||
test("placeholder: success path is verified via manual `EDITOR=true move -e` run", () => {
|
||||
// Intentionally empty assertion. See comment above.
|
||||
expect(true).toBe(true);
|
||||
// Ensure the fixture path is referenced so this test isn't seen
|
||||
// as truly empty if the fixture system ever needs assertion.
|
||||
writeFileSync(join(TMP, "exists.json"), "{}");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* executor.test.ts
|
||||
* ----------------
|
||||
* Unit tests for the execution driver against a fake `Device`. Covers the
|
||||
* three sweep outcomes, all three bounds policies, the rounding/interrupt
|
||||
* contract, and step pacing — none of which was testable before the device
|
||||
* seam existed.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { DEFAULT_CONFIG } from "../src/config.ts";
|
||||
import type { Config } from "../src/config.ts";
|
||||
import type { Device, Point } from "../src/device.ts";
|
||||
import { executePath, type Logger } from "../src/executor.ts";
|
||||
import type { BoundsPolicy, MoveContext, MovementStrategy } from "../src/strategies.ts";
|
||||
|
||||
const noopLog: Logger = { info: (): void => {}, event: (): void => {} };
|
||||
|
||||
/**
|
||||
* A scriptable `Device`. `getPosition` echoes the last commanded point
|
||||
* (simulating "the cursor stayed where we put it") unless `overrides` maps
|
||||
* the current getPosition call index to a substitute — used to inject a
|
||||
* mid-sweep user interruption.
|
||||
*/
|
||||
class FakeDevice implements Device {
|
||||
commanded: Point[] = [];
|
||||
sleeps: number[] = [];
|
||||
getCalls = 0;
|
||||
overrides = new Map<number, Point>();
|
||||
constructor(public w = 1920, public h = 1080, public initial: Point = { x: 0, y: 0 }) {}
|
||||
|
||||
async getPosition(): Promise<Point> {
|
||||
this.getCalls++;
|
||||
const o = this.overrides.get(this.getCalls);
|
||||
if (o) return o;
|
||||
return this.commanded.at(-1) ?? this.initial;
|
||||
}
|
||||
async setPosition(p: Point): Promise<void> {
|
||||
this.commanded.push(p);
|
||||
}
|
||||
async width(): Promise<number> {
|
||||
return this.w;
|
||||
}
|
||||
async height(): Promise<number> {
|
||||
return this.h;
|
||||
}
|
||||
async sleep(ms: number): Promise<void> {
|
||||
this.sleeps.push(ms);
|
||||
}
|
||||
}
|
||||
|
||||
/** A strategy that emits a fixed list of points under a chosen bounds policy. */
|
||||
function fixed(points: Point[], bounds: BoundsPolicy): MovementStrategy {
|
||||
return {
|
||||
name: "fixed",
|
||||
bounds,
|
||||
*path(): Generator<Point> {
|
||||
yield* points;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function ctxOf(start: Point, width: number, height: number, config?: Partial<Config>): MoveContext {
|
||||
return { start, width, height, config: { ...DEFAULT_CONFIG, ...config }, rng: Math.random };
|
||||
}
|
||||
|
||||
describe("executePath — outcomes", () => {
|
||||
test("clean sweep commands every point, restores to start, returns 'completed'", async () => {
|
||||
const dev = new FakeDevice();
|
||||
const start = { x: 500, y: 500 };
|
||||
const pts = [
|
||||
{ x: 501, y: 500 },
|
||||
{ x: 502, y: 500 },
|
||||
{ x: 503, y: 500 },
|
||||
];
|
||||
const outcome = await executePath(fixed(pts, "clamp"), ctxOf(start, dev.w, dev.h), dev, noopLog);
|
||||
expect(outcome).toBe("completed");
|
||||
// 3 steps + 1 restore.
|
||||
expect(dev.commanded).toEqual([...pts, start]);
|
||||
});
|
||||
|
||||
test("interruption mid-sweep returns 'interrupted' and does NOT restore", async () => {
|
||||
const dev = new FakeDevice();
|
||||
const start = { x: 500, y: 500 };
|
||||
const pts = [
|
||||
{ x: 501, y: 500 },
|
||||
{ x: 502, y: 500 },
|
||||
{ x: 503, y: 500 },
|
||||
];
|
||||
// 2nd getPosition call reports the user elsewhere.
|
||||
dev.overrides.set(2, { x: 9, y: 9 });
|
||||
const outcome = await executePath(fixed(pts, "clamp"), ctxOf(start, dev.w, dev.h), dev, noopLog);
|
||||
expect(outcome).toBe("interrupted");
|
||||
// Commanded points 1 and 2 only; never restored to start.
|
||||
expect(dev.commanded).toEqual([pts[0]!, pts[1]!]);
|
||||
expect(dev.commanded.at(-1)).not.toEqual(start);
|
||||
});
|
||||
|
||||
test("abort policy stops before commanding an out-of-bounds point", async () => {
|
||||
const dev = new FakeDevice(100, 100);
|
||||
const pts = [{ x: 150, y: 10 }]; // x >= width
|
||||
const outcome = await executePath(fixed(pts, "abort"), ctxOf({ x: 10, y: 10 }, 100, 100), dev, noopLog);
|
||||
expect(outcome).toBe("aborted");
|
||||
expect(dev.commanded).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("executePath — bounds policies", () => {
|
||||
test("clamp pins out-of-bounds coordinates to the inset edges", async () => {
|
||||
const dev = new FakeDevice(100, 100);
|
||||
const pts = [
|
||||
{ x: -5, y: 50 },
|
||||
{ x: 9999, y: 50 },
|
||||
];
|
||||
// travelRange(100) is inset by EDGE_MARGIN (2) to [2, 97].
|
||||
await executePath(fixed(pts, "clamp"), ctxOf({ x: 50, y: 50 }, 100, 100), dev, noopLog);
|
||||
expect(dev.commanded[0]).toEqual({ x: 2, y: 50 });
|
||||
expect(dev.commanded[1]).toEqual({ x: 97, y: 50 });
|
||||
});
|
||||
|
||||
test("reflect mirrors out-of-bounds coordinates back inside the inset range", async () => {
|
||||
const dev = new FakeDevice(100, 100);
|
||||
// Inset range [2, 97], span = 95; x=120 -> (120-2)=118, 190-118=72, +2 = 74.
|
||||
const pts = [{ x: 120, y: 50 }];
|
||||
await executePath(fixed(pts, "reflect"), ctxOf({ x: 50, y: 50 }, 100, 100), dev, noopLog);
|
||||
expect(dev.commanded[0]).toEqual({ x: 74, y: 50 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("executePath — readback tolerance", () => {
|
||||
test("a readback within tolerance is not treated as interruption", async () => {
|
||||
const dev = new FakeDevice();
|
||||
const start = { x: 500, y: 500 };
|
||||
const pts = [
|
||||
{ x: 510, y: 500 },
|
||||
{ x: 520, y: 500 },
|
||||
];
|
||||
// Each in-sweep readback lands 2px off the commanded point (OS jitter,
|
||||
// not the user). 2px is within READBACK_TOLERANCE, so the sweep runs on.
|
||||
dev.overrides.set(1, { x: 512, y: 501 });
|
||||
dev.overrides.set(2, { x: 518, y: 499 });
|
||||
const outcome = await executePath(fixed(pts, "clamp"), ctxOf(start, dev.w, dev.h), dev, noopLog);
|
||||
expect(outcome).toBe("completed");
|
||||
expect(dev.commanded).toEqual([...pts, start]);
|
||||
});
|
||||
|
||||
test("a readback beyond tolerance is treated as interruption", async () => {
|
||||
const dev = new FakeDevice();
|
||||
const start = { x: 500, y: 500 };
|
||||
const pts = [
|
||||
{ x: 510, y: 500 },
|
||||
{ x: 520, y: 500 },
|
||||
];
|
||||
// First readback is 3px off -> exceeds the 2px tolerance -> real user.
|
||||
dev.overrides.set(1, { x: 513, y: 500 });
|
||||
const outcome = await executePath(fixed(pts, "clamp"), ctxOf(start, dev.w, dev.h), dev, noopLog);
|
||||
expect(outcome).toBe("interrupted");
|
||||
expect(dev.commanded).toEqual([pts[0]!]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("executePath — rounding & pacing", () => {
|
||||
test("fractional targets are rounded and do not read as interruption", async () => {
|
||||
const dev = new FakeDevice();
|
||||
const start = { x: 500, y: 500 };
|
||||
const pts = [{ x: 10.4, y: 20.6 }]; // -> (10, 21)
|
||||
const outcome = await executePath(fixed(pts, "clamp"), ctxOf(start, dev.w, dev.h), dev, noopLog);
|
||||
expect(outcome).toBe("completed");
|
||||
expect(dev.commanded[0]).toEqual({ x: 10, y: 21 });
|
||||
});
|
||||
|
||||
test("sleeps once per step with the configured stepDelay", async () => {
|
||||
const dev = new FakeDevice();
|
||||
const pts = [
|
||||
{ x: 501, y: 500 },
|
||||
{ x: 502, y: 500 },
|
||||
];
|
||||
await executePath(fixed(pts, "clamp"), ctxOf({ x: 500, y: 500 }, dev.w, dev.h, { stepDelay: 7 }), dev, noopLog);
|
||||
expect(dev.sleeps).toEqual([7, 7]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* keeper.test.ts
|
||||
* --------------
|
||||
* Loop-level tests for `runKeeper` driven by a fake `Device`. The loop runs
|
||||
* forever in production, so the fake stops it by throwing a sentinel from
|
||||
* `sleep` once a call budget is exhausted; the test then inspects the
|
||||
* commands that were issued.
|
||||
*
|
||||
* These assert the two behaviors that matter: an idle cursor triggers a
|
||||
* synthetic sweep, and a moving cursor never does.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { DEFAULT_CONFIG } from "../src/config.ts";
|
||||
import type { Config } from "../src/config.ts";
|
||||
import type { Device, Point } from "../src/device.ts";
|
||||
import { runKeeper } from "../src/keeper.ts";
|
||||
|
||||
class StopError extends Error {}
|
||||
|
||||
/**
|
||||
* Fake device that echoes the last commanded point (so a synthetic sweep
|
||||
* completes cleanly) and aborts the loop after `budget` sleeps.
|
||||
*
|
||||
* `positions`, when provided, is consumed one entry per `getPosition` call
|
||||
* to simulate real user movement; otherwise the cursor is reported as
|
||||
* stationary at `initial`/the last commanded point (idle).
|
||||
*/
|
||||
class LoopDevice implements Device {
|
||||
commanded: Point[] = [];
|
||||
sleepCount = 0;
|
||||
constructor(
|
||||
public budget: number,
|
||||
public initial: Point = { x: 100, y: 100 },
|
||||
private positions: Point[] | null = null,
|
||||
) {}
|
||||
|
||||
async getPosition(): Promise<Point> {
|
||||
if (this.positions) return this.positions.shift() ?? this.initial;
|
||||
return this.commanded.at(-1) ?? this.initial;
|
||||
}
|
||||
async setPosition(p: Point): Promise<void> {
|
||||
this.commanded.push(p);
|
||||
}
|
||||
async width(): Promise<number> {
|
||||
return 1920;
|
||||
}
|
||||
async height(): Promise<number> {
|
||||
return 1080;
|
||||
}
|
||||
async sleep(): Promise<void> {
|
||||
if (++this.sleepCount > this.budget) throw new StopError();
|
||||
}
|
||||
}
|
||||
|
||||
const quietConfig = (overrides: Partial<Config>): Config => ({
|
||||
...DEFAULT_CONFIG,
|
||||
verbose: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
async function runUntilStop(config: Config, device: Device): Promise<void> {
|
||||
try {
|
||||
await runKeeper(config, device);
|
||||
} catch (err) {
|
||||
if (!(err instanceof StopError)) throw err;
|
||||
}
|
||||
}
|
||||
|
||||
describe("runKeeper", () => {
|
||||
test("fires a synthetic sweep once the cursor has been idle long enough", async () => {
|
||||
// moveInterval 0 => any elapsed time counts as "idle long enough",
|
||||
// so the first idle check triggers a sweep deterministically.
|
||||
const dev = new LoopDevice(50);
|
||||
await runUntilStop(quietConfig({ moveInterval: 0, stepCount: 3, stepSize: 1, pattern: "line" }), dev);
|
||||
// A sweep issued setPosition commands (3 steps + restore); an idle
|
||||
// loop with no sweep would have issued none.
|
||||
expect(dev.commanded.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
test("does not fire while the cursor keeps moving", async () => {
|
||||
// Every poll reports a new position => always "real activity", so the
|
||||
// idleness clock keeps resetting and no sweep ever fires.
|
||||
const moving: Point[] = Array.from({ length: 40 }, (_, i) => ({ x: i, y: i }));
|
||||
const dev = new LoopDevice(20, { x: 0, y: 0 }, moving);
|
||||
await runUntilStop(quietConfig({ moveInterval: 0, stepCount: 3, pattern: "line" }), dev);
|
||||
expect(dev.commanded.length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* strategies.test.ts
|
||||
* ------------------
|
||||
* Unit tests for the pure movement-pattern generators. No nut.js, no
|
||||
* device: each strategy is exercised by feeding a `MoveContext` (with a
|
||||
* deterministic `rng` where randomness matters) and asserting on the
|
||||
* emitted points.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { DEFAULT_CONFIG } from "../src/config.ts";
|
||||
import type { Config } from "../src/config.ts";
|
||||
import type { Point } from "../src/device.ts";
|
||||
import {
|
||||
arc,
|
||||
diagonal,
|
||||
figureEight,
|
||||
isPatternName,
|
||||
jitter,
|
||||
line,
|
||||
PATTERN_NAMES,
|
||||
resolvePatternName,
|
||||
STRATEGIES,
|
||||
walk,
|
||||
type MoveContext,
|
||||
} from "../src/strategies.ts";
|
||||
|
||||
/** Deterministic PRNG so stochastic strategies are reproducible under test. */
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed;
|
||||
return (): number => {
|
||||
a |= 0;
|
||||
a = (a + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
function ctxOf(overrides: {
|
||||
start?: Point;
|
||||
width?: number;
|
||||
height?: number;
|
||||
config?: Partial<Config>;
|
||||
rng?: () => number;
|
||||
}): MoveContext {
|
||||
return {
|
||||
start: overrides.start ?? { x: 500, y: 500 },
|
||||
width: overrides.width ?? 1920,
|
||||
height: overrides.height ?? 1080,
|
||||
config: { ...DEFAULT_CONFIG, ...overrides.config },
|
||||
rng: overrides.rng ?? Math.random,
|
||||
};
|
||||
}
|
||||
|
||||
describe("line", () => {
|
||||
test("emits stepCount points along +x with no vertical movement", () => {
|
||||
const pts = [...line.path(ctxOf({ config: { stepCount: 5, stepSize: 1 } }))];
|
||||
expect(pts.length).toBe(5);
|
||||
expect(pts.every((p) => p.y === 500)).toBe(true);
|
||||
expect(pts.map((p) => p.x)).toEqual([501, 502, 503, 504, 505]);
|
||||
});
|
||||
|
||||
test("honors stepSize for per-step distance", () => {
|
||||
const pts = [...line.path(ctxOf({ config: { stepCount: 3, stepSize: 10 } }))];
|
||||
expect(pts.map((p) => p.x)).toEqual([510, 520, 530]);
|
||||
});
|
||||
|
||||
test("reverses direction when there is no room to the right", () => {
|
||||
const pts = [...line.path(ctxOf({ start: { x: 90, y: 10 }, width: 100, config: { stepCount: 20, stepSize: 1 } }))];
|
||||
expect(pts[0]!.x).toBe(89);
|
||||
expect(pts.at(-1)!.x).toBe(70);
|
||||
});
|
||||
});
|
||||
|
||||
describe("diagonal", () => {
|
||||
test("moves on both axes toward the roomy corner", () => {
|
||||
const pts = [...diagonal.path(ctxOf({ config: { stepCount: 4, stepSize: 2 } }))];
|
||||
expect(pts.length).toBe(4);
|
||||
expect(pts.map((p) => p.x)).toEqual([502, 504, 506, 508]);
|
||||
expect(pts.map((p) => p.y)).toEqual([502, 504, 506, 508]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("jitter", () => {
|
||||
test("stays within its radius of start and returns stepCount points", () => {
|
||||
const size = 5;
|
||||
const stepCount = 50;
|
||||
// Radius scales off the sweep length (stepCount * stepSize) / 8, floored at 4.
|
||||
const radius = Math.max(4, (stepCount * size) / 8);
|
||||
const start = { x: 500, y: 500 };
|
||||
const pts = [...jitter.path(ctxOf({ start, config: { stepCount, stepSize: size }, rng: mulberry32(1) }))];
|
||||
expect(pts.length).toBe(stepCount);
|
||||
for (const p of pts) {
|
||||
expect(Math.hypot(p.x - start.x, p.y - start.y)).toBeLessThanOrEqual(radius + 1e-9);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("walk", () => {
|
||||
test("is a cumulative walk; a 0.5-constant rng yields zero net drift", () => {
|
||||
const start = { x: 400, y: 300 };
|
||||
const pts = [...walk.path(ctxOf({ start, config: { stepCount: 10, stepSize: 7 }, rng: () => 0.5 }))];
|
||||
expect(pts.length).toBe(10);
|
||||
// (0.5*2 - 1) === 0, so every step delta is zero.
|
||||
expect(pts.every((p) => p.x === start.x && p.y === start.y)).toBe(true);
|
||||
});
|
||||
|
||||
test("accumulates deltas step over step", () => {
|
||||
const pts = [...walk.path(ctxOf({ config: { stepCount: 3, stepSize: 4 }, rng: mulberry32(42) }))];
|
||||
expect(pts.length).toBe(3);
|
||||
expect(pts.every((p) => Number.isFinite(p.x) && Number.isFinite(p.y))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("arc", () => {
|
||||
test("emits stepCount finite points and lands on its endpoint", () => {
|
||||
const pts = [...arc.path(ctxOf({ config: { stepCount: 8, stepSize: 20 }, rng: mulberry32(7) }))];
|
||||
expect(pts.length).toBe(8);
|
||||
expect(pts.every((p) => Number.isFinite(p.x) && Number.isFinite(p.y))).toBe(true);
|
||||
// t = 1 at the final step, so B(1) is the endpoint — a stable point.
|
||||
const a = [...arc.path(ctxOf({ config: { stepCount: 8, stepSize: 20 }, rng: mulberry32(7) }))];
|
||||
expect(pts.at(-1)).toEqual(a.at(-1)!);
|
||||
});
|
||||
});
|
||||
|
||||
describe("figureEight", () => {
|
||||
test("returns to the start point after one full period", () => {
|
||||
const start = { x: 600, y: 400 };
|
||||
const pts = [...figureEight.path(ctxOf({ start, config: { stepCount: 40, stepSize: 10 } }))];
|
||||
expect(pts.length).toBe(40);
|
||||
expect(pts.at(-1)!.x).toBeCloseTo(start.x, 6);
|
||||
expect(pts.at(-1)!.y).toBeCloseTo(start.y, 6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("registry", () => {
|
||||
test("PATTERN_NAMES matches the registry keys and includes the default", () => {
|
||||
expect(new Set(PATTERN_NAMES)).toEqual(new Set(Object.keys(STRATEGIES)));
|
||||
expect(PATTERN_NAMES).toContain("line");
|
||||
});
|
||||
|
||||
test("isPatternName accepts registered names and rejects others", () => {
|
||||
for (const name of PATTERN_NAMES) expect(isPatternName(name)).toBe(true);
|
||||
expect(isPatternName("zigzag")).toBe(false);
|
||||
expect(isPatternName("")).toBe(false);
|
||||
// Must not be fooled by inherited Object.prototype members.
|
||||
expect(isPatternName("toString")).toBe(false);
|
||||
});
|
||||
|
||||
test("resolvePatternName maps every canonical name to itself", () => {
|
||||
for (const name of PATTERN_NAMES) expect(resolvePatternName(name)).toBe(name);
|
||||
});
|
||||
|
||||
test("resolvePatternName normalizes case and separators", () => {
|
||||
expect(resolvePatternName("figure-eight")).toBe("figureEight");
|
||||
expect(resolvePatternName("figure_eight")).toBe("figureEight");
|
||||
expect(resolvePatternName("FIGUREEIGHT")).toBe("figureEight");
|
||||
expect(resolvePatternName(" Figure Eight ")).toBe("figureEight");
|
||||
expect(resolvePatternName("LINE")).toBe("line");
|
||||
});
|
||||
|
||||
test("resolvePatternName returns null for unknown or prototype names", () => {
|
||||
expect(resolvePatternName("zigzag")).toBeNull();
|
||||
expect(resolvePatternName("")).toBeNull();
|
||||
expect(resolvePatternName("toString")).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user