import { createRectangle } from '@flighthq/geometry/contract'; import { getNodeChildAt, getNodeChildCount } from '@flighthq/node/contract'; import { getShapeBounds, getShapeBoundsCommand, unregisterShapeBoundsCommand } from '@flighthq/shape/contract'; import { getTextureSource } from '@flighthq/texture/contract'; import type { Sprite, ImportDiagnostic, Matrix, Node2D, RichText, Shape, TextLabel } from '@flighthq/types/contract'; import { SpriteKind, DisplayObjectKind, ImportDiagnosticSeverity, RichTextKind, ShapeKind, TextLabelKind, } from '@flighthq/types/contract'; import { createScene2DFromSvgDocument } from './svgDocument'; import { createReadyImageResourceForTest } from './testHelper'; describe('createScene2DFromSvgDocument', () => { it('explicitly registers neutral bounds for shapes imported without a renderer', () => { unregisterShapeBoundsCommand('drawPath'); expect(getShapeBoundsCommand('drawPath')).toBeNull(); const root = createScene2DFromSvgDocument(''); const shape = getNodeChildAt(root, 0) as Shape; const bounds = createRectangle(); expect(getShapeBounds(bounds, shape, 'fill')).toBe(true); expect(bounds).toMatchObject({ height: 10, width: 10, x: 3, y: 0 }); }); // ★ THE BOUNDING BOX THESE UNITS ARE DEFINED AGAINST EXCLUDES STROKE-WIDTH. Asserted as an AGREEMENT // between two routes to the same box, because that is the form the defect took: the node's own bounds // include the stroke (correct for culling and hit-testing), so a shape clipped through a parent got a // 14-wide box while the same shape clipped directly got 10. Two answers for one element, and no assertion // anywhere compared them. // // A single-route test would pass against either convention as long as it was applied consistently, which // is exactly what was NOT true here. Pinning the number as well, so the pair cannot agree on the wrong one. it('measures the objectBoundingBox without stroke, identically through a shape and through its group', () => { const clip = ``; const stroked = ` { const root = createScene2DFromSvgDocument(`${clip}${body}`); return (getNodeChildAt(root, 0) as Node2D | null)?.clip?.rect.width; }; // Half of a 10-wide geometry box. The inked extent is 14 wide, which would give 7. expect(clipWidth(`${stroked} clip-path="url(#c)"/>`)).toBeCloseTo(5, 4); expect(clipWidth(`${stroked}/>`)).toBeCloseTo(5, 4); }); // ★ THE GRADIENT BOX IS PLACED BY ITS ORIGIN, NOT ITS CENTRE. `createGradientTransformMatrix` ends with // `tx + width / 2` — it centres the box itself — so a caller that passes the midpoint gets the offset // applied twice and every gradient slides by half its own extent. That shipped: a 320-wide rect at x=60 // ramped from 220 instead of 60, measured on canvas, webgl and webgpu alike. // // Asserted on the recorded matrix rather than on pixels, so it fails in this package rather than in a // render baseline three layers away. The numbers are derived from the format, not read off the output: // an objectBoundingBox ramp x1=0→x2=1 over a rect at x=60 width=320 must span 60→380, so the box is 320 // wide and its CENTRE — which is what the matrix carries after the helper's own offset — is 220. // A gradientTransform is authored text, so `scale(0)` is a plain thing to write. Validated where the // file's value enters rather than at the renderer: `inverseMatrix` answers a singular matrix with a // defined-but-wrong result rather than NaN, so an unvalidated one paints wrong pixels silently. it('drops a singular gradientTransform and names the gradient, rather than carrying it to the renderer', () => { const diagnostics: ImportDiagnostic[] = []; createScene2DFromSvgDocument( ` `, diagnostics, ); const matches = diagnostics.filter((d) => d.kind === 'svg.gradient-transform-singular'); expect(matches).toHaveLength(1); expect(matches[0].severity).toBe('Recover'); expect(matches[0].detail).toMatchObject({ gradient: 'collapsed' }); }); it('keeps an invertible gradientTransform and reports nothing', () => { const diagnostics: ImportDiagnostic[] = []; createScene2DFromSvgDocument( ` `, diagnostics, ); expect(diagnostics.filter((d) => d.kind === 'svg.gradient-transform-singular')).toHaveLength(0); }); it('places a linear gradient box by its origin, spanning the shape rather than starting at its middle', () => { const root = createScene2DFromSvgDocument( ` `, ); const shape = getNodeChildAt(root, 0) as Shape; const commands = shape.data.commands; const matrix = commands[commands.indexOf('beginGradientFill') + 6] as Matrix; expect(matrix.a * 1638.4).toBeCloseTo(320, 4); expect(matrix.tx).toBeCloseTo(220, 4); }); // objectBoundingBox units on geometry with no width or height mean the effect is IGNORED — the same rule // the clip path already follows, defined once for every consumer of these units. So a zero-area shape // paints no gradient and says NOTHING: this is correct rendering of a correct file, not a failure. // // The second assertion is the one worth having. "Ignored" and "unresolved" both end with no gradient // painted, and the natural implementation — dropping the resolved gradient — routes conformant input into // `svg.unresolved-fill-gradient`, a Drop that blames the file for something the format prescribes. That is // the mistake this test exists to catch, and it caught it while this was being written. it('ignores an objectBoundingBox gradient on a zero-area shape without calling it unresolved', () => { const gradient = ``; const diagnostics: ImportDiagnostic[] = []; createScene2DFromSvgDocument(`${gradient}`, diagnostics); expect(diagnostics.map((diagnostic) => diagnostic.kind)).toEqual([]); }); // A clean parse is two claims: the values are right AND THE PARSER IS NOT COMPLAINING. Every other test // here checks the first. This checks the second — the one that catches an importer that produced a // plausible-looking tree while telling nobody it could not read part of the input. // // Skip is excluded rather than the list asserted empty: SVG is a large format and a well-formed document // may use an element this importer does not model, which is correct behaviour on correct input. What must // not appear is anything of higher severity. it('raises no data-integrity diagnostic for a well-formed document', () => { const diagnostics: ImportDiagnostic[] = []; createScene2DFromSvgDocument( ` `, diagnostics, ); const integrity = diagnostics.filter((diagnostic) => diagnostic.severity !== ImportDiagnosticSeverity.Skip); expect( integrity.map((diagnostic) => diagnostic.kind), `a good SVG made the importer complain: ${integrity.map((d) => d.kind).join(', ')}`, ).toEqual([]); }); it('applies object-bounding-box clip and mask content units to target geometry', () => { const root = createScene2DFromSvgDocument(` `); const clipped = getNodeChildAt(root, 0)!; expect(clipped.clip?.rect).toMatchObject({ height: 100, width: 100, x: 0, y: 0 }); const masked = getNodeChildAt(root, 1)!; expect(masked.clip?.rect).toMatchObject({ height: 100, width: 100, x: 50, y: 0 }); }); it('applies object-bounding-box clips in image-local geometry', () => { const diagnostics: ImportDiagnostic[] = []; const image = createReadyImageResourceForTest(20, 10); const root = createScene2DFromSvgDocument( ` Unmeasured `, diagnostics, { resolveImageResource: () => image }, ); const bitmap = getNodeChildAt(root, 0) as Sprite; expect(bitmap.clip?.rect).toMatchObject({ height: 10, width: 10, x: 0, y: 0 }); expect(bitmap.scaleX * (bitmap.clip?.rect.width ?? 0)).toBe(100); expect(bitmap.scaleY * (bitmap.clip?.rect.height ?? 0)).toBe(100); expect(getNodeChildAt(root, 1)?.clip?.rect).toMatchObject({ height: 100, width: 100, x: 0, y: 0 }); expect(getNodeChildAt(root, 2)?.clip?.rect).toMatchObject({ height: 100, width: 100, x: 0, y: 0 }); expect(getNodeChildAt(root, 3)?.clip).toBeNull(); // Reported because the bounds are UNMEASURABLE — this importer performs no text layout, so the format // defines a box here that we cannot compute. A zero-area target reaches the same code path and is // deliberately silent instead, because there the dropped clip is the correct rendering. expect(diagnostics).toContainEqual({ detail: { id: 'half' }, kind: 'svg.object-bounding-box-clip-unmeasurable-bounds', origin: 'applySvgElementClip', severity: 'Skip', }); }); it('applies root presentation to descendants', () => { const root = createScene2DFromSvgDocument(''); const shape = getNodeChildAt(root, 0) as Shape; expect(shape.data.commands).toContain(0xff0000ff); }); it('parses HSL colors and percentage alpha components', () => { const root = createScene2DFromSvgDocument( '', ); const shape = getNodeChildAt(root, 0) as Shape; const fill = shape.data.commands.indexOf('beginFill'); const stroke = shape.data.commands.indexOf('lineStyle'); expect(shape.data.commands.slice(fill + 2, fill + 4)).toEqual([0x008000ff, 0.5]); expect(shape.data.commands.slice(stroke + 3, stroke + 5)).toEqual([0x0000ffff, 0.25]); }); it('resolves currentColor in gradient stops', () => { const root = createScene2DFromSvgDocument(` `); const shape = getNodeChildAt(root, 0) as Shape; const gradient = shape.data.commands.indexOf('beginGradientFill'); expect(shape.data.commands[gradient + 3]).toEqual([0x123456ff, 0xabcdefff]); }); it('composes image geometry before its SVG transform', () => { const image = createReadyImageResourceForTest(20, 10); const root = createScene2DFromSvgDocument( '', undefined, { resolveImageResource: () => image }, ); const bitmap = getNodeChildAt(root, 0) as Sprite; expect(bitmap.x).toBe(13); expect(bitmap.y).toBe(24); expect(bitmap.scaleX).toBe(2); expect(bitmap.scaleY).toBe(3); }); it('maps image intrinsic dimensions with default preserveAspectRatio and explicit none', () => { const image = createReadyImageResourceForTest(20, 10); const root = createScene2DFromSvgDocument( ` `, undefined, { resolveImageResource: () => image }, ); expect(getNodeChildAt(root, 0)).toMatchObject({ scaleX: 5, scaleY: 5, x: 10, y: 45 }); expect(getNodeChildAt(root, 1)).toMatchObject({ scaleX: 5, scaleY: 10, x: 10, y: 20 }); }); it('composes authored geometry before transforms uniformly across element types', () => { const image = createReadyImageResourceForTest(10, 10); const root = createScene2DFromSvgDocument( ` T `, undefined, { resolveImageResource: () => image }, ); const shape = getNodeChildAt(root, 0) as Shape; const shapeBounds = createRectangle(); getShapeBounds(shapeBounds, shape); expect(shape.x + shapeBounds.x).toBe(13); expect((getNodeChildAt(root, 1) as Sprite).x).toBe(13); expect(getNodeChildAt(root, 2)?.x).toBe(13); expect((getNodeChildAt(root, 3) as TextLabel).x).toBe(13); }); it('composes use placement before its SVG transform', () => { const root = createScene2DFromSvgDocument(` `); const use = getNodeChildAt(root, 0)!; expect(use.x).toBe(14); expect(use.y).toBe(18); expect(use.scaleX).toBe(2); expect(use.scaleY).toBe(2); }); it('diagnoses applied filters and nested animation at their use sites', () => { const diagnostics: ImportDiagnostic[] = []; createScene2DFromSvgDocument( ` `, diagnostics, ); expect(diagnostics.map((diagnostic) => diagnostic.kind)).toEqual([ 'svg.unsupported-filter', 'svg.unsupported-animate', ]); }); it('inherits fill-rule through groups', () => { const root = createScene2DFromSvgDocument(` `); const group = getNodeChildAt(root, 0)!; const shape = getNodeChildAt(group, 0) as Shape; const drawPathIndex = shape.data.commands.indexOf('drawPath'); expect(shape.data.commands[drawPathIndex + 4]).toBe('evenOdd'); }); it('honors clip-rule winding and diagnoses mixed clip child winding', () => { const diagnostics: ImportDiagnostic[] = []; const root = createScene2DFromSvgDocument( ` `, diagnostics, ); expect(getNodeChildAt(root, 0)?.clip?.winding).toBe('evenOdd'); expect(getNodeChildAt(root, 2)?.clip?.winding).toBe('nonZero'); expect(diagnostics).toContainEqual({ detail: { id: 'mixed' }, kind: 'svg.mixed-clip-rule', origin: 'createSvgClipRegion', severity: 'Recover', }); }); it('instantiates use geometry inside clip paths with placement and transforms', () => { const root = createScene2DFromSvgDocument(` `); expect(getNodeChildAt(root, 0)?.clip?.rect).toMatchObject({ height: 20, width: 20, x: 10, y: 0 }); }); it('honors display suppression and descendant visibility overrides in clip geometry', () => { const root = createScene2DFromSvgDocument(` `); expect(getNodeChildAt(root, 0)?.clip?.rect).toMatchObject({ height: 10, width: 10, x: 10, y: 0 }); expect(getNodeChildAt(root, 1)?.clip?.rect).toMatchObject({ height: 10, width: 10, x: 20, y: 0 }); }); it('lets author CSS outrank presentation attributes by specificity and source order', () => { const root = createScene2DFromSvgDocument(` `); const shape = getNodeChildAt(root, 0) as Shape; expect(shape.data.commands).toContain(0xffa500ff); expect(shape.data.commands).not.toContain(0x0000ffff); }); it('preserves mixed text and tspan source order', () => { const root = createScene2DFromSvgDocument('ABC'); const text = getNodeChildAt(root, 0) as RichText; expect(text.data.text).toBe('ABC'); expect(text.data.textFormatRanges).toHaveLength(1); expect(text.data.textFormatRanges[0]).toMatchObject({ end: 2, start: 1 }); expect(text.data.textFormatRanges[0].format.color).toBe(0xff0000ff); }); it('preserves meaningful collapsed whitespace across mixed text runs', () => { const root = createScene2DFromSvgDocument( 'Hello world!', ); const text = getNodeChildAt(root, 0) as RichText; expect(text.data.text).toBe('Hello world!'); expect(text.data.textFormatRanges[0]).toMatchObject({ end: 11, start: 6 }); }); it('returns an empty tree and a structured diagnostic for non-SVG input', () => { const diagnostics: ImportDiagnostic[] = []; const root = createScene2DFromSvgDocument('', diagnostics); expect(root.kind).toBe(DisplayObjectKind); expect(getNodeChildCount(root)).toBe(0); expect(diagnostics).toEqual([ { detail: undefined, kind: 'svg.invalid-document', origin: 'createScene2DFromSvgDocument', severity: 'Reject', }, ]); }); it('imports grouped geometry with inherited CSS presentation and transforms', () => { const root = createScene2DFromSvgDocument(` `); expect(root.name).toBe('art'); expect(root.scaleX).toBe(2); expect(root.scaleY).toBe(2); const group = getNodeChildAt(root, 0)!; expect(group.name).toBe('layer'); expect(group.alpha).toBe(0.5); expect(group.x).toBe(4); expect(group.y).toBe(5); const shape = getNodeChildAt(group, 0) as Shape; expect(shape.kind).toBe(ShapeKind); expect(shape.name).toBe('mark'); expect(shape.data.commands).toContain('beginFill'); expect(shape.data.commands).toContain('lineStyle'); expect(shape.data.commands).toContain('drawPath'); expect(shape.data.commands).toContain(0x123456ff); expect(shape.data.commands).toContain(0xff0000ff); }); it('imports gradients, use references, text, clips, and mask degradation', () => { const diagnostics: ImportDiagnostic[] = []; const root = createScene2DFromSvgDocument( ` Hello `, diagnostics, ); const use = getNodeChildAt(root, 0)!; expect(use.name).toBe('first'); expect(use.x).toBe(7); expect(use.y).toBe(9); expect(use.clip?.contours).not.toBeNull(); const symbol = getNodeChildAt(use, 0)!; const gradientShape = getNodeChildAt(symbol, 0) as Shape; const gradientCommandIndex = gradientShape.data.commands.indexOf('beginGradientFill'); expect(gradientCommandIndex).toBeGreaterThanOrEqual(0); expect(gradientShape.data.commands[gradientCommandIndex + 3]).toEqual([0xff0000ff, 0x0000ffff]); const text = getNodeChildAt(root, 1) as TextLabel; expect(text.kind).toBe(TextLabelKind); expect(text.name).toBe('caption'); expect(text.data.text).toBe('Hello'); expect(text.data.textFormat.font).toBe('Inter'); expect(text.data.textFormat.size).toBe(14); expect(text.x).toBe(12); expect(text.y).toBe(16); const masked = getNodeChildAt(root, 2)!; expect(masked.clip).not.toBeNull(); expect(diagnostics).toContainEqual({ detail: { id: 'matte' }, kind: 'svg.mask-as-hard-clip', origin: 'applySvgElementClip', severity: 'Recover', }); }); it('imports every common SVG geometry element and skips live-document features loudly', () => { const diagnostics: ImportDiagnostic[] = []; const root = createScene2DFromSvgDocument( `
`, diagnostics, ); expect(getNodeChildCount(root)).toBe(6); // `image-resolver-unwired`, not `unresolved-image`: this document wires no resolveImageResource hook // at all, which is an unwired host integration rather than a fault in the file. The split exists so a // caller stops reading "your file lost data" when they simply have not wired the seam. expect(diagnostics.map((diagnostic) => diagnostic.kind)).toEqual([ 'svg.image-resolver-unwired', 'svg.unsupported-foreignObject', ]); }); it('resolves image resources through an explicit no-I/O seam', () => { const image = createReadyImageResourceForTest(20, 10); const root = createScene2DFromSvgDocument( '', undefined, { resolveImageResource: (href) => (href === 'asset.png' ? image : null) }, ); const bitmap = getNodeChildAt(root, 0) as Sprite; expect(bitmap.kind).toBe(SpriteKind); expect(bitmap.name).toBe('photo'); expect(bitmap.data.texture === null ? null : getTextureSource(bitmap.data.texture)).toBe(image); expect(bitmap.x).toBe(3); expect(bitmap.y).toBe(4); expect(bitmap.scaleX).toBe(2); expect(bitmap.scaleY).toBe(3); }); it('retains tspan style runs and diagnoses positional flattening', () => { const diagnostics: ImportDiagnostic[] = []; const root = createScene2DFromSvgDocument( ` Red Blue `, diagnostics, ); const text = getNodeChildAt(root, 0) as RichText; expect(text.kind).toBe(RichTextKind); expect(text.data.text).toBe('Red Blue'); expect(text.data.textFormatRanges).toHaveLength(2); expect(text.data.textFormatRanges[0].format.color).toBe(0xff0000ff); expect(text.data.textFormatRanges[1].format.color).toBe(0x0000ffff); expect(text.data.textFormatRanges[1].format.bold).toBe(true); expect(text.x).toBe(4); expect(text.y).toBe(8); expect(diagnostics).toContainEqual({ detail: { count: 2 }, kind: 'svg.tspan-position-flattened', origin: 'createSvgTextNode', severity: 'Recover', }); }); it('composes text placement and baseline before its SVG transform', () => { const root = createScene2DFromSvgDocument( 'Hello', ); const text = getNodeChildAt(root, 0) as TextLabel; expect(text.x).toBe(13); expect(text.y).toBe(15); }); it('honors preserveAspectRatio and forward gradient inheritance', () => { const root = createScene2DFromSvgDocument(` `); expect(root.scaleX).toBe(2); expect(root.scaleY).toBe(2); expect(root.x).toBe(50); const shape = getNodeChildAt(root, 0) as Shape; const gradientCommandIndex = shape.data.commands.indexOf('beginGradientFill'); expect(shape.data.commands[gradientCommandIndex + 3]).toEqual([0xff0000ff, 0x0000ffff]); const stretched = createScene2DFromSvgDocument( '', ); expect(stretched.scaleX).toBe(4); expect(stretched.scaleY).toBe(2); expect(stretched.x).toBe(0); }); it('uses symbol viewBox semantics and the use viewport size', () => { const root = createScene2DFromSvgDocument(` `); const use = getNodeChildAt(root, 0)!; const symbol = getNodeChildAt(use, 0)!; expect(symbol.scaleX).toBe(2); expect(symbol.scaleY).toBe(3); }); it('reports unsupported animation inside an instantiated symbol', () => { const diagnostics: ImportDiagnostic[] = []; createScene2DFromSvgDocument( ` `, diagnostics, ); expect(diagnostics).toContainEqual({ detail: { element: 'animate' }, kind: 'svg.unsupported-animate', // Reported by the sweep for elements a symbol instantiation never visited, not by the node // builder — which is the distinction the origin field exists to carry. origin: 'reportRemainingUnsupportedSvgElements', severity: 'Skip', }); }); });