- Strip tools/schema.ts from 1831 to 65 lines (remove duplicate tool defs) - Fix tools/index.ts to export ALL_TOOLS from registry instead of schema - Fix renderJsx → renderJSX typo in tools/create.ts and structure.ts - Add geometry.ts with shared rotation/coordinate math utilities - Add isArrayMixed() helper to use-multi-props.ts, deduplicate FillSection/StrokeSection/EffectsSection - Fix broken test imports: use @open-pencil/core instead of relative paths into packages/core/src/ - Merge duplicate import statements across all test files - Export profiler internals and style-run helpers from core index.ts - Add 6 new test files: color, undo, snap, vector, style-runs, text-editor (98 new tests) - Update renderer-effects test for renderer/ directory split
317 lines
10 KiB
TypeScript
317 lines
10 KiB
TypeScript
import { describe, test, expect } from 'bun:test'
|
|
|
|
import { SceneGraph } from '@open-pencil/core'
|
|
|
|
function pageId(graph: SceneGraph) {
|
|
return graph.getPages()[0].id
|
|
}
|
|
|
|
function rect(
|
|
graph: SceneGraph,
|
|
parentId: string,
|
|
props: { name?: string; x?: number; y?: number; width?: number; height?: number } = {}
|
|
) {
|
|
return graph.createNode('RECTANGLE', parentId, {
|
|
name: props.name ?? 'Rect',
|
|
x: props.x ?? 0,
|
|
y: props.y ?? 0,
|
|
width: props.width ?? 50,
|
|
height: props.height ?? 50
|
|
})
|
|
}
|
|
|
|
describe('flip', () => {
|
|
test('flipX defaults to false', () => {
|
|
const graph = new SceneGraph()
|
|
const node = rect(graph, pageId(graph))
|
|
expect(node.flipX).toBe(false)
|
|
expect(node.flipY).toBe(false)
|
|
})
|
|
|
|
test('flipX can be set via updateNode', () => {
|
|
const graph = new SceneGraph()
|
|
const node = rect(graph, pageId(graph))
|
|
graph.updateNode(node.id, { flipX: true })
|
|
expect(graph.getNode(node.id)!.flipX).toBe(true)
|
|
expect(graph.getNode(node.id)!.flipY).toBe(false)
|
|
})
|
|
|
|
test('flipY can be set via updateNode', () => {
|
|
const graph = new SceneGraph()
|
|
const node = rect(graph, pageId(graph))
|
|
graph.updateNode(node.id, { flipY: true })
|
|
expect(graph.getNode(node.id)!.flipX).toBe(false)
|
|
expect(graph.getNode(node.id)!.flipY).toBe(true)
|
|
})
|
|
|
|
test('flip toggles', () => {
|
|
const graph = new SceneGraph()
|
|
const node = rect(graph, pageId(graph))
|
|
graph.updateNode(node.id, { flipX: true })
|
|
expect(graph.getNode(node.id)!.flipX).toBe(true)
|
|
graph.updateNode(node.id, { flipX: false })
|
|
expect(graph.getNode(node.id)!.flipX).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('single-node alignment to parent', () => {
|
|
function setup() {
|
|
const graph = new SceneGraph()
|
|
const frame = graph.createNode('FRAME', pageId(graph), {
|
|
name: 'Container',
|
|
x: 0,
|
|
y: 0,
|
|
width: 400,
|
|
height: 300
|
|
})
|
|
const child = rect(graph, frame.id, { name: 'Child', x: 50, y: 50, width: 100, height: 80 })
|
|
return { graph, frame, child }
|
|
}
|
|
|
|
test('align left within parent', () => {
|
|
const { graph, frame, child } = setup()
|
|
graph.updateNode(child.id, { x: 0 })
|
|
expect(graph.getNode(child.id)!.x).toBe(0)
|
|
})
|
|
|
|
test('align right within parent', () => {
|
|
const { graph, frame, child } = setup()
|
|
const targetX = frame.width - child.width
|
|
graph.updateNode(child.id, { x: targetX })
|
|
expect(graph.getNode(child.id)!.x).toBe(300)
|
|
})
|
|
|
|
test('align center horizontal within parent', () => {
|
|
const { graph, frame, child } = setup()
|
|
const targetX = (frame.width - child.width) / 2
|
|
graph.updateNode(child.id, { x: targetX })
|
|
expect(graph.getNode(child.id)!.x).toBe(150)
|
|
})
|
|
|
|
test('align top within parent', () => {
|
|
const { graph, child } = setup()
|
|
graph.updateNode(child.id, { y: 0 })
|
|
expect(graph.getNode(child.id)!.y).toBe(0)
|
|
})
|
|
|
|
test('align bottom within parent', () => {
|
|
const { graph, frame, child } = setup()
|
|
const targetY = frame.height - child.height
|
|
graph.updateNode(child.id, { y: targetY })
|
|
expect(graph.getNode(child.id)!.y).toBe(220)
|
|
})
|
|
|
|
test('align center vertical within parent', () => {
|
|
const { graph, frame, child } = setup()
|
|
const targetY = (frame.height - child.height) / 2
|
|
graph.updateNode(child.id, { y: targetY })
|
|
expect(graph.getNode(child.id)!.y).toBe(110)
|
|
})
|
|
})
|
|
|
|
describe('multi-node alignment', () => {
|
|
function setup() {
|
|
const graph = new SceneGraph()
|
|
const page = pageId(graph)
|
|
const a = rect(graph, page, { name: 'A', x: 10, y: 20, width: 50, height: 30 })
|
|
const b = rect(graph, page, { name: 'B', x: 100, y: 80, width: 60, height: 40 })
|
|
const c = rect(graph, page, { name: 'C', x: 200, y: 50, width: 40, height: 50 })
|
|
return { graph, a, b, c }
|
|
}
|
|
|
|
test('align left — all nodes move to min x', () => {
|
|
const { graph, a, b, c } = setup()
|
|
const nodes = [a, b, c].map((n) => graph.getNode(n.id)!)
|
|
const abs = nodes.map((n) => graph.getAbsolutePosition(n.id))
|
|
const minX = Math.min(...abs.map((p, i) => p.x))
|
|
|
|
for (const n of nodes) {
|
|
const nodeAbs = graph.getAbsolutePosition(n.id)
|
|
graph.updateNode(n.id, { x: n.x + (minX - nodeAbs.x) })
|
|
}
|
|
|
|
expect(graph.getNode(a.id)!.x).toBe(10)
|
|
expect(graph.getNode(b.id)!.x).toBe(10)
|
|
expect(graph.getNode(c.id)!.x).toBe(10)
|
|
})
|
|
|
|
test('align right — all nodes align to max right edge', () => {
|
|
const { graph, a, b, c } = setup()
|
|
const nodes = [a, b, c].map((n) => graph.getNode(n.id)!)
|
|
const abs = nodes.map((n) => graph.getAbsolutePosition(n.id))
|
|
const maxX = Math.max(...abs.map((p, i) => p.x + nodes[i].width))
|
|
|
|
for (const n of nodes) {
|
|
const nodeAbs = graph.getAbsolutePosition(n.id)
|
|
const targetX = maxX - n.width
|
|
graph.updateNode(n.id, { x: n.x + (targetX - nodeAbs.x) })
|
|
}
|
|
|
|
expect(graph.getNode(a.id)!.x).toBe(190)
|
|
expect(graph.getNode(b.id)!.x).toBe(180)
|
|
expect(graph.getNode(c.id)!.x).toBe(200)
|
|
})
|
|
|
|
test('align top — all nodes move to min y', () => {
|
|
const { graph, a, b, c } = setup()
|
|
const nodes = [a, b, c].map((n) => graph.getNode(n.id)!)
|
|
const abs = nodes.map((n) => graph.getAbsolutePosition(n.id))
|
|
const minY = Math.min(...abs.map((p) => p.y))
|
|
|
|
for (const n of nodes) {
|
|
const nodeAbs = graph.getAbsolutePosition(n.id)
|
|
graph.updateNode(n.id, { y: n.y + (minY - nodeAbs.y) })
|
|
}
|
|
|
|
expect(graph.getNode(a.id)!.y).toBe(20)
|
|
expect(graph.getNode(b.id)!.y).toBe(20)
|
|
expect(graph.getNode(c.id)!.y).toBe(20)
|
|
})
|
|
})
|
|
|
|
describe('multi-node property merging', () => {
|
|
test('same values merge to single value', () => {
|
|
const graph = new SceneGraph()
|
|
const page = pageId(graph)
|
|
const a = rect(graph, page, { width: 100, height: 50 })
|
|
const b = rect(graph, page, { width: 100, height: 50 })
|
|
const nodes = [graph.getNode(a.id)!, graph.getNode(b.id)!]
|
|
|
|
const widths = new Set(nodes.map((n) => n.width))
|
|
expect(widths.size).toBe(1)
|
|
expect([...widths][0]).toBe(100)
|
|
})
|
|
|
|
test('different values are detected', () => {
|
|
const graph = new SceneGraph()
|
|
const page = pageId(graph)
|
|
const a = rect(graph, page, { width: 100 })
|
|
const b = rect(graph, page, { width: 200 })
|
|
const nodes = [graph.getNode(a.id)!, graph.getNode(b.id)!]
|
|
|
|
const widths = new Set(nodes.map((n) => n.width))
|
|
expect(widths.size).toBe(2)
|
|
})
|
|
|
|
test('update applies to all selected nodes', () => {
|
|
const graph = new SceneGraph()
|
|
const page = pageId(graph)
|
|
const a = rect(graph, page, { width: 100 })
|
|
const b = rect(graph, page, { width: 200 })
|
|
const c = rect(graph, page, { width: 150 })
|
|
|
|
for (const id of [a.id, b.id, c.id]) {
|
|
graph.updateNode(id, { width: 300 })
|
|
}
|
|
|
|
expect(graph.getNode(a.id)!.width).toBe(300)
|
|
expect(graph.getNode(b.id)!.width).toBe(300)
|
|
expect(graph.getNode(c.id)!.width).toBe(300)
|
|
})
|
|
|
|
test('opacity update applies uniformly', () => {
|
|
const graph = new SceneGraph()
|
|
const page = pageId(graph)
|
|
const a = rect(graph, page)
|
|
const b = rect(graph, page)
|
|
|
|
graph.updateNode(a.id, { opacity: 0.5 })
|
|
graph.updateNode(b.id, { opacity: 0.8 })
|
|
|
|
for (const id of [a.id, b.id]) {
|
|
graph.updateNode(id, { opacity: 0.75 })
|
|
}
|
|
|
|
expect(graph.getNode(a.id)!.opacity).toBe(0.75)
|
|
expect(graph.getNode(b.id)!.opacity).toBe(0.75)
|
|
})
|
|
|
|
test('fills comparison detects mixed', () => {
|
|
const graph = new SceneGraph()
|
|
const page = pageId(graph)
|
|
const a = rect(graph, page)
|
|
const b = rect(graph, page)
|
|
|
|
graph.updateNode(a.id, {
|
|
fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
|
|
})
|
|
graph.updateNode(b.id, {
|
|
fills: [{ type: 'SOLID', color: { r: 0, g: 1, b: 0, a: 1 }, opacity: 1, visible: true }]
|
|
})
|
|
|
|
const nodeA = graph.getNode(a.id)!
|
|
const nodeB = graph.getNode(b.id)!
|
|
expect(JSON.stringify(nodeA.fills)).not.toBe(JSON.stringify(nodeB.fills))
|
|
})
|
|
|
|
test('fills comparison detects same', () => {
|
|
const graph = new SceneGraph()
|
|
const page = pageId(graph)
|
|
const fill = { type: 'SOLID' as const, color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }
|
|
const a = rect(graph, page)
|
|
const b = rect(graph, page)
|
|
|
|
graph.updateNode(a.id, { fills: [{ ...fill }] })
|
|
graph.updateNode(b.id, { fills: [{ ...fill }] })
|
|
|
|
const nodeA = graph.getNode(a.id)!
|
|
const nodeB = graph.getNode(b.id)!
|
|
expect(JSON.stringify(nodeA.fills)).toBe(JSON.stringify(nodeB.fills))
|
|
})
|
|
})
|
|
|
|
describe('flip roundtrip via kiwi', () => {
|
|
async function roundtrip(graph: SceneGraph) {
|
|
const { initCodec } = await import('../../packages/core/src/kiwi/codec')
|
|
const { parseFigFile } = await import('../../packages/core/src/kiwi/fig-file')
|
|
const { exportFigFile } = await import('../../packages/core/src/fig-export')
|
|
await initCodec()
|
|
const buf = await exportFigFile(graph)
|
|
return parseFigFile(buf)
|
|
}
|
|
|
|
function findChild(graph: SceneGraph, name: string) {
|
|
const pages = graph.getPages()
|
|
const children = graph.getChildren(pages[0].id)
|
|
return children.find((c) => c.name === name)
|
|
}
|
|
|
|
test('flipX preserved through export/import', async () => {
|
|
const graph = new SceneGraph()
|
|
const page = pageId(graph)
|
|
rect(graph, page, { name: 'Flipped', x: 50, y: 50, width: 100, height: 80 })
|
|
graph.updateNode(graph.getChildren(page).find((n) => n.name === 'Flipped')!.id, { flipX: true })
|
|
|
|
const imported = await roundtrip(graph)
|
|
const found = findChild(imported, 'Flipped')
|
|
expect(found).toBeTruthy()
|
|
expect(found!.flipX).toBe(true)
|
|
})
|
|
|
|
test('non-flipped node stays non-flipped', async () => {
|
|
const graph = new SceneGraph()
|
|
rect(graph, pageId(graph), { name: 'Normal', x: 100, y: 50, width: 200, height: 100 })
|
|
|
|
const imported = await roundtrip(graph)
|
|
const found = findChild(imported, 'Normal')
|
|
expect(found).toBeTruthy()
|
|
expect(found!.flipX).toBe(false)
|
|
})
|
|
|
|
test('flipX with rotation preserved', async () => {
|
|
const graph = new SceneGraph()
|
|
const page = pageId(graph)
|
|
rect(graph, page, { name: 'RotFlip', x: 0, y: 0, width: 100, height: 100 })
|
|
graph.updateNode(graph.getChildren(page).find((n) => n.name === 'RotFlip')!.id, {
|
|
flipX: true,
|
|
rotation: 45
|
|
})
|
|
|
|
const imported = await roundtrip(graph)
|
|
const found = findChild(imported, 'RotFlip')
|
|
expect(found).toBeTruthy()
|
|
expect(found!.flipX).toBe(true)
|
|
expect(Math.round(found!.rotation)).toBe(45)
|
|
})
|
|
})
|