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: global find, keyboard shortcuts, array formulas (#119, #111, #47)' (#136) from feat/global-find-shortcuts-array into main

scott ad5d0a47 f7faac99

+1371
+223
src/lib/global-find.ts
··· 1 + /** 2 + * Global Find — cross-document search within the workspace. 3 + * 4 + * Pure logic module: query parsing, result ranking, navigation. 5 + * UI rendering and decryption handled by the application layer. 6 + */ 7 + 8 + export interface FindMatch { 9 + documentId: string; 10 + documentName: string; 11 + /** Line or cell reference */ 12 + location: string; 13 + /** Matched text snippet */ 14 + snippet: string; 15 + /** Character offset of match within snippet */ 16 + matchStart: number; 17 + matchEnd: number; 18 + /** Relevance score */ 19 + score: number; 20 + } 21 + 22 + export interface FindState { 23 + query: string; 24 + results: FindMatch[]; 25 + selectedIndex: number; 26 + caseSensitive: boolean; 27 + useRegex: boolean; 28 + wholeWord: boolean; 29 + } 30 + 31 + /** 32 + * Create initial find state. 33 + */ 34 + export function createFindState(): FindState { 35 + return { 36 + query: '', 37 + results: [], 38 + selectedIndex: -1, 39 + caseSensitive: false, 40 + useRegex: false, 41 + wholeWord: false, 42 + }; 43 + } 44 + 45 + /** 46 + * Update the search query. 47 + */ 48 + export function setQuery(state: FindState, query: string): FindState { 49 + return { ...state, query, selectedIndex: -1 }; 50 + } 51 + 52 + /** 53 + * Toggle case sensitivity. 54 + */ 55 + export function toggleCaseSensitive(state: FindState): FindState { 56 + return { ...state, caseSensitive: !state.caseSensitive }; 57 + } 58 + 59 + /** 60 + * Toggle regex mode. 61 + */ 62 + export function toggleRegex(state: FindState): FindState { 63 + return { ...state, useRegex: !state.useRegex }; 64 + } 65 + 66 + /** 67 + * Toggle whole word matching. 68 + */ 69 + export function toggleWholeWord(state: FindState): FindState { 70 + return { ...state, wholeWord: !state.wholeWord }; 71 + } 72 + 73 + /** 74 + * Build a RegExp from the current find options. 75 + * Returns null if the regex is invalid. 76 + */ 77 + export function buildSearchPattern(state: FindState): RegExp | null { 78 + if (!state.query) return null; 79 + 80 + try { 81 + let pattern = state.useRegex ? state.query : escapeRegex(state.query); 82 + if (state.wholeWord) { 83 + pattern = `\\b${pattern}\\b`; 84 + } 85 + const flags = state.caseSensitive ? 'g' : 'gi'; 86 + return new RegExp(pattern, flags); 87 + } catch { 88 + return null; 89 + } 90 + } 91 + 92 + /** 93 + * Escape special regex characters. 94 + */ 95 + export function escapeRegex(str: string): string { 96 + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 97 + } 98 + 99 + /** 100 + * Find all matches in a single document's text content. 101 + */ 102 + export function findInDocument( 103 + documentId: string, 104 + documentName: string, 105 + content: string, 106 + pattern: RegExp, 107 + contextChars = 40, 108 + ): FindMatch[] { 109 + const matches: FindMatch[] = []; 110 + let match; 111 + 112 + // Reset lastIndex for global regex 113 + pattern.lastIndex = 0; 114 + 115 + while ((match = pattern.exec(content)) !== null) { 116 + const start = Math.max(0, match.index - contextChars); 117 + const end = Math.min(content.length, match.index + match[0].length + contextChars); 118 + const snippet = content.slice(start, end); 119 + const matchStart = match.index - start; 120 + const matchEnd = matchStart + match[0].length; 121 + 122 + // Compute line number 123 + const lineNum = content.slice(0, match.index).split('\n').length; 124 + 125 + matches.push({ 126 + documentId, 127 + documentName, 128 + location: `Line ${lineNum}`, 129 + snippet, 130 + matchStart, 131 + matchEnd, 132 + score: 1, 133 + }); 134 + 135 + // Prevent infinite loop on zero-length matches 136 + if (match[0].length === 0) pattern.lastIndex++; 137 + } 138 + 139 + return matches; 140 + } 141 + 142 + /** 143 + * Search across multiple documents and rank results. 144 + */ 145 + export function searchAcrossDocuments( 146 + state: FindState, 147 + documents: Array<{ id: string; name: string; content: string }>, 148 + ): FindMatch[] { 149 + const pattern = buildSearchPattern(state); 150 + if (!pattern) return []; 151 + 152 + const allMatches: FindMatch[] = []; 153 + 154 + for (const doc of documents) { 155 + const matches = findInDocument(doc.id, doc.name, doc.content, pattern); 156 + 157 + // Boost score for document name matches 158 + const nameMatch = pattern.test(doc.name); 159 + if (nameMatch) { 160 + for (const m of matches) { 161 + m.score += 5; 162 + } 163 + } 164 + 165 + allMatches.push(...matches); 166 + } 167 + 168 + // Sort by score descending, then by document name 169 + allMatches.sort((a, b) => b.score - a.score || a.documentName.localeCompare(b.documentName)); 170 + 171 + return allMatches; 172 + } 173 + 174 + /** 175 + * Set results into state. 176 + */ 177 + export function setResults(state: FindState, results: FindMatch[]): FindState { 178 + return { ...state, results, selectedIndex: results.length > 0 ? 0 : -1 }; 179 + } 180 + 181 + /** 182 + * Navigate to next result. 183 + */ 184 + export function nextResult(state: FindState): FindState { 185 + if (state.results.length === 0) return state; 186 + const next = (state.selectedIndex + 1) % state.results.length; 187 + return { ...state, selectedIndex: next }; 188 + } 189 + 190 + /** 191 + * Navigate to previous result. 192 + */ 193 + export function prevResult(state: FindState): FindState { 194 + if (state.results.length === 0) return state; 195 + const prev = state.selectedIndex <= 0 ? state.results.length - 1 : state.selectedIndex - 1; 196 + return { ...state, selectedIndex: prev }; 197 + } 198 + 199 + /** 200 + * Get the currently selected result. 201 + */ 202 + export function selectedResult(state: FindState): FindMatch | null { 203 + if (state.selectedIndex < 0 || state.selectedIndex >= state.results.length) return null; 204 + return state.results[state.selectedIndex]; 205 + } 206 + 207 + /** 208 + * Count results per document. 209 + */ 210 + export function resultCountByDocument(results: FindMatch[]): Map<string, number> { 211 + const counts = new Map<string, number>(); 212 + for (const r of results) { 213 + counts.set(r.documentId, (counts.get(r.documentId) || 0) + 1); 214 + } 215 + return counts; 216 + } 217 + 218 + /** 219 + * Total result count. 220 + */ 221 + export function totalResultCount(state: FindState): number { 222 + return state.results.length; 223 + }
+239
src/lib/keyboard-shortcuts.ts
··· 1 + /** 2 + * Keyboard Shortcut Customization — user-configurable key bindings. 3 + * 4 + * Pure logic module: shortcut registry, conflict detection, matching. 5 + * Event listener attachment handled by the application layer. 6 + */ 7 + 8 + export interface KeyCombo { 9 + key: string; 10 + ctrl: boolean; 11 + shift: boolean; 12 + alt: boolean; 13 + meta: boolean; 14 + } 15 + 16 + export interface Shortcut { 17 + id: string; 18 + label: string; 19 + description: string; 20 + defaultCombo: KeyCombo; 21 + customCombo: KeyCombo | null; 22 + /** Category for grouping in settings UI */ 23 + category: string; 24 + } 25 + 26 + export interface ShortcutState { 27 + shortcuts: Map<string, Shortcut>; 28 + } 29 + 30 + /** 31 + * Create a key combo. 32 + */ 33 + export function keyCombo( 34 + key: string, 35 + modifiers: { ctrl?: boolean; shift?: boolean; alt?: boolean; meta?: boolean } = {}, 36 + ): KeyCombo { 37 + return { 38 + key: key.toLowerCase(), 39 + ctrl: modifiers.ctrl ?? false, 40 + shift: modifiers.shift ?? false, 41 + alt: modifiers.alt ?? false, 42 + meta: modifiers.meta ?? false, 43 + }; 44 + } 45 + 46 + /** 47 + * Format a key combo for display (e.g., "Ctrl+Shift+S"). 48 + */ 49 + export function formatCombo(combo: KeyCombo): string { 50 + const parts: string[] = []; 51 + if (combo.ctrl) parts.push('Ctrl'); 52 + if (combo.alt) parts.push('Alt'); 53 + if (combo.shift) parts.push('Shift'); 54 + if (combo.meta) parts.push('Cmd'); 55 + parts.push(combo.key.length === 1 ? combo.key.toUpperCase() : capitalize(combo.key)); 56 + return parts.join('+'); 57 + } 58 + 59 + function capitalize(s: string): string { 60 + return s.charAt(0).toUpperCase() + s.slice(1); 61 + } 62 + 63 + /** 64 + * Check if two key combos are equal. 65 + */ 66 + export function combosEqual(a: KeyCombo, b: KeyCombo): boolean { 67 + return ( 68 + a.key === b.key && 69 + a.ctrl === b.ctrl && 70 + a.shift === b.shift && 71 + a.alt === b.alt && 72 + a.meta === b.meta 73 + ); 74 + } 75 + 76 + /** 77 + * Create initial shortcut state. 78 + */ 79 + export function createShortcutState(): ShortcutState { 80 + return { shortcuts: new Map() }; 81 + } 82 + 83 + /** 84 + * Register a shortcut. 85 + */ 86 + export function registerShortcut( 87 + state: ShortcutState, 88 + id: string, 89 + label: string, 90 + description: string, 91 + defaultCombo: KeyCombo, 92 + category = 'General', 93 + ): ShortcutState { 94 + const shortcuts = new Map(state.shortcuts); 95 + shortcuts.set(id, { id, label, description, defaultCombo, customCombo: null, category }); 96 + return { shortcuts }; 97 + } 98 + 99 + /** 100 + * Set a custom key binding for a shortcut. 101 + */ 102 + export function setCustomBinding( 103 + state: ShortcutState, 104 + id: string, 105 + combo: KeyCombo, 106 + ): ShortcutState { 107 + const existing = state.shortcuts.get(id); 108 + if (!existing) return state; 109 + 110 + const shortcuts = new Map(state.shortcuts); 111 + shortcuts.set(id, { ...existing, customCombo: combo }); 112 + return { shortcuts }; 113 + } 114 + 115 + /** 116 + * Reset a shortcut to its default binding. 117 + */ 118 + export function resetBinding(state: ShortcutState, id: string): ShortcutState { 119 + const existing = state.shortcuts.get(id); 120 + if (!existing || !existing.customCombo) return state; 121 + 122 + const shortcuts = new Map(state.shortcuts); 123 + shortcuts.set(id, { ...existing, customCombo: null }); 124 + return { shortcuts }; 125 + } 126 + 127 + /** 128 + * Reset all shortcuts to defaults. 129 + */ 130 + export function resetAllBindings(state: ShortcutState): ShortcutState { 131 + const shortcuts = new Map<string, Shortcut>(); 132 + for (const [id, shortcut] of state.shortcuts) { 133 + shortcuts.set(id, { ...shortcut, customCombo: null }); 134 + } 135 + return { shortcuts }; 136 + } 137 + 138 + /** 139 + * Get the active combo for a shortcut (custom overrides default). 140 + */ 141 + export function activeCombo(shortcut: Shortcut): KeyCombo { 142 + return shortcut.customCombo ?? shortcut.defaultCombo; 143 + } 144 + 145 + /** 146 + * Find which shortcut matches a key event. 147 + */ 148 + export function matchShortcut( 149 + state: ShortcutState, 150 + combo: KeyCombo, 151 + ): Shortcut | null { 152 + for (const shortcut of state.shortcuts.values()) { 153 + if (combosEqual(activeCombo(shortcut), combo)) { 154 + return shortcut; 155 + } 156 + } 157 + return null; 158 + } 159 + 160 + /** 161 + * Detect conflicts — shortcuts that share the same active combo. 162 + */ 163 + export function findConflicts(state: ShortcutState): Array<[string, string]> { 164 + const conflicts: Array<[string, string]> = []; 165 + const entries = [...state.shortcuts.values()]; 166 + 167 + for (let i = 0; i < entries.length; i++) { 168 + for (let j = i + 1; j < entries.length; j++) { 169 + if (combosEqual(activeCombo(entries[i]), activeCombo(entries[j]))) { 170 + conflicts.push([entries[i].id, entries[j].id]); 171 + } 172 + } 173 + } 174 + 175 + return conflicts; 176 + } 177 + 178 + /** 179 + * Check if a specific combo would conflict with existing shortcuts. 180 + */ 181 + export function wouldConflict( 182 + state: ShortcutState, 183 + combo: KeyCombo, 184 + excludeId?: string, 185 + ): string | null { 186 + for (const shortcut of state.shortcuts.values()) { 187 + if (shortcut.id === excludeId) continue; 188 + if (combosEqual(activeCombo(shortcut), combo)) { 189 + return shortcut.id; 190 + } 191 + } 192 + return null; 193 + } 194 + 195 + /** 196 + * Get shortcuts grouped by category. 197 + */ 198 + export function shortcutsByCategory( 199 + state: ShortcutState, 200 + ): Map<string, Shortcut[]> { 201 + const categories = new Map<string, Shortcut[]>(); 202 + for (const shortcut of state.shortcuts.values()) { 203 + const list = categories.get(shortcut.category) || []; 204 + list.push(shortcut); 205 + categories.set(shortcut.category, list); 206 + } 207 + return categories; 208 + } 209 + 210 + /** 211 + * Serialize custom bindings for localStorage persistence. 212 + */ 213 + export function serializeBindings( 214 + state: ShortcutState, 215 + ): Record<string, KeyCombo> { 216 + const result: Record<string, KeyCombo> = {}; 217 + for (const [id, shortcut] of state.shortcuts) { 218 + if (shortcut.customCombo) { 219 + result[id] = shortcut.customCombo; 220 + } 221 + } 222 + return result; 223 + } 224 + 225 + /** 226 + * Restore custom bindings from serialized data. 227 + */ 228 + export function deserializeBindings( 229 + state: ShortcutState, 230 + data: Record<string, KeyCombo>, 231 + ): ShortcutState { 232 + let result = state; 233 + for (const [id, combo] of Object.entries(data)) { 234 + if (state.shortcuts.has(id)) { 235 + result = setCustomBinding(result, id, combo); 236 + } 237 + } 238 + return result; 239 + }
+222
src/sheets/array-formulas.ts
··· 1 + /** 2 + * Array Formulas — multi-cell formula expansion and modern functions. 3 + * 4 + * Pure logic module: spill range computation, array operations, modern functions. 5 + * Integration with the recalc engine handled separately. 6 + */ 7 + 8 + export interface SpillRange { 9 + /** Origin cell (where formula is entered) */ 10 + originRow: number; 11 + originCol: number; 12 + /** Dimensions of the spilled result */ 13 + rows: number; 14 + cols: number; 15 + } 16 + 17 + export interface ArrayResult { 18 + /** 2D array of values [row][col] */ 19 + values: unknown[][]; 20 + rows: number; 21 + cols: number; 22 + } 23 + 24 + /** 25 + * Create an array result from a 2D array of values. 26 + */ 27 + export function createArrayResult(values: unknown[][]): ArrayResult { 28 + if (values.length === 0) return { values: [], rows: 0, cols: 0 }; 29 + const rows = values.length; 30 + const cols = Math.max(...values.map(r => r.length)); 31 + // Normalize column count 32 + const normalized = values.map(r => { 33 + if (r.length < cols) { 34 + return [...r, ...Array(cols - r.length).fill(null)]; 35 + } 36 + return r; 37 + }); 38 + return { values: normalized, rows, cols }; 39 + } 40 + 41 + /** 42 + * Create a 1D array result from a flat array (column vector). 43 + */ 44 + export function columnVector(values: unknown[]): ArrayResult { 45 + return createArrayResult(values.map(v => [v])); 46 + } 47 + 48 + /** 49 + * Create a 1D array result from a flat array (row vector). 50 + */ 51 + export function rowVector(values: unknown[]): ArrayResult { 52 + return createArrayResult([values]); 53 + } 54 + 55 + /** 56 + * Compute the spill range for an array result placed at a given cell. 57 + */ 58 + export function computeSpillRange( 59 + originRow: number, 60 + originCol: number, 61 + result: ArrayResult, 62 + ): SpillRange { 63 + return { 64 + originRow, 65 + originCol, 66 + rows: result.rows, 67 + cols: result.cols, 68 + }; 69 + } 70 + 71 + /** 72 + * Get all cell positions covered by a spill range. 73 + */ 74 + export function spillCells(range: SpillRange): Array<{ row: number; col: number }> { 75 + const cells: Array<{ row: number; col: number }> = []; 76 + for (let r = 0; r < range.rows; r++) { 77 + for (let c = 0; c < range.cols; c++) { 78 + cells.push({ row: range.originRow + r, col: range.originCol + c }); 79 + } 80 + } 81 + return cells; 82 + } 83 + 84 + /** 85 + * Check if a cell position falls within a spill range. 86 + */ 87 + export function isInSpillRange( 88 + row: number, 89 + col: number, 90 + range: SpillRange, 91 + ): boolean { 92 + return ( 93 + row >= range.originRow && 94 + row < range.originRow + range.rows && 95 + col >= range.originCol && 96 + col < range.originCol + range.cols 97 + ); 98 + } 99 + 100 + /** 101 + * Check if two spill ranges overlap (collision detection). 102 + */ 103 + export function spillRangesOverlap(a: SpillRange, b: SpillRange): boolean { 104 + return !( 105 + a.originCol + a.cols <= b.originCol || 106 + b.originCol + b.cols <= a.originCol || 107 + a.originRow + a.rows <= b.originRow || 108 + b.originRow + b.rows <= a.originRow 109 + ); 110 + } 111 + 112 + /** 113 + * UNIQUE — remove duplicate rows from an array. 114 + */ 115 + export function arrayUnique(result: ArrayResult): ArrayResult { 116 + const seen = new Set<string>(); 117 + const unique: unknown[][] = []; 118 + for (const row of result.values) { 119 + const key = JSON.stringify(row); 120 + if (!seen.has(key)) { 121 + seen.add(key); 122 + unique.push(row); 123 + } 124 + } 125 + return createArrayResult(unique); 126 + } 127 + 128 + /** 129 + * SORT — sort an array by a column. 130 + */ 131 + export function arraySort( 132 + result: ArrayResult, 133 + sortCol = 0, 134 + ascending = true, 135 + ): ArrayResult { 136 + const sorted = [...result.values].sort((a, b) => { 137 + const va = a[sortCol]; 138 + const vb = b[sortCol]; 139 + if (typeof va === 'number' && typeof vb === 'number') { 140 + return ascending ? va - vb : vb - va; 141 + } 142 + const sa = String(va ?? ''); 143 + const sb = String(vb ?? ''); 144 + return ascending ? sa.localeCompare(sb) : sb.localeCompare(sa); 145 + }); 146 + return createArrayResult(sorted); 147 + } 148 + 149 + /** 150 + * FILTER — filter rows where a column meets a condition. 151 + */ 152 + export function arrayFilter( 153 + result: ArrayResult, 154 + filterCol: number, 155 + predicate: (value: unknown) => boolean, 156 + ): ArrayResult { 157 + const filtered = result.values.filter(row => predicate(row[filterCol])); 158 + return createArrayResult(filtered); 159 + } 160 + 161 + /** 162 + * TRANSPOSE — swap rows and columns. 163 + */ 164 + export function arrayTranspose(result: ArrayResult): ArrayResult { 165 + if (result.rows === 0 || result.cols === 0) return createArrayResult([]); 166 + const transposed: unknown[][] = []; 167 + for (let c = 0; c < result.cols; c++) { 168 + const row: unknown[] = []; 169 + for (let r = 0; r < result.rows; r++) { 170 + row.push(result.values[r][c]); 171 + } 172 + transposed.push(row); 173 + } 174 + return createArrayResult(transposed); 175 + } 176 + 177 + /** 178 + * SEQUENCE — generate a sequence of numbers. 179 + */ 180 + export function arraySequence( 181 + rows: number, 182 + cols = 1, 183 + start = 1, 184 + step = 1, 185 + ): ArrayResult { 186 + const values: unknown[][] = []; 187 + let current = start; 188 + for (let r = 0; r < rows; r++) { 189 + const row: unknown[] = []; 190 + for (let c = 0; c < cols; c++) { 191 + row.push(current); 192 + current += step; 193 + } 194 + values.push(row); 195 + } 196 + return createArrayResult(values); 197 + } 198 + 199 + /** 200 + * FLATTEN — convert a 2D array to a single column. 201 + */ 202 + export function arrayFlatten(result: ArrayResult): ArrayResult { 203 + const flat: unknown[] = []; 204 + for (const row of result.values) { 205 + for (const val of row) { 206 + flat.push(val); 207 + } 208 + } 209 + return columnVector(flat); 210 + } 211 + 212 + /** 213 + * Get a value from an array result at a specific position. 214 + */ 215 + export function getArrayValue( 216 + result: ArrayResult, 217 + row: number, 218 + col: number, 219 + ): unknown { 220 + if (row < 0 || row >= result.rows || col < 0 || col >= result.cols) return null; 221 + return result.values[row][col]; 222 + }
+208
tests/array-formulas.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + createArrayResult, 4 + columnVector, 5 + rowVector, 6 + computeSpillRange, 7 + spillCells, 8 + isInSpillRange, 9 + spillRangesOverlap, 10 + arrayUnique, 11 + arraySort, 12 + arrayFilter, 13 + arrayTranspose, 14 + arraySequence, 15 + arrayFlatten, 16 + getArrayValue, 17 + } from '../src/sheets/array-formulas.js'; 18 + 19 + describe('createArrayResult', () => { 20 + it('creates from 2D array', () => { 21 + const r = createArrayResult([[1, 2], [3, 4]]); 22 + expect(r.rows).toBe(2); 23 + expect(r.cols).toBe(2); 24 + expect(r.values).toEqual([[1, 2], [3, 4]]); 25 + }); 26 + 27 + it('normalizes ragged arrays', () => { 28 + const r = createArrayResult([[1, 2, 3], [4]]); 29 + expect(r.cols).toBe(3); 30 + expect(r.values[1]).toEqual([4, null, null]); 31 + }); 32 + 33 + it('handles empty array', () => { 34 + const r = createArrayResult([]); 35 + expect(r.rows).toBe(0); 36 + expect(r.cols).toBe(0); 37 + }); 38 + }); 39 + 40 + describe('columnVector', () => { 41 + it('creates column from flat array', () => { 42 + const r = columnVector([1, 2, 3]); 43 + expect(r.rows).toBe(3); 44 + expect(r.cols).toBe(1); 45 + expect(r.values).toEqual([[1], [2], [3]]); 46 + }); 47 + }); 48 + 49 + describe('rowVector', () => { 50 + it('creates row from flat array', () => { 51 + const r = rowVector([1, 2, 3]); 52 + expect(r.rows).toBe(1); 53 + expect(r.cols).toBe(3); 54 + expect(r.values).toEqual([[1, 2, 3]]); 55 + }); 56 + }); 57 + 58 + describe('computeSpillRange', () => { 59 + it('computes range from origin and result', () => { 60 + const result = createArrayResult([[1, 2], [3, 4], [5, 6]]); 61 + const range = computeSpillRange(2, 3, result); 62 + expect(range).toEqual({ originRow: 2, originCol: 3, rows: 3, cols: 2 }); 63 + }); 64 + }); 65 + 66 + describe('spillCells', () => { 67 + it('lists all cells in range', () => { 68 + const range = { originRow: 1, originCol: 1, rows: 2, cols: 3 }; 69 + const cells = spillCells(range); 70 + expect(cells).toHaveLength(6); 71 + expect(cells[0]).toEqual({ row: 1, col: 1 }); 72 + expect(cells[5]).toEqual({ row: 2, col: 3 }); 73 + }); 74 + }); 75 + 76 + describe('isInSpillRange', () => { 77 + const range = { originRow: 2, originCol: 3, rows: 3, cols: 2 }; 78 + 79 + it('returns true for cell inside range', () => { 80 + expect(isInSpillRange(3, 4, range)).toBe(true); 81 + }); 82 + 83 + it('returns true for origin cell', () => { 84 + expect(isInSpillRange(2, 3, range)).toBe(true); 85 + }); 86 + 87 + it('returns false for cell outside range', () => { 88 + expect(isInSpillRange(1, 3, range)).toBe(false); 89 + expect(isInSpillRange(5, 3, range)).toBe(false); 90 + expect(isInSpillRange(2, 5, range)).toBe(false); 91 + }); 92 + }); 93 + 94 + describe('spillRangesOverlap', () => { 95 + it('detects overlapping ranges', () => { 96 + const a = { originRow: 0, originCol: 0, rows: 3, cols: 3 }; 97 + const b = { originRow: 2, originCol: 2, rows: 2, cols: 2 }; 98 + expect(spillRangesOverlap(a, b)).toBe(true); 99 + }); 100 + 101 + it('detects non-overlapping ranges', () => { 102 + const a = { originRow: 0, originCol: 0, rows: 2, cols: 2 }; 103 + const b = { originRow: 3, originCol: 3, rows: 2, cols: 2 }; 104 + expect(spillRangesOverlap(a, b)).toBe(false); 105 + }); 106 + 107 + it('detects adjacent (non-overlapping)', () => { 108 + const a = { originRow: 0, originCol: 0, rows: 2, cols: 2 }; 109 + const b = { originRow: 0, originCol: 2, rows: 2, cols: 2 }; 110 + expect(spillRangesOverlap(a, b)).toBe(false); 111 + }); 112 + }); 113 + 114 + describe('arrayUnique', () => { 115 + it('removes duplicate rows', () => { 116 + const r = createArrayResult([[1, 'a'], [2, 'b'], [1, 'a'], [3, 'c']]); 117 + const u = arrayUnique(r); 118 + expect(u.rows).toBe(3); 119 + expect(u.values).toEqual([[1, 'a'], [2, 'b'], [3, 'c']]); 120 + }); 121 + }); 122 + 123 + describe('arraySort', () => { 124 + it('sorts by first column ascending', () => { 125 + const r = createArrayResult([[3, 'c'], [1, 'a'], [2, 'b']]); 126 + const s = arraySort(r, 0, true); 127 + expect(s.values).toEqual([[1, 'a'], [2, 'b'], [3, 'c']]); 128 + }); 129 + 130 + it('sorts descending', () => { 131 + const r = createArrayResult([[1], [3], [2]]); 132 + const s = arraySort(r, 0, false); 133 + expect(s.values).toEqual([[3], [2], [1]]); 134 + }); 135 + 136 + it('sorts strings', () => { 137 + const r = createArrayResult([['banana'], ['apple'], ['cherry']]); 138 + const s = arraySort(r, 0, true); 139 + expect(s.values[0][0]).toBe('apple'); 140 + }); 141 + }); 142 + 143 + describe('arrayFilter', () => { 144 + it('filters rows by predicate', () => { 145 + const r = createArrayResult([[1, 'a'], [2, 'b'], [3, 'c']]); 146 + const f = arrayFilter(r, 0, v => (v as number) > 1); 147 + expect(f.rows).toBe(2); 148 + expect(f.values).toEqual([[2, 'b'], [3, 'c']]); 149 + }); 150 + 151 + it('returns empty when nothing matches', () => { 152 + const r = createArrayResult([[1], [2]]); 153 + const f = arrayFilter(r, 0, v => (v as number) > 10); 154 + expect(f.rows).toBe(0); 155 + }); 156 + }); 157 + 158 + describe('arrayTranspose', () => { 159 + it('swaps rows and columns', () => { 160 + const r = createArrayResult([[1, 2, 3], [4, 5, 6]]); 161 + const t = arrayTranspose(r); 162 + expect(t.rows).toBe(3); 163 + expect(t.cols).toBe(2); 164 + expect(t.values).toEqual([[1, 4], [2, 5], [3, 6]]); 165 + }); 166 + 167 + it('handles empty array', () => { 168 + const t = arrayTranspose(createArrayResult([])); 169 + expect(t.rows).toBe(0); 170 + }); 171 + }); 172 + 173 + describe('arraySequence', () => { 174 + it('generates sequence', () => { 175 + const r = arraySequence(3, 1, 1, 1); 176 + expect(r.values).toEqual([[1], [2], [3]]); 177 + }); 178 + 179 + it('generates 2D sequence', () => { 180 + const r = arraySequence(2, 3, 10, 5); 181 + expect(r.values).toEqual([[10, 15, 20], [25, 30, 35]]); 182 + }); 183 + }); 184 + 185 + describe('arrayFlatten', () => { 186 + it('flattens 2D to column', () => { 187 + const r = createArrayResult([[1, 2], [3, 4]]); 188 + const f = arrayFlatten(r); 189 + expect(f.rows).toBe(4); 190 + expect(f.cols).toBe(1); 191 + expect(f.values).toEqual([[1], [2], [3], [4]]); 192 + }); 193 + }); 194 + 195 + describe('getArrayValue', () => { 196 + it('returns value at position', () => { 197 + const r = createArrayResult([[1, 2], [3, 4]]); 198 + expect(getArrayValue(r, 0, 0)).toBe(1); 199 + expect(getArrayValue(r, 1, 1)).toBe(4); 200 + }); 201 + 202 + it('returns null for out-of-bounds', () => { 203 + const r = createArrayResult([[1]]); 204 + expect(getArrayValue(r, 5, 0)).toBeNull(); 205 + expect(getArrayValue(r, 0, 5)).toBeNull(); 206 + expect(getArrayValue(r, -1, 0)).toBeNull(); 207 + }); 208 + });
+232
tests/global-find.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + createFindState, 4 + setQuery, 5 + toggleCaseSensitive, 6 + toggleRegex, 7 + toggleWholeWord, 8 + buildSearchPattern, 9 + escapeRegex, 10 + findInDocument, 11 + searchAcrossDocuments, 12 + setResults, 13 + nextResult, 14 + prevResult, 15 + selectedResult, 16 + resultCountByDocument, 17 + totalResultCount, 18 + } from '../src/lib/global-find.js'; 19 + 20 + describe('createFindState', () => { 21 + it('creates empty state', () => { 22 + const state = createFindState(); 23 + expect(state.query).toBe(''); 24 + expect(state.results).toEqual([]); 25 + expect(state.selectedIndex).toBe(-1); 26 + expect(state.caseSensitive).toBe(false); 27 + expect(state.useRegex).toBe(false); 28 + expect(state.wholeWord).toBe(false); 29 + }); 30 + }); 31 + 32 + describe('setQuery', () => { 33 + it('sets query and resets selection', () => { 34 + let state = createFindState(); 35 + state = { ...state, selectedIndex: 3 }; 36 + state = setQuery(state, 'hello'); 37 + expect(state.query).toBe('hello'); 38 + expect(state.selectedIndex).toBe(-1); 39 + }); 40 + }); 41 + 42 + describe('toggles', () => { 43 + it('toggleCaseSensitive', () => { 44 + let state = createFindState(); 45 + state = toggleCaseSensitive(state); 46 + expect(state.caseSensitive).toBe(true); 47 + state = toggleCaseSensitive(state); 48 + expect(state.caseSensitive).toBe(false); 49 + }); 50 + 51 + it('toggleRegex', () => { 52 + let state = createFindState(); 53 + state = toggleRegex(state); 54 + expect(state.useRegex).toBe(true); 55 + }); 56 + 57 + it('toggleWholeWord', () => { 58 + let state = createFindState(); 59 + state = toggleWholeWord(state); 60 + expect(state.wholeWord).toBe(true); 61 + }); 62 + }); 63 + 64 + describe('escapeRegex', () => { 65 + it('escapes special characters', () => { 66 + expect(escapeRegex('a.b*c')).toBe('a\\.b\\*c'); 67 + expect(escapeRegex('(test)')).toBe('\\(test\\)'); 68 + }); 69 + }); 70 + 71 + describe('buildSearchPattern', () => { 72 + it('returns null for empty query', () => { 73 + expect(buildSearchPattern(createFindState())).toBeNull(); 74 + }); 75 + 76 + it('builds case-insensitive pattern by default', () => { 77 + let state = setQuery(createFindState(), 'hello'); 78 + const re = buildSearchPattern(state)!; 79 + expect(re.flags).toContain('i'); 80 + expect(re.test('Hello World')).toBe(true); 81 + }); 82 + 83 + it('builds case-sensitive pattern', () => { 84 + let state = setQuery(createFindState(), 'hello'); 85 + state = toggleCaseSensitive(state); 86 + const re = buildSearchPattern(state)!; 87 + expect(re.flags).not.toContain('i'); 88 + expect(re.test('Hello')).toBe(false); 89 + expect(re.test('hello')).toBe(true); 90 + }); 91 + 92 + it('supports regex mode', () => { 93 + let state = setQuery(createFindState(), '\\d+'); 94 + state = toggleRegex(state); 95 + const re = buildSearchPattern(state)!; 96 + expect(re.test('abc123')).toBe(true); 97 + }); 98 + 99 + it('returns null for invalid regex', () => { 100 + let state = setQuery(createFindState(), '[invalid'); 101 + state = toggleRegex(state); 102 + expect(buildSearchPattern(state)).toBeNull(); 103 + }); 104 + 105 + it('supports whole word mode', () => { 106 + let state = setQuery(createFindState(), 'cat'); 107 + state = toggleWholeWord(state); 108 + const re = buildSearchPattern(state)!; 109 + expect(re.test('the cat sat')).toBe(true); 110 + expect(re.test('concatenate')).toBe(false); 111 + }); 112 + }); 113 + 114 + describe('findInDocument', () => { 115 + it('finds matches with context', () => { 116 + const pattern = /hello/gi; 117 + const matches = findInDocument('d1', 'Doc', 'Say hello world. Hello again.', pattern); 118 + expect(matches).toHaveLength(2); 119 + expect(matches[0].documentId).toBe('d1'); 120 + expect(matches[0].location).toBe('Line 1'); 121 + }); 122 + 123 + it('returns empty for no matches', () => { 124 + const pattern = /xyz/gi; 125 + expect(findInDocument('d1', 'Doc', 'Hello world', pattern)).toEqual([]); 126 + }); 127 + 128 + it('computes line numbers', () => { 129 + const pattern = /target/gi; 130 + const matches = findInDocument('d1', 'Doc', 'line1\nline2\ntarget here', pattern); 131 + expect(matches[0].location).toBe('Line 3'); 132 + }); 133 + }); 134 + 135 + describe('searchAcrossDocuments', () => { 136 + const docs = [ 137 + { id: 'd1', name: 'Budget Report', content: 'The budget is over budget limit.' }, 138 + { id: 'd2', name: 'Meeting Notes', content: 'Discussed the budget allocation.' }, 139 + { id: 'd3', name: 'Todo List', content: 'Nothing relevant here.' }, 140 + ]; 141 + 142 + it('searches across all documents', () => { 143 + const state = setQuery(createFindState(), 'budget'); 144 + const results = searchAcrossDocuments(state, docs); 145 + expect(results.length).toBeGreaterThanOrEqual(3); 146 + }); 147 + 148 + it('boosts score for document name matches', () => { 149 + const state = setQuery(createFindState(), 'budget'); 150 + const results = searchAcrossDocuments(state, docs); 151 + const d1Results = results.filter(r => r.documentId === 'd1'); 152 + const d2Results = results.filter(r => r.documentId === 'd2'); 153 + expect(d1Results[0].score).toBeGreaterThan(d2Results[0].score); 154 + }); 155 + 156 + it('returns empty for no matches', () => { 157 + const state = setQuery(createFindState(), 'xyzzy'); 158 + expect(searchAcrossDocuments(state, docs)).toEqual([]); 159 + }); 160 + }); 161 + 162 + describe('navigation', () => { 163 + const makeState = () => { 164 + let state = createFindState(); 165 + const results = [ 166 + { documentId: 'd1', documentName: 'A', location: 'L1', snippet: 'a', matchStart: 0, matchEnd: 1, score: 1 }, 167 + { documentId: 'd2', documentName: 'B', location: 'L2', snippet: 'b', matchStart: 0, matchEnd: 1, score: 1 }, 168 + { documentId: 'd3', documentName: 'C', location: 'L3', snippet: 'c', matchStart: 0, matchEnd: 1, score: 1 }, 169 + ]; 170 + return setResults(state, results); 171 + }; 172 + 173 + it('setResults selects first result', () => { 174 + const state = makeState(); 175 + expect(state.selectedIndex).toBe(0); 176 + }); 177 + 178 + it('nextResult advances', () => { 179 + let state = makeState(); 180 + state = nextResult(state); 181 + expect(state.selectedIndex).toBe(1); 182 + }); 183 + 184 + it('nextResult wraps around', () => { 185 + let state = makeState(); 186 + state = nextResult(nextResult(nextResult(state))); 187 + expect(state.selectedIndex).toBe(0); 188 + }); 189 + 190 + it('prevResult goes back', () => { 191 + let state = makeState(); 192 + state = nextResult(state); 193 + state = prevResult(state); 194 + expect(state.selectedIndex).toBe(0); 195 + }); 196 + 197 + it('prevResult wraps to end', () => { 198 + let state = makeState(); 199 + state = prevResult(state); 200 + expect(state.selectedIndex).toBe(2); 201 + }); 202 + 203 + it('selectedResult returns current', () => { 204 + const state = makeState(); 205 + expect(selectedResult(state)!.documentId).toBe('d1'); 206 + }); 207 + 208 + it('selectedResult returns null for empty', () => { 209 + expect(selectedResult(createFindState())).toBeNull(); 210 + }); 211 + }); 212 + 213 + describe('resultCountByDocument', () => { 214 + it('counts per document', () => { 215 + const results = [ 216 + { documentId: 'd1', documentName: 'A', location: '', snippet: '', matchStart: 0, matchEnd: 1, score: 1 }, 217 + { documentId: 'd1', documentName: 'A', location: '', snippet: '', matchStart: 0, matchEnd: 1, score: 1 }, 218 + { documentId: 'd2', documentName: 'B', location: '', snippet: '', matchStart: 0, matchEnd: 1, score: 1 }, 219 + ]; 220 + const counts = resultCountByDocument(results); 221 + expect(counts.get('d1')).toBe(2); 222 + expect(counts.get('d2')).toBe(1); 223 + }); 224 + }); 225 + 226 + describe('totalResultCount', () => { 227 + it('returns total count', () => { 228 + let state = createFindState(); 229 + state = { ...state, results: [{ documentId: 'd1', documentName: 'A', location: '', snippet: '', matchStart: 0, matchEnd: 1, score: 1 }] }; 230 + expect(totalResultCount(state)).toBe(1); 231 + }); 232 + });
+247
tests/keyboard-shortcuts.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + keyCombo, 4 + formatCombo, 5 + combosEqual, 6 + createShortcutState, 7 + registerShortcut, 8 + setCustomBinding, 9 + resetBinding, 10 + resetAllBindings, 11 + activeCombo, 12 + matchShortcut, 13 + findConflicts, 14 + wouldConflict, 15 + shortcutsByCategory, 16 + serializeBindings, 17 + deserializeBindings, 18 + } from '../src/lib/keyboard-shortcuts.js'; 19 + 20 + const ctrlS = keyCombo('s', { ctrl: true }); 21 + const ctrlShiftS = keyCombo('s', { ctrl: true, shift: true }); 22 + const metaK = keyCombo('k', { meta: true }); 23 + 24 + describe('keyCombo', () => { 25 + it('creates combo with defaults', () => { 26 + const combo = keyCombo('a'); 27 + expect(combo.key).toBe('a'); 28 + expect(combo.ctrl).toBe(false); 29 + expect(combo.shift).toBe(false); 30 + expect(combo.alt).toBe(false); 31 + expect(combo.meta).toBe(false); 32 + }); 33 + 34 + it('lowercases key', () => { 35 + expect(keyCombo('A').key).toBe('a'); 36 + }); 37 + 38 + it('accepts modifiers', () => { 39 + const combo = keyCombo('s', { ctrl: true, shift: true }); 40 + expect(combo.ctrl).toBe(true); 41 + expect(combo.shift).toBe(true); 42 + }); 43 + }); 44 + 45 + describe('formatCombo', () => { 46 + it('formats simple combo', () => { 47 + expect(formatCombo(ctrlS)).toBe('Ctrl+S'); 48 + }); 49 + 50 + it('formats multi-modifier combo', () => { 51 + expect(formatCombo(ctrlShiftS)).toBe('Ctrl+Shift+S'); 52 + }); 53 + 54 + it('formats meta key', () => { 55 + expect(formatCombo(metaK)).toBe('Cmd+K'); 56 + }); 57 + 58 + it('capitalizes named keys', () => { 59 + expect(formatCombo(keyCombo('escape'))).toBe('Escape'); 60 + }); 61 + }); 62 + 63 + describe('combosEqual', () => { 64 + it('returns true for matching combos', () => { 65 + expect(combosEqual(ctrlS, keyCombo('s', { ctrl: true }))).toBe(true); 66 + }); 67 + 68 + it('returns false for different keys', () => { 69 + expect(combosEqual(ctrlS, keyCombo('a', { ctrl: true }))).toBe(false); 70 + }); 71 + 72 + it('returns false for different modifiers', () => { 73 + expect(combosEqual(ctrlS, ctrlShiftS)).toBe(false); 74 + }); 75 + }); 76 + 77 + describe('registerShortcut', () => { 78 + it('adds shortcut to state', () => { 79 + let state = createShortcutState(); 80 + state = registerShortcut(state, 'save', 'Save', 'Save document', ctrlS); 81 + expect(state.shortcuts.size).toBe(1); 82 + expect(state.shortcuts.get('save')!.label).toBe('Save'); 83 + expect(state.shortcuts.get('save')!.category).toBe('General'); 84 + }); 85 + 86 + it('accepts custom category', () => { 87 + let state = createShortcutState(); 88 + state = registerShortcut(state, 'save', 'Save', 'Save', ctrlS, 'File'); 89 + expect(state.shortcuts.get('save')!.category).toBe('File'); 90 + }); 91 + }); 92 + 93 + describe('setCustomBinding', () => { 94 + it('overrides default binding', () => { 95 + let state = createShortcutState(); 96 + state = registerShortcut(state, 'save', 'Save', 'Save', ctrlS); 97 + state = setCustomBinding(state, 'save', ctrlShiftS); 98 + expect(state.shortcuts.get('save')!.customCombo).toEqual(ctrlShiftS); 99 + }); 100 + 101 + it('ignores unknown shortcut ID', () => { 102 + const state = createShortcutState(); 103 + expect(setCustomBinding(state, 'unknown', ctrlS)).toBe(state); 104 + }); 105 + }); 106 + 107 + describe('resetBinding', () => { 108 + it('clears custom binding', () => { 109 + let state = createShortcutState(); 110 + state = registerShortcut(state, 'save', 'Save', 'Save', ctrlS); 111 + state = setCustomBinding(state, 'save', ctrlShiftS); 112 + state = resetBinding(state, 'save'); 113 + expect(state.shortcuts.get('save')!.customCombo).toBeNull(); 114 + }); 115 + 116 + it('returns same state if no custom binding', () => { 117 + let state = createShortcutState(); 118 + state = registerShortcut(state, 'save', 'Save', 'Save', ctrlS); 119 + expect(resetBinding(state, 'save')).toBe(state); 120 + }); 121 + }); 122 + 123 + describe('resetAllBindings', () => { 124 + it('clears all custom bindings', () => { 125 + let state = createShortcutState(); 126 + state = registerShortcut(state, 'save', 'Save', 'Save', ctrlS); 127 + state = registerShortcut(state, 'find', 'Find', 'Find', metaK); 128 + state = setCustomBinding(state, 'save', ctrlShiftS); 129 + state = resetAllBindings(state); 130 + for (const s of state.shortcuts.values()) { 131 + expect(s.customCombo).toBeNull(); 132 + } 133 + }); 134 + }); 135 + 136 + describe('activeCombo', () => { 137 + it('returns custom if set', () => { 138 + let state = createShortcutState(); 139 + state = registerShortcut(state, 'save', 'Save', 'Save', ctrlS); 140 + state = setCustomBinding(state, 'save', ctrlShiftS); 141 + expect(activeCombo(state.shortcuts.get('save')!)).toEqual(ctrlShiftS); 142 + }); 143 + 144 + it('returns default if no custom', () => { 145 + let state = createShortcutState(); 146 + state = registerShortcut(state, 'save', 'Save', 'Save', ctrlS); 147 + expect(activeCombo(state.shortcuts.get('save')!)).toEqual(ctrlS); 148 + }); 149 + }); 150 + 151 + describe('matchShortcut', () => { 152 + it('finds matching shortcut', () => { 153 + let state = createShortcutState(); 154 + state = registerShortcut(state, 'save', 'Save', 'Save', ctrlS); 155 + const match = matchShortcut(state, ctrlS); 156 + expect(match!.id).toBe('save'); 157 + }); 158 + 159 + it('returns null for no match', () => { 160 + let state = createShortcutState(); 161 + state = registerShortcut(state, 'save', 'Save', 'Save', ctrlS); 162 + expect(matchShortcut(state, metaK)).toBeNull(); 163 + }); 164 + 165 + it('matches custom binding', () => { 166 + let state = createShortcutState(); 167 + state = registerShortcut(state, 'save', 'Save', 'Save', ctrlS); 168 + state = setCustomBinding(state, 'save', metaK); 169 + expect(matchShortcut(state, metaK)!.id).toBe('save'); 170 + expect(matchShortcut(state, ctrlS)).toBeNull(); 171 + }); 172 + }); 173 + 174 + describe('findConflicts', () => { 175 + it('detects conflicting bindings', () => { 176 + let state = createShortcutState(); 177 + state = registerShortcut(state, 'save', 'Save', 'Save', ctrlS); 178 + state = registerShortcut(state, 'sync', 'Sync', 'Sync', metaK); 179 + state = setCustomBinding(state, 'sync', ctrlS); 180 + const conflicts = findConflicts(state); 181 + expect(conflicts).toHaveLength(1); 182 + expect(conflicts[0]).toContain('save'); 183 + expect(conflicts[0]).toContain('sync'); 184 + }); 185 + 186 + it('returns empty for no conflicts', () => { 187 + let state = createShortcutState(); 188 + state = registerShortcut(state, 'save', 'Save', 'Save', ctrlS); 189 + state = registerShortcut(state, 'find', 'Find', 'Find', metaK); 190 + expect(findConflicts(state)).toEqual([]); 191 + }); 192 + }); 193 + 194 + describe('wouldConflict', () => { 195 + it('detects potential conflict', () => { 196 + let state = createShortcutState(); 197 + state = registerShortcut(state, 'save', 'Save', 'Save', ctrlS); 198 + expect(wouldConflict(state, ctrlS)).toBe('save'); 199 + }); 200 + 201 + it('excludes self', () => { 202 + let state = createShortcutState(); 203 + state = registerShortcut(state, 'save', 'Save', 'Save', ctrlS); 204 + expect(wouldConflict(state, ctrlS, 'save')).toBeNull(); 205 + }); 206 + }); 207 + 208 + describe('shortcutsByCategory', () => { 209 + it('groups shortcuts by category', () => { 210 + let state = createShortcutState(); 211 + state = registerShortcut(state, 'save', 'Save', 'Save', ctrlS, 'File'); 212 + state = registerShortcut(state, 'find', 'Find', 'Find', metaK, 'Edit'); 213 + state = registerShortcut(state, 'saveas', 'Save As', 'Save As', ctrlShiftS, 'File'); 214 + const cats = shortcutsByCategory(state); 215 + expect(cats.get('File')).toHaveLength(2); 216 + expect(cats.get('Edit')).toHaveLength(1); 217 + }); 218 + }); 219 + 220 + describe('serialize/deserialize', () => { 221 + it('round-trips custom bindings', () => { 222 + let state = createShortcutState(); 223 + state = registerShortcut(state, 'save', 'Save', 'Save', ctrlS); 224 + state = registerShortcut(state, 'find', 'Find', 'Find', metaK); 225 + state = setCustomBinding(state, 'save', ctrlShiftS); 226 + 227 + const data = serializeBindings(state); 228 + expect(Object.keys(data)).toEqual(['save']); 229 + 230 + // Reset and restore 231 + let fresh = createShortcutState(); 232 + fresh = registerShortcut(fresh, 'save', 'Save', 'Save', ctrlS); 233 + fresh = registerShortcut(fresh, 'find', 'Find', 'Find', metaK); 234 + fresh = deserializeBindings(fresh, data); 235 + 236 + expect(fresh.shortcuts.get('save')!.customCombo).toEqual(ctrlShiftS); 237 + expect(fresh.shortcuts.get('find')!.customCombo).toBeNull(); 238 + }); 239 + 240 + it('ignores unknown IDs on deserialize', () => { 241 + let state = createShortcutState(); 242 + state = registerShortcut(state, 'save', 'Save', 'Save', ctrlS); 243 + const data = { unknown: metaK }; 244 + const result = deserializeBindings(state, data); 245 + expect(result.shortcuts.get('save')!.customCombo).toBeNull(); 246 + }); 247 + });