openpencil/tests/engine/scene-graph/basic/graph.test.ts
2026-05-13 03:36:28 +03:00

450 lines
14 KiB
TypeScript

import { describe, expect, test } from 'bun:test'
import { generateId, SceneGraph } from '@open-pencil/core'
import { expectDefined } from '#tests/helpers/assert'
import { pageId, rect } from './helpers'
describe('SceneGraph', () => {
test('create rectangle', () => {
const graph = new SceneGraph()
const id = rect(graph, 'Rect', 100, 100, 200, 150)
const node = graph.getNode(id)
expect(node).toBeDefined()
expect(expectDefined(node, 'node').type).toBe('RECTANGLE')
expect(node.x).toBe(100)
expect(node.width).toBe(200)
})
test('create node skips imported ids that collide with the local generator', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const probe = generateId()
const nextLocalId = `0:${Number(probe.split(':')[1]) + 1}`
const imported = graph.createNode('RECTANGLE', page, {
id: nextLocalId,
name: 'Imported node'
})
const countBefore = graph.nodes.size
const created = graph.createNode('RECTANGLE', page, { name: 'Fresh node' })
expect(created.id).not.toBe(imported.id)
expect(graph.nodes.size).toBe(countBefore + 1)
expect(graph.getNode(imported.id)?.name).toBe('Imported node')
expect(graph.getNode(created.id)?.name).toBe('Fresh node')
})
test('create and delete', () => {
const graph = new SceneGraph()
const id = rect(graph, 'R')
expect(graph.getNode(id)).toBeTruthy()
graph.deleteNode(id)
expect(graph.getNode(id)).toBeFalsy()
})
test('delete frame recursively deletes children', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const frame = graph.createNode('FRAME', page, {
name: 'F',
x: 0,
y: 0,
width: 200,
height: 200
}).id
const child1 = graph.createNode('RECTANGLE', frame, {
name: 'C1',
x: 0,
y: 0,
width: 10,
height: 10
}).id
const child2 = graph.createNode('RECTANGLE', frame, {
name: 'C2',
x: 20,
y: 20,
width: 10,
height: 10
}).id
expect(graph.getNode(frame)).toBeTruthy()
expect(graph.getNode(child1)).toBeTruthy()
expect(graph.getNode(child2)).toBeTruthy()
graph.deleteNode(frame)
// Frame and all children should be deleted
expect(graph.getNode(frame)).toBeFalsy()
expect(graph.getNode(child1)).toBeFalsy()
expect(graph.getNode(child2)).toBeFalsy()
// Page should have no children
expect(graph.getChildren(page)).toHaveLength(0)
})
test('delete nested frame deletes all descendants', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const outer = graph.createNode('FRAME', page, {
name: 'Outer',
x: 0,
y: 0,
width: 300,
height: 300
}).id
const inner = graph.createNode('FRAME', outer, {
name: 'Inner',
x: 10,
y: 10,
width: 100,
height: 100
}).id
const leaf = graph.createNode('RECTANGLE', inner, {
name: 'Leaf',
x: 5,
y: 5,
width: 20,
height: 20
}).id
// Another top-level node to verify it survives
const survivor = rect(graph, 'Survivor')
graph.deleteNode(outer)
expect(graph.getNode(outer)).toBeFalsy()
expect(graph.getNode(inner)).toBeFalsy()
expect(graph.getNode(leaf)).toBeFalsy()
expect(graph.getNode(survivor)).toBeTruthy()
expect(graph.getChildren(page)).toHaveLength(1)
})
test('delete instance cleans up instance index', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const comp = graph.createNode('COMPONENT', page, { name: 'Comp', width: 100, height: 40 })
graph.createNode('RECTANGLE', comp.id, { name: 'BG', width: 100, height: 40 })
const inst = graph.createInstance(comp.id, page)
expect(inst).toBeDefined()
expect(graph.getInstances(comp.id)).toHaveLength(1)
graph.deleteNode(inst.id)
expect(graph.getInstances(comp.id)).toHaveLength(0)
})
test('delete root is no-op', () => {
const graph = new SceneGraph()
const rootId = graph.rootId
const before = graph.nodes.size
graph.deleteNode(rootId)
expect(graph.nodes.size).toBe(before)
})
test('reparent into frame', () => {
const graph = new SceneGraph()
const frame = graph.createNode('FRAME', pageId(graph), {
name: 'F',
x: 50,
y: 50,
width: 400,
height: 400
}).id
const r = rect(graph, 'R', 100, 100)
graph.reparentNode(r, frame)
const children = graph.getChildren(frame)
expect(children.map((c) => c.id)).toContain(r)
})
test('reparent to same parent is no-op', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const r = rect(graph, 'R', 100, 200)
const node = graph.getNode(r)
expect(node).toBeDefined()
expect(expectDefined(node, 'node').parentId).toBe(page)
graph.reparentNode(r, page)
// Position should not change
const after = graph.getNode(r)
expect(after).toBeDefined()
expect(expectDefined(after, 'updated node').x).toBe(100)
expect(expectDefined(after, 'updated node').y).toBe(200)
expect(after.parentId).toBe(page)
expect(graph.getChildren(page)).toHaveLength(1)
})
test('reparent preserves absolute position', () => {
const graph = new SceneGraph()
const frame = graph.createNode('FRAME', pageId(graph), {
name: 'F',
x: 200,
y: 100,
width: 400,
height: 400
}).id
const r = rect(graph, 'R', 300, 150)
// Node is at (300, 150) absolute on page
const absBefore = graph.getAbsolutePosition(r)
expect(absBefore.x).toBe(300)
expect(absBefore.y).toBe(150)
graph.reparentNode(r, frame)
// Now node is inside frame at (200,100), so local coords should be (100, 50)
const after = graph.getNode(r)
expect(after).toBeDefined()
expect(expectDefined(after, 'updated node').x).toBe(100)
expect(after.y).toBe(50)
// Absolute position preserved
const absAfter = graph.getAbsolutePosition(r)
expect(absAfter.x).toBe(300)
expect(absAfter.y).toBe(150)
})
test('reparent removes from old parent childIds', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const frame1 = graph.createNode('FRAME', page, {
name: 'F1',
x: 0,
y: 0,
width: 200,
height: 200
}).id
const frame2 = graph.createNode('FRAME', page, {
name: 'F2',
x: 300,
y: 0,
width: 200,
height: 200
}).id
const r = rect(graph, 'R', 10, 10)
// Move R into F1 first
graph.reparentNode(r, frame1)
expect(graph.getChildren(frame1).map((c) => c.id)).toContain(r)
// Now reparent to F2
graph.reparentNode(r, frame2)
expect(graph.getChildren(frame1).map((c) => c.id)).not.toContain(r)
expect(graph.getChildren(frame2).map((c) => c.id)).toContain(r)
})
test('reparent deep hierarchy preserves child positions', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const frame = graph.createNode('FRAME', page, {
name: 'F',
x: 50,
y: 50,
width: 200,
height: 200
}).id
const inner = graph.createNode('FRAME', page, {
name: 'Inner',
x: 100,
y: 100,
width: 80,
height: 80
}).id
const child = graph.createNode('RECTANGLE', inner, {
name: 'Child',
x: 10,
y: 10,
width: 20,
height: 20
}).id
// Child is at (110, 110) absolute
expect(graph.getAbsolutePosition(child)).toEqual({ x: 110, y: 110 })
// Reparent inner frame into frame F
graph.reparentNode(inner, frame)
// Inner was at (100,100), frame is at (50,50), so inner's local = (50,50)
const innerNode = graph.getNode(inner)
expect(innerNode).toBeDefined()
expect(expectDefined(innerNode, 'inner node').x).toBe(50)
expect(innerNode.y).toBe(50)
// Child still at (10,10) relative to inner, absolute = (110,110)
expect(graph.getAbsolutePosition(child)).toEqual({ x: 110, y: 110 })
})
test('children order', () => {
const graph = new SceneGraph()
rect(graph, 'A')
rect(graph, 'B')
rect(graph, 'C')
const names = graph.getChildren(pageId(graph)).map((n) => n.name)
expect(names).toEqual(['A', 'B', 'C'])
})
test('pages', () => {
const graph = new SceneGraph()
expect(graph.getPages()).toHaveLength(1)
expect(graph.getPages()[0].name).toBe('Page 1')
const page2 = graph.addPage('Page 2')
expect(graph.getPages()).toHaveLength(2)
expect(page2.name).toBe('Page 2')
rect(graph, 'Shape', 0, 0, 50, 50)
expect(graph.getChildren(pageId(graph))).toHaveLength(1)
expect(graph.getChildren(page2.id)).toHaveLength(0)
})
test('countDescendants returns 0 for empty page', () => {
const graph = new SceneGraph()
expect(graph.countDescendants(pageId(graph))).toBe(0)
})
test('countDescendants counts direct children', () => {
const graph = new SceneGraph()
rect(graph, 'A')
rect(graph, 'B')
rect(graph, 'C')
expect(graph.countDescendants(pageId(graph))).toBe(3)
})
test('countDescendants counts nested descendants recursively', () => {
const graph = new SceneGraph()
const frame = graph.createNode('FRAME', pageId(graph), {
name: 'F',
x: 0,
y: 0,
width: 200,
height: 200
})
rect(graph, 'Top', 0, 0) // direct child of page
graph.createNode('RECTANGLE', frame.id, { name: 'Inner1', x: 0, y: 0, width: 10, height: 10 })
graph.createNode('RECTANGLE', frame.id, { name: 'Inner2', x: 0, y: 0, width: 10, height: 10 })
// Page has 2 direct children (frame + Top) + frame has 2 children = 4 total
expect(graph.countDescendants(pageId(graph))).toBe(4)
})
test('countDescendants is per-page (multi-page document)', () => {
const graph = new SceneGraph()
const page1 = pageId(graph)
const page2 = graph.addPage('Page 2').id
// Add 3 nodes to page 1
rect(graph, 'A')
rect(graph, 'B')
rect(graph, 'C')
// Add 1 node to page 2
graph.createNode('RECTANGLE', page2, { name: 'D', x: 0, y: 0, width: 10, height: 10 })
expect(graph.countDescendants(page1)).toBe(3)
expect(graph.countDescendants(page2)).toBe(1)
// Total nodes across all pages is 4 + 2 pages + 1 root = 7
expect(graph.nodes.size).toBe(7)
})
test('update node', () => {
const graph = new SceneGraph()
const id = rect(graph, 'R')
graph.updateNode(id, { x: 200, name: 'Updated' })
const node = graph.getNode(id)
expect(node).toBeDefined()
expect(expectDefined(node, 'node').x).toBe(200)
expect(node.name).toBe('Updated')
})
test('create instance clones children with componentId mapping', () => {
const graph = new SceneGraph()
const comp = graph.createNode('COMPONENT', pageId(graph), {
name: 'Btn',
width: 100,
height: 40
})
const child = graph.createNode('RECTANGLE', comp.id, { name: 'BG', width: 100, height: 40 })
const instance = graph.createInstance(comp.id, pageId(graph))
expect(instance).toBeDefined()
expect(expectDefined(instance, 'instance').type).toBe('INSTANCE')
expect(instance.componentId).toBe(comp.id)
const instChildren = graph.getChildren(instance.id)
expect(instChildren).toHaveLength(1)
expect(instChildren[0].componentId).toBe(child.id)
expect(instChildren[0].name).toBe('BG')
})
test('syncInstances propagates changes from component to instance', () => {
const graph = new SceneGraph()
const comp = graph.createNode('COMPONENT', pageId(graph), {
name: 'Card',
width: 200,
height: 100
})
const label = graph.createNode('TEXT', comp.id, { name: 'Title', text: 'Hello', fontSize: 14 })
const instance = graph.createInstance(comp.id, pageId(graph))
expect(instance).toBeDefined()
const instLabel = graph.getChildren(expectDefined(instance, 'instance').id)[0]
expect(instLabel.text).toBe('Hello')
graph.updateNode(label.id, { text: 'Updated', fontSize: 18 })
graph.syncInstances(comp.id)
expect(instLabel.text).toBe('Updated')
expect(instLabel.fontSize).toBe(18)
})
test('syncInstances preserves overrides', () => {
const graph = new SceneGraph()
const comp = graph.createNode('COMPONENT', pageId(graph), {
name: 'Card',
width: 200,
height: 100
})
graph.createNode('TEXT', comp.id, { name: 'Title', text: 'Default', fontSize: 14 })
const instance = graph.createInstance(comp.id, pageId(graph))
expect(instance).toBeDefined()
const instLabel = graph.getChildren(expectDefined(instance, 'instance').id)[0]
// Override the text on the instance child
graph.updateNode(instLabel.id, { text: 'Custom' })
instance.overrides[`${instLabel.id}:text`] = 'Custom'
// Change component
graph.updateNode(graph.getChildren(comp.id)[0].id, { text: 'New Default', fontSize: 20 })
graph.syncInstances(comp.id)
// Text preserved (overridden), fontSize synced (not overridden)
expect(instLabel.text).toBe('Custom')
expect(instLabel.fontSize).toBe(20)
})
test('syncInstances adds new children from component', () => {
const graph = new SceneGraph()
const comp = graph.createNode('COMPONENT', pageId(graph), {
name: 'Card',
width: 200,
height: 100
})
graph.createNode('RECTANGLE', comp.id, { name: 'BG' })
const instance = graph.createInstance(comp.id, pageId(graph))
expect(instance).toBeDefined()
expect(graph.getChildren(expectDefined(instance, 'instance').id)).toHaveLength(1)
graph.createNode('TEXT', comp.id, { name: 'Label', text: 'New' })
graph.syncInstances(comp.id)
const instChildren = graph.getChildren(expectDefined(instance, 'instance').id)
expect(instChildren).toHaveLength(2)
expect(instChildren[1].name).toBe('Label')
expect(instChildren[1].text).toBe('New')
})
test('detachInstance breaks link', () => {
const graph = new SceneGraph()
const comp = graph.createNode('COMPONENT', pageId(graph), {
name: 'Btn',
width: 100,
height: 40
})
graph.createNode('RECTANGLE', comp.id, { name: 'BG' })
const instance = graph.createInstance(comp.id, pageId(graph))
expect(instance).toBeDefined()
expect(expectDefined(instance, 'instance').type).toBe('INSTANCE')
graph.detachInstance(expectDefined(instance, 'instance').id)
expect(expectDefined(instance, 'instance').type).toBe('FRAME')
expect(instance.componentId).toBeNull()
expect(graph.getInstances(comp.id)).toHaveLength(0)
})
})