openpencil/tests/engine/render/canvas/silhouette-autopsy.test.ts
Danila Poyarkov db056317a8 refactor(fig): extract paint import policy
- Move paint/effect normalization and derived glyph conversion into the Fig package\n- Rewire core import and instance paths through the public node-change boundary\n- Keep static renderer evidence pointed at the new format-policy source
2026-07-18 01:36:00 +03:00

563 lines
22 KiB
TypeScript

/**
* AUTOMATED VERIFICATION: Silhouette Shadow Autopsy Claims
*
* This file proves or disproves every factual claim made in
* scratch/silhouette-shadow-autopsy-20260502/ by testing against
* the actual running code.
*
* Run: bun run tests/engine/verify-silhouette-autopsy.ts
*
* Claims are numbered to match the autopsy documents.
* Each test either PASSES (claim confirmed) or FAILS (claim disproved
* or code changed since autopsy was written).
*/
import { beforeAll, describe, expect, test } from 'bun:test'
import { readFileSync } from 'node:fs'
import { SceneGraph } from '@open-pencil/scene-graph'
import { initCanvasKit } from '#cli/headless'
import { SkiaRenderer } from '#core/canvas'
import { fontManager } from '#core/text'
import { expectDefined } from '#tests/helpers/assert'
import {
coreSourcePath,
publicPath,
repoPath,
testPath as repoTestPath
} from '#tests/helpers/paths'
// === CLAIM EXTRACTION ===
// Each claim is: [doc_section, claim_text, verification_strategy]
// We load the relevant source files to verify the claims by static analysis
// AND runtime behavior where applicable.
const shadowsPath = coreSourcePath('canvas/shadows.ts')
const effectsPath = coreSourcePath('canvas/effects.ts')
const scenePath = coreSourcePath('canvas/scene.ts')
const rendererPath = coreSourcePath('canvas/renderer.ts')
const sgTypesPath = repoPath('packages/scene-graph/src/types.ts')
const nodeExportPath = coreSourcePath('kiwi/fig/node-change/export-node.ts')
const convertPath = repoPath('packages/fig/src/node-change/paint.ts')
const schemaPath = repoPath('packages/kiwi/src/fig/schema/fig.kiwi')
const codecPath = repoPath('packages/kiwi/src/fig/codec.ts')
const lifecyclePath = coreSourcePath('canvas/renderer/lifecycle.ts')
function readSource(path: string): string {
return readFileSync(path, 'utf-8')
}
// ============================================================
// DOCUMENT 01: THE CURRENT ENGINE — VERIFICATION
// ============================================================
describe('Doc 01 — The Current Engine: Static Code Claims', () => {
test('C01-01: Effect type defines 5 values (scene-graph/types.ts:138)', () => {
const src = readSource(sgTypesPath)
// Verify all 5 effect type strings are present in the file
for (const type of [
'DROP_SHADOW',
'INNER_SHADOW',
'LAYER_BLUR',
'BACKGROUND_BLUR',
'FOREGROUND_BLUR'
]) {
expect(src).toContain(type)
}
})
test('C01-02: Kiwi EffectType enum has only 4 values, missing LAYER_BLUR (kiwi/fig/codec/schema.ts:132-137)', () => {
const src = readSource(schemaPath)
expect(src).toContain('INNER_SHADOW = 0')
expect(src).toContain('DROP_SHADOW = 1')
expect(src).toContain('FOREGROUND_BLUR = 2')
expect(src).toContain('BACKGROUND_BLUR = 3')
expect(src).not.toContain('LAYER_BLUR =')
})
test('C01-03: Kiwi codec Effect interface also has 5 types (kiwi/fig/codec/index.ts:212)', () => {
const src = readSource(codecPath)
// The codec.ts file defines an Effect interface — verify all 5 types present
// Use a targeted search near the Effect interface definition
const effectInterfaceMatch = src.match(/interface Effect\s*\{([^}]+)\}/)
expect(effectInterfaceMatch).toBeTruthy()
const effectBody = expectDefined(effectInterfaceMatch, 'effectInterfaceMatch')[1]
for (const type of [
'DROP_SHADOW',
'INNER_SHADOW',
'LAYER_BLUR',
'BACKGROUND_BLUR',
'FOREGROUND_BLUR'
]) {
expect(effectBody).toContain(type)
}
})
test('C01-04: export-node.ts maps LAYER_BLUR → FOREGROUND_BLUR on export (node-change/export-node.ts:75)', () => {
const src = readSource(nodeExportPath)
expect(src).toContain("effect.type === 'LAYER_BLUR' ? 'FOREGROUND_BLUR'")
})
test('C01-05: convertEffects passes through type as-is on import (node-change/paint.ts:115)', () => {
const src = readSource(convertPath)
// The convertEffects function should map e.type directly without
// checking for LAYER_BLUR or remapping FOREGROUND_BLUR back
expect(src).toContain('type: e.type')
// Confirm there is NO reverse mapping (FOREGROUND_BLUR → LAYER_BLUR)
expect(src).not.toContain("'FOREGROUND_BLUR' ? 'LAYER_BLUR'")
})
test('C01-06: renderShapeUncached order = behind effects → fills → strokes → front effects (scene.ts:430-466)', () => {
const src = readSource(scenePath)
const renderShapeUncachedMatch = src.match(
/export function renderShapeUncached[\s\S]*?(?=\nexport function|\n$)/
)
expect(renderShapeUncachedMatch).toBeTruthy()
const body = expectDefined(renderShapeUncachedMatch, 'renderShapeUncachedMatch')[0]
// Find indices of key calls
const behindIdx = body.indexOf("'behind'")
const fillIdx = body.indexOf('drawNodeFill')
const strokeIdx = body.indexOf('drawNodeStroke')
const frontIdx = body.indexOf("'front'")
expect(behindIdx).toBeGreaterThan(-1)
expect(fillIdx).toBeGreaterThan(-1)
expect(strokeIdx).toBeGreaterThan(-1)
expect(frontIdx).toBeGreaterThan(-1)
// Verify order: behind < fill < stroke < front
expect(behindIdx).toBeLessThan(fillIdx)
expect(fillIdx).toBeLessThan(strokeIdx)
expect(strokeIdx).toBeLessThan(frontIdx)
})
test('C01-07: renderNode has opacity layer → blur layer → content → children (scene.ts:106-158)', () => {
const src = readSource(scenePath)
const renderNodeMatch = src.match(
/export function renderNode[\s\S]*?(?=\nexport function|\nexport const)/
)
expect(renderNodeMatch).toBeTruthy()
const body = expectDefined(renderNodeMatch, 'renderNodeMatch')[0]
// Verify opacity saveLayer comes before layerBlur saveLayer
const opacityLayerIdx = body.indexOf('opacity < 1')
const layerBlurCheckIdx = body.indexOf('layerBlur')
expect(opacityLayerIdx).toBeGreaterThan(-1)
expect(layerBlurCheckIdx).toBeGreaterThan(-1)
expect(opacityLayerIdx).toBeLessThan(layerBlurCheckIdx)
})
test('C01-08: drawShapeDropShadow uses auxFill.setMaskFilter (MaskFilter approach)', () => {
const src = readSource(shadowsPath)
expect(src).toContain('auxFill.setMaskFilter')
})
test('C01-09: drawShapeDropShadow uses drawRect/drawOval/drawRRect for shadow shape', () => {
const src = readSource(shadowsPath)
expect(src).toContain('drawRect')
expect(src).toContain('drawOval')
expect(src).toContain('drawRRect')
})
test('C01-10: text drop shadow uses MakeDropShadowOnly (effects.ts:15)', () => {
const src = readSource(effectsPath)
expect(src).toContain('MakeDropShadowOnly')
})
test('C01-11: renderDropShadow checks shapeNode.type !== "TEXT" to branch', () => {
const src = readSource(shadowsPath)
expect(src).toContain("shapeNode.type !== 'TEXT'")
})
test('C01-12: drawTextInnerShadow implements the exact formula with 4 saveLayer calls', () => {
const src = readSource(shadowsPath)
const drawTextInnerShadowMatch = src.match(
/function drawTextInnerShadow[\s\S]*?(?=\nfunction |\nexport )/
)
expect(drawTextInnerShadowMatch).toBeTruthy()
const body = expectDefined(drawTextInnerShadowMatch, 'drawTextInnerShadowMatch')[0]
// Count saveLayer calls — there are 4 (Master, Restrictive, Blur, DstOut)
// plus 2 canvas.save() calls (child transform + offset transform)
const saveLayerCount = (body.match(/canvas\.saveLayer/g) || []).length
const saveCount = (body.match(/canvas\.save\(\)/g) || []).length
expect(saveLayerCount).toBe(4)
expect(saveCount).toBe(2)
// Verify the formula operations are present
expect(body).toContain('BlendMode.SrcIn') // SrcIn (Tint layer)
expect(body).toContain('MakeBlend') // ColorFilter tint
expect(body).toContain('getCachedDecalBlur') // Blur
expect(body).toContain('translate') // Offset
expect(body).toContain('drawRect') // SolidRect
expect(body).toContain('BlendMode.DstOut') // DstOut
})
test('C01-13: drawShapeInnerShadow uses PathOp.Difference', () => {
const src = readSource(shadowsPath)
expect(src).toContain('PathOp.Difference')
})
test('C01-14: renderShape caches effects nodes via nodePictureCache (scene.ts:230-248)', () => {
const src = readSource(scenePath)
const renderShapeMatch = src.match(
/export function renderShape[\s\S]*?(?=\nexport function|\nexport const)/
)
expect(renderShapeMatch).toBeTruthy()
const body = expectDefined(renderShapeMatch, 'renderShapeMatch')[0]
expect(body).toContain('nodePictureCache')
expect(body).toContain('PictureRecorder')
})
test('C01-15: getShadowShapeChild accesses node.childIds twice (guard + read)', () => {
const src = readSource(scenePath)
// Should take the first child — no loop over children
expect(src).toContain('const child = graph.getNode(node.childIds[0])')
// Verify it's a single-child function, not multi-child
const getShadowShapeChildFn = src.match(
/function getShadowShapeChild[\s\S]*?(?=\nfunction |\nexport )/
)
expect(getShadowShapeChildFn).toBeTruthy()
const body = expectDefined(getShadowShapeChildFn, 'getShadowShapeChildFn')[0]
// No for loop over children
expect(body).not.toMatch(/for\s*\(.*childIds/)
// Accesses childIds twice: once for .length guard, once for [0] read
const childRefCount = (body.match(/childIds/g) || []).length
expect(childRefCount).toBe(2) // .length guard + [0] access
})
})
// ============================================================
// DOCUMENT 02: FORMULA DECONSTRUCTION — VERIFICATION
// ============================================================
describe('Doc 02 — Formula Deconstruction: Static Code Claims', () => {
test('C02-01: ColorFilter.MakeBlend is captured and explicitly .delete()d (GAP-01 fix)', () => {
const src = readSource(shadowsPath)
// MakeBlend is used (tintFilter + solidBlackFilter)
expect(src).toContain('ColorFilter.MakeBlend')
// solidBlackFilter is captured and deleted (GAP-01 fix)
expect(src).toContain('solidBlackFilter.delete()')
// tintFilter is also captured and deleted
expect(src).toContain('tintFilter.delete()')
})
test('C02-02: The DstOut layer stack uses 4 saveLayer calls (drawTextInnerShadow)', () => {
const src = readSource(shadowsPath)
const drawTextInnerShadowMatch = src.match(
/function drawTextInnerShadow[\s\S]*?(?=\nfunction |\nexport )/
)
expect(drawTextInnerShadowMatch).toBeTruthy()
const body = expectDefined(drawTextInnerShadowMatch, 'drawTextInnerShadowMatch')[0]
// 4 saveLayer: Master, Restrictive, Blur, DstOut
// 2 save: child transform, offset transform
// 6 restore: one per save/saveLayer
const saveLayers = (body.match(/canvas\.saveLayer/g) || []).length
const saves = (body.match(/canvas\.save\(\)/g) || []).length
const restoreInLoop = body.includes('while (restoreCount > 0)')
const restoreCountIncrements = (body.match(/restoreCount\+\+/g) || []).length
expect(saveLayers).toBe(4)
expect(saves).toBe(2)
expect(restoreInLoop).toBe(true)
expect(restoreCountIncrements).toBe(6) // 4 saveLayer + 2 save = 6 restores
})
test('C02-03: DecalBlur is used (not Clamp) for inner shadow (shadows.ts:184)', () => {
const src = readSource(shadowsPath)
// The drawTextInnerShadow function should use getCachedDecalBlur
expect(src).toContain('getCachedDecalBlur')
})
test('C02-04: fillPaint is NOT mutated — solid mask via ColorFilter on layer (GAP-03/05 fix)', () => {
const src = readSource(shadowsPath)
// ColorFilter.MakeBlend(black, SrcIn) on DstOut layer replaces fillPaint mutation
// No fillPaint.setColor(black) in drawTextInnerShadow
expect(src).toContain('solidBlackFilter')
// ColorFilter approach verified
expect(src).toContain('ColorFilter.MakeBlend')
})
test('C02-05: effects.ts has both getCachedBlur (Clamp) and getCachedDecalBlur (Decal)', () => {
const src = readSource(effectsPath)
expect(src).toContain('TileMode.Clamp')
expect(src).toContain('TileMode.Decal')
})
test('C02-06: SkiaRenderer has imageFilterCache but no colorFilterCache', () => {
const rendererSrc = readSource(rendererPath)
expect(rendererSrc).toContain('imageFilterCache')
expect(rendererSrc).toContain('maskFilterCache')
expect(rendererSrc).not.toContain('colorFilterCache')
})
})
// ============================================================
// DOCUMENT 03: ARTIFACT ANALYSIS — VERIFICATION
// ============================================================
describe('Doc 03 — Artifact Analysis: Static + Runtime Verification', () => {
test('C03-01: render visual inner-shadow counter script exists and checks for right-edge shadow', () => {
const visualTestPath = repoTestPath('engine/render/canvas/visual/inner-shadow-counter.ts')
const src = readFileSync(visualTestPath, 'utf-8')
expect(src).toContain('hasShadowOnRightInnerEdge')
expect(src).toContain("t.color === 'black'")
})
test('C03-02: The giant rect uses local expand, not rotation-aware expand (shadows.ts:188-194)', () => {
const src = readSource(shadowsPath)
const drawTextInnerShadowMatch = src.match(
/function drawTextInnerShadow[\s\S]*?(?=\nfunction |\nexport )/
)
expect(drawTextInnerShadowMatch).toBeTruthy()
const body = expectDefined(drawTextInnerShadowMatch, 'drawTextInnerShadowMatch')[0]
// Check the expand calculation
expect(body).toContain('const expand')
// The expand should use shapeNode.width/height directly, not a rotated bbox
expect(body).toContain('shapeNode.width')
expect(body).toContain('shapeNode.height')
})
test('C03-03: getShadowShapeChild only considers first child — no iteration', () => {
const src = readSource(scenePath)
const match = src.match(/function getShadowShapeChild[\s\S]*?(?=\nfunction |\nexport )/)
expect(match).toBeTruthy()
const body = expectDefined(match, 'match')[0]
// Verify it returns early after first child
expect(body).toContain('return child')
// Verify no array accumulation
expect(body).not.toContain('.push(')
expect(body).not.toContain('children')
})
})
// ============================================================
// RUNTIME BEHAVIOR VERIFICATION
// ============================================================
describe('Doc 01/03 — Runtime Behavior Verification', () => {
// These tests require CanvasKit initialization
let ck: Awaited<ReturnType<typeof initCanvasKit>>
let renderer: SkiaRenderer
let graph: SceneGraph
let pageId: string
beforeAll(async () => {
ck = await initCanvasKit()
const fontProvider = ck.TypefaceFontProvider.Make()
fontManager.attachProvider(ck, fontProvider)
// Load Inter font for text tests
const fontPath = publicPath('Inter-SemiBold.ttf')
try {
const fontData = readFileSync(fontPath)
fontManager.markLoaded(
'Inter',
'SemiBold',
fontData.buffer.slice(fontData.byteOffset, fontData.byteOffset + fontData.byteLength)
)
} catch {
void fontPath
}
const surface = expectDefined(ck.MakeSurface(200, 200), 'CanvasKit surface')
renderer = new SkiaRenderer(ck, surface)
renderer.fontProvider = fontProvider
renderer.fontsLoaded = true
graph = new SceneGraph()
pageId = graph.getPages()[0].id
})
test('C-RT01: Drop shadow renders behind fills (behavioral order verification)', () => {
// Create a node with fill + drop shadow
const node = graph.createNode('RECTANGLE', pageId, {
x: 10,
y: 10,
width: 50,
height: 50,
fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, visible: true, opacity: 1 }],
effects: [
{
type: 'DROP_SHADOW',
visible: true,
color: { r: 0, g: 0, b: 0, a: 1 },
offset: { x: 5, y: 5 },
radius: 10,
spread: 0
}
]
})
// Verify the node was created with effects
expect(node.effects).toHaveLength(1)
expect(node.effects[0].type).toBe('DROP_SHADOW')
// Verify the render order by examining renderShapeUncached source
const callOrder: string[] = []
const origRenderEffects = renderer.renderEffects.bind(renderer)
const origDrawNodeFill = renderer.drawNodeFill.bind(renderer)
renderer.renderEffects = (...args: Parameters<typeof renderer.renderEffects>) => {
callOrder.push(`effects:${args[4]}`)
origRenderEffects(...args)
}
renderer.drawNodeFill = (...args: Parameters<typeof renderer.drawNodeFill>) => {
callOrder.push('fill')
origDrawNodeFill(...args)
}
renderer.renderShapeUncached(renderer['surface'].getCanvas(), node, graph)
const behindIdx = callOrder.indexOf('effects:behind')
const fillIdx = callOrder.indexOf('fill')
const frontIdx = callOrder.indexOf('effects:front')
expect(behindIdx).toBeGreaterThan(-1)
expect(fillIdx).toBeGreaterThan(-1)
expect(behindIdx).toBeLessThan(fillIdx)
expect(fillIdx).toBeLessThan(frontIdx)
})
test('C-RT02: effectOverflow computes correct margin for drop shadow', () => {
const node = {
x: 0,
y: 0,
width: 100,
height: 100,
effects: [
{
visible: true,
type: 'DROP_SHADOW' as const,
radius: 10,
spread: 0,
offset: { x: 5, y: 5 },
color: { r: 0, g: 0, b: 0, a: 1 }
}
]
}
const overflow = renderer.effectOverflow(node)
// radius 10 + offset 5 = 15 margin needed
expect(overflow).toBeGreaterThanOrEqual(10)
expect(overflow).toBeLessThanOrEqual(20) // some implementations might differ slightly
})
test('C-RT03: Inner shadow on text uses the DstOut formula (trace verification)', () => {
// Trace that drawTextInnerShadow is called when TEXT + INNER_SHADOW
// We verify this by creating a text node with inner shadow and checking
// that the renderEffects call reaches the front pass with TEXT type
const textNode = graph.createNode('TEXT', pageId, {
x: 10,
y: 10,
width: 100,
height: 30,
text: 'TEST',
fontSize: 24,
fontFamily: 'Inter',
fontWeight: 600,
fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, visible: true, opacity: 1 }],
effects: [
{
type: 'INNER_SHADOW' as const,
visible: true,
color: { r: 0, g: 0, b: 0, a: 1 },
offset: { x: 4, y: 4 },
radius: 8,
spread: 0
}
]
})
expect(textNode.effects).toHaveLength(1)
expect(textNode.effects[0].type).toBe('INNER_SHADOW')
// Verify the node exists and renderEffects('front') will be called
// The actual rendering is verified by the behavioral test
const effectTypes = textNode.effects.map((e) => e.type)
expect(effectTypes).toContain('INNER_SHADOW')
})
test('C-RT05: GAP-01 fixed — ColorFilter MakeBlend is captured and .delete()d', () => {
const src = readSource(shadowsPath)
const drawTextInnerShadowMatch = src.match(
/function drawTextInnerShadow[\s\S]*?(?=\nfunction |\nexport )/
)
expect(drawTextInnerShadowMatch).toBeTruthy()
const body = expectDefined(drawTextInnerShadowMatch, 'drawTextInnerShadowMatch')[0]
// MakeBlend IS called (for tintFilter and solidBlackFilter)
expect(body).toContain('MakeBlend')
// FIX: solidBlackFilter is captured to a variable and .delete()d
expect(body).toContain('solidBlackFilter.delete()')
// FIX: tintFilter is captured to a variable and .delete()d
expect(body).toContain('tintFilter.delete()')
})
})
// ============================================================
// ROUND-TRIP LOSS VERIFICATION (LAYER_BLUR)
// ============================================================
describe('Doc 01 — LAYER_BLUR Round-Trip Loss Verification', () => {
test('C-RT-LOSS-01: convertEffects maps e.type directly (no FOREGROUND_BLUR → LAYER_BLUR)', () => {
// Dynamically import to verify the runtime behavior
const src = readSource(convertPath)
// The function should just do: type: e.type
expect(src).toMatch(/type:\s*e\.type/)
})
test('C-RT-LOSS-02: Kiwi EffectType enum preserves modern Figma values without LAYER_BLUR', () => {
const src = readSource(schemaPath)
const effectTypeBlock = src.match(/enum EffectType\s*\{([^}]*)\}/)
expect(effectTypeBlock).toBeTruthy()
const body = expectDefined(effectTypeBlock, 'effectTypeBlock')[1]
const valueCount = (body.match(/=\s*\d+\s*;/g) || []).length
expect(valueCount).toBe(10)
expect(body).toContain('FOREGROUND_BLUR = 2')
expect(body).not.toContain('LAYER_BLUR')
})
})
// ============================================================
// CACHE INFRASTRUCTURE VERIFICATION
// ============================================================
describe('Doc 02/03 — Cache Infrastructure Verification', () => {
test('C-CACHE-01: imageFilterCache exists and caches DropShadow + Blur filters', () => {
const rendererSrc = readSource(rendererPath)
expect(rendererSrc).toContain('imageFilterCache')
})
test('C-CACHE-02: maskFilterCache exists', () => {
const rendererSrc = readSource(rendererPath)
expect(rendererSrc).toContain('maskFilterCache')
})
test('C-CACHE-03: No colorFilterCache — MakeBlend is uncached', () => {
const rendererSrc = readSource(rendererPath)
expect(rendererSrc).not.toContain('colorFilterCache')
})
test('C-CACHE-04: destroy() cleans up imageFilterCache and maskFilterCache', () => {
const rendererSrc = readSource(rendererPath)
const destroyMatch = rendererSrc.match(/destroy\(\)[\s\S]*?(?=\n \w|\n\})/)
expect(destroyMatch).toBeTruthy()
const body = expectDefined(destroyMatch, 'destroyMatch')[0]
// destroy() delegates to destroyRenderer — verify the lifecycle module cleans caches
expect(body).toContain('destroyRenderer')
const lifecycleSrc = readSource(lifecyclePath)
expect(lifecycleSrc).toContain('imageFilterCache')
expect(lifecycleSrc).toContain('maskFilterCache')
})
test('C-CACHE-05: nodePictureCache exists for effect caching', () => {
const rendererSrc = readSource(rendererPath)
expect(rendererSrc).toContain('nodePictureCache')
expect(rendererSrc).toContain('scenePicture')
})
})