Collapse bounds policies to reflect-only; drop abort and clamp
The executor kept every commanded point on-screen via a per-strategy
BoundsPolicy of abort / clamp / reflect. Measured against the real
strategies, the other two earned nothing: abort truncated a sweep at the
first edge (line on a narrow screen ran only 90 of 250 steps), and clamp
could park the cursor against an edge (a monotonic ramp stalled 162 steps
in a row) -- both counter to the program's whole purpose of keeping the
cursor moving. reflect bounces off the edge and keeps going, and is
already what line/diagonal need in loop mode. arc's declared clamp was
provably dead code (it clamps its own endpoint, so no sample ever leaves
the screen).
Collapse to reflect-only:
- strategies.ts: remove the BoundsPolicy type and the `bounds` field from
the interface and all six strategies. Keep the local clamp() helper --
it's arc's endpoint geometry, not an on-screen policy; docstring says so.
- executor.ts: resolveTarget loses its policy parameter and its null
return and just reflects both axes; delete clampInt; SweepOutcome drops
"aborted"; ExecuteOptions drops `bounds`; remove the Out of bounds log.
- keeper.ts: loopOpts is now { restore: false, loop: true } -- the
reflect override added with loop mode is redundant.
- tests: drop the abort-outcome, clamp, and bounds-override tests; simplify
fixed() to take no policy; add a regression test that a monotonic ramp
past an edge never yields two identical points in a row (the guarantee
that motivated removing clamp).
Behavior is unchanged for every pattern at normal cursor positions
(verified: line's normal sweep is byte-identical). The only differences
are at a screen edge, where motion now bounces instead of stopping. No
config keys, flags, or pattern names changed.
Docs updated to match, including in-code comments, the README strategies
table (Bounds column removed) and verbose description, the sequence
diagram (resolveTarget signature + getPosition/width ordering + a loop-mode
note), and a CHANGELOG Changed entry.
This commit is contained in:
+52
-58
@@ -2,9 +2,9 @@
|
||||
* 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.
|
||||
* two sweep outcomes, on-screen reflection, the rounding/interrupt contract,
|
||||
* step pacing, and the loop/restore options — none of which was testable
|
||||
* before the device seam existed.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from "bun:test";
|
||||
@@ -13,7 +13,7 @@ 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";
|
||||
import type { MoveContext, MovementStrategy } from "../src/strategies.ts";
|
||||
|
||||
const noopLog: Logger = { info: (): void => {}, event: (): void => {} };
|
||||
|
||||
@@ -50,11 +50,10 @@ class FakeDevice implements Device {
|
||||
}
|
||||
}
|
||||
|
||||
/** A strategy that emits a fixed list of points under a chosen bounds policy. */
|
||||
function fixed(points: Point[], bounds: BoundsPolicy): MovementStrategy {
|
||||
/** A strategy that emits a fixed list of points. */
|
||||
function fixed(points: Point[]): MovementStrategy {
|
||||
return {
|
||||
name: "fixed",
|
||||
bounds,
|
||||
*path(): Generator<Point> {
|
||||
yield* points;
|
||||
},
|
||||
@@ -79,7 +78,7 @@ describe("executePath — outcomes", () => {
|
||||
{ x: 502, y: 500 },
|
||||
{ x: 503, y: 500 },
|
||||
];
|
||||
const outcome = await executePath(fixed(pts, "clamp"), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf());
|
||||
const outcome = await executePath(fixed(pts), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf());
|
||||
expect(outcome).toBe("completed");
|
||||
// 3 steps + 1 restore.
|
||||
expect(dev.commanded).toEqual([...pts, start]);
|
||||
@@ -95,42 +94,56 @@ describe("executePath — outcomes", () => {
|
||||
];
|
||||
// 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());
|
||||
const outcome = await executePath(fixed(pts), 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 () => {
|
||||
describe("executePath — on-screen reflection", () => {
|
||||
test("mirrors an out-of-range coordinate 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());
|
||||
await executePath(fixed(pts), ctxOf({ x: 50, y: 50 }, 100, 100), dev, noopLog, cfgOf());
|
||||
expect(dev.commanded[0]).toEqual({ x: 74, y: 50 });
|
||||
});
|
||||
|
||||
test("negative and far-past-edge coordinates both fold inside", async () => {
|
||||
const dev = new FakeDevice(100, 100);
|
||||
// Inset [2, 97]. x=-5 -> reflects to 9; x=99 -> 95 (period 190).
|
||||
const pts = [
|
||||
{ x: -5, y: 50 },
|
||||
{ x: 99, y: 50 },
|
||||
];
|
||||
await executePath(fixed(pts), ctxOf({ x: 50, y: 50 }, 100, 100), dev, noopLog, cfgOf());
|
||||
for (const p of dev.commanded.slice(0, 2)) {
|
||||
expect(p.x).toBeGreaterThanOrEqual(2);
|
||||
expect(p.x).toBeLessThanOrEqual(97);
|
||||
}
|
||||
});
|
||||
|
||||
test("a monotonic ramp past an edge keeps moving — never two identical points in a row", async () => {
|
||||
// This is the guarantee that motivated removing `clamp`: a clamp would
|
||||
// pin every over-the-edge point to the same edge pixel, stalling the
|
||||
// cursor. Reflection folds the ramp into a triangle wave, so the cursor
|
||||
// both rises and falls and never repeats a pixel step to step.
|
||||
const dev = new FakeDevice(40, 40);
|
||||
// Ramp x well past the right edge and back's worth of travel.
|
||||
const pts = Array.from({ length: 60 }, (_, i) => ({ x: 10 + i, y: 20 }));
|
||||
await executePath(fixed(pts), ctxOf({ x: 10, y: 20 }, 40, 40), dev, noopLog, cfgOf({ stepDelay: 0 }));
|
||||
const xs = dev.commanded.slice(0, 60).map((p) => p.x);
|
||||
// No stall: consecutive commanded points always differ.
|
||||
for (let i = 1; i < xs.length; i++) {
|
||||
expect(xs[i]).not.toBe(xs[i - 1]);
|
||||
}
|
||||
// It bounced: the ramp both increased and decreased at some point.
|
||||
const rose = xs.some((x, i) => i > 0 && x > xs[i - 1]!);
|
||||
const fell = xs.some((x, i) => i > 0 && x < xs[i - 1]!);
|
||||
expect(rose && fell).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("executePath — options", () => {
|
||||
@@ -142,7 +155,7 @@ describe("executePath — options", () => {
|
||||
{ x: 502, y: 500 },
|
||||
];
|
||||
const outcome = await executePath(
|
||||
fixed(pts, "clamp"),
|
||||
fixed(pts),
|
||||
ctxOf(start, dev.w, dev.h),
|
||||
dev,
|
||||
noopLog,
|
||||
@@ -158,34 +171,15 @@ describe("executePath — options", () => {
|
||||
const dev = new FakeDevice();
|
||||
const start = { x: 500, y: 500 };
|
||||
const pts = [{ x: 501, y: 500 }];
|
||||
await executePath(fixed(pts, "clamp"), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf());
|
||||
await executePath(fixed(pts), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf());
|
||||
expect(dev.commanded).toEqual([...pts, start]);
|
||||
});
|
||||
|
||||
test("bounds override supersedes the strategy's declared policy", async () => {
|
||||
const dev = new FakeDevice(100, 100);
|
||||
// Declared 'abort' would stop before this out-of-bounds point; the
|
||||
// 'reflect' override folds it back inside instead (span [2,97]:
|
||||
// x=120 -> 74) and the sweep completes.
|
||||
const strat = fixed([{ x: 120, y: 50 }], "abort");
|
||||
const outcome = await executePath(
|
||||
strat,
|
||||
ctxOf({ x: 50, y: 50 }, 100, 100),
|
||||
dev,
|
||||
noopLog,
|
||||
cfgOf(),
|
||||
{ bounds: "reflect", restore: false },
|
||||
);
|
||||
expect(outcome).toBe("completed");
|
||||
expect(dev.commanded[0]).toEqual({ x: 74, y: 50 });
|
||||
});
|
||||
|
||||
test("loop:true runs loopPath when present, path otherwise", async () => {
|
||||
const dev = new FakeDevice();
|
||||
// A strategy whose loopPath differs from its path, both finite here.
|
||||
const strat: MovementStrategy = {
|
||||
name: "dual",
|
||||
bounds: "clamp",
|
||||
*path(): Generator<Point> {
|
||||
yield { x: 1, y: 1 };
|
||||
},
|
||||
@@ -203,7 +197,7 @@ describe("executePath — options", () => {
|
||||
|
||||
test("loop:true falls back to path when the strategy has no loopPath", async () => {
|
||||
const dev = new FakeDevice();
|
||||
const strat = fixed([{ x: 3, y: 3 }], "clamp");
|
||||
const strat = fixed([{ x: 3, y: 3 }]);
|
||||
await executePath(strat, ctxOf({ x: 0, y: 0 }, dev.w, dev.h), dev, noopLog, cfgOf(), {
|
||||
loop: true,
|
||||
restore: false,
|
||||
@@ -224,7 +218,7 @@ describe("executePath — readback tolerance", () => {
|
||||
// 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());
|
||||
const outcome = await executePath(fixed(pts), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf());
|
||||
expect(outcome).toBe("completed");
|
||||
expect(dev.commanded).toEqual([...pts, start]);
|
||||
});
|
||||
@@ -238,7 +232,7 @@ describe("executePath — readback tolerance", () => {
|
||||
];
|
||||
// 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());
|
||||
const outcome = await executePath(fixed(pts), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf());
|
||||
expect(outcome).toBe("interrupted");
|
||||
expect(dev.commanded).toEqual([pts[0]!]);
|
||||
});
|
||||
@@ -249,7 +243,7 @@ describe("executePath — rounding & pacing", () => {
|
||||
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());
|
||||
const outcome = await executePath(fixed(pts), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf());
|
||||
expect(outcome).toBe("completed");
|
||||
expect(dev.commanded[0]).toEqual({ x: 10, y: 21 });
|
||||
});
|
||||
@@ -260,7 +254,7 @@ describe("executePath — rounding & pacing", () => {
|
||||
{ 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 }));
|
||||
await executePath(fixed(pts), ctxOf({ x: 500, y: 500 }, dev.w, dev.h), dev, noopLog, cfgOf({ stepDelay: 7 }));
|
||||
expect(dev.sleeps).toEqual([7, 7]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user