import { expect, mock, spyOn, test } from 'bun:test' import type { Canvas, Image as CKImage, ImageInfo, Paint, Surface } from 'canvaskit-wasm' import { getAbsolutePositionFull, SceneGraph } from '@open-pencil/scene-graph' import type { SkiaRenderer } from '#core/canvas/renderer' import { computeRetainedSubtreeBounds, renderSceneBacking } from '#core/canvas/renderer/retained-backing' import { asCanvas, asCanvasKit, asDouble, asRenderer } from './helpers' function createRenderer(surfaceFactory: (info: ImageInfo) => Surface | null) { const renderer: Partial = { ck: asCanvasKit({ AlphaType: { Premul: 'Premul' }, ColorSpace: { SRGB: 'SRGB' }, ColorType: { RGBA_8888: 'RGBA_8888' }, Color4f: mock((r: number, g: number, b: number, a: number) => [r, g, b, a]), LTRBRect: mock((left: number, top: number, right: number, bottom: number) => [ left, top, right, bottom ]), FilterMode: { Linear: 'Linear', Nearest: 'Nearest' }, MipmapMode: { None: 'None' } }), surface: asDouble({ makeSurface: mock(surfaceFactory) }), opacityPaint: asDouble({ setAlphaf: mock() }), panX: 0, panY: 0, zoom: 1, dpr: 1, viewportWidth: 100, viewportHeight: 100, pageColor: { r: 1, g: 1, b: 1, a: 1 }, pageId: 'page', navigationPhase: 'idle', sceneBacking: null, sceneBackingBuild: null, sceneBackingAllocationFailed: false, sceneBackingNeedsCrispRender: false, sceneBackingPreviewUntil: 0, sceneBackingAverageRecordMs: 40, sceneBackingAverageViewportIntervalMs: 80, scenePictureVersion: 0, scenePicturePositionPreviewVersion: 0, scenePicturePageId: null, subtreePictureCache: new Map(), subtreePictureCachePageId: null, subtreePictureCacheSceneVersion: 0, subtreePictureCachePositionPreviewVersion: 0, worldViewport: { x: 0, y: 0, w: 0, h: 0 }, renderNode: mock() } return asRenderer(renderer) } function createCanvas() { const canvas: Partial = { drawImageRect: mock(), drawImageRectOptions: mock(), save: mock(), restore: mock(), translate: mock(), scale: mock() } return asCanvas(canvas) } function createGraph(positionPreviewVersion = 0) { return asDouble({ rootId: 'root', positionPreviewVersion, getNode: mock((id: string) => { if (id === 'page') return { id: 'page', type: 'CANVAS', childIds: [] } return null }), getAbsolutePosition: mock(() => ({ x: 0, y: 0 })) }) } test('retained subtree bounds include descendants transformed by rotated ancestors', () => { const graph = new SceneGraph() const page = graph.getPages()[0] if (!page) throw new Error('Expected the default page') const parent = graph.createNode('INSTANCE', page.id, { x: 100, y: 100, width: 100, height: 100, rotation: 90 }) const child = graph.createNode('VECTOR', parent.id, { x: 1000, y: 0, width: 100, height: 10 }) const exact = getAbsolutePositionFull(child, graph) const bounds = computeRetainedSubtreeBounds(graph, parent.id) if (!bounds) throw new Error('Expected retained subtree bounds') expect(bounds.minX).toBeLessThanOrEqual(exact.boundX) expect(bounds.minY).toBeLessThanOrEqual(exact.boundY) expect(bounds.maxX).toBeGreaterThanOrEqual(exact.boundX + exact.width) expect(bounds.maxY).toBeGreaterThanOrEqual(exact.boundY + exact.height) }) test('retained subtree bounds rotate directional effect overflow into world space', () => { const graph = new SceneGraph() const page = graph.getPages()[0] if (!page) throw new Error('Expected the default page') const node = graph.createNode('RECTANGLE', page.id, { x: 100, y: 100, width: 100, height: 50, rotation: 90, effects: [ { type: 'DROP_SHADOW', color: { r: 0, g: 0, b: 0, a: 1 }, offset: { x: 20, y: 0 }, radius: 0, spread: 0, visible: true } ] }) const base = getAbsolutePositionFull(node, graph) const bounds = computeRetainedSubtreeBounds(graph, node.id) if (!bounds) throw new Error('Expected retained subtree bounds') expect(bounds.maxY).toBeCloseTo(base.boundY + base.height + 20) }) test('retained scene backing falls back when CanvasKit cannot create an offscreen surface', () => { const r = createRenderer(() => null) const canvas = createCanvas() const graph = createGraph() expect(renderSceneBacking(r, canvas, graph, 1)).toBe(false) expect(r.surface.makeSurface).toHaveBeenCalled() expect(canvas.drawImageRectOptions).not.toHaveBeenCalled() expect(r.sceneBacking).toBeNull() }) test('retained scene backing bounds wide HiDPI allocations without depending on GPU limits', () => { const requests: ImageInfo[] = [] const r = createRenderer((info) => { requests.push(info) return null }) r.viewportWidth = 2998 r.viewportHeight = 1490 r.dpr = 2 expect(renderSceneBacking(r, createCanvas(), createGraph(), 1)).toBe(false) expect(requests).toHaveLength(1) const request = requests[0] expect(request).toBeDefined() const viewportPixels = Math.ceil(r.viewportWidth * r.dpr) * Math.ceil(r.viewportHeight * r.dpr) expect((request?.width ?? 0) * (request?.height ?? 0)).toBeLessThanOrEqual( Math.max(16_010_000, viewportPixels) ) expect(request?.width).toBe(Math.ceil(r.viewportWidth * r.dpr)) }) test('retained scene backing preserves the full margin when it fits the allocation budget', () => { const requests: ImageInfo[] = [] const r = createRenderer((info) => { requests.push(info) return null }) r.viewportWidth = 800 r.viewportHeight = 600 r.dpr = 1 renderSceneBacking(r, createCanvas(), createGraph(), 1) expect(requests[0]).toMatchObject({ width: 2400, height: 1800 }) }) test('retained scene backing reports a throwing allocation and disables further attempts', () => { const error = new TypeError("Cannot set properties of null (setting 'be')") const r = createRenderer(() => { throw error }) const canvas = createCanvas() const warn = spyOn(console, 'warn').mockImplementation(() => undefined) expect(() => renderSceneBacking(r, canvas, createGraph(), 1)).not.toThrow() expect(renderSceneBacking(r, canvas, createGraph(), 1)).toBe(false) expect(r.sceneBackingAllocationFailed).toBe(true) expect(r.surface.makeSurface).toHaveBeenCalledTimes(1) expect(warn).toHaveBeenCalledTimes(1) expect(warn).toHaveBeenCalledWith( 'Disabling retained scene backing after CanvasKit failed to allocate 300×300', error ) expect(r.sceneBacking).toBeNull() expect(canvas.drawImageRectOptions).not.toHaveBeenCalled() warn.mockRestore() }) test('retained scene backing filters cross-zoom previews instead of falling back to live rendering', () => { const r = createRenderer(() => null) r.zoom = 1 r.sceneBackingPreviewUntil = Number.POSITIVE_INFINITY r.sceneBacking = { anchorPanX: 0, anchorPanY: 0, marginDeviceX: 0, marginDeviceY: 0, image: asDouble({ delete: mock() }), pageId: 'page', sceneVersion: 1, positionPreviewVersion: 0, panX: 0, panY: 0, zoom: 0.5, width: 300, height: 300, dpr: 1, worldX: 0, worldY: 0, worldWidth: 600, worldHeight: 600 } as NonNullable const canvas = createCanvas() const graph = createGraph() expect(renderSceneBacking(r, canvas, graph, 1)).toBe('backing') expect(canvas.drawImageRectOptions).toHaveBeenCalledWith( r.sceneBacking.image, expect.anything(), expect.anything(), r.ck.FilterMode.Linear, r.ck.MipmapMode.None, r.opacityPaint ) }) test('retained scene backing allows same-zoom previews while panning', () => { const r = createRenderer(() => null) r.zoom = 1 r.sceneBackingPreviewUntil = Number.POSITIVE_INFINITY r.sceneBacking = { anchorPanX: 0, anchorPanY: 0, marginDeviceX: 0, marginDeviceY: 0, image: asDouble({ delete: mock() }), pageId: 'page', sceneVersion: 1, positionPreviewVersion: 0, panX: 0, panY: 0, zoom: 1, width: 300, height: 300, dpr: 1, worldX: 0, worldY: 0, worldWidth: 300, worldHeight: 300 } as NonNullable const canvas = createCanvas() const graph = createGraph() expect(renderSceneBacking(r, canvas, graph, 1)).toBe('backing') expect(canvas.drawImageRectOptions).toHaveBeenCalledTimes(1) expect(r.sceneBackingNeedsCrispRender).toBe(true) r.sceneBackingPreviewUntil = 0 expect(renderSceneBacking(r, canvas, graph, 1)).toBe('retained-pictures') expect(r.sceneBackingNeedsCrispRender).toBe(false) expect(canvas.drawImageRectOptions).toHaveBeenCalledTimes(1) expect(r.surface.makeSurface).not.toHaveBeenCalled() }) test('retained scene backing invalidates stale position-preview metadata', () => { const r = createRenderer(() => null) r.sceneBacking = { anchorPanX: 0, anchorPanY: 0, marginDeviceX: 0, marginDeviceY: 0, image: asDouble({ delete: mock() }), pageId: 'page', sceneVersion: 1, positionPreviewVersion: 1, panX: 0, panY: 0, zoom: 1, width: 100, height: 100, dpr: 1, worldX: 0, worldY: 0, worldWidth: 100, worldHeight: 100 } as NonNullable r.scenePicturePositionPreviewVersion = 1 const canvas = createCanvas() const graph = createGraph(2) expect(renderSceneBacking(r, canvas, graph, 1)).toBe(false) expect(canvas.drawImageRectOptions).not.toHaveBeenCalled() })