2026-02-28 04:45:50 +00:00
|
|
|
import { describe, test, expect } from 'bun:test'
|
|
|
|
|
|
|
|
|
|
import { SceneGraph } from '../../src/engine/scene-graph'
|
|
|
|
|
|
Pages support: multi-page documents like Figma
Document structure: Document (root) → Pages (CANVAS) → Nodes.
Each page has its own viewport (pan/zoom) and background color.
- CANVAS node type for pages
- SceneGraph: addPage(), getPages(), getAbsolutePosition stops at CANVAS
- Store: currentPageId, switchPage(), addPage(), deletePage(), renamePage()
- All operations (create, select, paste, hit test, render) scoped to current page
- Per-page viewport state saved/restored on page switch
- Pages list in LayersPanel with add/switch/double-click rename
- .fig import creates proper pages from DOCUMENT→CANVAS hierarchy
- Renderer takes pageId to render correct page's children
- Unit test for pages, updated E2E tests for page-scoped graph
2026-02-28 10:12:27 +00:00
|
|
|
function pageId(graph: SceneGraph) {
|
|
|
|
|
return graph.getPages()[0].id
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-28 04:45:50 +00:00
|
|
|
function rect(graph: SceneGraph, name: string, x = 0, y = 0, w = 50, h = 50) {
|
Pages support: multi-page documents like Figma
Document structure: Document (root) → Pages (CANVAS) → Nodes.
Each page has its own viewport (pan/zoom) and background color.
- CANVAS node type for pages
- SceneGraph: addPage(), getPages(), getAbsolutePosition stops at CANVAS
- Store: currentPageId, switchPage(), addPage(), deletePage(), renamePage()
- All operations (create, select, paste, hit test, render) scoped to current page
- Per-page viewport state saved/restored on page switch
- Pages list in LayersPanel with add/switch/double-click rename
- .fig import creates proper pages from DOCUMENT→CANVAS hierarchy
- Renderer takes pageId to render correct page's children
- Unit test for pages, updated E2E tests for page-scoped graph
2026-02-28 10:12:27 +00:00
|
|
|
return graph.createNode('RECTANGLE', pageId(graph), { name, x, y, width: w, height: h }).id
|
2026-02-28 04:45:50 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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.type).toBe('RECTANGLE')
|
|
|
|
|
expect(node.x).toBe(100)
|
|
|
|
|
expect(node.width).toBe(200)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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('reparent into frame', () => {
|
|
|
|
|
const graph = new SceneGraph()
|
Pages support: multi-page documents like Figma
Document structure: Document (root) → Pages (CANVAS) → Nodes.
Each page has its own viewport (pan/zoom) and background color.
- CANVAS node type for pages
- SceneGraph: addPage(), getPages(), getAbsolutePosition stops at CANVAS
- Store: currentPageId, switchPage(), addPage(), deletePage(), renamePage()
- All operations (create, select, paste, hit test, render) scoped to current page
- Per-page viewport state saved/restored on page switch
- Pages list in LayersPanel with add/switch/double-click rename
- .fig import creates proper pages from DOCUMENT→CANVAS hierarchy
- Renderer takes pageId to render correct page's children
- Unit test for pages, updated E2E tests for page-scoped graph
2026-02-28 10:12:27 +00:00
|
|
|
const frame = graph.createNode('FRAME', pageId(graph), { name: 'F', x: 50, y: 50, width: 400, height: 400 }).id
|
2026-02-28 04:45:50 +00:00
|
|
|
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('children order', () => {
|
|
|
|
|
const graph = new SceneGraph()
|
|
|
|
|
rect(graph, 'A')
|
|
|
|
|
rect(graph, 'B')
|
|
|
|
|
rect(graph, 'C')
|
Pages support: multi-page documents like Figma
Document structure: Document (root) → Pages (CANVAS) → Nodes.
Each page has its own viewport (pan/zoom) and background color.
- CANVAS node type for pages
- SceneGraph: addPage(), getPages(), getAbsolutePosition stops at CANVAS
- Store: currentPageId, switchPage(), addPage(), deletePage(), renamePage()
- All operations (create, select, paste, hit test, render) scoped to current page
- Per-page viewport state saved/restored on page switch
- Pages list in LayersPanel with add/switch/double-click rename
- .fig import creates proper pages from DOCUMENT→CANVAS hierarchy
- Renderer takes pageId to render correct page's children
- Unit test for pages, updated E2E tests for page-scoped graph
2026-02-28 10:12:27 +00:00
|
|
|
const names = graph.getChildren(pageId(graph)).map(n => n.name)
|
2026-02-28 04:45:50 +00:00
|
|
|
expect(names).toEqual(['A', 'B', 'C'])
|
|
|
|
|
})
|
|
|
|
|
|
Pages support: multi-page documents like Figma
Document structure: Document (root) → Pages (CANVAS) → Nodes.
Each page has its own viewport (pan/zoom) and background color.
- CANVAS node type for pages
- SceneGraph: addPage(), getPages(), getAbsolutePosition stops at CANVAS
- Store: currentPageId, switchPage(), addPage(), deletePage(), renamePage()
- All operations (create, select, paste, hit test, render) scoped to current page
- Per-page viewport state saved/restored on page switch
- Pages list in LayersPanel with add/switch/double-click rename
- .fig import creates proper pages from DOCUMENT→CANVAS hierarchy
- Renderer takes pageId to render correct page's children
- Unit test for pages, updated E2E tests for page-scoped graph
2026-02-28 10:12:27 +00:00
|
|
|
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)
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-28 04:45:50 +00:00
|
|
|
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.x).toBe(200)
|
|
|
|
|
expect(node.name).toBe('Updated')
|
|
|
|
|
})
|
2026-02-28 16:57:52 +00:00
|
|
|
|
|
|
|
|
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.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))!
|
|
|
|
|
const instLabel = graph.getChildren(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))!
|
|
|
|
|
const instLabel = graph.getChildren(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(graph.getChildren(instance.id)).toHaveLength(1)
|
|
|
|
|
|
|
|
|
|
graph.createNode('TEXT', comp.id, { name: 'Label', text: 'New' })
|
|
|
|
|
graph.syncInstances(comp.id)
|
|
|
|
|
|
|
|
|
|
const instChildren = graph.getChildren(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.type).toBe('INSTANCE')
|
|
|
|
|
|
|
|
|
|
graph.detachInstance(instance.id)
|
|
|
|
|
expect(instance.type).toBe('FRAME')
|
|
|
|
|
expect(instance.componentId).toBeNull()
|
|
|
|
|
expect(graph.getInstances(comp.id)).toHaveLength(0)
|
|
|
|
|
})
|
2026-02-28 04:45:50 +00:00
|
|
|
})
|
2026-02-28 22:47:52 +00:00
|
|
|
|
|
|
|
|
describe('Variables', () => {
|
|
|
|
|
function pageId(graph: SceneGraph): string {
|
|
|
|
|
return graph.getPages()[0]!.id
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test('add and resolve color variable', () => {
|
|
|
|
|
const graph = new SceneGraph()
|
|
|
|
|
graph.addCollection({
|
|
|
|
|
id: 'col1',
|
|
|
|
|
name: 'Colors',
|
|
|
|
|
modes: [{ modeId: 'm1', name: 'Light' }],
|
|
|
|
|
defaultModeId: 'm1',
|
|
|
|
|
variableIds: []
|
|
|
|
|
})
|
|
|
|
|
graph.addVariable({
|
|
|
|
|
id: 'v1',
|
|
|
|
|
name: 'Primary',
|
|
|
|
|
type: 'COLOR',
|
|
|
|
|
collectionId: 'col1',
|
|
|
|
|
valuesByMode: { m1: { r: 0, g: 0.5, b: 1, a: 1 } },
|
|
|
|
|
description: '',
|
|
|
|
|
hiddenFromPublishing: false
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const color = graph.resolveColorVariable('v1')
|
|
|
|
|
expect(color).toEqual({ r: 0, g: 0.5, b: 1, a: 1 })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('resolve number variable', () => {
|
|
|
|
|
const graph = new SceneGraph()
|
|
|
|
|
graph.addCollection({
|
|
|
|
|
id: 'col1',
|
|
|
|
|
name: 'Spacing',
|
|
|
|
|
modes: [{ modeId: 'm1', name: 'Default' }],
|
|
|
|
|
defaultModeId: 'm1',
|
|
|
|
|
variableIds: []
|
|
|
|
|
})
|
|
|
|
|
graph.addVariable({
|
|
|
|
|
id: 'v1',
|
|
|
|
|
name: 'spacing/md',
|
|
|
|
|
type: 'FLOAT',
|
|
|
|
|
collectionId: 'col1',
|
|
|
|
|
valuesByMode: { m1: 16 },
|
|
|
|
|
description: '',
|
|
|
|
|
hiddenFromPublishing: false
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(graph.resolveNumberVariable('v1')).toBe(16)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('resolve alias variable', () => {
|
|
|
|
|
const graph = new SceneGraph()
|
|
|
|
|
graph.addCollection({
|
|
|
|
|
id: 'col1',
|
|
|
|
|
name: 'Tokens',
|
|
|
|
|
modes: [{ modeId: 'm1', name: 'Light' }],
|
|
|
|
|
defaultModeId: 'm1',
|
|
|
|
|
variableIds: []
|
|
|
|
|
})
|
|
|
|
|
graph.addVariable({
|
|
|
|
|
id: 'v1',
|
|
|
|
|
name: 'Blue/500',
|
|
|
|
|
type: 'COLOR',
|
|
|
|
|
collectionId: 'col1',
|
|
|
|
|
valuesByMode: { m1: { r: 0, g: 0, b: 1, a: 1 } },
|
|
|
|
|
description: '',
|
|
|
|
|
hiddenFromPublishing: false
|
|
|
|
|
})
|
|
|
|
|
graph.addVariable({
|
|
|
|
|
id: 'v2',
|
|
|
|
|
name: 'Primary',
|
|
|
|
|
type: 'COLOR',
|
|
|
|
|
collectionId: 'col1',
|
|
|
|
|
valuesByMode: { m1: { aliasId: 'v1' } },
|
|
|
|
|
description: '',
|
|
|
|
|
hiddenFromPublishing: false
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(graph.resolveColorVariable('v2')).toEqual({ r: 0, g: 0, b: 1, a: 1 })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('mode switching changes resolved value', () => {
|
|
|
|
|
const graph = new SceneGraph()
|
|
|
|
|
graph.addCollection({
|
|
|
|
|
id: 'col1',
|
|
|
|
|
name: 'Theme',
|
|
|
|
|
modes: [
|
|
|
|
|
{ modeId: 'light', name: 'Light' },
|
|
|
|
|
{ modeId: 'dark', name: 'Dark' }
|
|
|
|
|
],
|
|
|
|
|
defaultModeId: 'light',
|
|
|
|
|
variableIds: []
|
|
|
|
|
})
|
|
|
|
|
graph.addVariable({
|
|
|
|
|
id: 'v1',
|
|
|
|
|
name: 'bg',
|
|
|
|
|
type: 'COLOR',
|
|
|
|
|
collectionId: 'col1',
|
|
|
|
|
valuesByMode: {
|
|
|
|
|
light: { r: 1, g: 1, b: 1, a: 1 },
|
|
|
|
|
dark: { r: 0, g: 0, b: 0, a: 1 }
|
|
|
|
|
},
|
|
|
|
|
description: '',
|
|
|
|
|
hiddenFromPublishing: false
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(graph.resolveColorVariable('v1')).toEqual({ r: 1, g: 1, b: 1, a: 1 })
|
|
|
|
|
graph.setActiveMode('col1', 'dark')
|
|
|
|
|
expect(graph.resolveColorVariable('v1')).toEqual({ r: 0, g: 0, b: 0, a: 1 })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('bind and unbind variable to node', () => {
|
|
|
|
|
const graph = new SceneGraph()
|
|
|
|
|
const node = graph.createNode('RECTANGLE', pageId(graph), { name: 'Rect' })
|
|
|
|
|
|
|
|
|
|
graph.bindVariable(node.id, 'fills/0/color', 'v1')
|
|
|
|
|
expect(node.boundVariables['fills/0/color']).toBe('v1')
|
|
|
|
|
|
|
|
|
|
graph.unbindVariable(node.id, 'fills/0/color')
|
|
|
|
|
expect(node.boundVariables['fills/0/color']).toBeUndefined()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('removing variable cleans up bindings', () => {
|
|
|
|
|
const graph = new SceneGraph()
|
|
|
|
|
graph.addCollection({
|
|
|
|
|
id: 'col1',
|
|
|
|
|
name: 'Colors',
|
|
|
|
|
modes: [{ modeId: 'm1', name: 'Default' }],
|
|
|
|
|
defaultModeId: 'm1',
|
|
|
|
|
variableIds: []
|
|
|
|
|
})
|
|
|
|
|
graph.addVariable({
|
|
|
|
|
id: 'v1',
|
|
|
|
|
name: 'Red',
|
|
|
|
|
type: 'COLOR',
|
|
|
|
|
collectionId: 'col1',
|
|
|
|
|
valuesByMode: { m1: { r: 1, g: 0, b: 0, a: 1 } },
|
|
|
|
|
description: '',
|
|
|
|
|
hiddenFromPublishing: false
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const node = graph.createNode('RECTANGLE', pageId(graph), { name: 'Rect' })
|
|
|
|
|
graph.bindVariable(node.id, 'fills/0/color', 'v1')
|
|
|
|
|
expect(node.boundVariables['fills/0/color']).toBe('v1')
|
|
|
|
|
|
|
|
|
|
graph.removeVariable('v1')
|
|
|
|
|
expect(node.boundVariables['fills/0/color']).toBeUndefined()
|
|
|
|
|
expect(graph.variables.size).toBe(0)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('circular alias does not infinite loop', () => {
|
|
|
|
|
const graph = new SceneGraph()
|
|
|
|
|
graph.addCollection({
|
|
|
|
|
id: 'col1',
|
|
|
|
|
name: 'Test',
|
|
|
|
|
modes: [{ modeId: 'm1', name: 'Default' }],
|
|
|
|
|
defaultModeId: 'm1',
|
|
|
|
|
variableIds: []
|
|
|
|
|
})
|
|
|
|
|
graph.addVariable({
|
|
|
|
|
id: 'v1',
|
|
|
|
|
name: 'A',
|
|
|
|
|
type: 'COLOR',
|
|
|
|
|
collectionId: 'col1',
|
|
|
|
|
valuesByMode: { m1: { aliasId: 'v2' } },
|
|
|
|
|
description: '',
|
|
|
|
|
hiddenFromPublishing: false
|
|
|
|
|
})
|
|
|
|
|
graph.addVariable({
|
|
|
|
|
id: 'v2',
|
|
|
|
|
name: 'B',
|
|
|
|
|
type: 'COLOR',
|
|
|
|
|
collectionId: 'col1',
|
|
|
|
|
valuesByMode: { m1: { aliasId: 'v1' } },
|
|
|
|
|
description: '',
|
|
|
|
|
hiddenFromPublishing: false
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(graph.resolveColorVariable('v1')).toBeUndefined()
|
|
|
|
|
})
|
|
|
|
|
})
|