openpencil/tests/engine/label-cache.test.ts
Danila Poyarkov 3b32b4d80c
Perf: 5.4× faster file open, worker-based parsing, instance index (#96)
* Perf: cache label collection, offload .fig compression to worker

Label cache: collect sections/components once per scene change,
filter by viewport on each frame. Eliminates full tree walk during
pan/zoom (~17ms/frame → <1ms on large files).

Export worker: move fflate compression off the main thread to prevent
frame drops during save (451ms+ → non-blocking).

* Perf: worker-based .fig parsing, instance index, non-blocking font loading

- Offload .fig parsing (unzip + Kiwi decode) to a Web Worker
- Add instance index (componentId → Set<nodeId>) for O(1) getInstances()
- Defer graph event subscription during file open to skip redundant syncs
- Make font loading non-blocking — render immediately, load fonts in background
- Copy image buffers before worker transfer to prevent detached ArrayBuffer crash
- Show toast on font load failure and file open errors
- Cache failed Google Fonts families to avoid repeated network requests
- Fix missing ref import in FillPicker
- Yield to UI between parse and layout for responsive loading spinner
2026-03-12 14:49:28 +03:00

217 lines
6.7 KiB
TypeScript

import { describe, it, expect } from 'bun:test'
import { SceneGraph, LabelCache } from '@open-pencil/core'
function buildGraph() {
const g = new SceneGraph()
const pageId = g.getPages()[0].id
const sectionId = g.createNode('SECTION', pageId, {
name: 'Section 1',
x: 0,
y: 0,
width: 200,
height: 200
}).id
const nestedSectionId = g.createNode('SECTION', sectionId, {
name: 'Nested Section',
x: 10,
y: 10,
width: 100,
height: 100
}).id
const compSetId = g.createNode('COMPONENT_SET', pageId, {
name: 'Button Set',
x: 300,
y: 0,
width: 200,
height: 200
}).id
const compId = g.createNode('COMPONENT', compSetId, {
name: 'Button',
x: 10,
y: 10,
width: 80,
height: 40
}).id
const standaloneCompId = g.createNode('COMPONENT', pageId, {
name: 'Standalone',
x: 600,
y: 0,
width: 100,
height: 50
}).id
return { g, pageId, sectionId, nestedSectionId, compSetId, compId, standaloneCompId }
}
describe('LabelCache', () => {
it('collects sections on first call', () => {
const { g, pageId } = buildGraph()
const cache = new LabelCache()
cache.update(g, pageId, 1)
const sections = cache.getSections(g, { x: -1000, y: -1000, w: 3000, h: 3000 })
expect(sections.length).toBe(2)
expect(sections.map((s) => s.node.name).sort()).toEqual(['Nested Section', 'Section 1'])
})
it('returns cached data when sceneVersion unchanged', () => {
const { g, pageId } = buildGraph()
const cache = new LabelCache()
cache.update(g, pageId, 1)
const first = cache.getSections(g, { x: -1000, y: -1000, w: 3000, h: 3000 })
cache.update(g, pageId, 1)
const second = cache.getSections(g, { x: -1000, y: -1000, w: 3000, h: 3000 })
expect(first.length).toBe(second.length)
expect(first.map((s) => s.node.id)).toEqual(second.map((s) => s.node.id))
})
it('invalidates when sceneVersion changes', () => {
const { g, pageId } = buildGraph()
const cache = new LabelCache()
cache.update(g, pageId, 1)
const before = cache.getSections(g, { x: -1000, y: -1000, w: 3000, h: 3000 })
g.createNode('SECTION', pageId, { name: 'New Section', x: 500, y: 500, width: 100, height: 100 })
cache.update(g, pageId, 2)
const after = cache.getSections(g, { x: -1000, y: -1000, w: 3000, h: 3000 })
expect(after.length).toBe(before.length + 1)
})
it('invalidates when pageId changes', () => {
const { g, pageId } = buildGraph()
const cache = new LabelCache()
cache.update(g, pageId, 1)
expect(cache.getSections(g, { x: -1000, y: -1000, w: 3000, h: 3000 }).length).toBe(2)
const page2 = g.addPage('Page 2')
cache.update(g, page2.id, 1)
expect(cache.getSections(g, { x: -1000, y: -1000, w: 3000, h: 3000 }).length).toBe(0)
})
it('filters sections by viewport', () => {
const { g, pageId } = buildGraph()
const cache = new LabelCache()
cache.update(g, pageId, 1)
const all = cache.getSections(g, { x: -1000, y: -1000, w: 3000, h: 3000 })
expect(all.length).toBe(2)
const visible = cache.getSections(g, { x: 0, y: 0, w: 50, h: 50 })
expect(visible.length).toBeGreaterThan(0)
const offscreen = cache.getSections(g, { x: 5000, y: 5000, w: 100, h: 100 })
expect(offscreen.length).toBe(0)
})
it('tracks nested sections', () => {
const { g, pageId } = buildGraph()
const cache = new LabelCache()
cache.update(g, pageId, 1)
const sections = cache.getSections(g, { x: -1000, y: -1000, w: 3000, h: 3000 })
const topLevel = sections.find((s) => s.node.name === 'Section 1')
const nested = sections.find((s) => s.node.name === 'Nested Section')
expect(topLevel?.nested).toBe(false)
expect(nested?.nested).toBe(true)
})
it('collects components and component sets', () => {
const { g, pageId } = buildGraph()
const cache = new LabelCache()
cache.update(g, pageId, 1)
const components = cache.getComponents(g, { x: -1000, y: -1000, w: 3000, h: 3000 })
expect(components.length).toBe(3)
const names = components.map((c) => c.node.name).sort()
expect(names).toEqual(['Button', 'Button Set', 'Standalone'])
})
it('identifies components inside component sets', () => {
const { g, pageId } = buildGraph()
const cache = new LabelCache()
cache.update(g, pageId, 1)
const components = cache.getComponents(g, { x: -1000, y: -1000, w: 3000, h: 3000 })
const buttonSet = components.find((c) => c.node.name === 'Button Set')
const button = components.find((c) => c.node.name === 'Button')
const standalone = components.find((c) => c.node.name === 'Standalone')
expect(buttonSet?.inside).toBe(false)
expect(button?.inside).toBe(true)
expect(standalone?.inside).toBe(false)
})
it('skips invisible nodes', () => {
const { g, pageId, sectionId } = buildGraph()
const cache = new LabelCache()
g.updateNode(sectionId, { visible: false })
cache.update(g, pageId, 1)
const sections = cache.getSections(g, { x: -1000, y: -1000, w: 3000, h: 3000 })
const names = sections.map((s) => s.node.name)
expect(names).not.toContain('Section 1')
expect(names).not.toContain('Nested Section')
})
it('handles empty graph', () => {
const g = new SceneGraph()
const pageId = g.getPages()[0].id
const cache = new LabelCache()
cache.update(g, pageId, 0)
expect(cache.getSections(g, { x: 0, y: 0, w: 1000, h: 1000 })).toEqual([])
expect(cache.getComponents(g, { x: 0, y: 0, w: 1000, h: 1000 })).toEqual([])
})
it('explicit invalidate() forces recollection', () => {
const { g, pageId } = buildGraph()
const cache = new LabelCache()
cache.update(g, pageId, 1)
expect(cache.getSections(g, { x: -1000, y: -1000, w: 3000, h: 3000 }).length).toBe(2)
g.createNode('SECTION', pageId, { name: 'Sneaky', x: 800, y: 0, width: 50, height: 50 })
cache.update(g, pageId, 1)
expect(cache.getSections(g, { x: -1000, y: -1000, w: 3000, h: 3000 }).length).toBe(2)
cache.invalidate()
cache.update(g, pageId, 1)
expect(cache.getSections(g, { x: -1000, y: -1000, w: 3000, h: 3000 }).length).toBe(3)
})
it('computes correct absolute positions for nested nodes', () => {
const { g, pageId } = buildGraph()
const cache = new LabelCache()
cache.update(g, pageId, 1)
const sections = cache.getSections(g, { x: -1000, y: -1000, w: 3000, h: 3000 })
const nested = sections.find((s) => s.node.name === 'Nested Section')
expect(nested?.absX).toBe(10)
expect(nested?.absY).toBe(10)
const components = cache.getComponents(g, { x: -1000, y: -1000, w: 3000, h: 3000 })
const button = components.find((c) => c.node.name === 'Button')
expect(button?.absX).toBe(310)
expect(button?.absY).toBe(10)
})
})