/** * Tests for the three forms of stitchlen / filllen / resample: * Form 1 (numeric) — unchanged, regression-guarded * Form 2 (list) — cycling pattern * Form 3 (reporter) — per-stitch callback * * Also covers the companion filllen list / reporter and the resample() overloads. */ import { describe, it, expect } from 'vitest'; import { run, NeedlescriptError } from '../engine.ts'; // ── helpers ────────────────────────────────────────────────────────────────── const stitches = (src: string) => run(src).events.filter((e) => e.t === 'stitch'); const r3 = (n: number) => Math.round(n * 1000) / 1000; // Returns the gaps (distances) between consecutive stitches. function gaps(src: string): number[] { const s = stitches(src); const out: number[] = []; for (let i = 1; i < s.length; i++) { out.push(r3(Math.hypot(s[i].x - s[i - 1].x, s[i].y - s[i - 1].y))); } return out; } // ── Form 2: list mode (stitchlen [a, b, …]) ────────────────────────────────── describe('stitchlen list form', () => { it('single-element list is equivalent to the numeric form', () => { const listRes = stitches('lock 0 stitchlen [5] fd 25'); const numRes = stitches('lock 0 stitchlen 5 fd 25'); expect(listRes.length).toBe(numRes.length); }); it('[4, 1.5] produces alternating long–short gaps', () => { const g = gaps('lock 0 stitchlen [4, 1.5] fd 22'); // Pattern cycles 4, 1.5, 4, 1.5, … — the first few gaps should alternate. // Allow a small tolerance because the last stitch is a "remainder" that may differ. expect(g[0]).toBeCloseTo(4, 2); expect(g[1]).toBeCloseTo(1.5, 2); expect(g[2]).toBeCloseTo(4, 2); expect(g[3]).toBeCloseTo(1.5, 2); }); it('phase offset shifts the starting index', () => { // [4, 1.5] phase 1 → starts at 1.5, then 4, 1.5, … const g = gaps('lock 0 stitchlen [4, 1.5] 1 fd 22'); expect(g[0]).toBeCloseTo(1.5, 2); expect(g[1]).toBeCloseTo(4, 2); }); it('pattern resets at pen-up / pen-down (new stretch)', () => { // Two separate runs; each should start at the first pattern element. // Run 1: fd 20 (starting at y=0), pattern [4, 1.5] → first stitch at y=4 // Run 2: starting at y=30, same pattern → first stitch at y=34 const events = run('lock 0 stitchlen [4, 1.5] fd 20 up moveto 0 30 down fd 20').events; // Find the jump to y=30 const jumpIdx = events.findIndex((e) => e.t === 'jump' && e.y > 25); expect(jumpIdx).toBeGreaterThanOrEqual(0); // First stitch AFTER the jump should be 4mm from (0, 30) const run2First = events.slice(jumpIdx + 1).find((e) => e.t === 'stitch'); expect(run2First).toBeDefined(); if (run2First) { const d = Math.hypot(run2First.x - 0, run2First.y - 30); expect(r3(d)).toBeCloseTo(4, 2); } }); it('list clamps out-of-range elements and warns', () => { const { warnings } = run('stitchlen [20] fd 5'); expect(warnings.some((w) => w.includes('clamped'))).toBe(true); }); it('empty list is an error', () => { expect(() => run('stitchlen [] fd 5')).toThrow(NeedlescriptError); }); it('non-numeric list element is an error', () => { expect(() => run("stitchlen [3, 'hello'] fd 5")).toThrow(NeedlescriptError); }); it('revert to numeric form clears the list', () => { // No errors; the second stitchlen should disengage the list. const s = stitches('lock 0 stitchlen [4, 1.5] fd 10 stitchlen 3 fd 12'); expect(s.length).toBeGreaterThan(0); // Combined run: list-part stitches + numeric-part stitches (shared anchor). expect(s.length).toBe( stitches('lock 0 stitchlen [4, 1.5] fd 10').length + stitches('lock 0 stitchlen 3 fd 12').length - 1, // shared anchor ); }); }); // ── Form 3: reporter mode (stitchlen @fn) ──────────────────────────────────── describe('stitchlen reporter form', () => { it('constant reporter gives identical output to numeric form', () => { const src = ` def constlen(t, s, i, p) [ return 4 ] lock 0 `; const repRes = stitches(src + 'stitchlen @constlen fd 20'); const numRes = stitches('lock 0 stitchlen 4 fd 20'); // Allow ±1 stitch tolerance (reporter handles remainder differently). expect(Math.abs(repRes.length - numRes.length)).toBeLessThanOrEqual(1); }); it('i parameter increments correctly (one call per stitch)', () => { const src = ` def countlen(t, s, i, p) [ return 5 ] lock 0 stitchlen @countlen fd 25 `; const result = run(src); const s = result.events.filter((e) => e.t === 'stitch'); // 5 stitches of 5mm each over 25mm (plus anchor = 6 total) expect(s.length).toBe(6); }); it('s is 0 at start and approaches 1 at end', () => { // Store s values in a global list via a reporter side effect isn't possible // since reporters are pure. Instead verify via stitch positions. // A reporter that returns lerp(1, 5, s) should give shorter stitches at start // and longer at end. const src = ` def taper(t, s, i, p) [ return lerp(1, 5, s) ] lock 0 stitchlen @taper fd 40 `; const s = stitches(src); const g = []; for (let i = 1; i < s.length; i++) { g.push(Math.hypot(s[i].x - s[i - 1].x, s[i].y - s[i - 1].y)); } // First gap should be smaller than last gap. expect(g[0]).toBeLessThan(g[g.length - 1]); }); it('p gives hoop-space position (x increases when going east)', () => { // With the turtle heading east (rt 90), positions along x should increase. // A reporter that uses p[0] to scale the advance should give longer // stitches as x grows. const src = ` def bypos(t, s, i, p) [ let xpos = max(1, p[0]) return clamp(xpos / 5, 0.4, 6) ] lock 0 rt 90 stitchlen @bypos fd 30 `; const s = stitches(src); // p[0] starts near 0 and grows — stitches should get longer. const g = []; for (let i = 1; i < s.length; i++) { g.push(Math.hypot(s[i].x - s[i - 1].x, s[i].y - s[i - 1].y)); } // Last gap should be larger than first gap. expect(g[g.length - 1]).toBeGreaterThan(g[0]); }); it('reporter returning ≤ 0 is an error', () => { const src = ` def badlen(t, s, i, p) [ return -1 ] stitchlen @badlen fd 5 `; expect(() => run(src)).toThrow(NeedlescriptError); }); it('reporter returning non-number is an error', () => { const src = ` def badlen(t, s, i, p) [ return [1, 2] ] stitchlen @badlen fd 5 `; expect(() => run(src)).toThrow(NeedlescriptError); }); it('reporter with wrong parameter count is an error at engage site', () => { const src = ` def badlen(t, s) [ return 2 ] stitchlen @badlen fd 5 `; expect(() => run(src)).toThrow(NeedlescriptError); }); it('reporter never reached return is an error', () => { // A reporter that may not return is caught at parse time (existing reporter check). // A reporter that always returns is fine, so test the never-return case via a // forced structural gap (this tests that the engine catches it gracefully). // The most direct test is a reporter that returns nothing — parse-time error. expect(() => run(` def noreturn(t, s, i, p) [ let x = 1 ] stitchlen @noreturn fd 5 `), ).toThrow(); }); it('revert to numeric form disengages the reporter', () => { const src = ` def constlen(t, s, i, p) [ return 3 ] lock 0 stitchlen @constlen fd 12 stitchlen 5 fd 25 `; expect(() => run(src)).not.toThrow(); const s = stitches(src); expect(s.length).toBeGreaterThan(0); }); it('stretch resets t to 0 at each new pen-down run', () => { // A reporter that returns t+0.5 would grow stitch length each stitch // within a stretch, but reset at a new stretch. // Verify no error and stitches are produced across two stretches. const src = ` def growing(t, s, i, p) [ return clamp(0.5 + t * 0.1, 0.4, 5) ] lock 0 stitchlen @growing fd 10 up moveto 0 20 down fd 10 `; expect(() => run(src)).not.toThrow(); const s = stitches(src); expect(s.length).toBeGreaterThan(2); }); it('works on a full circle arc without error', () => { const src = ` def constlen(t, s, i, p) [ return 3 ] lock 0 stitchlen @constlen arc 360 20 `; expect(() => run(src)).not.toThrow(); const s = stitches(src); expect(s.length).toBeGreaterThan(10); }); }); // ── filllen list form ───────────────────────────────────────────────────────── describe('filllen list form', () => { const squareFill = (lenSpec: string) => ` lock 0 ${lenSpec} beginfill repeat 4 [ fd 20 rt 90 ] endfill trim `; it('filllen [2] produces stitches on a basic square fill', () => { // The list form uses the streamline generator (different path from buildtin // tatami), so counts won't match exactly — just verify stitches are produced. const listRes = stitches(squareFill('filllen [2]')); expect(listRes.length).toBeGreaterThan(20); }); it('filllen list form runs without error on a basic fill', () => { expect(() => run(squareFill('filllen [3, 1.5]'))).not.toThrow(); const s = stitches(squareFill('filllen [3, 1.5]')); expect(s.length).toBeGreaterThan(20); }); it('filllen list with phase offset runs without error', () => { expect(() => run(squareFill('filllen [3, 1.5] 1'))).not.toThrow(); }); it('empty filllen list is an error', () => { expect(() => run(squareFill('filllen []'))).toThrow(NeedlescriptError); }); it('filllen 0 disengages the list (follow stitchlen)', () => { // Set list, then clear it by filllen 0. const src = ` lock 0 filllen [3, 1.5] filllen 0 stitchlen 4 beginfill repeat 4 [ fd 20 rt 90 ] endfill `; expect(() => run(src)).not.toThrow(); }); }); // ── filllen reporter form ───────────────────────────────────────────────────── describe('filllen reporter form', () => { it('runs without error on a basic fill', () => { const src = ` def rowlen(t, s, i, p) [ return 3 ] lock 0 filllen @rowlen beginfill repeat 4 [ fd 20 rt 90 ] endfill `; expect(() => run(src)).not.toThrow(); const s = stitches(src); expect(s.length).toBeGreaterThan(20); }); it('wrong parameter count is an error', () => { const src = ` def rowlen(t, s) [ return 3 ] filllen @rowlen beginfill repeat 4 [ fd 10 rt 90 ] endfill `; expect(() => run(src)).toThrow(NeedlescriptError); }); it('filllen 0 after reporter disengages it', () => { const src = ` def rowlen(t, s, i, p) [ return 3 ] lock 0 filllen @rowlen filllen 0 beginfill repeat 4 [ fd 10 rt 90 ] endfill `; expect(() => run(src)).not.toThrow(); }); }); // ── resample list form ──────────────────────────────────────────────────────── describe('resample list form', () => { it('resample(path, [4, 1.5]) produces alternating long-short gaps', () => { const src = ` let path = trace [ fd 20 ] let pts = resample(path, [4, 1.5]) let result = len(pts) `; expect(() => run(src)).not.toThrow(); }); it('resample(path, [2]) matches resample(path, 2)', () => { const src1 = ` let path = trace [ fd 20 ] let pts = resample(path, [2]) let result = len(pts) `; const src2 = ` let path = trace [ fd 20 ] let pts = resample(path, 2) let result = len(pts) `; const r1 = run(src1); const r2 = run(src2); // Length should be identical (single-element list = numeric). const getLen = (res: ReturnType) => (res.globals?.['result'] as number) ?? 0; expect(getLen(r1)).toBe(getLen(r2)); }); it('phase offset shifts the starting position', () => { // With phase 0 the pattern starts at [4, 1.5]; with phase 1 at [1.5, 4]. // Just verify it runs without error and produces different lengths. const src0 = ` let path = trace [ fd 30 ] let pts = resample(path, [4, 1.5], 0) let result = len(pts) `; const src1 = ` let path = trace [ fd 30 ] let pts = resample(path, [4, 1.5], 1) let result = len(pts) `; expect(() => run(src0)).not.toThrow(); expect(() => run(src1)).not.toThrow(); }); it('empty pattern list is an error', () => { const src = ` let path = trace [ fd 10 ] let pts = resample(path, []) `; expect(() => run(src)).toThrow(NeedlescriptError); }); it('non-numeric pattern element is an error', () => { const src = ` let path = trace [ fd 10 ] let pts = resample(path, [3, 'bad']) `; expect(() => run(src)).toThrow(NeedlescriptError); }); }); // ── resample reporter form ──────────────────────────────────────────────────── describe('resample reporter form', () => { it('constant reporter gives same result as numeric resample', () => { const src = ` def constspacing(t, s, i, p) [ return 3 ] let path = trace [ fd 30 ] let pts = resample(path, @constspacing) let result = len(pts) `; const srcNum = ` let path = trace [ fd 30 ] let pts = resample(path, 3) let result = len(pts) `; const getLen = (res: ReturnType) => (res.globals?.['result'] as number) ?? 0; const r1 = run(src); const r2 = run(srcNum); // Allow ±1 for remainder handling expect(Math.abs(getLen(r1) - getLen(r2))).toBeLessThanOrEqual(1); }); it('runs without error on a non-trivial path', () => { const src = ` def spacing(t, s, i, p) [ return lerp(1, 5, s) ] let path = trace [ arc 360 15 ] let pts = resample(path, @spacing) print len(pts) `; expect(() => run(src)).not.toThrow(); const result = run(src); // Should have produced more than 5 points const printed = result.printed ?? []; const n = printed.length > 0 ? parseInt(printed[0] as string, 10) : 0; expect(n).toBeGreaterThan(5); }); it('wrong parameter count is an error at call site', () => { const src = ` def badspacing(t) [ return 2 ] let path = trace [ fd 10 ] let pts = resample(path, @badspacing) `; expect(() => run(src)).toThrow(NeedlescriptError); }); it('reporter returning ≤ 0 is an error', () => { const src = ` def zero(t, s, i, p) [ return 0 ] let path = trace [ fd 10 ] let pts = resample(path, @zero) `; expect(() => run(src)).toThrow(NeedlescriptError); }); }); // ── interaction: stitchlen list + satin mode ────────────────────────────────── describe('stitchlen list form interactions', () => { it('list form does not affect satin columns', () => { // Entering satin mode should not error when a list is active. const src = ` lock 0 stitchlen [4, 1.5] satin 3 fd 20 `; expect(() => run(src)).not.toThrow(); const s = stitches(src); expect(s.length).toBeGreaterThan(5); }); it('bean stitch works with list form', () => { const src = ` lock 0 stitchlen [4, 1.5] bean 3 fd 20 `; const plain = stitches('lock 0 stitchlen [4, 1.5] fd 20'); const beaned = stitches(src); expect(beaned.length).toBeGreaterThan(plain.length); }); });