/** * 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 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; rng?: () => number; }): MoveContext { return { start: overrides.start ?? { x: 500, y: 500 }, width: overrides.width ?? 1920, height: overrides.height ?? 1080, rng: overrides.rng ?? Math.random, }; } describe("line", () => { 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); // 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 }))]; expect(pts[0]!.x).toBe(89); // 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 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 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, 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); } }); }); 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, 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 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 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); // 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 }))]; 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); }); }); 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(); }); });