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 = { 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 = { 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 = { type: 'RECTANGLE', x: 10, y: 20, width: 50, height: 60, rotation: 45, flipY: true, effects: [] } const node: Partial = { 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 = { type: 'TEXT', x: 10, y: 20, width: 50, height: 60, text: 'Hello', effects: [] } const node: Partial = { 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 = { type: 'RECTANGLE', x: 10, y: 20, width: 50, height: 60, rotation: 45, flipY: true, effects: [] } const node: Partial = { 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 = { type: 'TEXT', x: 10, y: 20, width: 50, height: 60, text: 'Hello', rotation: 45, effects: [] } const node: Partial = { 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 = { 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 = { 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 = { 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 = { 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) }) })