import { describe, test, expect } from 'bun:test' import { SceneGraph, type Effect, type Rect } from '@open-pencil/core' import { getNodeOrThrow } from '#tests/helpers/assert' function pageId(graph: SceneGraph) { return graph.getPages()[0].id } function makeEffect(overrides: Partial = {}): Effect { return { type: 'DROP_SHADOW', color: { r: 0, g: 0, b: 0, a: 0.25 }, offset: { x: 0, y: 4 }, radius: 4, spread: 0, visible: true, ...overrides } } describe('Effect types on scene graph', () => { test('drop shadow with spread value', () => { const graph = new SceneGraph() const node = graph.createNode('RECTANGLE', pageId(graph), { name: 'Card', width: 200, height: 100 }) graph.updateNode(node.id, { effects: [makeEffect({ spread: 10 })] }) const updated = getNodeOrThrow(graph, node.id) expect(updated.effects[0].spread).toBe(10) }) test('inner shadow with spread value', () => { const graph = new SceneGraph() const node = graph.createNode('RECTANGLE', pageId(graph), { name: 'Card', width: 200, height: 100 }) graph.updateNode(node.id, { effects: [makeEffect({ type: 'INNER_SHADOW', spread: 5 })] }) const updated = getNodeOrThrow(graph, node.id) expect(updated.effects[0].type).toBe('INNER_SHADOW') expect(updated.effects[0].spread).toBe(5) }) test('background blur effect', () => { const graph = new SceneGraph() const node = graph.createNode('FRAME', pageId(graph), { name: 'GlassCard', width: 300, height: 200 }) graph.updateNode(node.id, { effects: [makeEffect({ type: 'BACKGROUND_BLUR', radius: 20 })] }) const updated = getNodeOrThrow(graph, node.id) expect(updated.effects[0].type).toBe('BACKGROUND_BLUR') expect(updated.effects[0].radius).toBe(20) }) test('foreground blur effect', () => { const graph = new SceneGraph() const node = graph.createNode('FRAME', pageId(graph), { name: 'FrostedOverlay', width: 300, height: 200 }) graph.updateNode(node.id, { effects: [makeEffect({ type: 'FOREGROUND_BLUR', radius: 8 })] }) const updated = getNodeOrThrow(graph, node.id) expect(updated.effects[0].type).toBe('FOREGROUND_BLUR') expect(updated.effects[0].radius).toBe(8) }) test('layer blur effect', () => { const graph = new SceneGraph() const node = graph.createNode('RECTANGLE', pageId(graph), { name: 'Blurred', width: 100, height: 100 }) graph.updateNode(node.id, { effects: [makeEffect({ type: 'LAYER_BLUR', radius: 12 })] }) const updated = getNodeOrThrow(graph, node.id) expect(updated.effects[0].type).toBe('LAYER_BLUR') expect(updated.effects[0].radius).toBe(12) }) test('text node with drop shadow', () => { const graph = new SceneGraph() const node = graph.createNode('TEXT', pageId(graph), { name: 'ShadowedText', width: 200, height: 50, text: 'Hello' }) graph.updateNode(node.id, { effects: [makeEffect({ type: 'DROP_SHADOW', radius: 4, offset: { x: 2, y: 2 } })] }) const updated = getNodeOrThrow(graph, node.id) expect(updated.type).toBe('TEXT') expect(updated.effects[0].type).toBe('DROP_SHADOW') }) test('text node with inner shadow', () => { const graph = new SceneGraph() const node = graph.createNode('TEXT', pageId(graph), { name: 'InnerShadowText', width: 200, height: 50, text: 'Hello' }) graph.updateNode(node.id, { effects: [makeEffect({ type: 'INNER_SHADOW', radius: 2, offset: { x: 1, y: 1 } })] }) const updated = getNodeOrThrow(graph, node.id) expect(updated.type).toBe('TEXT') expect(updated.effects[0].type).toBe('INNER_SHADOW') }) test('multiple effects on single node', () => { const graph = new SceneGraph() const node = graph.createNode('FRAME', pageId(graph), { name: 'MultiEffect', width: 300, height: 200 }) graph.updateNode(node.id, { effects: [ makeEffect({ type: 'DROP_SHADOW', radius: 8, spread: 2 }), makeEffect({ type: 'INNER_SHADOW', radius: 4, spread: 1 }), makeEffect({ type: 'BACKGROUND_BLUR', radius: 16 }), makeEffect({ type: 'LAYER_BLUR', radius: 4 }) ] }) const updated = getNodeOrThrow(graph, node.id) expect(updated.effects).toHaveLength(4) expect(updated.effects.map((e) => e.type)).toEqual([ 'DROP_SHADOW', 'INNER_SHADOW', 'BACKGROUND_BLUR', 'LAYER_BLUR' ]) }) test('invisible effect is preserved', () => { const graph = new SceneGraph() const node = graph.createNode('RECTANGLE', pageId(graph), { name: 'Hidden', width: 100, height: 100 }) graph.updateNode(node.id, { effects: [makeEffect({ visible: false })] }) expect(getNodeOrThrow(graph, node.id).effects[0].visible).toBe(false) }) test('drop shadow with negative spread (inset shrink)', () => { const graph = new SceneGraph() const node = graph.createNode('RECTANGLE', pageId(graph), { name: 'ShrunkShadow', width: 100, height: 100 }) graph.updateNode(node.id, { effects: [makeEffect({ spread: -5 })] }) expect(getNodeOrThrow(graph, node.id).effects[0].spread).toBe(-5) }) }) describe('Resize logic', () => { type ResizeState = Pick function resizeFlags(handle: string) { return { moveLeft: handle.includes('w'), moveRight: handle.includes('e'), moveTop: handle === 'nw' || handle === 'n' || handle === 'ne', moveBottom: handle === 'sw' || handle === 's' || handle === 'se' } } function applyHandleDelta(handle: string, origRect: Rect, dx: number, dy: number): ResizeState { const state: ResizeState = { ...origRect } const flags = resizeFlags(handle) if (flags.moveRight) state.width = origRect.width + dx if (flags.moveLeft) { state.x = origRect.x + dx state.width = origRect.width - dx } if (flags.moveBottom) state.height = origRect.height + dy if (flags.moveTop) { state.y = origRect.y + dy state.height = origRect.height - dy } return state } function applyAspectConstraint( handle: string, origRect: Rect, state: ResizeState, dx: number, dy: number ) { const aspect = origRect.width / origRect.height const flags = resizeFlags(handle) if (handle === 'n' || handle === 's') { state.width = Math.abs(state.height) * aspect state.x = origRect.x + (origRect.width - state.width) / 2 } else if (handle === 'e' || handle === 'w') { state.height = Math.abs(state.width) / aspect state.y = origRect.y + (origRect.height - state.height) / 2 } else if (Math.abs(dx) > Math.abs(dy)) { state.height = (Math.abs(state.width) / aspect) * Math.sign(state.height || 1) if (flags.moveTop) state.y = origRect.y + origRect.height - Math.abs(state.height) } else { state.width = Math.abs(state.height) * aspect * Math.sign(state.width || 1) if (flags.moveLeft) state.x = origRect.x + origRect.width - Math.abs(state.width) } } function normalizeResize(state: ResizeState): ResizeState { if (state.width < 0) { state.x += state.width state.width = -state.width } if (state.height < 0) { state.y += state.height state.height = -state.height } return state } function applyResize( handle: string, origRect: Rect, startX: number, startY: number, cx: number, cy: number, constrain = false ) { const dx = cx - startX const dy = cy - startY const state = applyHandleDelta(handle, origRect, dx, dy) if (constrain && origRect.width > 0 && origRect.height > 0) { applyAspectConstraint(handle, origRect, state, dx, dy) } const normalized = normalizeResize(state) return { x: Math.round(normalized.x), y: Math.round(normalized.y), width: Math.round(Math.max(1, normalized.width)), height: Math.round(Math.max(1, normalized.height)) } } const orig = { x: 100, y: 100, width: 200, height: 100 } test('east handle: grow right', () => { const result = applyResize('e', orig, 300, 150, 350, 150) expect(result).toEqual({ x: 100, y: 100, width: 250, height: 100 }) }) test('east handle: shrink right', () => { const result = applyResize('e', orig, 300, 150, 250, 150) expect(result).toEqual({ x: 100, y: 100, width: 150, height: 100 }) }) test('east handle: drag past opposite bound', () => { const result = applyResize('e', orig, 300, 150, 50, 150) expect(result.x).toBe(50) expect(result.width).toBe(50) expect(result.y).toBe(100) expect(result.height).toBe(100) }) test('west handle: grow left', () => { const result = applyResize('w', orig, 100, 150, 50, 150) expect(result).toEqual({ x: 50, y: 100, width: 250, height: 100 }) }) test('west handle: shrink left', () => { const result = applyResize('w', orig, 100, 150, 150, 150) expect(result).toEqual({ x: 150, y: 100, width: 150, height: 100 }) }) test('west handle: drag past opposite bound', () => { const result = applyResize('w', orig, 100, 150, 350, 150) expect(result.width).toBe(50) expect(result.x).toBe(300) }) test('north handle: grow up', () => { const result = applyResize('n', orig, 200, 100, 200, 50) expect(result).toEqual({ x: 100, y: 50, width: 200, height: 150 }) }) test('north handle: shrink up', () => { const result = applyResize('n', orig, 200, 100, 200, 150) expect(result).toEqual({ x: 100, y: 150, width: 200, height: 50 }) }) test('north handle: drag past opposite bound', () => { const result = applyResize('n', orig, 200, 100, 200, 250) expect(result.height).toBe(50) expect(result.y).toBe(200) }) test('south handle: grow down', () => { const result = applyResize('s', orig, 200, 200, 200, 250) expect(result).toEqual({ x: 100, y: 100, width: 200, height: 150 }) }) test('south handle: drag past opposite bound', () => { const result = applyResize('s', orig, 200, 200, 200, 50) expect(result.height).toBe(50) expect(result.y).toBe(50) }) test('nw handle: grow both axes', () => { const result = applyResize('nw', orig, 100, 100, 50, 50) expect(result).toEqual({ x: 50, y: 50, width: 250, height: 150 }) }) test('nw handle: shrink both axes', () => { const result = applyResize('nw', orig, 100, 100, 200, 150) expect(result).toEqual({ x: 200, y: 150, width: 100, height: 50 }) }) test('nw handle: drag past opposite corner', () => { const result = applyResize('nw', orig, 100, 100, 350, 250) expect(result.x).toBe(300) expect(result.y).toBe(200) expect(result.width).toBe(50) expect(result.height).toBe(50) }) test('se handle: grow both axes', () => { const result = applyResize('se', orig, 300, 200, 350, 250) expect(result).toEqual({ x: 100, y: 100, width: 250, height: 150 }) }) test('se handle: drag past opposite corner', () => { const result = applyResize('se', orig, 300, 200, 50, 50) expect(result.x).toBe(50) expect(result.y).toBe(50) expect(result.width).toBe(50) expect(result.height).toBe(50) }) test('ne handle: grow right and up', () => { const result = applyResize('ne', orig, 300, 100, 350, 50) expect(result).toEqual({ x: 100, y: 50, width: 250, height: 150 }) }) test('sw handle: grow left and down', () => { const result = applyResize('sw', orig, 100, 200, 50, 250) expect(result).toEqual({ x: 50, y: 100, width: 250, height: 150 }) }) test('opposite edge stays fixed when resizing left', () => { const result = applyResize('w', orig, 100, 150, 50, 150) expect(result.x + result.width).toBe(300) }) test('opposite edge stays fixed when resizing top', () => { const result = applyResize('n', orig, 200, 100, 200, 50) expect(result.y + result.height).toBe(200) }) test('opposite edge stays fixed when resizing nw', () => { const result = applyResize('nw', orig, 100, 100, 50, 50) expect(result.x + result.width).toBe(300) expect(result.y + result.height).toBe(200) }) test('width never goes below 1', () => { const result = applyResize('e', orig, 300, 150, 100, 150) expect(result.width).toBeGreaterThanOrEqual(1) }) test('height never goes below 1', () => { const result = applyResize('s', orig, 200, 200, 200, 100) expect(result.height).toBeGreaterThanOrEqual(1) }) test('constrained se preserves aspect ratio', () => { const result = applyResize('se', orig, 300, 200, 400, 200, true) const aspect = result.width / result.height expect(Math.abs(aspect - 2)).toBeLessThan(0.01) }) test('constrained nw preserves aspect ratio', () => { const result = applyResize('nw', orig, 100, 100, 0, 100, true) const aspect = result.width / result.height expect(Math.abs(aspect - 2)).toBeLessThan(0.01) }) })