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.
This commit is contained in:
2026-08-14 12:56:22 -05:00
parent db3310c247
commit ec33648e74
15 changed files with 233 additions and 224 deletions
+2 -6
View File
@@ -15,8 +15,6 @@ const NONE: ConfigOverrides = {
moveInterval: undefined,
checkInterval: undefined,
stepDelay: undefined,
stepCount: undefined,
stepSize: undefined,
pattern: undefined,
verbose: undefined,
};
@@ -46,12 +44,10 @@ describe("resolveConfig", () => {
expect(cfg.checkInterval).toBe(2000);
});
test("stepDelay, stepCount, stepSize pass through untouched (no unit conversion)", () => {
const cli: ConfigOverrides = { ...NONE, stepDelay: 75, stepCount: 100, stepSize: 4 };
test("stepDelay passes through untouched (no unit conversion)", () => {
const cli: ConfigOverrides = { ...NONE, stepDelay: 75 };
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", () => {
+22 -9
View File
@@ -43,7 +43,7 @@ describe("loadConfigFile (explicit path)", () => {
// Fields not in the file are undefined.
expect(result!.checkInterval).toBeUndefined();
expect(result!.stepDelay).toBeUndefined();
expect(result!.stepCount).toBeUndefined();
expect(result!.pattern).toBeUndefined();
});
test("returns all-undefined overrides for an empty object", () => {
@@ -81,8 +81,8 @@ describe("loadConfigFile (explicit path)", () => {
});
test("throws on non-positive numeric values", () => {
const negative = writeFixture("neg.json", JSON.stringify({ stepCount: -1 }));
expect(() => loadConfigFile(negative)).toThrow(/'stepCount'.*positive number/);
const negative = writeFixture("neg.json", JSON.stringify({ moveInterval: -1 }));
expect(() => loadConfigFile(negative)).toThrow(/'moveInterval'.*positive number/);
const zero = writeFixture("zero.json", JSON.stringify({ stepDelay: 0 }));
expect(() => loadConfigFile(zero)).toThrow(/'stepDelay'.*positive number/);
@@ -98,11 +98,10 @@ describe("loadConfigFile (explicit path)", () => {
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 }));
test("accepts a known pattern", () => {
const path = writeFixture("pattern.json", JSON.stringify({ pattern: "arc" }));
const result = loadConfigFile(path);
expect(result!.pattern).toBe("arc");
expect(result!.stepSize).toBe(3);
});
test("normalizes a loosely-spelled pattern to its canonical name", () => {
@@ -117,9 +116,23 @@ describe("loadConfigFile (explicit path)", () => {
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/);
test("tolerates obsolete stepCount/stepSize keys, ignoring their values", () => {
// Seeded by pre-1.3.0 installs; must not hard-fail on upgrade. They're
// accepted but not surfaced as overrides (and even an invalid value,
// like a negative, is ignored rather than rejected).
const path = writeFixture(
"obsolete.json",
JSON.stringify({ moveInterval: 60, stepCount: -1, stepSize: 3 }),
);
const result = loadConfigFile(path);
expect(result).not.toBeNull();
expect(result!.moveInterval).toBe(60);
expect(result as unknown as Record<string, unknown>).not.toHaveProperty("stepCount");
});
test("still rejects a genuinely unknown key", () => {
const path = writeFixture("unknown.json", JSON.stringify({ movInterval: 60 }));
expect(() => loadConfigFile(path)).toThrow(/unknown key 'movInterval'/);
});
});
+16 -11
View File
@@ -61,8 +61,13 @@ function fixed(points: Point[], bounds: BoundsPolicy): MovementStrategy {
};
}
function ctxOf(start: Point, width: number, height: number, config?: Partial<Config>): MoveContext {
return { start, width, height, config: { ...DEFAULT_CONFIG, ...config }, rng: Math.random };
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", () => {
@@ -74,7 +79,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);
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]);
@@ -90,7 +95,7 @@ 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);
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]!]);
@@ -100,7 +105,7 @@ describe("executePath — outcomes", () => {
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);
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([]);
});
@@ -114,7 +119,7 @@ describe("executePath — bounds policies", () => {
{ 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);
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 });
});
@@ -123,7 +128,7 @@ describe("executePath — bounds policies", () => {
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);
await executePath(fixed(pts, "reflect"), ctxOf({ x: 50, y: 50 }, 100, 100), dev, noopLog, cfgOf());
expect(dev.commanded[0]).toEqual({ x: 74, y: 50 });
});
});
@@ -140,7 +145,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);
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]);
});
@@ -154,7 +159,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);
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]!]);
});
@@ -165,7 +170,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);
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 });
});
@@ -176,7 +181,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, { stepDelay: 7 }), dev, noopLog);
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]);
});
});
+5 -4
View File
@@ -73,9 +73,10 @@ describe("runKeeper", () => {
// 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.
await runUntilStop(quietConfig({ moveInterval: 0, pattern: "line" }), dev);
// A sweep issued setPosition commands (the sweep is interrupted by the
// sleep budget before it finishes, but many steps land); an idle loop
// with no sweep would have issued none.
expect(dev.commanded.length).toBeGreaterThanOrEqual(3);
});
@@ -84,7 +85,7 @@ describe("runKeeper", () => {
// 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);
await runUntilStop(quietConfig({ moveInterval: 0, pattern: "line" }), dev);
expect(dev.commanded.length).toBe(0);
});
});
+32 -40
View File
@@ -9,8 +9,6 @@
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,
@@ -42,56 +40,50 @@ 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);
test("emits its full 250-step, 250px sweep along +x with no vertical drift (preserved default)", () => {
const pts = [...line.path(ctxOf({ start: { x: 500, y: 500 } }))];
expect(pts.length).toBe(250);
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]);
// 1px per step: 501..750.
expect(pts[0]!.x).toBe(501);
expect(pts.at(-1)!.x).toBe(750);
});
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 } }))];
const pts = [...line.path(ctxOf({ start: { x: 90, y: 10 }, width: 100 }))];
expect(pts[0]!.x).toBe(89);
expect(pts.at(-1)!.x).toBe(70);
// Heads left: each step decreases x by 1.
expect(pts[1]!.x).toBe(88);
expect(pts.at(-1)!.x).toBe(90 - 250);
});
});
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]);
test("moves 1px on both axes toward the roomy corner for 250 steps", () => {
const pts = [...diagonal.path(ctxOf({ start: { x: 500, y: 500 } }))];
expect(pts.length).toBe(250);
expect(pts[0]!).toEqual({ x: 501, y: 501 });
expect(pts.at(-1)!).toEqual({ x: 750, y: 750 });
});
});
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);
test("stays within its fixed radius of start across its fixed step count", () => {
const radius = 30; // JITTER_RADIUS
const start = { x: 500, y: 500 };
const pts = [...jitter.path(ctxOf({ start, config: { stepCount, stepSize: size }, rng: mulberry32(1) }))];
expect(pts.length).toBe(stepCount);
const pts = [...jitter.path(ctxOf({ start, rng: mulberry32(1) }))];
expect(pts.length).toBe(80); // JITTER_STEPS
for (const p of pts) {
expect(Math.hypot(p.x - start.x, p.y - start.y)).toBeLessThanOrEqual(radius + 1e-9);
}
@@ -101,35 +93,35 @@ describe("jitter", () => {
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);
const pts = [...walk.path(ctxOf({ start, rng: () => 0.5 }))];
expect(pts.length).toBe(200); // WALK_STEPS
// (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);
test("accumulates finite deltas step over step", () => {
const pts = [...walk.path(ctxOf({ rng: mulberry32(42) }))];
expect(pts.length).toBe(200);
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);
test("emits its fixed step count of finite points, deterministic under a fixed seed", () => {
const pts = [...arc.path(ctxOf({ rng: mulberry32(7) }))];
expect(pts.length).toBe(120); // ARC_STEPS
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)!);
// Same seed -> same endpoint (t = 1 at the final step is a stable point).
const again = [...arc.path(ctxOf({ rng: mulberry32(7) }))];
expect(pts.at(-1)).toEqual(again.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);
const pts = [...figureEight.path(ctxOf({ start }))];
expect(pts.length).toBe(90); // FIG8_STEPS
expect(pts.at(-1)!.x).toBeCloseTo(start.x, 6);
expect(pts.at(-1)!.y).toBeCloseTo(start.y, 6);
});