openpencil/tests/engine/render/canvas/labels/transform.test.ts
Danila Poyarkov 1a2107b0e1 fix(canvas): keep labels readable and refine section badges
Choose readable opposite edges for rotated frame titles and size badges, sharing placement with hit testing. Render section titles as compact inset badges with contrast-aware borders and hover feedback.
2026-09-15 19:54:04 +03:00

117 lines
4.3 KiB
TypeScript

import { expect, test } from 'bun:test'
import type { Font } from 'canvaskit-wasm'
import { SceneGraph } from '@open-pencil/scene-graph'
import { LabelCache } from '#core/canvas/labels/cache'
import { hitTestSectionTitle, hitTestFrameTitle } from '#core/canvas/labels/hit-test'
import { frameLabelPlacement, labelTransform } from '#core/canvas/labels/transform'
import { createSceneGeometry } from '#core/geometry'
import { expectDefined } from '#tests/helpers/assert'
function fixture() {
const graph = new SceneGraph()
const page = expectDefined(graph.getPages()[0], 'page')
const section = graph.createNode('SECTION', page.id, {
name: 'Section',
x: 100,
y: 100,
width: 200,
height: 100
})
const frame = graph.createNode('FRAME', section.id, {
name: 'Frame',
x: 20,
y: 30,
width: 80,
height: 40
})
return { graph, page, section, frame }
}
const font = {
getGlyphIDs(text: string) {
return Uint16Array.from([...text].map((_, i) => i))
},
getGlyphWidths(ids: Uint16Array) {
return new Float32Array(ids.length).fill(8)
}
} as Font
test('label origins and edge anchors include a live ancestor rotation without mutating nodes', () => {
const { graph, section, frame } = fixture()
const preview = { nodeId: section.id, angle: 90 }
const title = labelTransform(frame, graph, preview)
expect(title.x).toBeCloseTo(220)
expect(title.y).toBeCloseTo(70)
expect(title.rotation).toBe(90)
const bottom = labelTransform(frame, graph, preview, { x: frame.width / 2, y: frame.height })
expect(bottom.x).toBeCloseTo(180)
expect(bottom.y).toBeCloseTo(110)
expect(section.rotation).toBe(0)
expect(frame.rotation).toBe(0)
const bounds = createSceneGeometry(graph, preview).bounds(frame)
graph.updateNode(section.id, { rotation: 90 })
expect(labelTransform(frame, graph, null)).toEqual(title)
expect(createSceneGeometry(graph, null).bounds(frame)).toEqual(bounds)
})
for (const [angle, rotation, width, height] of [
[40, 40, 80, 40],
[50, -40, 40, 80],
[140, -40, 80, 40],
[-145, 35, 80, 40],
[180, 0, 80, 40]
] as const) {
test(`frame labels choose readable opposite edges at ${angle} degrees`, () => {
const { graph, frame } = fixture()
graph.updateNode(frame.id, { rotation: angle })
const title = frameLabelPlacement(frame, graph, null)
const size = frameLabelPlacement(frame, graph, null, { x: 0.5, y: 1 })
expect(title.rotation).toBeCloseTo(rotation, 9)
expect(title.width).toBe(width)
expect(title.height).toBe(height)
const radians = (rotation * Math.PI) / 180
const dx = size.x - title.x
const dy = size.y - title.y
expect(dx * Math.cos(radians) + dy * Math.sin(radians)).toBeCloseTo(width / 2, 9)
expect(-dx * Math.sin(radians) + dy * Math.cos(radians)).toBeCloseTo(height, 9)
})
}
test('readability thresholds use identical edges during preview and after commit', () => {
for (const angle of [-135, -45, 45, 135]) {
const { graph, frame } = fixture()
const preview = frameLabelPlacement(frame, graph, { nodeId: frame.id, angle })
graph.updateNode(frame.id, { rotation: angle })
expect(frameLabelPlacement(frame, graph, null)).toEqual(preview)
}
})
test('section culling uses preview world bounds rather than cached unrotated coordinates', () => {
const { graph, page, section } = fixture()
const cache = new LabelCache()
cache.update(graph, page.id, 1)
const viewport = { x: 215, y: 65, w: 10, h: 10 }
expect(cache.getSections(graph, viewport)).toHaveLength(0)
expect(
cache.getSections(graph, viewport, { nodeId: section.id, angle: 90 }).map(({ node }) => node.id)
).toEqual([section.id])
})
test('section and nested frame hit targets follow their rendered title transforms', () => {
const { graph, page, section, frame } = fixture()
graph.updateNode(section.id, { rotation: 90 })
const cache = new LabelCache()
cache.update(graph, page.id, 1)
// Section title local point (4, -10), transformed through its top-left (250, 50).
for (const catalog of [undefined, cache]) {
expect(hitTestSectionTitle(graph, 260, 54, 1, page.id, font, catalog)?.id).toBe(section.id)
}
// The readable frame title moves to the horizontal edge at (180, 70).
expect(hitTestFrameTitle(graph, 184, 60, 1, new Set([frame.id]), font)?.id).toBe(frame.id)
expect(hitTestFrameTitle(graph, 230, 74, 1, new Set([frame.id]), font)).toBeNull()
})