openpencil/tests/engine/tools/analyze/overlaps/bounds.test.ts
Joseph Cumines 99e4153ec5 test(tools): create control-case node on the plain graph
CodeRabbit found the RotatedPlain control node was created on
instead of , so the unstroked control assertion was not actually
validating the same setup without a stroke.
2026-06-19 11:47:37 +10:00

238 lines
9.2 KiB
TypeScript

import { describe, expect, test } from 'bun:test'
import { SceneGraph, type Rect } from '@open-pencil/core'
import { computeOverlaps } from '@open-pencil/core/tools/analyze/overlaps'
import { frame, pageId, rect } from './helpers'
describe('analyze overlaps visible bounds', () => {
test('large background below a small badge is not reported as overlay', () => {
const graph = new SceneGraph()
const page = pageId(graph)
rect(graph, 'Backdrop', page, 0, 0, 100, 100)
rect(graph, 'Badge', page, 10, 10, 10, 10)
const result = computeOverlaps(graph)
expect(result.overlaps.some((o) => o.category === 'overlay')).toBe(false)
expect(result.overlaps.some((o) => o.category === 'sibling-overlap')).toBe(true)
})
test('text decoration extends visual bounds below raw height', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const t = graph.createNode('TEXT', page, {
name: 'Underlined',
x: 0,
y: 0,
width: 100,
height: 10,
text: 'Underlined',
fontSize: 14,
textDecoration: 'UNDERLINE'
})
rect(graph, 'Below', page, 0, 11, 100, 10)
const result = computeOverlaps(graph)
expect(result.summary.overlapCount).toBeGreaterThan(0)
expect(result.overlaps.some((o) => o.nodeA.id === t.id || o.nodeB.id === t.id)).toBe(true)
})
test('vector geometry expands bounds beyond width and height', () => {
const graph = new SceneGraph()
const page = pageId(graph)
function rectGeometryBlob(local: Rect) {
const parts = [
{ cmd: 1, x: local.x, y: local.y },
{ cmd: 2, x: local.x + local.width, y: local.y },
{ cmd: 2, x: local.x + local.width, y: local.y + local.height },
{ cmd: 2, x: local.x, y: local.y + local.height },
{ cmd: 0 }
]
let byteLength = 0
for (const part of parts) {
byteLength += 1
if (part.x !== undefined) byteLength += 8
}
const bytes = new Uint8Array(byteLength)
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
let offset = 0
for (const part of parts) {
bytes[offset++] = part.cmd
if (part.x !== undefined) {
dv.setFloat32(offset, part.x, true)
dv.setFloat32(offset + 4, part.y as number, true)
offset += 8
}
}
return bytes
}
const v = graph.createNode('VECTOR', page, {
name: 'WideVector',
x: 0,
y: 0,
width: 10,
height: 10,
fillGeometry: [
{
commandsBlob: rectGeometryBlob({ x: 0, y: 0, width: 200, height: 200 }),
windingRule: 'NONZERO'
}
]
})
rect(graph, 'Far', page, 150, 150, 50, 50)
const result = computeOverlaps(graph)
expect(result.summary.overlapCount).toBeGreaterThan(0)
expect(result.overlaps.some((o) => o.nodeA.id === v.id || o.nodeB.id === v.id)).toBe(true)
})
test('ancestor clipping prevents false overlaps across the clip boundary', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const clipFrame = frame(graph, 'ClipFrame', page, 0, 0, 50, 50, true)
rect(graph, 'ClippedChild', clipFrame.id, 40, 0, 20, 20)
rect(graph, 'Outside', page, 50, 0, 20, 20)
const clipped = computeOverlaps(graph, { category: 'sibling-overlap' })
expect(clipped.summary.overlapCount).toBe(0)
const noClipFrame = frame(graph, 'NoClipFrame', page, 200, 0, 50, 50, false)
rect(graph, 'UnclippedChild', noClipFrame.id, 40, 0, 20, 20)
rect(graph, 'Outside2', page, 250, 0, 20, 20)
const unclipped = computeOverlaps(graph, { category: 'sibling-overlap' })
expect(unclipped.summary.overlapCount).toBe(1)
})
test('rotated clipping frame prevents false sibling overlap', () => {
const graph = new SceneGraph()
const page = pageId(graph)
// A 90°-rotated clipping frame. Its local space [0,100]x[0,100] maps to
// canvas [0,100]x[0,100] (a square rotated 90° around its center is the
// same square). But the OLD axis-aligned clip used the rotated origin
// (100,0) and produced a wrong clip rectangle [100,200]x[0,100].
const clipFrame = frame(graph, 'RotatedClip', page, 0, 0, 100, 100, true)
graph.updateNode(clipFrame.id, { rotation: 90 })
// Child at local (10,10,20,20). After 90° rotation around the frame
// center (50,50), the child lands at canvas AABB [70,90]x[10,30].
rect(graph, 'Child', clipFrame.id, 10, 10, 20, 20)
// Outside node starting exactly at the clip frame's right boundary.
// With the BUGGY code the child's clipped bounds were [100,110]x[10,30]
// (wrong), producing a false overlap with [100,120]x[10,30].
// With the fix the child's clipped bounds are [70,90]x[10,30] (correct),
// so there is no overlap.
rect(graph, 'Outside', page, 100, 10, 20, 20)
const result = computeOverlaps(graph, { category: 'sibling-overlap' })
expect(result.summary.overlapCount).toBe(0)
})
test('child of rotated non-clipping parent is detected at correct position', () => {
const graph = new SceneGraph()
const page = pageId(graph)
// Non-clipping parent rotated 90°. A child at local (10,10,20,20)
// maps to canvas AABB [70,90]x[10,30] after rotation. A second node
// placed at canvas (72,12,10,10) should overlap — proving the bounds
// are computed from the world matrix, not from the rotated origin.
const parent = frame(graph, 'RotatedParent', page, 0, 0, 100, 100, false)
graph.updateNode(parent.id, { rotation: 90 })
const child = rect(graph, 'Child', parent.id, 10, 10, 20, 20)
rect(graph, 'OverlapTarget', page, 72, 12, 10, 10)
const result = computeOverlaps(graph, { category: 'sibling-overlap' })
expect(result.summary.overlapCount).toBeGreaterThan(0)
expect(result.overlaps.some((o) => o.nodeA.id === child.id || o.nodeB.id === child.id)).toBe(
true
)
})
test('rotated clipping frame still clips children that protrude', () => {
const graph = new SceneGraph()
const page = pageId(graph)
// 90°-rotated clipping frame at (0,0,100,100). Its canvas-space clip is
// [0,100]x[0,100] (a square rotated 90° around its center is the same
// square). The OLD axis-aligned clip used the rotated origin (100,0) and
// produced a wrong clip rectangle [100,200]x[0,100].
const clipFrame = frame(graph, 'RotatedClip90', page, 0, 0, 100, 100, true)
graph.updateNode(clipFrame.id, { rotation: 90 })
// Child at local (10,10,80,80) — a large child whose AABB (after 90°
// rotation) is [10,90]x[10,90], entirely inside the [0,100]x[0,100] clip.
rect(graph, 'Center', clipFrame.id, 10, 10, 80, 80)
// Outside node at (110,10,20,20) — its AABB [110,130]x[10,30] does NOT
// overlap the child's clipped AABB [10,90]x[10,90] and does NOT overlap
// the clip frame's AABB [0,100]x[0,100]. With the BUGGY code, the child's
// clipped bounds were [100,170]x[10,90] (wrong), which WOULD overlap
// [110,130]x[10,30] — a false positive.
rect(graph, 'Outside', page, 110, 10, 20, 20)
const result = computeOverlaps(graph, { category: 'sibling-overlap' })
expect(result.summary.overlapCount).toBe(0)
})
test('a stroked rotated node expands bounds along the rotated axes', () => {
const graph = new SceneGraph()
const page = pageId(graph)
// 10x10 rectangle at (45,45) rotated 45° around its center (50,50). Its
// unstroked canvas AABB is [42.93, 57.07]² (half-diagonal ≈ 7.07).
// An OUTSIDE stroke of weight 20 expands the LOCAL box by 20 on each side;
// after 45° rotation the canvas-space expansion is 20*(|cos45|+|sin45|) ≈
// 28.28 per side, so the stroked AABB is [14.64, 85.36]². The OLD code
// expanded the already-rotated AABB by 20 → only [22.93, 77.07]².
const stroked = graph.createNode('RECTANGLE', page, {
name: 'RotatedStroked',
x: 45,
y: 45,
width: 10,
height: 10,
rotation: 45,
strokes: [
{
color: { r: 0, g: 0, b: 0, a: 1 },
weight: 20,
opacity: 1,
visible: true,
align: 'OUTSIDE'
}
]
})
// Target at canvas (18, 48, 2, 2): inside the rotation-aware stroked AABB
// (18 > 14.64) but outside both the unstroked AABB (18 < 42.93) and the
// OLD naive expansion (18 < 22.93). Only the rotation-aware stroke reaches it.
rect(graph, 'GapTarget', page, 18, 48, 2, 2)
const result = computeOverlaps(graph, { category: 'sibling-overlap' })
expect(result.summary.overlapCount).toBeGreaterThan(0)
expect(
result.overlaps.some((o) => o.nodeA.id === stroked.id || o.nodeB.id === stroked.id)
).toBe(true)
// Without the stroke, the same target must NOT overlap — proving the reach
// into the gap is caused by the stroked bounds, not the raw node box.
const plain = new SceneGraph()
const plainPage = pageId(plain)
plain.createNode('RECTANGLE', plainPage, {
name: 'RotatedPlain',
x: 45,
y: 45,
width: 10,
height: 10,
rotation: 45
})
rect(plain, 'GapTarget', plainPage, 18, 48, 2, 2)
const plainResult = computeOverlaps(plain, { category: 'sibling-overlap' })
expect(plainResult.summary.overlapCount).toBe(0)
})
})