openpencil/tests/engine/render/canvas/effect-raster-integration.test.ts
Danila Poyarkov 11baf4f8f5 refactor(canvas): share bounded resource cache bookkeeping
Share indexed recency, count/weight budgets and native disposal across six caches while preserving domain invalidation and eviction policies. Keep pools, weak memos and dependency-owned resources separate.\n\nAccount for effect pixels incrementally and preserve caller ownership on rejection. Validate disposal, slot reuse, exact integer accounting and unchanged raster output.
2026-09-15 21:28:16 +03:00

219 lines
6.7 KiB
TypeScript

import { beforeAll, describe, expect, test } from 'bun:test'
import { SceneGraph } from '@open-pencil/scene-graph'
import { initCanvasKit } from '#cli/headless'
import { SkiaRenderer } from '#core/canvas'
import {
EffectRasterCache,
type EffectRasterCacheEntry
} from '#core/canvas/renderer/effect-raster-cache'
import { expectDefined } from '#tests/helpers/assert'
let ck: Awaited<ReturnType<typeof initCanvasKit>>
beforeAll(async () => {
ck = await initCanvasKit()
})
function createEffectGraph(effectType: 'DROP_SHADOW' | 'BACKGROUND_BLUR') {
const graph = new SceneGraph()
const page = graph.getPages()[0]
if (!page) throw new Error('Expected default page')
const effect =
effectType === 'DROP_SHADOW'
? {
type: 'DROP_SHADOW' as const,
color: { r: 0.05, g: 0.1, b: 0.2, a: 0.7 },
offset: { x: 8, y: 10 },
radius: 12,
spread: 2,
visible: true
}
: {
type: 'BACKGROUND_BLUR' as const,
color: { r: 0, g: 0, b: 0, a: 0 },
offset: { x: 0, y: 0 },
radius: 12,
spread: 0,
visible: true
}
graph.createNode('RECTANGLE', page.id, {
x: 50,
y: 50,
width: 100,
height: 80,
fills: [
{
type: 'SOLID',
color: { r: 0.3, g: 0.6, b: 0.9, a: 1 },
opacity: 1,
visible: true
}
],
effects: [effect]
})
return { graph, pageId: page.id }
}
function renderPixels(renderer: SkiaRenderer, graph: SceneGraph): Uint8Array {
renderer.render(graph, new Set(), {}, 1, 'scene')
const image = renderer.surface.makeImageSnapshot()
const pixels = image.readPixels(0, 0, {
width: 256,
height: 192,
colorType: ck.ColorType.RGBA_8888,
alphaType: ck.AlphaType.Unpremul,
colorSpace: ck.ColorSpace.SRGB
})
image.delete()
return expectDefined(pixels, 'retained backing pixels')
}
function differingChannels(a: Uint8Array, b: Uint8Array, tolerance: number): number {
let different = 0
for (let index = 0; index < a.length; index++) {
if (Math.abs(a[index] - b[index]) > tolerance) different++
}
return different
}
describe('retained effect raster integration', () => {
test('rejected rasters remain alive through drawing and are then disposed', () => {
class RejectingCache extends EffectRasterCache {
last: EffectRasterCacheEntry | null = null
override set(key: string, entry: EffectRasterCacheEntry): boolean {
this.last = entry
return super.set(key, entry)
}
}
const { graph, pageId } = createEffectGraph('DROP_SHADOW')
const retained = new SkiaRenderer(
ck,
expectDefined(ck.MakeSurface(256, 192), 'retained surface')
)
const transient = new SkiaRenderer(
ck,
expectDefined(ck.MakeSurface(256, 192), 'transient surface')
)
const rejected = new RejectingCache(0)
transient.effectRasterCache = rejected
for (const renderer of [retained, transient]) {
renderer.viewportWidth = 256
renderer.viewportHeight = 192
renderer.pageId = pageId
renderer.pageColor = { r: 1, g: 1, b: 1, a: 1 }
renderer.zoom = 1
renderer.dpr = 1
}
try {
const pixels = renderPixels(transient, graph)
const center = (90 * 256 + 90) * 4
expect(pixels[center]).toBeLessThan(200)
expect(pixels[center + 3]).toBe(255)
expect(pixels).toEqual(renderPixels(retained, graph))
expect(rejected.size).toBe(0)
expect(rejected.last?.image.isDeleted()).toBe(true)
expect(retained.effectRasterCache.size).toBe(1)
} finally {
transient.destroy()
retained.destroy()
}
})
test('settled drop-shadow backing closely matches direct rendering', () => {
const { graph, pageId } = createEffectGraph('DROP_SHADOW')
const directSurface = expectDefined(ck.MakeSurface(256, 192), 'direct surface')
const backingSurface = expectDefined(ck.MakeSurface(256, 192), 'backing surface')
const direct = new SkiaRenderer(ck, directSurface)
const retained = new SkiaRenderer(ck, backingSurface)
for (const renderer of [direct, retained]) {
renderer.viewportWidth = 256
renderer.viewportHeight = 192
renderer.pageId = pageId
renderer.pageColor = { r: 1, g: 1, b: 1, a: 1 }
renderer.dpr = 1
renderer.zoom = 1
}
try {
direct.renderingSceneBacking = false
direct.surface.getCanvas().clear(ck.WHITE)
direct.renderSceneToCanvas(direct.surface.getCanvas(), graph, pageId)
direct.surface.flush()
const directImage = direct.surface.makeImageSnapshot()
const directPixels = expectDefined(
directImage.readPixels(0, 0, {
width: 256,
height: 192,
colorType: ck.ColorType.RGBA_8888,
alphaType: ck.AlphaType.Unpremul,
colorSpace: ck.ColorSpace.SRGB
}),
'direct pixels'
)
directImage.delete()
const retainedPixels = renderPixels(retained, graph)
expect(retained.effectRasterCache.size).toBe(1)
expect(differingChannels(directPixels, retainedPixels, 12)).toBeLessThan(
directPixels.length * 0.02
)
} finally {
direct.destroy()
retained.destroy()
}
})
test('nested shadow subtrees retain the single-picture backing path', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
if (!page) throw new Error('Expected default page')
const frame = graph.createNode('FRAME', page.id, { width: 200, height: 160 })
graph.createNode('RECTANGLE', frame.id, {
x: 20,
y: 20,
width: 100,
height: 80,
effects: [
{
type: 'DROP_SHADOW',
color: { r: 0, g: 0, b: 0, a: 0.5 },
offset: { x: 4, y: 6 },
radius: 8,
spread: 0,
visible: true
}
]
})
const surface = expectDefined(ck.MakeSurface(256, 192), 'nested shadow surface')
const renderer = new SkiaRenderer(ck, surface)
renderer.viewportWidth = 256
renderer.viewportHeight = 192
renderer.pageId = page.id
try {
renderPixels(renderer, graph)
expect(renderer.effectRasterCache.size).toBe(0)
expect(renderer.subtreePictureCache.has(frame.id)).toBe(true)
} finally {
renderer.destroy()
}
})
test('backdrop-dependent blur remains on the picture fallback', () => {
const { graph, pageId } = createEffectGraph('BACKGROUND_BLUR')
const surface = expectDefined(ck.MakeSurface(256, 192), 'blur surface')
const renderer = new SkiaRenderer(ck, surface)
renderer.viewportWidth = 256
renderer.viewportHeight = 192
renderer.pageId = pageId
try {
renderPixels(renderer, graph)
expect(renderer.effectRasterCache.size).toBe(0)
} finally {
renderer.destroy()
}
})
})