Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: slides canvas engine, presenter mode, drag-drop file handling (#65, #67, #84)

Pure logic modules with full test coverage (116 tests):
- Slide deck model with element CRUD, positioning, layer ordering
- Presenter state with timer, navigation, speaker notes, pace tracking
- File validation, type detection, batch validation, paste handling

+1589
+194
src/lib/drag-drop.ts
··· 1 + /** 2 + * Drag & Drop / Paste — file handling for image and document uploads. 3 + * 4 + * Pure logic module: file validation, type detection, paste extraction. 5 + * Actual File API and DOM events handled by the UI layer. 6 + */ 7 + 8 + export interface FileInfo { 9 + name: string; 10 + size: number; 11 + mimeType: string; 12 + /** File extension (lowercase, without dot) */ 13 + extension: string; 14 + } 15 + 16 + export interface UploadValidation { 17 + valid: boolean; 18 + error: string | null; 19 + } 20 + 21 + export interface DropZoneConfig { 22 + /** Allowed MIME types (empty = all) */ 23 + allowedTypes: string[]; 24 + /** Max file size in bytes */ 25 + maxFileSize: number; 26 + /** Max files per drop */ 27 + maxFiles: number; 28 + /** Allow paste from clipboard */ 29 + allowPaste: boolean; 30 + } 31 + 32 + /** 33 + * Create default drop zone config. 34 + */ 35 + export function createDropZoneConfig( 36 + options: Partial<DropZoneConfig> = {}, 37 + ): DropZoneConfig { 38 + return { 39 + allowedTypes: options.allowedTypes ?? [], 40 + maxFileSize: options.maxFileSize ?? 10 * 1024 * 1024, // 10MB 41 + maxFiles: options.maxFiles ?? 10, 42 + allowPaste: options.allowPaste ?? true, 43 + }; 44 + } 45 + 46 + /** 47 + * Create an image-only drop zone config. 48 + */ 49 + export function imageDropZoneConfig(maxFileSize?: number): DropZoneConfig { 50 + return createDropZoneConfig({ 51 + allowedTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml'], 52 + maxFileSize: maxFileSize ?? 5 * 1024 * 1024, 53 + maxFiles: 5, 54 + }); 55 + } 56 + 57 + /** 58 + * Extract file info from a file-like object. 59 + */ 60 + export function extractFileInfo( 61 + name: string, 62 + size: number, 63 + mimeType: string, 64 + ): FileInfo { 65 + const dotIndex = name.lastIndexOf('.'); 66 + const extension = dotIndex >= 0 ? name.slice(dotIndex + 1).toLowerCase() : ''; 67 + return { name, size, mimeType: mimeType || guessMimeType(extension), extension }; 68 + } 69 + 70 + /** 71 + * Guess MIME type from extension. 72 + */ 73 + export function guessMimeType(extension: string): string { 74 + const map: Record<string, string> = { 75 + png: 'image/png', 76 + jpg: 'image/jpeg', 77 + jpeg: 'image/jpeg', 78 + gif: 'image/gif', 79 + webp: 'image/webp', 80 + svg: 'image/svg+xml', 81 + pdf: 'application/pdf', 82 + txt: 'text/plain', 83 + csv: 'text/csv', 84 + json: 'application/json', 85 + md: 'text/markdown', 86 + }; 87 + return map[extension.toLowerCase()] ?? 'application/octet-stream'; 88 + } 89 + 90 + /** 91 + * Validate a single file against the config. 92 + */ 93 + export function validateFile( 94 + file: FileInfo, 95 + config: DropZoneConfig, 96 + ): UploadValidation { 97 + if (file.size > config.maxFileSize) { 98 + return { valid: false, error: `File too large (max ${formatFileSize(config.maxFileSize)})` }; 99 + } 100 + 101 + if (config.allowedTypes.length > 0 && !config.allowedTypes.includes(file.mimeType)) { 102 + return { valid: false, error: `File type ${file.mimeType} not allowed` }; 103 + } 104 + 105 + return { valid: true, error: null }; 106 + } 107 + 108 + /** 109 + * Validate a batch of files. 110 + */ 111 + export function validateBatch( 112 + files: FileInfo[], 113 + config: DropZoneConfig, 114 + ): { valid: FileInfo[]; errors: Array<{ file: FileInfo; error: string }> } { 115 + const valid: FileInfo[] = []; 116 + const errors: Array<{ file: FileInfo; error: string }> = []; 117 + 118 + if (files.length > config.maxFiles) { 119 + // Only validate up to maxFiles 120 + for (const f of files.slice(config.maxFiles)) { 121 + errors.push({ file: f, error: `Too many files (max ${config.maxFiles})` }); 122 + } 123 + } 124 + 125 + for (const file of files.slice(0, config.maxFiles)) { 126 + const result = validateFile(file, config); 127 + if (result.valid) { 128 + valid.push(file); 129 + } else { 130 + errors.push({ file, error: result.error! }); 131 + } 132 + } 133 + 134 + return { valid, errors }; 135 + } 136 + 137 + /** 138 + * Check if a MIME type is an image. 139 + */ 140 + export function isImageType(mimeType: string): boolean { 141 + return mimeType.startsWith('image/'); 142 + } 143 + 144 + /** 145 + * Check if a paste event likely contains an image. 146 + */ 147 + export function isPasteImage(mimeType: string): boolean { 148 + return isImageType(mimeType); 149 + } 150 + 151 + /** 152 + * Generate a filename for pasted images. 153 + */ 154 + export function generatePasteFilename(mimeType: string): string { 155 + const ext = mimeType.split('/')[1]?.replace('svg+xml', 'svg') ?? 'png'; 156 + return `pasted-image-${Date.now()}.${ext}`; 157 + } 158 + 159 + /** 160 + * Format file size for display. 161 + */ 162 + export function formatFileSize(bytes: number): string { 163 + if (bytes < 1024) return `${bytes} B`; 164 + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; 165 + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; 166 + } 167 + 168 + /** 169 + * Get total size of a batch of files. 170 + */ 171 + export function totalBatchSize(files: FileInfo[]): number { 172 + return files.reduce((sum, f) => sum + f.size, 0); 173 + } 174 + 175 + /** 176 + * Sort files by size (largest first). 177 + */ 178 + export function sortBySize(files: FileInfo[]): FileInfo[] { 179 + return [...files].sort((a, b) => b.size - a.size); 180 + } 181 + 182 + /** 183 + * Group files by MIME type category. 184 + */ 185 + export function groupByType(files: FileInfo[]): Map<string, FileInfo[]> { 186 + const groups = new Map<string, FileInfo[]>(); 187 + for (const file of files) { 188 + const category = file.mimeType.split('/')[0]; 189 + const group = groups.get(category) || []; 190 + group.push(file); 191 + groups.set(category, group); 192 + } 193 + return groups; 194 + }
+256
src/slides/canvas-engine.ts
··· 1 + /** 2 + * Slides Canvas Engine — fixed-ratio slide rendering and element layout. 3 + * 4 + * Pure logic module: slide model, element positioning, layer ordering. 5 + * DOM/canvas rendering handled by the slides UI layer. 6 + */ 7 + 8 + export type ElementType = 'text' | 'image' | 'shape' | 'code' | 'chart' | 'embed'; 9 + export type ShapeType = 'rectangle' | 'ellipse' | 'triangle' | 'arrow' | 'line'; 10 + 11 + export interface SlideElement { 12 + id: string; 13 + type: ElementType; 14 + x: number; 15 + y: number; 16 + width: number; 17 + height: number; 18 + rotation: number; 19 + /** Layer order (higher = on top) */ 20 + zIndex: number; 21 + /** Element-specific content */ 22 + content: string; 23 + /** For shapes */ 24 + shapeType?: ShapeType; 25 + /** Style properties */ 26 + style: Record<string, string>; 27 + } 28 + 29 + export interface Slide { 30 + id: string; 31 + elements: SlideElement[]; 32 + background: string; 33 + notes: string; 34 + } 35 + 36 + export interface DeckState { 37 + slides: Slide[]; 38 + currentSlide: number; 39 + /** Fixed aspect ratio (width/height) */ 40 + aspectRatio: number; 41 + } 42 + 43 + let _slideCounter = 0; 44 + let _elementCounter = 0; 45 + 46 + export const DEFAULT_ASPECT_RATIO = 16 / 9; 47 + export const SLIDE_WIDTH = 960; 48 + export const SLIDE_HEIGHT = 540; 49 + 50 + /** 51 + * Create an empty deck. 52 + */ 53 + export function createDeck(): DeckState { 54 + const slide = createSlide(); 55 + return { slides: [slide], currentSlide: 0, aspectRatio: DEFAULT_ASPECT_RATIO }; 56 + } 57 + 58 + /** 59 + * Create a new slide. 60 + */ 61 + export function createSlide(background = '#ffffff'): Slide { 62 + return { 63 + id: `slide-${Date.now()}-${++_slideCounter}`, 64 + elements: [], 65 + background, 66 + notes: '', 67 + }; 68 + } 69 + 70 + /** 71 + * Add a slide at a position. 72 + */ 73 + export function addSlide( 74 + state: DeckState, 75 + index?: number, 76 + background?: string, 77 + ): DeckState { 78 + const slide = createSlide(background); 79 + const slides = [...state.slides]; 80 + const pos = index ?? slides.length; 81 + slides.splice(pos, 0, slide); 82 + return { ...state, slides }; 83 + } 84 + 85 + /** 86 + * Remove a slide. 87 + */ 88 + export function removeSlide(state: DeckState, index: number): DeckState { 89 + if (state.slides.length <= 1) return state; 90 + const slides = state.slides.filter((_, i) => i !== index); 91 + const currentSlide = Math.min(state.currentSlide, slides.length - 1); 92 + return { ...state, slides, currentSlide }; 93 + } 94 + 95 + /** 96 + * Move a slide from one position to another. 97 + */ 98 + export function moveSlide(state: DeckState, from: number, to: number): DeckState { 99 + const slides = [...state.slides]; 100 + const [moved] = slides.splice(from, 1); 101 + slides.splice(to, 0, moved); 102 + return { ...state, slides }; 103 + } 104 + 105 + /** 106 + * Duplicate a slide. 107 + */ 108 + export function duplicateSlide(state: DeckState, index: number): DeckState { 109 + const original = state.slides[index]; 110 + if (!original) return state; 111 + const copy: Slide = { 112 + ...createSlide(original.background), 113 + elements: original.elements.map(e => ({ ...e, id: `el-${Date.now()}-${++_elementCounter}` })), 114 + notes: original.notes, 115 + }; 116 + const slides = [...state.slides]; 117 + slides.splice(index + 1, 0, copy); 118 + return { ...state, slides }; 119 + } 120 + 121 + /** 122 + * Go to a specific slide. 123 + */ 124 + export function goToSlide(state: DeckState, index: number): DeckState { 125 + const clamped = Math.max(0, Math.min(index, state.slides.length - 1)); 126 + return { ...state, currentSlide: clamped }; 127 + } 128 + 129 + /** 130 + * Add an element to the current slide. 131 + */ 132 + export function addElement( 133 + state: DeckState, 134 + type: ElementType, 135 + x: number, 136 + y: number, 137 + width: number, 138 + height: number, 139 + content = '', 140 + style: Record<string, string> = {}, 141 + ): DeckState { 142 + const element: SlideElement = { 143 + id: `el-${Date.now()}-${++_elementCounter}`, 144 + type, 145 + x, y, width, height, 146 + rotation: 0, 147 + zIndex: currentSlide(state).elements.length, 148 + content, 149 + style, 150 + }; 151 + return updateCurrentSlide(state, slide => ({ 152 + ...slide, 153 + elements: [...slide.elements, element], 154 + })); 155 + } 156 + 157 + /** 158 + * Remove an element from the current slide. 159 + */ 160 + export function removeElement(state: DeckState, elementId: string): DeckState { 161 + return updateCurrentSlide(state, slide => ({ 162 + ...slide, 163 + elements: slide.elements.filter(e => e.id !== elementId), 164 + })); 165 + } 166 + 167 + /** 168 + * Move an element. 169 + */ 170 + export function moveElement( 171 + state: DeckState, 172 + elementId: string, 173 + x: number, 174 + y: number, 175 + ): DeckState { 176 + return updateElement(state, elementId, { x, y }); 177 + } 178 + 179 + /** 180 + * Resize an element. 181 + */ 182 + export function resizeElement( 183 + state: DeckState, 184 + elementId: string, 185 + width: number, 186 + height: number, 187 + ): DeckState { 188 + return updateElement(state, elementId, { 189 + width: Math.max(10, width), 190 + height: Math.max(10, height), 191 + }); 192 + } 193 + 194 + /** 195 + * Bring an element to front. 196 + */ 197 + export function bringToFront(state: DeckState, elementId: string): DeckState { 198 + const slide = currentSlide(state); 199 + const maxZ = Math.max(...slide.elements.map(e => e.zIndex), 0); 200 + return updateElement(state, elementId, { zIndex: maxZ + 1 }); 201 + } 202 + 203 + /** 204 + * Send an element to back. 205 + */ 206 + export function sendToBack(state: DeckState, elementId: string): DeckState { 207 + const slide = currentSlide(state); 208 + const minZ = Math.min(...slide.elements.map(e => e.zIndex), 0); 209 + return updateElement(state, elementId, { zIndex: minZ - 1 }); 210 + } 211 + 212 + /** 213 + * Get the current slide. 214 + */ 215 + export function currentSlide(state: DeckState): Slide { 216 + return state.slides[state.currentSlide]; 217 + } 218 + 219 + /** 220 + * Get slide count. 221 + */ 222 + export function slideCount(state: DeckState): number { 223 + return state.slides.length; 224 + } 225 + 226 + /** 227 + * Get element count on current slide. 228 + */ 229 + export function elementCount(state: DeckState): number { 230 + return currentSlide(state).elements.length; 231 + } 232 + 233 + /** Internal: update an element's properties */ 234 + function updateElement( 235 + state: DeckState, 236 + elementId: string, 237 + updates: Partial<SlideElement>, 238 + ): DeckState { 239 + return updateCurrentSlide(state, slide => ({ 240 + ...slide, 241 + elements: slide.elements.map(e => 242 + e.id === elementId ? { ...e, ...updates } : e, 243 + ), 244 + })); 245 + } 246 + 247 + /** Internal: update the current slide */ 248 + function updateCurrentSlide( 249 + state: DeckState, 250 + updater: (slide: Slide) => Slide, 251 + ): DeckState { 252 + const slides = state.slides.map((s, i) => 253 + i === state.currentSlide ? updater(s) : s, 254 + ); 255 + return { ...state, slides }; 256 + }
+185
src/slides/presenter-mode.ts
··· 1 + /** 2 + * Presenter Mode — speaker notes, timer, navigation for presentations. 3 + * 4 + * Pure logic module: timer, navigation, notes display state. 5 + * DOM rendering (dual-screen display) handled by the slides UI layer. 6 + */ 7 + 8 + export interface PresenterState { 9 + active: boolean; 10 + currentSlide: number; 11 + totalSlides: number; 12 + /** Elapsed time in seconds */ 13 + elapsedSeconds: number; 14 + /** Target duration in seconds (0 = no timer) */ 15 + targetDuration: number; 16 + /** Whether timer is running */ 17 + timerRunning: boolean; 18 + /** Show notes panel */ 19 + showNotes: boolean; 20 + /** Show next slide preview */ 21 + showPreview: boolean; 22 + /** Speaker notes per slide */ 23 + notes: Map<number, string>; 24 + } 25 + 26 + /** 27 + * Create initial presenter state. 28 + */ 29 + export function createPresenterState( 30 + totalSlides: number, 31 + targetDuration = 0, 32 + ): PresenterState { 33 + return { 34 + active: false, 35 + currentSlide: 0, 36 + totalSlides, 37 + elapsedSeconds: 0, 38 + targetDuration, 39 + timerRunning: false, 40 + showNotes: true, 41 + showPreview: true, 42 + notes: new Map(), 43 + }; 44 + } 45 + 46 + /** 47 + * Start presenter mode. 48 + */ 49 + export function startPresentation(state: PresenterState): PresenterState { 50 + return { ...state, active: true, currentSlide: 0, elapsedSeconds: 0, timerRunning: true }; 51 + } 52 + 53 + /** 54 + * Stop presenter mode. 55 + */ 56 + export function stopPresentation(state: PresenterState): PresenterState { 57 + return { ...state, active: false, timerRunning: false }; 58 + } 59 + 60 + /** 61 + * Go to next slide. 62 + */ 63 + export function nextSlide(state: PresenterState): PresenterState { 64 + if (state.currentSlide >= state.totalSlides - 1) return state; 65 + return { ...state, currentSlide: state.currentSlide + 1 }; 66 + } 67 + 68 + /** 69 + * Go to previous slide. 70 + */ 71 + export function prevSlide(state: PresenterState): PresenterState { 72 + if (state.currentSlide <= 0) return state; 73 + return { ...state, currentSlide: state.currentSlide - 1 }; 74 + } 75 + 76 + /** 77 + * Jump to a specific slide. 78 + */ 79 + export function jumpToSlide(state: PresenterState, index: number): PresenterState { 80 + const clamped = Math.max(0, Math.min(index, state.totalSlides - 1)); 81 + return { ...state, currentSlide: clamped }; 82 + } 83 + 84 + /** 85 + * Tick the timer (called every second). 86 + */ 87 + export function tickTimer(state: PresenterState): PresenterState { 88 + if (!state.timerRunning) return state; 89 + return { ...state, elapsedSeconds: state.elapsedSeconds + 1 }; 90 + } 91 + 92 + /** 93 + * Toggle timer. 94 + */ 95 + export function toggleTimer(state: PresenterState): PresenterState { 96 + return { ...state, timerRunning: !state.timerRunning }; 97 + } 98 + 99 + /** 100 + * Reset timer. 101 + */ 102 + export function resetTimer(state: PresenterState): PresenterState { 103 + return { ...state, elapsedSeconds: 0 }; 104 + } 105 + 106 + /** 107 + * Toggle notes panel visibility. 108 + */ 109 + export function toggleNotes(state: PresenterState): PresenterState { 110 + return { ...state, showNotes: !state.showNotes }; 111 + } 112 + 113 + /** 114 + * Toggle next slide preview. 115 + */ 116 + export function togglePreview(state: PresenterState): PresenterState { 117 + return { ...state, showPreview: !state.showPreview }; 118 + } 119 + 120 + /** 121 + * Set speaker notes for a slide. 122 + */ 123 + export function setNotes( 124 + state: PresenterState, 125 + slideIndex: number, 126 + text: string, 127 + ): PresenterState { 128 + const notes = new Map(state.notes); 129 + if (text) { 130 + notes.set(slideIndex, text); 131 + } else { 132 + notes.delete(slideIndex); 133 + } 134 + return { ...state, notes }; 135 + } 136 + 137 + /** 138 + * Get notes for the current slide. 139 + */ 140 + export function currentNotes(state: PresenterState): string { 141 + return state.notes.get(state.currentSlide) ?? ''; 142 + } 143 + 144 + /** 145 + * Format elapsed time as MM:SS. 146 + */ 147 + export function formatTime(seconds: number): string { 148 + const m = Math.floor(seconds / 60); 149 + const s = seconds % 60; 150 + return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; 151 + } 152 + 153 + /** 154 + * Get time remaining (for target duration). 155 + */ 156 + export function timeRemaining(state: PresenterState): number { 157 + if (state.targetDuration <= 0) return 0; 158 + return Math.max(0, state.targetDuration - state.elapsedSeconds); 159 + } 160 + 161 + /** 162 + * Check if over time. 163 + */ 164 + export function isOverTime(state: PresenterState): boolean { 165 + return state.targetDuration > 0 && state.elapsedSeconds > state.targetDuration; 166 + } 167 + 168 + /** 169 + * Calculate pace (are we ahead or behind?). 170 + * Returns a value where < 0 means behind, > 0 means ahead. 171 + */ 172 + export function paceDelta(state: PresenterState): number { 173 + if (state.targetDuration <= 0 || state.totalSlides <= 1) return 0; 174 + const expectedProgress = state.elapsedSeconds / state.targetDuration; 175 + const actualProgress = state.currentSlide / (state.totalSlides - 1); 176 + return actualProgress - expectedProgress; 177 + } 178 + 179 + /** 180 + * Get progress percentage (0-100). 181 + */ 182 + export function progressPercent(state: PresenterState): number { 183 + if (state.totalSlides <= 1) return 100; 184 + return (state.currentSlide / (state.totalSlides - 1)) * 100; 185 + }
+325
tests/canvas-engine.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + createDeck, 4 + createSlide, 5 + addSlide, 6 + removeSlide, 7 + moveSlide, 8 + duplicateSlide, 9 + goToSlide, 10 + addElement, 11 + removeElement, 12 + moveElement, 13 + resizeElement, 14 + bringToFront, 15 + sendToBack, 16 + currentSlide, 17 + slideCount, 18 + elementCount, 19 + DEFAULT_ASPECT_RATIO, 20 + SLIDE_WIDTH, 21 + SLIDE_HEIGHT, 22 + } from '../src/slides/canvas-engine'; 23 + 24 + describe('canvas-engine', () => { 25 + describe('constants', () => { 26 + it('has 16:9 default aspect ratio', () => { 27 + expect(DEFAULT_ASPECT_RATIO).toBeCloseTo(16 / 9); 28 + }); 29 + 30 + it('has 960x540 slide dimensions', () => { 31 + expect(SLIDE_WIDTH).toBe(960); 32 + expect(SLIDE_HEIGHT).toBe(540); 33 + }); 34 + }); 35 + 36 + describe('createDeck', () => { 37 + it('creates a deck with one slide', () => { 38 + const deck = createDeck(); 39 + expect(deck.slides).toHaveLength(1); 40 + expect(deck.currentSlide).toBe(0); 41 + expect(deck.aspectRatio).toBeCloseTo(16 / 9); 42 + }); 43 + 44 + it('first slide has white background', () => { 45 + const deck = createDeck(); 46 + expect(deck.slides[0].background).toBe('#ffffff'); 47 + }); 48 + 49 + it('first slide has no elements', () => { 50 + const deck = createDeck(); 51 + expect(deck.slides[0].elements).toHaveLength(0); 52 + }); 53 + }); 54 + 55 + describe('createSlide', () => { 56 + it('creates a slide with default white background', () => { 57 + const slide = createSlide(); 58 + expect(slide.background).toBe('#ffffff'); 59 + expect(slide.elements).toHaveLength(0); 60 + expect(slide.notes).toBe(''); 61 + }); 62 + 63 + it('creates a slide with custom background', () => { 64 + const slide = createSlide('#000000'); 65 + expect(slide.background).toBe('#000000'); 66 + }); 67 + 68 + it('generates unique IDs', () => { 69 + const a = createSlide(); 70 + const b = createSlide(); 71 + expect(a.id).not.toBe(b.id); 72 + }); 73 + }); 74 + 75 + describe('addSlide', () => { 76 + it('adds a slide at the end by default', () => { 77 + const deck = createDeck(); 78 + const updated = addSlide(deck); 79 + expect(slideCount(updated)).toBe(2); 80 + }); 81 + 82 + it('adds a slide at a specific position', () => { 83 + const deck = createDeck(); 84 + const updated = addSlide(addSlide(deck), 1, '#ff0000'); 85 + expect(updated.slides[1].background).toBe('#ff0000'); 86 + }); 87 + 88 + it('preserves existing slides', () => { 89 + const deck = createDeck(); 90 + const originalId = deck.slides[0].id; 91 + const updated = addSlide(deck); 92 + expect(updated.slides[0].id).toBe(originalId); 93 + }); 94 + }); 95 + 96 + describe('removeSlide', () => { 97 + it('removes a slide by index', () => { 98 + let deck = createDeck(); 99 + deck = addSlide(deck); 100 + const secondId = deck.slides[1].id; 101 + const updated = removeSlide(deck, 0); 102 + expect(slideCount(updated)).toBe(1); 103 + expect(updated.slides[0].id).toBe(secondId); 104 + }); 105 + 106 + it('does not remove the last slide', () => { 107 + const deck = createDeck(); 108 + const updated = removeSlide(deck, 0); 109 + expect(slideCount(updated)).toBe(1); 110 + }); 111 + 112 + it('clamps currentSlide when removing the last slide in the list', () => { 113 + let deck = createDeck(); 114 + deck = addSlide(deck); 115 + deck = goToSlide(deck, 1); 116 + const updated = removeSlide(deck, 1); 117 + expect(updated.currentSlide).toBe(0); 118 + }); 119 + }); 120 + 121 + describe('moveSlide', () => { 122 + it('moves a slide from one position to another', () => { 123 + let deck = createDeck(); 124 + deck = addSlide(deck); 125 + deck = addSlide(deck); 126 + const firstId = deck.slides[0].id; 127 + const updated = moveSlide(deck, 0, 2); 128 + expect(updated.slides[2].id).toBe(firstId); 129 + }); 130 + }); 131 + 132 + describe('duplicateSlide', () => { 133 + it('creates a copy after the original', () => { 134 + let deck = createDeck(); 135 + deck = addElement(deck, 'text', 10, 20, 100, 50, 'Hello'); 136 + const updated = duplicateSlide(deck, 0); 137 + expect(slideCount(updated)).toBe(2); 138 + expect(updated.slides[1].elements).toHaveLength(1); 139 + expect(updated.slides[1].elements[0].content).toBe('Hello'); 140 + }); 141 + 142 + it('duplicate has different IDs', () => { 143 + let deck = createDeck(); 144 + deck = addElement(deck, 'text', 10, 20, 100, 50, 'Hello'); 145 + const updated = duplicateSlide(deck, 0); 146 + expect(updated.slides[1].id).not.toBe(updated.slides[0].id); 147 + expect(updated.slides[1].elements[0].id).not.toBe(updated.slides[0].elements[0].id); 148 + }); 149 + 150 + it('preserves notes', () => { 151 + let deck = createDeck(); 152 + // Manually set notes on the slide 153 + deck = { 154 + ...deck, 155 + slides: deck.slides.map(s => ({ ...s, notes: 'Speaker notes here' })), 156 + }; 157 + const updated = duplicateSlide(deck, 0); 158 + expect(updated.slides[1].notes).toBe('Speaker notes here'); 159 + }); 160 + 161 + it('returns unchanged state for invalid index', () => { 162 + const deck = createDeck(); 163 + const updated = duplicateSlide(deck, 5); 164 + expect(updated).toBe(deck); 165 + }); 166 + }); 167 + 168 + describe('goToSlide', () => { 169 + it('goes to a valid slide index', () => { 170 + let deck = createDeck(); 171 + deck = addSlide(deck); 172 + deck = addSlide(deck); 173 + const updated = goToSlide(deck, 2); 174 + expect(updated.currentSlide).toBe(2); 175 + }); 176 + 177 + it('clamps to 0 for negative index', () => { 178 + const deck = createDeck(); 179 + const updated = goToSlide(deck, -5); 180 + expect(updated.currentSlide).toBe(0); 181 + }); 182 + 183 + it('clamps to last slide for too-large index', () => { 184 + let deck = createDeck(); 185 + deck = addSlide(deck); 186 + const updated = goToSlide(deck, 100); 187 + expect(updated.currentSlide).toBe(1); 188 + }); 189 + }); 190 + 191 + describe('addElement', () => { 192 + it('adds an element to the current slide', () => { 193 + const deck = createDeck(); 194 + const updated = addElement(deck, 'text', 10, 20, 200, 100, 'Hello'); 195 + expect(elementCount(updated)).toBe(1); 196 + const el = currentSlide(updated).elements[0]; 197 + expect(el.type).toBe('text'); 198 + expect(el.x).toBe(10); 199 + expect(el.y).toBe(20); 200 + expect(el.width).toBe(200); 201 + expect(el.height).toBe(100); 202 + expect(el.content).toBe('Hello'); 203 + expect(el.rotation).toBe(0); 204 + }); 205 + 206 + it('assigns incrementing zIndex', () => { 207 + let deck = createDeck(); 208 + deck = addElement(deck, 'text', 0, 0, 100, 50); 209 + deck = addElement(deck, 'image', 0, 0, 100, 50); 210 + const els = currentSlide(deck).elements; 211 + expect(els[0].zIndex).toBe(0); 212 + expect(els[1].zIndex).toBe(1); 213 + }); 214 + 215 + it('applies custom style', () => { 216 + const deck = createDeck(); 217 + const updated = addElement(deck, 'text', 0, 0, 100, 50, '', { color: 'red' }); 218 + expect(currentSlide(updated).elements[0].style).toEqual({ color: 'red' }); 219 + }); 220 + }); 221 + 222 + describe('removeElement', () => { 223 + it('removes an element by ID', () => { 224 + let deck = createDeck(); 225 + deck = addElement(deck, 'text', 0, 0, 100, 50); 226 + const elId = currentSlide(deck).elements[0].id; 227 + const updated = removeElement(deck, elId); 228 + expect(elementCount(updated)).toBe(0); 229 + }); 230 + 231 + it('does nothing for non-existent ID', () => { 232 + let deck = createDeck(); 233 + deck = addElement(deck, 'text', 0, 0, 100, 50); 234 + const updated = removeElement(deck, 'no-such-id'); 235 + expect(elementCount(updated)).toBe(1); 236 + }); 237 + }); 238 + 239 + describe('moveElement', () => { 240 + it('moves an element to new coordinates', () => { 241 + let deck = createDeck(); 242 + deck = addElement(deck, 'text', 0, 0, 100, 50); 243 + const elId = currentSlide(deck).elements[0].id; 244 + const updated = moveElement(deck, elId, 50, 75); 245 + const el = currentSlide(updated).elements[0]; 246 + expect(el.x).toBe(50); 247 + expect(el.y).toBe(75); 248 + }); 249 + }); 250 + 251 + describe('resizeElement', () => { 252 + it('resizes an element', () => { 253 + let deck = createDeck(); 254 + deck = addElement(deck, 'text', 0, 0, 100, 50); 255 + const elId = currentSlide(deck).elements[0].id; 256 + const updated = resizeElement(deck, elId, 300, 200); 257 + const el = currentSlide(updated).elements[0]; 258 + expect(el.width).toBe(300); 259 + expect(el.height).toBe(200); 260 + }); 261 + 262 + it('enforces minimum size of 10', () => { 263 + let deck = createDeck(); 264 + deck = addElement(deck, 'text', 0, 0, 100, 50); 265 + const elId = currentSlide(deck).elements[0].id; 266 + const updated = resizeElement(deck, elId, 5, 3); 267 + const el = currentSlide(updated).elements[0]; 268 + expect(el.width).toBe(10); 269 + expect(el.height).toBe(10); 270 + }); 271 + }); 272 + 273 + describe('bringToFront', () => { 274 + it('sets zIndex above all others', () => { 275 + let deck = createDeck(); 276 + deck = addElement(deck, 'text', 0, 0, 100, 50); 277 + deck = addElement(deck, 'image', 0, 0, 100, 50); 278 + const firstId = currentSlide(deck).elements[0].id; 279 + const updated = bringToFront(deck, firstId); 280 + const el = currentSlide(updated).elements.find(e => e.id === firstId)!; 281 + const maxZ = Math.max(...currentSlide(updated).elements.map(e => e.zIndex)); 282 + expect(el.zIndex).toBe(maxZ); 283 + }); 284 + }); 285 + 286 + describe('sendToBack', () => { 287 + it('sets zIndex below all others', () => { 288 + let deck = createDeck(); 289 + deck = addElement(deck, 'text', 0, 0, 100, 50); 290 + deck = addElement(deck, 'image', 0, 0, 100, 50); 291 + const secondId = currentSlide(deck).elements[1].id; 292 + const updated = sendToBack(deck, secondId); 293 + const el = currentSlide(updated).elements.find(e => e.id === secondId)!; 294 + const minZ = Math.min(...currentSlide(updated).elements.map(e => e.zIndex)); 295 + expect(el.zIndex).toBe(minZ); 296 + }); 297 + }); 298 + 299 + describe('currentSlide', () => { 300 + it('returns the slide at the current index', () => { 301 + let deck = createDeck(); 302 + deck = addSlide(deck); 303 + deck = goToSlide(deck, 1); 304 + expect(currentSlide(deck).id).toBe(deck.slides[1].id); 305 + }); 306 + }); 307 + 308 + describe('slideCount', () => { 309 + it('returns number of slides', () => { 310 + let deck = createDeck(); 311 + deck = addSlide(deck); 312 + deck = addSlide(deck); 313 + expect(slideCount(deck)).toBe(3); 314 + }); 315 + }); 316 + 317 + describe('elementCount', () => { 318 + it('returns number of elements on current slide', () => { 319 + let deck = createDeck(); 320 + deck = addElement(deck, 'text', 0, 0, 100, 50); 321 + deck = addElement(deck, 'image', 0, 0, 100, 50); 322 + expect(elementCount(deck)).toBe(2); 323 + }); 324 + }); 325 + });
+318
tests/drag-drop.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + createDropZoneConfig, 4 + imageDropZoneConfig, 5 + extractFileInfo, 6 + guessMimeType, 7 + validateFile, 8 + validateBatch, 9 + isImageType, 10 + isPasteImage, 11 + generatePasteFilename, 12 + formatFileSize, 13 + totalBatchSize, 14 + sortBySize, 15 + groupByType, 16 + type FileInfo, 17 + type DropZoneConfig, 18 + } from '../src/lib/drag-drop'; 19 + 20 + describe('drag-drop', () => { 21 + describe('createDropZoneConfig', () => { 22 + it('creates default config', () => { 23 + const config = createDropZoneConfig(); 24 + expect(config.allowedTypes).toEqual([]); 25 + expect(config.maxFileSize).toBe(10 * 1024 * 1024); 26 + expect(config.maxFiles).toBe(10); 27 + expect(config.allowPaste).toBe(true); 28 + }); 29 + 30 + it('accepts partial overrides', () => { 31 + const config = createDropZoneConfig({ maxFiles: 3, allowPaste: false }); 32 + expect(config.maxFiles).toBe(3); 33 + expect(config.allowPaste).toBe(false); 34 + expect(config.maxFileSize).toBe(10 * 1024 * 1024); // still default 35 + }); 36 + }); 37 + 38 + describe('imageDropZoneConfig', () => { 39 + it('allows only image types', () => { 40 + const config = imageDropZoneConfig(); 41 + expect(config.allowedTypes).toContain('image/png'); 42 + expect(config.allowedTypes).toContain('image/jpeg'); 43 + expect(config.allowedTypes).toContain('image/gif'); 44 + expect(config.allowedTypes).toContain('image/webp'); 45 + expect(config.allowedTypes).toContain('image/svg+xml'); 46 + expect(config.allowedTypes).toHaveLength(5); 47 + }); 48 + 49 + it('has 5MB default max size', () => { 50 + const config = imageDropZoneConfig(); 51 + expect(config.maxFileSize).toBe(5 * 1024 * 1024); 52 + }); 53 + 54 + it('accepts custom max size', () => { 55 + const config = imageDropZoneConfig(2 * 1024 * 1024); 56 + expect(config.maxFileSize).toBe(2 * 1024 * 1024); 57 + }); 58 + 59 + it('allows max 5 files', () => { 60 + expect(imageDropZoneConfig().maxFiles).toBe(5); 61 + }); 62 + }); 63 + 64 + describe('extractFileInfo', () => { 65 + it('extracts basic info', () => { 66 + const info = extractFileInfo('photo.png', 1024, 'image/png'); 67 + expect(info.name).toBe('photo.png'); 68 + expect(info.size).toBe(1024); 69 + expect(info.mimeType).toBe('image/png'); 70 + expect(info.extension).toBe('png'); 71 + }); 72 + 73 + it('normalizes extension to lowercase', () => { 74 + const info = extractFileInfo('PHOTO.PNG', 1024, 'image/png'); 75 + expect(info.extension).toBe('png'); 76 + }); 77 + 78 + it('handles no extension', () => { 79 + const info = extractFileInfo('Makefile', 256, ''); 80 + expect(info.extension).toBe(''); 81 + }); 82 + 83 + it('guesses MIME type when empty', () => { 84 + const info = extractFileInfo('data.json', 512, ''); 85 + expect(info.mimeType).toBe('application/json'); 86 + }); 87 + 88 + it('handles multiple dots in name', () => { 89 + const info = extractFileInfo('archive.tar.gz', 2048, ''); 90 + expect(info.extension).toBe('gz'); 91 + }); 92 + }); 93 + 94 + describe('guessMimeType', () => { 95 + it('maps common image extensions', () => { 96 + expect(guessMimeType('png')).toBe('image/png'); 97 + expect(guessMimeType('jpg')).toBe('image/jpeg'); 98 + expect(guessMimeType('jpeg')).toBe('image/jpeg'); 99 + expect(guessMimeType('gif')).toBe('image/gif'); 100 + expect(guessMimeType('webp')).toBe('image/webp'); 101 + expect(guessMimeType('svg')).toBe('image/svg+xml'); 102 + }); 103 + 104 + it('maps document extensions', () => { 105 + expect(guessMimeType('pdf')).toBe('application/pdf'); 106 + expect(guessMimeType('txt')).toBe('text/plain'); 107 + expect(guessMimeType('csv')).toBe('text/csv'); 108 + expect(guessMimeType('json')).toBe('application/json'); 109 + expect(guessMimeType('md')).toBe('text/markdown'); 110 + }); 111 + 112 + it('is case-insensitive', () => { 113 + expect(guessMimeType('PNG')).toBe('image/png'); 114 + expect(guessMimeType('Jpg')).toBe('image/jpeg'); 115 + }); 116 + 117 + it('returns octet-stream for unknown extensions', () => { 118 + expect(guessMimeType('xyz')).toBe('application/octet-stream'); 119 + expect(guessMimeType('')).toBe('application/octet-stream'); 120 + }); 121 + }); 122 + 123 + describe('validateFile', () => { 124 + const config: DropZoneConfig = { 125 + allowedTypes: ['image/png', 'image/jpeg'], 126 + maxFileSize: 1024 * 1024, // 1MB 127 + maxFiles: 5, 128 + allowPaste: true, 129 + }; 130 + 131 + it('validates a valid file', () => { 132 + const file: FileInfo = { name: 'photo.png', size: 500, mimeType: 'image/png', extension: 'png' }; 133 + const result = validateFile(file, config); 134 + expect(result.valid).toBe(true); 135 + expect(result.error).toBeNull(); 136 + }); 137 + 138 + it('rejects files that are too large', () => { 139 + const file: FileInfo = { name: 'big.png', size: 2 * 1024 * 1024, mimeType: 'image/png', extension: 'png' }; 140 + const result = validateFile(file, config); 141 + expect(result.valid).toBe(false); 142 + expect(result.error).toContain('too large'); 143 + }); 144 + 145 + it('rejects disallowed MIME types', () => { 146 + const file: FileInfo = { name: 'doc.pdf', size: 500, mimeType: 'application/pdf', extension: 'pdf' }; 147 + const result = validateFile(file, config); 148 + expect(result.valid).toBe(false); 149 + expect(result.error).toContain('not allowed'); 150 + }); 151 + 152 + it('allows any type when allowedTypes is empty', () => { 153 + const openConfig = createDropZoneConfig(); 154 + const file: FileInfo = { name: 'anything.xyz', size: 100, mimeType: 'application/octet-stream', extension: 'xyz' }; 155 + expect(validateFile(file, openConfig).valid).toBe(true); 156 + }); 157 + }); 158 + 159 + describe('validateBatch', () => { 160 + const config: DropZoneConfig = { 161 + allowedTypes: [], 162 + maxFileSize: 1024 * 1024, 163 + maxFiles: 2, 164 + allowPaste: true, 165 + }; 166 + 167 + const smallFile = (name: string): FileInfo => ({ 168 + name, size: 100, mimeType: 'text/plain', extension: 'txt', 169 + }); 170 + 171 + it('validates a batch within limits', () => { 172 + const result = validateBatch([smallFile('a.txt'), smallFile('b.txt')], config); 173 + expect(result.valid).toHaveLength(2); 174 + expect(result.errors).toHaveLength(0); 175 + }); 176 + 177 + it('rejects files beyond maxFiles', () => { 178 + const files = [smallFile('a.txt'), smallFile('b.txt'), smallFile('c.txt')]; 179 + const result = validateBatch(files, config); 180 + expect(result.valid).toHaveLength(2); 181 + expect(result.errors).toHaveLength(1); 182 + expect(result.errors[0].error).toContain('Too many'); 183 + }); 184 + 185 + it('validates individual files within the batch', () => { 186 + const bigFile: FileInfo = { name: 'big.txt', size: 5 * 1024 * 1024, mimeType: 'text/plain', extension: 'txt' }; 187 + const result = validateBatch([smallFile('a.txt'), bigFile], config); 188 + expect(result.valid).toHaveLength(1); 189 + expect(result.errors).toHaveLength(1); 190 + expect(result.errors[0].error).toContain('too large'); 191 + }); 192 + 193 + it('handles empty batch', () => { 194 + const result = validateBatch([], config); 195 + expect(result.valid).toHaveLength(0); 196 + expect(result.errors).toHaveLength(0); 197 + }); 198 + }); 199 + 200 + describe('isImageType', () => { 201 + it('returns true for image MIME types', () => { 202 + expect(isImageType('image/png')).toBe(true); 203 + expect(isImageType('image/jpeg')).toBe(true); 204 + expect(isImageType('image/svg+xml')).toBe(true); 205 + }); 206 + 207 + it('returns false for non-image types', () => { 208 + expect(isImageType('application/pdf')).toBe(false); 209 + expect(isImageType('text/plain')).toBe(false); 210 + }); 211 + }); 212 + 213 + describe('isPasteImage', () => { 214 + it('returns true for image types', () => { 215 + expect(isPasteImage('image/png')).toBe(true); 216 + }); 217 + 218 + it('returns false for non-image types', () => { 219 + expect(isPasteImage('text/plain')).toBe(false); 220 + }); 221 + }); 222 + 223 + describe('generatePasteFilename', () => { 224 + it('generates a filename with correct extension', () => { 225 + const name = generatePasteFilename('image/png'); 226 + expect(name).toMatch(/^pasted-image-\d+\.png$/); 227 + }); 228 + 229 + it('converts svg+xml to svg', () => { 230 + const name = generatePasteFilename('image/svg+xml'); 231 + expect(name).toMatch(/\.svg$/); 232 + }); 233 + 234 + it('uses jpeg extension for jpeg', () => { 235 + const name = generatePasteFilename('image/jpeg'); 236 + expect(name).toMatch(/\.jpeg$/); 237 + }); 238 + }); 239 + 240 + describe('formatFileSize', () => { 241 + it('formats bytes', () => { 242 + expect(formatFileSize(512)).toBe('512 B'); 243 + }); 244 + 245 + it('formats kilobytes', () => { 246 + expect(formatFileSize(2048)).toBe('2.0 KB'); 247 + }); 248 + 249 + it('formats megabytes', () => { 250 + expect(formatFileSize(5 * 1024 * 1024)).toBe('5.0 MB'); 251 + }); 252 + 253 + it('formats edge at 1024', () => { 254 + expect(formatFileSize(1024)).toBe('1.0 KB'); 255 + }); 256 + 257 + it('formats 0 bytes', () => { 258 + expect(formatFileSize(0)).toBe('0 B'); 259 + }); 260 + }); 261 + 262 + describe('totalBatchSize', () => { 263 + it('sums file sizes', () => { 264 + const files: FileInfo[] = [ 265 + { name: 'a.txt', size: 100, mimeType: 'text/plain', extension: 'txt' }, 266 + { name: 'b.txt', size: 200, mimeType: 'text/plain', extension: 'txt' }, 267 + { name: 'c.txt', size: 300, mimeType: 'text/plain', extension: 'txt' }, 268 + ]; 269 + expect(totalBatchSize(files)).toBe(600); 270 + }); 271 + 272 + it('returns 0 for empty array', () => { 273 + expect(totalBatchSize([])).toBe(0); 274 + }); 275 + }); 276 + 277 + describe('sortBySize', () => { 278 + it('sorts largest first', () => { 279 + const files: FileInfo[] = [ 280 + { name: 'small.txt', size: 10, mimeType: 'text/plain', extension: 'txt' }, 281 + { name: 'big.txt', size: 1000, mimeType: 'text/plain', extension: 'txt' }, 282 + { name: 'med.txt', size: 500, mimeType: 'text/plain', extension: 'txt' }, 283 + ]; 284 + const sorted = sortBySize(files); 285 + expect(sorted[0].name).toBe('big.txt'); 286 + expect(sorted[1].name).toBe('med.txt'); 287 + expect(sorted[2].name).toBe('small.txt'); 288 + }); 289 + 290 + it('does not mutate original', () => { 291 + const files: FileInfo[] = [ 292 + { name: 'a.txt', size: 100, mimeType: 'text/plain', extension: 'txt' }, 293 + ]; 294 + const sorted = sortBySize(files); 295 + expect(sorted).not.toBe(files); 296 + }); 297 + }); 298 + 299 + describe('groupByType', () => { 300 + it('groups files by MIME category', () => { 301 + const files: FileInfo[] = [ 302 + { name: 'a.png', size: 100, mimeType: 'image/png', extension: 'png' }, 303 + { name: 'b.jpg', size: 200, mimeType: 'image/jpeg', extension: 'jpg' }, 304 + { name: 'c.txt', size: 300, mimeType: 'text/plain', extension: 'txt' }, 305 + { name: 'd.csv', size: 400, mimeType: 'text/csv', extension: 'csv' }, 306 + { name: 'e.pdf', size: 500, mimeType: 'application/pdf', extension: 'pdf' }, 307 + ]; 308 + const groups = groupByType(files); 309 + expect(groups.get('image')).toHaveLength(2); 310 + expect(groups.get('text')).toHaveLength(2); 311 + expect(groups.get('application')).toHaveLength(1); 312 + }); 313 + 314 + it('returns empty map for no files', () => { 315 + expect(groupByType([]).size).toBe(0); 316 + }); 317 + }); 318 + });
+311
tests/presenter-mode.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + createPresenterState, 4 + startPresentation, 5 + stopPresentation, 6 + nextSlide, 7 + prevSlide, 8 + jumpToSlide, 9 + tickTimer, 10 + toggleTimer, 11 + resetTimer, 12 + toggleNotes, 13 + togglePreview, 14 + setNotes, 15 + currentNotes, 16 + formatTime, 17 + timeRemaining, 18 + isOverTime, 19 + paceDelta, 20 + progressPercent, 21 + } from '../src/slides/presenter-mode'; 22 + 23 + describe('presenter-mode', () => { 24 + describe('createPresenterState', () => { 25 + it('creates initial state with correct defaults', () => { 26 + const state = createPresenterState(10); 27 + expect(state.active).toBe(false); 28 + expect(state.currentSlide).toBe(0); 29 + expect(state.totalSlides).toBe(10); 30 + expect(state.elapsedSeconds).toBe(0); 31 + expect(state.targetDuration).toBe(0); 32 + expect(state.timerRunning).toBe(false); 33 + expect(state.showNotes).toBe(true); 34 + expect(state.showPreview).toBe(true); 35 + expect(state.notes.size).toBe(0); 36 + }); 37 + 38 + it('accepts a target duration', () => { 39 + const state = createPresenterState(5, 300); 40 + expect(state.targetDuration).toBe(300); 41 + }); 42 + }); 43 + 44 + describe('startPresentation', () => { 45 + it('activates and resets to slide 0 with timer running', () => { 46 + let state = createPresenterState(10); 47 + state = { ...state, currentSlide: 5, elapsedSeconds: 100 }; 48 + const started = startPresentation(state); 49 + expect(started.active).toBe(true); 50 + expect(started.currentSlide).toBe(0); 51 + expect(started.elapsedSeconds).toBe(0); 52 + expect(started.timerRunning).toBe(true); 53 + }); 54 + }); 55 + 56 + describe('stopPresentation', () => { 57 + it('deactivates and stops timer', () => { 58 + let state = createPresenterState(10); 59 + state = startPresentation(state); 60 + const stopped = stopPresentation(state); 61 + expect(stopped.active).toBe(false); 62 + expect(stopped.timerRunning).toBe(false); 63 + }); 64 + }); 65 + 66 + describe('nextSlide', () => { 67 + it('advances to next slide', () => { 68 + const state = createPresenterState(10); 69 + const next = nextSlide(state); 70 + expect(next.currentSlide).toBe(1); 71 + }); 72 + 73 + it('does not go past the last slide', () => { 74 + let state = createPresenterState(3); 75 + state = { ...state, currentSlide: 2 }; 76 + const next = nextSlide(state); 77 + expect(next.currentSlide).toBe(2); 78 + expect(next).toBe(state); // same reference 79 + }); 80 + }); 81 + 82 + describe('prevSlide', () => { 83 + it('goes to previous slide', () => { 84 + let state = createPresenterState(10); 85 + state = { ...state, currentSlide: 5 }; 86 + const prev = prevSlide(state); 87 + expect(prev.currentSlide).toBe(4); 88 + }); 89 + 90 + it('does not go below 0', () => { 91 + const state = createPresenterState(10); 92 + const prev = prevSlide(state); 93 + expect(prev.currentSlide).toBe(0); 94 + expect(prev).toBe(state); 95 + }); 96 + }); 97 + 98 + describe('jumpToSlide', () => { 99 + it('jumps to a specific slide', () => { 100 + const state = createPresenterState(10); 101 + const jumped = jumpToSlide(state, 7); 102 + expect(jumped.currentSlide).toBe(7); 103 + }); 104 + 105 + it('clamps to valid range (low)', () => { 106 + const state = createPresenterState(10); 107 + expect(jumpToSlide(state, -5).currentSlide).toBe(0); 108 + }); 109 + 110 + it('clamps to valid range (high)', () => { 111 + const state = createPresenterState(10); 112 + expect(jumpToSlide(state, 100).currentSlide).toBe(9); 113 + }); 114 + }); 115 + 116 + describe('tickTimer', () => { 117 + it('increments elapsed seconds when running', () => { 118 + let state = createPresenterState(10); 119 + state = { ...state, timerRunning: true }; 120 + const ticked = tickTimer(state); 121 + expect(ticked.elapsedSeconds).toBe(1); 122 + }); 123 + 124 + it('does nothing when timer is not running', () => { 125 + const state = createPresenterState(10); 126 + const ticked = tickTimer(state); 127 + expect(ticked).toBe(state); 128 + }); 129 + }); 130 + 131 + describe('toggleTimer', () => { 132 + it('toggles timer on and off', () => { 133 + const state = createPresenterState(10); 134 + expect(state.timerRunning).toBe(false); 135 + const toggled = toggleTimer(state); 136 + expect(toggled.timerRunning).toBe(true); 137 + const again = toggleTimer(toggled); 138 + expect(again.timerRunning).toBe(false); 139 + }); 140 + }); 141 + 142 + describe('resetTimer', () => { 143 + it('resets elapsed seconds to 0', () => { 144 + let state = createPresenterState(10); 145 + state = { ...state, elapsedSeconds: 120 }; 146 + const reset = resetTimer(state); 147 + expect(reset.elapsedSeconds).toBe(0); 148 + }); 149 + }); 150 + 151 + describe('toggleNotes', () => { 152 + it('toggles notes panel visibility', () => { 153 + const state = createPresenterState(10); 154 + expect(state.showNotes).toBe(true); 155 + expect(toggleNotes(state).showNotes).toBe(false); 156 + }); 157 + }); 158 + 159 + describe('togglePreview', () => { 160 + it('toggles preview visibility', () => { 161 + const state = createPresenterState(10); 162 + expect(state.showPreview).toBe(true); 163 + expect(togglePreview(state).showPreview).toBe(false); 164 + }); 165 + }); 166 + 167 + describe('setNotes / currentNotes', () => { 168 + it('sets and retrieves notes for a slide', () => { 169 + let state = createPresenterState(10); 170 + state = setNotes(state, 3, 'Remember to pause'); 171 + state = { ...state, currentSlide: 3 }; 172 + expect(currentNotes(state)).toBe('Remember to pause'); 173 + }); 174 + 175 + it('returns empty string for slides without notes', () => { 176 + const state = createPresenterState(10); 177 + expect(currentNotes(state)).toBe(''); 178 + }); 179 + 180 + it('removes notes when set to empty string', () => { 181 + let state = createPresenterState(10); 182 + state = setNotes(state, 0, 'Note'); 183 + state = setNotes(state, 0, ''); 184 + expect(state.notes.has(0)).toBe(false); 185 + }); 186 + 187 + it('supports multiple slides with notes', () => { 188 + let state = createPresenterState(10); 189 + state = setNotes(state, 0, 'Intro'); 190 + state = setNotes(state, 5, 'Main point'); 191 + state = setNotes(state, 9, 'Conclusion'); 192 + expect(state.notes.size).toBe(3); 193 + }); 194 + }); 195 + 196 + describe('formatTime', () => { 197 + it('formats 0 seconds', () => { 198 + expect(formatTime(0)).toBe('00:00'); 199 + }); 200 + 201 + it('formats seconds only', () => { 202 + expect(formatTime(45)).toBe('00:45'); 203 + }); 204 + 205 + it('formats minutes and seconds', () => { 206 + expect(formatTime(125)).toBe('02:05'); 207 + }); 208 + 209 + it('formats large values', () => { 210 + expect(formatTime(3661)).toBe('61:01'); 211 + }); 212 + }); 213 + 214 + describe('timeRemaining', () => { 215 + it('returns 0 when no target duration', () => { 216 + const state = createPresenterState(10, 0); 217 + expect(timeRemaining(state)).toBe(0); 218 + }); 219 + 220 + it('calculates remaining time', () => { 221 + let state = createPresenterState(10, 300); 222 + state = { ...state, elapsedSeconds: 120 }; 223 + expect(timeRemaining(state)).toBe(180); 224 + }); 225 + 226 + it('never returns negative', () => { 227 + let state = createPresenterState(10, 300); 228 + state = { ...state, elapsedSeconds: 500 }; 229 + expect(timeRemaining(state)).toBe(0); 230 + }); 231 + }); 232 + 233 + describe('isOverTime', () => { 234 + it('returns false when no target', () => { 235 + const state = createPresenterState(10, 0); 236 + expect(isOverTime(state)).toBe(false); 237 + }); 238 + 239 + it('returns false when under time', () => { 240 + let state = createPresenterState(10, 300); 241 + state = { ...state, elapsedSeconds: 200 }; 242 + expect(isOverTime(state)).toBe(false); 243 + }); 244 + 245 + it('returns false when exactly at target', () => { 246 + let state = createPresenterState(10, 300); 247 + state = { ...state, elapsedSeconds: 300 }; 248 + expect(isOverTime(state)).toBe(false); 249 + }); 250 + 251 + it('returns true when over time', () => { 252 + let state = createPresenterState(10, 300); 253 + state = { ...state, elapsedSeconds: 301 }; 254 + expect(isOverTime(state)).toBe(true); 255 + }); 256 + }); 257 + 258 + describe('paceDelta', () => { 259 + it('returns 0 when no target duration', () => { 260 + const state = createPresenterState(10, 0); 261 + expect(paceDelta(state)).toBe(0); 262 + }); 263 + 264 + it('returns 0 when only 1 slide', () => { 265 + const state = createPresenterState(1, 300); 266 + expect(paceDelta(state)).toBe(0); 267 + }); 268 + 269 + it('returns positive when ahead of pace', () => { 270 + let state = createPresenterState(10, 100); 271 + // At 10 seconds, expected progress = 10/100 = 0.1 272 + // At slide 5, actual progress = 5/9 ≈ 0.556 273 + // Delta = 0.556 - 0.1 = positive (ahead) 274 + state = { ...state, currentSlide: 5, elapsedSeconds: 10 }; 275 + expect(paceDelta(state)).toBeGreaterThan(0); 276 + }); 277 + 278 + it('returns negative when behind pace', () => { 279 + let state = createPresenterState(10, 100); 280 + // At 90 seconds, expected = 0.9 281 + // At slide 1, actual = 1/9 ≈ 0.111 282 + // Delta = 0.111 - 0.9 = negative (behind) 283 + state = { ...state, currentSlide: 1, elapsedSeconds: 90 }; 284 + expect(paceDelta(state)).toBeLessThan(0); 285 + }); 286 + }); 287 + 288 + describe('progressPercent', () => { 289 + it('returns 100 for single-slide deck', () => { 290 + const state = createPresenterState(1); 291 + expect(progressPercent(state)).toBe(100); 292 + }); 293 + 294 + it('returns 0 at slide 0', () => { 295 + const state = createPresenterState(10); 296 + expect(progressPercent(state)).toBe(0); 297 + }); 298 + 299 + it('returns 50 at midpoint', () => { 300 + let state = createPresenterState(11); // 0-10, midpoint is 5 301 + state = { ...state, currentSlide: 5 }; 302 + expect(progressPercent(state)).toBe(50); 303 + }); 304 + 305 + it('returns 100 at last slide', () => { 306 + let state = createPresenterState(10); 307 + state = { ...state, currentSlide: 9 }; 308 + expect(progressPercent(state)).toBe(100); 309 + }); 310 + }); 311 + });