openpencil/tests/engine/cli/overlaps.test.ts
Joseph Cumines f862beb08d Add heuristic overlap detection tool and CLI
Implement the analyze overlaps CLI command, RPC endpoint, and ToolDef
to enable heuristic detection of visual overlaps, parent overflows, and
overlay patterns. Geometry computation now uses world-matrix visual
bounds to handle nested ancestor rotations and clipping frames correctly.

Update file-backed CLI commands to use Node fs/promises instead of Bun
runtime file APIs. This is required for the published CLI to function
when installed and executed in a standard Node environment. Add the
package.json subpath export to the core package for metadata consumers.
2026-06-19 09:54:27 +10:00

163 lines
5.4 KiB
TypeScript

import { expect, setDefaultTimeout, test } from 'bun:test'
import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { SceneGraph } from '@open-pencil/core'
import { exportFigFile } from '@open-pencil/core/io/formats/fig'
import { cliSourcePath, repoPath, requireBuiltWorkspacePackages } from '#tests/helpers/paths'
import { heavy } from '#tests/helpers/test-utils'
requireBuiltWorkspacePackages()
setDefaultTimeout(30_000)
const CLI = cliSourcePath('index.ts')
const FIXTURE = repoPath('tests/fixtures/gold-preview.fig')
async function makeTwoPageFixture(): Promise<string> {
const graph = new SceneGraph()
const page1 = graph.getPages()[0].id
const page2 = graph.addPage('Page 2')
graph.createNode('RECTANGLE', page1, { name: 'A', x: 0, y: 0, width: 100, height: 100 })
graph.createNode('RECTANGLE', page1, { name: 'B', x: 50, y: 50, width: 100, height: 100 })
graph.createNode('RECTANGLE', page2.id, { name: 'C', x: 0, y: 0, width: 100, height: 100 })
graph.createNode('RECTANGLE', page2.id, { name: 'D', x: 50, y: 50, width: 100, height: 100 })
const data = await exportFigFile(graph)
const dir = mkdtempSync(join(tmpdir(), 'overlap-cli-'))
const file = join(dir, 'two-pages.fig')
writeFileSync(file, data)
return file
}
async function run(args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const proc = Bun.spawn(['bun', CLI, ...args], {
stdout: 'pipe',
stderr: 'pipe'
})
const [stdout, stderr] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text()
])
const exitCode = await proc.exited
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode }
}
heavy('analyze overlaps CLI', () => {
test('--json returns valid overlap data', async () => {
const { stdout, exitCode } = await run(['analyze', 'overlaps', FIXTURE, '--json'])
expect(exitCode).toBe(0)
const data = JSON.parse(stdout)
expect(data.overlaps).toBeArray()
expect(typeof data.summary.totalNodes).toBe('number')
expect(typeof data.summary.analyzedNodes).toBe('number')
expect(typeof data.summary.overlapCount).toBe('number')
})
test('human-readable mode prints a summary', async () => {
const { stdout, exitCode } = await run(['analyze', 'overlaps', FIXTURE])
expect(exitCode).toBe(0)
expect(stdout.length).toBeGreaterThan(0)
expect(stdout).toContain('analyzed nodes')
})
test('invalid scope exits with error', async () => {
const { exitCode, stderr } = await run(['analyze', 'overlaps', FIXTURE, '--scope', 'invalid'])
expect(exitCode).not.toBe(0)
expect(stderr).toContain('Invalid scope')
})
test('invalid severity exits with error', async () => {
const { exitCode, stderr } = await run([
'analyze',
'overlaps',
FIXTURE,
'--severity',
'invalid'
])
expect(exitCode).not.toBe(0)
expect(stderr).toContain('Invalid severity')
})
test('invalid category exits with error', async () => {
const { exitCode, stderr } = await run(['analyze', 'overlaps', FIXTURE, '--category', 'bogus'])
expect(exitCode).not.toBe(0)
expect(stderr).toContain('Invalid categor')
})
test('invalid --min-area exits with error', async () => {
const { exitCode, stderr } = await run(['analyze', 'overlaps', FIXTURE, '--min-area', 'abc'])
expect(exitCode).not.toBe(0)
expect(stderr).toContain('--min-area')
})
test('invalid --limit exits with error', async () => {
const { exitCode, stderr } = await run(['analyze', 'overlaps', FIXTURE, '--limit', '0'])
expect(exitCode).not.toBe(0)
expect(stderr).toContain('--limit')
})
test('--category filters results', async () => {
const { stdout, exitCode } = await run([
'analyze',
'overlaps',
FIXTURE,
'--json',
'--category',
'parent-overflow'
])
expect(exitCode).toBe(0)
const data = JSON.parse(stdout)
expect(data.overlaps.every((o: { category: string }) => o.category === 'parent-overflow')).toBe(
true
)
})
test('scope argument is case-insensitive', async () => {
const { stdout, exitCode } = await run([
'analyze',
'overlaps',
FIXTURE,
'--json',
'--scope',
'SAME-PARENT'
])
expect(exitCode).toBe(0)
const data = JSON.parse(stdout)
expect(data.overlaps).toBeArray()
expect(typeof data.summary.totalNodes).toBe('number')
})
test('default mode scopes analysis to the first page only', async () => {
const fixture = await makeTwoPageFixture()
const { stdout, exitCode } = await run(['analyze', 'overlaps', fixture, '--json'])
expect(exitCode).toBe(0)
const data = JSON.parse(stdout)
expect(data.summary.overlapCount).toBe(1)
expect(data.summary.totalNodes).toBe(2)
expect(data.overlaps[0].nodeA.name).toBe('A')
expect(data.overlaps[0].nodeB.name).toBe('B')
})
test('--page scopes analysis to the named page', async () => {
const fixture = await makeTwoPageFixture()
const { stdout, exitCode } = await run([
'analyze',
'overlaps',
fixture,
'--json',
'--page',
'Page 2'
])
expect(exitCode).toBe(0)
const data = JSON.parse(stdout)
expect(data.summary.overlapCount).toBe(1)
expect(data.summary.totalNodes).toBe(2)
expect(data.overlaps[0].nodeA.name).toBe('C')
expect(data.overlaps[0].nodeB.name).toBe('D')
})
})