import { describe, expect, test } from 'bun:test'
import {
SceneGraph,
renderNodesToSVG,
geometryBlobToSVGPath,
vectorNetworkToSVGPaths,
svg,
renderSVGNode
} 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('')
})
test('element with text child', () => {
const node = svg('text', { x: 10 }, 'Hello')
expect(renderSVGNode(node)).toBe('Hello')
})
test('nested elements', () => {
const node = svg('g', { id: 'group' }, svg('rect', { width: 10, height: 10 }))
const result = renderSVGNode(node)
expect(result).toContain('')
expect(result).toContain(' ')
expect(result).toContain('')
})
test('filters null/undefined attrs', () => {
const node = svg('rect', { width: 100, fill: undefined, stroke: null })
expect(renderSVGNode(node)).toBe('')
})
test('filters null/undefined/false children', () => {
const node = svg('g', {}, null, false, svg('rect', {}), undefined)
const result = renderSVGNode(node)
expect(result).toContain('')
})
test('escapes attribute values', () => {
const node = svg('text', { 'data-info': 'a"b {
const node = svg('text', {}, '1 < 2 & 3 > 0')
expect(renderSVGNode(node)).toBe('1 < 2 & 3 > 0')
})
})
// --- geometryBlobToSVGPath tests ---
describe('geometryBlobToSVGPath()', () => {
function makeBlob(commands: number[]): Uint8Array {
const buf = new ArrayBuffer(commands.length)
const view = new DataView(buf)
let o = 0
for (const cmd of commands) {
view.setUint8(o++, cmd)
}
return new Uint8Array(buf)
}
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('