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: form builder, sheet embeds, cross-doc links (#77, #70, #43)' (#138) from feat/forms-embeds-integration into main

scott 93425b54 cd924dba

+1172
+274
src/forms/form-builder.ts
··· 1 + /** 2 + * Form Builder — question types and form construction. 3 + * 4 + * Pure logic module: form schema, question CRUD, validation, submission. 5 + * DOM rendering handled by the forms UI layer. 6 + */ 7 + 8 + export type QuestionType = 9 + | 'short_text' 10 + | 'long_text' 11 + | 'number' 12 + | 'email' 13 + | 'url' 14 + | 'single_choice' 15 + | 'multiple_choice' 16 + | 'dropdown' 17 + | 'date' 18 + | 'rating' 19 + | 'scale' 20 + | 'file_upload'; 21 + 22 + export interface QuestionOption { 23 + id: string; 24 + label: string; 25 + } 26 + 27 + export interface Question { 28 + id: string; 29 + type: QuestionType; 30 + label: string; 31 + description: string; 32 + required: boolean; 33 + options: QuestionOption[]; 34 + /** For scale/rating: min and max */ 35 + scaleMin?: number; 36 + scaleMax?: number; 37 + /** Validation pattern (regex string) */ 38 + validationPattern?: string; 39 + } 40 + 41 + export interface FormSchema { 42 + id: string; 43 + title: string; 44 + description: string; 45 + questions: Question[]; 46 + /** Target sheet ID for responses */ 47 + targetSheetId: string | null; 48 + createdAt: number; 49 + updatedAt: number; 50 + } 51 + 52 + export interface FormResponse { 53 + formId: string; 54 + answers: Map<string, unknown>; 55 + submittedAt: number; 56 + } 57 + 58 + let _formCounter = 0; 59 + let _questionCounter = 0; 60 + let _optionCounter = 0; 61 + 62 + /** 63 + * Create a new empty form. 64 + */ 65 + export function createForm(title: string, description = ''): FormSchema { 66 + const now = Date.now(); 67 + return { 68 + id: `form-${now}-${++_formCounter}`, 69 + title, 70 + description, 71 + questions: [], 72 + targetSheetId: null, 73 + createdAt: now, 74 + updatedAt: now, 75 + }; 76 + } 77 + 78 + /** 79 + * Add a question to the form. 80 + */ 81 + export function addQuestion( 82 + form: FormSchema, 83 + type: QuestionType, 84 + label: string, 85 + options: Partial<Pick<Question, 'description' | 'required' | 'options' | 'scaleMin' | 'scaleMax' | 'validationPattern'>> = {}, 86 + ): FormSchema { 87 + const question: Question = { 88 + id: `q-${Date.now()}-${++_questionCounter}`, 89 + type, 90 + label, 91 + description: options.description ?? '', 92 + required: options.required ?? false, 93 + options: options.options ?? [], 94 + scaleMin: options.scaleMin, 95 + scaleMax: options.scaleMax, 96 + validationPattern: options.validationPattern, 97 + }; 98 + return { 99 + ...form, 100 + questions: [...form.questions, question], 101 + updatedAt: Date.now(), 102 + }; 103 + } 104 + 105 + /** 106 + * Remove a question from the form. 107 + */ 108 + export function removeQuestion(form: FormSchema, questionId: string): FormSchema { 109 + return { 110 + ...form, 111 + questions: form.questions.filter(q => q.id !== questionId), 112 + updatedAt: Date.now(), 113 + }; 114 + } 115 + 116 + /** 117 + * Update a question's properties. 118 + */ 119 + export function updateQuestion( 120 + form: FormSchema, 121 + questionId: string, 122 + updates: Partial<Omit<Question, 'id'>>, 123 + ): FormSchema { 124 + return { 125 + ...form, 126 + questions: form.questions.map(q => 127 + q.id === questionId ? { ...q, ...updates } : q, 128 + ), 129 + updatedAt: Date.now(), 130 + }; 131 + } 132 + 133 + /** 134 + * Reorder a question (move to new index). 135 + */ 136 + export function moveQuestion( 137 + form: FormSchema, 138 + questionId: string, 139 + toIndex: number, 140 + ): FormSchema { 141 + const fromIndex = form.questions.findIndex(q => q.id === questionId); 142 + if (fromIndex === -1) return form; 143 + const clamped = Math.max(0, Math.min(toIndex, form.questions.length - 1)); 144 + const questions = [...form.questions]; 145 + const [moved] = questions.splice(fromIndex, 1); 146 + questions.splice(clamped, 0, moved); 147 + return { ...form, questions, updatedAt: Date.now() }; 148 + } 149 + 150 + /** 151 + * Create an option for choice questions. 152 + */ 153 + export function createOption(label: string): QuestionOption { 154 + return { id: `opt-${Date.now()}-${++_optionCounter}`, label }; 155 + } 156 + 157 + /** 158 + * Add an option to a choice question. 159 + */ 160 + export function addOption( 161 + form: FormSchema, 162 + questionId: string, 163 + label: string, 164 + ): FormSchema { 165 + return updateQuestion(form, questionId, { 166 + options: [ 167 + ...(form.questions.find(q => q.id === questionId)?.options ?? []), 168 + createOption(label), 169 + ], 170 + }); 171 + } 172 + 173 + /** 174 + * Set the target sheet for responses. 175 + */ 176 + export function setTargetSheet(form: FormSchema, sheetId: string | null): FormSchema { 177 + return { ...form, targetSheetId: sheetId, updatedAt: Date.now() }; 178 + } 179 + 180 + /** 181 + * Validate a single answer against its question. 182 + */ 183 + export function validateAnswer(question: Question, answer: unknown): string | null { 184 + // Required check 185 + if (question.required && (answer === null || answer === undefined || answer === '')) { 186 + return 'This field is required'; 187 + } 188 + 189 + // Allow empty for non-required 190 + if (answer === null || answer === undefined || answer === '') return null; 191 + 192 + const str = String(answer); 193 + 194 + switch (question.type) { 195 + case 'email': 196 + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str)) return 'Invalid email address'; 197 + break; 198 + case 'url': 199 + if (!/^https?:\/\/[^\s]+$/.test(str)) return 'Invalid URL'; 200 + break; 201 + case 'number': 202 + if (isNaN(Number(str))) return 'Must be a number'; 203 + break; 204 + case 'date': 205 + if (isNaN(new Date(str).getTime())) return 'Invalid date'; 206 + break; 207 + case 'single_choice': 208 + case 'dropdown': 209 + if (!question.options.some(o => o.id === str || o.label === str)) return 'Invalid choice'; 210 + break; 211 + case 'rating': 212 + case 'scale': { 213 + const num = Number(str); 214 + const min = question.scaleMin ?? 1; 215 + const max = question.scaleMax ?? 5; 216 + if (isNaN(num) || num < min || num > max) return `Must be between ${min} and ${max}`; 217 + break; 218 + } 219 + } 220 + 221 + // Custom validation pattern 222 + if (question.validationPattern) { 223 + try { 224 + if (!new RegExp(question.validationPattern).test(str)) return 'Invalid format'; 225 + } catch { 226 + // Invalid pattern, skip 227 + } 228 + } 229 + 230 + return null; 231 + } 232 + 233 + /** 234 + * Validate an entire form submission. 235 + */ 236 + export function validateSubmission( 237 + form: FormSchema, 238 + answers: Map<string, unknown>, 239 + ): Map<string, string> { 240 + const errors = new Map<string, string>(); 241 + for (const q of form.questions) { 242 + const error = validateAnswer(q, answers.get(q.id)); 243 + if (error) errors.set(q.id, error); 244 + } 245 + return errors; 246 + } 247 + 248 + /** 249 + * Count questions in the form. 250 + */ 251 + export function questionCount(form: FormSchema): number { 252 + return form.questions.length; 253 + } 254 + 255 + /** 256 + * Count required questions. 257 + */ 258 + export function requiredCount(form: FormSchema): number { 259 + return form.questions.filter(q => q.required).length; 260 + } 261 + 262 + /** 263 + * Duplicate a form with a new ID. 264 + */ 265 + export function duplicateForm(form: FormSchema): FormSchema { 266 + const now = Date.now(); 267 + return { 268 + ...form, 269 + id: `form-${now}-${++_formCounter}`, 270 + title: `${form.title} (Copy)`, 271 + createdAt: now, 272 + updatedAt: now, 273 + }; 274 + }
+182
src/lib/cross-doc-links.ts
··· 1 + /** 2 + * Cross-Document Links — manage references between docs, sheets, and forms. 3 + * 4 + * Pure logic module: link creation, resolution, graph traversal. 5 + * Live syncing handled by the collaboration layer. 6 + */ 7 + 8 + export type DocType = 'doc' | 'sheet' | 'form' | 'slide'; 9 + 10 + export interface CrossDocLink { 11 + id: string; 12 + sourceId: string; 13 + sourceType: DocType; 14 + targetId: string; 15 + targetType: DocType; 16 + /** Label displayed for the link */ 17 + label: string; 18 + /** Position in source document */ 19 + position: number; 20 + createdAt: number; 21 + } 22 + 23 + export interface LinkGraph { 24 + links: CrossDocLink[]; 25 + } 26 + 27 + let _linkCounter = 0; 28 + 29 + /** 30 + * Create empty link graph. 31 + */ 32 + export function createLinkGraph(): LinkGraph { 33 + return { links: [] }; 34 + } 35 + 36 + /** 37 + * Add a cross-document link. 38 + */ 39 + export function addLink( 40 + graph: LinkGraph, 41 + sourceId: string, 42 + sourceType: DocType, 43 + targetId: string, 44 + targetType: DocType, 45 + label: string, 46 + position = 0, 47 + ): LinkGraph { 48 + const link: CrossDocLink = { 49 + id: `link-${Date.now()}-${++_linkCounter}`, 50 + sourceId, 51 + sourceType, 52 + targetId, 53 + targetType, 54 + label, 55 + position, 56 + createdAt: Date.now(), 57 + }; 58 + return { links: [...graph.links, link] }; 59 + } 60 + 61 + /** 62 + * Remove a link by ID. 63 + */ 64 + export function removeLink(graph: LinkGraph, linkId: string): LinkGraph { 65 + return { links: graph.links.filter(l => l.id !== linkId) }; 66 + } 67 + 68 + /** 69 + * Get all outgoing links from a document. 70 + */ 71 + export function outgoingLinks(graph: LinkGraph, sourceId: string): CrossDocLink[] { 72 + return graph.links.filter(l => l.sourceId === sourceId); 73 + } 74 + 75 + /** 76 + * Get all incoming links to a document (backlinks). 77 + */ 78 + export function incomingLinks(graph: LinkGraph, targetId: string): CrossDocLink[] { 79 + return graph.links.filter(l => l.targetId === targetId); 80 + } 81 + 82 + /** 83 + * Get all documents linked to or from a document (both directions). 84 + */ 85 + export function connectedDocIds(graph: LinkGraph, docId: string): string[] { 86 + const ids = new Set<string>(); 87 + for (const link of graph.links) { 88 + if (link.sourceId === docId) ids.add(link.targetId); 89 + if (link.targetId === docId) ids.add(link.sourceId); 90 + } 91 + return [...ids]; 92 + } 93 + 94 + /** 95 + * Check if two documents are linked (either direction). 96 + */ 97 + export function areLinked(graph: LinkGraph, docA: string, docB: string): boolean { 98 + return graph.links.some( 99 + l => 100 + (l.sourceId === docA && l.targetId === docB) || 101 + (l.sourceId === docB && l.targetId === docA), 102 + ); 103 + } 104 + 105 + /** 106 + * Find all links between two specific documents. 107 + */ 108 + export function linksBetween( 109 + graph: LinkGraph, 110 + docA: string, 111 + docB: string, 112 + ): CrossDocLink[] { 113 + return graph.links.filter( 114 + l => 115 + (l.sourceId === docA && l.targetId === docB) || 116 + (l.sourceId === docB && l.targetId === docA), 117 + ); 118 + } 119 + 120 + /** 121 + * Count links by document type. 122 + */ 123 + export function linkCountByType(graph: LinkGraph): Record<string, number> { 124 + const counts: Record<string, number> = {}; 125 + for (const link of graph.links) { 126 + const key = `${link.sourceType}->${link.targetType}`; 127 + counts[key] = (counts[key] || 0) + 1; 128 + } 129 + return counts; 130 + } 131 + 132 + /** 133 + * Get unique document IDs in the graph. 134 + */ 135 + export function allDocIds(graph: LinkGraph): string[] { 136 + const ids = new Set<string>(); 137 + for (const link of graph.links) { 138 + ids.add(link.sourceId); 139 + ids.add(link.targetId); 140 + } 141 + return [...ids]; 142 + } 143 + 144 + /** 145 + * Find orphan documents (no incoming links). 146 + */ 147 + export function orphanDocs(graph: LinkGraph, allDocs: string[]): string[] { 148 + const hasIncoming = new Set<string>(); 149 + for (const link of graph.links) { 150 + hasIncoming.add(link.targetId); 151 + } 152 + return allDocs.filter(id => !hasIncoming.has(id)); 153 + } 154 + 155 + /** 156 + * Remove all links involving a document (when document is deleted). 157 + */ 158 + export function removeDocLinks(graph: LinkGraph, docId: string): LinkGraph { 159 + return { 160 + links: graph.links.filter(l => l.sourceId !== docId && l.targetId !== docId), 161 + }; 162 + } 163 + 164 + /** 165 + * Update a link's label. 166 + */ 167 + export function updateLinkLabel( 168 + graph: LinkGraph, 169 + linkId: string, 170 + label: string, 171 + ): LinkGraph { 172 + return { 173 + links: graph.links.map(l => (l.id === linkId ? { ...l, label } : l)), 174 + }; 175 + } 176 + 177 + /** 178 + * Total link count. 179 + */ 180 + export function totalLinks(graph: LinkGraph): number { 181 + return graph.links.length; 182 + }
+185
src/lib/sheet-embed.ts
··· 1 + /** 2 + * Sheet Range Embed — embed live sheet ranges in documents. 3 + * 4 + * Pure logic module: embed config, range parsing, data snapshot. 5 + * DOM rendering and live sync handled by the UI/provider layer. 6 + */ 7 + 8 + export interface CellRange { 9 + startCol: number; 10 + startRow: number; 11 + endCol: number; 12 + endRow: number; 13 + } 14 + 15 + export interface SheetEmbed { 16 + id: string; 17 + /** Source sheet document ID */ 18 + sheetId: string; 19 + /** Human-readable range like "A1:D10" */ 20 + rangeStr: string; 21 + /** Parsed range */ 22 + range: CellRange; 23 + /** Position in target document (character offset) */ 24 + position: number; 25 + /** Show column headers */ 26 + showHeaders: boolean; 27 + /** Show row numbers */ 28 + showRowNumbers: boolean; 29 + } 30 + 31 + export interface EmbedSnapshot { 32 + embedId: string; 33 + /** 2D array of display values [row][col] */ 34 + values: string[][]; 35 + /** Column headers */ 36 + headers: string[]; 37 + updatedAt: number; 38 + } 39 + 40 + let _embedCounter = 0; 41 + 42 + /** 43 + * Parse a range string like "A1:D10" into a CellRange. 44 + */ 45 + export function parseRange(rangeStr: string): CellRange | null { 46 + const match = rangeStr.match(/^([A-Z]+)(\d+):([A-Z]+)(\d+)$/i); 47 + if (!match) return null; 48 + 49 + const startCol = letterToCol(match[1].toUpperCase()); 50 + const startRow = parseInt(match[2], 10); 51 + const endCol = letterToCol(match[3].toUpperCase()); 52 + const endRow = parseInt(match[4], 10); 53 + 54 + if (startCol > endCol || startRow > endRow) return null; 55 + 56 + return { startCol, startRow, endCol, endRow }; 57 + } 58 + 59 + /** 60 + * Convert column letter(s) to zero-based index. 61 + */ 62 + export function letterToCol(letter: string): number { 63 + let col = 0; 64 + for (let i = 0; i < letter.length; i++) { 65 + col = col * 26 + (letter.charCodeAt(i) - 64); 66 + } 67 + return col - 1; 68 + } 69 + 70 + /** 71 + * Convert zero-based column index to letter(s). 72 + */ 73 + export function colToLetter(col: number): string { 74 + let result = ''; 75 + let c = col; 76 + while (c >= 0) { 77 + result = String.fromCharCode((c % 26) + 65) + result; 78 + c = Math.floor(c / 26) - 1; 79 + } 80 + return result; 81 + } 82 + 83 + /** 84 + * Format a CellRange back to string. 85 + */ 86 + export function formatRange(range: CellRange): string { 87 + return `${colToLetter(range.startCol)}${range.startRow}:${colToLetter(range.endCol)}${range.endRow}`; 88 + } 89 + 90 + /** 91 + * Create a sheet embed. 92 + */ 93 + export function createSheetEmbed( 94 + sheetId: string, 95 + rangeStr: string, 96 + position: number, 97 + options: { showHeaders?: boolean; showRowNumbers?: boolean } = {}, 98 + ): SheetEmbed | null { 99 + const range = parseRange(rangeStr); 100 + if (!range) return null; 101 + 102 + return { 103 + id: `embed-${Date.now()}-${++_embedCounter}`, 104 + sheetId, 105 + rangeStr: rangeStr.toUpperCase(), 106 + range, 107 + position, 108 + showHeaders: options.showHeaders ?? true, 109 + showRowNumbers: options.showRowNumbers ?? false, 110 + }; 111 + } 112 + 113 + /** 114 + * Extract a snapshot of values from sheet data for an embed. 115 + */ 116 + export function extractSnapshot( 117 + embed: SheetEmbed, 118 + cellValues: Map<string, unknown>, 119 + colHeaders: string[], 120 + ): EmbedSnapshot { 121 + const { range } = embed; 122 + const values: string[][] = []; 123 + const headers: string[] = []; 124 + 125 + // Build headers 126 + for (let c = range.startCol; c <= range.endCol; c++) { 127 + headers.push(colHeaders[c] ?? colToLetter(c)); 128 + } 129 + 130 + // Build value rows 131 + for (let r = range.startRow; r <= range.endRow; r++) { 132 + const row: string[] = []; 133 + for (let c = range.startCol; c <= range.endCol; c++) { 134 + const cellId = `${colToLetter(c)}${r}`; 135 + const val = cellValues.get(cellId); 136 + row.push(val !== undefined && val !== null ? String(val) : ''); 137 + } 138 + values.push(row); 139 + } 140 + 141 + return { 142 + embedId: embed.id, 143 + values, 144 + headers, 145 + updatedAt: Date.now(), 146 + }; 147 + } 148 + 149 + /** 150 + * Get the dimensions of an embed range. 151 + */ 152 + export function embedDimensions(embed: SheetEmbed): { rows: number; cols: number } { 153 + const { range } = embed; 154 + return { 155 + rows: range.endRow - range.startRow + 1, 156 + cols: range.endCol - range.startCol + 1, 157 + }; 158 + } 159 + 160 + /** 161 + * Update the range of an embed. 162 + */ 163 + export function updateEmbedRange( 164 + embed: SheetEmbed, 165 + rangeStr: string, 166 + ): SheetEmbed | null { 167 + const range = parseRange(rangeStr); 168 + if (!range) return null; 169 + return { ...embed, rangeStr: rangeStr.toUpperCase(), range }; 170 + } 171 + 172 + /** 173 + * Move the embed position in the document. 174 + */ 175 + export function moveEmbed(embed: SheetEmbed, position: number): SheetEmbed { 176 + return { ...embed, position }; 177 + } 178 + 179 + /** 180 + * Count cells in the embed range. 181 + */ 182 + export function cellCount(embed: SheetEmbed): number { 183 + const dims = embedDimensions(embed); 184 + return dims.rows * dims.cols; 185 + }
+166
tests/cross-doc-links.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + createLinkGraph, 4 + addLink, 5 + removeLink, 6 + outgoingLinks, 7 + incomingLinks, 8 + connectedDocIds, 9 + areLinked, 10 + linksBetween, 11 + linkCountByType, 12 + allDocIds, 13 + orphanDocs, 14 + removeDocLinks, 15 + updateLinkLabel, 16 + totalLinks, 17 + } from '../src/lib/cross-doc-links.js'; 18 + 19 + function buildGraph() { 20 + let g = createLinkGraph(); 21 + g = addLink(g, 'd1', 'doc', 'd2', 'sheet', 'Budget Sheet'); 22 + g = addLink(g, 'd1', 'doc', 'd3', 'doc', 'Meeting Notes'); 23 + g = addLink(g, 'd2', 'sheet', 'd3', 'doc', 'Notes Link'); 24 + return g; 25 + } 26 + 27 + describe('createLinkGraph', () => { 28 + it('creates empty graph', () => { 29 + const g = createLinkGraph(); 30 + expect(g.links).toEqual([]); 31 + }); 32 + }); 33 + 34 + describe('addLink', () => { 35 + it('adds link to graph', () => { 36 + let g = createLinkGraph(); 37 + g = addLink(g, 'd1', 'doc', 'd2', 'sheet', 'My Sheet'); 38 + expect(g.links).toHaveLength(1); 39 + expect(g.links[0].sourceId).toBe('d1'); 40 + expect(g.links[0].targetId).toBe('d2'); 41 + expect(g.links[0].label).toBe('My Sheet'); 42 + }); 43 + 44 + it('generates unique IDs', () => { 45 + let g = createLinkGraph(); 46 + g = addLink(g, 'd1', 'doc', 'd2', 'sheet', 'A'); 47 + g = addLink(g, 'd1', 'doc', 'd3', 'doc', 'B'); 48 + expect(g.links[0].id).not.toBe(g.links[1].id); 49 + }); 50 + }); 51 + 52 + describe('removeLink', () => { 53 + it('removes link by ID', () => { 54 + const g = buildGraph(); 55 + const id = g.links[0].id; 56 + const updated = removeLink(g, id); 57 + expect(updated.links).toHaveLength(2); 58 + }); 59 + }); 60 + 61 + describe('outgoingLinks', () => { 62 + it('returns links from a document', () => { 63 + const g = buildGraph(); 64 + const out = outgoingLinks(g, 'd1'); 65 + expect(out).toHaveLength(2); 66 + }); 67 + 68 + it('returns empty for no outgoing', () => { 69 + const g = buildGraph(); 70 + expect(outgoingLinks(g, 'd3')).toHaveLength(0); 71 + }); 72 + }); 73 + 74 + describe('incomingLinks', () => { 75 + it('returns backlinks to a document', () => { 76 + const g = buildGraph(); 77 + const inc = incomingLinks(g, 'd3'); 78 + expect(inc).toHaveLength(2); 79 + }); 80 + }); 81 + 82 + describe('connectedDocIds', () => { 83 + it('returns all connected docs', () => { 84 + const g = buildGraph(); 85 + const connected = connectedDocIds(g, 'd1'); 86 + expect(connected).toContain('d2'); 87 + expect(connected).toContain('d3'); 88 + expect(connected).not.toContain('d1'); 89 + }); 90 + }); 91 + 92 + describe('areLinked', () => { 93 + it('detects linked documents', () => { 94 + const g = buildGraph(); 95 + expect(areLinked(g, 'd1', 'd2')).toBe(true); 96 + expect(areLinked(g, 'd2', 'd1')).toBe(true); 97 + }); 98 + 99 + it('returns false for unlinked', () => { 100 + const g = createLinkGraph(); 101 + expect(areLinked(g, 'd1', 'd2')).toBe(false); 102 + }); 103 + }); 104 + 105 + describe('linksBetween', () => { 106 + it('returns links in both directions', () => { 107 + let g = buildGraph(); 108 + g = addLink(g, 'd2', 'sheet', 'd1', 'doc', 'Reverse'); 109 + const between = linksBetween(g, 'd1', 'd2'); 110 + expect(between).toHaveLength(2); 111 + }); 112 + }); 113 + 114 + describe('linkCountByType', () => { 115 + it('counts by type pair', () => { 116 + const g = buildGraph(); 117 + const counts = linkCountByType(g); 118 + expect(counts['doc->sheet']).toBe(1); 119 + expect(counts['doc->doc']).toBe(1); 120 + expect(counts['sheet->doc']).toBe(1); 121 + }); 122 + }); 123 + 124 + describe('allDocIds', () => { 125 + it('returns all unique document IDs', () => { 126 + const g = buildGraph(); 127 + const ids = allDocIds(g); 128 + expect(ids).toContain('d1'); 129 + expect(ids).toContain('d2'); 130 + expect(ids).toContain('d3'); 131 + expect(ids).toHaveLength(3); 132 + }); 133 + }); 134 + 135 + describe('orphanDocs', () => { 136 + it('finds docs with no incoming links', () => { 137 + const g = buildGraph(); 138 + const orphans = orphanDocs(g, ['d1', 'd2', 'd3']); 139 + expect(orphans).toContain('d1'); 140 + expect(orphans).not.toContain('d3'); 141 + }); 142 + }); 143 + 144 + describe('removeDocLinks', () => { 145 + it('removes all links involving a document', () => { 146 + const g = buildGraph(); 147 + const updated = removeDocLinks(g, 'd1'); 148 + expect(updated.links).toHaveLength(1); 149 + expect(updated.links[0].sourceId).toBe('d2'); 150 + }); 151 + }); 152 + 153 + describe('updateLinkLabel', () => { 154 + it('updates label of a link', () => { 155 + const g = buildGraph(); 156 + const id = g.links[0].id; 157 + const updated = updateLinkLabel(g, id, 'New Label'); 158 + expect(updated.links.find(l => l.id === id)!.label).toBe('New Label'); 159 + }); 160 + }); 161 + 162 + describe('totalLinks', () => { 163 + it('returns total count', () => { 164 + expect(totalLinks(buildGraph())).toBe(3); 165 + }); 166 + });
+216
tests/form-builder.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + createForm, 4 + addQuestion, 5 + removeQuestion, 6 + updateQuestion, 7 + moveQuestion, 8 + createOption, 9 + addOption, 10 + setTargetSheet, 11 + validateAnswer, 12 + validateSubmission, 13 + questionCount, 14 + requiredCount, 15 + duplicateForm, 16 + type Question, 17 + } from '../src/forms/form-builder.js'; 18 + 19 + describe('createForm', () => { 20 + it('creates empty form', () => { 21 + const form = createForm('Survey'); 22 + expect(form.title).toBe('Survey'); 23 + expect(form.questions).toEqual([]); 24 + expect(form.targetSheetId).toBeNull(); 25 + }); 26 + 27 + it('generates unique IDs', () => { 28 + const f1 = createForm('A'); 29 + const f2 = createForm('B'); 30 + expect(f1.id).not.toBe(f2.id); 31 + }); 32 + }); 33 + 34 + describe('addQuestion', () => { 35 + it('adds question to form', () => { 36 + let form = createForm('Test'); 37 + form = addQuestion(form, 'short_text', 'Name'); 38 + expect(form.questions).toHaveLength(1); 39 + expect(form.questions[0].label).toBe('Name'); 40 + expect(form.questions[0].type).toBe('short_text'); 41 + }); 42 + 43 + it('accepts options', () => { 44 + let form = createForm('Test'); 45 + form = addQuestion(form, 'email', 'Email', { required: true, description: 'Your email' }); 46 + expect(form.questions[0].required).toBe(true); 47 + expect(form.questions[0].description).toBe('Your email'); 48 + }); 49 + }); 50 + 51 + describe('removeQuestion', () => { 52 + it('removes question by ID', () => { 53 + let form = createForm('Test'); 54 + form = addQuestion(form, 'short_text', 'Q1'); 55 + form = addQuestion(form, 'short_text', 'Q2'); 56 + const id = form.questions[0].id; 57 + form = removeQuestion(form, id); 58 + expect(form.questions).toHaveLength(1); 59 + expect(form.questions[0].label).toBe('Q2'); 60 + }); 61 + }); 62 + 63 + describe('updateQuestion', () => { 64 + it('updates question properties', () => { 65 + let form = createForm('Test'); 66 + form = addQuestion(form, 'short_text', 'Name'); 67 + const id = form.questions[0].id; 68 + form = updateQuestion(form, id, { label: 'Full Name', required: true }); 69 + expect(form.questions[0].label).toBe('Full Name'); 70 + expect(form.questions[0].required).toBe(true); 71 + }); 72 + }); 73 + 74 + describe('moveQuestion', () => { 75 + it('moves question to new index', () => { 76 + let form = createForm('Test'); 77 + form = addQuestion(form, 'short_text', 'A'); 78 + form = addQuestion(form, 'short_text', 'B'); 79 + form = addQuestion(form, 'short_text', 'C'); 80 + const id = form.questions[2].id; 81 + form = moveQuestion(form, id, 0); 82 + expect(form.questions[0].label).toBe('C'); 83 + expect(form.questions[1].label).toBe('A'); 84 + }); 85 + 86 + it('clamps to bounds', () => { 87 + let form = createForm('Test'); 88 + form = addQuestion(form, 'short_text', 'A'); 89 + form = addQuestion(form, 'short_text', 'B'); 90 + form = moveQuestion(form, form.questions[0].id, 100); 91 + expect(form.questions[1].label).toBe('A'); 92 + }); 93 + 94 + it('returns same form for unknown ID', () => { 95 + const form = createForm('Test'); 96 + expect(moveQuestion(form, 'unknown', 0)).toBe(form); 97 + }); 98 + }); 99 + 100 + describe('createOption / addOption', () => { 101 + it('creates option with unique ID', () => { 102 + const o1 = createOption('Yes'); 103 + const o2 = createOption('No'); 104 + expect(o1.id).not.toBe(o2.id); 105 + expect(o1.label).toBe('Yes'); 106 + }); 107 + 108 + it('adds option to question', () => { 109 + let form = createForm('Test'); 110 + form = addQuestion(form, 'single_choice', 'Color'); 111 + const qId = form.questions[0].id; 112 + form = addOption(form, qId, 'Red'); 113 + form = addOption(form, qId, 'Blue'); 114 + expect(form.questions[0].options).toHaveLength(2); 115 + }); 116 + }); 117 + 118 + describe('setTargetSheet', () => { 119 + it('sets target sheet', () => { 120 + let form = createForm('Test'); 121 + form = setTargetSheet(form, 'sheet-1'); 122 + expect(form.targetSheetId).toBe('sheet-1'); 123 + }); 124 + 125 + it('can clear target', () => { 126 + let form = createForm('Test'); 127 + form = setTargetSheet(form, 'sheet-1'); 128 + form = setTargetSheet(form, null); 129 + expect(form.targetSheetId).toBeNull(); 130 + }); 131 + }); 132 + 133 + describe('validateAnswer', () => { 134 + const textQ: Question = { id: 'q1', type: 'short_text', label: 'Name', description: '', required: true, options: [] }; 135 + const emailQ: Question = { id: 'q2', type: 'email', label: 'Email', description: '', required: false, options: [] }; 136 + const numberQ: Question = { id: 'q3', type: 'number', label: 'Age', description: '', required: false, options: [] }; 137 + const choiceQ: Question = { 138 + id: 'q4', type: 'single_choice', label: 'Pick', description: '', required: false, 139 + options: [{ id: 'o1', label: 'A' }, { id: 'o2', label: 'B' }], 140 + }; 141 + const ratingQ: Question = { id: 'q5', type: 'rating', label: 'Rate', description: '', required: false, options: [], scaleMin: 1, scaleMax: 5 }; 142 + 143 + it('validates required field', () => { 144 + expect(validateAnswer(textQ, '')).toBe('This field is required'); 145 + expect(validateAnswer(textQ, 'Alice')).toBeNull(); 146 + }); 147 + 148 + it('allows empty for non-required', () => { 149 + expect(validateAnswer(emailQ, '')).toBeNull(); 150 + }); 151 + 152 + it('validates email format', () => { 153 + expect(validateAnswer(emailQ, 'bad')).toBe('Invalid email address'); 154 + expect(validateAnswer(emailQ, 'a@b.c')).toBeNull(); 155 + }); 156 + 157 + it('validates number', () => { 158 + expect(validateAnswer(numberQ, 'abc')).toBe('Must be a number'); 159 + expect(validateAnswer(numberQ, '25')).toBeNull(); 160 + }); 161 + 162 + it('validates choice', () => { 163 + expect(validateAnswer(choiceQ, 'A')).toBeNull(); 164 + expect(validateAnswer(choiceQ, 'o1')).toBeNull(); 165 + expect(validateAnswer(choiceQ, 'C')).toBe('Invalid choice'); 166 + }); 167 + 168 + it('validates rating range', () => { 169 + expect(validateAnswer(ratingQ, '3')).toBeNull(); 170 + expect(validateAnswer(ratingQ, '0')).toBe('Must be between 1 and 5'); 171 + expect(validateAnswer(ratingQ, '6')).toBe('Must be between 1 and 5'); 172 + }); 173 + }); 174 + 175 + describe('validateSubmission', () => { 176 + it('returns errors for invalid answers', () => { 177 + let form = createForm('Test'); 178 + form = addQuestion(form, 'short_text', 'Name', { required: true }); 179 + form = addQuestion(form, 'email', 'Email'); 180 + const answers = new Map<string, unknown>([ 181 + [form.questions[0].id, ''], 182 + [form.questions[1].id, 'bad-email'], 183 + ]); 184 + const errors = validateSubmission(form, answers); 185 + expect(errors.size).toBe(2); 186 + }); 187 + 188 + it('returns empty map for valid submission', () => { 189 + let form = createForm('Test'); 190 + form = addQuestion(form, 'short_text', 'Name'); 191 + const answers = new Map<string, unknown>([[form.questions[0].id, 'Alice']]); 192 + expect(validateSubmission(form, answers).size).toBe(0); 193 + }); 194 + }); 195 + 196 + describe('questionCount / requiredCount', () => { 197 + it('counts questions', () => { 198 + let form = createForm('Test'); 199 + form = addQuestion(form, 'short_text', 'A', { required: true }); 200 + form = addQuestion(form, 'short_text', 'B'); 201 + form = addQuestion(form, 'short_text', 'C', { required: true }); 202 + expect(questionCount(form)).toBe(3); 203 + expect(requiredCount(form)).toBe(2); 204 + }); 205 + }); 206 + 207 + describe('duplicateForm', () => { 208 + it('creates copy with new ID', () => { 209 + let form = createForm('Original'); 210 + form = addQuestion(form, 'short_text', 'Q1'); 211 + const copy = duplicateForm(form); 212 + expect(copy.id).not.toBe(form.id); 213 + expect(copy.title).toBe('Original (Copy)'); 214 + expect(copy.questions).toEqual(form.questions); 215 + }); 216 + });
+149
tests/sheet-embed.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + parseRange, 4 + letterToCol, 5 + colToLetter, 6 + formatRange, 7 + createSheetEmbed, 8 + extractSnapshot, 9 + embedDimensions, 10 + updateEmbedRange, 11 + moveEmbed, 12 + cellCount, 13 + } from '../src/lib/sheet-embed.js'; 14 + 15 + describe('letterToCol', () => { 16 + it('converts single letters', () => { 17 + expect(letterToCol('A')).toBe(0); 18 + expect(letterToCol('B')).toBe(1); 19 + expect(letterToCol('Z')).toBe(25); 20 + }); 21 + 22 + it('converts multi-letter columns', () => { 23 + expect(letterToCol('AA')).toBe(26); 24 + expect(letterToCol('AZ')).toBe(51); 25 + }); 26 + }); 27 + 28 + describe('colToLetter', () => { 29 + it('converts single digit columns', () => { 30 + expect(colToLetter(0)).toBe('A'); 31 + expect(colToLetter(25)).toBe('Z'); 32 + }); 33 + 34 + it('converts multi-letter columns', () => { 35 + expect(colToLetter(26)).toBe('AA'); 36 + expect(colToLetter(51)).toBe('AZ'); 37 + }); 38 + 39 + it('round-trips with letterToCol', () => { 40 + for (let i = 0; i < 100; i++) { 41 + expect(letterToCol(colToLetter(i))).toBe(i); 42 + } 43 + }); 44 + }); 45 + 46 + describe('parseRange', () => { 47 + it('parses valid range', () => { 48 + const range = parseRange('A1:D10'); 49 + expect(range).toEqual({ startCol: 0, startRow: 1, endCol: 3, endRow: 10 }); 50 + }); 51 + 52 + it('is case insensitive', () => { 53 + const range = parseRange('a1:d10'); 54 + expect(range).toEqual({ startCol: 0, startRow: 1, endCol: 3, endRow: 10 }); 55 + }); 56 + 57 + it('returns null for invalid range', () => { 58 + expect(parseRange('invalid')).toBeNull(); 59 + expect(parseRange('A1')).toBeNull(); 60 + }); 61 + 62 + it('returns null for reversed range', () => { 63 + expect(parseRange('D10:A1')).toBeNull(); 64 + }); 65 + }); 66 + 67 + describe('formatRange', () => { 68 + it('formats range to string', () => { 69 + expect(formatRange({ startCol: 0, startRow: 1, endCol: 3, endRow: 10 })).toBe('A1:D10'); 70 + }); 71 + }); 72 + 73 + describe('createSheetEmbed', () => { 74 + it('creates embed from valid range', () => { 75 + const embed = createSheetEmbed('s1', 'A1:C5', 100); 76 + expect(embed).not.toBeNull(); 77 + expect(embed!.sheetId).toBe('s1'); 78 + expect(embed!.rangeStr).toBe('A1:C5'); 79 + expect(embed!.showHeaders).toBe(true); 80 + expect(embed!.showRowNumbers).toBe(false); 81 + }); 82 + 83 + it('returns null for invalid range', () => { 84 + expect(createSheetEmbed('s1', 'invalid', 0)).toBeNull(); 85 + }); 86 + 87 + it('accepts display options', () => { 88 + const embed = createSheetEmbed('s1', 'A1:B2', 0, { showHeaders: false, showRowNumbers: true }); 89 + expect(embed!.showHeaders).toBe(false); 90 + expect(embed!.showRowNumbers).toBe(true); 91 + }); 92 + }); 93 + 94 + describe('extractSnapshot', () => { 95 + it('extracts values from cell data', () => { 96 + const embed = createSheetEmbed('s1', 'A1:B2', 0)!; 97 + const values = new Map<string, unknown>([ 98 + ['A1', 'Hello'], ['B1', 42], 99 + ['A2', 'World'], ['B2', 99], 100 + ]); 101 + const snap = extractSnapshot(embed, values, ['Name', 'Score']); 102 + expect(snap.headers).toEqual(['Name', 'Score']); 103 + expect(snap.values).toEqual([['Hello', '42'], ['World', '99']]); 104 + }); 105 + 106 + it('handles missing values', () => { 107 + const embed = createSheetEmbed('s1', 'A1:A2', 0)!; 108 + const snap = extractSnapshot(embed, new Map(), []); 109 + expect(snap.values).toEqual([[''], ['']]); 110 + }); 111 + }); 112 + 113 + describe('embedDimensions', () => { 114 + it('returns rows and cols', () => { 115 + const embed = createSheetEmbed('s1', 'A1:C5', 0)!; 116 + const dims = embedDimensions(embed); 117 + expect(dims.rows).toBe(5); 118 + expect(dims.cols).toBe(3); 119 + }); 120 + }); 121 + 122 + describe('updateEmbedRange', () => { 123 + it('updates range', () => { 124 + const embed = createSheetEmbed('s1', 'A1:B2', 0)!; 125 + const updated = updateEmbedRange(embed, 'C3:E10'); 126 + expect(updated).not.toBeNull(); 127 + expect(updated!.rangeStr).toBe('C3:E10'); 128 + }); 129 + 130 + it('returns null for invalid range', () => { 131 + const embed = createSheetEmbed('s1', 'A1:B2', 0)!; 132 + expect(updateEmbedRange(embed, 'invalid')).toBeNull(); 133 + }); 134 + }); 135 + 136 + describe('moveEmbed', () => { 137 + it('updates position', () => { 138 + const embed = createSheetEmbed('s1', 'A1:B2', 0)!; 139 + const moved = moveEmbed(embed, 500); 140 + expect(moved.position).toBe(500); 141 + }); 142 + }); 143 + 144 + describe('cellCount', () => { 145 + it('computes total cells', () => { 146 + const embed = createSheetEmbed('s1', 'A1:C5', 0)!; 147 + expect(cellCount(embed)).toBe(15); 148 + }); 149 + });