import { describe, it, expect } from 'vitest';
import { svgToCode, convertShapes } from '../../svg-import/import-policy.ts';
import { run } from '../engine.ts';
// ── SVG test fixtures ──────────────────────────────────────────────────────
const simpleLine = ``;
const filledRect = ``;
const strokedRect = ``;
const circle = ``;
const filledCircle = ``;
const ellipse = ``;
const polygon = ``;
const polyline = ``;
const pathRect = ``;
const pathCubic = ``;
const pathArc = ``;
const groupWithTransform = ``;
const multiColorSvg = ``;
const invisibleOnly = ``;
const emptyPath = ``;
const withDefs = ``;
const styleAttrSvg = ``;
const fillAndStroke = ``;
// ── svgToCode ──────────────────────────────────────────────────────────────
describe('svgToCode', () => {
describe('return shape', () => {
it('returns { code, report }', () => {
const result = svgToCode(simpleLine);
expect(result).toHaveProperty('code');
expect(result).toHaveProperty('report');
expect(typeof result.code).toBe('string');
});
it('code is non-empty for a valid SVG', () => {
const { code } = svgToCode(simpleLine);
expect(code.length).toBeGreaterThan(0);
});
it('report includes fills, outlines, colors, segments, tolerance, fitMM', () => {
const { report } = svgToCode(simpleLine);
expect(report).toHaveProperty('fills');
expect(report).toHaveProperty('outlines');
expect(report).toHaveProperty('colors');
expect(report).toHaveProperty('segments');
expect(report).toHaveProperty('tolerance');
expect(report).toHaveProperty('fitMM');
});
});
// ── supported shapes ────────────────────────────────────────────────────────
describe('supported shapes', () => {
it('converts ', () => {
const { code } = svgToCode(simpleLine);
expect(code).toContain('sewpath');
});
it('converts with stroke', () => {
const { code } = svgToCode(strokedRect);
expect(code).toContain('sewpath');
});
it('converts with fill → beginfill/endfill', () => {
const { code } = svgToCode(filledRect);
expect(code).toContain('beginfill');
expect(code).toContain('endfill');
});
it('converts ', () => {
const { code } = svgToCode(circle);
expect(code).toContain('sewpath');
});
it('converts filled → fill block', () => {
const { code } = svgToCode(filledCircle);
expect(code).toContain('beginfill');
});
it('converts ', () => {
const { code } = svgToCode(ellipse);
expect(code).toContain('sewpath');
});
it('converts ', () => {
const { code } = svgToCode(polygon);
expect(code).toContain('sewpath');
});
it('converts ', () => {
const { code } = svgToCode(polyline);
expect(code).toContain('sewpath');
});
it('converts with line commands (L, Z)', () => {
const { code } = svgToCode(pathRect);
expect(code).toContain('sewpath');
});
it('converts with cubic bezier (C)', () => {
const { code } = svgToCode(pathCubic);
expect(code).toContain('sewpath');
});
it('converts with arc (A)', () => {
const { code } = svgToCode(pathArc);
expect(code).toContain('sewpath');
});
});
// ── transforms ─────────────────────────────────────────────────────────────
describe('transforms', () => {
it('applies translate transform', () => {
const { code } = svgToCode(groupWithTransform);
expect(code).toContain('sewpath');
});
it('transform attribute is applied: rotated shape differs from unrotated', () => {
// A rectangle with a 45-degree rotation should produce different geometry
// than the same rectangle without rotation.
const noRotate = ``;
const withRotate = ``;
const base = svgToCode(noRotate).code;
const rotated = svgToCode(withRotate).code;
expect(base).not.toBe(rotated);
});
});
// ── colors ─────────────────────────────────────────────────────────────────
describe('color mapping', () => {
it('single color SVG has 1 color in report', () => {
const { report } = svgToCode(simpleLine);
expect(report.colors).toBe(1);
});
it('multi-color SVG has > 1 colors when colors differ enough', () => {
const { report } = svgToCode(multiColorSvg);
expect(report.colors).toBeGreaterThanOrEqual(1);
});
it('code contains "color N" for multi-color designs', () => {
// red vs blue are far apart in the palette → should get separate color groups
const { code, report } = svgToCode(multiColorSvg);
if (report.colors > 1) {
expect(code).toMatch(/color \d/);
}
});
it('style="fill:green; stroke:red;" is parsed correctly', () => {
const { code } = svgToCode(styleAttrSvg);
expect(code).toContain('beginfill'); // green fill
});
});
// ── fill + stroke shapes ───────────────────────────────────────────────────
describe('shapes with both fill and stroke', () => {
it('generates a procedure for shapes with both fill and stroke', () => {
const { code } = svgToCode(fillAndStroke);
expect(code).toContain('beginfill');
// Both paint operations reference one shared geometry declaration.
expect(code.match(/let rect_1 =/g)).toHaveLength(1);
});
});
// ── skipped elements ───────────────────────────────────────────────────────
describe('skipped elements', () => {
it('elements inside are not converted', () => {
// withDefs has a circle inside defs AND a circle directly — only the direct one converts
const { report } = svgToCode(withDefs);
expect(report.outlines).toBe(1);
});
it('ignored tags are reported in report.ignored', () => {
// Include a valid geometry element alongside the unsupported
const svgWithText = ``;
const { report } = svgToCode(svgWithText);
expect(report.ignored?.['text']).toBeGreaterThan(0);
});
});
// ── fitMM option ───────────────────────────────────────────────────────────
describe('fitMM option', () => {
it('report.fitMM matches the option', () => {
const { report } = svgToCode(simpleLine, { fitMM: 120 });
expect(report.fitMM).toBe(120);
});
it('default fitMM is 80', () => {
const { report } = svgToCode(simpleLine);
expect(report.fitMM).toBe(80);
});
});
// ── error cases ────────────────────────────────────────────────────────────
describe('error handling', () => {
it('throws on invalid XML', () => {
expect(() => svgToCode(' {
expect(() => svgToCode(invisibleOnly)).toThrow();
});
it('throws when all paths are degenerate', () => {
expect(() => svgToCode(emptyPath)).toThrow();
});
it('throws on non-SVG root element', () => {
expect(() => svgToCode('')).toThrow();
});
it('throws on an unresolved paint instead of substituting gray', () => {
const svg = ``;
expect(() => svgToCode(svg)).toThrow(/representative thread color/);
});
});
// ── generated code validity ────────────────────────────────────────────────
describe('generated code is valid Needlescript', () => {
it('line SVG generates runnable code', () => {
const { code } = svgToCode(simpleLine);
expect(() => run(code)).not.toThrow();
});
it('filled rect SVG generates runnable code', () => {
const { code } = svgToCode(filledRect);
expect(() => run(code)).not.toThrow();
});
it('linear-gradient SVG generates a runnable density-neutral thread recipe', () => {
const svg = ``;
const { code, report } = svgToCode(svg);
expect(code).toContain('std.stitchcraft.gradientrowsn');
expect(report).toMatchObject({ fills: 1, outlines: 0, colors: 2 });
expect(() => run(code)).not.toThrow();
});
it('circle SVG generates runnable code', () => {
const { code } = svgToCode(circle);
expect(() => run(code)).not.toThrow();
});
it('generated code from filled circle produces stitches', () => {
const { code } = svgToCode(filledCircle);
const result = run(code);
const s = result.events.filter((e) => e.t === 'stitch');
expect(s.length).toBeGreaterThan(0);
});
});
// ── implicit fill default ──────────────────────────────────────────────────
describe('SVG paint defaults', () => {
it('an element with no fill/stroke attributes inherits default black fill', () => {
// In SVG the default fill is black, stroke is none
const svg = ``;
const { code } = svgToCode(svg);
expect(code).toContain('beginfill');
});
});
});
// ── convertShapes (lower-level API) ─────────────────────────────────────────
describe('convertShapes', () => {
type Point = [number, number];
type Shape = { subpaths: Point[][]; fill: string | null; stroke: string | null };
const square: Shape = {
subpaths: [
[
[0, 0],
[100, 0],
[100, 100],
[0, 100],
[0, 0],
],
],
fill: null,
stroke: '#000000',
};
it('returns code and report', () => {
const result = convertShapes([square]);
expect(result).toHaveProperty('code');
expect(result).toHaveProperty('report');
});
it('scales geometry to fitMM', () => {
const r80 = convertShapes([square], { fitMM: 80 });
const r40 = convertShapes([square], { fitMM: 40 });
expect(r80.report.fitMM).toBe(80);
expect(r40.report.fitMM).toBe(40);
});
it('throws when all shapes are invisible', () => {
const invisible: Shape = {
subpaths: [
[
[0, 0],
[10, 0],
[10, 10],
],
],
fill: null,
stroke: null,
};
expect(() => convertShapes([invisible])).toThrow();
});
it('throws on zero-size geometry', () => {
const zero: Shape = {
subpaths: [
[
[5, 5],
[5, 5],
],
],
fill: null,
stroke: '#000',
};
expect(() => convertShapes([zero])).toThrow();
});
it('fill shape produces beginfill block in code', () => {
const filled: Shape = {
subpaths: [
[
[0, 0],
[100, 0],
[100, 100],
[0, 100],
[0, 0],
],
],
fill: '#FF0000',
stroke: null,
};
const { code } = convertShapes([filled]);
expect(code).toContain('beginfill');
});
it('stroke shape produces trace commands without beginfill', () => {
const { code } = convertShapes([square]);
expect(code).not.toContain('beginfill');
expect(code).toContain('sewpath');
});
it('custom palette is used for thread color mapping', () => {
const red: Shape = {
subpaths: [
[
[0, 0],
[100, 0],
[100, 100],
[0, 100],
[0, 0],
],
],
fill: '#FF0000',
stroke: null,
};
const blue: Shape = {
subpaths: [
[
[0, 50],
[100, 50],
[100, 100],
[0, 100],
[0, 50],
],
],
fill: '#0000FF',
stroke: null,
};
const palette = ['#FF0000', '#0000FF'];
const { code } = convertShapes([red, blue], { palette });
// Should assign color 0 and color 1
expect(code).toMatch(/color [01]/);
});
it('RDP simplification report has segments ≤ maxSegments', () => {
// A circle approximated with many points
const pts: Point[] = [];
for (let i = 0; i <= 360; i++) {
const a = (i / 360) * 2 * Math.PI;
pts.push([50 + 40 * Math.cos(a), 50 + 40 * Math.sin(a)]);
}
const shape: Shape = { subpaths: [pts], fill: null, stroke: '#000' };
const { report } = convertShapes([shape], { maxSegments: 100 });
expect(report.segments).toBeLessThanOrEqual(100);
});
});