openpencil/tests/engine/render/canvas/picture-invalidation.test.ts
Danila Poyarkov 6a06b31dd8
perf(text): measure each text once per shaping and layout width (#959)
* perf(text): measure each text once per shaping and layout width

Layout asked for the same text at the same width hundreds of times per build, and each request built and laid out a new paragraph. Sizes are now cached beside glyph coverage, keyed by the same shaping inputs, so layout resizing the box keeps them.

* refactor(text): reuse Size for measured text

* perf(text): keep measurements when a paragraph rebuilds for a new box

The paragraph cache dropped coverage and sizes whenever its own inputs changed, including a resize that reaches drawing without a graph update. Shaping compares its own inputs, so it is kept there.
2026-10-07 19:48:25 +00:00

90 lines
3.1 KiB
TypeScript

import { expect, mock, test } from 'bun:test'
import { EffectRasterCache } from '#core/canvas/renderer/effect-raster-cache'
import { invalidateAllPictures, invalidateNodePicture } from '#core/canvas/renderer/state'
import { TextPreparationCache } from '#core/canvas/text/preparation-cache'
import { asRenderer } from './helpers'
function deletable() {
return { delete: mock() }
}
test('full picture invalidation resets tiled font-dependent resources', () => {
const scenePicture = deletable()
const backingImage = deletable()
const nodePicture = deletable()
const subtreePicture = deletable()
const textPreparationCache = new TextPreparationCache()
textPreparationCache.clear = mock()
const renderer = asRenderer({
textPreparationCache,
scenePicture,
scenePictureVersion: 1,
scenePictureFontGeneration: 1,
sceneBacking: { image: backingImage },
sceneBackingBuild: null,
nodePictureCache: new Map([['node', nodePicture]]),
nodePictureCacheGenerations: new Map([['node', 1]]),
nodePictureCacheDependencies: new Map([['node', []]]),
effectRasterCache: new EffectRasterCache(),
subtreePictureCache: new Map([['subtree', { picture: subtreePicture }]]),
subtreePictureCachePageId: 'page',
subtreePictureCacheSceneVersion: 1,
subtreePictureCachePositionPreviewVersion: 1,
subtreePictureCacheFontGeneration: 1,
tiledScene: { invalidateStructure: mock() }
})
invalidateAllPictures(renderer)
expect(textPreparationCache.clear).toHaveBeenCalledTimes(1)
expect(renderer.tiledScene.invalidateStructure).toHaveBeenCalledTimes(1)
expect(scenePicture.delete).toHaveBeenCalledTimes(1)
expect(backingImage.delete).toHaveBeenCalledTimes(1)
expect(nodePicture.delete).toHaveBeenCalledTimes(1)
expect(subtreePicture.delete).toHaveBeenCalledTimes(1)
})
test('node picture invalidation removes pictures that depend on a changed child', () => {
const parentPicture = deletable()
const childPicture = deletable()
const textPreparationCache = new TextPreparationCache()
textPreparationCache.deleteNode = mock()
const renderer = asRenderer({
textPreparationCache,
nodePictureCache: new Map([
['parent', parentPicture],
['child', childPicture]
]),
nodePictureCacheGenerations: new Map([
['parent', 1],
['child', 1]
]),
nodePictureCacheDependencies: new Map([
['parent', ['child']],
['child', []]
]),
effectRasterCache: new EffectRasterCache(),
subtreePictureCache: new Map()
})
invalidateNodePicture(renderer, 'child')
expect(textPreparationCache.deleteNode).toHaveBeenCalledWith('child', {
keepShaping: false
})
expect(parentPicture.delete).toHaveBeenCalledTimes(1)
expect(childPicture.delete).toHaveBeenCalledTimes(1)
expect(renderer.nodePictureCache.size).toBe(0)
invalidateNodePicture(renderer, 'child', ['width', 'height', 'x'])
expect(textPreparationCache.deleteNode).toHaveBeenLastCalledWith('child', {
keepShaping: true
})
invalidateNodePicture(renderer, 'child', ['width', 'text'])
expect(textPreparationCache.deleteNode).toHaveBeenLastCalledWith('child', {
keepShaping: false
})
})