openpencil/tests/engine/render/canvas/effects/edge-cases.test.ts
2026-06-30 10:54:32 +03:00

415 lines
11 KiB
TypeScript

import { describe, expect, mock, test } from 'bun:test'
import type { Canvas, Path } from 'canvaskit-wasm'
import type { SceneNode } from '@open-pencil/scene-graph'
import { renderEffects } from '#core/canvas/shadows'
import { createMockCanvas, createMockRenderer, mockCalls } from './helpers'
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],
childIds: []
}
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 on stroked containers uses the container bounds', () => {
const r = createMockRenderer()
const canvas = createMockCanvas()
const node: Partial<SceneNode> = {
type: 'FRAME',
width: 224,
height: 424,
fills: [],
effects: [
{
type: 'DROP_SHADOW',
visible: true,
color: { r: 0, g: 0, b: 0, a: 1 },
offset: { x: 0, y: 2 },
radius: 4,
spread: 0,
showShadowBehindNode: false
}
],
strokes: [{ visible: true, weight: 1, opacity: 1 }],
strokeGeometry: [{} as Path],
childIds: ['header']
}
r.getStrokeGeometry = mock(() => [{} as Path])
renderEffects(r, canvas as Canvas, node as SceneNode, new Float32Array(4), true, 'behind')
expect(r.getStrokeGeometry).not.toHaveBeenCalled()
expect(canvas.drawRRect).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)
})
test('drop shadow restores canvas and paint state when drawing throws', () => {
const r = createMockRenderer()
const canvas = createMockCanvas()
canvas.drawRect = mock(() => {
throw new Error('draw failed')
})
const node: Partial<SceneNode> = {
type: 'RECTANGLE',
width: 100,
height: 100,
fills: [],
effects: [
{
type: 'DROP_SHADOW',
visible: true,
color: { r: 0, g: 0, b: 0, a: 1 },
offset: { x: 0, y: 0 },
radius: 10,
spread: 0
}
],
strokeGeometry: [],
childIds: []
}
expect(() =>
renderEffects(r, canvas as Canvas, node as SceneNode, new Float32Array(4), false, 'behind')
).toThrow('draw failed')
expect(canvas.restore).toHaveBeenCalled()
expect(r.auxFill.setMaskFilter).toHaveBeenCalledWith(null)
expect(r.auxFill.setBlendMode).toHaveBeenCalledWith(r.ck.BlendMode.SrcOver)
expect(r.effectLayerPaint.setImageFilter).toHaveBeenCalledWith(null)
})
test('text inner shadow restores canvas when glyph rendering throws', () => {
const r = createMockRenderer()
const canvas = createMockCanvas()
r.renderText = mock(() => {
throw new Error('text failed')
})
const node: Partial<SceneNode> = {
type: 'TEXT',
width: 100,
height: 100,
fills: [],
effects: [
{
type: 'INNER_SHADOW',
visible: true,
color: { r: 0, g: 0, b: 0, a: 1 },
offset: { x: 0, y: 0 },
radius: 10,
spread: 0
}
]
}
expect(() =>
renderEffects(r, canvas as Canvas, node as SceneNode, new Float32Array(4), false, 'front')
).toThrow('text failed')
expect(mockCalls(canvas.restore).length).toBe(2)
expect(r.effectLayerPaint.setColorFilter).toHaveBeenCalledWith(null)
expect(r.effectLayerPaint.setImageFilter).toHaveBeenCalledWith(null)
})
})