Files
Move/tests/executor.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

183 lines
7.1 KiB
TypeScript

/**
* 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]);
});
});