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:
2026-08-13 15:36:22 -05:00
parent 7777b16540
commit db3310c247
18 changed files with 1319 additions and 153 deletions
+169
View File
@@ -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();
});
});