import { describe, expect, mock, test } from 'bun:test' import type { Canvas } from 'canvaskit-wasm' import { SceneGraph } from '@open-pencil/scene-graph' import type { SkiaRenderer } from '#core/canvas/renderer' import { renderNode } from '#core/canvas/scene' function pageId(graph: SceneGraph) { return graph.getPages()[0].id } function createCanvas() { return { save: mock(() => undefined), restore: mock(() => undefined), translate: mock(() => 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: -100, y: -100, w: 1000, h: 1000 }, ck: { BlendMode: { SrcOver: 'SrcOver', DstIn: 'DstIn' }, LTRBRect: mock(() => new Float32Array(4)), ClipOp: { Intersect: 'Intersect' }, ColorFilter: { MakeLuma: mock(() => ({ delete: mock(() => undefined) })) } }, opacityPaint: { setAlphaf: mock(() => undefined), setBlendMode: mock(() => undefined) }, effectLayerPaint: { setImageFilter: mock(() => undefined), setColorFilter: mock(() => undefined), setBlendMode: mock(() => undefined) }, getCachedBlur: mock(() => null), renderShape: mock((_canvas: Canvas, node) => { rendered.push(node.id) }), renderSection: mock((_canvas: Canvas, node) => { rendered.push(node.id) }), renderComponentSet: mock((_canvas: Canvas, node) => { rendered.push(node.id) }), renderNode(canvas, graph, nodeId, overlays, parentAbsX, parentAbsY) { renderNode(this as SkiaRenderer, canvas, graph, nodeId, overlays, parentAbsX, parentAbsY) } } return { renderer: renderer as SkiaRenderer, rendered } } describe('canvas masks', () => { test('uses a visible mask node to clip following siblings', () => { const graph = new SceneGraph() const frame = graph.createNode('FRAME', pageId(graph), { width: 200, height: 200 }) const below = graph.createNode('RECTANGLE', frame.id, { width: 200, height: 200 }) const mask = graph.createNode('ELLIPSE', frame.id, { width: 100, height: 100, isMask: true }) const clipped = graph.createNode('RECTANGLE', frame.id, { width: 200, height: 200 }) const { renderer, rendered } = createRenderer() const canvas = createCanvas() renderNode(renderer, canvas as Canvas, graph, frame.id, {}) expect(rendered).toEqual([frame.id, below.id, clipped.id, mask.id]) expect(renderer.effectLayerPaint.setBlendMode).toHaveBeenCalledWith('DstIn') expect(renderer.effectLayerPaint.setBlendMode).toHaveBeenLastCalledWith('SrcOver') expect(canvas.saveLayer).toHaveBeenCalledTimes(2) expect(renderer.ck.LTRBRect).toHaveBeenCalledWith(0, 0, 200, 200) for (const call of canvas.saveLayer.mock.calls) { expect(call[1]).toBeInstanceOf(Float32Array) } }) test('combines consecutive mask nodes before clipping following siblings', () => { const graph = new SceneGraph() const frame = graph.createNode('FRAME', pageId(graph), { width: 200, height: 200 }) const firstMask = graph.createNode('RECTANGLE', frame.id, { width: 80, height: 80, isMask: true }) const secondMask = graph.createNode('ELLIPSE', frame.id, { width: 80, height: 80, isMask: true }) const clipped = graph.createNode('RECTANGLE', frame.id, { width: 200, height: 200 }) const nextMask = graph.createNode('RECTANGLE', frame.id, { width: 40, height: 40, isMask: true }) const nextClipped = graph.createNode('RECTANGLE', frame.id, { width: 100, height: 100 }) const { renderer, rendered } = createRenderer() const canvas = createCanvas() renderNode(renderer, canvas as Canvas, graph, frame.id, {}) expect(rendered).toEqual([ frame.id, clipped.id, firstMask.id, secondMask.id, nextClipped.id, nextMask.id ]) expect(canvas.saveLayer).toHaveBeenCalledTimes(4) }) test('applies luminance masks through a luma color filter', () => { const graph = new SceneGraph() const frame = graph.createNode('FRAME', pageId(graph), { width: 200, height: 200 }) const mask = graph.createNode('RECTANGLE', frame.id, { width: 100, height: 100, isMask: true, maskType: 'LUMINANCE' }) const clipped = graph.createNode('RECTANGLE', frame.id, { width: 200, height: 200 }) const { renderer, rendered } = createRenderer() const canvas = createCanvas() renderNode(renderer, canvas as Canvas, graph, frame.id, {}) expect(rendered).toEqual([frame.id, clipped.id, mask.id]) expect(renderer.ck.ColorFilter.MakeLuma).toHaveBeenCalled() expect(renderer.effectLayerPaint.setColorFilter).toHaveBeenCalledWith(expect.any(Object)) expect(renderer.effectLayerPaint.setColorFilter).toHaveBeenLastCalledWith(null) }) test('does not draw mask nodes as ordinary layers', () => { const graph = new SceneGraph() const mask = graph.createNode('RECTANGLE', pageId(graph), { width: 100, height: 100, isMask: true }) const { renderer, rendered } = createRenderer() renderNode(renderer, createCanvas() as Canvas, graph, mask.id, {}) expect(rendered).toEqual([]) }) })