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

121 lines
3.8 KiB
TypeScript

import { describe, expect, test } from 'bun:test'
import type { Canvas } from 'canvaskit-wasm'
import type { SceneNode } from '@open-pencil/scene-graph'
import { renderEffects } from '#core/canvas/shadows'
import { createMockCanvas, createMockRenderer } from './helpers'
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).toHaveBeenCalledWith(r.effectLayerPaint, expect.any(Float32Array))
expect(r.ck.LTRBRect).toHaveBeenCalledWith(-20, -20, 125, 125)
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)
for (const call of canvas.saveLayer.mock.calls) {
expect(call[1]).toBeInstanceOf(Float32Array)
}
// 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)
}
}
})
})