openpencil/tests/engine/svg/export.test.ts
Danila Poyarkov 9e4d7bee5d chore(lint): fix unused test code
- Remove stale imports and unused locals from split engine and e2e tests
- Drop the targeted no-unused-vars test allowances
- Keep check and affected test suites warning-free
2026-05-06 02:53:19 +03:00

766 lines
23 KiB
TypeScript

import { describe, expect, test } from 'bun:test'
import {
SceneGraph,
geometryBlobToSVGPath,
renderNodesToSVG,
renderSVGNode,
svg,
vectorNetworkToSVGPaths
} from '@open-pencil/core'
function makeGraph() {
const graph = new SceneGraph()
graph.createNode('CANVAS', graph.rootId, { name: 'Page 1' })
return graph
}
function pageId(graph: SceneGraph) {
return graph.getPages()[0].id
}
function exportSVG(graph: SceneGraph, nodeIds: string[], xmlDecl = false): string | null {
return renderNodesToSVG(graph, pageId(graph), nodeIds, { xmlDeclaration: xmlDecl })
}
// --- SVGNode builder tests ---
describe('svg() and renderSVGNode()', () => {
test('self-closing element', () => {
const node = svg('rect', { width: 100, height: 50 })
expect(renderSVGNode(node)).toBe('<rect width="100" height="50"/>')
})
test('element with text child', () => {
const node = svg('text', { x: 10 }, 'Hello')
expect(renderSVGNode(node)).toBe('<text x="10">Hello</text>')
})
test('nested elements', () => {
const node = svg('g', { id: 'group' }, svg('rect', { width: 10, height: 10 }))
const result = renderSVGNode(node)
expect(result).toContain('<g id="group">')
expect(result).toContain(' <rect width="10" height="10"/>')
expect(result).toContain('</g>')
})
test('filters null/undefined attrs', () => {
const node = svg('rect', { width: 100, fill: undefined, stroke: null })
expect(renderSVGNode(node)).toBe('<rect width="100"/>')
})
test('filters null/undefined/false children', () => {
const node = svg('g', {}, null, false, svg('rect', {}), undefined)
const result = renderSVGNode(node)
expect(result).toContain('<rect/>')
})
test('escapes attribute values', () => {
const node = svg('text', { 'data-info': 'a"b<c' })
expect(renderSVGNode(node)).toContain('data-info="a&quot;b&lt;c"')
})
test('escapes text content', () => {
const node = svg('text', {}, '1 < 2 & 3 > 0')
expect(renderSVGNode(node)).toBe('<text>1 &lt; 2 &amp; 3 &gt; 0</text>')
})
})
// --- geometryBlobToSVGPath tests ---
describe('geometryBlobToSVGPath()', () => {
function makeBlobWithFloats(ops: Array<{ cmd: number; floats?: number[] }>): Uint8Array {
let size = 0
for (const op of ops) {
size += 1 + (op.floats?.length ?? 0) * 4
}
const buf = new ArrayBuffer(size)
const view = new DataView(buf)
let o = 0
for (const op of ops) {
view.setUint8(o, op.cmd)
o += 1
if (op.floats) {
for (const f of op.floats) {
view.setFloat32(o, f, true)
o += 4
}
}
}
return new Uint8Array(buf)
}
test('empty blob', () => {
expect(geometryBlobToSVGPath(new Uint8Array(0))).toBe('')
})
test('move + line + close', () => {
const blob = makeBlobWithFloats([
{ cmd: 1, floats: [10, 20] },
{ cmd: 2, floats: [30, 40] },
{ cmd: 0 }
])
expect(geometryBlobToSVGPath(blob)).toBe('M10 20L30 40Z')
})
test('cubic bezier', () => {
const blob = makeBlobWithFloats([
{ cmd: 1, floats: [0, 0] },
{ cmd: 4, floats: [10, 0, 20, 10, 30, 30] },
{ cmd: 0 }
])
const result = geometryBlobToSVGPath(blob)
expect(result).toContain('M0 0')
expect(result).toContain('C10 0 20 10 30 30')
expect(result).toContain('Z')
})
})
// --- vectorNetworkToSVGPaths tests ---
describe('vectorNetworkToSVGPaths()', () => {
test('single straight segment', () => {
const paths = vectorNetworkToSVGPaths({
vertices: [
{ x: 0, y: 0 },
{ x: 100, y: 100 }
],
segments: [
{
start: 0,
end: 1,
tangentStart: { x: 0, y: 0 },
tangentEnd: { x: 0, y: 0 }
}
],
regions: []
})
expect(paths).toHaveLength(1)
expect(paths[0]).toContain('M0 0')
expect(paths[0]).toContain('L100 100')
})
test('curved segment', () => {
const paths = vectorNetworkToSVGPaths({
vertices: [
{ x: 0, y: 0 },
{ x: 100, y: 0 }
],
segments: [
{
start: 0,
end: 1,
tangentStart: { x: 0, y: 50 },
tangentEnd: { x: 0, y: 50 }
}
],
regions: []
})
expect(paths).toHaveLength(1)
expect(paths[0]).toContain('C')
})
test('region with loop', () => {
const paths = vectorNetworkToSVGPaths({
vertices: [
{ x: 0, y: 0 },
{ x: 100, y: 0 },
{ x: 50, y: 100 }
],
segments: [
{ start: 0, end: 1, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } },
{ start: 1, end: 2, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } },
{ start: 2, end: 0, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } }
],
regions: [{ windingRule: 'NONZERO', loops: [[0, 1, 2]] }]
})
expect(paths).toHaveLength(1)
expect(paths[0]).toContain('M0 0')
expect(paths[0]).toContain('Z')
})
test('empty network', () => {
const paths = vectorNetworkToSVGPaths({ vertices: [], segments: [], regions: [] })
expect(paths).toHaveLength(0)
})
})
// --- Full SVG export tests ---
describe('renderNodesToSVG()', () => {
test('returns null for empty selection', () => {
const graph = makeGraph()
expect(exportSVG(graph, [])).toBeNull()
})
test('returns null for hidden nodes', () => {
const graph = makeGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), {
width: 100,
height: 50,
visible: false
})
expect(exportSVG(graph, [node.id])).toBeNull()
})
test('basic rectangle', () => {
const graph = makeGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), {
width: 100,
height: 50,
fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
})
const result = exportSVG(graph, [node.id])!
expect(result).toContain('<svg')
expect(result).toContain('width="100"')
expect(result).toContain('height="50"')
expect(result).toContain('viewBox="0 0 100 50"')
expect(result).toContain('<rect')
expect(result).toContain('fill="#FF0000"')
})
test('xml declaration', () => {
const graph = makeGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), {
width: 10,
height: 10,
fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
})
const withDecl = renderNodesToSVG(graph, pageId(graph), [node.id], { xmlDeclaration: true })!
const withoutDecl = renderNodesToSVG(graph, pageId(graph), [node.id], {
xmlDeclaration: false
})!
expect(withDecl).toStartWith('<?xml')
expect(withoutDecl).toStartWith('<svg')
})
test('ellipse', () => {
const graph = makeGraph()
const node = graph.createNode('ELLIPSE', pageId(graph), {
width: 80,
height: 60,
fills: [{ type: 'SOLID', color: { r: 0, g: 0.5, b: 1, a: 1 }, opacity: 1, visible: true }]
})
const result = exportSVG(graph, [node.id])!
expect(result).toContain('<ellipse')
expect(result).toContain('cx="40"')
expect(result).toContain('cy="30"')
expect(result).toContain('rx="40"')
expect(result).toContain('ry="30"')
})
test('line', () => {
const graph = makeGraph()
const node = graph.createNode('LINE', pageId(graph), {
width: 100,
height: 0,
strokes: [
{
color: { r: 0, g: 0, b: 0, a: 1 },
weight: 2,
opacity: 1,
visible: true,
align: 'CENTER' as const
}
]
})
const result = exportSVG(graph, [node.id])!
expect(result).toContain('<line')
expect(result).toContain('x2="100"')
expect(result).toContain('stroke-width="2"')
})
test('star', () => {
const graph = makeGraph()
const node = graph.createNode('STAR', pageId(graph), {
width: 100,
height: 100,
pointCount: 5,
starInnerRadius: 0.382,
fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 0, a: 1 }, opacity: 1, visible: true }]
})
const result = exportSVG(graph, [node.id])!
expect(result).toContain('<polygon')
expect(result).toContain('points="')
})
test('polygon', () => {
const graph = makeGraph()
const node = graph.createNode('POLYGON', pageId(graph), {
width: 100,
height: 100,
pointCount: 6,
fills: [{ type: 'SOLID', color: { r: 0, g: 1, b: 0, a: 1 }, opacity: 1, visible: true }]
})
const result = exportSVG(graph, [node.id])!
expect(result).toContain('<polygon')
})
test('rounded rectangle', () => {
const graph = makeGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), {
width: 200,
height: 100,
cornerRadius: 16,
fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true }]
})
const result = exportSVG(graph, [node.id])!
expect(result).toContain('rx="16"')
expect(result).toContain('ry="16"')
})
test('independent corners produce path', () => {
const graph = makeGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), {
width: 100,
height: 50,
cornerRadius: 10,
independentCorners: true,
topLeftRadius: 10,
topRightRadius: 0,
bottomRightRadius: 20,
bottomLeftRadius: 5,
fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
})
const result = exportSVG(graph, [node.id])!
expect(result).toContain('<path')
expect(result).toContain('d="M10 0')
})
test('text node', () => {
const graph = makeGraph()
const node = graph.createNode('TEXT', pageId(graph), {
width: 200,
height: 24,
text: 'Hello World',
fontSize: 18,
fontWeight: 700,
fontFamily: 'Inter',
fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
})
const result = exportSVG(graph, [node.id])!
expect(result).toContain('<text')
expect(result).toContain('font-size="18"')
expect(result).toContain('font-weight="700"')
expect(result).toContain('font-family="Inter"')
expect(result).toContain('>Hello World</text>')
})
test('rtl text node exports direction and logical anchor', () => {
const graph = makeGraph()
const node = graph.createNode('TEXT', pageId(graph), {
width: 180,
height: 24,
text: 'مرحبا',
fontSize: 18,
textDirection: 'RTL',
textAlignHorizontal: 'LEFT',
fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
})
const result = exportSVG(graph, [node.id])!
expect(result).toContain('direction="rtl"')
expect(result).toContain('text-anchor="end"')
expect(result).toContain('x="180"')
})
test('text with style runs', () => {
const graph = makeGraph()
const node = graph.createNode('TEXT', pageId(graph), {
width: 200,
height: 24,
text: 'Hello Bold',
fontSize: 14,
fontFamily: 'Inter',
fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }],
styleRuns: [
{ start: 0, length: 6, style: {} },
{ start: 6, length: 4, style: { fontWeight: 700 } }
]
})
const result = exportSVG(graph, [node.id])!
expect(result).toContain('<tspan')
expect(result).toContain('font-weight="700"')
})
test('opacity', () => {
const graph = makeGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), {
width: 50,
height: 50,
opacity: 0.5,
fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
})
const result = exportSVG(graph, [node.id])!
expect(result).toContain('opacity="0.5"')
})
test('rotation', () => {
const graph = makeGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), {
width: 100,
height: 50,
rotation: 45,
fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
})
const result = exportSVG(graph, [node.id])!
expect(result).toContain('rotate(45,')
})
test('stroke with dash pattern', () => {
const graph = makeGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), {
width: 100,
height: 100,
strokes: [
{
color: { r: 0, g: 0, b: 0, a: 1 },
weight: 2,
opacity: 1,
visible: true,
align: 'CENTER' as const,
dashPattern: [5, 3]
}
]
})
const result = exportSVG(graph, [node.id])!
expect(result).toContain('stroke-dasharray="5 3"')
})
test('stroke cap and join', () => {
const graph = makeGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), {
width: 100,
height: 100,
strokes: [
{
color: { r: 0, g: 0, b: 0, a: 1 },
weight: 3,
opacity: 1,
visible: true,
align: 'CENTER' as const,
cap: 'ROUND',
join: 'BEVEL'
}
]
})
const result = exportSVG(graph, [node.id])!
expect(result).toContain('stroke-linecap="round"')
expect(result).toContain('stroke-linejoin="bevel"')
})
test('frame with children', () => {
const graph = makeGraph()
const frame = graph.createNode('FRAME', pageId(graph), {
name: 'Card',
width: 200,
height: 150,
fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true }]
})
graph.createNode('RECTANGLE', frame.id, {
width: 180,
height: 100,
x: 10,
y: 10,
fills: [{ type: 'SOLID', color: { r: 0.9, g: 0.9, b: 0.9, a: 1 }, opacity: 1, visible: true }]
})
const result = exportSVG(graph, [frame.id])!
expect(result).toContain('<g')
expect(result).toContain('translate(10, 10)')
})
test('frame with clipsContent', () => {
const graph = makeGraph()
const frame = graph.createNode('FRAME', pageId(graph), {
width: 100,
height: 100,
clipsContent: true,
fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true }]
})
graph.createNode('RECTANGLE', frame.id, {
width: 200,
height: 200,
fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
})
const result = exportSVG(graph, [frame.id])!
expect(result).toContain('<clipPath')
expect(result).toContain('clip-path="url(#clip')
})
test('hidden children excluded', () => {
const graph = makeGraph()
const frame = graph.createNode('FRAME', pageId(graph), {
width: 100,
height: 100
})
graph.createNode('RECTANGLE', frame.id, {
name: 'Visible',
width: 50,
height: 50,
fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
})
graph.createNode('RECTANGLE', frame.id, {
name: 'Hidden',
width: 50,
height: 50,
visible: false,
fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 1, a: 1 }, opacity: 1, visible: true }]
})
const result = exportSVG(graph, [frame.id])!
expect(result).toContain('fill="#FF0000"')
expect(result).not.toContain('fill="#0000FF"')
})
test('linear gradient', () => {
const graph = makeGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), {
width: 100,
height: 100,
fills: [
{
type: 'GRADIENT_LINEAR',
color: { r: 0, g: 0, b: 0, a: 1 },
opacity: 1,
visible: true,
gradientStops: [
{ position: 0, color: { r: 1, g: 0, b: 0, a: 1 } },
{ position: 1, color: { r: 0, g: 0, b: 1, a: 1 } }
],
gradientTransform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
}
]
})
const result = exportSVG(graph, [node.id])!
expect(result).toContain('<linearGradient')
expect(result).toContain('<stop')
expect(result).toContain('url(#grad')
})
test('radial gradient', () => {
const graph = makeGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), {
width: 100,
height: 100,
fills: [
{
type: 'GRADIENT_RADIAL',
color: { r: 0, g: 0, b: 0, a: 1 },
opacity: 1,
visible: true,
gradientStops: [
{ position: 0, color: { r: 1, g: 1, b: 0, a: 1 } },
{ position: 1, color: { r: 0, g: 1, b: 0, a: 1 } }
],
gradientTransform: { m00: 1, m01: 0, m02: 0.5, m10: 0, m11: 1, m12: 0.5 }
}
]
})
const result = exportSVG(graph, [node.id])!
expect(result).toContain('<radialGradient')
expect(result).toContain('url(#grad')
})
test('drop shadow effect', () => {
const graph = makeGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), {
width: 100,
height: 100,
fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true }],
effects: [
{
type: 'DROP_SHADOW',
color: { r: 0, g: 0, b: 0, a: 0.25 },
offset: { x: 0, y: 4 },
radius: 8,
spread: 0,
visible: true
}
]
})
const result = exportSVG(graph, [node.id])!
expect(result).toContain('<filter')
expect(result).toContain('<feDropShadow')
expect(result).toContain('dy="4"')
expect(result).toContain('filter="url(#fx')
})
test('layer blur effect', () => {
const graph = makeGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), {
width: 50,
height: 50,
fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }],
effects: [
{
type: 'LAYER_BLUR',
color: { r: 0, g: 0, b: 0, a: 0 },
offset: { x: 0, y: 0 },
radius: 10,
spread: 0,
visible: true
}
]
})
const result = exportSVG(graph, [node.id])!
expect(result).toContain('<feGaussianBlur')
expect(result).toContain('stdDeviation="5"')
})
test('multiple nodes', () => {
const graph = makeGraph()
const a = graph.createNode('RECTANGLE', pageId(graph), {
x: 0,
y: 0,
width: 50,
height: 50,
fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
})
const b = graph.createNode('ELLIPSE', pageId(graph), {
x: 60,
y: 0,
width: 40,
height: 40,
fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 1, a: 1 }, opacity: 1, visible: true }]
})
const result = exportSVG(graph, [a.id, b.id])!
expect(result).toContain('viewBox="0 0 100 50"')
expect(result).toContain('<rect')
expect(result).toContain('<ellipse')
})
test('blend mode', () => {
const graph = makeGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), {
width: 100,
height: 100,
blendMode: 'MULTIPLY',
fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
})
const result = exportSVG(graph, [node.id])!
expect(result).toContain('mix-blend-mode: multiply')
})
test('fill with opacity < 1', () => {
const graph = makeGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), {
width: 50,
height: 50,
fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 0.5, visible: true }]
})
const result = exportSVG(graph, [node.id])!
expect(result).toContain('fill="#FF0000')
})
test('component and instance treated as containers', () => {
const graph = makeGraph()
const comp = graph.createNode('COMPONENT', pageId(graph), {
width: 100,
height: 100,
fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true }]
})
graph.createNode('RECTANGLE', comp.id, {
width: 80,
height: 80,
x: 10,
y: 10,
fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
})
const result = exportSVG(graph, [comp.id])!
expect(result).toContain('<rect')
expect(result).toContain('translate(10, 10)')
})
test('section node', () => {
const graph = makeGraph()
const section = graph.createNode('SECTION', pageId(graph), {
width: 500,
height: 300,
fills: [
{ type: 'SOLID', color: { r: 0.95, g: 0.95, b: 0.95, a: 1 }, opacity: 1, visible: true }
]
})
const result = exportSVG(graph, [section.id])!
expect(result).toContain('<rect')
expect(result).toContain('width="500"')
})
test('flip transforms', () => {
const graph = makeGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), {
width: 100,
height: 50,
flipX: true,
fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
})
const result = exportSVG(graph, [node.id])!
expect(result).toContain('scale(-1, 1)')
})
test('stroke opacity', () => {
const graph = makeGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), {
width: 100,
height: 100,
strokes: [
{
color: { r: 1, g: 0, b: 0, a: 1 },
weight: 1,
opacity: 0.5,
visible: true,
align: 'CENTER' as const
}
]
})
const result = exportSVG(graph, [node.id])!
expect(result).toContain('stroke-opacity="0.5"')
})
test('no fill no stroke produces no output for groups', () => {
const graph = makeGraph()
const group = graph.createNode('GROUP', pageId(graph), {
width: 100,
height: 100
})
const result = exportSVG(graph, [group.id])
expect(result).toBeNull()
})
test('group with children', () => {
const graph = makeGraph()
const group = graph.createNode('GROUP', pageId(graph), {
width: 100,
height: 100
})
graph.createNode('RECTANGLE', group.id, {
width: 50,
height: 50,
fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
})
const result = exportSVG(graph, [group.id])!
expect(result).toContain('<rect')
expect(result).toContain('fill="#FF0000"')
})
test('inner shadow effect', () => {
const graph = makeGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), {
width: 100,
height: 100,
fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true }],
effects: [
{
type: 'INNER_SHADOW',
color: { r: 0, g: 0, b: 0, a: 0.5 },
offset: { x: 2, y: 2 },
radius: 4,
spread: 0,
visible: true
}
]
})
const result = exportSVG(graph, [node.id])!
expect(result).toContain('<filter')
expect(result).toContain('<feGaussianBlur')
expect(result).toContain('<feComposite')
})
})