openpencil/tests/engine/render/canvas/retained-backing-pixels.test.ts
Danila Poyarkov 79552129b5 fix(canvas): discard stale zoom fallback pictures
Backing installation advances the preview baseline but previously stamped old whole-scene pictures with the new scene version. Zooming outside backing coverage could replay deleted content and hide newly created shapes.

Discard mismatched pictures before advancing that baseline, preserving valid pictures. Cover scene, preview, page and font invalidation plus exact visible pixels through zoom reversals in retained and tiled renderers.
2026-09-15 17:40:34 +03:00

217 lines
7.8 KiB
TypeScript

import { beforeAll, expect, spyOn, test } from 'bun:test'
import { SceneGraph } from '@open-pencil/scene-graph'
import { initCanvasKit } from '#cli/headless'
import { SkiaRenderer } from '#core/canvas'
import { render } from '#core/canvas/renderer/pipeline'
import { renderSceneBacking } from '#core/canvas/renderer/retained-backing'
import { expectDefined } from '#tests/helpers/assert'
let ck: Awaited<ReturnType<typeof initCanvasKit>>
beforeAll(async () => {
ck = await initCanvasKit()
})
function createFixture() {
const graph = new SceneGraph()
const page = expectDefined(graph.getPages()[0], 'page')
for (const x of [20, 40.25, 60.5, 80.75]) {
graph.createNode('RECTANGLE', page.id, {
x,
y: 20,
width: 1,
height: 80,
fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
})
}
return { graph, pageId: page.id }
}
function createRenderer(pageId: string) {
const surface = expectDefined(ck.MakeSurface(2176, 2128), 'surface')
const renderer = new SkiaRenderer(ck, surface)
renderer.viewportWidth = 1088
renderer.viewportHeight = 1064
renderer.dpr = 2
renderer.pageId = pageId
renderer.pageColor = { r: 1, g: 1, b: 1, a: 1 }
renderer.panX = 0.125
renderer.panY = 0.375
return renderer
}
for (const scope of ['matching', 'scene', 'preview', 'page', 'font'] as const) {
test(`backing installation preserves only a matching whole-scene picture: ${scope}`, () => {
const { graph, pageId } = createFixture()
const renderer = createRenderer(pageId)
try {
render(renderer, graph, new Set(), {}, 1, 'full')
const picture = expectDefined(renderer.scenePicture, 'whole-scene picture')
const deletion = spyOn(picture, 'delete')
try {
if (scope === 'scene') renderer.scenePictureVersion--
if (scope === 'preview') renderer.scenePicturePositionPreviewVersion--
if (scope === 'page') renderer.scenePicturePageId = null
if (scope === 'font') renderer.scenePictureFontGeneration--
expect(renderSceneBacking(renderer, renderer.surface.getCanvas(), graph, 1)).not.toBe(false)
expect(renderer.scenePicture).toBe(scope === 'matching' ? picture : null)
expect(deletion).toHaveBeenCalledTimes(scope === 'matching' ? 0 : 1)
expect(renderer.scenePictureVersion).toBe(1)
expect(renderer.scenePicturePositionPreviewVersion).toBe(graph.positionPreviewVersion)
} finally {
deletion.mockRestore()
}
} finally {
renderer.destroy()
}
})
}
function pixels(renderer: SkiaRenderer) {
renderer.surface.flush()
const image = renderer.surface.makeImageSnapshot()
try {
return expectDefined(
image.readPixels(0, 0, {
width: 200,
height: 220,
colorType: ck.ColorType.RGBA_8888,
alphaType: ck.AlphaType.Unpremul,
colorSpace: ck.ColorSpace.SRGB
}),
'scene pixels'
)
} finally {
image.delete()
}
}
function directPixels(renderer: SkiaRenderer, graph: SceneGraph, pageId: string) {
const canvas = renderer.surface.getCanvas()
canvas.clear(ck.WHITE)
canvas.save()
try {
canvas.scale(renderer.dpr, renderer.dpr)
canvas.translate(renderer.panX, renderer.panY)
canvas.scale(renderer.zoom, renderer.zoom)
renderer.renderSceneToCanvas(canvas, graph, pageId)
} finally {
canvas.restore()
}
return pixels(renderer)
}
test('capped backing preserves the pixel grid after fractional pan and pixel-density changes', () => {
const { graph, pageId } = createFixture()
const direct = createRenderer(pageId)
const retained = createRenderer(pageId)
try {
for (const { pan, dpr, zoom } of [
{ pan: 0.125, dpr: 2, zoom: 1 },
{ pan: 0.25, dpr: 2, zoom: 1 },
{ pan: 0.25, dpr: 1, zoom: 1 },
{ pan: 0.25, dpr: 2, zoom: 1 },
{ pan: 0.1, dpr: 1.25, zoom: 1 },
{ pan: 0.3, dpr: 1.5, zoom: 1 },
{ pan: 0.3, dpr: 1.5, zoom: 1.00001 }
]) {
direct.zoom = zoom
retained.zoom = zoom
direct.panX = pan
retained.panX = pan
direct.dpr = dpr
retained.dpr = dpr
retained.sceneBackingPreviewUntil = 0
retained.render(graph, new Set(), {}, 1, 'scene')
for (let frame = 0; frame < 20 && retained.sceneBackingNeedsCrispRender; frame++) {
retained.sceneBackingPreviewUntil = 0
retained.render(graph, new Set(), {}, 1, 'scene')
}
expect(retained.sceneBackingNeedsCrispRender).toBe(false)
const backing = expectDefined(retained.sceneBacking, 'settled backing')
expect(backing.zoom).toBe(zoom)
expect(backing.anchorPanX).toBe(pan)
// Matching an unchanged viewport must not repeatedly rebuild due to
// floating-point subtraction of the overscan margin.
retained.render(graph, new Set(), {}, 1, 'scene')
expect(retained.sceneBacking).toBe(backing)
const expected = directPixels(direct, graph, pageId)
const actual = pixels(retained)
const differingChannels = actual.reduce(
(count, value, index) => count + Number(value !== expected[index]),
0
)
expect(differingChannels).toBe(0)
}
} finally {
direct.destroy()
retained.destroy()
}
})
test('settlement reuses pictures and the navigation image without another cache', () => {
const { graph, pageId } = createFixture()
const renderer = createRenderer(pageId)
try {
renderer.render(graph, new Set(), {}, 1, 'scene')
const backing = expectDefined(renderer.sceneBacking, 'backing')
const pictures = new Map(
[...renderer.subtreePictureCache].map(([id, entry]) => [id, entry.picture])
)
expect(pictures.size).toBeGreaterThan(0)
const nodeDraws = spyOn(renderer, 'renderNode')
const allocations = spyOn(renderer.surface, 'makeSurface')
try {
renderer.sceneBackingPreviewUntil = 0
renderer.render(graph, new Set(), {}, 1, 'scene')
expect(renderer.profiler.stats.scenePictureMissReason).toBe('retained-pictures')
expect(renderer.sceneBacking).toBe(backing)
expect(renderer.scenePicture).toBeNull()
expect(renderer.subtreePictureCache.size).toBe(pictures.size)
for (const [id, picture] of pictures)
expect(renderer.subtreePictureCache.get(id)?.picture).toBe(picture)
expect(nodeDraws).not.toHaveBeenCalled()
expect(allocations).not.toHaveBeenCalled()
renderer.navigationPhase = 'pan'
renderer.render(graph, new Set(), {}, 1, 'scene')
expect(renderer.profiler.stats.scenePictureMissReason).toBe('backing')
expect(renderer.sceneBacking).toBe(backing)
expect(nodeDraws).not.toHaveBeenCalled()
expect(allocations).not.toHaveBeenCalled()
} finally {
nodeDraws.mockRestore()
allocations.mockRestore()
}
} finally {
renderer.destroy()
}
})
test('active edits draw directly without replacing the overscan backing', () => {
const { graph, pageId } = createFixture()
const renderer = createRenderer(pageId)
try {
renderer.render(graph, new Set(), {}, 1, 'scene')
const backing = expectDefined(renderer.sceneBacking, 'initial backing')
renderer.sceneBackingNeedsCrispRender = true
renderer.tiledScenePending = true
for (const sceneVersion of [2, 3, 4]) {
render(renderer, graph, new Set(), {}, sceneVersion, 'scene', true)
expect(renderer.sceneBacking).toBe(backing)
expect(renderer.sceneBackingBuild).toBeNull()
expect(renderer.sceneBackingNeedsCrispRender).toBe(false)
expect(renderer.tiledScenePending).toBe(false)
expect(renderer.profiler.stats.scenePictureMode).toBe('volatile')
expect(renderer.profiler.stats.scenePictureMissReason).toBe('active-edit')
}
renderer.render(graph, new Set(), {}, 4, 'scene')
expect(renderer.sceneBacking?.sceneVersion).toBe(4)
expect(renderer.sceneBacking).not.toBe(backing)
} finally {
renderer.destroy()
}
})