openpencil/tests/engine/render/canvas/effects.test.ts
Danila Poyarkov c3f5189f8f style: tighten import grouping
- Configure oxfmt custom import groups for workspace, app, package, and test aliases
- Keep type imports grouped with their matching source category instead of one global tail group
- Expand the format script to cover formatter config, Vite files, and scripts
2026-05-06 02:22:08 +03:00

888 lines
25 KiB
TypeScript

import { describe, test, expect, mock } from 'bun:test'
import type { Canvas, Path } from 'canvaskit-wasm'
import type { SkiaRenderer } from '#core/canvas/renderer'
import { renderNode, renderShapeUncached } from '#core/canvas/scene'
import { renderEffects } from '#core/canvas/shadows'
import type { SceneNode, SceneGraph } from '#core/scene-graph'
function mockCalls(fn: ReturnType<typeof mock>): unknown[][] {
return (fn as { mock: { calls: unknown[][] } }).mock.calls
}
function createMockRenderer(overrides: Partial<SkiaRenderer> = {}): SkiaRenderer {
return {
ck: {
Color4f: mock((r, g, b, a) => new Float32Array([r, g, b, a])),
LTRBRect: mock((l, t, r, b) => new Float32Array([l, t, r, b])),
RRectXY: mock(() => new Float32Array(12)),
ClipOp: { Intersect: 0 },
Path: class {
addOval = mock(() => {})
addRect = mock(() => {})
addRRect = mock(() => {})
op = mock(() => {})
delete = mock(() => {})
copy = mock(() => this)
stroke = mock(() => this)
moveTo = mock(() => {})
lineTo = mock(() => {})
cubicTo = mock(() => {})
close = mock(() => {})
},
PathOp: { Difference: 0 },
BlendMode: { SrcOver: 0, SrcIn: 1, DstOut: 2 },
ColorType: { RGBA_8888: 0 },
AlphaType: { Premul: 0, Unpremul: 1 },
ColorSpace: { SRGB: 0 },
TileMode: { Decal: 0, Clamp: 1, Repeat: 2, Mirror: 3 },
FilterMode: { Nearest: 0, Linear: 1 },
MipmapMode: { None: 0, Nearest: 1, Linear: 2 },
BLACK: new Float32Array([0, 0, 0, 1]),
WHITE: new Float32Array([1, 1, 1, 1]),
TRANSPARENT: new Float32Array([0, 0, 0, 0]),
Paint: class {
setColor = mock(() => {})
setShader = mock(() => {})
delete = mock(() => {})
},
ColorFilter: {
MakeBlend: mock(() => ({ delete: () => {} })),
MakeMatrix: mock(() => ({ delete: () => {} }))
}
},
auxFill: {
setColor: mock(() => {}),
setMaskFilter: mock(() => {}),
setImageFilter: mock(() => {}),
setAlphaf: mock(() => {}),
setBlendMode: mock(() => {}),
setShader: mock(() => {}),
delete: mock(() => {})
},
auxStroke: {
setStrokeWidth: mock(() => {}),
setColor: mock(() => {}),
setPathEffect: mock(() => {}),
setAlphaf: mock(() => {}),
setBlendMode: mock(() => {}),
delete: mock(() => {})
},
fillPaint: {
setColor: mock(() => {}),
setAlphaf: mock(() => {}),
setShader: mock(() => {}),
getColor: mock(() => new Float32Array([0, 0, 0, 1])),
setBlendMode: mock(() => {}),
delete: mock(() => {})
},
strokePaint: {
setColor: mock(() => {}),
setStrokeWidth: mock(() => {}),
setAlphaf: mock(() => {}),
setPathEffect: mock(() => {}),
setStrokeCap: mock(() => {}),
setStrokeJoin: mock(() => {}),
setBlendMode: mock(() => {}),
delete: mock(() => {})
},
opacityPaint: {
setAlphaf: mock(() => {}),
setBlendMode: mock(() => {}),
delete: mock(() => {})
},
effectLayerPaint: {
setColor: mock(() => {}),
setMaskFilter: mock(() => {}),
setImageFilter: mock(() => {}),
setColorFilter: mock(() => {}),
setBlendMode: mock(() => {}),
delete: mock(() => {})
},
color4f: mock((r, g, b, a) => new Float32Array([r, g, b, a])),
ltrb: mock((l, t, r, b) => new Float32Array([l, t, r, b])),
getCachedMaskBlur: mock(() => ({})),
getCachedDropShadow: mock(() => ({})),
getCachedDecalBlur: mock(() => ({})),
getCachedBlur: mock(() => ({})),
getStrokeGeometry: mock(() => null),
getFillGeometry: mock(() => null),
getVectorPaths: mock(() => null),
makeRRect: mock(() => new Float32Array(12)),
makeRRectWithSpread: mock(() => new Float32Array(12)),
makeRRectWithOffset: mock(() => new Float32Array(12)),
renderText: mock(() => {}),
applyClippedBlur: mock(() => {}),
applyFill: mock(() => true),
renderShape: mock(() => {}),
renderSection: mock(() => {}),
renderComponentSet: mock(() => {}),
renderEffects: mock((...args) => renderEffects(overrides as SkiaRenderer, ...args)),
drawNodeFill: mock(() => {}),
drawNodeStroke: mock(() => {}),
drawStrokeWithAlign: mock(() => {}),
resolveStrokeColor: mock(() => ({ r: 0, g: 0, b: 0, a: 1 })),
nodePictureCache: {
get: mock(() => null),
set: mock(() => {})
},
isRectangularType: mock(() => true),
worldViewport: { x: 0, y: 0, w: 1000, h: 1000 },
...overrides
} as SkiaRenderer
}
function createMockCanvas() {
return {
save: mock(() => {}),
restore: mock(() => {}),
translate: mock(() => {}),
rotate: mock(() => {}),
scale: mock(() => {}),
drawOval: mock(() => {}),
drawRRect: mock(() => {}),
drawRect: mock(() => {}),
drawPath: mock(() => {}),
saveLayer: mock(() => {}),
clipPath: mock(() => {}),
clipRRect: mock(() => {}),
clipRect: mock(() => {}),
drawPicture: mock(() => {}),
drawParagraph: mock(() => {})
}
}
describe('Renderer effect ordering (Behavioral)', () => {
test('drop shadow renders before fills', () => {
const r = createMockRenderer()
const canvas = createMockCanvas()
const node: Partial<SceneNode> = {
id: 'node1',
type: 'RECTANGLE',
width: 100,
height: 100,
effects: [
{
type: 'DROP_SHADOW',
visible: true,
color: { r: 0, g: 0, b: 0, a: 0.5 },
offset: { x: 0, y: 0 },
radius: 10,
spread: 0
}
],
fills: [{ visible: true, type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1 }],
strokes: [],
strokeGeometry: []
}
const graph: Partial<SceneGraph> = {
getNode: mock(() => node as SceneNode)
}
const callOrder: string[] = []
r.renderEffects = mock((_c, _n, _r, _h, pass) => {
callOrder.push(`renderEffects:${pass}`)
})
r.drawNodeFill = mock(() => {
callOrder.push('drawNodeFill')
})
renderShapeUncached(r, canvas as Canvas, node as SceneNode, graph as SceneGraph)
expect(callOrder).toEqual(['renderEffects:behind', 'drawNodeFill', 'renderEffects:front'])
})
test('inner shadow and blur render after strokes', () => {
const r = createMockRenderer()
const canvas = createMockCanvas()
const node: Partial<SceneNode> = {
id: 'node1',
type: 'RECTANGLE',
width: 100,
height: 100,
fills: [],
childIds: [],
effects: [
{
type: 'INNER_SHADOW',
visible: true,
color: { r: 0, g: 0, b: 0, a: 0.5 },
offset: { x: 0, y: 0 },
radius: 10,
spread: 0
}
],
strokes: [{ visible: true, weight: 1, opacity: 1 }],
strokeGeometry: []
}
const graph: Partial<SceneGraph> = {
getNode: mock(() => node as SceneNode)
}
const callOrder: string[] = []
r.renderEffects = mock((_c, _n, _r, _h, pass) => {
callOrder.push(`renderEffects:${pass}`)
})
r.drawStrokeWithAlign = mock(() => {
callOrder.push('drawStrokeWithAlign')
})
renderShapeUncached(r, canvas as Canvas, node as SceneNode, graph as SceneGraph)
// Strokes are rendered between behind and front effects
const strokeIdx = callOrder.indexOf('drawStrokeWithAlign')
const frontIdx = callOrder.indexOf('renderEffects:front')
expect(strokeIdx).toBeGreaterThan(-1)
expect(frontIdx).toBeGreaterThan(-1)
expect(strokeIdx).toBeLessThan(frontIdx)
})
})
describe('Renderer handles all effect types (Behavioral)', () => {
test('handles DROP_SHADOW', () => {
const r = createMockRenderer()
const canvas = createMockCanvas()
const node: Partial<SceneNode> = {
type: 'RECTANGLE',
width: 100,
height: 100,
fills: [],
childIds: [],
strokeGeometry: [],
effects: [
{
type: 'DROP_SHADOW',
visible: true,
color: { r: 0, g: 0, b: 0, a: 0.5 },
offset: { x: 5, y: 5 },
radius: 10,
spread: 0
}
]
}
renderEffects(r, canvas as Canvas, node as SceneNode, new Float32Array(4), false, 'behind')
expect(canvas.drawRect).toHaveBeenCalled()
})
test('handles INNER_SHADOW', () => {
const r = createMockRenderer()
const canvas = createMockCanvas()
const node: Partial<SceneNode> = {
type: 'RECTANGLE',
width: 100,
height: 100,
fills: [],
childIds: [],
effects: [
{
type: 'INNER_SHADOW',
visible: true,
color: { r: 0, g: 0, b: 0, a: 0.5 },
offset: { x: 5, y: 5 },
radius: 10,
spread: 0
}
]
}
renderEffects(
r,
canvas as Canvas,
node as SceneNode,
new Float32Array([0, 0, 100, 100]),
false,
'front'
)
expect(canvas.drawPath).toHaveBeenCalled()
})
test('handles BACKGROUND_BLUR', () => {
const r = createMockRenderer()
const canvas = createMockCanvas()
const node: Partial<SceneNode> = {
fills: [],
childIds: [],
effects: [{ type: 'BACKGROUND_BLUR', visible: true, radius: 10 }]
}
renderEffects(r, canvas as Canvas, node as SceneNode, new Float32Array(4), false, 'behind')
expect(r.applyClippedBlur).toHaveBeenCalled()
})
test('handles LAYER_BLUR in renderNode', () => {
const r = createMockRenderer()
const canvas = createMockCanvas()
const node: Partial<SceneNode> = {
id: 'n1',
visible: true,
x: 0,
y: 0,
width: 100,
height: 100,
rotation: 0,
opacity: 1,
effects: [{ type: 'LAYER_BLUR', visible: true, radius: 10 }],
childIds: []
}
const graph: Partial<SceneGraph> = {
getNode: mock(() => node as SceneNode)
}
renderNode(r, canvas as Canvas, graph as SceneGraph, 'n1', {})
expect(r.getCachedBlur).toHaveBeenCalledWith(5)
expect(canvas.saveLayer).toHaveBeenCalled()
})
test('handles FOREGROUND_BLUR in renderNode', () => {
const r = createMockRenderer()
const canvas = createMockCanvas()
const node: Partial<SceneNode> = {
id: 'n1',
visible: true,
x: 0,
y: 0,
width: 100,
height: 100,
rotation: 0,
opacity: 1,
effects: [{ type: 'FOREGROUND_BLUR', visible: true, radius: 20 }],
childIds: []
}
const graph: Partial<SceneGraph> = {
getNode: mock(() => node as SceneNode)
}
renderNode(r, canvas as Canvas, graph as SceneGraph, 'n1', {})
expect(r.getCachedBlur).toHaveBeenCalledWith(10)
expect(canvas.saveLayer).toHaveBeenCalled()
})
})
describe('Shadow spread support (Behavioral)', () => {
test('drop shadow uses spread for shape expansion', () => {
const r = createMockRenderer()
const canvas = createMockCanvas()
const node: Partial<SceneNode> = {
type: 'RECTANGLE',
width: 100,
height: 100,
fills: [{ visible: true, type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1 }],
effects: [
{
type: 'DROP_SHADOW',
visible: true,
color: { r: 0, g: 0, b: 0, a: 0.5 },
offset: { x: 5, y: 5 },
radius: 10,
spread: 4
}
],
strokeGeometry: []
}
const rect = new Float32Array([0, 0, 100, 100])
renderEffects(r, canvas as Canvas, node as SceneNode, rect, false, 'behind')
expect(canvas.drawRect).toHaveBeenCalled()
expect(r.ltrb).toHaveBeenCalledWith(-4, -4, 104, 104)
})
test('drop shadow uses makeRRectWithSpread when node has radius', () => {
const r = createMockRenderer()
const canvas = createMockCanvas()
const node: Partial<SceneNode> = {
type: 'RECTANGLE',
width: 100,
height: 100,
fills: [{ visible: true, type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1 }],
effects: [
{
type: 'DROP_SHADOW',
visible: true,
color: { r: 0, g: 0, b: 0, a: 0.5 },
offset: { x: 5, y: 5 },
radius: 10,
spread: 4
}
],
strokeGeometry: []
}
const rect = new Float32Array([0, 0, 100, 100])
renderEffects(r, canvas as Canvas, node as SceneNode, rect, true, 'behind')
expect(r.makeRRectWithSpread).toHaveBeenCalledWith(node, 4)
expect(canvas.drawRRect).toHaveBeenCalled()
})
test('inner shadow uses spread for cutout contraction', () => {
const r = createMockRenderer()
const canvas = createMockCanvas()
const node: Partial<SceneNode> = {
type: 'RECTANGLE',
width: 100,
height: 100,
fills: [],
childIds: [],
effects: [
{
type: 'INNER_SHADOW',
visible: true,
color: { r: 0, g: 0, b: 0, a: 0.5 },
offset: { x: 5, y: 5 },
radius: 10,
spread: 4
}
]
}
const rect = new Float32Array([0, 0, 100, 100])
renderEffects(r, canvas as Canvas, node as SceneNode, rect, false, 'front')
expect(r.ck.LTRBRect).toHaveBeenCalledWith(9, 9, 101, 101)
expect(canvas.drawPath).toHaveBeenCalled()
})
})
describe('Text shadow renders on glyphs, not bounding box (Behavioral)', () => {
test('drop shadow has TEXT-specific branch', () => {
const r = createMockRenderer()
const canvas = createMockCanvas()
const node: Partial<SceneNode> = {
type: 'TEXT',
width: 100,
height: 100,
fills: [],
childIds: [],
effects: [
{
type: 'DROP_SHADOW',
visible: true,
color: { r: 0, g: 0, b: 0, a: 0.5 },
offset: { x: 5, y: 5 },
radius: 10,
spread: 0
}
]
}
const rect = new Float32Array([0, 0, 100, 100])
renderEffects(r, canvas as Canvas, node as SceneNode, rect, false, 'behind')
expect(r.getCachedDropShadow).toHaveBeenCalled()
expect(canvas.saveLayer).toHaveBeenCalled()
expect(r.renderText).toHaveBeenCalled()
})
test('inner shadow has TEXT-specific branch', () => {
const r = createMockRenderer()
const canvas = createMockCanvas()
const node: Partial<SceneNode> = {
type: 'TEXT',
width: 100,
height: 100,
fills: [],
childIds: [],
effects: [
{
type: 'INNER_SHADOW',
visible: true,
color: { r: 0, g: 0, b: 0, a: 0.5 },
offset: { x: 5, y: 5 },
radius: 10,
spread: 0
}
]
}
const rect = new Float32Array([0, 0, 100, 100])
renderEffects(r, canvas as Canvas, node as SceneNode, rect, false, 'front')
// 4-layer saveLayer stack: Master, SrcIn/Tint, Blur, DstOut/Punch
expect(canvas.saveLayer).toHaveBeenCalledTimes(4)
// renderText called twice: once for mask (step 2), once for punch-out (step 8)
expect(r.renderText).toHaveBeenCalledTimes(2)
// Blend modes for the SrcIn and DstOut layers
expect(r.effectLayerPaint.setBlendMode).toHaveBeenCalledWith(r.ck.BlendMode.SrcIn)
expect(r.effectLayerPaint.setBlendMode).toHaveBeenCalledWith(r.ck.BlendMode.DstOut)
// Blur filter for the shadow
expect(r.getCachedDecalBlur).toHaveBeenCalledTimes(1)
// Giant rect for (1-M) base
expect(canvas.drawRect).toHaveBeenCalledTimes(1)
})
test('inner shadow detaches ColorFilter from effectLayerPaint before deletion', () => {
const r = createMockRenderer()
const canvas = createMockCanvas()
const node: Partial<SceneNode> = {
type: 'TEXT',
width: 100,
height: 100,
fills: [],
childIds: [],
effects: [
{
type: 'INNER_SHADOW',
visible: true,
color: { r: 0, g: 0, b: 0, a: 0.5 },
offset: { x: 5, y: 5 },
radius: 10,
spread: 0
}
]
}
const rect = new Float32Array([0, 0, 100, 100])
renderEffects(r, canvas as Canvas, node as SceneNode, rect, false, 'front')
const calls = r.effectLayerPaint.setColorFilter.mock.calls
// The final call must be null (exit guard cleans up)
const lastCall = calls[calls.length - 1]
expect(lastCall[0]).toBeNull()
// Every non-null ColorFilter assignment must have a subsequent null detach.
// This verifies the detach-before-delete pattern for both tintFilter and solidBlackFilter.
for (let i = 0; i < calls.length; i++) {
if (calls[i][0] !== null) {
const hasSubsequentNull = calls.slice(i + 1).some((c) => c[0] === null)
expect(hasSubsequentNull).toBe(true)
}
}
})
})
describe('Edge cases and bug fixes', () => {
test('drop shadow cast from stroke when node has no fill', () => {
const r = createMockRenderer()
const canvas = createMockCanvas()
const node: Partial<SceneNode> = {
type: 'RECTANGLE',
width: 100,
height: 100,
fills: [{ visible: false, type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1 }],
effects: [
{
type: 'DROP_SHADOW',
visible: true,
color: { r: 0, g: 0, b: 0, a: 1 },
offset: { x: 0, y: 0 },
radius: 10,
spread: 0
}
],
strokes: [{ visible: true, weight: 2, opacity: 1 }],
strokeGeometry: [{} as Path]
}
r.getStrokeGeometry = mock(() => [{} as Path])
renderEffects(r, canvas as Canvas, node as SceneNode, new Float32Array(4), false, 'behind')
expect(r.getStrokeGeometry).toHaveBeenCalledWith(node)
expect(canvas.drawPath).toHaveBeenCalled()
})
test('drop shadow from child applies child transforms (geometric parity)', () => {
const r = createMockRenderer()
const canvas = createMockCanvas()
const child: Partial<SceneNode> = {
type: 'RECTANGLE',
x: 10,
y: 20,
width: 50,
height: 60,
rotation: 45,
flipY: true,
effects: []
}
const node: Partial<SceneNode> = {
type: 'FRAME',
width: 100,
height: 100,
fills: [],
effects: [
{
type: 'DROP_SHADOW',
visible: true,
color: { r: 0, g: 0, b: 0, a: 1 },
offset: { x: 5, y: 5 },
radius: 0,
spread: 0
}
],
childIds: ['child1']
}
renderEffects(
r,
canvas as Canvas,
node as SceneNode,
new Float32Array(4),
false,
'behind',
child as SceneNode
)
// Verify order: translate (offset) -> rotate -> translate (flip) -> scale
const translateCalls = mockCalls(canvas.translate)
const rotateCalls = mockCalls(canvas.rotate)
const scaleCalls = mockCalls(canvas.scale)
expect(translateCalls[0]).toEqual([5 + 10, 5 + 20])
expect(rotateCalls[0]).toEqual([45, 25, 30])
expect(translateCalls[1]).toEqual([0, 60])
expect(scaleCalls[0]).toEqual([1, -1])
expect(canvas.drawRect).toHaveBeenCalled()
})
test('drop shadow from TEXT child renders glyphs (fidelity parity)', () => {
const r = createMockRenderer()
const canvas = createMockCanvas()
const child: Partial<SceneNode> = {
type: 'TEXT',
x: 10,
y: 20,
width: 50,
height: 60,
text: 'Hello',
effects: []
}
const node: Partial<SceneNode> = {
type: 'FRAME',
width: 100,
height: 100,
fills: [],
effects: [
{
type: 'DROP_SHADOW',
visible: true,
color: { r: 0, g: 0, b: 0, a: 1 },
offset: { x: 5, y: 5 },
radius: 10,
spread: 0
}
],
childIds: ['child1']
}
renderEffects(
r,
canvas as Canvas,
node as SceneNode,
new Float32Array(4),
false,
'behind',
child as SceneNode
)
expect(r.getCachedDropShadow).toHaveBeenCalled()
expect(canvas.saveLayer).toHaveBeenCalled()
expect(canvas.translate).toHaveBeenCalledWith(15, 25)
expect(r.renderText).toHaveBeenCalledWith(expect.anything(), child)
})
test('inner shadow from shape child applies child transforms and rects (geometric parity)', () => {
const r = createMockRenderer()
const canvas = createMockCanvas()
const child: Partial<SceneNode> = {
type: 'RECTANGLE',
x: 10,
y: 20,
width: 50,
height: 60,
rotation: 45,
flipY: true,
effects: []
}
const node: Partial<SceneNode> = {
type: 'FRAME',
width: 100,
height: 100,
fills: [],
effects: [
{
type: 'INNER_SHADOW',
visible: true,
color: { r: 0, g: 0, b: 0, a: 1 },
offset: { x: 5, y: 5 },
radius: 0,
spread: 0
}
],
childIds: ['child1']
}
renderEffects(
r,
canvas as Canvas,
node as SceneNode,
new Float32Array(4),
false,
'front',
child as SceneNode
)
const translateCalls = mockCalls(canvas.translate)
const rotateCalls = mockCalls(canvas.rotate)
const scaleCalls = mockCalls(canvas.scale)
expect(translateCalls[0]).toEqual([10, 20])
expect(rotateCalls[0]).toEqual([45, 25, 30])
expect(translateCalls[1]).toEqual([0, 60])
expect(scaleCalls[0]).toEqual([1, -1])
expect(r.ck.LTRBRect).toHaveBeenCalledWith(0, 0, 50, 60)
expect(canvas.clipRect).toHaveBeenCalled()
})
test('drop shadow from TEXT child applies offset in parent space', () => {
const r = createMockRenderer()
const canvas = createMockCanvas()
const child: Partial<SceneNode> = {
type: 'TEXT',
x: 10,
y: 20,
width: 50,
height: 60,
text: 'Hello',
rotation: 45,
effects: []
}
const node: Partial<SceneNode> = {
type: 'FRAME',
width: 100,
height: 100,
fills: [],
effects: [
{
type: 'DROP_SHADOW',
visible: true,
color: { r: 0, g: 0, b: 0, a: 1 },
offset: { x: 5, y: 5 },
radius: 10,
spread: 0
}
],
childIds: ['child1']
}
renderEffects(
r,
canvas as Canvas,
node as SceneNode,
new Float32Array(4),
false,
'behind',
child as SceneNode
)
const translateCalls = mockCalls(canvas.translate)
expect(translateCalls[0]).toEqual([15, 25]) // offset + child position
// The filter should NOT have the offset (neutralized to 0,0)
expect(r.getCachedDropShadow).toHaveBeenCalledWith(0, 0, 5, expect.anything())
})
test('INNER_SHADOW with large offset does not vanish (bounding box union)', () => {
const r = createMockRenderer()
const canvas = createMockCanvas()
const node: Partial<SceneNode> = {
type: 'RECTANGLE',
width: 100,
height: 100,
fills: [],
effects: [
{
type: 'INNER_SHADOW',
visible: true,
color: { r: 0, g: 0, b: 0, a: 1 },
offset: { x: 200, y: 0 },
radius: 10,
spread: 0
}
]
}
renderEffects(
r,
canvas as Canvas,
node as SceneNode,
new Float32Array([0, 0, 100, 100]),
false,
'front'
)
// Expect LTRBRect for 'big' to encompass both the shape (0 to 100) and the offset hole (200 to 300)
// with expand (20) padding: min(-20, -20+200) = -20, max(100+20, 100+20+200) = 320
expect(r.ck.LTRBRect).toHaveBeenCalledWith(
-20, // min(-expand, -expand + offsetX)
-20, // min(-expand, -expand + offsetY)
320, // max(width + expand, width + expand + offsetX)
120 // max(height + expand, height + expand + offsetY)
)
expect(canvas.drawPath).toHaveBeenCalled()
})
test('INNER_SHADOW with spread on rounded rect shrinks hole concentrically', () => {
const r = createMockRenderer()
const canvas = createMockCanvas()
const node: Partial<SceneNode> = {
type: 'RECTANGLE',
width: 100,
height: 100,
cornerRadius: 10,
fills: [],
effects: [
{
type: 'INNER_SHADOW',
visible: true,
color: { r: 0, g: 0, b: 0, a: 1 },
offset: { x: 5, y: 5 },
radius: 10,
spread: 4
}
]
}
renderEffects(
r,
canvas as Canvas,
node as SceneNode,
new Float32Array([0, 0, 100, 100]),
true,
'front'
)
// makeRRectWithOffset should receive (node, localOffsetX, localOffsetY, spread)
// localOffsetX = 5, localOffsetY = 5, spread = 4
expect(r.makeRRectWithOffset).toHaveBeenCalledWith(node, 5, 5, 4)
})
})
describe('INNER_SHADOW bug proofs', () => {
test('INNER_SHADOW solid mask via ColorFilter on DstOut layer', () => {
const r = createMockRenderer()
const canvas = createMockCanvas()
const node: Partial<SceneNode> = {
type: 'TEXT',
width: 100,
height: 100,
fills: [],
childIds: [],
effects: [
{
type: 'INNER_SHADOW',
visible: true,
color: { r: 0, g: 0, b: 0, a: 1 },
offset: { x: 5, y: 5 },
radius: 0,
spread: 0
}
]
}
renderEffects(r, canvas as Canvas, node as SceneNode, new Float32Array(4), false, 'front')
// PROOF: ColorFilter.MakeBlend(black, SrcIn) on DstOut layer paint
// forces renderText output to solid black without mutating fillPaint.
// The DstOut saveLayer punches the solid mask from the blurred block.
expect(canvas.saveLayer).toHaveBeenCalled()
expect(canvas.drawRect).toHaveBeenCalled()
})
})