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.
238 lines
9.2 KiB
TypeScript
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)
|
|
})
|
|
})
|