import { describe, expect, mock, test } from 'bun:test' import { SceneGraph } from '@open-pencil/scene-graph' import Matrix, { type Mat3 } from '@open-pencil/scene-graph/matrix' import type { SkiaRenderer } from '#core/canvas/renderer' import { renderNode } from '#core/canvas/scene' import { expectDefined } from '#tests/helpers/assert' import { asCanvas, asRenderer } from './helpers' function pageId(graph: SceneGraph) { return graph.getPages()[0].id } function createCanvas() { return { save: mock(() => undefined), restore: mock(() => undefined), translate: mock(() => undefined), concat: mock((_matrix: Mat3) => undefined), rotate: mock(() => undefined), scale: mock(() => undefined), saveLayer: mock(() => undefined), clipRect: mock(() => undefined), clipRRect: mock(() => undefined) } } function createRenderer() { const rendered: string[] = [] const renderer = { _nodeCount: 0, _culledCount: 0, worldViewport: { x: 900, y: 900, w: 300, h: 300 }, opacityPaint: { setAlphaf: mock(() => undefined) }, effectLayerPaint: { setImageFilter: mock(() => undefined), setColorFilter: mock(() => undefined), setBlendMode: mock(() => undefined) }, ck: { BlendMode: { SrcOver: 'SrcOver' }, LTRBRect: mock((left: number, top: number, right: number, bottom: number) => [ left, top, right, bottom ]), ClipOp: { Intersect: 'Intersect' } }, getCachedBlur: mock(() => null), renderShape: mock((_canvas, node) => { rendered.push(node.id) }), renderSection: mock((_canvas, node) => { rendered.push(node.id) }), renderComponentSet: mock((_canvas, node) => { rendered.push(node.id) }), renderNode(...args: Parameters) { renderNode(asRenderer(this), ...args) } } return { renderer: asRenderer(renderer), rendered } } describe('canvas culling', () => { test('uses accumulated absolute position for nested children', () => { const graph = new SceneGraph() const frame = graph.createNode('FRAME', pageId(graph), { x: 1000, y: 1000, width: 200, height: 200 }) const text = graph.createNode('TEXT', frame.id, { x: 0, y: 0, width: 100, height: 24, text: 'Visible nested text' }) const { renderer, rendered } = createRenderer() renderNode(renderer, asCanvas(createCanvas()), graph, frame.id, {}) expect(rendered).toContain(frame.id) expect(rendered).toContain(text.id) expect(renderer._culledCount).toBe(0) }) test('uses world bounds for children of transformed instances', () => { const graph = new SceneGraph() const instance = graph.createNode('INSTANCE', pageId(graph), { x: 1000, y: 1000, width: 100, height: 800, rotation: 90 }) const connector = graph.createNode('VECTOR', instance.id, { x: 0, y: 700, width: 100, height: 20 }) const { renderer, rendered } = createRenderer() renderer.worldViewport = { x: 700, y: 1300, w: 300, h: 300 } renderNode(renderer, asCanvas(createCanvas()), graph, instance.id, {}) expect(rendered).toContain(connector.id) expect(renderer._culledCount).toBe(0) }) test('culling follows an ancestor rotation preview before the graph commits', () => { const graph = new SceneGraph() const instance = graph.createNode('INSTANCE', pageId(graph), { x: 1000, y: 1000, width: 100, height: 800 }) const connector = graph.createNode('VECTOR', instance.id, { x: 0, y: 700, width: 100, height: 20 }) const { renderer, rendered } = createRenderer() renderer.worldViewport = { x: 700, y: 1300, w: 300, h: 300 } renderNode(renderer, asCanvas(createCanvas()), graph, instance.id, { rotationPreview: { nodeId: instance.id, angle: 90 } }) expect(rendered).toContain(connector.id) expect(instance.rotation).toBe(0) }) test('applies reflection before rotation like the scene transform matrix', () => { const graph = new SceneGraph() const vector = graph.createNode('VECTOR', pageId(graph), { width: 100, height: 50, rotation: 90, flipX: true }) const { renderer } = createRenderer() renderer.worldViewport = { x: -100, y: -100, w: 300, h: 300 } const canvas = createCanvas() renderNode(renderer, asCanvas(canvas), graph, vector.id, {}) const matrix = expectDefined(canvas.concat.mock.calls[0]?.[0], 'drawing transform') const start = Matrix.mapPoint(matrix, { x: 0, y: 0 }) const end = Matrix.mapPoint(matrix, { x: 100, y: 50 }) expect(start.x).toBeCloseTo(25, 9) expect(start.y).toBeCloseTo(-25, 9) expect(end.x).toBeCloseTo(75, 9) expect(end.y).toBeCloseTo(75, 9) }) })