openpencil/tests/engine/multi-selection.test.ts
Danila Poyarkov 7e06969ab5 Multi-selection properties panel with tests
Show position, appearance, fill, stroke, and effects sections
when multiple nodes are selected. Shared values display normally,
differing values show 'Mixed'. Editing applies to all selected.

- Shared composable (use-multi-props) for isMulti, active, activeNode
- ScrubInput supports MIXED symbol as model-value
- Per-node previous values for correct undo on multi-edit
- Fix flip transform encoding (scale first column only)
- Fix npm publish auth for MCP package
- 22 new tests: flip, alignment, multi-node merging, kiwi roundtrip
2026-03-03 20:35:51 +03:00

317 lines
10 KiB
TypeScript

import { describe, test, expect } from 'bun:test'
import { SceneGraph } from '../../packages/core/src/scene-graph'
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)
})
})