126 lines
6.1 KiB
TypeScript
126 lines
6.1 KiB
TypeScript
/**
|
||
* Task F8 – SVG-Import als Block: Geometrie-Konvertierung.
|
||
*
|
||
* Path-Parser (M/L/H/V/C/S/Q/T/A/Z, relative + absolute), Bézier-Flattening,
|
||
* Arc→Polyline, Konventions-Fixes (rect=Zentrum, circle=cx/cy direkt),
|
||
* ellipse→Polyline statt verstümmeltem Kreis, sowie Block-Payload-Erzeugung
|
||
* (SVG → Element-Array + Thumbnail) für die globale Bibliothek.
|
||
*/
|
||
import { describe, it, expect } from 'vitest';
|
||
import { importSVG, importSVGAsBlockPayload } from '../src/services/importService';
|
||
|
||
describe('F8: SVG path → Polyline-Approximation', () => {
|
||
it('M + L + Z erzeugt geschlossene Polyline mit exakten Punkten', () => {
|
||
const res = importSVG('<svg xmlns="http://www.w3.org/2000/svg"><path d="M 0 0 L 100 0 L 100 100 Z" stroke="#fff" fill="none"/></svg>');
|
||
expect(res.success).toBe(true);
|
||
const poly = res.elements.find((e) => e.type === 'polygon');
|
||
expect(poly).toBeDefined();
|
||
const pts = poly!.properties.points as Array<{ x: number; y: number }>;
|
||
expect(pts.length).toBeGreaterThanOrEqual(3);
|
||
expect(pts[0]).toEqual({ x: 0, y: 0 });
|
||
expect(pts[1]).toEqual({ x: 100, y: 0 });
|
||
expect(pts[2]).toEqual({ x: 100, y: 100 });
|
||
});
|
||
|
||
it('kleine Befehle sind relativ (m/l): Startpunkt wirkt', () => {
|
||
const res = importSVG('<svg xmlns="http://www.w3.org/2000/svg"><path d="m 10 10 l 20 0 l 0 20 z"/></svg>');
|
||
const poly = res.elements.find((e) => e.type === 'polygon');
|
||
const pts = poly!.properties.points as Array<{ x: number; y: number }>;
|
||
expect(pts[0]).toEqual({ x: 10, y: 10 });
|
||
expect(pts[1]).toEqual({ x: 30, y: 10 });
|
||
expect(pts[2]).toEqual({ x: 30, y: 30 });
|
||
});
|
||
|
||
it('kubischer Bézier (C) wird geflattet: Kurvenpunkte + Endpunkt exakt', () => {
|
||
const res = importSVG('<svg xmlns="http://www.w3.org/2000/svg"><path d="M 0 0 C 50 100 100 0 100 100"/></svg>');
|
||
const poly = res.elements.find((e) => e.type === 'polyline');
|
||
expect(poly).toBeDefined();
|
||
const pts = poly!.properties.points as Array<{ x: number; y: number }>;
|
||
expect(pts.length).toBeGreaterThan(10); // geflattet, nicht nur Endpunkte
|
||
expect(pts[0]).toEqual({ x: 0, y: 0 });
|
||
expect(pts[pts.length - 1]).toEqual({ x: 100, y: 100 });
|
||
// Mittelpunkt der Kubik-Bézier bei t=0.5: exakt berechenbar
|
||
expect(pts[Math.floor(pts.length / 2)].y).toBeGreaterThan(20);
|
||
});
|
||
|
||
it('H/V-Befehle (horizontal/vertical) funktionieren', () => {
|
||
const res = importSVG('<svg xmlns="http://www.w3.org/2000/svg"><path d="M 0 0 H 50 V 30 L 10 30 Z"/></svg>');
|
||
const poly = res.elements.find((e) => e.type === 'polygon');
|
||
const pts = poly!.properties.points as Array<{ x: number; y: number }>;
|
||
expect(pts[0]).toEqual({ x: 0, y: 0 });
|
||
expect(pts[1]).toEqual({ x: 50, y: 0 });
|
||
expect(pts[2]).toEqual({ x: 50, y: 30 });
|
||
});
|
||
|
||
it('Arc (A) wird approximiert: Halbkreis über (100,0) mit korrektem Bogenverlauf', () => {
|
||
const res = importSVG('<svg xmlns="http://www.w3.org/2000/svg"><path d="M 0 0 A 50 50 0 0 1 100 0"/></svg>');
|
||
const poly = res.elements.find((e) => e.type === 'polyline');
|
||
expect(poly).toBeDefined();
|
||
const pts = poly!.properties.points as Array<{ x: number; y: number }>;
|
||
expect(pts.length).toBeGreaterThan(8);
|
||
expect(pts[0]).toEqual({ x: 0, y: 0 });
|
||
expect(pts[pts.length - 1]).toEqual({ x: 100, y: 0 });
|
||
// Scheitel des Halbkreises: (50, -50) bei sweep=1 (y-up im Bogen oben)
|
||
const maxY = Math.max(...pts.map((p) => p.y));
|
||
const minY = Math.min(...pts.map((p) => p.y));
|
||
expect(minY).toBeLessThan(-40); // Bogen wölbt sich nach oben (negativ)
|
||
expect(maxY).toBeCloseTo(0, 5);
|
||
});
|
||
|
||
it('Mehrfach-Subpaths ergeben mehrere Elemente', () => {
|
||
const res = importSVG('<svg xmlns="http://www.w3.org/2000/svg"><path d="M 0 0 L 10 0 M 100 100 L 110 100"/></svg>');
|
||
const polys = res.elements.filter((e) => e.type === 'polyline');
|
||
expect(polys.length).toBe(2);
|
||
});
|
||
});
|
||
|
||
describe('F8: Konventions-Fixes', () => {
|
||
it('rect: x/y = Zentrum (App-Konvention, nicht Ecke)', () => {
|
||
const res = importSVG('<svg xmlns="http://www.w3.org/2000/svg"><rect x="0" y="0" width="100" height="50"/></svg>');
|
||
const rect = res.elements.find((e) => e.type === 'rect');
|
||
expect(rect).toBeDefined();
|
||
expect(rect!.x).toBe(50);
|
||
expect(rect!.y).toBe(25);
|
||
expect(rect!.width).toBe(100);
|
||
});
|
||
|
||
it('circle: el.x/el.y = Zentrum direkt (Renderer-Konvention)', () => {
|
||
const res = importSVG('<svg xmlns="http://www.w3.org/2000/svg"><circle cx="30" cy="40" r="10"/></svg>');
|
||
const circ = res.elements.find((e) => e.type === 'circle');
|
||
expect(circ).toBeDefined();
|
||
expect(circ!.x).toBe(30);
|
||
expect(circ!.y).toBe(40);
|
||
expect(circ!.properties.radius).toBe(10);
|
||
});
|
||
|
||
it('ellipse → geschlossenes Polygon mit rx/ry korrekt (nicht verstümmelter Kreis)', () => {
|
||
const res = importSVG('<svg xmlns="http://www.w3.org/2000/svg"><ellipse cx="50" cy="50" rx="100" ry="40"/></svg>');
|
||
const poly = res.elements.find((e) => e.type === 'polygon');
|
||
expect(poly).toBeDefined();
|
||
const pts = poly!.properties.points as Array<{ x: number; y: number }>;
|
||
expect(pts.length).toBeGreaterThanOrEqual(32);
|
||
expect(Math.max(...pts.map((p) => p.x))).toBeCloseTo(150, 5);
|
||
expect(Math.min(...pts.map((p) => p.x))).toBeCloseTo(-50, 5);
|
||
expect(Math.max(...pts.map((p) => p.y))).toBeCloseTo(90, 5);
|
||
expect(Math.min(...pts.map((p) => p.y))).toBeCloseTo(10, 5);
|
||
});
|
||
});
|
||
|
||
describe('F8: SVG als Block-Payload', () => {
|
||
it('importSVGAsBlockPayload liefert Element-Array als JSON-String + svg als Thumbnail', () => {
|
||
const payload = importSVGAsBlockPayload('<svg xmlns="http://www.w3.org/2000/svg"><rect x="0" y="0" width="80" height="40"/></svg>');
|
||
expect(payload.blockData).toBeTruthy();
|
||
const els = JSON.parse(payload.blockData);
|
||
expect(Array.isArray(els)).toBe(true);
|
||
expect(els.length).toBe(1);
|
||
expect(els[0].type).toBe('rect');
|
||
expect(payload.svg).toContain('<svg');
|
||
});
|
||
|
||
it('leeres SVG ohne Geometrie liefert null blockData (Caller nutzt {type:svg}-Fallback)', () => {
|
||
const payload = importSVGAsBlockPayload('<svg xmlns="http://www.w3.org/2000/svg"><defs/></svg>');
|
||
expect(payload.blockData).toBeNull();
|
||
expect(payload.svg).toContain('<svg');
|
||
});
|
||
});
|