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