* perf(scene-graph): accelerate axis-aligned hit tests - Reuse cached world positions for untransformed node chains - Preserve exact matrix hit testing beneath rotated and flipped ancestors - Profile pointer, hit-test, cached, overlay, and volatile rendering at 500 and 2,000 nodes * fix(vue): restore layer drop indicators - Forward reactive drag instructions through virtualized layer item slots - Render above, below, and child feedback before the drop completes - Add browser regressions for reorder lines and container highlights * fix(scene-graph): harden hit-test performance coverage - Cache transformed ancestry within each hit-test traversal - Invalidate absolute positions after preview flips and reparenting - Attach scale-relative browser profiles and correct auto-layout fixture geometry * fix(scene-graph): reject cyclic layer reorder - Prevent reorderChild from parenting a node beneath its descendant - Cover graph preservation after a rejected cyclic reorder
470 lines
13 KiB
TypeScript
470 lines
13 KiB
TypeScript
import { describe, expect, test } from 'bun:test'
|
|
|
|
import { SceneGraph } from '@open-pencil/core'
|
|
|
|
import { expectDefined, getNodeOrThrow } from '#tests/helpers/assert'
|
|
|
|
function pageId(graph: SceneGraph) {
|
|
return graph.getPages()[0].id
|
|
}
|
|
|
|
describe('hitTest — group behavior', () => {
|
|
function setup() {
|
|
const graph = new SceneGraph()
|
|
const page = pageId(graph)
|
|
const group = graph.createNode('GROUP', page, {
|
|
name: 'Group',
|
|
x: 100,
|
|
y: 100,
|
|
width: 200,
|
|
height: 200
|
|
})
|
|
const child = graph.createNode('RECTANGLE', group.id, {
|
|
name: 'Rect',
|
|
x: 10,
|
|
y: 10,
|
|
width: 50,
|
|
height: 50
|
|
})
|
|
return { graph, page, group, child }
|
|
}
|
|
|
|
test('single click on child returns GROUP (not child)', () => {
|
|
const { graph, page } = setup()
|
|
const hit = graph.hitTest(110, 110, page)
|
|
expect(hit).not.toBeNull()
|
|
expect(expectDefined(hit, 'hit node').type).toBe('GROUP')
|
|
})
|
|
|
|
test('hitTestDeep on child returns child directly', () => {
|
|
const { graph, page, child } = setup()
|
|
const hit = graph.hitTestDeep(110, 110, page)
|
|
expect(hit).not.toBeNull()
|
|
expect(expectDefined(hit, 'hit node').id).toBe(child.id)
|
|
})
|
|
|
|
test('hitTest with scope=group returns child', () => {
|
|
const { graph, group } = setup()
|
|
const hit = graph.hitTest(110, 110, group.id)
|
|
expect(hit).not.toBeNull()
|
|
expect(expectDefined(hit, 'hit node').name).toBe('Rect')
|
|
})
|
|
|
|
test('hitTest miss inside group returns null', () => {
|
|
const { graph, group } = setup()
|
|
const hit = graph.hitTest(180, 180, group.id)
|
|
expect(hit).toBeNull()
|
|
})
|
|
|
|
test('click outside group returns null', () => {
|
|
const { graph, page } = setup()
|
|
const hit = graph.hitTest(50, 50, page)
|
|
expect(hit).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('hitTest — nested groups', () => {
|
|
function setup() {
|
|
const graph = new SceneGraph()
|
|
const page = pageId(graph)
|
|
const groupA = graph.createNode('GROUP', page, {
|
|
name: 'GroupA',
|
|
x: 100,
|
|
y: 100,
|
|
width: 300,
|
|
height: 300
|
|
})
|
|
const groupB = graph.createNode('GROUP', groupA.id, {
|
|
name: 'GroupB',
|
|
x: 50,
|
|
y: 50,
|
|
width: 100,
|
|
height: 100
|
|
})
|
|
const rect = graph.createNode('RECTANGLE', groupB.id, {
|
|
name: 'DeepRect',
|
|
x: 10,
|
|
y: 10,
|
|
width: 30,
|
|
height: 30
|
|
})
|
|
return { graph, page, groupA, groupB, rect }
|
|
}
|
|
|
|
test('click from page scope returns GroupA', () => {
|
|
const { graph, page } = setup()
|
|
const hit = graph.hitTest(100, 100, page)
|
|
expect(hit).not.toBeNull()
|
|
expect(expectDefined(hit, 'hit node').name).toBe('GroupA')
|
|
})
|
|
|
|
test('click with scope=GroupA returns GroupB', () => {
|
|
const { graph, groupA } = setup()
|
|
const hit = graph.hitTest(150, 150, groupA.id)
|
|
expect(hit).not.toBeNull()
|
|
expect(expectDefined(hit, 'hit node').name).toBe('GroupB')
|
|
})
|
|
|
|
test('click with scope=GroupB returns DeepRect', () => {
|
|
const { graph, groupB } = setup()
|
|
const hit = graph.hitTest(160, 160, groupB.id)
|
|
expect(hit).not.toBeNull()
|
|
expect(expectDefined(hit, 'hit node').name).toBe('DeepRect')
|
|
})
|
|
|
|
test('hitTestDeep from page returns deepest child', () => {
|
|
const { graph, page, rect } = setup()
|
|
const hit = graph.hitTestDeep(160, 160, page)
|
|
expect(hit).not.toBeNull()
|
|
expect(expectDefined(hit, 'hit node').id).toBe(rect.id)
|
|
})
|
|
})
|
|
|
|
describe('hitTest — locked nodes', () => {
|
|
test('locked container blocks enter (returns container, not child)', () => {
|
|
const graph = new SceneGraph()
|
|
const page = pageId(graph)
|
|
const frame = graph.createNode('FRAME', page, {
|
|
name: 'LockedFrame',
|
|
x: 0,
|
|
y: 0,
|
|
width: 200,
|
|
height: 200,
|
|
locked: true
|
|
})
|
|
graph.createNode('RECTANGLE', frame.id, {
|
|
name: 'Child',
|
|
x: 10,
|
|
y: 10,
|
|
width: 50,
|
|
height: 50
|
|
})
|
|
|
|
const hit = graph.hitTestDeep(10, 10, page)
|
|
expect(hit).not.toBeNull()
|
|
expect(expectDefined(hit, 'hit node').name).toBe('LockedFrame')
|
|
})
|
|
|
|
test('locked leaf node is still clickable', () => {
|
|
const graph = new SceneGraph()
|
|
const page = pageId(graph)
|
|
graph.createNode('RECTANGLE', page, {
|
|
name: 'LockedRect',
|
|
x: 0,
|
|
y: 0,
|
|
width: 50,
|
|
height: 50,
|
|
locked: true
|
|
})
|
|
|
|
const hit = graph.hitTest(25, 25, page)
|
|
expect(hit).not.toBeNull()
|
|
expect(expectDefined(hit, 'hit node').name).toBe('LockedRect')
|
|
})
|
|
})
|
|
|
|
describe('hitTest — visibility', () => {
|
|
test('invisible node is not hit', () => {
|
|
const graph = new SceneGraph()
|
|
const page = pageId(graph)
|
|
graph.createNode('RECTANGLE', page, {
|
|
name: 'Hidden',
|
|
x: 0,
|
|
y: 0,
|
|
width: 50,
|
|
height: 50,
|
|
visible: false
|
|
})
|
|
|
|
const hit = graph.hitTest(25, 25, page)
|
|
expect(hit).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('scene graph — locked node operations', () => {
|
|
test('locked node cannot be deleted via filter', () => {
|
|
const graph = new SceneGraph()
|
|
const page = pageId(graph)
|
|
const rect = graph.createNode('RECTANGLE', page, {
|
|
name: 'LockedRect',
|
|
x: 0,
|
|
y: 0,
|
|
width: 50,
|
|
height: 50,
|
|
locked: true
|
|
})
|
|
|
|
const node = graph.getNode(rect.id)
|
|
expect(node).not.toBeNull()
|
|
expect(expectDefined(node, 'node').locked).toBe(true)
|
|
})
|
|
|
|
test('lock can be toggled', () => {
|
|
const graph = new SceneGraph()
|
|
const page = pageId(graph)
|
|
const rect = graph.createNode('RECTANGLE', page, {
|
|
name: 'Rect',
|
|
x: 0,
|
|
y: 0,
|
|
width: 50,
|
|
height: 50
|
|
})
|
|
|
|
expect(getNodeOrThrow(graph, rect.id).locked).toBe(false)
|
|
graph.updateNode(rect.id, { locked: true })
|
|
expect(getNodeOrThrow(graph, rect.id).locked).toBe(true)
|
|
graph.updateNode(rect.id, { locked: false })
|
|
expect(getNodeOrThrow(graph, rect.id).locked).toBe(false)
|
|
})
|
|
|
|
test('visibility can be toggled', () => {
|
|
const graph = new SceneGraph()
|
|
const page = pageId(graph)
|
|
const rect = graph.createNode('RECTANGLE', page, {
|
|
name: 'Rect',
|
|
x: 0,
|
|
y: 0,
|
|
width: 50,
|
|
height: 50
|
|
})
|
|
|
|
expect(getNodeOrThrow(graph, rect.id).visible).toBe(true)
|
|
graph.updateNode(rect.id, { visible: false })
|
|
expect(getNodeOrThrow(graph, rect.id).visible).toBe(false)
|
|
graph.updateNode(rect.id, { visible: true })
|
|
expect(getNodeOrThrow(graph, rect.id).visible).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('hitTest — frame with children', () => {
|
|
test('frame scope hit test finds children', () => {
|
|
const graph = new SceneGraph()
|
|
const page = pageId(graph)
|
|
const frame = graph.createNode('FRAME', page, {
|
|
name: 'Frame',
|
|
x: 50,
|
|
y: 50,
|
|
width: 200,
|
|
height: 200
|
|
})
|
|
const child = graph.createNode('RECTANGLE', frame.id, {
|
|
name: 'InnerRect',
|
|
x: 20,
|
|
y: 20,
|
|
width: 40,
|
|
height: 40
|
|
})
|
|
|
|
const hit = graph.hitTest(70, 70, frame.id)
|
|
expect(hit).not.toBeNull()
|
|
expect(expectDefined(hit, 'hit node').id).toBe(child.id)
|
|
})
|
|
|
|
test('rotated frame scope hit test finds children using rotated local bounds', () => {
|
|
const graph = new SceneGraph()
|
|
const page = pageId(graph)
|
|
const frame = graph.createNode('FRAME', page, {
|
|
name: 'RotatedFrame',
|
|
x: 100,
|
|
y: 100,
|
|
width: 200,
|
|
height: 120,
|
|
rotation: 45
|
|
})
|
|
const child = graph.createNode('RECTANGLE', frame.id, {
|
|
name: 'InnerRect',
|
|
x: 60,
|
|
y: 30,
|
|
width: 80,
|
|
height: 40
|
|
})
|
|
|
|
// point that is inside rotated frame bounds and child bounds
|
|
const hitInside = graph.hitTest(193, 111, frame.id)
|
|
expect(hitInside).not.toBeNull()
|
|
expect(expectDefined(hitInside, 'inside hit node').id).toBe(child.id)
|
|
|
|
// point that would be inside if frame were not rotated
|
|
const hitOutside = graph.hitTest(160, 130, frame.id)
|
|
expect(hitOutside).toBeNull()
|
|
})
|
|
|
|
test('cached absolute positions refresh after preview flips and reparenting', () => {
|
|
const graph = new SceneGraph()
|
|
const page = pageId(graph)
|
|
const left = graph.createNode('FRAME', page, { x: 100, y: 100, width: 200, height: 100 })
|
|
const right = graph.createNode('FRAME', page, { x: 400, y: 100, width: 200, height: 100 })
|
|
const child = graph.createNode('RECTANGLE', left.id, {
|
|
x: 20,
|
|
y: 20,
|
|
width: 40,
|
|
height: 40
|
|
})
|
|
|
|
expect(graph.hitTest(130, 130, left.id)?.id).toBe(child.id)
|
|
graph.updateNodePreview(left.id, { flipX: true })
|
|
expect(graph.hitTest(270, 130, left.id)?.id).toBe(child.id)
|
|
expect(graph.hitTest(130, 130, left.id)).toBeNull()
|
|
|
|
graph.updateNode(left.id, { flipX: false })
|
|
expect(graph.getAbsolutePosition(child.id)).toEqual({ x: 120, y: 120 })
|
|
graph.reorderChild(child.id, right.id, 0)
|
|
expect(graph.getAbsolutePosition(child.id)).toEqual({ x: 420, y: 120 })
|
|
expect(graph.hitTest(430, 130, right.id)?.id).toBe(child.id)
|
|
|
|
graph.reorderChild(right.id, child.id, 0)
|
|
expect(right.parentId).toBe(page)
|
|
expect(child.parentId).toBe(right.id)
|
|
})
|
|
|
|
test('unrotated child inside a flipped ancestor uses transformed hit testing', () => {
|
|
const graph = new SceneGraph()
|
|
const page = pageId(graph)
|
|
const frame = graph.createNode('FRAME', page, {
|
|
x: 100,
|
|
y: 100,
|
|
width: 200,
|
|
height: 100,
|
|
flipX: true
|
|
})
|
|
const child = graph.createNode('RECTANGLE', frame.id, {
|
|
x: 20,
|
|
y: 20,
|
|
width: 40,
|
|
height: 40
|
|
})
|
|
|
|
expect(graph.hitTest(250, 140, frame.id)?.id).toBe(child.id)
|
|
expect(graph.hitTest(130, 140, frame.id)).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('hitTest — opaque containers (COMPONENT/INSTANCE)', () => {
|
|
test('hitTest on COMPONENT returns component itself (not child)', () => {
|
|
const graph = new SceneGraph()
|
|
const page = pageId(graph)
|
|
const comp = graph.createNode('COMPONENT', page, {
|
|
name: 'MyComp',
|
|
x: 0,
|
|
y: 0,
|
|
width: 100,
|
|
height: 100
|
|
})
|
|
graph.createNode('RECTANGLE', comp.id, {
|
|
name: 'CompChild',
|
|
x: 10,
|
|
y: 10,
|
|
width: 30,
|
|
height: 30
|
|
})
|
|
|
|
const hit = graph.hitTest(10, 10, page)
|
|
expect(hit).not.toBeNull()
|
|
expect(expectDefined(hit, 'hit node').name).toBe('MyComp')
|
|
})
|
|
|
|
test('hitTestDeep inside COMPONENT scope finds child', () => {
|
|
const graph = new SceneGraph()
|
|
const page = pageId(graph)
|
|
const comp = graph.createNode('COMPONENT', page, {
|
|
name: 'MyComp',
|
|
x: 0,
|
|
y: 0,
|
|
width: 100,
|
|
height: 100
|
|
})
|
|
const child = graph.createNode('RECTANGLE', comp.id, {
|
|
name: 'CompChild',
|
|
x: 10,
|
|
y: 10,
|
|
width: 30,
|
|
height: 30
|
|
})
|
|
|
|
const hit = graph.hitTestDeep(10, 10, comp.id)
|
|
expect(hit).not.toBeNull()
|
|
expect(expectDefined(hit, 'hit node').id).toBe(child.id)
|
|
})
|
|
|
|
test('hitTest on INSTANCE returns instance itself', () => {
|
|
const graph = new SceneGraph()
|
|
const page = pageId(graph)
|
|
const inst = graph.createNode('INSTANCE', page, {
|
|
name: 'MyInstance',
|
|
x: 50,
|
|
y: 50,
|
|
width: 80,
|
|
height: 80
|
|
})
|
|
graph.createNode('RECTANGLE', inst.id, {
|
|
name: 'InstChild',
|
|
x: 5,
|
|
y: 5,
|
|
width: 20,
|
|
height: 20
|
|
})
|
|
|
|
const hit = graph.hitTest(55, 55, page)
|
|
expect(hit).not.toBeNull()
|
|
expect(expectDefined(hit, 'hit node').name).toBe('MyInstance')
|
|
})
|
|
|
|
test('hitTestDeep inside INSTANCE scope finds child', () => {
|
|
const graph = new SceneGraph()
|
|
const page = pageId(graph)
|
|
const inst = graph.createNode('INSTANCE', page, {
|
|
name: 'MyInstance',
|
|
x: 50,
|
|
y: 50,
|
|
width: 80,
|
|
height: 80
|
|
})
|
|
const child = graph.createNode('RECTANGLE', inst.id, {
|
|
name: 'InstChild',
|
|
x: 5,
|
|
y: 5,
|
|
width: 20,
|
|
height: 20
|
|
})
|
|
|
|
const hit = graph.hitTestDeep(55, 55, inst.id)
|
|
expect(hit).not.toBeNull()
|
|
expect(expectDefined(hit, 'hit node').id).toBe(child.id)
|
|
})
|
|
})
|
|
|
|
describe('hitTest — absolute position and scope offset', () => {
|
|
test('scoped hitTest accounts for parent offset', () => {
|
|
const graph = new SceneGraph()
|
|
const page = pageId(graph)
|
|
const frame = graph.createNode('FRAME', page, {
|
|
name: 'Frame',
|
|
x: 200,
|
|
y: 300,
|
|
width: 400,
|
|
height: 400
|
|
})
|
|
const child = graph.createNode('RECTANGLE', frame.id, {
|
|
name: 'Child',
|
|
x: 50,
|
|
y: 60,
|
|
width: 100,
|
|
height: 100
|
|
})
|
|
|
|
const abs = graph.getAbsolutePosition(frame.id)
|
|
expect(abs.x).toBe(200)
|
|
expect(abs.y).toBe(300)
|
|
const abs2 = graph.getAbsolutePosition(child.id)
|
|
expect(abs2.x).toBe(250)
|
|
expect(abs2.y).toBe(360)
|
|
|
|
const hit = graph.hitTest(250, 360, frame.id)
|
|
expect(hit).not.toBeNull()
|
|
expect(expectDefined(hit, 'hit node').id).toBe(child.id)
|
|
|
|
const missHit = graph.hitTest(50, 60, frame.id)
|
|
expect(missHit).toBeNull()
|
|
})
|
|
})
|