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

Configure Feed

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

Merge pull request 'feat: blob storage, image cells, AI formula assistant (#80, #82, #101)' (#140) from feat/blob-storage-image-cells-ai into main

scott 4da707e6 ac6434af

+1156
+204
src/lib/blob-storage.ts
··· 1 + /** 2 + * Encrypted Blob Storage — manage encrypted file uploads. 3 + * 4 + * Pure logic module: blob metadata, chunking, quota tracking. 5 + * Actual encryption (AES-256-GCM) and network upload handled by crypto/provider layers. 6 + */ 7 + 8 + export interface BlobMetadata { 9 + id: string; 10 + fileName: string; 11 + mimeType: string; 12 + /** Original file size in bytes */ 13 + size: number; 14 + /** Encrypted size (slightly larger due to IV + tag) */ 15 + encryptedSize: number; 16 + /** Number of chunks */ 17 + chunks: number; 18 + /** Upload timestamp */ 19 + uploadedAt: number; 20 + /** Document this blob belongs to */ 21 + documentId: string; 22 + /** SHA-256 hash of original content for dedup */ 23 + contentHash: string; 24 + } 25 + 26 + export interface BlobStorageState { 27 + blobs: Map<string, BlobMetadata>; 28 + /** Total encrypted bytes used */ 29 + totalSize: number; 30 + /** Quota in bytes (0 = unlimited) */ 31 + quota: number; 32 + } 33 + 34 + export const CHUNK_SIZE = 256 * 1024; // 256KB chunks 35 + export const ENCRYPTION_OVERHEAD = 28; // 12 byte IV + 16 byte auth tag 36 + 37 + let _blobCounter = 0; 38 + 39 + /** 40 + * Create initial blob storage state. 41 + */ 42 + export function createBlobStorage(quota = 0): BlobStorageState { 43 + return { blobs: new Map(), totalSize: 0, quota }; 44 + } 45 + 46 + /** 47 + * Calculate the number of chunks for a file. 48 + */ 49 + export function calculateChunks(size: number): number { 50 + return Math.max(1, Math.ceil(size / CHUNK_SIZE)); 51 + } 52 + 53 + /** 54 + * Calculate encrypted size (each chunk gets IV + auth tag overhead). 55 + */ 56 + export function calculateEncryptedSize(size: number): number { 57 + const chunks = calculateChunks(size); 58 + return size + chunks * ENCRYPTION_OVERHEAD; 59 + } 60 + 61 + /** 62 + * Register a new blob upload. 63 + */ 64 + export function registerBlob( 65 + state: BlobStorageState, 66 + fileName: string, 67 + mimeType: string, 68 + size: number, 69 + documentId: string, 70 + contentHash: string, 71 + ): { state: BlobStorageState; blob: BlobMetadata } | { error: string } { 72 + const encryptedSize = calculateEncryptedSize(size); 73 + 74 + if (state.quota > 0 && state.totalSize + encryptedSize > state.quota) { 75 + return { error: 'Storage quota exceeded' }; 76 + } 77 + 78 + const blob: BlobMetadata = { 79 + id: `blob-${Date.now()}-${++_blobCounter}`, 80 + fileName, 81 + mimeType, 82 + size, 83 + encryptedSize, 84 + chunks: calculateChunks(size), 85 + uploadedAt: Date.now(), 86 + documentId, 87 + contentHash, 88 + }; 89 + 90 + const blobs = new Map(state.blobs); 91 + blobs.set(blob.id, blob); 92 + 93 + return { 94 + state: { ...state, blobs, totalSize: state.totalSize + encryptedSize }, 95 + blob, 96 + }; 97 + } 98 + 99 + /** 100 + * Remove a blob. 101 + */ 102 + export function removeBlob( 103 + state: BlobStorageState, 104 + blobId: string, 105 + ): BlobStorageState { 106 + const blob = state.blobs.get(blobId); 107 + if (!blob) return state; 108 + 109 + const blobs = new Map(state.blobs); 110 + blobs.delete(blobId); 111 + 112 + return { ...state, blobs, totalSize: state.totalSize - blob.encryptedSize }; 113 + } 114 + 115 + /** 116 + * Find blob by content hash (for deduplication). 117 + */ 118 + export function findByHash( 119 + state: BlobStorageState, 120 + contentHash: string, 121 + ): BlobMetadata | null { 122 + for (const blob of state.blobs.values()) { 123 + if (blob.contentHash === contentHash) return blob; 124 + } 125 + return null; 126 + } 127 + 128 + /** 129 + * Get all blobs for a document. 130 + */ 131 + export function blobsForDocument( 132 + state: BlobStorageState, 133 + documentId: string, 134 + ): BlobMetadata[] { 135 + return [...state.blobs.values()].filter(b => b.documentId === documentId); 136 + } 137 + 138 + /** 139 + * Remove all blobs for a document. 140 + */ 141 + export function removeBlobsForDocument( 142 + state: BlobStorageState, 143 + documentId: string, 144 + ): BlobStorageState { 145 + let result = state; 146 + for (const blob of state.blobs.values()) { 147 + if (blob.documentId === documentId) { 148 + result = removeBlob(result, blob.id); 149 + } 150 + } 151 + return result; 152 + } 153 + 154 + /** 155 + * Check if quota allows a file of given size. 156 + */ 157 + export function canUpload(state: BlobStorageState, size: number): boolean { 158 + if (state.quota === 0) return true; 159 + return state.totalSize + calculateEncryptedSize(size) <= state.quota; 160 + } 161 + 162 + /** 163 + * Get storage usage as a percentage (0-100). 164 + */ 165 + export function usagePercent(state: BlobStorageState): number { 166 + if (state.quota === 0) return 0; 167 + return Math.min(100, (state.totalSize / state.quota) * 100); 168 + } 169 + 170 + /** 171 + * Format file size for display. 172 + */ 173 + export function formatSize(bytes: number): string { 174 + if (bytes < 1024) return `${bytes} B`; 175 + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; 176 + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; 177 + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; 178 + } 179 + 180 + /** 181 + * Get allowed MIME types for images. 182 + */ 183 + export function imageTypes(): string[] { 184 + return ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml']; 185 + } 186 + 187 + /** 188 + * Check if a MIME type is an image. 189 + */ 190 + export function isImage(mimeType: string): boolean { 191 + return imageTypes().includes(mimeType); 192 + } 193 + 194 + /** 195 + * Count blobs by MIME type category. 196 + */ 197 + export function countByType(state: BlobStorageState): Record<string, number> { 198 + const counts: Record<string, number> = {}; 199 + for (const blob of state.blobs.values()) { 200 + const category = blob.mimeType.split('/')[0]; 201 + counts[category] = (counts[category] || 0) + 1; 202 + } 203 + return counts; 204 + }
+217
src/sheets/ai-formula.ts
··· 1 + /** 2 + * AI Formula Assistant — natural language to formula conversion. 3 + * 4 + * Pure logic module: prompt building, formula validation, suggestion management. 5 + * Actual AI inference (via Aperture) handled by the network layer. 6 + */ 7 + 8 + export interface FormulaContext { 9 + /** Column headers for reference */ 10 + headers: string[]; 11 + /** Sample values from first few rows */ 12 + sampleData: string[][]; 13 + /** Currently selected cell ID */ 14 + selectedCell: string; 15 + /** Existing formula in cell (if any) */ 16 + currentFormula: string; 17 + } 18 + 19 + export interface FormulaSuggestion { 20 + id: string; 21 + formula: string; 22 + explanation: string; 23 + confidence: number; 24 + } 25 + 26 + export interface AIFormulaState { 27 + query: string; 28 + suggestions: FormulaSuggestion[]; 29 + selectedIndex: number; 30 + loading: boolean; 31 + error: string | null; 32 + /** Recent queries for history */ 33 + history: string[]; 34 + maxHistory: number; 35 + } 36 + 37 + let _suggestionCounter = 0; 38 + 39 + /** 40 + * Create initial AI formula state. 41 + */ 42 + export function createAIFormulaState(maxHistory = 20): AIFormulaState { 43 + return { 44 + query: '', 45 + suggestions: [], 46 + selectedIndex: -1, 47 + loading: false, 48 + error: null, 49 + history: [], 50 + maxHistory, 51 + }; 52 + } 53 + 54 + /** 55 + * Set the natural language query. 56 + */ 57 + export function setQuery(state: AIFormulaState, query: string): AIFormulaState { 58 + return { ...state, query, error: null }; 59 + } 60 + 61 + /** 62 + * Build the prompt for the AI model from context. 63 + */ 64 + export function buildPrompt(query: string, context: FormulaContext): string { 65 + const lines: string[] = []; 66 + lines.push('Convert this natural language request to a spreadsheet formula.'); 67 + lines.push(''); 68 + lines.push(`Request: ${query}`); 69 + lines.push(''); 70 + 71 + if (context.headers.length > 0) { 72 + lines.push(`Columns: ${context.headers.join(', ')}`); 73 + } 74 + 75 + if (context.sampleData.length > 0) { 76 + lines.push('Sample data:'); 77 + for (const row of context.sampleData.slice(0, 3)) { 78 + lines.push(` ${row.join(' | ')}`); 79 + } 80 + } 81 + 82 + if (context.selectedCell) { 83 + lines.push(`Current cell: ${context.selectedCell}`); 84 + } 85 + 86 + if (context.currentFormula) { 87 + lines.push(`Existing formula: ${context.currentFormula}`); 88 + } 89 + 90 + lines.push(''); 91 + lines.push('Respond with ONLY the formula (starting with =). No explanation.'); 92 + 93 + return lines.join('\n'); 94 + } 95 + 96 + /** 97 + * Parse an AI response into formula suggestions. 98 + */ 99 + export function parseResponse(response: string): FormulaSuggestion[] { 100 + const suggestions: FormulaSuggestion[] = []; 101 + const lines = response.split('\n').map(l => l.trim()).filter(Boolean); 102 + 103 + for (const line of lines) { 104 + // Extract formulas (lines starting with =) 105 + const formulaMatch = line.match(/^(=[^]*?)(?:\s*[-–—]\s*(.+))?$/); 106 + if (formulaMatch) { 107 + suggestions.push({ 108 + id: `sug-${Date.now()}-${++_suggestionCounter}`, 109 + formula: formulaMatch[1].trim(), 110 + explanation: formulaMatch[2]?.trim() ?? '', 111 + confidence: 0.8, 112 + }); 113 + } 114 + } 115 + 116 + return suggestions; 117 + } 118 + 119 + /** 120 + * Set suggestions from AI response. 121 + */ 122 + export function setSuggestions( 123 + state: AIFormulaState, 124 + suggestions: FormulaSuggestion[], 125 + ): AIFormulaState { 126 + return { 127 + ...state, 128 + suggestions, 129 + selectedIndex: suggestions.length > 0 ? 0 : -1, 130 + loading: false, 131 + error: null, 132 + }; 133 + } 134 + 135 + /** 136 + * Set loading state. 137 + */ 138 + export function setLoading(state: AIFormulaState, loading: boolean): AIFormulaState { 139 + return { ...state, loading }; 140 + } 141 + 142 + /** 143 + * Set error state. 144 + */ 145 + export function setError(state: AIFormulaState, error: string): AIFormulaState { 146 + return { ...state, error, loading: false, suggestions: [] }; 147 + } 148 + 149 + /** 150 + * Select next suggestion. 151 + */ 152 + export function nextSuggestion(state: AIFormulaState): AIFormulaState { 153 + if (state.suggestions.length === 0) return state; 154 + const next = (state.selectedIndex + 1) % state.suggestions.length; 155 + return { ...state, selectedIndex: next }; 156 + } 157 + 158 + /** 159 + * Select previous suggestion. 160 + */ 161 + export function prevSuggestion(state: AIFormulaState): AIFormulaState { 162 + if (state.suggestions.length === 0) return state; 163 + const prev = state.selectedIndex <= 0 ? state.suggestions.length - 1 : state.selectedIndex - 1; 164 + return { ...state, selectedIndex: prev }; 165 + } 166 + 167 + /** 168 + * Get the currently selected suggestion. 169 + */ 170 + export function selectedSuggestion(state: AIFormulaState): FormulaSuggestion | null { 171 + if (state.selectedIndex < 0 || state.selectedIndex >= state.suggestions.length) return null; 172 + return state.suggestions[state.selectedIndex]; 173 + } 174 + 175 + /** 176 + * Add query to history. 177 + */ 178 + export function addToHistory(state: AIFormulaState, query: string): AIFormulaState { 179 + if (!query.trim()) return state; 180 + const history = [query, ...state.history.filter(h => h !== query)]; 181 + if (history.length > state.maxHistory) { 182 + history.length = state.maxHistory; 183 + } 184 + return { ...state, history }; 185 + } 186 + 187 + /** 188 + * Validate a formula string (basic syntax check). 189 + */ 190 + export function isValidFormula(formula: string): boolean { 191 + if (!formula.startsWith('=')) return false; 192 + if (formula.length < 2) return false; 193 + 194 + // Check balanced parentheses 195 + let depth = 0; 196 + for (const ch of formula) { 197 + if (ch === '(') depth++; 198 + if (ch === ')') depth--; 199 + if (depth < 0) return false; 200 + } 201 + return depth === 0; 202 + } 203 + 204 + /** 205 + * Extract function names from a formula. 206 + */ 207 + export function extractFunctions(formula: string): string[] { 208 + const matches = formula.match(/[A-Z_]+(?=\()/g); 209 + return matches ? [...new Set(matches)] : []; 210 + } 211 + 212 + /** 213 + * Clear suggestions and reset. 214 + */ 215 + export function clearSuggestions(state: AIFormulaState): AIFormulaState { 216 + return { ...state, suggestions: [], selectedIndex: -1, error: null }; 217 + }
+186
src/sheets/image-cells.ts
··· 1 + /** 2 + * Image Cells — display images inside sheet cells. 3 + * 4 + * Pure logic module: image reference management, sizing, layout. 5 + * DOM rendering and blob fetching handled by the UI layer. 6 + */ 7 + 8 + export type ImageFit = 'contain' | 'cover' | 'fill' | 'none'; 9 + 10 + export interface CellImage { 11 + /** Blob storage ID */ 12 + blobId: string; 13 + /** Original image dimensions */ 14 + naturalWidth: number; 15 + naturalHeight: number; 16 + /** Display fit mode */ 17 + fit: ImageFit; 18 + /** Alt text for accessibility */ 19 + alt: string; 20 + } 21 + 22 + export interface ImageCellState { 23 + /** Map of cell ID (e.g., "B3") to image */ 24 + images: Map<string, CellImage>; 25 + } 26 + 27 + /** 28 + * Create initial image cell state. 29 + */ 30 + export function createImageCellState(): ImageCellState { 31 + return { images: new Map() }; 32 + } 33 + 34 + /** 35 + * Set an image in a cell. 36 + */ 37 + export function setCellImage( 38 + state: ImageCellState, 39 + cellId: string, 40 + blobId: string, 41 + naturalWidth: number, 42 + naturalHeight: number, 43 + options: { fit?: ImageFit; alt?: string } = {}, 44 + ): ImageCellState { 45 + const images = new Map(state.images); 46 + images.set(cellId, { 47 + blobId, 48 + naturalWidth, 49 + naturalHeight, 50 + fit: options.fit ?? 'contain', 51 + alt: options.alt ?? '', 52 + }); 53 + return { images }; 54 + } 55 + 56 + /** 57 + * Remove an image from a cell. 58 + */ 59 + export function removeCellImage( 60 + state: ImageCellState, 61 + cellId: string, 62 + ): ImageCellState { 63 + if (!state.images.has(cellId)) return state; 64 + const images = new Map(state.images); 65 + images.delete(cellId); 66 + return { images }; 67 + } 68 + 69 + /** 70 + * Get the image for a cell, if any. 71 + */ 72 + export function getCellImage( 73 + state: ImageCellState, 74 + cellId: string, 75 + ): CellImage | null { 76 + return state.images.get(cellId) ?? null; 77 + } 78 + 79 + /** 80 + * Check if a cell has an image. 81 + */ 82 + export function hasImage(state: ImageCellState, cellId: string): boolean { 83 + return state.images.has(cellId); 84 + } 85 + 86 + /** 87 + * Update the fit mode for a cell image. 88 + */ 89 + export function updateImageFit( 90 + state: ImageCellState, 91 + cellId: string, 92 + fit: ImageFit, 93 + ): ImageCellState { 94 + const existing = state.images.get(cellId); 95 + if (!existing) return state; 96 + const images = new Map(state.images); 97 + images.set(cellId, { ...existing, fit }); 98 + return { images }; 99 + } 100 + 101 + /** 102 + * Update the alt text for a cell image. 103 + */ 104 + export function updateImageAlt( 105 + state: ImageCellState, 106 + cellId: string, 107 + alt: string, 108 + ): ImageCellState { 109 + const existing = state.images.get(cellId); 110 + if (!existing) return state; 111 + const images = new Map(state.images); 112 + images.set(cellId, { ...existing, alt }); 113 + return { images }; 114 + } 115 + 116 + /** 117 + * Calculate display dimensions that fit within a cell while preserving aspect ratio. 118 + */ 119 + export function fitDimensions( 120 + naturalWidth: number, 121 + naturalHeight: number, 122 + cellWidth: number, 123 + cellHeight: number, 124 + fit: ImageFit, 125 + ): { width: number; height: number } { 126 + if (fit === 'fill') return { width: cellWidth, height: cellHeight }; 127 + if (fit === 'none') return { width: naturalWidth, height: naturalHeight }; 128 + 129 + const aspectRatio = naturalWidth / naturalHeight; 130 + const cellRatio = cellWidth / cellHeight; 131 + 132 + if (fit === 'contain') { 133 + if (aspectRatio > cellRatio) { 134 + return { width: cellWidth, height: cellWidth / aspectRatio }; 135 + } 136 + return { width: cellHeight * aspectRatio, height: cellHeight }; 137 + } 138 + 139 + // cover 140 + if (aspectRatio > cellRatio) { 141 + return { width: cellHeight * aspectRatio, height: cellHeight }; 142 + } 143 + return { width: cellWidth, height: cellWidth / aspectRatio }; 144 + } 145 + 146 + /** 147 + * Get all cell IDs that have images. 148 + */ 149 + export function imageCellIds(state: ImageCellState): string[] { 150 + return [...state.images.keys()]; 151 + } 152 + 153 + /** 154 + * Count total images. 155 + */ 156 + export function imageCount(state: ImageCellState): number { 157 + return state.images.size; 158 + } 159 + 160 + /** 161 + * Get all unique blob IDs referenced by images. 162 + */ 163 + export function referencedBlobIds(state: ImageCellState): string[] { 164 + const ids = new Set<string>(); 165 + for (const img of state.images.values()) { 166 + ids.add(img.blobId); 167 + } 168 + return [...ids]; 169 + } 170 + 171 + /** 172 + * Remove all images that reference a deleted blob. 173 + */ 174 + export function removeImagesForBlob( 175 + state: ImageCellState, 176 + blobId: string, 177 + ): ImageCellState { 178 + const images = new Map<string, CellImage>(); 179 + for (const [cellId, img] of state.images) { 180 + if (img.blobId !== blobId) { 181 + images.set(cellId, img); 182 + } 183 + } 184 + if (images.size === state.images.size) return state; 185 + return { images }; 186 + }
+198
tests/ai-formula.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + createAIFormulaState, 4 + setQuery, 5 + buildPrompt, 6 + parseResponse, 7 + setSuggestions, 8 + setLoading, 9 + setError, 10 + nextSuggestion, 11 + prevSuggestion, 12 + selectedSuggestion, 13 + addToHistory, 14 + isValidFormula, 15 + extractFunctions, 16 + clearSuggestions, 17 + type FormulaContext, 18 + } from '../src/sheets/ai-formula.js'; 19 + 20 + const context: FormulaContext = { 21 + headers: ['Name', 'Score', 'Grade'], 22 + sampleData: [['Alice', '95', 'A'], ['Bob', '82', 'B']], 23 + selectedCell: 'D2', 24 + currentFormula: '', 25 + }; 26 + 27 + describe('createAIFormulaState', () => { 28 + it('creates empty state', () => { 29 + const state = createAIFormulaState(); 30 + expect(state.query).toBe(''); 31 + expect(state.suggestions).toEqual([]); 32 + expect(state.loading).toBe(false); 33 + expect(state.history).toEqual([]); 34 + }); 35 + }); 36 + 37 + describe('setQuery', () => { 38 + it('sets query and clears error', () => { 39 + let state = createAIFormulaState(); 40 + state = setError(state, 'oops'); 41 + state = setQuery(state, 'sum of scores'); 42 + expect(state.query).toBe('sum of scores'); 43 + expect(state.error).toBeNull(); 44 + }); 45 + }); 46 + 47 + describe('buildPrompt', () => { 48 + it('includes query and context', () => { 49 + const prompt = buildPrompt('average of Score column', context); 50 + expect(prompt).toContain('average of Score column'); 51 + expect(prompt).toContain('Name, Score, Grade'); 52 + expect(prompt).toContain('D2'); 53 + expect(prompt).toContain('Alice'); 54 + }); 55 + 56 + it('includes existing formula if present', () => { 57 + const ctx = { ...context, currentFormula: '=SUM(B:B)' }; 58 + const prompt = buildPrompt('modify to exclude zeros', ctx); 59 + expect(prompt).toContain('=SUM(B:B)'); 60 + }); 61 + }); 62 + 63 + describe('parseResponse', () => { 64 + it('parses formula from response', () => { 65 + const suggestions = parseResponse('=SUM(B2:B100)'); 66 + expect(suggestions).toHaveLength(1); 67 + expect(suggestions[0].formula).toBe('=SUM(B2:B100)'); 68 + }); 69 + 70 + it('parses formula with explanation', () => { 71 + const suggestions = parseResponse('=AVERAGE(B2:B100) — Calculates the average score'); 72 + expect(suggestions).toHaveLength(1); 73 + expect(suggestions[0].formula).toBe('=AVERAGE(B2:B100)'); 74 + expect(suggestions[0].explanation).toBe('Calculates the average score'); 75 + }); 76 + 77 + it('parses multiple formulas', () => { 78 + const suggestions = parseResponse('=SUM(B2:B100)\n=SUMIF(B2:B100,">0")'); 79 + expect(suggestions).toHaveLength(2); 80 + }); 81 + 82 + it('returns empty for no formulas', () => { 83 + expect(parseResponse('No formula found')).toEqual([]); 84 + }); 85 + }); 86 + 87 + describe('setSuggestions', () => { 88 + it('sets suggestions and selects first', () => { 89 + let state = createAIFormulaState(); 90 + state = setLoading(state, true); 91 + const suggestions = parseResponse('=SUM(B:B)\n=AVERAGE(B:B)'); 92 + state = setSuggestions(state, suggestions); 93 + expect(state.suggestions).toHaveLength(2); 94 + expect(state.selectedIndex).toBe(0); 95 + expect(state.loading).toBe(false); 96 + }); 97 + }); 98 + 99 + describe('navigation', () => { 100 + it('nextSuggestion cycles', () => { 101 + let state = createAIFormulaState(); 102 + state = setSuggestions(state, parseResponse('=A\n=B\n=C')); 103 + state = nextSuggestion(state); 104 + expect(state.selectedIndex).toBe(1); 105 + state = nextSuggestion(nextSuggestion(state)); 106 + expect(state.selectedIndex).toBe(0); // wraps 107 + }); 108 + 109 + it('prevSuggestion wraps to end', () => { 110 + let state = createAIFormulaState(); 111 + state = setSuggestions(state, parseResponse('=A\n=B')); 112 + state = prevSuggestion(state); 113 + expect(state.selectedIndex).toBe(1); 114 + }); 115 + 116 + it('selectedSuggestion returns current', () => { 117 + let state = createAIFormulaState(); 118 + state = setSuggestions(state, parseResponse('=SUM(B:B)')); 119 + expect(selectedSuggestion(state)!.formula).toBe('=SUM(B:B)'); 120 + }); 121 + 122 + it('selectedSuggestion returns null for empty', () => { 123 + expect(selectedSuggestion(createAIFormulaState())).toBeNull(); 124 + }); 125 + }); 126 + 127 + describe('addToHistory', () => { 128 + it('adds query to front', () => { 129 + let state = createAIFormulaState(); 130 + state = addToHistory(state, 'sum of A'); 131 + state = addToHistory(state, 'average of B'); 132 + expect(state.history[0]).toBe('average of B'); 133 + expect(state.history[1]).toBe('sum of A'); 134 + }); 135 + 136 + it('deduplicates', () => { 137 + let state = createAIFormulaState(); 138 + state = addToHistory(state, 'sum'); 139 + state = addToHistory(state, 'avg'); 140 + state = addToHistory(state, 'sum'); 141 + expect(state.history).toEqual(['sum', 'avg']); 142 + }); 143 + 144 + it('trims to maxHistory', () => { 145 + let state = createAIFormulaState(3); 146 + state = addToHistory(state, 'a'); 147 + state = addToHistory(state, 'b'); 148 + state = addToHistory(state, 'c'); 149 + state = addToHistory(state, 'd'); 150 + expect(state.history).toHaveLength(3); 151 + }); 152 + 153 + it('ignores empty query', () => { 154 + let state = createAIFormulaState(); 155 + state = addToHistory(state, ''); 156 + state = addToHistory(state, ' '); 157 + expect(state.history).toHaveLength(0); 158 + }); 159 + }); 160 + 161 + describe('isValidFormula', () => { 162 + it('accepts valid formulas', () => { 163 + expect(isValidFormula('=SUM(A1:A10)')).toBe(true); 164 + expect(isValidFormula('=A1+B1')).toBe(true); 165 + expect(isValidFormula('=IF(A1>0,A1,0)')).toBe(true); 166 + }); 167 + 168 + it('rejects invalid formulas', () => { 169 + expect(isValidFormula('SUM(A1)')).toBe(false); // no = 170 + expect(isValidFormula('=')).toBe(false); // too short 171 + expect(isValidFormula('=SUM(A1')).toBe(false); // unbalanced parens 172 + expect(isValidFormula('=SUM)A1(')).toBe(false); // wrong paren order 173 + }); 174 + }); 175 + 176 + describe('extractFunctions', () => { 177 + it('extracts function names', () => { 178 + expect(extractFunctions('=SUM(A1:A10)+AVERAGE(B1:B10)')).toEqual(['SUM', 'AVERAGE']); 179 + }); 180 + 181 + it('deduplicates', () => { 182 + expect(extractFunctions('=SUM(A1)+SUM(B1)')).toEqual(['SUM']); 183 + }); 184 + 185 + it('returns empty for no functions', () => { 186 + expect(extractFunctions('=A1+B1')).toEqual([]); 187 + }); 188 + }); 189 + 190 + describe('clearSuggestions', () => { 191 + it('clears suggestions and error', () => { 192 + let state = createAIFormulaState(); 193 + state = setSuggestions(state, parseResponse('=SUM(A:A)')); 194 + state = clearSuggestions(state); 195 + expect(state.suggestions).toEqual([]); 196 + expect(state.selectedIndex).toBe(-1); 197 + }); 198 + });
+188
tests/blob-storage.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + createBlobStorage, 4 + calculateChunks, 5 + calculateEncryptedSize, 6 + registerBlob, 7 + removeBlob, 8 + findByHash, 9 + blobsForDocument, 10 + removeBlobsForDocument, 11 + canUpload, 12 + usagePercent, 13 + formatSize, 14 + imageTypes, 15 + isImage, 16 + countByType, 17 + CHUNK_SIZE, 18 + ENCRYPTION_OVERHEAD, 19 + } from '../src/lib/blob-storage.js'; 20 + 21 + describe('calculateChunks', () => { 22 + it('returns 1 for small files', () => { 23 + expect(calculateChunks(100)).toBe(1); 24 + expect(calculateChunks(CHUNK_SIZE)).toBe(1); 25 + }); 26 + 27 + it('returns correct count for larger files', () => { 28 + expect(calculateChunks(CHUNK_SIZE + 1)).toBe(2); 29 + expect(calculateChunks(CHUNK_SIZE * 3)).toBe(3); 30 + }); 31 + }); 32 + 33 + describe('calculateEncryptedSize', () => { 34 + it('adds overhead per chunk', () => { 35 + const size = CHUNK_SIZE; // 1 chunk 36 + expect(calculateEncryptedSize(size)).toBe(size + ENCRYPTION_OVERHEAD); 37 + }); 38 + 39 + it('scales with chunk count', () => { 40 + const size = CHUNK_SIZE * 3; // 3 chunks 41 + expect(calculateEncryptedSize(size)).toBe(size + 3 * ENCRYPTION_OVERHEAD); 42 + }); 43 + }); 44 + 45 + describe('registerBlob', () => { 46 + it('registers a blob successfully', () => { 47 + const state = createBlobStorage(); 48 + const result = registerBlob(state, 'photo.png', 'image/png', 1024, 'd1', 'hash123'); 49 + expect('blob' in result).toBe(true); 50 + if ('blob' in result) { 51 + expect(result.blob.fileName).toBe('photo.png'); 52 + expect(result.state.blobs.size).toBe(1); 53 + expect(result.state.totalSize).toBeGreaterThan(0); 54 + } 55 + }); 56 + 57 + it('rejects when quota exceeded', () => { 58 + const state = createBlobStorage(100); // 100 byte quota 59 + const result = registerBlob(state, 'big.bin', 'application/octet-stream', 1000, 'd1', 'hash'); 60 + expect('error' in result).toBe(true); 61 + }); 62 + 63 + it('generates unique IDs', () => { 64 + let state = createBlobStorage(); 65 + const r1 = registerBlob(state, 'a.png', 'image/png', 100, 'd1', 'h1'); 66 + if ('state' in r1) { 67 + const r2 = registerBlob(r1.state, 'b.png', 'image/png', 100, 'd1', 'h2'); 68 + if ('state' in r2) { 69 + expect(r1.blob.id).not.toBe(r2.blob.id); 70 + } 71 + } 72 + }); 73 + }); 74 + 75 + describe('removeBlob', () => { 76 + it('removes blob and adjusts size', () => { 77 + const state = createBlobStorage(); 78 + const r = registerBlob(state, 'a.png', 'image/png', 1024, 'd1', 'h1'); 79 + if ('state' in r) { 80 + const after = removeBlob(r.state, r.blob.id); 81 + expect(after.blobs.size).toBe(0); 82 + expect(after.totalSize).toBe(0); 83 + } 84 + }); 85 + 86 + it('returns same state for unknown ID', () => { 87 + const state = createBlobStorage(); 88 + expect(removeBlob(state, 'unknown')).toBe(state); 89 + }); 90 + }); 91 + 92 + describe('findByHash', () => { 93 + it('finds blob by content hash', () => { 94 + const state = createBlobStorage(); 95 + const r = registerBlob(state, 'a.png', 'image/png', 100, 'd1', 'abc123'); 96 + if ('state' in r) { 97 + expect(findByHash(r.state, 'abc123')).toBe(r.blob); 98 + expect(findByHash(r.state, 'xyz')).toBeNull(); 99 + } 100 + }); 101 + }); 102 + 103 + describe('blobsForDocument', () => { 104 + it('returns blobs for a document', () => { 105 + let state = createBlobStorage(); 106 + let r = registerBlob(state, 'a.png', 'image/png', 100, 'd1', 'h1'); 107 + if ('state' in r) state = r.state; 108 + r = registerBlob(state, 'b.png', 'image/png', 100, 'd2', 'h2'); 109 + if ('state' in r) state = r.state; 110 + r = registerBlob(state, 'c.png', 'image/png', 100, 'd1', 'h3'); 111 + if ('state' in r) state = r.state; 112 + expect(blobsForDocument(state, 'd1')).toHaveLength(2); 113 + }); 114 + }); 115 + 116 + describe('removeBlobsForDocument', () => { 117 + it('removes all blobs for a document', () => { 118 + let state = createBlobStorage(); 119 + let r = registerBlob(state, 'a.png', 'image/png', 100, 'd1', 'h1'); 120 + if ('state' in r) state = r.state; 121 + r = registerBlob(state, 'b.png', 'image/png', 100, 'd2', 'h2'); 122 + if ('state' in r) state = r.state; 123 + state = removeBlobsForDocument(state, 'd1'); 124 + expect(state.blobs.size).toBe(1); 125 + }); 126 + }); 127 + 128 + describe('canUpload', () => { 129 + it('allows when under quota', () => { 130 + const state = createBlobStorage(1024 * 1024); 131 + expect(canUpload(state, 100)).toBe(true); 132 + }); 133 + 134 + it('rejects when over quota', () => { 135 + const state = createBlobStorage(100); 136 + expect(canUpload(state, 1000)).toBe(false); 137 + }); 138 + 139 + it('always allows with no quota', () => { 140 + const state = createBlobStorage(0); 141 + expect(canUpload(state, 999999999)).toBe(true); 142 + }); 143 + }); 144 + 145 + describe('usagePercent', () => { 146 + it('returns 0 for no quota', () => { 147 + expect(usagePercent(createBlobStorage(0))).toBe(0); 148 + }); 149 + 150 + it('computes percentage', () => { 151 + let state = createBlobStorage(10000); 152 + const r = registerBlob(state, 'a', 'text/plain', 100, 'd1', 'h'); 153 + if ('state' in r) { 154 + expect(usagePercent(r.state)).toBeGreaterThan(0); 155 + expect(usagePercent(r.state)).toBeLessThan(100); 156 + } 157 + }); 158 + }); 159 + 160 + describe('formatSize', () => { 161 + it('formats bytes', () => expect(formatSize(500)).toBe('500 B')); 162 + it('formats KB', () => expect(formatSize(1536)).toBe('1.5 KB')); 163 + it('formats MB', () => expect(formatSize(1048576)).toBe('1.0 MB')); 164 + it('formats GB', () => expect(formatSize(1073741824)).toBe('1.0 GB')); 165 + }); 166 + 167 + describe('isImage', () => { 168 + it('detects image types', () => { 169 + expect(isImage('image/png')).toBe(true); 170 + expect(isImage('image/jpeg')).toBe(true); 171 + expect(isImage('text/plain')).toBe(false); 172 + }); 173 + }); 174 + 175 + describe('countByType', () => { 176 + it('counts by MIME category', () => { 177 + let state = createBlobStorage(); 178 + let r = registerBlob(state, 'a.png', 'image/png', 100, 'd1', 'h1'); 179 + if ('state' in r) state = r.state; 180 + r = registerBlob(state, 'b.txt', 'text/plain', 100, 'd1', 'h2'); 181 + if ('state' in r) state = r.state; 182 + r = registerBlob(state, 'c.jpg', 'image/jpeg', 100, 'd1', 'h3'); 183 + if ('state' in r) state = r.state; 184 + const counts = countByType(state); 185 + expect(counts['image']).toBe(2); 186 + expect(counts['text']).toBe(1); 187 + }); 188 + });
+163
tests/image-cells.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + createImageCellState, 4 + setCellImage, 5 + removeCellImage, 6 + getCellImage, 7 + hasImage, 8 + updateImageFit, 9 + updateImageAlt, 10 + fitDimensions, 11 + imageCellIds, 12 + imageCount, 13 + referencedBlobIds, 14 + removeImagesForBlob, 15 + } from '../src/sheets/image-cells.js'; 16 + 17 + describe('createImageCellState', () => { 18 + it('creates empty state', () => { 19 + const state = createImageCellState(); 20 + expect(state.images.size).toBe(0); 21 + }); 22 + }); 23 + 24 + describe('setCellImage / getCellImage', () => { 25 + it('sets and gets image', () => { 26 + let state = createImageCellState(); 27 + state = setCellImage(state, 'B3', 'blob-1', 800, 600); 28 + const img = getCellImage(state, 'B3'); 29 + expect(img).not.toBeNull(); 30 + expect(img!.blobId).toBe('blob-1'); 31 + expect(img!.naturalWidth).toBe(800); 32 + expect(img!.fit).toBe('contain'); 33 + }); 34 + 35 + it('accepts options', () => { 36 + let state = createImageCellState(); 37 + state = setCellImage(state, 'A1', 'blob-1', 100, 100, { fit: 'cover', alt: 'Logo' }); 38 + expect(getCellImage(state, 'A1')!.fit).toBe('cover'); 39 + expect(getCellImage(state, 'A1')!.alt).toBe('Logo'); 40 + }); 41 + 42 + it('returns null for empty cell', () => { 43 + expect(getCellImage(createImageCellState(), 'A1')).toBeNull(); 44 + }); 45 + }); 46 + 47 + describe('removeCellImage', () => { 48 + it('removes image', () => { 49 + let state = createImageCellState(); 50 + state = setCellImage(state, 'A1', 'blob-1', 100, 100); 51 + state = removeCellImage(state, 'A1'); 52 + expect(hasImage(state, 'A1')).toBe(false); 53 + }); 54 + 55 + it('returns same state for no image', () => { 56 + const state = createImageCellState(); 57 + expect(removeCellImage(state, 'A1')).toBe(state); 58 + }); 59 + }); 60 + 61 + describe('hasImage', () => { 62 + it('returns true/false', () => { 63 + let state = createImageCellState(); 64 + state = setCellImage(state, 'A1', 'blob-1', 100, 100); 65 + expect(hasImage(state, 'A1')).toBe(true); 66 + expect(hasImage(state, 'B2')).toBe(false); 67 + }); 68 + }); 69 + 70 + describe('updateImageFit / updateImageAlt', () => { 71 + it('updates fit mode', () => { 72 + let state = createImageCellState(); 73 + state = setCellImage(state, 'A1', 'blob-1', 100, 100); 74 + state = updateImageFit(state, 'A1', 'fill'); 75 + expect(getCellImage(state, 'A1')!.fit).toBe('fill'); 76 + }); 77 + 78 + it('updates alt text', () => { 79 + let state = createImageCellState(); 80 + state = setCellImage(state, 'A1', 'blob-1', 100, 100); 81 + state = updateImageAlt(state, 'A1', 'My image'); 82 + expect(getCellImage(state, 'A1')!.alt).toBe('My image'); 83 + }); 84 + 85 + it('returns same state for missing cell', () => { 86 + const state = createImageCellState(); 87 + expect(updateImageFit(state, 'A1', 'cover')).toBe(state); 88 + }); 89 + }); 90 + 91 + describe('fitDimensions', () => { 92 + it('contain: landscape in square cell', () => { 93 + const dims = fitDimensions(200, 100, 100, 100, 'contain'); 94 + expect(dims.width).toBe(100); 95 + expect(dims.height).toBe(50); 96 + }); 97 + 98 + it('contain: portrait in square cell', () => { 99 + const dims = fitDimensions(100, 200, 100, 100, 'contain'); 100 + expect(dims.width).toBe(50); 101 + expect(dims.height).toBe(100); 102 + }); 103 + 104 + it('cover: landscape in square cell', () => { 105 + const dims = fitDimensions(200, 100, 100, 100, 'cover'); 106 + expect(dims.width).toBe(200); 107 + expect(dims.height).toBe(100); 108 + }); 109 + 110 + it('fill: stretches to cell', () => { 111 + const dims = fitDimensions(200, 100, 50, 50, 'fill'); 112 + expect(dims.width).toBe(50); 113 + expect(dims.height).toBe(50); 114 + }); 115 + 116 + it('none: uses natural size', () => { 117 + const dims = fitDimensions(200, 100, 50, 50, 'none'); 118 + expect(dims.width).toBe(200); 119 + expect(dims.height).toBe(100); 120 + }); 121 + }); 122 + 123 + describe('imageCellIds / imageCount', () => { 124 + it('lists cells and counts', () => { 125 + let state = createImageCellState(); 126 + state = setCellImage(state, 'A1', 'b1', 100, 100); 127 + state = setCellImage(state, 'C3', 'b2', 100, 100); 128 + expect(imageCellIds(state)).toContain('A1'); 129 + expect(imageCellIds(state)).toContain('C3'); 130 + expect(imageCount(state)).toBe(2); 131 + }); 132 + }); 133 + 134 + describe('referencedBlobIds', () => { 135 + it('returns unique blob IDs', () => { 136 + let state = createImageCellState(); 137 + state = setCellImage(state, 'A1', 'b1', 100, 100); 138 + state = setCellImage(state, 'B2', 'b1', 100, 100); 139 + state = setCellImage(state, 'C3', 'b2', 100, 100); 140 + const ids = referencedBlobIds(state); 141 + expect(ids).toHaveLength(2); 142 + expect(ids).toContain('b1'); 143 + expect(ids).toContain('b2'); 144 + }); 145 + }); 146 + 147 + describe('removeImagesForBlob', () => { 148 + it('removes all images referencing a blob', () => { 149 + let state = createImageCellState(); 150 + state = setCellImage(state, 'A1', 'b1', 100, 100); 151 + state = setCellImage(state, 'B2', 'b1', 100, 100); 152 + state = setCellImage(state, 'C3', 'b2', 100, 100); 153 + state = removeImagesForBlob(state, 'b1'); 154 + expect(imageCount(state)).toBe(1); 155 + expect(hasImage(state, 'C3')).toBe(true); 156 + }); 157 + 158 + it('returns same state if blob not referenced', () => { 159 + let state = createImageCellState(); 160 + state = setCellImage(state, 'A1', 'b1', 100, 100); 161 + expect(removeImagesForBlob(state, 'b99')).toBe(state); 162 + }); 163 + });