From fa4c7658604a4b4f6e0305f8a0f6bb0903732c8f Mon Sep 17 00:00:00 2001 From: jlightner Date: Thu, 26 Mar 2026 06:26:09 +0000 Subject: [PATCH] =?UTF-8?q?test:=20Created=20exportService.ts=20with=20com?= =?UTF-8?q?poseCanvasSVG(),=20validateForExpo=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "app/src/utils/exportService.ts" - "app/src/utils/__tests__/exportService.test.ts" - "app/src/api/engine.ts" - "app/src/api/__tests__/engine.test.ts" - "app/src/types/opentype.d.ts" GSD-Task: S01/T03 --- app/src/api/__tests__/engine.test.ts | 71 +++- app/src/api/engine.ts | 34 ++ .../hooks/__tests__/useDebouncedTrace.test.ts | 4 +- app/src/types/opentype.d.ts | 33 ++ app/src/utils/__tests__/exportService.test.ts | 401 ++++++++++++++++++ app/src/utils/__tests__/fontService.test.ts | 7 +- app/src/utils/exportService.ts | 289 +++++++++++++ app/src/views/ImportConvert.tsx | 2 +- app/vite.config.ts | 2 +- 9 files changed, 837 insertions(+), 6 deletions(-) create mode 100644 app/src/types/opentype.d.ts create mode 100644 app/src/utils/__tests__/exportService.test.ts create mode 100644 app/src/utils/exportService.ts diff --git a/app/src/api/__tests__/engine.test.ts b/app/src/api/__tests__/engine.test.ts index 9af4897..4d75f20 100644 --- a/app/src/api/__tests__/engine.test.ts +++ b/app/src/api/__tests__/engine.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { getPresets, traceImage, simplifyVector } from '../engine'; +import { getPresets, traceImage, simplifyVector, exportAsDxf } from '../engine'; // ---------- helpers ---------- @@ -147,3 +147,72 @@ describe('simplifyVector', () => { await expect(simplifyVector(file, 1.0)).rejects.toThrow(/failed.*400/i); }); }); + +// ---------- exportAsDxf ---------- + +function mockFetchBlob(blobContent: string) { + return vi.fn().mockResolvedValue({ + ok: true, + status: 200, + blob: () => Promise.resolve(new Blob([blobContent], { type: 'application/octet-stream' })), + text: () => Promise.resolve(blobContent), + }); +} + +describe('exportAsDxf', () => { + it('sends FormData with SVG content, output_format=dxf, units, and scale_factor', async () => { + globalThis.fetch = mockFetchBlob('DXF_CONTENT_BINARY'); + + const svgContent = ''; + const result = await exportAsDxf(svgContent, 'inches', 0.010416667); + + expect(globalThis.fetch).toHaveBeenCalledOnce(); + const [url, opts] = (globalThis.fetch as ReturnType).mock.calls[0]; + expect(url).toBe('/engine/simplify'); + expect(opts.method).toBe('POST'); + + const body: FormData = opts.body; + expect(body).toBeInstanceOf(FormData); + expect(body.get('output_format')).toBe('dxf'); + expect(body.get('units')).toBe('inches'); + expect(body.get('scale_factor')).toBe('0.010416667'); + + // File should be present as a Blob + const file = body.get('file'); + expect(file).toBeInstanceOf(Blob); + + // Result should be a Blob + expect(result).toBeInstanceOf(Blob); + const text = await result.text(); + expect(text).toBe('DXF_CONTENT_BINARY'); + }); + + it('sends mm units correctly', async () => { + globalThis.fetch = mockFetchBlob('DXF_MM'); + + await exportAsDxf('', 'mm', 0.264583333); + + const [, opts] = (globalThis.fetch as ReturnType).mock.calls[0]; + const body: FormData = opts.body; + expect(body.get('units')).toBe('mm'); + expect(body.get('scale_factor')).toBe('0.264583333'); + }); + + it('passes AbortSignal when provided', async () => { + globalThis.fetch = mockFetchBlob('DXF'); + const controller = new AbortController(); + + await exportAsDxf('', 'inches', 1.0, controller.signal); + + const [, opts] = (globalThis.fetch as ReturnType).mock.calls[0]; + expect(opts.signal).toBe(controller.signal); + }); + + it('throws on non-ok response', async () => { + globalThis.fetch = mockFetchFail(500, 'Internal Server Error'); + + await expect( + exportAsDxf('', 'inches', 1.0), + ).rejects.toThrow(/DXF export.*failed.*500/i); + }); +}); diff --git a/app/src/api/engine.ts b/app/src/api/engine.ts index e4609cc..c8f7f7d 100644 --- a/app/src/api/engine.ts +++ b/app/src/api/engine.ts @@ -67,3 +67,37 @@ export async function simplifyVector( } return res.json() as Promise; } + +/** + * Export an SVG as DXF via the engine's simplify endpoint. + * + * Posts the SVG content as a file with output_format=dxf, along with + * units and scale_factor parameters. Returns the raw DXF binary as a Blob. + */ +export async function exportAsDxf( + svgContent: string, + units: 'inches' | 'mm', + scaleFactor: number, + signal?: AbortSignal, +): Promise { + const form = new FormData(); + form.append( + 'file', + new Blob([svgContent], { type: 'image/svg+xml' }), + 'export.svg', + ); + form.append('output_format', 'dxf'); + form.append('units', units); + form.append('scale_factor', String(scaleFactor)); + + const res = await fetch(`${BASE}/simplify`, { + method: 'POST', + body: form, + signal, + }); + if (!res.ok) { + const detail = await res.text().catch(() => res.statusText); + throw new Error(`POST /engine/simplify (DXF export) failed: ${res.status} — ${detail}`); + } + return res.blob(); +} diff --git a/app/src/hooks/__tests__/useDebouncedTrace.test.ts b/app/src/hooks/__tests__/useDebouncedTrace.test.ts index 9128ff4..f2a2657 100644 --- a/app/src/hooks/__tests__/useDebouncedTrace.test.ts +++ b/app/src/hooks/__tests__/useDebouncedTrace.test.ts @@ -88,7 +88,7 @@ describe('useDebouncedTrace', () => { }); it('passes AbortSignal to fetch and aborts previous requests', async () => { - let firstSignal: AbortSignal | undefined; + let firstSignal: AbortSignal | null | undefined; let callCount = 0; globalThis.fetch = vi.fn().mockImplementation((_url: string, opts?: RequestInit) => { callCount++; @@ -105,7 +105,7 @@ describe('useDebouncedTrace', () => { const file = new File(['pixels'], 'test.png', { type: 'image/png' }); - const { result, rerender } = renderHook( + const { rerender } = renderHook( ({ params }) => useDebouncedTrace(file, 'sign', params, DEBOUNCE_MS), { initialProps: { params: { epsilon: 1 } as Record } }, ); diff --git a/app/src/types/opentype.d.ts b/app/src/types/opentype.d.ts new file mode 100644 index 0000000..b676e0e --- /dev/null +++ b/app/src/types/opentype.d.ts @@ -0,0 +1,33 @@ +declare module 'opentype.js' { + interface Glyph { + advanceWidth: number; + getPath(x: number, y: number, fontSize: number): Path; + } + + interface PathCommand { + type: string; + x?: number; + y?: number; + x1?: number; + y1?: number; + x2?: number; + y2?: number; + } + + interface Path { + commands: PathCommand[]; + toPathData(decimalPlaces?: number): string; + } + + interface Font { + unitsPerEm: number; + ascender: number; + descender: number; + charToGlyph(char: string): Glyph; + getPath(text: string, x: number, y: number, fontSize: number): Path; + stringToGlyphs(text: string): Glyph[]; + } + + export function parse(buffer: ArrayBuffer): Font; + export function load(url: string, callback: (err: Error | null, font?: Font) => void): void; +} diff --git a/app/src/utils/__tests__/exportService.test.ts b/app/src/utils/__tests__/exportService.test.ts new file mode 100644 index 0000000..f4a7a3a --- /dev/null +++ b/app/src/utils/__tests__/exportService.test.ts @@ -0,0 +1,401 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { + composeCanvasSVG, + validateForExport, + triggerDownload, +} from '../exportService'; +import type { + ArtboardConfig, + CanvasObject, + RectObject, + CircleObject, + EllipseObject, + LineObject, + ImageObject, + TextObject, +} from '../../types/canvas'; + +// -- Helpers ------------------------------------------------------------------ + +function makeArtboard(overrides?: Partial): ArtboardConfig { + return { + shape: 'rect', + width: 4, + height: 6, + unit: 'inches', + ...overrides, + }; +} + +function makeRect(overrides?: Partial): RectObject { + return { + id: 'rect-1', + name: 'Rectangle 1', + type: 'rect', + x: 10, + y: 20, + width: 100, + height: 50, + rotation: 0, + visible: true, + locked: false, + opacity: 1, + fill: '#ff0000', + stroke: '#000000', + strokeWidth: 2, + ...overrides, + }; +} + +function makeCircle(overrides?: Partial): CircleObject { + return { + id: 'circle-1', + name: 'Circle 1', + type: 'circle', + x: 50, + y: 50, + radius: 25, + rotation: 0, + visible: true, + locked: false, + opacity: 1, + fill: '#00ff00', + stroke: '#000000', + strokeWidth: 1, + ...overrides, + }; +} + +function makeEllipse(overrides?: Partial): EllipseObject { + return { + id: 'ellipse-1', + name: 'Ellipse 1', + type: 'ellipse', + x: 100, + y: 100, + radiusX: 40, + radiusY: 20, + rotation: 0, + visible: true, + locked: false, + opacity: 1, + fill: '#0000ff', + stroke: '#000000', + strokeWidth: 1, + ...overrides, + }; +} + +function makeLine(overrides?: Partial): LineObject { + return { + id: 'line-1', + name: 'Line 1', + type: 'line', + x: 0, + y: 0, + points: [10, 20, 30, 40, 50, 60], + rotation: 0, + visible: true, + locked: false, + opacity: 1, + stroke: '#333333', + strokeWidth: 3, + lineStyle: 'solid', + dash: [], + ...overrides, + }; +} + +function makeImage(overrides?: Partial): ImageObject { + return { + id: 'img-1', + name: 'Image 1', + type: 'image', + x: 10, + y: 10, + width: 200, + height: 150, + rotation: 0, + visible: true, + locked: false, + opacity: 1, + src: 'blob:http://localhost:3000/abc-image/svg+xml', + ...overrides, + }; +} + +function makeText(overrides?: Partial): TextObject { + return { + id: 'text-1', + name: 'Text 1', + type: 'text', + x: 10, + y: 10, + text: 'Hello', + fontFamily: 'Roboto', + fontSize: 24, + letterSpacing: 0, + lineHeight: 1.2, + fill: '#000000', + stroke: '', + strokeWidth: 0, + width: 200, + rotation: 0, + visible: true, + locked: false, + opacity: 1, + ...overrides, + }; +} + +// -- validateForExport -------------------------------------------------------- + +describe('validateForExport', () => { + it('returns valid=true with no issues for clean objects', () => { + const artboard = makeArtboard(); + const objects: CanvasObject[] = [makeRect(), makeCircle()]; + const result = validateForExport(objects, artboard); + expect(result.valid).toBe(true); + expect(result.issues).toHaveLength(0); + }); + + it('returns error when artboard is null', () => { + const result = validateForExport([makeRect()], null); + expect(result.valid).toBe(false); + expect(result.issues).toHaveLength(1); + expect(result.issues[0].severity).toBe('error'); + expect(result.issues[0].message).toMatch(/artboard/i); + }); + + it('returns error for visible text objects', () => { + const artboard = makeArtboard(); + const text = makeText(); + const result = validateForExport([text], artboard); + expect(result.valid).toBe(false); + const textIssue = result.issues.find( + (i) => i.objectId === 'text-1', + ); + expect(textIssue).toBeDefined(); + expect(textIssue!.severity).toBe('error'); + expect(textIssue!.message).toMatch(/converted to paths/i); + }); + + it('ignores hidden text objects', () => { + const artboard = makeArtboard(); + const text = makeText({ visible: false }); + const result = validateForExport([text], artboard); + expect(result.valid).toBe(true); + expect(result.issues).toHaveLength(0); + }); + + it('warns on raster images', () => { + const artboard = makeArtboard(); + const img = makeImage({ src: 'blob:http://localhost:3000/raster-png' }); + const result = validateForExport([img], artboard); + expect(result.valid).toBe(true); + const imgIssue = result.issues.find( + (i) => i.objectId === 'img-1', + ); + expect(imgIssue).toBeDefined(); + expect(imgIssue!.severity).toBe('warning'); + expect(imgIssue!.message).toMatch(/raster/i); + }); + + it('does not warn on SVG image sources', () => { + const artboard = makeArtboard(); + const img = makeImage({ + src: 'blob:http://localhost:3000/abc-image/svg+xml', + }); + const result = validateForExport([img], artboard); + expect(result.valid).toBe(true); + expect(result.issues).toHaveLength(0); + }); + + it('collects multiple issues', () => { + const text = makeText(); + const rasterImg = makeImage({ + id: 'img-raster', + name: 'Raster', + src: 'data:image/png;base64,abc', + }); + const result = validateForExport([text, rasterImg], null); + expect(result.valid).toBe(false); + // artboard error + text error + raster warning = 3 + expect(result.issues).toHaveLength(3); + const errors = result.issues.filter((i) => i.severity === 'error'); + const warnings = result.issues.filter((i) => i.severity === 'warning'); + expect(errors).toHaveLength(2); + expect(warnings).toHaveLength(1); + }); +}); + +// -- composeCanvasSVG --------------------------------------------------------- + +describe('composeCanvasSVG', () => { + it('produces SVG with correct viewBox and real-world dimensions (inches)', () => { + const artboard = makeArtboard({ width: 4, height: 6, unit: 'inches' }); + const svg = composeCanvasSVG([], artboard); + // 4in * 96 PPI = 384px, 6in * 96 PPI = 576px + expect(svg).toContain('viewBox="0 0 384 576"'); + expect(svg).toContain('width="4in"'); + expect(svg).toContain('height="6in"'); + }); + + it('produces SVG with correct dimensions (mm)', () => { + const artboard = makeArtboard({ width: 100, height: 200, unit: 'mm' }); + const svg = composeCanvasSVG([], artboard); + expect(svg).toContain('width="100mm"'); + expect(svg).toContain('height="200mm"'); + // 100mm * (96/25.4) ≈ 377.9528 + expect(svg).toMatch(/viewBox="0 0 377\.95\d* 755\.90\d*"/); + }); + + it('renders rect objects correctly', () => { + const artboard = makeArtboard(); + const rect = makeRect({ x: 10, y: 20, width: 100, height: 50 }); + const svg = composeCanvasSVG([rect], artboard); + expect(svg).toContain(' { + const artboard = makeArtboard(); + const circle = makeCircle({ x: 50, y: 50, radius: 25 }); + const svg = composeCanvasSVG([circle], artboard); + expect(svg).toContain(' { + const artboard = makeArtboard(); + const ellipse = makeEllipse({ x: 100, y: 100, radiusX: 40, radiusY: 20 }); + const svg = composeCanvasSVG([ellipse], artboard); + expect(svg).toContain(' { + const artboard = makeArtboard(); + const line = makeLine({ x: 5, y: 10, points: [0, 0, 20, 30] }); + const svg = composeCanvasSVG([line], artboard); + expect(svg).toContain(' elements', () => { + const artboard = makeArtboard(); + const img = makeImage({ + src: 'blob:http://localhost:3000/abc-image/svg+xml', + }); + const svg = composeCanvasSVG([img], artboard); + expect(svg).toContain('