openpencil/tests/engine/tools/analyze/overlaps/index.test.ts
Joseph Cumines ad22513bc7 fix(tools): address CodeRabbit review on analyze_overlaps
- Export OverlapScope, AnalyzeOverlapsSummary, and OverlapIntersection
  from the analyze barrel so consumers don't deep-import.
- Expand the local rectangle by stroke overflow before transforming
  through the world matrix, so stroked rotated nodes get their true
  rotated footprint instead of an underestimated canvas-space expansion.
- Add clipPolygon to geometry and preserve the clipped polygon through
  the full clip chain in computeNodeBounds, so a later outer clip can't
  reintroduce corners an inner clip already removed.
- Cap returned overlaps to an empty list when limit is 0 or negative;
  summary totals still reflect the complete result set.
- Trim whitespace from scope and severity inputs so " major " resolves.
- Add geometry and overlap tests covering each fix.
2026-06-19 11:38:38 +10:00

466 lines
17 KiB
TypeScript

import { describe, expect, test } from 'bun:test'
import { FigmaAPI, SceneGraph } from '@open-pencil/core'
import {
analyzeOverlaps,
computeOverlaps,
type OverlapScope
} from '@open-pencil/core/tools/analyze/overlaps'
import { frame, pageId, rect, text } from './helpers'
describe('analyze overlaps', () => {
test('detects overlapping sibling rectangles', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const parent = frame(graph, 'Parent', page, 0, 0, 200, 200)
rect(graph, 'A', parent.id, 0, 0, 100, 100)
rect(graph, 'B', parent.id, 50, 50, 100, 100)
const result = computeOverlaps(graph, { scope: 'same-parent' })
expect(result.summary.overlapCount).toBeGreaterThan(0)
expect(result.overlaps.some((o) => o.category === 'sibling-overlap')).toBe(true)
})
test('ignores separated rectangles', () => {
const graph = new SceneGraph()
const page = pageId(graph)
rect(graph, 'A', page, 0, 0, 50, 50)
rect(graph, 'B', page, 200, 200, 50, 50)
const result = computeOverlaps(graph)
expect(result.summary.overlapCount).toBe(0)
})
test('skips hidden and locked nodes by default', () => {
const graph = new SceneGraph()
const page = pageId(graph)
rect(graph, 'A', page, 0, 0, 100, 100)
const hidden = rect(graph, 'Hidden', page, 50, 50, 100, 100)
graph.updateNode(hidden.id, { visible: false })
const result = computeOverlaps(graph)
expect(result.summary.overlapCount).toBe(0)
})
test('includes hidden nodes when include_hidden is true', () => {
const graph = new SceneGraph()
const page = pageId(graph)
rect(graph, 'A', page, 0, 0, 100, 100)
const hidden = rect(graph, 'Hidden', page, 50, 50, 100, 100)
graph.updateNode(hidden.id, { visible: false })
const result = computeOverlaps(graph, { include_hidden: true })
expect(result.summary.overlapCount).toBeGreaterThan(0)
})
test('reports text that overflows a non-clipping frame', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const parent = frame(graph, 'Frame', page, 0, 0, 80, 80)
text(graph, 'Wide', parent.id, 0, 0, 200, 20)
const result = computeOverlaps(graph)
expect(result.overlaps.some((o) => o.category === 'parent-overflow')).toBe(true)
})
test('reports a child that is fully outside its non-clipping parent', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const parent = frame(graph, 'Frame', page, 0, 0, 100, 100)
rect(graph, 'Stray', parent.id, 200, 0, 50, 50)
const result = computeOverlaps(graph)
expect(result.overlaps).toHaveLength(1)
const overflow = result.overlaps[0]
expect(overflow.category).toBe('parent-overflow')
expect(overflow.area).toBe(2500)
expect(overflow.ratio).toBe(1)
})
test('does not report overflow when parent clips content', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const parent = frame(graph, 'Frame', page, 0, 0, 80, 80, true)
text(graph, 'Wide', parent.id, 0, 0, 200, 20)
const result = computeOverlaps(graph)
expect(result.overlaps).toHaveLength(0)
})
test('detects rotated overlap', () => {
const graph = new SceneGraph()
const page = pageId(graph)
rect(graph, 'A', page, 0, 0, 100, 20)
const rotated = rect(graph, 'B', page, 0, 0, 100, 20)
graph.updateNode(rotated.id, { rotation: 45 })
const result = computeOverlaps(graph)
expect(result.summary.overlapCount).toBeGreaterThan(0)
})
test('detects a suspicious overlay covering a smaller sibling', () => {
const graph = new SceneGraph()
const page = pageId(graph)
// Create the smaller node first so the later (and therefore visually on-top) larger node
// genuinely covers it.
rect(graph, 'Badge', page, 10, 10, 10, 10)
rect(graph, 'Backdrop', page, 0, 0, 100, 100)
const result = computeOverlaps(graph)
expect(result.overlaps.some((o) => o.category === 'overlay')).toBe(true)
const overlay = result.overlaps.find((o) => o.category === 'overlay')
expect(overlay?.message).toContain('"Backdrop" appears to be an overlay covering "Badge"')
})
test('filters by node type', () => {
const graph = new SceneGraph()
const page = pageId(graph)
rect(graph, 'A', page, 0, 0, 100, 100)
const t = text(graph, 'B', page, 50, 50, 100, 100)
const result = computeOverlaps(graph, { type: 'TEXT' })
expect(result.overlaps.every((o) => o.nodeA.id === t.id || o.nodeB.id === t.id)).toBe(true)
})
test('respects min_area threshold', () => {
const graph = new SceneGraph()
const page = pageId(graph)
rect(graph, 'A', page, 0, 0, 100, 100)
rect(graph, 'B', page, 99, 99, 2, 2)
const result = computeOverlaps(graph, { min_area: 100 })
expect(result.summary.overlapCount).toBe(0)
})
test('does not flag ancestor-descendant overlap', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const parent = frame(graph, 'Frame', page, 0, 0, 200, 200)
rect(graph, 'Child', parent.id, 0, 0, 100, 100)
const result = computeOverlaps(graph)
expect(result.summary.overlapCount).toBe(0)
})
test('scope=same-parent only reports siblings sharing a parent', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const parentA = frame(graph, 'ParentA', page, 0, 0, 200, 200)
const parentB = frame(graph, 'ParentB', page, 300, 0, 200, 200)
rect(graph, 'A1', parentA.id, 0, 0, 100, 100)
rect(graph, 'A2', parentA.id, 50, 50, 100, 100)
rect(graph, 'B1', parentB.id, 0, 0, 100, 100)
rect(graph, 'B2', parentB.id, 50, 50, 100, 100)
const result = computeOverlaps(graph, { scope: 'same-parent', category: 'sibling-overlap' })
expect(result.summary.overlapCount).toBe(2)
expect(
result.overlaps.every(
(o) => o.nodeA.parentId === o.nodeB.parentId && o.nodeA.parentId !== page
)
).toBe(true)
})
test('scope=cross-parent only reports nodes from different parents', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const parentA = frame(graph, 'ParentA', page, 0, 0, 200, 200)
const parentB = frame(graph, 'ParentB', page, 300, 0, 200, 200)
rect(graph, 'A', parentA.id, 150, 0, 110, 100)
rect(graph, 'B', parentB.id, -60, 0, 110, 100)
const result = computeOverlaps(graph, { scope: 'cross-parent', category: 'sibling-overlap' })
expect(result.summary.overlapCount).toBe(1)
expect(result.overlaps[0].nodeA.parentId).not.toBe(result.overlaps[0].nodeB.parentId)
})
test('scope=top-level only reports direct page children', () => {
const graph = new SceneGraph()
const page = pageId(graph)
rect(graph, 'TopA', page, 0, 0, 100, 100)
rect(graph, 'TopB', page, 50, 50, 100, 100)
const parent = frame(graph, 'Parent', page, 300, 300, 200, 200)
rect(graph, 'InnerA', parent.id, 0, 0, 100, 100)
rect(graph, 'InnerB', parent.id, 50, 50, 100, 100)
const result = computeOverlaps(graph, { scope: 'top-level' })
expect(result.summary.overlapCount).toBe(1)
expect(result.overlaps[0].nodeA.parentId).toBe(page)
expect(result.overlaps[0].nodeB.parentId).toBe(page)
})
test('scope=inside-parent only reports children inside a non-page parent', () => {
const graph = new SceneGraph()
const page = pageId(graph)
rect(graph, 'TopA', page, 0, 0, 100, 100)
rect(graph, 'TopB', page, 50, 50, 100, 100)
const parent = frame(graph, 'Parent', page, 300, 300, 200, 200)
rect(graph, 'InnerA', parent.id, 0, 0, 100, 100)
rect(graph, 'InnerB', parent.id, 50, 50, 100, 100)
const result = computeOverlaps(graph, { scope: 'inside-parent' })
expect(result.summary.overlapCount).toBe(1)
expect(result.overlaps[0].nodeA.parentId).toBe(parent.id)
expect(result.overlaps[0].nodeB.parentId).toBe(parent.id)
})
test('respects min_ratio threshold', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const parent = frame(graph, 'Parent', page, 0, 0, 200, 200)
rect(graph, 'A', parent.id, 0, 0, 100, 100)
rect(graph, 'B', parent.id, 50, 50, 100, 100)
const excluded = computeOverlaps(graph, { scope: 'same-parent', min_ratio: 0.5 })
expect(excluded.overlaps).toHaveLength(0)
const included = computeOverlaps(graph, { scope: 'same-parent', min_ratio: 0.1 })
expect(included.overlaps.length).toBeGreaterThan(0)
})
test('excludes locked nodes by default', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const locked = rect(graph, 'Locked', page, 0, 0, 100, 100)
graph.updateNode(locked.id, { locked: true })
rect(graph, 'A', page, 50, 50, 100, 100)
const result = computeOverlaps(graph)
expect(
result.overlaps.some((o) => o.nodeA.name === 'Locked' || o.nodeB.name === 'Locked')
).toBe(false)
})
test('includes locked nodes when include_locked is true', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const locked = rect(graph, 'Locked', page, 0, 0, 100, 100)
graph.updateNode(locked.id, { locked: true })
rect(graph, 'A', page, 50, 50, 100, 100)
const result = computeOverlaps(graph, { include_locked: true })
expect(
result.overlaps.some((o) => o.nodeA.name === 'Locked' || o.nodeB.name === 'Locked')
).toBe(true)
})
test('excludes absolutely-positioned nodes by default', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const absolute = rect(graph, 'Absolute', page, 0, 0, 100, 100)
graph.updateNode(absolute.id, { layoutPositioning: 'ABSOLUTE' })
rect(graph, 'A', page, 50, 50, 100, 100)
const result = computeOverlaps(graph)
expect(
result.overlaps.some((o) => o.nodeA.name === 'Absolute' || o.nodeB.name === 'Absolute')
).toBe(false)
})
test('includes absolutely-positioned nodes when include_absolute is true', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const absolute = rect(graph, 'Absolute', page, 0, 0, 100, 100)
graph.updateNode(absolute.id, { layoutPositioning: 'ABSOLUTE' })
rect(graph, 'A', page, 50, 50, 100, 100)
const result = computeOverlaps(graph, { include_absolute: true })
expect(
result.overlaps.some((o) => o.nodeA.name === 'Absolute' || o.nodeB.name === 'Absolute')
).toBe(true)
})
test('filters results by page name', () => {
const graph = new SceneGraph()
const page1 = pageId(graph)
const page2 = graph.addPage('Page 2')
const a1 = rect(graph, 'A1', page1, 0, 0, 100, 100)
const a2 = rect(graph, 'A2', page1, 50, 50, 100, 100)
const b1 = rect(graph, 'B1', page2.id, 0, 0, 100, 100)
const b2 = rect(graph, 'B2', page2.id, 50, 50, 100, 100)
const page1Result = computeOverlaps(graph, { page: 'Page 1' })
expect(page1Result.overlaps.length).toBeGreaterThan(0)
expect(
page1Result.overlaps.every(
(o) =>
(o.nodeA.id === a1.id || o.nodeA.id === a2.id) &&
(o.nodeB.id === a1.id || o.nodeB.id === a2.id)
)
).toBe(true)
const page2Result = computeOverlaps(graph, { page: 'Page 2' })
expect(page2Result.overlaps.length).toBeGreaterThan(0)
expect(
page2Result.overlaps.every(
(o) =>
(o.nodeA.id === b1.id || o.nodeA.id === b2.id) &&
(o.nodeB.id === b1.id || o.nodeB.id === b2.id)
)
).toBe(true)
const missing = computeOverlaps(graph, { page: 'Missing' })
expect(missing.overlaps).toHaveLength(0)
})
test('scope values are case-insensitive', () => {
const graph = new SceneGraph()
const page = pageId(graph)
rect(graph, 'A', page, 0, 0, 100, 100)
rect(graph, 'B', page, 50, 50, 100, 100)
const result = computeOverlaps(graph, { scope: 'SAME-PARENT' as OverlapScope })
expect(result.overlaps.length).toBeGreaterThan(0)
})
test('scope and severity inputs are trimmed of surrounding whitespace', () => {
const graph = new SceneGraph()
const page = pageId(graph)
rect(graph, 'A', page, 0, 0, 100, 100)
rect(graph, 'B', page, 50, 50, 100, 100)
const scoped = computeOverlaps(graph, { scope: ' same-parent ' as OverlapScope })
expect(scoped.overlaps.length).toBeGreaterThan(0)
const severityCapped = computeOverlaps(graph, {
scope: ' same-parent ' as OverlapScope,
severity: ' minor ' as OverlapSeverity
})
// Both overlaps are minor sibling-overlaps, so trimming severity keeps them.
expect(severityCapped.overlaps.length).toBe(scoped.overlaps.length)
})
test('ToolDef defaults to current page and does not report overlaps on other pages', () => {
const graph = new SceneGraph()
const page2 = graph.addPage('Page 2')
rect(graph, 'A', page2.id, 0, 0, 100, 100)
rect(graph, 'B', page2.id, 50, 50, 100, 100)
const api = new FigmaAPI(graph)
const result = analyzeOverlaps.execute(api, {})
expect(result.summary.overlapCount).toBe(0)
expect(result.overlaps).toHaveLength(0)
})
test('ToolDef respects explicit page argument', () => {
const graph = new SceneGraph()
const page2 = graph.addPage('Page 2')
rect(graph, 'A', page2.id, 0, 0, 100, 100)
rect(graph, 'B', page2.id, 50, 50, 100, 100)
const api = new FigmaAPI(graph)
const result = analyzeOverlaps.execute(api, { page: 'Page 2' })
expect(result.summary.overlapCount).toBeGreaterThan(0)
})
test('scope=top-level excludes nested parent-overflow', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const parent = frame(graph, 'Frame', page, 0, 0, 80, 80)
text(graph, 'Wide', parent.id, 0, 0, 200, 20)
const all = computeOverlaps(graph)
expect(all.overlaps.some((o) => o.category === 'parent-overflow')).toBe(true)
const topLevel = computeOverlaps(graph, { scope: 'top-level' })
expect(topLevel.overlaps.some((o) => o.category === 'parent-overflow')).toBe(false)
})
test('scope=inside-parent includes nested parent-overflow', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const parent = frame(graph, 'Frame', page, 0, 0, 80, 80)
text(graph, 'Wide', parent.id, 0, 0, 200, 20)
const result = computeOverlaps(graph, { scope: 'inside-parent' })
expect(result.overlaps.some((o) => o.category === 'parent-overflow')).toBe(true)
})
test('skips nodes inside a hidden ancestor by default', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const hiddenParent = frame(graph, 'HiddenParent', page, 0, 0, 200, 200)
graph.updateNode(hiddenParent.id, { visible: false })
rect(graph, 'A', hiddenParent.id, 0, 0, 100, 100)
rect(graph, 'B', hiddenParent.id, 50, 50, 100, 100)
const result = computeOverlaps(graph)
expect(result.summary.overlapCount).toBe(0)
})
test('includes nodes inside a hidden ancestor when include_hidden is true', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const hiddenParent = frame(graph, 'HiddenParent', page, 0, 0, 200, 200)
graph.updateNode(hiddenParent.id, { visible: false })
rect(graph, 'A', hiddenParent.id, 0, 0, 100, 100)
rect(graph, 'B', hiddenParent.id, 50, 50, 100, 100)
const result = computeOverlaps(graph, { include_hidden: true })
expect(result.summary.overlapCount).toBeGreaterThan(0)
})
test('skips nodes inside a locked ancestor by default', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const lockedParent = frame(graph, 'LockedParent', page, 0, 0, 200, 200)
graph.updateNode(lockedParent.id, { locked: true })
rect(graph, 'A', lockedParent.id, 0, 0, 100, 100)
rect(graph, 'B', lockedParent.id, 50, 50, 100, 100)
const result = computeOverlaps(graph)
expect(result.summary.overlapCount).toBe(0)
})
test('summary totals reflect full result set when a limit is applied', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const parent = frame(graph, 'Frame', page, 0, 0, 200, 200)
for (let i = 0; i < 4; i++) {
rect(graph, `R${i}`, parent.id, 0, 0, 100, 100)
}
const result = computeOverlaps(graph, { scope: 'inside-parent', limit: 2 })
expect(result.overlaps.length).toBe(2)
expect(result.summary.overlapCount).toBeGreaterThan(2)
const totalByCategory = Object.values(result.summary.byCategory).reduce((a, b) => a + b, 0)
expect(totalByCategory).toBe(result.summary.overlapCount)
const totalBySeverity = Object.values(result.summary.bySeverity).reduce((a, b) => a + b, 0)
expect(totalBySeverity).toBe(result.summary.overlapCount)
})
test('limit of zero returns no overlaps while the summary still reports the full set', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const parent = frame(graph, 'Frame', page, 0, 0, 200, 200)
for (let i = 0; i < 4; i++) {
rect(graph, `R${i}`, parent.id, 0, 0, 100, 100)
}
const result = computeOverlaps(graph, { scope: 'inside-parent', limit: 0 })
expect(result.overlaps).toHaveLength(0)
expect(result.summary.overlapCount).toBeGreaterThan(0)
})
test('a negative limit is clamped to zero and returns no overlaps', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const parent = frame(graph, 'Frame', page, 0, 0, 200, 200)
for (let i = 0; i < 4; i++) {
rect(graph, `R${i}`, parent.id, 0, 0, 100, 100)
}
const result = computeOverlaps(graph, { scope: 'inside-parent', limit: -3 })
expect(result.overlaps).toHaveLength(0)
expect(result.summary.overlapCount).toBeGreaterThan(0)
})
})