Files
Move/tests/executor.test.ts
T
nokeo08 ec33648e74 Remove stepCount/stepSize; patterns own their geometry
The stepCount and stepSize knobs were two controls for one quantity users
actually care about (reach), and the number of steps is an implementation
detail nobody meaningfully tunes. Each pattern has a natural size and
resolution — a jitter is inherently small, an arc a broad curve — so those
now live as constants in each strategy rather than as global config.

- strategies.ts: each pattern defines its own step count and size; MoveContext
  drops `config` down to pure geometry (start/width/height/rng), and the
  module no longer imports Config at all (dissolving the type-only-import
  cycle workaround). line stays byte-for-byte: 250 one-pixel steps.
- executor.ts: executePath takes `config` for pacing (stepDelay); the path
  itself needs nothing from it.
- config.ts / cli.ts / move.ts / config.default.json: drop stepCount and
  stepSize from the type, seed, validation, resolver, CLI flags (-n, -s),
  and help. stepDelay stays as the one pacing lever.
- configFile.ts: tolerate the removed keys instead of rejecting them — every
  pre-1.3.0 install seeded stepCount, so a hard "unknown key" failure on
  upgrade is avoided. They're ignored with a one-line stderr notice; genuine
  unknown keys still error.

The -n/--step-count CLI flag (shipped since 1.0.0) is now an unknown option;
config files degrade gracefully, command lines don't. Stays in the unpushed
1.3.0 release. 64 tests pass.
2026-08-14 12:56:22 -05:00

188 lines
7.3 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): MoveContext {
return { start, width, height, rng: Math.random };
}
/** A full `Config` for the executor's pacing; only `stepDelay` matters here. */
function cfgOf(config?: Partial<Config>): Config {
return { ...DEFAULT_CONFIG, ...config };
}
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, cfgOf());
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, cfgOf());
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, cfgOf());
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, cfgOf());
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, cfgOf());
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, cfgOf());
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, cfgOf());
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, cfgOf());
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), dev, noopLog, cfgOf({ stepDelay: 7 }));
expect(dev.sleeps).toEqual([7, 7]);
});
});