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 top-level 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(2) const names = components.map((c) => c.node.name).sort() expect(names).toEqual(['Button Set', 'Standalone']) }) it('skips components nested inside other components', () => { 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.find((c) => c.node.name === 'Button Set')).toBeDefined() expect(components.find((c) => c.node.name === 'Button')).toBeUndefined() expect(components.find((c) => c.node.name === 'Standalone')).toBeDefined() }) 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 buttonSet = components.find((c) => c.node.name === 'Button Set') expect(buttonSet?.absX).toBe(300) expect(buttonSet?.absY).toBe(0) }) })