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(sheets): power formulas, recalc engine, and formula UX' (#47) from feat/sheets-power-formulas into main

scott 570aa0e5 401e9019

+4007 -24
+250 -1
src/css/app.css
··· 3721 3721 font-size: 0.9rem; 3722 3722 } 3723 3723 3724 - .formula-input { 3724 + .formula-input, 3725 + .formula-input-wrap { 3725 3726 min-width: 0; 3726 3727 flex: 1; 3727 3728 font-size: 0.9rem; ··· 4146 4147 4147 4148 [data-theme="dark"] .formula-autocomplete-item.selected { 4148 4149 background: var(--color-teal-light); 4150 + } 4151 + 4152 + /* ======================================================== 4153 + Formula Syntax Highlighting 4154 + ======================================================== */ 4155 + 4156 + /* Overlay container for formula bar highlighting */ 4157 + .formula-input-wrap { 4158 + position: relative; 4159 + flex: 1; 4160 + display: flex; 4161 + } 4162 + 4163 + .formula-input-wrap .formula-input { 4164 + /* Override flex:1 from base — the wrap handles flex now */ 4165 + flex: 1; 4166 + background: transparent; 4167 + position: relative; 4168 + z-index: 1; 4169 + /* Make the text invisible (the highlight layer shows it) when formula */ 4170 + caret-color: var(--color-text); 4171 + } 4172 + 4173 + .formula-input-wrap .formula-input:focus { 4174 + /* Keep focus ring on the input */ 4175 + border-color: var(--color-teal); 4176 + box-shadow: 0 0 0 2px var(--color-focus); 4177 + } 4178 + 4179 + .formula-highlight-layer { 4180 + position: absolute; 4181 + top: 0; 4182 + left: 0; 4183 + right: 0; 4184 + bottom: 0; 4185 + pointer-events: none; 4186 + font-family: var(--font-mono); 4187 + font-size: 0.85rem; 4188 + padding: 0.3rem 0.6rem; 4189 + overflow: hidden; 4190 + white-space: nowrap; 4191 + border: 1px solid transparent; 4192 + border-radius: var(--radius-sm); 4193 + z-index: 0; 4194 + color: transparent; 4195 + display: flex; 4196 + align-items: center; 4197 + } 4198 + 4199 + /* When highlight layer is active, make input text transparent 4200 + so highlighted text shows through */ 4201 + .formula-input-wrap .formula-input.formula-highlighting { 4202 + color: transparent; 4203 + } 4204 + 4205 + /* Token colors — Light theme */ 4206 + .formula-token-cell_ref { 4207 + color: oklch(0.55 0.2 250); 4208 + } 4209 + 4210 + .formula-token-function { 4211 + color: oklch(0.5 0.2 300); 4212 + } 4213 + 4214 + .formula-token-string { 4215 + color: oklch(0.5 0.15 150); 4216 + } 4217 + 4218 + .formula-token-number { 4219 + color: oklch(0.55 0.15 60); 4220 + } 4221 + 4222 + .formula-token-boolean { 4223 + color: oklch(0.5 0.2 300); 4224 + } 4225 + 4226 + .formula-token-operator { 4227 + color: var(--color-text); 4228 + } 4229 + 4230 + .formula-token-paren { 4231 + color: var(--color-text-muted); 4232 + } 4233 + 4234 + .formula-token-error { 4235 + color: oklch(0.55 0.2 25); 4236 + font-weight: 600; 4237 + } 4238 + 4239 + .formula-token-whitespace { 4240 + /* Preserve whitespace width */ 4241 + white-space: pre; 4242 + } 4243 + 4244 + .formula-token-identifier, 4245 + .formula-token-unknown { 4246 + color: var(--color-text); 4247 + } 4248 + 4249 + /* Token colors — Dark theme */ 4250 + [data-theme="dark"] .formula-token-cell_ref { 4251 + color: oklch(0.7 0.2 250); 4252 + } 4253 + 4254 + [data-theme="dark"] .formula-token-function { 4255 + color: oklch(0.7 0.15 300); 4256 + } 4257 + 4258 + [data-theme="dark"] .formula-token-string { 4259 + color: oklch(0.7 0.15 150); 4260 + } 4261 + 4262 + [data-theme="dark"] .formula-token-number { 4263 + color: oklch(0.7 0.15 60); 4264 + } 4265 + 4266 + [data-theme="dark"] .formula-token-boolean { 4267 + color: oklch(0.7 0.15 300); 4268 + } 4269 + 4270 + [data-theme="dark"] .formula-token-error { 4271 + color: oklch(0.65 0.2 25); 4272 + } 4273 + 4274 + /* Matching paren highlight */ 4275 + .formula-token-paren-match { 4276 + background: var(--color-teal-light); 4277 + border-radius: 2px; 4278 + } 4279 + 4280 + /* ======================================================== 4281 + Range Highlight Overlays 4282 + ======================================================== */ 4283 + 4284 + .range-highlight-overlay { 4285 + position: absolute; 4286 + inset: 0; 4287 + pointer-events: none; 4288 + z-index: 5; 4289 + box-sizing: border-box; 4290 + } 4291 + 4292 + /* ======================================================== 4293 + Formula Tooltip (Parameter Hints) 4294 + ======================================================== */ 4295 + 4296 + .formula-tooltip { 4297 + position: fixed; 4298 + z-index: 1100; 4299 + min-width: 200px; 4300 + max-width: 420px; 4301 + padding: 6px 10px; 4302 + background: var(--color-bg); 4303 + border: 1px solid var(--color-border-strong); 4304 + border-radius: var(--radius-md); 4305 + box-shadow: var(--shadow-md); 4306 + font-family: var(--font-body); 4307 + font-size: 0.78rem; 4308 + line-height: 1.45; 4309 + color: var(--color-text); 4310 + } 4311 + 4312 + .formula-tooltip-signature { 4313 + font-family: var(--font-mono); 4314 + font-size: 0.8rem; 4315 + margin-bottom: 3px; 4316 + white-space: nowrap; 4317 + overflow-x: auto; 4318 + } 4319 + 4320 + .formula-tooltip-fn { 4321 + color: oklch(0.5 0.2 300); 4322 + font-weight: 700; 4323 + } 4324 + 4325 + .formula-tooltip-param { 4326 + color: var(--color-text-muted); 4327 + } 4328 + 4329 + .formula-tooltip-param-active { 4330 + color: var(--color-text); 4331 + font-weight: 700; 4332 + text-decoration: underline; 4333 + text-decoration-color: var(--color-teal); 4334 + text-underline-offset: 2px; 4335 + } 4336 + 4337 + .formula-tooltip-optional { 4338 + color: var(--color-text-faint); 4339 + } 4340 + 4341 + .formula-tooltip-param-desc { 4342 + font-size: 0.75rem; 4343 + color: var(--color-text-muted); 4344 + margin-bottom: 2px; 4345 + padding-left: 2px; 4346 + } 4347 + 4348 + .formula-tooltip-param-desc strong { 4349 + color: var(--color-text); 4350 + font-weight: 600; 4351 + } 4352 + 4353 + .formula-tooltip-desc { 4354 + font-size: 0.72rem; 4355 + color: var(--color-text-faint); 4356 + border-top: 1px solid var(--color-border); 4357 + margin-top: 3px; 4358 + padding-top: 3px; 4359 + } 4360 + 4361 + /* Dark theme for tooltip */ 4362 + [data-theme="dark"] .formula-tooltip { 4363 + background: var(--color-surface); 4364 + border-color: var(--color-border-strong); 4365 + } 4366 + 4367 + [data-theme="dark"] .formula-tooltip-fn { 4368 + color: oklch(0.7 0.15 300); 4369 + } 4370 + 4371 + /* prefers-color-scheme fallback for syntax highlighting */ 4372 + @media (prefers-color-scheme: dark) { 4373 + :root:not([data-theme="light"]) .formula-token-cell_ref { 4374 + color: oklch(0.7 0.2 250); 4375 + } 4376 + :root:not([data-theme="light"]) .formula-token-function { 4377 + color: oklch(0.7 0.15 300); 4378 + } 4379 + :root:not([data-theme="light"]) .formula-token-string { 4380 + color: oklch(0.7 0.15 150); 4381 + } 4382 + :root:not([data-theme="light"]) .formula-token-number { 4383 + color: oklch(0.7 0.15 60); 4384 + } 4385 + :root:not([data-theme="light"]) .formula-token-boolean { 4386 + color: oklch(0.7 0.15 300); 4387 + } 4388 + :root:not([data-theme="light"]) .formula-token-error { 4389 + color: oklch(0.65 0.2 25); 4390 + } 4391 + :root:not([data-theme="light"]) .formula-tooltip { 4392 + background: var(--color-surface); 4393 + border-color: var(--color-border-strong); 4394 + } 4395 + :root:not([data-theme="light"]) .formula-tooltip-fn { 4396 + color: oklch(0.7 0.15 300); 4397 + } 4149 4398 } 4150 4399 4151 4400 /* ========================================================
+12
src/sheets/formula-autocomplete.js
··· 66 66 // Lookup 67 67 { name: 'VLOOKUP', signature: 'VLOOKUP(lookup_value, table_array, col_index, [range_lookup])' }, 68 68 { name: 'HLOOKUP', signature: 'HLOOKUP(lookup_value, table_array, row_index, [range_lookup])' }, 69 + { name: 'XLOOKUP', signature: 'XLOOKUP(lookup_value, lookup_array, return_array, [if_not_found], [match_mode], [search_mode])' }, 69 70 { name: 'INDEX', signature: 'INDEX(array, row_num, [col_num])' }, 70 71 { name: 'MATCH', signature: 'MATCH(lookup_value, lookup_array, [match_type])' }, 71 72 72 73 // Conditional 73 74 { name: 'SUMIF', signature: 'SUMIF(range, criteria, [sum_range])' }, 75 + { name: 'SUMIFS', signature: 'SUMIFS(sum_range, criteria_range1, criteria1, [criteria_range2, criteria2], ...)' }, 74 76 { name: 'COUNTIF', signature: 'COUNTIF(range, criteria)' }, 77 + { name: 'COUNTIFS', signature: 'COUNTIFS(criteria_range1, criteria1, [criteria_range2, criteria2], ...)' }, 75 78 { name: 'AVERAGEIF', signature: 'AVERAGEIF(range, criteria, [average_range])' }, 79 + { name: 'AVERAGEIFS', signature: 'AVERAGEIFS(average_range, criteria_range1, criteria1, [criteria_range2, criteria2], ...)' }, 80 + 81 + // Text (additional) 82 + { name: 'TEXTJOIN', signature: 'TEXTJOIN(delimiter, ignore_empty, range1, [range2], ...)' }, 83 + { name: 'CONCAT', signature: 'CONCAT(range1, [range2], ...)' }, 84 + 85 + // Logical (additional) 86 + { name: 'SWITCH', signature: 'SWITCH(expression, case1, value1, [case2, value2, ...], [default])' }, 87 + { name: 'LET', signature: 'LET(name1, value1, [name2, value2, ...], calculation)' }, 76 88 ]; 77 89 78 90 /**
+266
src/sheets/formula-highlighter.js
··· 1 + /** 2 + * Formula Bar Syntax Highlighting (Chainlink #94). 3 + * 4 + * Tokenizes formula strings for display with colored spans. 5 + * Uses position-preserving tokenization so the highlighted output 6 + * maps exactly back to the original text. 7 + */ 8 + 9 + // Known error values in spreadsheets 10 + const ERROR_PATTERN = /^#(REF!|N\/A|VALUE!|ERROR!|NAME\?|NULL!|NUM!|DIV\/0!)/; 11 + 12 + // Known function names (uppercase) — used to distinguish functions from cell refs 13 + const KNOWN_FUNCTIONS = new Set([ 14 + 'SUM', 'AVERAGE', 'COUNT', 'COUNTA', 'MIN', 'MAX', 'MEDIAN', 'STDEV', 15 + 'ABS', 'ROUND', 'ROUNDUP', 'ROUNDDOWN', 'INT', 'MOD', 'POWER', 'SQRT', 16 + 'LOG', 'LN', 'EXP', 'PI', 'RAND', 17 + 'IF', 'AND', 'OR', 'NOT', 'IFERROR', 18 + 'CONCATENATE', 'LEN', 'LEFT', 'RIGHT', 'MID', 19 + 'UPPER', 'LOWER', 'TRIM', 'SUBSTITUTE', 'FIND', 'SEARCH', 20 + 'TEXT', 'VALUE', 21 + 'NOW', 'TODAY', 'DATE', 'YEAR', 'MONTH', 'DAY', 22 + 'VLOOKUP', 'HLOOKUP', 'INDEX', 'MATCH', 23 + 'SUMIF', 'COUNTIF', 'AVERAGEIF', 24 + ]); 25 + 26 + const CELL_REF_PATTERN = /^\$?[A-Z]{1,3}\$?\d+$/i; 27 + 28 + /** 29 + * Tokenize a formula string for syntax highlighting. 30 + * Returns tokens with original positions preserved so the highlighted 31 + * output reconstructs the exact formula text. 32 + * 33 + * @param {string} formula - The formula string (including leading '=') 34 + * @returns {Array<{text: string, type: string, start: number, end: number}>} 35 + */ 36 + export function tokenizeForHighlighting(formula) { 37 + const tokens = []; 38 + let i = 0; 39 + const s = formula; 40 + 41 + while (i < s.length) { 42 + const start = i; 43 + 44 + // Leading '=' operator 45 + if (i === 0 && s[i] === '=') { 46 + tokens.push({ text: '=', type: 'operator', start: 0, end: 1 }); 47 + i++; 48 + continue; 49 + } 50 + 51 + // Whitespace 52 + if (s[i] === ' ' || s[i] === '\t') { 53 + let ws = ''; 54 + while (i < s.length && (s[i] === ' ' || s[i] === '\t')) { 55 + ws += s[i++]; 56 + } 57 + tokens.push({ text: ws, type: 'whitespace', start, end: i }); 58 + continue; 59 + } 60 + 61 + // Error values: #REF!, #N/A, #VALUE!, etc. 62 + if (s[i] === '#') { 63 + const rest = s.slice(i); 64 + const m = rest.match(ERROR_PATTERN); 65 + if (m) { 66 + const errText = '#' + m[1]; 67 + tokens.push({ text: errText, type: 'error', start: i, end: i + errText.length }); 68 + i += errText.length; 69 + continue; 70 + } 71 + // Unknown # — just emit as operator 72 + tokens.push({ text: '#', type: 'operator', start: i, end: i + 1 }); 73 + i++; 74 + continue; 75 + } 76 + 77 + // String literal 78 + if (s[i] === '"') { 79 + let str = '"'; 80 + i++; 81 + while (i < s.length && s[i] !== '"') { 82 + if (s[i] === '\\' && i + 1 < s.length) { 83 + str += s[i++]; 84 + } 85 + str += s[i++]; 86 + } 87 + if (i < s.length) { 88 + str += '"'; 89 + i++; // skip closing quote 90 + } 91 + tokens.push({ text: str, type: 'string', start, end: i }); 92 + continue; 93 + } 94 + 95 + // Quoted sheet name: 'Sheet Name'!A1 96 + if (s[i] === "'") { 97 + let ref = "'"; 98 + i++; 99 + while (i < s.length && s[i] !== "'") { 100 + ref += s[i++]; 101 + } 102 + if (i < s.length) { 103 + ref += "'"; 104 + i++; // skip closing quote 105 + } 106 + // Expect ! after quoted sheet name 107 + if (i < s.length && s[i] === '!') { 108 + ref += '!'; 109 + i++; 110 + // Read the cell ref after ! 111 + while (i < s.length && /[A-Za-z0-9$:]/.test(s[i])) { 112 + ref += s[i++]; 113 + } 114 + } 115 + tokens.push({ text: ref, type: 'cell_ref', start, end: i }); 116 + continue; 117 + } 118 + 119 + // Number 120 + if (/[0-9]/.test(s[i]) || (s[i] === '.' && i + 1 < s.length && /[0-9]/.test(s[i + 1]))) { 121 + let num = ''; 122 + while (i < s.length && /[0-9.eE]/.test(s[i])) { 123 + num += s[i++]; 124 + // Handle sign in scientific notation 125 + if (i < s.length && /[eE]/.test(s[i - 1]) && (s[i] === '+' || s[i] === '-')) { 126 + num += s[i++]; 127 + } 128 + } 129 + tokens.push({ text: num, type: 'number', start, end: i }); 130 + continue; 131 + } 132 + 133 + // Word: could be function, cell ref, boolean, cross-sheet ref, or identifier 134 + if (/[A-Za-z$_]/.test(s[i])) { 135 + let word = ''; 136 + while (i < s.length && /[A-Za-z0-9$_.]/.test(s[i])) { 137 + word += s[i++]; 138 + } 139 + 140 + const upper = word.toUpperCase(); 141 + 142 + // Boolean 143 + if (upper === 'TRUE' || upper === 'FALSE') { 144 + tokens.push({ text: word, type: 'boolean', start, end: i }); 145 + continue; 146 + } 147 + 148 + // Cross-sheet ref: word!cellRef 149 + if (i < s.length && s[i] === '!') { 150 + let ref = word + '!'; 151 + i++; // skip ! 152 + while (i < s.length && /[A-Za-z0-9$:]/.test(s[i])) { 153 + ref += s[i++]; 154 + } 155 + tokens.push({ text: ref, type: 'cell_ref', start, end: i }); 156 + continue; 157 + } 158 + 159 + // Function: next non-space char is '(' 160 + let peek = i; 161 + while (peek < s.length && s[peek] === ' ') peek++; 162 + if (peek < s.length && s[peek] === '(' && KNOWN_FUNCTIONS.has(upper)) { 163 + tokens.push({ text: word, type: 'function', start, end: i }); 164 + continue; 165 + } 166 + 167 + // Cell reference pattern 168 + const stripped = word.replace(/\$/g, ''); 169 + if (CELL_REF_PATTERN.test(stripped)) { 170 + tokens.push({ text: word, type: 'cell_ref', start, end: i }); 171 + continue; 172 + } 173 + 174 + // Unknown identifier 175 + tokens.push({ text: word, type: 'identifier', start, end: i }); 176 + continue; 177 + } 178 + 179 + // Parentheses 180 + if (s[i] === '(' || s[i] === ')') { 181 + tokens.push({ text: s[i], type: 'paren', start: i, end: i + 1 }); 182 + i++; 183 + continue; 184 + } 185 + 186 + // Comma 187 + if (s[i] === ',') { 188 + tokens.push({ text: ',', type: 'operator', start: i, end: i + 1 }); 189 + i++; 190 + continue; 191 + } 192 + 193 + // Colon 194 + if (s[i] === ':') { 195 + tokens.push({ text: ':', type: 'operator', start: i, end: i + 1 }); 196 + i++; 197 + continue; 198 + } 199 + 200 + // Multi-character comparison operators 201 + if (s[i] === '<') { 202 + if (i + 1 < s.length && s[i + 1] === '=') { 203 + tokens.push({ text: '<=', type: 'operator', start: i, end: i + 2 }); 204 + i += 2; 205 + continue; 206 + } 207 + if (i + 1 < s.length && s[i + 1] === '>') { 208 + tokens.push({ text: '<>', type: 'operator', start: i, end: i + 2 }); 209 + i += 2; 210 + continue; 211 + } 212 + tokens.push({ text: '<', type: 'operator', start: i, end: i + 1 }); 213 + i++; 214 + continue; 215 + } 216 + 217 + if (s[i] === '>') { 218 + if (i + 1 < s.length && s[i + 1] === '=') { 219 + tokens.push({ text: '>=', type: 'operator', start: i, end: i + 2 }); 220 + i += 2; 221 + continue; 222 + } 223 + tokens.push({ text: '>', type: 'operator', start: i, end: i + 1 }); 224 + i++; 225 + continue; 226 + } 227 + 228 + // Single-character operators 229 + if ('+-*/^&='.includes(s[i])) { 230 + tokens.push({ text: s[i], type: 'operator', start: i, end: i + 1 }); 231 + i++; 232 + continue; 233 + } 234 + 235 + // Unknown character — skip 236 + tokens.push({ text: s[i], type: 'unknown', start: i, end: i + 1 }); 237 + i++; 238 + } 239 + 240 + return tokens; 241 + } 242 + 243 + /** 244 + * Escape HTML special characters for safe insertion. 245 + */ 246 + function escapeHtml(text) { 247 + return text 248 + .replace(/&/g, '&amp;') 249 + .replace(/</g, '&lt;') 250 + .replace(/>/g, '&gt;') 251 + .replace(/"/g, '&quot;'); 252 + } 253 + 254 + /** 255 + * Render highlighted formula tokens as an HTML string. 256 + * Each token is wrapped in a <span> with a class based on its type. 257 + * 258 + * @param {Array<{text: string, type: string, start: number, end: number}>} tokens 259 + * @returns {string} HTML string 260 + */ 261 + export function renderHighlightedFormula(tokens) { 262 + return tokens.map(t => { 263 + const escaped = escapeHtml(t.text); 264 + return `<span class="formula-token-${t.type}">${escaped}</span>`; 265 + }).join(''); 266 + }
+541
src/sheets/formula-tooltip.js
··· 1 + /** 2 + * Rich Formula Tooltips with Parameter Highlighting (Chainlink #93). 3 + * 4 + * When typing inside a function call, shows a tooltip with the full 5 + * function signature, highlighting the current parameter based on 6 + * cursor position (counting commas and parens). 7 + */ 8 + 9 + /** 10 + * Complete function metadata for all 57 supported functions. 11 + * Each entry has a description and per-parameter info. 12 + */ 13 + export const FUNCTION_METADATA = { 14 + // --- Math & Stats --- 15 + SUM: { 16 + desc: 'Adds all numbers in a range', 17 + params: [ 18 + { name: 'range1', desc: 'First range to sum', required: true }, 19 + { name: 'range2', desc: 'Additional ranges to sum', required: false }, 20 + ], 21 + }, 22 + AVERAGE: { 23 + desc: 'Returns the arithmetic mean of the arguments', 24 + params: [ 25 + { name: 'range1', desc: 'First range to average', required: true }, 26 + { name: 'range2', desc: 'Additional ranges', required: false }, 27 + ], 28 + }, 29 + COUNT: { 30 + desc: 'Counts the number of cells that contain numbers', 31 + params: [ 32 + { name: 'range1', desc: 'First range to count', required: true }, 33 + { name: 'range2', desc: 'Additional ranges', required: false }, 34 + ], 35 + }, 36 + COUNTA: { 37 + desc: 'Counts the number of non-empty cells', 38 + params: [ 39 + { name: 'range1', desc: 'First range to count', required: true }, 40 + { name: 'range2', desc: 'Additional ranges', required: false }, 41 + ], 42 + }, 43 + MIN: { 44 + desc: 'Returns the smallest value in a set of numbers', 45 + params: [ 46 + { name: 'range1', desc: 'First range to evaluate', required: true }, 47 + { name: 'range2', desc: 'Additional ranges', required: false }, 48 + ], 49 + }, 50 + MAX: { 51 + desc: 'Returns the largest value in a set of numbers', 52 + params: [ 53 + { name: 'range1', desc: 'First range to evaluate', required: true }, 54 + { name: 'range2', desc: 'Additional ranges', required: false }, 55 + ], 56 + }, 57 + MEDIAN: { 58 + desc: 'Returns the median of the given numbers', 59 + params: [ 60 + { name: 'range1', desc: 'First range of values', required: true }, 61 + { name: 'range2', desc: 'Additional ranges', required: false }, 62 + ], 63 + }, 64 + STDEV: { 65 + desc: 'Estimates standard deviation based on a sample', 66 + params: [ 67 + { name: 'range1', desc: 'First range of values', required: true }, 68 + { name: 'range2', desc: 'Additional ranges', required: false }, 69 + ], 70 + }, 71 + ABS: { 72 + desc: 'Returns the absolute value of a number', 73 + params: [ 74 + { name: 'number', desc: 'The number to get the absolute value of', required: true }, 75 + ], 76 + }, 77 + ROUND: { 78 + desc: 'Rounds a number to a specified number of digits', 79 + params: [ 80 + { name: 'number', desc: 'The number to round', required: true }, 81 + { name: 'num_digits', desc: 'Number of decimal places (default 0)', required: false }, 82 + ], 83 + }, 84 + ROUNDUP: { 85 + desc: 'Rounds a number up, away from zero', 86 + params: [ 87 + { name: 'number', desc: 'The number to round up', required: true }, 88 + { name: 'num_digits', desc: 'Number of decimal places (default 0)', required: false }, 89 + ], 90 + }, 91 + ROUNDDOWN: { 92 + desc: 'Rounds a number down, toward zero', 93 + params: [ 94 + { name: 'number', desc: 'The number to round down', required: true }, 95 + { name: 'num_digits', desc: 'Number of decimal places (default 0)', required: false }, 96 + ], 97 + }, 98 + INT: { 99 + desc: 'Rounds a number down to the nearest integer', 100 + params: [ 101 + { name: 'number', desc: 'The number to round down', required: true }, 102 + ], 103 + }, 104 + MOD: { 105 + desc: 'Returns the remainder after dividing a number by a divisor', 106 + params: [ 107 + { name: 'number', desc: 'The number to divide', required: true }, 108 + { name: 'divisor', desc: 'The divisor', required: true }, 109 + ], 110 + }, 111 + POWER: { 112 + desc: 'Returns a number raised to a power', 113 + params: [ 114 + { name: 'number', desc: 'The base number', required: true }, 115 + { name: 'power', desc: 'The exponent', required: true }, 116 + ], 117 + }, 118 + SQRT: { 119 + desc: 'Returns the positive square root of a number', 120 + params: [ 121 + { name: 'number', desc: 'The number to take the square root of', required: true }, 122 + ], 123 + }, 124 + LOG: { 125 + desc: 'Returns the logarithm of a number to a specified base', 126 + params: [ 127 + { name: 'number', desc: 'The positive number to take the log of', required: true }, 128 + { name: 'base', desc: 'The base of the logarithm (default 10)', required: false }, 129 + ], 130 + }, 131 + LN: { 132 + desc: 'Returns the natural logarithm of a number', 133 + params: [ 134 + { name: 'number', desc: 'The positive number to take the natural log of', required: true }, 135 + ], 136 + }, 137 + EXP: { 138 + desc: 'Returns e raised to the power of a number', 139 + params: [ 140 + { name: 'number', desc: 'The exponent applied to the base e', required: true }, 141 + ], 142 + }, 143 + PI: { 144 + desc: 'Returns the value of pi (3.14159...)', 145 + params: [], 146 + }, 147 + RAND: { 148 + desc: 'Returns a random number between 0 and 1', 149 + params: [], 150 + }, 151 + 152 + // --- Logical --- 153 + IF: { 154 + desc: 'Returns one value if a condition is true and another if false', 155 + params: [ 156 + { name: 'condition', desc: 'The logical test to evaluate', required: true }, 157 + { name: 'value_if_true', desc: 'Value returned when condition is true', required: true }, 158 + { name: 'value_if_false', desc: 'Value returned when condition is false', required: false }, 159 + ], 160 + }, 161 + AND: { 162 + desc: 'Returns TRUE if all arguments are true', 163 + params: [ 164 + { name: 'logical1', desc: 'First condition to evaluate', required: true }, 165 + { name: 'logical2', desc: 'Additional conditions', required: false }, 166 + ], 167 + }, 168 + OR: { 169 + desc: 'Returns TRUE if any argument is true', 170 + params: [ 171 + { name: 'logical1', desc: 'First condition to evaluate', required: true }, 172 + { name: 'logical2', desc: 'Additional conditions', required: false }, 173 + ], 174 + }, 175 + NOT: { 176 + desc: 'Reverses the logic of its argument', 177 + params: [ 178 + { name: 'logical', desc: 'The value or expression to negate', required: true }, 179 + ], 180 + }, 181 + IFERROR: { 182 + desc: 'Returns a value if no error, otherwise returns an alternate value', 183 + params: [ 184 + { name: 'value', desc: 'The value to check for an error', required: true }, 185 + { name: 'value_if_error', desc: 'Value to return if an error is found', required: true }, 186 + ], 187 + }, 188 + 189 + // --- Text --- 190 + CONCATENATE: { 191 + desc: 'Joins several text strings into one', 192 + params: [ 193 + { name: 'text1', desc: 'First text string', required: true }, 194 + { name: 'text2', desc: 'Additional text strings to join', required: false }, 195 + ], 196 + }, 197 + LEN: { 198 + desc: 'Returns the number of characters in a text string', 199 + params: [ 200 + { name: 'text', desc: 'The text string to measure', required: true }, 201 + ], 202 + }, 203 + LEFT: { 204 + desc: 'Returns the leftmost characters from a text string', 205 + params: [ 206 + { name: 'text', desc: 'The source text string', required: true }, 207 + { name: 'num_chars', desc: 'Number of characters to extract (default 1)', required: false }, 208 + ], 209 + }, 210 + RIGHT: { 211 + desc: 'Returns the rightmost characters from a text string', 212 + params: [ 213 + { name: 'text', desc: 'The source text string', required: true }, 214 + { name: 'num_chars', desc: 'Number of characters to extract (default 1)', required: false }, 215 + ], 216 + }, 217 + MID: { 218 + desc: 'Returns a specific number of characters from a text string', 219 + params: [ 220 + { name: 'text', desc: 'The source text string', required: true }, 221 + { name: 'start_num', desc: 'Position of the first character (1-based)', required: true }, 222 + { name: 'num_chars', desc: 'Number of characters to extract', required: true }, 223 + ], 224 + }, 225 + UPPER: { 226 + desc: 'Converts text to uppercase', 227 + params: [ 228 + { name: 'text', desc: 'The text to convert', required: true }, 229 + ], 230 + }, 231 + LOWER: { 232 + desc: 'Converts text to lowercase', 233 + params: [ 234 + { name: 'text', desc: 'The text to convert', required: true }, 235 + ], 236 + }, 237 + TRIM: { 238 + desc: 'Removes leading and trailing spaces from text', 239 + params: [ 240 + { name: 'text', desc: 'The text to trim', required: true }, 241 + ], 242 + }, 243 + SUBSTITUTE: { 244 + desc: 'Replaces occurrences of old text with new text in a string', 245 + params: [ 246 + { name: 'text', desc: 'The text containing characters to replace', required: true }, 247 + { name: 'old_text', desc: 'The text to find and replace', required: true }, 248 + { name: 'new_text', desc: 'The replacement text', required: true }, 249 + { name: 'instance', desc: 'Which occurrence to replace (default: all)', required: false }, 250 + ], 251 + }, 252 + FIND: { 253 + desc: 'Finds the position of a text string within another (case-sensitive)', 254 + params: [ 255 + { name: 'find_text', desc: 'The text to find', required: true }, 256 + { name: 'within_text', desc: 'The text to search within', required: true }, 257 + { name: 'start_num', desc: 'Position to start searching from (default 1)', required: false }, 258 + ], 259 + }, 260 + SEARCH: { 261 + desc: 'Finds the position of a text string within another (case-insensitive)', 262 + params: [ 263 + { name: 'find_text', desc: 'The text to find', required: true }, 264 + { name: 'within_text', desc: 'The text to search within', required: true }, 265 + { name: 'start_num', desc: 'Position to start searching from (default 1)', required: false }, 266 + ], 267 + }, 268 + TEXT: { 269 + desc: 'Formats a number as text with a specified format', 270 + params: [ 271 + { name: 'value', desc: 'The number to format', required: true }, 272 + { name: 'format_text', desc: 'Format pattern (e.g. "0.00", "#,##0")', required: true }, 273 + ], 274 + }, 275 + VALUE: { 276 + desc: 'Converts a text string that represents a number to a number', 277 + params: [ 278 + { name: 'text', desc: 'The text to convert to a number', required: true }, 279 + ], 280 + }, 281 + 282 + // --- Date --- 283 + NOW: { 284 + desc: 'Returns the current date and time', 285 + params: [], 286 + }, 287 + TODAY: { 288 + desc: 'Returns the current date', 289 + params: [], 290 + }, 291 + DATE: { 292 + desc: 'Creates a date from year, month, and day components', 293 + params: [ 294 + { name: 'year', desc: 'The year (e.g. 2024)', required: true }, 295 + { name: 'month', desc: 'The month (1-12)', required: true }, 296 + { name: 'day', desc: 'The day of the month (1-31)', required: true }, 297 + ], 298 + }, 299 + YEAR: { 300 + desc: 'Returns the year of a date', 301 + params: [ 302 + { name: 'date', desc: 'The date to extract the year from', required: true }, 303 + ], 304 + }, 305 + MONTH: { 306 + desc: 'Returns the month of a date (1-12)', 307 + params: [ 308 + { name: 'date', desc: 'The date to extract the month from', required: true }, 309 + ], 310 + }, 311 + DAY: { 312 + desc: 'Returns the day of the month of a date (1-31)', 313 + params: [ 314 + { name: 'date', desc: 'The date to extract the day from', required: true }, 315 + ], 316 + }, 317 + 318 + // --- Lookup --- 319 + VLOOKUP: { 320 + desc: 'Searches first column of range for key and returns value from specified column', 321 + params: [ 322 + { name: 'lookup_value', desc: 'The value to search for', required: true }, 323 + { name: 'table_array', desc: 'Range containing the data', required: true }, 324 + { name: 'col_index', desc: 'Column number to return (1-based)', required: true }, 325 + { name: 'range_lookup', desc: 'FALSE for exact match, TRUE for approximate', required: false }, 326 + ], 327 + }, 328 + HLOOKUP: { 329 + desc: 'Searches first row of range for key and returns value from specified row', 330 + params: [ 331 + { name: 'lookup_value', desc: 'The value to search for', required: true }, 332 + { name: 'table_array', desc: 'Range containing the data', required: true }, 333 + { name: 'row_index', desc: 'Row number to return (1-based)', required: true }, 334 + { name: 'range_lookup', desc: 'FALSE for exact match, TRUE for approximate', required: false }, 335 + ], 336 + }, 337 + INDEX: { 338 + desc: 'Returns the value of a cell in a range at a given row and column', 339 + params: [ 340 + { name: 'array', desc: 'The range of cells', required: true }, 341 + { name: 'row_num', desc: 'Row number in the range (1-based)', required: true }, 342 + { name: 'col_num', desc: 'Column number in the range (1-based)', required: false }, 343 + ], 344 + }, 345 + MATCH: { 346 + desc: 'Returns the relative position of a value in a range', 347 + params: [ 348 + { name: 'lookup_value', desc: 'The value to search for', required: true }, 349 + { name: 'lookup_array', desc: 'The range to search', required: true }, 350 + { name: 'match_type', desc: '1 for less than, 0 for exact, -1 for greater than', required: false }, 351 + ], 352 + }, 353 + 354 + // --- Conditional --- 355 + SUMIF: { 356 + desc: 'Sums cells that meet a specified condition', 357 + params: [ 358 + { name: 'range', desc: 'The range to evaluate against the criteria', required: true }, 359 + { name: 'criteria', desc: 'The condition to match (e.g. ">100")', required: true }, 360 + { name: 'sum_range', desc: 'The range to sum (default: same as range)', required: false }, 361 + ], 362 + }, 363 + COUNTIF: { 364 + desc: 'Counts cells that meet a specified condition', 365 + params: [ 366 + { name: 'range', desc: 'The range to evaluate', required: true }, 367 + { name: 'criteria', desc: 'The condition to match', required: true }, 368 + ], 369 + }, 370 + AVERAGEIF: { 371 + desc: 'Averages cells that meet a specified condition', 372 + params: [ 373 + { name: 'range', desc: 'The range to evaluate against the criteria', required: true }, 374 + { name: 'criteria', desc: 'The condition to match', required: true }, 375 + { name: 'average_range', desc: 'The range to average (default: same as range)', required: false }, 376 + ], 377 + }, 378 + }; 379 + 380 + /** 381 + * Detect which function the cursor is currently inside, and which 382 + * parameter index it's at. 383 + * 384 + * Parses backwards from the cursor position, counting parentheses 385 + * and commas to determine the active function and parameter. 386 + * 387 + * @param {string} formula - The full formula string (including '=') 388 + * @param {number} cursorPosition - The cursor position in the string 389 + * @returns {{ functionName: string, paramIndex: number } | null} 390 + */ 391 + export function detectCurrentFunction(formula, cursorPosition) { 392 + if (!formula || cursorPosition <= 0) return null; 393 + 394 + // Work with the portion up to the cursor 395 + const text = formula.slice(0, cursorPosition); 396 + 397 + // Walk backwards tracking paren depth and commas 398 + let depth = 0; 399 + let commas = 0; 400 + let inString = false; 401 + 402 + for (let i = text.length - 1; i >= 0; i--) { 403 + const ch = text[i]; 404 + 405 + // Track string literals (scanning backwards) 406 + if (ch === '"') { 407 + // Check if this quote is escaped 408 + let escapes = 0; 409 + let j = i - 1; 410 + while (j >= 0 && text[j] === '\\') { escapes++; j--; } 411 + if (escapes % 2 === 0) { 412 + inString = !inString; 413 + } 414 + continue; 415 + } 416 + 417 + if (inString) continue; 418 + 419 + if (ch === ')') { 420 + depth++; 421 + continue; 422 + } 423 + 424 + if (ch === '(') { 425 + if (depth > 0) { 426 + depth--; 427 + continue; 428 + } 429 + 430 + // We found the matching open paren at depth 0. 431 + // Look backwards from here to find the function name. 432 + let nameEnd = i; 433 + // Skip whitespace before the paren 434 + let k = i - 1; 435 + while (k >= 0 && text[k] === ' ') k--; 436 + 437 + // Read the function name 438 + let nameStart = k; 439 + while (nameStart >= 0 && /[A-Za-z0-9_]/.test(text[nameStart])) { 440 + nameStart--; 441 + } 442 + nameStart++; // back to first char of name 443 + 444 + if (nameStart <= k) { 445 + const name = text.slice(nameStart, k + 1).toUpperCase(); 446 + if (FUNCTION_METADATA[name]) { 447 + return { functionName: name, paramIndex: commas }; 448 + } 449 + } 450 + 451 + // Not a recognized function — keep going up 452 + // (the open paren might be a grouping paren, not a function call) 453 + // Reset commas for the outer level 454 + commas = 0; 455 + continue; 456 + } 457 + 458 + if (ch === ',' && depth === 0) { 459 + commas++; 460 + continue; 461 + } 462 + } 463 + 464 + return null; 465 + } 466 + 467 + /** 468 + * Render the tooltip DOM element showing function info. 469 + * 470 + * @param {string} functionName - The function name (uppercase) 471 + * @param {number} paramIndex - The current parameter index 472 + * @param {HTMLElement} anchorElement - The element to position the tooltip near 473 + * @returns {HTMLElement | null} The tooltip element, or null if function not found 474 + */ 475 + export function renderTooltip(functionName, paramIndex, anchorElement) { 476 + const meta = FUNCTION_METADATA[functionName]; 477 + if (!meta) return null; 478 + 479 + // Remove any existing tooltip 480 + hideTooltip(); 481 + 482 + const tooltip = document.createElement('div'); 483 + tooltip.className = 'formula-tooltip'; 484 + tooltip.id = 'formula-tooltip'; 485 + 486 + // Build signature line with highlighted current param 487 + const sigParts = []; 488 + sigParts.push(`<span class="formula-tooltip-fn">${functionName}</span>(`); 489 + meta.params.forEach((p, idx) => { 490 + if (idx > 0) sigParts.push(', '); 491 + const isActive = idx === paramIndex; 492 + const cls = isActive ? 'formula-tooltip-param-active' : 'formula-tooltip-param'; 493 + const bracket = p.required ? '' : ['[', ']']; 494 + if (!p.required) sigParts.push('<span class="formula-tooltip-optional">[</span>'); 495 + sigParts.push(`<span class="${cls}">${p.name}</span>`); 496 + if (!p.required) sigParts.push('<span class="formula-tooltip-optional">]</span>'); 497 + }); 498 + sigParts.push(')'); 499 + 500 + // Build param description (for active param) 501 + let paramDesc = ''; 502 + if (meta.params[paramIndex]) { 503 + const p = meta.params[paramIndex]; 504 + paramDesc = `<div class="formula-tooltip-param-desc"><strong>${p.name}</strong>: ${p.desc}</div>`; 505 + } 506 + 507 + tooltip.innerHTML = ` 508 + <div class="formula-tooltip-signature">${sigParts.join('')}</div> 509 + ${paramDesc} 510 + <div class="formula-tooltip-desc">${meta.desc}</div> 511 + `; 512 + 513 + // Position near anchor 514 + document.body.appendChild(tooltip); 515 + 516 + if (anchorElement) { 517 + const rect = anchorElement.getBoundingClientRect(); 518 + const tooltipRect = tooltip.getBoundingClientRect(); 519 + const viewportHeight = window.innerHeight; 520 + 521 + // Prefer placing below the anchor 522 + let top = rect.bottom + 4; 523 + if (top + tooltipRect.height > viewportHeight) { 524 + // Place above if not enough room below 525 + top = rect.top - tooltipRect.height - 4; 526 + } 527 + 528 + tooltip.style.left = `${rect.left}px`; 529 + tooltip.style.top = `${top}px`; 530 + } 531 + 532 + return tooltip; 533 + } 534 + 535 + /** 536 + * Remove the tooltip from the DOM. 537 + */ 538 + export function hideTooltip() { 539 + const existing = document.getElementById('formula-tooltip'); 540 + if (existing) existing.remove(); 541 + }
+282 -2
src/sheets/formulas.js
··· 315 315 // Named range identifier (not a function, not a cell ref) 316 316 if (t.type === TokenType.IDENTIFIER) { 317 317 this.advance(); 318 + // Check LET-scoped variables first 319 + const identLower = t.value.toLowerCase(); 320 + if (this._letScope && identLower in this._letScope) { 321 + return this._letScope[identLower]; 322 + } 318 323 return this.resolveNamedRange(t.value); 319 324 } 320 325 321 326 if (t.type === TokenType.FUNCTION) { 322 327 this.advance(); 328 + // Special handling for LET — needs compile-time name resolution 329 + if (t.value === 'LET') { 330 + return this.parseLet(); 331 + } 323 332 this.expect(TokenType.LPAREN); 324 333 const args = []; 325 334 if (this.peek().type !== TokenType.RPAREN) { 326 - args.push(this.parseFunctionArg()); 335 + // Handle first arg — could be omitted if first token is COMMA 336 + if (this.peek().type === TokenType.COMMA) { 337 + args.push(undefined); 338 + } else { 339 + args.push(this.parseFunctionArg()); 340 + } 327 341 while (this.peek().type === TokenType.COMMA) { 328 342 this.advance(); 329 - args.push(this.parseFunctionArg()); 343 + // Handle omitted arguments (consecutive commas or trailing comma before RPAREN) 344 + if (this.peek().type === TokenType.COMMA || this.peek().type === TokenType.RPAREN) { 345 + args.push(undefined); 346 + } else { 347 + args.push(this.parseFunctionArg()); 348 + } 330 349 } 331 350 } 332 351 this.expect(TokenType.RPAREN); ··· 385 404 return this.expression(); 386 405 } 387 406 407 + // Parse LET(name1, value1, [name2, value2, ...], calculation) 408 + parseLet() { 409 + this.expect(TokenType.LPAREN); 410 + const prevScope = this._letScope ? { ...this._letScope } : null; 411 + if (!this._letScope) this._letScope = {}; 412 + 413 + // Collect name/value pairs 414 + // LET requires at least 3 args: name, value, calculation 415 + // We read pairs of (identifier, expression) then a final expression 416 + const names = []; 417 + const values = []; 418 + 419 + // Read first name 420 + while (true) { 421 + // Current token should be an identifier (the variable name) 422 + // The tokenizer may have produced FUNCTION, IDENTIFIER, or CELL_REF for the name 423 + const nameToken = this.peek(); 424 + let varName; 425 + if (nameToken.type === TokenType.IDENTIFIER || nameToken.type === TokenType.FUNCTION) { 426 + this.advance(); 427 + varName = nameToken.value.toLowerCase(); 428 + } else if (nameToken.type === TokenType.CELL_REF) { 429 + // Allow cell-ref-like identifiers as LET names (e.g., "x" wouldn't hit this, but handle gracefully) 430 + this.advance(); 431 + varName = nameToken.value.toLowerCase(); 432 + } else { 433 + throw new Error('LET: expected variable name'); 434 + } 435 + 436 + this.expect(TokenType.COMMA); 437 + // Parse the value expression 438 + const val = this.parseFunctionArg(); 439 + this._letScope[varName] = val; 440 + names.push(varName); 441 + values.push(val); 442 + 443 + // Now check: is the next thing a COMMA followed by what looks like another name/value pair, 444 + // or is it RPAREN (meaning we just got the calculation as the value)? 445 + // Actually, the grammar is: after value, if COMMA follows, there's either another name/value pair or the final calc. 446 + // We need to peek ahead: COMMA + IDENTIFIER/FUNCTION + COMMA means more pairs. 447 + // COMMA + expression + RPAREN means final calculation. 448 + if (this.peek().type === TokenType.COMMA) { 449 + this.advance(); // consume comma 450 + 451 + // Check if what follows is "identifier COMMA" pattern (another name/value pair) 452 + // or if it's the final calculation expression 453 + const savedPos = this.pos; 454 + const nextToken = this.peek(); 455 + if ((nextToken.type === TokenType.IDENTIFIER || nextToken.type === TokenType.FUNCTION) && 456 + this.tokens[this.pos + 1]?.type === TokenType.COMMA) { 457 + // Another name/value pair — continue the loop 458 + continue; 459 + } 460 + // It's the final calculation 461 + const result = this.parseFunctionArg(); 462 + this.expect(TokenType.RPAREN); 463 + // Restore previous scope 464 + this._letScope = prevScope; 465 + return result; 466 + } else if (this.peek().type === TokenType.RPAREN) { 467 + // The "value" we just parsed IS the calculation (single name/value pair case) 468 + // Wait, this means we had LET(name, calc) with only 2 args, which is invalid. 469 + // Actually, in our loop: we read name, comma, value. If next is RPAREN, 470 + // then value IS the final calculation expression. 471 + // But LET needs at least name, value, calculation (3 args). 472 + // Re-reading the logic: we consumed name + comma + value. 473 + // If RPAREN follows, that means: LET(name, value) — only 2 args, invalid. 474 + // But actually for LET(name, value, calc): after reading name+comma, we parse value. 475 + // Then we see COMMA, advance, see it's the final calc, parse it, expect RPAREN. 476 + // So hitting RPAREN here means LET(name, expression) — invalid, but let's be lenient 477 + // and just return the value. 478 + this.advance(); // consume RPAREN 479 + this._letScope = prevScope; 480 + return val; 481 + } 482 + } 483 + } 484 + 388 485 resolveRange(startRef, endRef) { 389 486 const start = parseRef(startRef); 390 487 const end = parseRef(endRef); ··· 481 578 case 'EXP': return Math.exp(toNum(args[0])); 482 579 case 'PI': return Math.PI; 483 580 case 'RAND': return Math.random(); 581 + case 'RANDBETWEEN': { 582 + const bottom = Math.ceil(toNum(args[0])); 583 + const top = Math.floor(toNum(args[1])); 584 + return Math.floor(Math.random() * (top - bottom + 1)) + bottom; 585 + } 484 586 485 587 case 'IF': return args[0] ? args[1] : (args[2] ?? false); 486 588 case 'AND': return flat(args).every(Boolean); ··· 600 702 return Math.sqrt(variance); 601 703 } 602 704 705 + case 'XLOOKUP': { 706 + const needle = args[0]; 707 + const lookupArr = Array.isArray(args[1]) ? args[1] : [args[1]]; 708 + const returnArr = Array.isArray(args[2]) ? args[2] : [args[2]]; 709 + const ifNotFound = args[3] !== undefined ? args[3] : '#N/A'; 710 + const matchMode = args[4] !== undefined ? toNum(args[4]) : 0; 711 + const searchMode = args[5] !== undefined ? toNum(args[5]) : 1; 712 + 713 + // Flatten lookup and return arrays to 1D — use their linear length 714 + const lookupLen = lookupArr.length; 715 + const indices = []; 716 + for (let i = 0; i < lookupLen; i++) indices.push(i); 717 + if (searchMode === -1) indices.reverse(); 718 + 719 + let foundIdx = -1; 720 + 721 + if (matchMode === 0) { 722 + // Exact match 723 + for (const i of indices) { 724 + if (valuesEqual(lookupArr[i], needle)) { foundIdx = i; break; } 725 + } 726 + } else if (matchMode === 2) { 727 + // Wildcard match 728 + const pattern = wildcardToRegex(String(needle)); 729 + for (const i of indices) { 730 + if (pattern.test(String(lookupArr[i]))) { foundIdx = i; break; } 731 + } 732 + } else if (matchMode === -1) { 733 + // Exact or next smaller 734 + let bestIdx = -1; 735 + let bestVal = -Infinity; 736 + for (let i = 0; i < lookupLen; i++) { 737 + const v = lookupArr[i]; 738 + const cmp = compareValues(v, needle); 739 + if (cmp === 0) { foundIdx = i; break; } 740 + if (cmp < 0 && toNum(v) > bestVal) { bestVal = toNum(v); bestIdx = i; } 741 + } 742 + if (foundIdx === -1) foundIdx = bestIdx; 743 + } else if (matchMode === 1) { 744 + // Exact or next larger 745 + let bestIdx = -1; 746 + let bestVal = Infinity; 747 + for (let i = 0; i < lookupLen; i++) { 748 + const v = lookupArr[i]; 749 + const cmp = compareValues(v, needle); 750 + if (cmp === 0) { foundIdx = i; break; } 751 + if (cmp > 0 && toNum(v) < bestVal) { bestVal = toNum(v); bestIdx = i; } 752 + } 753 + if (foundIdx === -1) foundIdx = bestIdx; 754 + } 755 + 756 + if (foundIdx === -1) return ifNotFound; 757 + return returnArr[foundIdx] !== undefined ? returnArr[foundIdx] : ifNotFound; 758 + } 759 + 760 + case 'SUMIFS': { 761 + // SUMIFS(sum_range, criteria_range1, criteria1, [criteria_range2, criteria2], ...) 762 + const sumRange = Array.isArray(args[0]) ? args[0] : [args[0]]; 763 + const criteriaCount = Math.floor((args.length - 1) / 2); 764 + let sum = 0; 765 + for (let i = 0; i < sumRange.length; i++) { 766 + let allMatch = true; 767 + for (let c = 0; c < criteriaCount; c++) { 768 + const critRange = Array.isArray(args[1 + c * 2]) ? args[1 + c * 2] : [args[1 + c * 2]]; 769 + const criteria = args[2 + c * 2]; 770 + if (!matchCriteriaWild(critRange[i], criteria)) { allMatch = false; break; } 771 + } 772 + if (allMatch) sum += toNum(sumRange[i] ?? 0); 773 + } 774 + return sum; 775 + } 776 + 777 + case 'COUNTIFS': { 778 + // COUNTIFS(criteria_range1, criteria1, [criteria_range2, criteria2], ...) 779 + const criteriaCount = Math.floor(args.length / 2); 780 + if (criteriaCount === 0) return 0; 781 + const firstRange = Array.isArray(args[0]) ? args[0] : [args[0]]; 782 + let count = 0; 783 + for (let i = 0; i < firstRange.length; i++) { 784 + let allMatch = true; 785 + for (let c = 0; c < criteriaCount; c++) { 786 + const critRange = Array.isArray(args[c * 2]) ? args[c * 2] : [args[c * 2]]; 787 + const criteria = args[1 + c * 2]; 788 + if (!matchCriteriaWild(critRange[i], criteria)) { allMatch = false; break; } 789 + } 790 + if (allMatch) count++; 791 + } 792 + return count; 793 + } 794 + 795 + case 'AVERAGEIFS': { 796 + // AVERAGEIFS(average_range, criteria_range1, criteria1, ...) 797 + const avgRange = Array.isArray(args[0]) ? args[0] : [args[0]]; 798 + const criteriaCount = Math.floor((args.length - 1) / 2); 799 + const vals = []; 800 + for (let i = 0; i < avgRange.length; i++) { 801 + let allMatch = true; 802 + for (let c = 0; c < criteriaCount; c++) { 803 + const critRange = Array.isArray(args[1 + c * 2]) ? args[1 + c * 2] : [args[1 + c * 2]]; 804 + const criteria = args[2 + c * 2]; 805 + if (!matchCriteriaWild(critRange[i], criteria)) { allMatch = false; break; } 806 + } 807 + if (allMatch) vals.push(toNum(avgRange[i] ?? 0)); 808 + } 809 + return vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : '#DIV/0!'; 810 + } 811 + 812 + case 'TEXTJOIN': { 813 + const delimiter = String(args[0]); 814 + const ignoreEmpty = Boolean(args[1]); 815 + const values = []; 816 + for (let i = 2; i < args.length; i++) { 817 + if (Array.isArray(args[i])) { 818 + for (const v of args[i]) values.push(v); 819 + } else { 820 + values.push(args[i]); 821 + } 822 + } 823 + const filtered = ignoreEmpty ? values.filter(v => v !== '' && v !== null && v !== undefined) : values; 824 + return filtered.map(String).join(delimiter); 825 + } 826 + 827 + case 'CONCAT': { 828 + const values = []; 829 + for (const arg of args) { 830 + if (Array.isArray(arg)) { 831 + for (const v of arg) { 832 + if (v !== '' && v !== null && v !== undefined) values.push(v); 833 + } 834 + } else { 835 + values.push(arg); 836 + } 837 + } 838 + return values.map(String).join(''); 839 + } 840 + 841 + case 'SWITCH': { 842 + // SWITCH(expression, case1, value1, [case2, value2, ...], [default]) 843 + const expr = args[0]; 844 + const pairs = args.slice(1); 845 + const hasDefault = pairs.length % 2 === 1; 846 + const pairCount = Math.floor(pairs.length / 2); 847 + for (let i = 0; i < pairCount; i++) { 848 + if (valuesEqual(expr, pairs[i * 2])) return pairs[i * 2 + 1]; 849 + } 850 + return hasDefault ? pairs[pairs.length - 1] : '#N/A'; 851 + } 852 + 603 853 default: return `#NAME? (${name})`; 604 854 } 605 855 } ··· 626 876 if (criteria.startsWith('<')) return toNum(value) < toNum(criteria.slice(1)); 627 877 if (criteria.startsWith('=')) return String(value) === criteria.slice(1); 628 878 return String(value).toLowerCase() === String(criteria).toLowerCase(); 879 + } 880 + return value === criteria; 881 + } 882 + 883 + /** Convert wildcard pattern (* and ?) to a RegExp */ 884 + function wildcardToRegex(pattern) { 885 + const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&'); 886 + const regexStr = escaped.replace(/\*/g, '.*').replace(/\?/g, '.'); 887 + return new RegExp('^' + regexStr + '$', 'i'); 888 + } 889 + 890 + /** matchCriteria with wildcard support for SUMIFS/COUNTIFS/AVERAGEIFS */ 891 + function matchCriteriaWild(value, criteria) { 892 + if (typeof criteria === 'string') { 893 + if (criteria.startsWith('>=')) return toNum(value) >= toNum(criteria.slice(2)); 894 + if (criteria.startsWith('<=')) return toNum(value) <= toNum(criteria.slice(2)); 895 + if (criteria.startsWith('<>')) return String(value) !== criteria.slice(2); 896 + if (criteria.startsWith('>')) return toNum(value) > toNum(criteria.slice(1)); 897 + if (criteria.startsWith('<')) return toNum(value) < toNum(criteria.slice(1)); 898 + if (criteria.startsWith('=')) return String(value) === criteria.slice(1); 899 + // Check for wildcards 900 + if (criteria.includes('*') || criteria.includes('?')) { 901 + return wildcardToRegex(criteria).test(String(value)); 902 + } 903 + // Empty criteria matches empty cells 904 + if (criteria === '') return value === '' || value === null || value === undefined; 905 + return String(value).toLowerCase() === String(criteria).toLowerCase(); 906 + } 907 + if (typeof criteria === 'number') { 908 + return toNum(value) === criteria; 629 909 } 630 910 return value === criteria; 631 911 }
+7 -1
src/sheets/index.html
··· 202 202 <div class="formula-bar" id="main-content"> 203 203 <input class="cell-address" id="cell-address" readonly value="A1" aria-label="Cell address"> 204 204 <span style="color: var(--color-text-faint); font-family: var(--font-mono); font-size: 0.85rem;">f</span> 205 - <input class="formula-input" id="formula-input" placeholder="Value or formula (=SUM(A1:A10))" aria-label="Formula input"> 205 + <div class="formula-input-wrap" id="formula-input-wrap"> 206 + <div class="formula-highlight-layer" id="formula-highlight-layer" aria-hidden="true"></div> 207 + <input class="formula-input" id="formula-input" placeholder="Value or formula (=SUM(A1:A10))" aria-label="Formula input"> 208 + </div> 206 209 </div> 207 210 208 211 <!-- Spreadsheet grid --> ··· 261 264 262 265 <!-- Formula autocomplete dropdown --> 263 266 <div class="formula-autocomplete" id="formula-autocomplete" style="display:none"></div> 267 + 268 + <!-- Formula tooltip (parameter hints) --> 269 + <div class="formula-tooltip" id="formula-tooltip" style="display:none"></div> 264 270 265 271 <!-- Cell note tooltip --> 266 272 <div class="cell-note-tooltip" id="cell-note-tooltip" style="display:none"></div>
+182 -20
src/sheets/main.js
··· 9 9 import { importKey, encryptString, decryptString } from '../lib/crypto.js'; 10 10 import { EncryptedProvider } from '../lib/provider.js'; 11 11 import { evaluate, extractRefs, formatCell, parseRef, colToLetter, letterToCol, cellId } from './formulas.js'; 12 + import { RecalcEngine } from './recalc.js'; 12 13 import { importXlsx, isValidXlsx } from './xlsx-import.js'; 13 14 import { validateChartConfig, parseDataRange, extractChartData, transformChartData, buildChartJsConfig, CHART_TYPES, CHART_COLORS } from './charts.js'; 14 15 import { getUniqueColumnValues, applyFilters, clearColumnFilter, clearAllFilters, buildFilterState } from './filter.js'; ··· 19 20 import { computeSelectionStats, formatStatValue } from './status-bar.js'; 20 21 import { FORMULA_FUNCTIONS, filterFunctions, navigateAutocomplete, getSelectedFunction } from './formula-autocomplete.js'; 21 22 import { createNote, updateNote, deleteNote, getNote, hasNote, getAllNotes } from './cell-notes.js'; 23 + import { tokenizeForHighlighting, renderHighlightedFormula } from './formula-highlighter.js'; 24 + import { extractFormulaRanges, assignRangeColors, renderGridHighlights, clearGridHighlights } from './range-highlight.js'; 25 + import { detectCurrentFunction, renderTooltip, hideTooltip } from './formula-tooltip.js'; 22 26 23 27 // --- Constants --- 24 28 const DEFAULT_ROWS = 100; ··· 407 411 408 412 const evalCache = new Map(); 409 413 414 + // --- Recalc engine integration --- 415 + function buildRecalcCellStore() { 416 + return { 417 + get(id) { 418 + const data = getCellData(id); 419 + if (!data) return null; 420 + return { v: data.v ?? '', f: data.f || '' }; 421 + }, 422 + set(id, cell) { 423 + evalCache.set('__cell__' + id, cell.v); 424 + }, 425 + has(id) { 426 + return getCellData(id) !== null; 427 + }, 428 + entries() { 429 + const cells = getCells(); 430 + const result = []; 431 + cells.forEach((yCell, id) => { 432 + const f = yCell.get('f') || ''; 433 + const v = yCell.get('v') ?? ''; 434 + result.push([id, { v, f }]); 435 + }); 436 + return result[Symbol.iterator](); 437 + }, 438 + getAllFormulaCells() { 439 + const cells = getCells(); 440 + const result = []; 441 + cells.forEach((yCell, id) => { 442 + const f = yCell.get('f') || ''; 443 + if (f) result.push([id, { v: yCell.get('v') ?? '', f }]); 444 + }); 445 + return result; 446 + }, 447 + }; 448 + } 449 + 450 + let recalcEngine = null; 451 + 452 + function getRecalcEngine() { 453 + if (!recalcEngine) { 454 + recalcEngine = new RecalcEngine(buildRecalcCellStore()); 455 + recalcEngine.buildFullGraph(); 456 + } 457 + return recalcEngine; 458 + } 459 + 460 + function invalidateRecalcEngine() { 461 + recalcEngine = null; 462 + } 463 + 410 464 function evaluateFormula(formula) { 411 465 if (evalCache.has(formula)) return evalCache.get(formula); 412 466 const result = evaluate(formula, (ref) => { 467 + const cachedKey = '__cell__' + ref; 468 + if (evalCache.has(cachedKey)) return evalCache.get(cachedKey); 413 469 const data = getCellData(ref); 414 470 if (!data) return ''; 415 471 if (data.f) return evaluateFormula(data.f); ··· 664 720 input.select(); 665 721 formulaInput.value = value; 666 722 input.addEventListener('keydown', onEditKeyDown); 667 - input.addEventListener('blur', () => { hideAutocomplete(); commitEdit(); }); 723 + input.addEventListener('blur', () => { hideAutocomplete(); hideTooltip(); commitEdit(); }); 668 724 // Attach formula autocomplete to cell editor 669 725 attachCellEditorAutocomplete(input); 726 + // Attach formula UX enhancements: range highlights + tooltip 727 + attachCellEditorFormulaUX(input, td); 728 + // Initial highlight/range update 729 + updateFormulaHighlight(value); 730 + updateFormulaRangeHighlights(value); 670 731 } 671 732 672 733 function commitEdit() { ··· 687 748 } 688 749 if (td) td.classList.remove('editing'); 689 750 editingCell = null; 690 - evalCache.clear(); 751 + evalCache.clear(); invalidateRecalcEngine(); 752 + clearGridHighlights(); 753 + hideTooltip(); 691 754 refreshVisibleCells(); 692 755 } 693 756 ··· 698 761 editingCell = null; 699 762 grid.querySelectorAll('.cell-editor').forEach(el => el.remove()); 700 763 grid.querySelectorAll('.editing').forEach(el => el.classList.remove('editing')); 764 + clearGridHighlights(); 765 + hideTooltip(); 701 766 updateFormulaBar(); 702 767 } 703 768 } ··· 732 797 // Undo: Cmd+Z (Mac) / Ctrl+Z 733 798 if ((e.metaKey || e.ctrlKey) && key === 'z' && !e.shiftKey) { 734 799 e.preventDefault(); 735 - if (undoManager) { undoManager.undo(); evalCache.clear(); refreshVisibleCells(); } 800 + if (undoManager) { undoManager.undo(); evalCache.clear(); invalidateRecalcEngine(); refreshVisibleCells(); } 736 801 } 737 802 // Redo: Cmd+Shift+Z (Mac) / Ctrl+Y (Windows/Linux) 738 803 if (((e.metaKey || e.ctrlKey) && e.shiftKey && key === 'z') || (e.ctrlKey && !e.metaKey && key === 'y')) { 739 804 e.preventDefault(); 740 - if (undoManager) { undoManager.redo(); evalCache.clear(); refreshVisibleCells(); } 805 + if (undoManager) { undoManager.redo(); evalCache.clear(); invalidateRecalcEngine(); refreshVisibleCells(); } 741 806 } 742 807 }); 743 808 ··· 779 844 } 780 845 } 781 846 }); 782 - evalCache.clear(); 847 + evalCache.clear(); invalidateRecalcEngine(); 783 848 refreshVisibleCells(); 784 849 } 785 850 ··· 813 878 } 814 879 } 815 880 }); 816 - evalCache.clear(); 881 + evalCache.clear(); invalidateRecalcEngine(); 817 882 refreshVisibleCells(); 818 883 } 819 884 ··· 876 941 } 877 942 const cellData = getCellData(id); 878 943 formulaInput.value = cellData?.f ? '=' + cellData.f : (cellData?.v ?? ''); 944 + updateFormulaHighlight(formulaInput.value); 945 + } 946 + 947 + // --- Formula syntax highlighting helpers --- 948 + const formulaHighlightLayer = document.getElementById('formula-highlight-layer'); 949 + 950 + function updateFormulaHighlight(text) { 951 + if (!formulaHighlightLayer) return; 952 + if (text && text.startsWith('=')) { 953 + const tokens = tokenizeForHighlighting(text); 954 + formulaHighlightLayer.innerHTML = renderHighlightedFormula(tokens); 955 + formulaHighlightLayer.style.display = ''; 956 + formulaInput.classList.add('formula-highlighting'); 957 + } else { 958 + formulaHighlightLayer.innerHTML = ''; 959 + formulaHighlightLayer.style.display = 'none'; 960 + formulaInput.classList.remove('formula-highlighting'); 961 + } 962 + } 963 + 964 + function updateFormulaRangeHighlights(text) { 965 + clearGridHighlights(); 966 + if (!text || !text.startsWith('=')) return; 967 + const formula = text.slice(1); 968 + const ranges = extractFormulaRanges(formula); 969 + if (ranges.length === 0) return; 970 + const colored = assignRangeColors(ranges); 971 + renderGridHighlights(colored, grid, parseRef, colToLetter); 972 + } 973 + 974 + function updateFormulaTooltip(text, cursorPos, anchorEl) { 975 + if (!text || !text.startsWith('=')) { 976 + hideTooltip(); 977 + return; 978 + } 979 + const result = detectCurrentFunction(text, cursorPos); 980 + if (result) { 981 + renderTooltip(result.functionName, result.paramIndex, anchorEl); 982 + } else { 983 + hideTooltip(); 984 + } 985 + } 986 + 987 + function onFormulaInputUpdate() { 988 + const text = formulaInput.value; 989 + updateFormulaHighlight(text); 990 + updateFormulaRangeHighlights(text); 991 + updateFormulaTooltip(text, formulaInput.selectionStart, formulaInput); 992 + if (formulaHighlightLayer) { 993 + formulaHighlightLayer.scrollLeft = formulaInput.scrollLeft; 994 + } 879 995 } 880 996 881 997 function refreshVisibleCells() { ··· 918 1034 setCellData(id, { v: value, f: '' }); 919 1035 } 920 1036 921 - evalCache.clear(); 1037 + evalCache.clear(); invalidateRecalcEngine(); 922 1038 refreshVisibleCells(); 923 1039 } 924 1040 ··· 996 1112 997 1113 // Undo/Redo toolbar buttons 998 1114 document.getElementById('tb-undo').addEventListener('click', () => { 999 - if (undoManager) { undoManager.undo(); evalCache.clear(); refreshVisibleCells(); } 1115 + if (undoManager) { undoManager.undo(); evalCache.clear(); invalidateRecalcEngine(); refreshVisibleCells(); } 1000 1116 }); 1001 1117 document.getElementById('tb-redo').addEventListener('click', () => { 1002 - if (undoManager) { undoManager.redo(); evalCache.clear(); refreshVisibleCells(); } 1118 + if (undoManager) { undoManager.redo(); evalCache.clear(); invalidateRecalcEngine(); refreshVisibleCells(); } 1003 1119 }); 1004 1120 1005 1121 document.getElementById('tb-bold').addEventListener('click', () => { ··· 1086 1202 } 1087 1203 }); 1088 1204 }); 1089 - evalCache.clear(); 1205 + evalCache.clear(); invalidateRecalcEngine(); 1090 1206 refreshVisibleCells(); 1091 1207 } 1092 1208 ··· 1142 1258 tab.className = 'sheet-tab' + (i === activeSheetIdx ? ' active' : ''); 1143 1259 tab.dataset.sheet = i; 1144 1260 tab.textContent = sheet.get('name') || 'Sheet ' + (i + 1); 1145 - tab.addEventListener('click', () => { activeSheetIdx = i; renderSheetTabs(); evalCache.clear(); renderGrid(); }); 1261 + tab.addEventListener('click', () => { activeSheetIdx = i; renderSheetTabs(); evalCache.clear(); invalidateRecalcEngine(); renderGrid(); }); 1146 1262 tab.addEventListener('dblclick', () => { const name = prompt('Sheet name:', sheet.get('name')); if (name) { sheet.set('name', name); renderSheetTabs(); } }); 1147 1263 sheetTabsContainer.insertBefore(tab, addBtn); 1148 1264 } ··· 1151 1267 document.getElementById('add-sheet').addEventListener('click', () => { 1152 1268 let count = 0; 1153 1269 ySheets.forEach((_, key) => { if (key.startsWith('sheet_')) count++; }); 1154 - ensureSheet(count); activeSheetIdx = count; renderSheetTabs(); evalCache.clear(); renderGrid(); 1270 + ensureSheet(count); activeSheetIdx = count; renderSheetTabs(); evalCache.clear(); invalidateRecalcEngine(); renderGrid(); 1155 1271 }); 1156 1272 1157 1273 // --- Document title --- ··· 1187 1303 statusText.textContent = 'Synced'; 1188 1304 // Re-attach cell observer after sync — the snapshot may have replaced the Y.Map 1189 1305 // that getCells() returned during initial setup (before data loaded) 1190 - getCells().observeDeep(() => { evalCache.clear(); scheduleRenderGrid(); updateFormulaBar(); }); 1306 + getCells().observeDeep(() => { evalCache.clear(); invalidateRecalcEngine(); scheduleRenderGrid(); updateFormulaBar(); }); 1191 1307 renderGrid(); 1192 1308 }); 1193 1309 ··· 1315 1431 if (neededRows > (sheet.get('rowCount') || DEFAULT_ROWS)) sheet.set('rowCount', neededRows); 1316 1432 if (neededCols > (sheet.get('colCount') || DEFAULT_COLS)) sheet.set('colCount', neededCols); 1317 1433 }); 1318 - evalCache.clear(); renderGrid(); 1434 + evalCache.clear(); invalidateRecalcEngine(); renderGrid(); 1319 1435 1320 1436 if (hasHeaders) { 1321 1437 ydoc.transact(() => { ··· 1385 1501 }); 1386 1502 1387 1503 // --- React to Yjs changes --- 1388 - getCells().observeDeep(() => { evalCache.clear(); scheduleRenderGrid(); updateFormulaBar(); }); 1504 + getCells().observeDeep(() => { evalCache.clear(); invalidateRecalcEngine(); scheduleRenderGrid(); updateFormulaBar(); }); 1389 1505 ySheets.observe(() => { renderSheetTabs(); }); 1390 1506 1391 1507 // Re-render when colWidths, freeze state, CF rules, validations, or stripedRows change from remote collaborators ··· 1400 1516 } 1401 1517 // CF rules or validations changed 1402 1518 if (event.target && (event.target === getCfRules() || event.target === getValidations())) { 1403 - evalCache.clear(); refreshVisibleCells(); return; 1519 + evalCache.clear(); invalidateRecalcEngine(); refreshVisibleCells(); return; 1404 1520 } 1405 1521 } 1406 1522 }); ··· 1952 2068 }); 1953 2069 }); 1954 2070 1955 - evalCache.clear(); 2071 + evalCache.clear(); invalidateRecalcEngine(); 1956 2072 refreshVisibleCells(); 1957 2073 overlay.remove(); 1958 2074 }); ··· 2122 2238 const yArr = getCfRules(); 2123 2239 ydoc.transact(() => { yArr.delete(idx, 1); }); 2124 2240 renderCfModal(); 2125 - evalCache.clear(); 2241 + evalCache.clear(); invalidateRecalcEngine(); 2126 2242 refreshVisibleCells(); 2127 2243 }); 2128 2244 }); ··· 2140 2256 const yArr = getCfRules(); 2141 2257 ydoc.transact(() => { yArr.push([JSON.stringify(rule)]); }); 2142 2258 renderCfModal(); 2143 - evalCache.clear(); 2259 + evalCache.clear(); invalidateRecalcEngine(); 2144 2260 refreshVisibleCells(); 2145 2261 }); 2146 2262 ··· 2305 2421 const numVal = Number(item); 2306 2422 const value = item === '' ? '' : (!isNaN(numVal) && item !== '' ? numVal : item); 2307 2423 setCellData(cellIdStr, { v: value, f: '' }); 2308 - evalCache.clear(); 2424 + evalCache.clear(); invalidateRecalcEngine(); 2309 2425 refreshVisibleCells(); 2310 2426 dropdown.remove(); 2311 2427 }); ··· 2540 2656 hideAutocomplete(); 2541 2657 } 2542 2658 }); 2659 + 2660 + // ======================================================== 2661 + // Formula UX: Syntax Highlighting, Range Highlights, Tooltips 2662 + // ======================================================== 2663 + 2664 + formulaInput.addEventListener('input', onFormulaInputUpdate); 2665 + formulaInput.addEventListener('click', () => { 2666 + updateFormulaTooltip(formulaInput.value, formulaInput.selectionStart, formulaInput); 2667 + }); 2668 + formulaInput.addEventListener('keyup', (e) => { 2669 + if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) { 2670 + updateFormulaTooltip(formulaInput.value, formulaInput.selectionStart, formulaInput); 2671 + } 2672 + }); 2673 + formulaInput.addEventListener('scroll', () => { 2674 + if (formulaHighlightLayer) { 2675 + formulaHighlightLayer.scrollLeft = formulaInput.scrollLeft; 2676 + } 2677 + }); 2678 + formulaInput.addEventListener('focus', () => { 2679 + const text = formulaInput.value; 2680 + updateFormulaHighlight(text); 2681 + updateFormulaRangeHighlights(text); 2682 + }); 2683 + formulaInput.addEventListener('blur', () => { 2684 + hideTooltip(); 2685 + clearGridHighlights(); 2686 + }); 2687 + 2688 + function attachCellEditorFormulaUX(inputEl, anchorTd) { 2689 + inputEl.addEventListener('input', () => { 2690 + const text = inputEl.value; 2691 + formulaInput.value = text; 2692 + updateFormulaHighlight(text); 2693 + updateFormulaRangeHighlights(text); 2694 + updateFormulaTooltip(text, inputEl.selectionStart, anchorTd); 2695 + }); 2696 + inputEl.addEventListener('click', () => { 2697 + updateFormulaTooltip(inputEl.value, inputEl.selectionStart, anchorTd); 2698 + }); 2699 + inputEl.addEventListener('keyup', (e) => { 2700 + if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) { 2701 + updateFormulaTooltip(inputEl.value, inputEl.selectionStart, anchorTd); 2702 + } 2703 + }); 2704 + } 2543 2705 2544 2706 // ======================================================== 2545 2707 // Cell Notes
+272
src/sheets/range-highlight.js
··· 1 + /** 2 + * Range Highlighting While Editing (Chainlink #95). 3 + * 4 + * When editing a formula, highlights referenced cells/ranges on the grid 5 + * with colored borders/overlays. Each unique reference gets a color from 6 + * a rotating palette of 6 colors. 7 + */ 8 + 9 + /** 10 + * 6-color rotating palette for range highlights. 11 + * Each entry is an OkLch color string usable in both light and dark themes. 12 + * The colors are designed to be vibrant enough to stand out on the grid 13 + * but not overpowering. 14 + */ 15 + export const RANGE_COLORS = [ 16 + 'oklch(0.55 0.2 250)', // blue 17 + 'oklch(0.55 0.18 155)', // green 18 + 'oklch(0.5 0.2 300)', // purple 19 + 'oklch(0.6 0.18 60)', // orange 20 + 'oklch(0.55 0.2 25)', // red 21 + 'oklch(0.55 0.15 195)', // teal 22 + ]; 23 + 24 + // Cell reference pattern: optional $, 1-3 letters, optional $, 1+ digits 25 + const CELL_REF_RE = /\$?[A-Z]{1,3}\$?\d+/gi; 26 + 27 + // Range pattern: cellref:cellref 28 + const RANGE_RE = /(\$?[A-Z]{1,3}\$?\d+):(\$?[A-Z]{1,3}\$?\d+)/gi; 29 + 30 + // Cross-sheet ref pattern: SheetName!CellRef or 'Sheet Name'!CellRef 31 + const CROSS_SHEET_RE = /(?:'[^']+?'|[A-Za-z_]\w*)!(\$?[A-Z]{1,3}\$?\d+(?::\$?[A-Z]{1,3}\$?\d+)?)/gi; 32 + 33 + // Quoted cross-sheet ref: 'Sheet Name'!A1:B5 34 + const QUOTED_CROSS_SHEET_RE = /'([^']+)'!(\$?[A-Z]{1,3}\$?\d+(?::\$?[A-Z]{1,3}\$?\d+)?)/gi; 35 + 36 + // Unquoted cross-sheet ref: Sheet2!A1:B5 37 + const UNQUOTED_CROSS_SHEET_RE = /([A-Za-z_]\w*)!(\$?[A-Z]{1,3}\$?\d+(?::\$?[A-Z]{1,3}\$?\d+)?)/gi; 38 + 39 + // Known function names to avoid matching as cell refs 40 + const KNOWN_FUNCTIONS = new Set([ 41 + 'SUM', 'AVERAGE', 'COUNT', 'COUNTA', 'MIN', 'MAX', 'MEDIAN', 'STDEV', 42 + 'ABS', 'ROUND', 'ROUNDUP', 'ROUNDDOWN', 'INT', 'MOD', 'POWER', 'SQRT', 43 + 'LOG', 'LN', 'EXP', 'PI', 'RAND', 44 + 'IF', 'AND', 'OR', 'NOT', 'IFERROR', 45 + 'CONCATENATE', 'LEN', 'LEFT', 'RIGHT', 'MID', 46 + 'UPPER', 'LOWER', 'TRIM', 'SUBSTITUTE', 'FIND', 'SEARCH', 47 + 'TEXT', 'VALUE', 48 + 'NOW', 'TODAY', 'DATE', 'YEAR', 'MONTH', 'DAY', 49 + 'VLOOKUP', 'HLOOKUP', 'INDEX', 'MATCH', 50 + 'SUMIF', 'COUNTIF', 'AVERAGEIF', 51 + 'TRUE', 'FALSE', 52 + ]); 53 + 54 + /** 55 + * Extract all cell/range references from a formula string, 56 + * along with their positions in the string. 57 + * 58 + * @param {string} formula - The formula (without leading '=') 59 + * @returns {Array<{ref: string, startIndex: number, endIndex: number}>} 60 + */ 61 + export function extractFormulaRanges(formula) { 62 + const results = []; 63 + // Track which positions we've already consumed (for cross-sheet refs) 64 + const consumed = new Set(); 65 + 66 + // 1. Extract quoted cross-sheet refs first: 'Sheet Name'!A1:B5 67 + { 68 + const re = /'([^']+)'!(\$?[A-Z]{1,3}\$?\d+(?::\$?[A-Z]{1,3}\$?\d+)?)/gi; 69 + let m; 70 + while ((m = re.exec(formula)) !== null) { 71 + const fullRef = m[0]; // e.g. 'My Sheet'!A1 72 + results.push({ 73 + ref: fullRef, 74 + startIndex: m.index, 75 + endIndex: m.index + fullRef.length, 76 + }); 77 + for (let p = m.index; p < m.index + fullRef.length; p++) { 78 + consumed.add(p); 79 + } 80 + } 81 + } 82 + 83 + // 2. Extract unquoted cross-sheet refs: Sheet2!A1:B5 84 + { 85 + const re = /([A-Za-z_]\w*)!(\$?[A-Z]{1,3}\$?\d+(?::\$?[A-Z]{1,3}\$?\d+)?)/gi; 86 + let m; 87 + while ((m = re.exec(formula)) !== null) { 88 + if (consumed.has(m.index)) continue; 89 + const sheetName = m[1]; 90 + // Don't match if sheetName is a known function (shouldn't happen, but be safe) 91 + if (KNOWN_FUNCTIONS.has(sheetName.toUpperCase())) continue; 92 + const fullRef = m[0]; // e.g. Sheet2!A1 93 + results.push({ 94 + ref: fullRef, 95 + startIndex: m.index, 96 + endIndex: m.index + fullRef.length, 97 + }); 98 + for (let p = m.index; p < m.index + fullRef.length; p++) { 99 + consumed.add(p); 100 + } 101 + } 102 + } 103 + 104 + // 3. Extract ranges (A1:B5) that aren't part of cross-sheet refs 105 + { 106 + const re = /(\$?[A-Z]{1,3}\$?\d+):(\$?[A-Z]{1,3}\$?\d+)/gi; 107 + let m; 108 + while ((m = re.exec(formula)) !== null) { 109 + if (consumed.has(m.index)) continue; 110 + const ref = m[0]; 111 + // Verify neither part looks like a function name 112 + const part1 = m[1].replace(/\$/g, '').replace(/\d+$/, ''); 113 + if (KNOWN_FUNCTIONS.has(part1.toUpperCase())) continue; 114 + results.push({ 115 + ref, 116 + startIndex: m.index, 117 + endIndex: m.index + ref.length, 118 + }); 119 + for (let p = m.index; p < m.index + ref.length; p++) { 120 + consumed.add(p); 121 + } 122 + } 123 + } 124 + 125 + // 4. Extract single cell refs that aren't already consumed 126 + { 127 + const re = /\$?[A-Z]{1,3}\$?\d+/gi; 128 + let m; 129 + while ((m = re.exec(formula)) !== null) { 130 + if (consumed.has(m.index)) continue; 131 + const ref = m[0]; 132 + // Check if this is preceded by a letter (part of a word like SUM) 133 + if (m.index > 0) { 134 + const prevChar = formula[m.index - 1]; 135 + if (/[A-Za-z_]/.test(prevChar)) continue; 136 + } 137 + // Check the stripped version is a valid cell ref 138 + const stripped = ref.replace(/\$/g, ''); 139 + const letters = stripped.replace(/\d+$/, ''); 140 + if (KNOWN_FUNCTIONS.has(letters.toUpperCase()) && letters.length === stripped.replace(/\d+$/, '').length) { 141 + // Could be a function name prefix — skip if followed by ( 142 + const afterEnd = m.index + ref.length; 143 + let peek = afterEnd; 144 + while (peek < formula.length && formula[peek] === ' ') peek++; 145 + if (peek < formula.length && formula[peek] === '(') continue; 146 + } 147 + results.push({ 148 + ref, 149 + startIndex: m.index, 150 + endIndex: m.index + ref.length, 151 + }); 152 + for (let p = m.index; p < m.index + ref.length; p++) { 153 + consumed.add(p); 154 + } 155 + } 156 + } 157 + 158 + // Sort by position 159 + results.sort((a, b) => a.startIndex - b.startIndex); 160 + 161 + return results; 162 + } 163 + 164 + /** 165 + * Assign colors from the rotating palette to each range reference. 166 + * The same ref always gets the same color within a formula. 167 + * 168 + * @param {Array<{ref: string, startIndex: number, endIndex: number}>} ranges 169 + * @returns {Array<{ref: string, startIndex: number, endIndex: number, color: string}>} 170 + */ 171 + export function assignRangeColors(ranges) { 172 + if (ranges.length === 0) return []; 173 + 174 + const colorMap = new Map(); 175 + let nextColorIdx = 0; 176 + 177 + return ranges.map(r => { 178 + if (!colorMap.has(r.ref)) { 179 + colorMap.set(r.ref, RANGE_COLORS[nextColorIdx % RANGE_COLORS.length]); 180 + nextColorIdx++; 181 + } 182 + return { 183 + ...r, 184 + color: colorMap.get(r.ref), 185 + }; 186 + }); 187 + } 188 + 189 + /** 190 + * Render colored overlay elements on the grid for highlighted ranges. 191 + * Creates absolutely positioned divs over the referenced cells. 192 + * 193 + * @param {Array<{ref: string, color: string}>} coloredRanges 194 + * @param {HTMLElement} gridElement - The sheet-grid table element 195 + * @param {Function} parseRef - Function to parse cell refs into {col, row} 196 + * @param {Function} colToLetter - Function to convert col number to letter 197 + */ 198 + export function renderGridHighlights(coloredRanges, gridElement, parseRef, colToLetter) { 199 + clearGridHighlights(); 200 + 201 + for (const range of coloredRanges) { 202 + const ref = range.ref; 203 + // Strip sheet name prefix for grid cell lookup 204 + const localRef = ref.includes('!') ? ref.split('!').pop() : ref; 205 + 206 + // Handle range refs (A1:B5) 207 + if (localRef.includes(':')) { 208 + const [startRef, endRef] = localRef.split(':'); 209 + const start = parseRef(startRef.replace(/\$/g, '').toUpperCase()); 210 + const end = parseRef(endRef.replace(/\$/g, '').toUpperCase()); 211 + if (!start || !end) continue; 212 + 213 + const rowMin = Math.min(start.row, end.row); 214 + const rowMax = Math.max(start.row, end.row); 215 + const colMin = Math.min(start.col, end.col); 216 + const colMax = Math.max(start.col, end.col); 217 + 218 + for (let r = rowMin; r <= rowMax; r++) { 219 + for (let c = colMin; c <= colMax; c++) { 220 + const cellRef = colToLetter(c) + r; 221 + highlightCell(gridElement, cellRef, range.color, { 222 + top: r === rowMin, 223 + bottom: r === rowMax, 224 + left: c === colMin, 225 + right: c === colMax, 226 + }); 227 + } 228 + } 229 + } else { 230 + // Single cell ref 231 + const cellRef = localRef.replace(/\$/g, '').toUpperCase(); 232 + highlightCell(gridElement, cellRef, range.color, { 233 + top: true, bottom: true, left: true, right: true, 234 + }); 235 + } 236 + } 237 + } 238 + 239 + /** 240 + * Add a highlight overlay to a single cell. 241 + */ 242 + function highlightCell(gridElement, cellRef, color, borders) { 243 + const td = gridElement.querySelector(`td[data-id="${cellRef}"]`); 244 + if (!td) return; 245 + 246 + const overlay = document.createElement('div'); 247 + overlay.className = 'range-highlight-overlay'; 248 + overlay.style.position = 'absolute'; 249 + overlay.style.inset = '0'; 250 + overlay.style.pointerEvents = 'none'; 251 + overlay.style.zIndex = '5'; 252 + 253 + // Build border style — only add border on edges of the range 254 + const bw = '2px'; 255 + overlay.style.borderTop = borders.top ? `${bw} solid ${color}` : 'none'; 256 + overlay.style.borderBottom = borders.bottom ? `${bw} solid ${color}` : 'none'; 257 + overlay.style.borderLeft = borders.left ? `${bw} solid ${color}` : 'none'; 258 + overlay.style.borderRight = borders.right ? `${bw} solid ${color}` : 'none'; 259 + 260 + // Light background fill 261 + overlay.style.backgroundColor = color.replace(')', ' / 0.08)').replace('oklch(', 'oklch('); 262 + 263 + td.style.position = 'relative'; 264 + td.appendChild(overlay); 265 + } 266 + 267 + /** 268 + * Remove all range highlight overlays from the grid. 269 + */ 270 + export function clearGridHighlights() { 271 + document.querySelectorAll('.range-highlight-overlay').forEach(el => el.remove()); 272 + }
+511
src/sheets/recalc.js
··· 1 + /** 2 + * Topological recalculation engine for the spreadsheet. 3 + * 4 + * Features: 5 + * - Builds a dependency graph from cell formulas 6 + * - Recalculates only dirty cells in topological order (Kahn's algorithm) 7 + * - Detects circular references and marks them with #CIRCULAR! 8 + * - Tracks volatile functions (NOW, TODAY, RAND, RANDBETWEEN) 9 + * - Supports incremental graph updates when a cell's formula changes 10 + * - Works with cross-sheet references (SheetName!CellId keys) 11 + * 12 + * This module is DOM-free and operates on an abstract cell store interface. 13 + */ 14 + 15 + import { extractRefs, evaluate, parseRef, colToLetter } from './formulas.js'; 16 + 17 + // --- Volatile functions --- 18 + 19 + export const VOLATILE_FUNCTIONS = ['NOW', 'TODAY', 'RAND', 'RANDBETWEEN']; 20 + 21 + /** 22 + * Check if a formula contains any volatile function. 23 + * @param {string} formula - Formula string (without leading '=') 24 + * @returns {boolean} 25 + */ 26 + export function isVolatile(formula) { 27 + const upper = formula.toUpperCase(); 28 + return VOLATILE_FUNCTIONS.some(fn => upper.includes(fn + '(') || upper.includes(fn + ' (')); 29 + } 30 + 31 + // --- Recalculation Engine --- 32 + 33 + /** 34 + * @typedef {Object} CellStore 35 + * @property {(id: string) => {v: any, f: string} | null} get 36 + * @property {(id: string, cell: {v: any, f: string}) => void} set 37 + * @property {(id: string) => boolean} has 38 + * @property {() => IterableIterator<[string, {v: any, f: string}]>} entries 39 + * @property {() => [string, {v: any, f: string}][]} getAllFormulaCells 40 + */ 41 + 42 + /** 43 + * @typedef {Object} RecalcOptions 44 + * @property {(cellId: string) => void} [onEvaluate] - Called when a cell is evaluated (for testing) 45 + * @property {Object} [namedRanges] - Map of lowercase name -> { range, sheet } 46 + * @property {Object} [crossSheetResolver] - Resolver for cross-sheet references 47 + */ 48 + 49 + export class RecalcEngine { 50 + /** 51 + * @param {CellStore} store - Cell data store 52 + * @param {RecalcOptions} [options] 53 + */ 54 + constructor(store, options = {}) { 55 + this.store = store; 56 + this.options = options; 57 + 58 + // Dependency graph 59 + // precedents: cellId -> Set<string> (cells this cell depends on) 60 + // dependents: cellId -> Set<string> (cells that depend on this cell) 61 + this.precedents = new Map(); 62 + this.dependents = new Map(); 63 + 64 + // Volatile cell tracking 65 + this.volatileCells = new Set(); 66 + 67 + // Cycle information from the last recalculation 68 + this._cyclePaths = []; 69 + } 70 + 71 + /** 72 + * Build the full dependency graph from scratch. 73 + * Call this once at initialization or when the entire sheet changes. 74 + */ 75 + buildFullGraph() { 76 + this.precedents.clear(); 77 + this.dependents.clear(); 78 + this.volatileCells.clear(); 79 + 80 + for (const [id, cell] of this.store.entries()) { 81 + if (!cell.f) continue; 82 + this._addCellEdges(id, cell.f); 83 + } 84 + } 85 + 86 + /** 87 + * Incrementally update the graph for a single cell whose formula changed. 88 + * @param {string} cellId 89 + */ 90 + updateCell(cellId) { 91 + // Remove old edges 92 + this._removeCellEdges(cellId); 93 + 94 + // Add new edges if the cell has a formula 95 + const cell = this.store.get(cellId); 96 + if (cell && cell.f) { 97 + this._addCellEdges(cellId, cell.f); 98 + } 99 + } 100 + 101 + /** 102 + * Get the set of cells that cellId depends on (its inputs). 103 + * @param {string} cellId 104 + * @returns {Set<string>} 105 + */ 106 + getPrecedents(cellId) { 107 + return this.precedents.get(cellId) || new Set(); 108 + } 109 + 110 + /** 111 + * Get the set of cells that depend on cellId (its outputs). 112 + * @param {string} cellId 113 + * @returns {Set<string>} 114 + */ 115 + getDependents(cellId) { 116 + return this.dependents.get(cellId) || new Set(); 117 + } 118 + 119 + /** 120 + * Get cycle paths detected during the last recalculation. 121 + * Each path is an array of cellIds forming the cycle, e.g. ["A1", "B1", "C1", "A1"]. 122 + * @returns {string[][]} 123 + */ 124 + getCyclePaths() { 125 + return this._cyclePaths; 126 + } 127 + 128 + /** 129 + * Recalculate after a single cell edit. 130 + * Dirty-marks the edited cell + all transitive dependents, 131 + * then recalculates in topological order. 132 + * 133 + * @param {string} editedCellId - The cell that was edited 134 + * @returns {Set<string>} Set of cell IDs whose display values actually changed 135 + */ 136 + recalculate(editedCellId) { 137 + return this.recalculateMultiple([editedCellId]); 138 + } 139 + 140 + /** 141 + * Recalculate after multiple cell edits. 142 + * @param {string[]} editedCellIds - The cells that were edited 143 + * @returns {Set<string>} Set of cell IDs whose display values actually changed 144 + */ 145 + recalculateMultiple(editedCellIds) { 146 + // Step 1: Collect all dirty cells (edited + transitive dependents) 147 + const dirty = this._collectDirty(editedCellIds); 148 + 149 + // Step 2: Topological sort of dirty cells, detect cycles 150 + return this._evaluateDirty(dirty); 151 + } 152 + 153 + /** 154 + * Recalculate all volatile cells and their dependents. 155 + * Call this on a timer or on any UI interaction. 156 + * @returns {Set<string>} Set of cell IDs whose display values actually changed 157 + */ 158 + recalculateVolatile() { 159 + if (this.volatileCells.size === 0) return new Set(); 160 + return this.recalculateMultiple([...this.volatileCells]); 161 + } 162 + 163 + // --- Internal methods --- 164 + 165 + /** 166 + * Add edges for a cell's formula to the graph. 167 + * @param {string} cellId 168 + * @param {string} formula 169 + */ 170 + _addCellEdges(cellId, formula) { 171 + let refs = extractRefs(formula); 172 + 173 + // Also resolve named ranges to their constituent cells 174 + if (this.options.namedRanges) { 175 + refs = this._resolveNamedRangeRefs(formula, refs); 176 + } 177 + 178 + if (refs.size === 0) { 179 + // Formula with no cell refs (e.g., constant or volatile like RAND()) 180 + // Still track it if volatile 181 + if (isVolatile(formula)) { 182 + this.volatileCells.add(cellId); 183 + } 184 + return; 185 + } 186 + 187 + this.precedents.set(cellId, new Set(refs)); 188 + 189 + for (const ref of refs) { 190 + if (!this.dependents.has(ref)) { 191 + this.dependents.set(ref, new Set()); 192 + } 193 + this.dependents.get(ref).add(cellId); 194 + } 195 + 196 + // Track volatile cells 197 + if (isVolatile(formula)) { 198 + this.volatileCells.add(cellId); 199 + } 200 + } 201 + 202 + /** 203 + * Remove all edges for a cell from the graph. 204 + * @param {string} cellId 205 + */ 206 + _removeCellEdges(cellId) { 207 + const oldPrecs = this.precedents.get(cellId); 208 + if (oldPrecs) { 209 + for (const ref of oldPrecs) { 210 + const deps = this.dependents.get(ref); 211 + if (deps) { 212 + deps.delete(cellId); 213 + if (deps.size === 0) this.dependents.delete(ref); 214 + } 215 + } 216 + this.precedents.delete(cellId); 217 + } 218 + this.volatileCells.delete(cellId); 219 + } 220 + 221 + /** 222 + * Resolve named range identifiers in a formula to actual cell references. 223 + * Returns the union of extractRefs results and named range cell refs. 224 + * @param {string} formula 225 + * @param {Set<string>} existingRefs 226 + * @returns {Set<string>} 227 + */ 228 + _resolveNamedRangeRefs(formula, existingRefs) { 229 + const namedRanges = this.options.namedRanges; 230 + if (!namedRanges) return existingRefs; 231 + 232 + const combined = new Set(existingRefs); 233 + 234 + // Scan formula for identifiers that match named ranges 235 + // Simple approach: tokenize and look for identifiers 236 + const identPattern = /[A-Za-z_][A-Za-z0-9_.]*(?=\s*[\),+\-*/^&<>=]|\s*$)/g; 237 + let match; 238 + while ((match = identPattern.exec(formula)) !== null) { 239 + const name = match[0].toLowerCase(); 240 + const entry = namedRanges[name]; 241 + if (entry) { 242 + const rangeStr = entry.range; 243 + const parts = rangeStr.split(':'); 244 + if (parts.length === 2) { 245 + // Range — expand to individual cells 246 + const start = parseRef(parts[0]); 247 + const end = parseRef(parts[1]); 248 + if (start && end) { 249 + const rowMin = Math.min(start.row, end.row); 250 + const rowMax = Math.max(start.row, end.row); 251 + const colMin = Math.min(start.col, end.col); 252 + const colMax = Math.max(start.col, end.col); 253 + for (let r = rowMin; r <= rowMax; r++) { 254 + for (let c = colMin; c <= colMax; c++) { 255 + combined.add(colToLetter(c) + r); 256 + } 257 + } 258 + } 259 + } else { 260 + // Single cell 261 + combined.add(parts[0]); 262 + } 263 + } 264 + } 265 + 266 + return combined; 267 + } 268 + 269 + /** 270 + * Collect all dirty cells: the edited cells + all their transitive dependents. 271 + * Uses BFS to walk the dependents graph. 272 + * @param {string[]} editedCellIds 273 + * @returns {Set<string>} 274 + */ 275 + _collectDirty(editedCellIds) { 276 + const dirty = new Set(); 277 + const queue = [...editedCellIds]; 278 + 279 + while (queue.length > 0) { 280 + const cellId = queue.shift(); 281 + if (dirty.has(cellId)) continue; 282 + dirty.add(cellId); 283 + 284 + const deps = this.dependents.get(cellId); 285 + if (deps) { 286 + for (const dep of deps) { 287 + if (!dirty.has(dep)) { 288 + queue.push(dep); 289 + } 290 + } 291 + } 292 + } 293 + 294 + return dirty; 295 + } 296 + 297 + /** 298 + * Evaluate dirty cells in topological order using Kahn's algorithm. 299 + * Detects cycles: any cell still with in-edges after Kahn's completes is in a cycle. 300 + * 301 + * @param {Set<string>} dirty - Set of dirty cell IDs 302 + * @returns {Set<string>} Set of cell IDs whose display value actually changed 303 + */ 304 + _evaluateDirty(dirty) { 305 + this._cyclePaths = []; 306 + const changed = new Set(); 307 + 308 + // Filter to only formula cells that need recalculation 309 + const formulaCells = new Set(); 310 + for (const cellId of dirty) { 311 + const cell = this.store.get(cellId); 312 + if (cell && cell.f) { 313 + formulaCells.add(cellId); 314 + } 315 + } 316 + 317 + if (formulaCells.size === 0) return changed; 318 + 319 + // Build the sub-graph induced by dirty formula cells. 320 + // In-degree only counts edges from OTHER formula cells in the dirty set. 321 + // Non-formula dirty cells (edited value cells) are already resolved — they 322 + // act as sources in the topological sort without needing evaluation. 323 + const inDegree = new Map(); 324 + const subDeps = new Map(); // within the dirty subgraph: source -> Set<target> 325 + 326 + for (const cellId of formulaCells) { 327 + let degree = 0; 328 + const precs = this.precedents.get(cellId); 329 + if (precs) { 330 + for (const prec of precs) { 331 + // Only count in-degree from other dirty FORMULA cells 332 + if (formulaCells.has(prec) && prec !== cellId) { 333 + degree++; 334 + if (!subDeps.has(prec)) subDeps.set(prec, new Set()); 335 + subDeps.get(prec).add(cellId); 336 + } 337 + // Self-reference counts toward cycle detection 338 + if (prec === cellId) { 339 + degree++; 340 + if (!subDeps.has(prec)) subDeps.set(prec, new Set()); 341 + subDeps.get(prec).add(cellId); 342 + } 343 + } 344 + } 345 + inDegree.set(cellId, degree); 346 + } 347 + 348 + // Kahn's algorithm 349 + const queue = []; 350 + for (const [cellId, degree] of inDegree) { 351 + if (degree === 0) { 352 + queue.push(cellId); 353 + } 354 + } 355 + 356 + const sorted = []; 357 + const sortedSet = new Set(); 358 + while (queue.length > 0) { 359 + const cellId = queue.shift(); 360 + sorted.push(cellId); 361 + sortedSet.add(cellId); 362 + 363 + const deps = subDeps.get(cellId); 364 + if (deps) { 365 + for (const dep of deps) { 366 + if (!inDegree.has(dep)) continue; 367 + const newDeg = inDegree.get(dep) - 1; 368 + inDegree.set(dep, newDeg); 369 + if (newDeg === 0) { 370 + queue.push(dep); 371 + } 372 + } 373 + } 374 + } 375 + 376 + // Detect cycles: formula cells not in sorted order are in cycles 377 + const cycleCells = new Set(); 378 + for (const cellId of formulaCells) { 379 + if (!sortedSet.has(cellId)) { 380 + cycleCells.add(cellId); 381 + } 382 + } 383 + 384 + // Build cycle paths for reporting 385 + if (cycleCells.size > 0) { 386 + this._buildCyclePaths(cycleCells); 387 + } 388 + 389 + // Evaluate cells in topological order 390 + for (const cellId of sorted) { 391 + this._evaluateCell(cellId, changed); 392 + } 393 + 394 + // Mark cycle cells with #CIRCULAR! error 395 + for (const cellId of cycleCells) { 396 + const cell = this.store.get(cellId); 397 + if (cell) { 398 + const oldVal = cell.v; 399 + cell.v = '#CIRCULAR!'; 400 + this.store.set(cellId, cell); 401 + if (oldVal !== '#CIRCULAR!') { 402 + changed.add(cellId); 403 + } 404 + } 405 + } 406 + 407 + return changed; 408 + } 409 + 410 + /** 411 + * Evaluate a single cell's formula and update its value in the store. 412 + * @param {string} cellId 413 + * @param {Set<string>} changed - Accumulator for cells whose values changed 414 + */ 415 + _evaluateCell(cellId, changed) { 416 + const cell = this.store.get(cellId); 417 + if (!cell || !cell.f) return; 418 + 419 + if (this.options.onEvaluate) { 420 + this.options.onEvaluate(cellId); 421 + } 422 + 423 + const oldVal = cell.v; 424 + 425 + // Build a getCellValue that reads from the store 426 + const getCellValue = (ref) => { 427 + const data = this.store.get(ref); 428 + if (!data) return ''; 429 + if (data.f) return data.v; // Already evaluated (topo order guarantees this) 430 + return data.v ?? ''; 431 + }; 432 + 433 + const result = evaluate( 434 + cell.f, 435 + getCellValue, 436 + this.options.crossSheetResolver || null, 437 + this.options.namedRanges || null, 438 + ); 439 + 440 + cell.v = result; 441 + this.store.set(cellId, cell); 442 + 443 + // Check if the display value actually changed 444 + if (!Object.is(oldVal, result) && !(oldVal === '' && result === '') && String(oldVal) !== String(result)) { 445 + changed.add(cellId); 446 + } 447 + } 448 + 449 + /** 450 + * Build cycle path information for reporting. 451 + * Uses DFS from cycle cells to find actual cycle paths. 452 + * @param {Set<string>} cycleCells 453 + */ 454 + _buildCyclePaths(cycleCells) { 455 + const visited = new Set(); 456 + const paths = []; 457 + 458 + for (const startCell of cycleCells) { 459 + if (visited.has(startCell)) continue; 460 + 461 + // DFS to find a cycle 462 + const path = []; 463 + const inStack = new Set(); 464 + const found = this._dfsFindCycle(startCell, cycleCells, path, inStack, visited); 465 + if (found) { 466 + paths.push(found); 467 + } 468 + } 469 + 470 + this._cyclePaths = paths; 471 + } 472 + 473 + /** 474 + * DFS helper to find a cycle path starting from a cell. 475 + * @param {string} cellId 476 + * @param {Set<string>} cycleCells 477 + * @param {string[]} path 478 + * @param {Set<string>} inStack 479 + * @param {Set<string>} visited 480 + * @returns {string[] | null} 481 + */ 482 + _dfsFindCycle(cellId, cycleCells, path, inStack, visited) { 483 + if (inStack.has(cellId)) { 484 + // Found a cycle — extract path from the first occurrence to here 485 + const cycleStart = path.indexOf(cellId); 486 + const cyclePath = path.slice(cycleStart); 487 + cyclePath.push(cellId); 488 + return cyclePath; 489 + } 490 + 491 + if (visited.has(cellId)) return null; 492 + 493 + inStack.add(cellId); 494 + path.push(cellId); 495 + visited.add(cellId); 496 + 497 + const precs = this.precedents.get(cellId); 498 + if (precs) { 499 + for (const prec of precs) { 500 + if (cycleCells.has(prec)) { 501 + const result = this._dfsFindCycle(prec, cycleCells, path, inStack, visited); 502 + if (result) return result; 503 + } 504 + } 505 + } 506 + 507 + inStack.delete(cellId); 508 + path.pop(); 509 + return null; 510 + } 511 + }
+268
tests/formula-highlighter.test.js
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + tokenizeForHighlighting, 4 + renderHighlightedFormula, 5 + } from '../src/sheets/formula-highlighter.js'; 6 + 7 + /** 8 + * Tests for Formula Bar Syntax Highlighting (Chainlink #94). 9 + * 10 + * When a cell with a formula is selected or being edited, the formula bar 11 + * should syntax-highlight the formula text with colored spans for each 12 + * token type: cell refs, functions, strings, numbers, operators, parens, errors. 13 + */ 14 + 15 + describe('tokenizeForHighlighting', () => { 16 + it('is a function', () => { 17 + expect(typeof tokenizeForHighlighting).toBe('function'); 18 + }); 19 + 20 + it('returns an array of tokens', () => { 21 + const tokens = tokenizeForHighlighting('=SUM(A1:B5)'); 22 + expect(Array.isArray(tokens)).toBe(true); 23 + expect(tokens.length).toBeGreaterThan(0); 24 + }); 25 + 26 + it('each token has text, type, start, and end', () => { 27 + const tokens = tokenizeForHighlighting('=SUM(A1:B5)'); 28 + for (const t of tokens) { 29 + expect(t).toHaveProperty('text'); 30 + expect(t).toHaveProperty('type'); 31 + expect(t).toHaveProperty('start'); 32 + expect(t).toHaveProperty('end'); 33 + expect(typeof t.text).toBe('string'); 34 + expect(typeof t.type).toBe('string'); 35 + expect(typeof t.start).toBe('number'); 36 + expect(typeof t.end).toBe('number'); 37 + } 38 + }); 39 + 40 + it('tokenizes a simple number', () => { 41 + const tokens = tokenizeForHighlighting('=42'); 42 + const numToken = tokens.find(t => t.type === 'number'); 43 + expect(numToken).toBeTruthy(); 44 + expect(numToken.text).toBe('42'); 45 + }); 46 + 47 + it('tokenizes a decimal number', () => { 48 + const tokens = tokenizeForHighlighting('=3.14'); 49 + const numToken = tokens.find(t => t.type === 'number'); 50 + expect(numToken).toBeTruthy(); 51 + expect(numToken.text).toBe('3.14'); 52 + }); 53 + 54 + it('tokenizes a string literal', () => { 55 + const tokens = tokenizeForHighlighting('="hello"'); 56 + const strToken = tokens.find(t => t.type === 'string'); 57 + expect(strToken).toBeTruthy(); 58 + expect(strToken.text).toBe('"hello"'); 59 + }); 60 + 61 + it('tokenizes a function name', () => { 62 + const tokens = tokenizeForHighlighting('=SUM(A1)'); 63 + const fnToken = tokens.find(t => t.type === 'function'); 64 + expect(fnToken).toBeTruthy(); 65 + expect(fnToken.text).toBe('SUM'); 66 + }); 67 + 68 + it('tokenizes a cell reference', () => { 69 + const tokens = tokenizeForHighlighting('=A1'); 70 + const refToken = tokens.find(t => t.type === 'cell_ref'); 71 + expect(refToken).toBeTruthy(); 72 + expect(refToken.text).toBe('A1'); 73 + }); 74 + 75 + it('tokenizes absolute cell references', () => { 76 + const tokens = tokenizeForHighlighting('=$B$2'); 77 + const refToken = tokens.find(t => t.type === 'cell_ref'); 78 + expect(refToken).toBeTruthy(); 79 + expect(refToken.text).toBe('$B$2'); 80 + }); 81 + 82 + it('tokenizes a cross-sheet reference', () => { 83 + const tokens = tokenizeForHighlighting('=Sheet2!A1'); 84 + const refToken = tokens.find(t => t.type === 'cell_ref'); 85 + expect(refToken).toBeTruthy(); 86 + expect(refToken.text).toBe('Sheet2!A1'); 87 + }); 88 + 89 + it('tokenizes operators', () => { 90 + const tokens = tokenizeForHighlighting('=A1+B1*2'); 91 + const ops = tokens.filter(t => t.type === 'operator'); 92 + // Includes the leading '=', '+', and '*' 93 + expect(ops.length).toBe(3); 94 + expect(ops[0].text).toBe('='); 95 + expect(ops[1].text).toBe('+'); 96 + expect(ops[2].text).toBe('*'); 97 + }); 98 + 99 + it('tokenizes comparison operators', () => { 100 + const tokens = tokenizeForHighlighting('=A1>=B1'); 101 + const ops = tokens.filter(t => t.type === 'operator'); 102 + expect(ops.some(o => o.text === '>=')).toBe(true); 103 + }); 104 + 105 + it('tokenizes parentheses', () => { 106 + const tokens = tokenizeForHighlighting('=SUM(A1)'); 107 + const parens = tokens.filter(t => t.type === 'paren'); 108 + expect(parens.length).toBe(2); 109 + expect(parens[0].text).toBe('('); 110 + expect(parens[1].text).toBe(')'); 111 + }); 112 + 113 + it('tokenizes the equals sign', () => { 114 + const tokens = tokenizeForHighlighting('=SUM(A1)'); 115 + expect(tokens[0].type).toBe('operator'); 116 + expect(tokens[0].text).toBe('='); 117 + }); 118 + 119 + it('tokenizes comma separator', () => { 120 + const tokens = tokenizeForHighlighting('=SUM(A1,B1)'); 121 + const commas = tokens.filter(t => t.type === 'operator' && t.text === ','); 122 + expect(commas.length).toBe(1); 123 + }); 124 + 125 + it('tokenizes colon in ranges', () => { 126 + const tokens = tokenizeForHighlighting('=A1:B5'); 127 + const colons = tokens.filter(t => t.type === 'operator' && t.text === ':'); 128 + expect(colons.length).toBe(1); 129 + }); 130 + 131 + it('tokenizes error values', () => { 132 + const tokens = tokenizeForHighlighting('=IFERROR(A1,#REF!)'); 133 + const errors = tokens.filter(t => t.type === 'error'); 134 + expect(errors.length).toBe(1); 135 + expect(errors[0].text).toBe('#REF!'); 136 + }); 137 + 138 + it('tokenizes #N/A error', () => { 139 + const tokens = tokenizeForHighlighting('=#N/A'); 140 + const errors = tokens.filter(t => t.type === 'error'); 141 + expect(errors.length).toBe(1); 142 + expect(errors[0].text).toBe('#N/A'); 143 + }); 144 + 145 + it('tokenizes #VALUE! error', () => { 146 + const tokens = tokenizeForHighlighting('=#VALUE!'); 147 + const errors = tokens.filter(t => t.type === 'error'); 148 + expect(errors.length).toBe(1); 149 + expect(errors[0].text).toBe('#VALUE!'); 150 + }); 151 + 152 + it('handles boolean values', () => { 153 + const tokens = tokenizeForHighlighting('=IF(TRUE,1,0)'); 154 + const boolToken = tokens.find(t => t.type === 'boolean'); 155 + expect(boolToken).toBeTruthy(); 156 + expect(boolToken.text.toUpperCase()).toBe('TRUE'); 157 + }); 158 + 159 + it('preserves original positions correctly', () => { 160 + const formula = '=SUM(A1:B5)'; 161 + const tokens = tokenizeForHighlighting(formula); 162 + // Reconstruct the formula from token positions 163 + for (const t of tokens) { 164 + expect(formula.substring(t.start, t.end)).toBe(t.text); 165 + } 166 + }); 167 + 168 + it('covers the entire formula with no gaps or overlaps', () => { 169 + const formula = '=SUM(A1:B5)+10'; 170 + const tokens = tokenizeForHighlighting(formula); 171 + // Tokens should cover start to end with possible whitespace gaps 172 + let pos = 0; 173 + for (const t of tokens) { 174 + expect(t.start).toBeGreaterThanOrEqual(pos); 175 + pos = t.end; 176 + } 177 + expect(pos).toBe(formula.length); 178 + }); 179 + 180 + it('handles complex nested formulas', () => { 181 + const formula = '=IF(SUM(A1:A10)>100,"high",VLOOKUP(B1,C1:D10,2,FALSE))'; 182 + const tokens = tokenizeForHighlighting(formula); 183 + const fns = tokens.filter(t => t.type === 'function'); 184 + expect(fns.map(f => f.text)).toContain('IF'); 185 + expect(fns.map(f => f.text)).toContain('SUM'); 186 + expect(fns.map(f => f.text)).toContain('VLOOKUP'); 187 + }); 188 + 189 + it('handles whitespace correctly', () => { 190 + const formula = '= SUM( A1 : B5 )'; 191 + const tokens = tokenizeForHighlighting(formula); 192 + // Should have whitespace tokens 193 + const wsTokens = tokens.filter(t => t.type === 'whitespace'); 194 + expect(wsTokens.length).toBeGreaterThan(0); 195 + }); 196 + 197 + it('handles empty formula', () => { 198 + const tokens = tokenizeForHighlighting('='); 199 + expect(tokens.length).toBe(1); 200 + expect(tokens[0].type).toBe('operator'); 201 + expect(tokens[0].text).toBe('='); 202 + }); 203 + 204 + it('handles quoted sheet name in cross-sheet ref', () => { 205 + const tokens = tokenizeForHighlighting("='My Sheet'!A1"); 206 + const refToken = tokens.find(t => t.type === 'cell_ref'); 207 + expect(refToken).toBeTruthy(); 208 + expect(refToken.text).toContain('My Sheet'); 209 + }); 210 + }); 211 + 212 + describe('renderHighlightedFormula', () => { 213 + it('is a function', () => { 214 + expect(typeof renderHighlightedFormula).toBe('function'); 215 + }); 216 + 217 + it('returns an HTML string', () => { 218 + const tokens = tokenizeForHighlighting('=SUM(A1)'); 219 + const html = renderHighlightedFormula(tokens); 220 + expect(typeof html).toBe('string'); 221 + expect(html.length).toBeGreaterThan(0); 222 + }); 223 + 224 + it('wraps tokens in span elements with correct class names', () => { 225 + const tokens = tokenizeForHighlighting('=SUM(A1)'); 226 + const html = renderHighlightedFormula(tokens); 227 + expect(html).toContain('formula-token-function'); 228 + expect(html).toContain('formula-token-cell_ref'); 229 + expect(html).toContain('formula-token-paren'); 230 + }); 231 + 232 + it('wraps number tokens with formula-token-number class', () => { 233 + const tokens = tokenizeForHighlighting('=42'); 234 + const html = renderHighlightedFormula(tokens); 235 + expect(html).toContain('formula-token-number'); 236 + expect(html).toContain('42'); 237 + }); 238 + 239 + it('wraps string tokens with formula-token-string class', () => { 240 + const tokens = tokenizeForHighlighting('="hello"'); 241 + const html = renderHighlightedFormula(tokens); 242 + expect(html).toContain('formula-token-string'); 243 + expect(html).toContain('hello'); 244 + }); 245 + 246 + it('wraps error tokens with formula-token-error class', () => { 247 + const tokens = tokenizeForHighlighting('=#REF!'); 248 + const html = renderHighlightedFormula(tokens); 249 + expect(html).toContain('formula-token-error'); 250 + expect(html).toContain('#REF!'); 251 + }); 252 + 253 + it('escapes HTML special characters in token text', () => { 254 + const tokens = tokenizeForHighlighting('=A1<>B1'); 255 + const html = renderHighlightedFormula(tokens); 256 + // < and > should be escaped in the HTML output 257 + expect(html).toContain('&lt;&gt;'); 258 + }); 259 + 260 + it('preserves the complete formula text in output', () => { 261 + const formula = '=SUM(A1:B5)+10'; 262 + const tokens = tokenizeForHighlighting(formula); 263 + const html = renderHighlightedFormula(tokens); 264 + // Strip HTML tags and verify text content 265 + const textContent = html.replace(/<[^>]+>/g, ''); 266 + expect(textContent).toBe(formula); 267 + }); 268 + });
+201
tests/formula-tooltip.test.js
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + detectCurrentFunction, 4 + FUNCTION_METADATA, 5 + } from '../src/sheets/formula-tooltip.js'; 6 + 7 + /** 8 + * Tests for Rich Formula Tooltips with Parameter Highlighting (Chainlink #93). 9 + * 10 + * When typing inside a function call, a tooltip shows the function signature 11 + * with the current parameter highlighted, plus a description. 12 + */ 13 + 14 + describe('FUNCTION_METADATA', () => { 15 + it('is an object', () => { 16 + expect(typeof FUNCTION_METADATA).toBe('object'); 17 + expect(FUNCTION_METADATA).not.toBeNull(); 18 + }); 19 + 20 + it('has entries for all 57 functions', () => { 21 + const expectedFunctions = [ 22 + 'SUM', 'AVERAGE', 'COUNT', 'COUNTA', 'MIN', 'MAX', 'MEDIAN', 'STDEV', 23 + 'ABS', 'ROUND', 'ROUNDUP', 'ROUNDDOWN', 'INT', 'MOD', 'POWER', 'SQRT', 24 + 'LOG', 'LN', 'EXP', 'PI', 'RAND', 25 + 'IF', 'AND', 'OR', 'NOT', 'IFERROR', 26 + 'CONCATENATE', 'LEN', 'LEFT', 'RIGHT', 'MID', 27 + 'UPPER', 'LOWER', 'TRIM', 'SUBSTITUTE', 'FIND', 'SEARCH', 28 + 'TEXT', 'VALUE', 29 + 'NOW', 'TODAY', 'DATE', 'YEAR', 'MONTH', 'DAY', 30 + 'VLOOKUP', 'HLOOKUP', 'INDEX', 'MATCH', 31 + 'SUMIF', 'COUNTIF', 'AVERAGEIF', 32 + ]; 33 + for (const fn of expectedFunctions) { 34 + expect(FUNCTION_METADATA).toHaveProperty(fn); 35 + } 36 + }); 37 + 38 + it('each entry has desc and params', () => { 39 + for (const [name, meta] of Object.entries(FUNCTION_METADATA)) { 40 + expect(meta).toHaveProperty('desc'); 41 + expect(meta).toHaveProperty('params'); 42 + expect(typeof meta.desc).toBe('string'); 43 + expect(meta.desc.length).toBeGreaterThan(0); 44 + expect(Array.isArray(meta.params)).toBe(true); 45 + } 46 + }); 47 + 48 + it('each param has name, desc, and required', () => { 49 + for (const [name, meta] of Object.entries(FUNCTION_METADATA)) { 50 + for (const p of meta.params) { 51 + expect(p).toHaveProperty('name'); 52 + expect(p).toHaveProperty('desc'); 53 + expect(p).toHaveProperty('required'); 54 + expect(typeof p.name).toBe('string'); 55 + expect(typeof p.desc).toBe('string'); 56 + expect(typeof p.required).toBe('boolean'); 57 + } 58 + } 59 + }); 60 + 61 + it('SUM has correct metadata structure', () => { 62 + const sum = FUNCTION_METADATA.SUM; 63 + expect(sum.desc).toBeTruthy(); 64 + expect(sum.params.length).toBeGreaterThanOrEqual(1); 65 + expect(sum.params[0].name).toBe('range1'); 66 + expect(sum.params[0].required).toBe(true); 67 + }); 68 + 69 + it('VLOOKUP has 4 params', () => { 70 + const vl = FUNCTION_METADATA.VLOOKUP; 71 + expect(vl.params.length).toBe(4); 72 + expect(vl.params[0].name).toBe('lookup_value'); 73 + expect(vl.params[0].required).toBe(true); 74 + expect(vl.params[3].name).toBe('range_lookup'); 75 + expect(vl.params[3].required).toBe(false); 76 + }); 77 + 78 + it('IF has 3 params with value_if_false optional', () => { 79 + const ifMeta = FUNCTION_METADATA.IF; 80 + expect(ifMeta.params.length).toBe(3); 81 + expect(ifMeta.params[2].name).toBe('value_if_false'); 82 + expect(ifMeta.params[2].required).toBe(false); 83 + }); 84 + 85 + it('PI and RAND have 0 params', () => { 86 + expect(FUNCTION_METADATA.PI.params.length).toBe(0); 87 + expect(FUNCTION_METADATA.RAND.params.length).toBe(0); 88 + }); 89 + 90 + it('NOW and TODAY have 0 params', () => { 91 + expect(FUNCTION_METADATA.NOW.params.length).toBe(0); 92 + expect(FUNCTION_METADATA.TODAY.params.length).toBe(0); 93 + }); 94 + }); 95 + 96 + describe('detectCurrentFunction', () => { 97 + it('is a function', () => { 98 + expect(typeof detectCurrentFunction).toBe('function'); 99 + }); 100 + 101 + it('returns null when cursor is not inside a function', () => { 102 + const result = detectCurrentFunction('=A1+B1', 3); 103 + expect(result).toBeNull(); 104 + }); 105 + 106 + it('detects function name at first parameter', () => { 107 + const result = detectCurrentFunction('=SUM(A1)', 5); 108 + expect(result).not.toBeNull(); 109 + expect(result.functionName).toBe('SUM'); 110 + expect(result.paramIndex).toBe(0); 111 + }); 112 + 113 + it('detects second parameter after one comma', () => { 114 + const result = detectCurrentFunction('=SUM(A1,B1)', 8); 115 + expect(result).not.toBeNull(); 116 + expect(result.functionName).toBe('SUM'); 117 + expect(result.paramIndex).toBe(1); 118 + }); 119 + 120 + it('detects third parameter after two commas', () => { 121 + const result = detectCurrentFunction('=IF(A1>0,A1,B1)', 13); 122 + expect(result).not.toBeNull(); 123 + expect(result.functionName).toBe('IF'); 124 + expect(result.paramIndex).toBe(2); 125 + }); 126 + 127 + it('handles cursor right after opening paren', () => { 128 + const result = detectCurrentFunction('=SUM(', 5); 129 + expect(result).not.toBeNull(); 130 + expect(result.functionName).toBe('SUM'); 131 + expect(result.paramIndex).toBe(0); 132 + }); 133 + 134 + it('handles nested functions — returns innermost', () => { 135 + // =IF(SUM(A1:A10)>100,B1,C1) 136 + // ^cursor here inside SUM 137 + const result = detectCurrentFunction('=IF(SUM(A1:A10)>100,B1,C1)', 8); 138 + expect(result).not.toBeNull(); 139 + expect(result.functionName).toBe('SUM'); 140 + expect(result.paramIndex).toBe(0); 141 + }); 142 + 143 + it('handles cursor after closing nested function — returns outer', () => { 144 + // =IF(SUM(A1:A10)>100,B1,C1) 145 + // ^cursor here, inside IF after second comma 146 + const result = detectCurrentFunction('=IF(SUM(A1:A10)>100,B1,C1)', 23); 147 + expect(result).not.toBeNull(); 148 + expect(result.functionName).toBe('IF'); 149 + expect(result.paramIndex).toBe(2); 150 + }); 151 + 152 + it('returns null when cursor is after closing paren', () => { 153 + const result = detectCurrentFunction('=SUM(A1)', 8); 154 + expect(result).toBeNull(); 155 + }); 156 + 157 + it('handles empty formula', () => { 158 + const result = detectCurrentFunction('=', 1); 159 + expect(result).toBeNull(); 160 + }); 161 + 162 + it('handles cursor at position 0', () => { 163 + const result = detectCurrentFunction('=SUM(A1)', 0); 164 + expect(result).toBeNull(); 165 + }); 166 + 167 + it('handles VLOOKUP with all 4 params', () => { 168 + const formula = '=VLOOKUP(A1,B1:D10,3,FALSE)'; 169 + // cursor in 3rd param (col_index) 170 + const result = detectCurrentFunction(formula, 20); 171 + expect(result).not.toBeNull(); 172 + expect(result.functionName).toBe('VLOOKUP'); 173 + expect(result.paramIndex).toBe(2); 174 + }); 175 + 176 + it('skips commas inside nested function calls', () => { 177 + // =IF(SUM(A1,B1),C1,D1) 178 + // ^cursor at C1, which is param 1 of IF 179 + const formula = '=IF(SUM(A1,B1),C1,D1)'; 180 + const result = detectCurrentFunction(formula, 16); 181 + expect(result).not.toBeNull(); 182 + expect(result.functionName).toBe('IF'); 183 + expect(result.paramIndex).toBe(1); 184 + }); 185 + 186 + it('skips commas inside string literals', () => { 187 + // =CONCATENATE("a,b",C1) 188 + // ^cursor at C1 189 + const formula = '=CONCATENATE("a,b",C1)'; 190 + const result = detectCurrentFunction(formula, 20); 191 + expect(result).not.toBeNull(); 192 + expect(result.functionName).toBe('CONCATENATE'); 193 + expect(result.paramIndex).toBe(1); 194 + }); 195 + 196 + it('is case insensitive for function names', () => { 197 + const result = detectCurrentFunction('=sum(A1)', 5); 198 + expect(result).not.toBeNull(); 199 + expect(result.functionName).toBe('SUM'); 200 + }); 201 + });
+441
tests/formulas-power.test.js
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { evaluate } from '../src/sheets/formulas.js'; 3 + 4 + // Helper: evaluate with a simple cell map 5 + function evalWith(formula, cells = {}) { 6 + return evaluate(formula, (ref) => cells[ref] ?? ''); 7 + } 8 + 9 + // ─────────────────────────────────────────────────────── 10 + // XLOOKUP 11 + // ─────────────────────────────────────────────────────── 12 + describe('XLOOKUP', () => { 13 + const lookupCells = { 14 + // lookup_array in A1:A5, return_array in B1:B5 15 + A1: 'Apple', B1: 10, 16 + A2: 'Banana', B2: 20, 17 + A3: 'Cherry', B3: 30, 18 + A4: 'Date', B4: 40, 19 + A5: 'Elderberry', B5: 50, 20 + }; 21 + 22 + it('exact match — finds value', () => { 23 + expect(evalWith('XLOOKUP("Cherry",A1:A5,B1:B5)', lookupCells)).toBe(30); 24 + }); 25 + 26 + it('exact match — case insensitive', () => { 27 + expect(evalWith('XLOOKUP("cherry",A1:A5,B1:B5)', lookupCells)).toBe(30); 28 + }); 29 + 30 + it('exact match — not found returns #N/A by default', () => { 31 + expect(evalWith('XLOOKUP("Fig",A1:A5,B1:B5)', lookupCells)).toBe('#N/A'); 32 + }); 33 + 34 + it('exact match — not found returns custom if_not_found', () => { 35 + expect(evalWith('XLOOKUP("Fig",A1:A5,B1:B5,"Not found")', lookupCells)).toBe('Not found'); 36 + }); 37 + 38 + it('exact match — numeric lookup', () => { 39 + const cells = { A1: 100, B1: 'x', A2: 200, B2: 'y', A3: 300, B3: 'z' }; 40 + expect(evalWith('XLOOKUP(200,A1:A3,B1:B3)', cells)).toBe('y'); 41 + }); 42 + 43 + it('exact match — first match (default search_mode)', () => { 44 + const cells = { A1: 'a', B1: 1, A2: 'b', B2: 2, A3: 'a', B3: 3 }; 45 + expect(evalWith('XLOOKUP("a",A1:A3,B1:B3)', cells)).toBe(1); 46 + }); 47 + 48 + it('reverse search — last match with search_mode -1', () => { 49 + const cells = { A1: 'a', B1: 1, A2: 'b', B2: 2, A3: 'a', B3: 3 }; 50 + expect(evalWith('XLOOKUP("a",A1:A3,B1:B3,,-1)', cells)).toBe(1); 51 + // search_mode -1 means last-to-first, so first match found from end is A3 52 + expect(evalWith('XLOOKUP("a",A1:A3,B1:B3,,0,-1)', cells)).toBe(3); 53 + }); 54 + 55 + it('wildcard match — * matches any sequence', () => { 56 + expect(evalWith('XLOOKUP("Ch*",A1:A5,B1:B5,,2)', lookupCells)).toBe(30); 57 + }); 58 + 59 + it('wildcard match — ? matches single char', () => { 60 + const cells = { A1: 'cat', B1: 1, A2: 'car', B2: 2, A3: 'cab', B3: 3 }; 61 + expect(evalWith('XLOOKUP("ca?",A1:A3,B1:B3,,2)', cells)).toBe(1); 62 + }); 63 + 64 + it('wildcard match — no match', () => { 65 + expect(evalWith('XLOOKUP("Zz*",A1:A5,B1:B5,,2)', lookupCells)).toBe('#N/A'); 66 + }); 67 + 68 + it('approximate match — next smaller (match_mode -1)', () => { 69 + const cells = { A1: 10, B1: 'a', A2: 20, B2: 'b', A3: 30, B3: 'c' }; 70 + // Looking for 25, next smaller is 20 71 + expect(evalWith('XLOOKUP(25,A1:A3,B1:B3,,-1)', cells)).toBe('b'); 72 + }); 73 + 74 + it('approximate match — next larger (match_mode 1)', () => { 75 + const cells = { A1: 10, B1: 'a', A2: 20, B2: 'b', A3: 30, B3: 'c' }; 76 + // Looking for 25, next larger is 30 77 + expect(evalWith('XLOOKUP(25,A1:A3,B1:B3,,1)', cells)).toBe('c'); 78 + }); 79 + 80 + it('approximate match — exact value found with match_mode -1', () => { 81 + const cells = { A1: 10, B1: 'a', A2: 20, B2: 'b', A3: 30, B3: 'c' }; 82 + expect(evalWith('XLOOKUP(20,A1:A3,B1:B3,,-1)', cells)).toBe('b'); 83 + }); 84 + 85 + it('approximate match — no smaller value returns #N/A', () => { 86 + const cells = { A1: 10, B1: 'a', A2: 20, B2: 'b' }; 87 + expect(evalWith('XLOOKUP(5,A1:A2,B1:B2,,-1)', cells)).toBe('#N/A'); 88 + }); 89 + 90 + it('approximate match — no larger value returns #N/A', () => { 91 + const cells = { A1: 10, B1: 'a', A2: 20, B2: 'b' }; 92 + expect(evalWith('XLOOKUP(25,A1:A2,B1:B2,,1)', cells)).toBe('#N/A'); 93 + }); 94 + 95 + it('works with horizontal ranges', () => { 96 + const cells = { A1: 'x', B1: 'y', C1: 'z', A2: 10, B2: 20, C2: 30 }; 97 + expect(evalWith('XLOOKUP("y",A1:C1,A2:C2)', cells)).toBe(20); 98 + }); 99 + 100 + it('returns if_not_found when lookup_array is empty-ish', () => { 101 + const cells = {}; 102 + expect(evalWith('XLOOKUP("x",A1:A3,B1:B3,"empty")', cells)).toBe('empty'); 103 + }); 104 + 105 + it('error propagation — error in lookup_value', () => { 106 + // If the lookup_value itself is an error string, it should still attempt matching 107 + const cells = { A1: '#REF!', B1: 99 }; 108 + expect(evalWith('XLOOKUP("#REF!",A1:A1,B1:B1)', cells)).toBe(99); 109 + }); 110 + }); 111 + 112 + // ─────────────────────────────────────────────────────── 113 + // SUMIFS 114 + // ─────────────────────────────────────────────────────── 115 + describe('SUMIFS', () => { 116 + const cells = { 117 + // A = category, B = region, C = amount 118 + A1: 'Fruit', B1: 'East', C1: 100, 119 + A2: 'Veggie', B2: 'West', C2: 200, 120 + A3: 'Fruit', B3: 'West', C3: 150, 121 + A4: 'Fruit', B4: 'East', C4: 250, 122 + A5: 'Veggie', B5: 'East', C5: 300, 123 + }; 124 + 125 + it('single criteria — exact string', () => { 126 + expect(evalWith('SUMIFS(C1:C5,A1:A5,"Fruit")', cells)).toBe(500); // 100+150+250 127 + }); 128 + 129 + it('two criteria — AND logic', () => { 130 + expect(evalWith('SUMIFS(C1:C5,A1:A5,"Fruit",B1:B5,"East")', cells)).toBe(350); // 100+250 131 + }); 132 + 133 + it('three criteria', () => { 134 + const c = { 135 + A1: 'a', B1: 1, C1: 'x', D1: 10, 136 + A2: 'a', B2: 2, C2: 'x', D2: 20, 137 + A3: 'a', B3: 1, C3: 'x', D3: 30, 138 + A4: 'b', B4: 1, C4: 'x', D4: 40, 139 + }; 140 + expect(evalWith('SUMIFS(D1:D4,A1:A4,"a",B1:B4,1,C1:C4,"x")', c)).toBe(40); // 10+30 141 + }); 142 + 143 + it('numeric criteria with > operator', () => { 144 + expect(evalWith('SUMIFS(C1:C5,C1:C5,">150")', cells)).toBe(750); // 200+250+300 145 + }); 146 + 147 + it('numeric criteria with <= operator', () => { 148 + expect(evalWith('SUMIFS(C1:C5,C1:C5,"<=150")', cells)).toBe(250); // 100+150 149 + }); 150 + 151 + it('numeric criteria with <> operator', () => { 152 + expect(evalWith('SUMIFS(C1:C5,A1:A5,"<>Fruit")', cells)).toBe(500); // 200+300 153 + }); 154 + 155 + it('wildcard criteria with *', () => { 156 + expect(evalWith('SUMIFS(C1:C5,A1:A5,"Fr*")', cells)).toBe(500); // Fruit matches 157 + }); 158 + 159 + it('no matches returns 0', () => { 160 + expect(evalWith('SUMIFS(C1:C5,A1:A5,"Dairy")', cells)).toBe(0); 161 + }); 162 + 163 + it('empty cells do not match numeric criteria', () => { 164 + const c = { A1: '', B1: 10, A2: 5, B2: 20, A3: '', B3: 30 }; 165 + expect(evalWith('SUMIFS(B1:B3,A1:A3,">0")', c)).toBe(20); // only A2=5 > 0 166 + }); 167 + 168 + it('empty cells match "" criteria', () => { 169 + const c = { A1: '', B1: 10, A2: 'x', B2: 20, A3: '', B3: 30 }; 170 + expect(evalWith('SUMIFS(B1:B3,A1:A3,"")', c)).toBe(40); // A1 and A3 are empty 171 + }); 172 + }); 173 + 174 + // ─────────────────────────────────────────────────────── 175 + // COUNTIFS 176 + // ─────────────────────────────────────────────────────── 177 + describe('COUNTIFS', () => { 178 + const cells = { 179 + A1: 'Fruit', B1: 'East', C1: 100, 180 + A2: 'Veggie', B2: 'West', C2: 200, 181 + A3: 'Fruit', B3: 'West', C3: 150, 182 + A4: 'Fruit', B4: 'East', C4: 250, 183 + A5: 'Veggie', B5: 'East', C5: 300, 184 + }; 185 + 186 + it('single criteria', () => { 187 + expect(evalWith('COUNTIFS(A1:A5,"Fruit")', cells)).toBe(3); 188 + }); 189 + 190 + it('two criteria', () => { 191 + expect(evalWith('COUNTIFS(A1:A5,"Fruit",B1:B5,"East")', cells)).toBe(2); 192 + }); 193 + 194 + it('numeric criteria with >=', () => { 195 + expect(evalWith('COUNTIFS(C1:C5,">=200")', cells)).toBe(3); // 200, 250, 300 196 + }); 197 + 198 + it('no matches returns 0', () => { 199 + expect(evalWith('COUNTIFS(A1:A5,"Dairy")', cells)).toBe(0); 200 + }); 201 + 202 + it('wildcard criteria with ?', () => { 203 + const c = { A1: 'cat', A2: 'car', A3: 'dog', A4: 'cap' }; 204 + expect(evalWith('COUNTIFS(A1:A4,"ca?")', c)).toBe(3); // cat, car, cap 205 + }); 206 + 207 + it('three criteria', () => { 208 + expect(evalWith('COUNTIFS(A1:A5,"Fruit",B1:B5,"East",C1:C5,">100")', cells)).toBe(1); // only row 4 209 + }); 210 + }); 211 + 212 + // ─────────────────────────────────────────────────────── 213 + // AVERAGEIFS 214 + // ─────────────────────────────────────────────────────── 215 + describe('AVERAGEIFS', () => { 216 + const cells = { 217 + A1: 'Fruit', B1: 'East', C1: 100, 218 + A2: 'Veggie', B2: 'West', C2: 200, 219 + A3: 'Fruit', B3: 'West', C3: 150, 220 + A4: 'Fruit', B4: 'East', C4: 250, 221 + A5: 'Veggie', B5: 'East', C5: 300, 222 + }; 223 + 224 + it('single criteria', () => { 225 + const result = evalWith('AVERAGEIFS(C1:C5,A1:A5,"Fruit")', cells); 226 + expect(result).toBeCloseTo(166.667, 2); // (100+150+250)/3 227 + }); 228 + 229 + it('two criteria', () => { 230 + expect(evalWith('AVERAGEIFS(C1:C5,A1:A5,"Fruit",B1:B5,"East")', cells)).toBe(175); // (100+250)/2 231 + }); 232 + 233 + it('no matches returns #DIV/0!', () => { 234 + expect(evalWith('AVERAGEIFS(C1:C5,A1:A5,"Dairy")', cells)).toBe('#DIV/0!'); 235 + }); 236 + 237 + it('numeric criteria', () => { 238 + expect(evalWith('AVERAGEIFS(C1:C5,C1:C5,">200")', cells)).toBe(275); // (250+300)/2 239 + }); 240 + 241 + it('wildcard criteria', () => { 242 + const result = evalWith('AVERAGEIFS(C1:C5,A1:A5,"V*")', cells); 243 + expect(result).toBe(250); // (200+300)/2 244 + }); 245 + }); 246 + 247 + // ─────────────────────────────────────────────────────── 248 + // LET 249 + // ─────────────────────────────────────────────────────── 250 + describe('LET', () => { 251 + it('single variable', () => { 252 + expect(evalWith('LET(x, 10, x * 2)')).toBe(20); 253 + }); 254 + 255 + it('two variables', () => { 256 + expect(evalWith('LET(x, 3, y, 4, x + y)')).toBe(7); 257 + }); 258 + 259 + it('later variable references earlier variable', () => { 260 + expect(evalWith('LET(x, 5, y, x * 2, y + 1)')).toBe(11); 261 + }); 262 + 263 + it('variable with cell reference', () => { 264 + const cells = { A1: 100 }; 265 + expect(evalWith('LET(price, A1, tax, 0.08, price * (1 + tax))', cells)).toBe(108); 266 + }); 267 + 268 + it('variable used multiple times avoids recomputation', () => { 269 + expect(evalWith('LET(x, 10, x + x + x)')).toBe(30); 270 + }); 271 + 272 + it('nested LET', () => { 273 + expect(evalWith('LET(x, 5, LET(y, x + 1, y * 2))')).toBe(12); 274 + }); 275 + 276 + it('LET with range in calculation', () => { 277 + const cells = { A1: 1, A2: 2, A3: 3 }; 278 + expect(evalWith('LET(factor, 10, SUM(A1:A3) * factor)', cells)).toBe(60); 279 + }); 280 + 281 + it('LET variable shadows outer context', () => { 282 + // Even if there were a named range called "x", the LET variable takes precedence 283 + expect(evalWith('LET(x, 42, x)')).toBe(42); 284 + }); 285 + }); 286 + 287 + // ─────────────────────────────────────────────────────── 288 + // TEXTJOIN 289 + // ─────────────────────────────────────────────────────── 290 + describe('TEXTJOIN', () => { 291 + it('joins values with delimiter', () => { 292 + const cells = { A1: 'a', A2: 'b', A3: 'c' }; 293 + expect(evalWith('TEXTJOIN(",",TRUE,A1:A3)', cells)).toBe('a,b,c'); 294 + }); 295 + 296 + it('ignores empty cells when ignore_empty is TRUE', () => { 297 + const cells = { A1: 'a', A2: '', A3: 'c' }; 298 + expect(evalWith('TEXTJOIN(",",TRUE,A1:A3)', cells)).toBe('a,c'); 299 + }); 300 + 301 + it('includes empty cells when ignore_empty is FALSE', () => { 302 + const cells = { A1: 'a', A2: '', A3: 'c' }; 303 + expect(evalWith('TEXTJOIN(",",FALSE,A1:A3)', cells)).toBe('a,,c'); 304 + }); 305 + 306 + it('joins with multi-char delimiter', () => { 307 + const cells = { A1: 'hello', A2: 'world' }; 308 + expect(evalWith('TEXTJOIN(" - ",TRUE,A1:A2)', cells)).toBe('hello - world'); 309 + }); 310 + 311 + it('joins multiple ranges', () => { 312 + const cells = { A1: 'a', A2: 'b', B1: 'c', B2: 'd' }; 313 + expect(evalWith('TEXTJOIN(",",TRUE,A1:A2,B1:B2)', cells)).toBe('a,b,c,d'); 314 + }); 315 + 316 + it('joins scalar values', () => { 317 + expect(evalWith('TEXTJOIN("-",TRUE,"a","b","c")')).toBe('a-b-c'); 318 + }); 319 + 320 + it('empty delimiter concatenates directly', () => { 321 + const cells = { A1: 'x', A2: 'y', A3: 'z' }; 322 + expect(evalWith('TEXTJOIN("",TRUE,A1:A3)', cells)).toBe('xyz'); 323 + }); 324 + 325 + it('numeric values are converted to strings', () => { 326 + const cells = { A1: 1, A2: 2, A3: 3 }; 327 + expect(evalWith('TEXTJOIN(",",TRUE,A1:A3)', cells)).toBe('1,2,3'); 328 + }); 329 + }); 330 + 331 + // ─────────────────────────────────────────────────────── 332 + // CONCAT 333 + // ─────────────────────────────────────────────────────── 334 + describe('CONCAT', () => { 335 + it('concatenates range values', () => { 336 + const cells = { A1: 'a', A2: 'b', A3: 'c' }; 337 + expect(evalWith('CONCAT(A1:A3)', cells)).toBe('abc'); 338 + }); 339 + 340 + it('concatenates multiple ranges', () => { 341 + const cells = { A1: 'x', B1: 'y' }; 342 + expect(evalWith('CONCAT(A1:A1,B1:B1)', cells)).toBe('xy'); 343 + }); 344 + 345 + it('concatenates scalar values', () => { 346 + expect(evalWith('CONCAT("hello"," ","world")')).toBe('hello world'); 347 + }); 348 + 349 + it('includes empty cells', () => { 350 + const cells = { A1: 'a', A2: '', A3: 'c' }; 351 + expect(evalWith('CONCAT(A1:A3)', cells)).toBe('ac'); 352 + }); 353 + 354 + it('converts numbers to strings', () => { 355 + const cells = { A1: 1, A2: 2 }; 356 + expect(evalWith('CONCAT(A1:A2)', cells)).toBe('12'); 357 + }); 358 + }); 359 + 360 + // ─────────────────────────────────────────────────────── 361 + // SWITCH 362 + // ─────────────────────────────────────────────────────── 363 + describe('SWITCH', () => { 364 + it('returns value for matching case', () => { 365 + expect(evalWith('SWITCH(2,1,"one",2,"two",3,"three")')).toBe('two'); 366 + }); 367 + 368 + it('returns first matching case', () => { 369 + expect(evalWith('SWITCH(1,1,"first",1,"second")')).toBe('first'); 370 + }); 371 + 372 + it('returns default when no match (odd args after expression)', () => { 373 + expect(evalWith('SWITCH(99,1,"one",2,"two","default")')).toBe('default'); 374 + }); 375 + 376 + it('returns #N/A when no match and no default', () => { 377 + expect(evalWith('SWITCH(99,1,"one",2,"two")')).toBe('#N/A'); 378 + }); 379 + 380 + it('works with string expressions', () => { 381 + expect(evalWith('SWITCH("b","a",1,"b",2,"c",3)')).toBe(2); 382 + }); 383 + 384 + it('works with cell references', () => { 385 + const cells = { A1: 'x' }; 386 + expect(evalWith('SWITCH(A1,"x","found","y","nope")', cells)).toBe('found'); 387 + }); 388 + 389 + it('works with boolean cases', () => { 390 + expect(evalWith('SWITCH(TRUE,FALSE,"no",TRUE,"yes")')).toBe('yes'); 391 + }); 392 + 393 + it('default value is last arg when odd count after expression', () => { 394 + // SWITCH(expr, c1, v1, c2, v2, default) — 5 args after expr, odd → last is default 395 + expect(evalWith('SWITCH(5, 1, "a", 2, "b", "fallback")')).toBe('fallback'); 396 + }); 397 + }); 398 + 399 + // ─────────────────────────────────────────────────────── 400 + // Edge cases and integration 401 + // ─────────────────────────────────────────────────────── 402 + describe('Power functions — edge cases', () => { 403 + it('XLOOKUP nested in IF', () => { 404 + const cells = { A1: 'x', B1: 100, A2: 'y', B2: 200 }; 405 + expect(evalWith('IF(TRUE,XLOOKUP("y",A1:A2,B1:B2),0)', cells)).toBe(200); 406 + }); 407 + 408 + it('SUMIFS result used in arithmetic', () => { 409 + const cells = { 410 + A1: 'a', B1: 10, 411 + A2: 'a', B2: 20, 412 + A3: 'b', B3: 30, 413 + }; 414 + expect(evalWith('SUMIFS(B1:B3,A1:A3,"a") * 2', cells)).toBe(60); 415 + }); 416 + 417 + it('SWITCH with expressions as values', () => { 418 + const cells = { A1: 1 }; 419 + expect(evalWith('SWITCH(A1,1,A1*10,2,A1*20)', cells)).toBe(10); 420 + }); 421 + 422 + it('TEXTJOIN with XLOOKUP result would work in nested formulas', () => { 423 + // Just verify TEXTJOIN works with inline values 424 + expect(evalWith('TEXTJOIN(",",TRUE,"a","b")')).toBe('a,b'); 425 + }); 426 + 427 + it('COUNTIFS with wildcard ? matching single character', () => { 428 + const cells = { A1: 'ab', A2: 'ac', A3: 'abc', A4: 'a' }; 429 + expect(evalWith('COUNTIFS(A1:A4,"a?")', cells)).toBe(2); // ab, ac 430 + }); 431 + 432 + it('SUMIFS with mixed criteria types', () => { 433 + const cells = { 434 + A1: 'x', B1: 10, C1: 50, 435 + A2: 'y', B2: 20, C2: 60, 436 + A3: 'x', B3: 30, C3: 70, 437 + }; 438 + // Sum C where A="x" AND B>15 439 + expect(evalWith('SUMIFS(C1:C3,A1:A3,"x",B1:B3,">15")', cells)).toBe(70); 440 + }); 441 + });
+13
tests/formulas.test.js
··· 157 157 expect(evalWith('SQRT(144)')).toBe(12); 158 158 }); 159 159 160 + it('RAND / RANDBETWEEN', () => { 161 + const rand = evalWith('RAND()'); 162 + expect(typeof rand).toBe('number'); 163 + expect(rand).toBeGreaterThanOrEqual(0); 164 + expect(rand).toBeLessThan(1); 165 + 166 + const rb = evalWith('RANDBETWEEN(1,10)'); 167 + expect(typeof rb).toBe('number'); 168 + expect(rb).toBeGreaterThanOrEqual(1); 169 + expect(rb).toBeLessThanOrEqual(10); 170 + expect(Number.isInteger(rb)).toBe(true); 171 + }); 172 + 160 173 it('string functions', () => { 161 174 expect(evalWith('LEN("hello")')).toBe(5); 162 175 expect(evalWith('UPPER("hello")')).toBe('HELLO');
+177
tests/range-highlight.test.js
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + extractFormulaRanges, 4 + assignRangeColors, 5 + RANGE_COLORS, 6 + } from '../src/sheets/range-highlight.js'; 7 + 8 + /** 9 + * Tests for Range Highlighting While Editing (Chainlink #95). 10 + * 11 + * When editing a formula, referenced cells/ranges should be highlighted 12 + * on the grid with colored borders/overlays. Each unique reference gets 13 + * a color from a rotating palette. 14 + */ 15 + 16 + describe('RANGE_COLORS', () => { 17 + it('is an array of 6 colors', () => { 18 + expect(Array.isArray(RANGE_COLORS)).toBe(true); 19 + expect(RANGE_COLORS.length).toBe(6); 20 + }); 21 + 22 + it('each color is a string', () => { 23 + for (const c of RANGE_COLORS) { 24 + expect(typeof c).toBe('string'); 25 + } 26 + }); 27 + }); 28 + 29 + describe('extractFormulaRanges', () => { 30 + it('is a function', () => { 31 + expect(typeof extractFormulaRanges).toBe('function'); 32 + }); 33 + 34 + it('returns an array', () => { 35 + const result = extractFormulaRanges('SUM(A1:B5)'); 36 + expect(Array.isArray(result)).toBe(true); 37 + }); 38 + 39 + it('extracts a single cell reference', () => { 40 + const result = extractFormulaRanges('A1+B1'); 41 + expect(result.length).toBe(2); 42 + expect(result[0].ref).toBe('A1'); 43 + expect(result[1].ref).toBe('B1'); 44 + }); 45 + 46 + it('extracts a range reference', () => { 47 + const result = extractFormulaRanges('SUM(A1:B5)'); 48 + const range = result.find(r => r.ref === 'A1:B5'); 49 + expect(range).toBeTruthy(); 50 + }); 51 + 52 + it('each result has ref, startIndex, and endIndex', () => { 53 + const result = extractFormulaRanges('SUM(A1:B5)'); 54 + for (const r of result) { 55 + expect(r).toHaveProperty('ref'); 56 + expect(r).toHaveProperty('startIndex'); 57 + expect(r).toHaveProperty('endIndex'); 58 + expect(typeof r.ref).toBe('string'); 59 + expect(typeof r.startIndex).toBe('number'); 60 + expect(typeof r.endIndex).toBe('number'); 61 + } 62 + }); 63 + 64 + it('extracts absolute references', () => { 65 + const result = extractFormulaRanges('$A$1+$B$2'); 66 + expect(result.length).toBe(2); 67 + expect(result[0].ref).toBe('$A$1'); 68 + expect(result[1].ref).toBe('$B$2'); 69 + }); 70 + 71 + it('extracts cross-sheet references', () => { 72 + const result = extractFormulaRanges('Sheet2!A1+B1'); 73 + expect(result.some(r => r.ref === 'Sheet2!A1')).toBe(true); 74 + }); 75 + 76 + it('extracts multiple different references', () => { 77 + const result = extractFormulaRanges('A1+B1+C1:D5'); 78 + expect(result.length).toBe(3); 79 + }); 80 + 81 + it('returns positions that match the formula text', () => { 82 + const formula = 'SUM(A1:B5)'; 83 + const result = extractFormulaRanges(formula); 84 + for (const r of result) { 85 + expect(formula.substring(r.startIndex, r.endIndex)).toBe(r.ref); 86 + } 87 + }); 88 + 89 + it('returns empty array for formulas with no references', () => { 90 + const result = extractFormulaRanges('42+10'); 91 + expect(result).toEqual([]); 92 + }); 93 + 94 + it('handles nested function calls', () => { 95 + const result = extractFormulaRanges('IF(SUM(A1:A10)>100,B1,C1)'); 96 + expect(result.length).toBe(3); 97 + expect(result.some(r => r.ref === 'A1:A10')).toBe(true); 98 + expect(result.some(r => r.ref === 'B1')).toBe(true); 99 + expect(result.some(r => r.ref === 'C1')).toBe(true); 100 + }); 101 + 102 + it('handles quoted sheet names', () => { 103 + const result = extractFormulaRanges("'My Sheet'!A1+B2"); 104 + expect(result.some(r => r.ref.includes('My Sheet'))).toBe(true); 105 + }); 106 + 107 + it('does not extract function names as references', () => { 108 + const result = extractFormulaRanges('SUM(A1)'); 109 + expect(result.every(r => r.ref !== 'SUM')).toBe(true); 110 + }); 111 + }); 112 + 113 + describe('assignRangeColors', () => { 114 + it('is a function', () => { 115 + expect(typeof assignRangeColors).toBe('function'); 116 + }); 117 + 118 + it('assigns a color to each range', () => { 119 + const ranges = [ 120 + { ref: 'A1', startIndex: 0, endIndex: 2 }, 121 + { ref: 'B1', startIndex: 3, endIndex: 5 }, 122 + ]; 123 + const result = assignRangeColors(ranges); 124 + expect(Array.isArray(result)).toBe(true); 125 + expect(result.length).toBe(2); 126 + for (const r of result) { 127 + expect(r).toHaveProperty('color'); 128 + expect(typeof r.color).toBe('string'); 129 + } 130 + }); 131 + 132 + it('gives same ref same color', () => { 133 + const ranges = [ 134 + { ref: 'A1', startIndex: 0, endIndex: 2 }, 135 + { ref: 'B1', startIndex: 3, endIndex: 5 }, 136 + { ref: 'A1', startIndex: 6, endIndex: 8 }, 137 + ]; 138 + const result = assignRangeColors(ranges); 139 + const a1Colors = result.filter(r => r.ref === 'A1').map(r => r.color); 140 + expect(new Set(a1Colors).size).toBe(1); 141 + }); 142 + 143 + it('gives different refs different colors (up to palette size)', () => { 144 + const ranges = [ 145 + { ref: 'A1', startIndex: 0, endIndex: 2 }, 146 + { ref: 'B1', startIndex: 3, endIndex: 5 }, 147 + { ref: 'C1', startIndex: 6, endIndex: 8 }, 148 + ]; 149 + const result = assignRangeColors(ranges); 150 + const colors = result.map(r => r.color); 151 + expect(new Set(colors).size).toBe(3); 152 + }); 153 + 154 + it('cycles colors after palette is exhausted', () => { 155 + const ranges = []; 156 + const refs = ['A1', 'B1', 'C1', 'D1', 'E1', 'F1', 'G1', 'H1']; 157 + for (let i = 0; i < refs.length; i++) { 158 + ranges.push({ ref: refs[i], startIndex: i * 3, endIndex: i * 3 + 2 }); 159 + } 160 + const result = assignRangeColors(ranges); 161 + // The 7th ref should cycle back to the 1st color 162 + expect(result[6].color).toBe(result[0].color); 163 + }); 164 + 165 + it('preserves original ref, startIndex, endIndex', () => { 166 + const ranges = [{ ref: 'A1', startIndex: 0, endIndex: 2 }]; 167 + const result = assignRangeColors(ranges); 168 + expect(result[0].ref).toBe('A1'); 169 + expect(result[0].startIndex).toBe(0); 170 + expect(result[0].endIndex).toBe(2); 171 + }); 172 + 173 + it('returns empty array for empty input', () => { 174 + const result = assignRangeColors([]); 175 + expect(result).toEqual([]); 176 + }); 177 + });
+584
tests/recalc.test.js
··· 1 + import { describe, it, expect, vi, beforeEach } from 'vitest'; 2 + import { 3 + RecalcEngine, 4 + isVolatile, 5 + VOLATILE_FUNCTIONS, 6 + } from '../src/sheets/recalc.js'; 7 + 8 + /** 9 + * Recalculation engine tests. 10 + * 11 + * Tests cover: 12 + * 1. Topological recalculation (Kahn's algorithm) 13 + * 2. Circular reference detection with cycle path 14 + * 3. Volatile function handling (NOW, TODAY, RAND, RANDBETWEEN) 15 + * 4. Incremental graph updates on cell edit 16 + * 5. Cross-sheet dependency tracking 17 + * 6. Named range dependency tracking 18 + */ 19 + 20 + // --- Helpers --- 21 + 22 + /** 23 + * Create a simple cell store from a plain object. 24 + * Each entry: cellId -> { v, f } where f is formula string (without '='). 25 + */ 26 + function makeCellStore(data) { 27 + const store = new Map(); 28 + for (const [id, cell] of Object.entries(data)) { 29 + store.set(id, { ...cell }); 30 + } 31 + return { 32 + get(id) { return store.get(id) || null; }, 33 + set(id, cell) { store.set(id, { ...cell }); }, 34 + has(id) { return store.has(id); }, 35 + entries() { return store.entries(); }, 36 + getAllFormulaCells() { 37 + const result = []; 38 + for (const [id, cell] of store.entries()) { 39 + if (cell.f) result.push([id, cell]); 40 + } 41 + return result; 42 + }, 43 + }; 44 + } 45 + 46 + // ===================================================================== 47 + // 1. TOPOLOGICAL RECALCULATION 48 + // ===================================================================== 49 + 50 + describe('RecalcEngine — topological recalculation', () => { 51 + it('constructs and exposes dependency graph', () => { 52 + const store = makeCellStore({ 53 + A1: { v: 10, f: '' }, 54 + B1: { v: '', f: 'A1*2' }, 55 + C1: { v: '', f: 'B1+1' }, 56 + }); 57 + const engine = new RecalcEngine(store); 58 + engine.buildFullGraph(); 59 + 60 + // B1 depends on A1 61 + expect(engine.getPrecedents('B1')).toContain('A1'); 62 + // C1 depends on B1 63 + expect(engine.getPrecedents('C1')).toContain('B1'); 64 + // A1 has dependent B1 65 + expect(engine.getDependents('A1')).toContain('B1'); 66 + // B1 has dependent C1 67 + expect(engine.getDependents('B1')).toContain('C1'); 68 + }); 69 + 70 + it('recalculates only dirty cells on single edit', () => { 71 + const store = makeCellStore({ 72 + A1: { v: 10, f: '' }, 73 + B1: { v: '', f: 'A1*2' }, 74 + C1: { v: '', f: 'B1+1' }, 75 + D1: { v: 100, f: '' }, // unrelated cell 76 + E1: { v: '', f: 'D1+5' }, // depends only on D1 77 + }); 78 + const engine = new RecalcEngine(store); 79 + engine.buildFullGraph(); 80 + 81 + // Edit A1: should dirty A1, B1, C1 but NOT D1 or E1 82 + store.set('A1', { v: 20, f: '' }); 83 + const changed = engine.recalculate('A1'); 84 + 85 + // B1 and C1 should have been recalculated 86 + expect(changed.has('B1')).toBe(true); 87 + expect(changed.has('C1')).toBe(true); 88 + // E1 should NOT have been recalculated 89 + expect(changed.has('E1')).toBe(false); 90 + }); 91 + 92 + it('computes values in topological order (upstream before downstream)', () => { 93 + const evalOrder = []; 94 + const store = makeCellStore({ 95 + A1: { v: 1, f: '' }, 96 + B1: { v: '', f: 'A1+1' }, // depends on A1 97 + C1: { v: '', f: 'B1+1' }, // depends on B1 98 + D1: { v: '', f: 'C1+1' }, // depends on C1 99 + }); 100 + const engine = new RecalcEngine(store, { 101 + onEvaluate(cellId) { evalOrder.push(cellId); }, 102 + }); 103 + engine.buildFullGraph(); 104 + 105 + store.set('A1', { v: 5, f: '' }); 106 + engine.recalculate('A1'); 107 + 108 + // B1 must be evaluated before C1, C1 before D1 109 + const bIdx = evalOrder.indexOf('B1'); 110 + const cIdx = evalOrder.indexOf('C1'); 111 + const dIdx = evalOrder.indexOf('D1'); 112 + expect(bIdx).toBeLessThan(cIdx); 113 + expect(cIdx).toBeLessThan(dIdx); 114 + }); 115 + 116 + it('handles diamond dependency pattern', () => { 117 + // A1 -> B1, A1 -> C1, B1 -> D1, C1 -> D1 118 + const store = makeCellStore({ 119 + A1: { v: 10, f: '' }, 120 + B1: { v: '', f: 'A1+1' }, 121 + C1: { v: '', f: 'A1+2' }, 122 + D1: { v: '', f: 'B1+C1' }, 123 + }); 124 + const engine = new RecalcEngine(store); 125 + engine.buildFullGraph(); 126 + 127 + store.set('A1', { v: 20, f: '' }); 128 + const changed = engine.recalculate('A1'); 129 + 130 + expect(changed.has('B1')).toBe(true); 131 + expect(changed.has('C1')).toBe(true); 132 + expect(changed.has('D1')).toBe(true); 133 + 134 + // Verify D1 sees updated B1 and C1 135 + // A1=20, B1=21, C1=22, D1=43 136 + const d1 = store.get('D1'); 137 + expect(d1.v).toBe(43); 138 + }); 139 + 140 + it('returns only cells whose display value actually changed', () => { 141 + const store = makeCellStore({ 142 + A1: { v: 10, f: '' }, 143 + B1: { v: '', f: 'IF(A1>5, 100, 0)' }, // output is 100 for any A1>5 144 + }); 145 + const engine = new RecalcEngine(store); 146 + engine.buildFullGraph(); 147 + 148 + // Initial evaluation 149 + engine.recalculate('A1'); 150 + expect(store.get('B1').v).toBe(100); 151 + 152 + // Change A1 from 10 to 20 — B1 is still 100 153 + store.set('A1', { v: 20, f: '' }); 154 + const changed = engine.recalculate('A1'); 155 + // B1 was re-evaluated but its value didn't change 156 + expect(changed.has('B1')).toBe(false); 157 + }); 158 + 159 + it('handles cell with no dependents (leaf cell edit)', () => { 160 + const store = makeCellStore({ 161 + A1: { v: 10, f: '' }, 162 + B1: { v: '', f: 'A1*2' }, 163 + }); 164 + const engine = new RecalcEngine(store); 165 + engine.buildFullGraph(); 166 + 167 + // Edit B1 to a plain value — nothing depends on B1's formula, so nothing else changes 168 + store.set('B1', { v: 99, f: '' }); 169 + engine.updateCell('B1'); 170 + const changed = engine.recalculate('B1'); 171 + expect(changed.size).toBe(0); 172 + }); 173 + 174 + it('handles range references in formulas', () => { 175 + const store = makeCellStore({ 176 + A1: { v: 1, f: '' }, 177 + A2: { v: 2, f: '' }, 178 + A3: { v: 3, f: '' }, 179 + B1: { v: '', f: 'SUM(A1:A3)' }, 180 + }); 181 + const engine = new RecalcEngine(store); 182 + engine.buildFullGraph(); 183 + 184 + store.set('A2', { v: 20, f: '' }); 185 + const changed = engine.recalculate('A2'); 186 + expect(changed.has('B1')).toBe(true); 187 + expect(store.get('B1').v).toBe(24); // 1 + 20 + 3 188 + }); 189 + }); 190 + 191 + // ===================================================================== 192 + // 2. CIRCULAR REFERENCE DETECTION 193 + // ===================================================================== 194 + 195 + describe('RecalcEngine — circular reference detection', () => { 196 + it('detects direct self-reference', () => { 197 + const store = makeCellStore({ 198 + A1: { v: '', f: 'A1+1' }, 199 + }); 200 + const engine = new RecalcEngine(store); 201 + engine.buildFullGraph(); 202 + 203 + const changed = engine.recalculate('A1'); 204 + const a1 = store.get('A1'); 205 + expect(a1.v).toBe('#CIRCULAR!'); 206 + }); 207 + 208 + it('detects two-cell cycle (A1 -> B1 -> A1)', () => { 209 + const store = makeCellStore({ 210 + A1: { v: '', f: 'B1+1' }, 211 + B1: { v: '', f: 'A1+1' }, 212 + }); 213 + const engine = new RecalcEngine(store); 214 + engine.buildFullGraph(); 215 + 216 + engine.recalculate('A1'); 217 + expect(store.get('A1').v).toBe('#CIRCULAR!'); 218 + expect(store.get('B1').v).toBe('#CIRCULAR!'); 219 + }); 220 + 221 + it('detects three-cell cycle and includes path', () => { 222 + const store = makeCellStore({ 223 + A1: { v: '', f: 'C1+1' }, 224 + B1: { v: '', f: 'A1+1' }, 225 + C1: { v: '', f: 'B1+1' }, 226 + }); 227 + const engine = new RecalcEngine(store); 228 + engine.buildFullGraph(); 229 + 230 + engine.recalculate('A1'); 231 + 232 + // All cells in the cycle should have #CIRCULAR! 233 + expect(store.get('A1').v).toBe('#CIRCULAR!'); 234 + expect(store.get('B1').v).toBe('#CIRCULAR!'); 235 + expect(store.get('C1').v).toBe('#CIRCULAR!'); 236 + }); 237 + 238 + it('provides cycle path in error context', () => { 239 + const store = makeCellStore({ 240 + A1: { v: '', f: 'B1+1' }, 241 + B1: { v: '', f: 'C1+1' }, 242 + C1: { v: '', f: 'A1+1' }, 243 + }); 244 + const engine = new RecalcEngine(store); 245 + engine.buildFullGraph(); 246 + 247 + const result = engine.recalculate('A1'); 248 + const cyclePaths = engine.getCyclePaths(); 249 + // Should contain something like ["A1", "B1", "C1", "A1"] or similar 250 + expect(cyclePaths.length).toBeGreaterThan(0); 251 + const path = cyclePaths[0]; 252 + expect(path.length).toBeGreaterThanOrEqual(3); 253 + // The path should form a cycle: first element equals last element 254 + expect(path[0]).toBe(path[path.length - 1]); 255 + }); 256 + 257 + it('circular cells do not crash the engine — non-circular cells still work', () => { 258 + const store = makeCellStore({ 259 + A1: { v: '', f: 'B1+1' }, 260 + B1: { v: '', f: 'A1+1' }, 261 + C1: { v: 10, f: '' }, 262 + D1: { v: '', f: 'C1*2' }, // not in the cycle 263 + }); 264 + const engine = new RecalcEngine(store); 265 + engine.buildFullGraph(); 266 + 267 + engine.recalculate('A1'); 268 + // Circular cells get error 269 + expect(store.get('A1').v).toBe('#CIRCULAR!'); 270 + expect(store.get('B1').v).toBe('#CIRCULAR!'); 271 + 272 + // Non-circular cells still work 273 + engine.recalculate('C1'); 274 + expect(store.get('D1').v).toBe(20); 275 + }); 276 + 277 + it('cycle introduced by edit is detected', () => { 278 + const store = makeCellStore({ 279 + A1: { v: 10, f: '' }, 280 + B1: { v: '', f: 'A1+1' }, 281 + }); 282 + const engine = new RecalcEngine(store); 283 + engine.buildFullGraph(); 284 + 285 + // Now edit A1 to reference B1, creating a cycle 286 + store.set('A1', { v: '', f: 'B1+1' }); 287 + engine.updateCell('A1'); 288 + const changed = engine.recalculate('A1'); 289 + 290 + expect(store.get('A1').v).toBe('#CIRCULAR!'); 291 + expect(store.get('B1').v).toBe('#CIRCULAR!'); 292 + }); 293 + }); 294 + 295 + // ===================================================================== 296 + // 3. VOLATILE FUNCTION HANDLING 297 + // ===================================================================== 298 + 299 + describe('Volatile function handling', () => { 300 + it('VOLATILE_FUNCTIONS includes NOW, TODAY, RAND, RANDBETWEEN', () => { 301 + expect(VOLATILE_FUNCTIONS).toContain('NOW'); 302 + expect(VOLATILE_FUNCTIONS).toContain('TODAY'); 303 + expect(VOLATILE_FUNCTIONS).toContain('RAND'); 304 + expect(VOLATILE_FUNCTIONS).toContain('RANDBETWEEN'); 305 + }); 306 + 307 + it('isVolatile returns true for formulas containing volatile functions', () => { 308 + expect(isVolatile('NOW()')).toBe(true); 309 + expect(isVolatile('TODAY()')).toBe(true); 310 + expect(isVolatile('RAND()')).toBe(true); 311 + expect(isVolatile('RANDBETWEEN(1,10)')).toBe(true); 312 + expect(isVolatile('A1+NOW()')).toBe(true); 313 + expect(isVolatile('IF(A1>0, RAND(), 0)')).toBe(true); 314 + }); 315 + 316 + it('isVolatile returns false for non-volatile formulas', () => { 317 + expect(isVolatile('A1+B1')).toBe(false); 318 + expect(isVolatile('SUM(A1:A10)')).toBe(false); 319 + expect(isVolatile('IF(A1>0, 1, 0)')).toBe(false); 320 + expect(isVolatile('ROUND(A1, 2)')).toBe(false); 321 + }); 322 + 323 + it('isVolatile is case-insensitive', () => { 324 + expect(isVolatile('now()')).toBe(true); 325 + expect(isVolatile('Today()')).toBe(true); 326 + expect(isVolatile('rand()')).toBe(true); 327 + }); 328 + 329 + it('volatile cells are always marked dirty during recalculation', () => { 330 + const store = makeCellStore({ 331 + A1: { v: '', f: 'RAND()' }, 332 + B1: { v: '', f: 'A1*100' }, 333 + }); 334 + const engine = new RecalcEngine(store); 335 + engine.buildFullGraph(); 336 + 337 + // Initial recalc 338 + engine.recalculate('A1'); 339 + const v1 = store.get('A1').v; 340 + 341 + // Recalculate all volatile cells — A1 should be re-evaluated 342 + // even if nothing was explicitly edited 343 + const changed = engine.recalculateVolatile(); 344 + // A1 is volatile, so it was re-evaluated (value likely changed) 345 + // B1 depends on A1, so it was also re-evaluated 346 + // We just verify the engine ran without error and returned results 347 + expect(changed).toBeInstanceOf(Set); 348 + }); 349 + 350 + it('non-volatile cells are not marked dirty by recalculateVolatile', () => { 351 + const evalOrder = []; 352 + const store = makeCellStore({ 353 + A1: { v: '', f: 'RAND()' }, 354 + B1: { v: '', f: 'A1*100' }, 355 + C1: { v: 10, f: '' }, 356 + D1: { v: '', f: 'C1+5' }, // non-volatile, not dependent on A1 357 + }); 358 + const engine = new RecalcEngine(store, { 359 + onEvaluate(cellId) { evalOrder.push(cellId); }, 360 + }); 361 + engine.buildFullGraph(); 362 + 363 + // Do initial full recalc 364 + engine.recalculate('A1'); 365 + engine.recalculate('C1'); 366 + evalOrder.length = 0; 367 + 368 + // Volatile recalc should only touch A1 and B1, not D1 369 + engine.recalculateVolatile(); 370 + expect(evalOrder).toContain('A1'); 371 + expect(evalOrder).not.toContain('D1'); 372 + }); 373 + }); 374 + 375 + // ===================================================================== 376 + // 4. INCREMENTAL GRAPH UPDATES 377 + // ===================================================================== 378 + 379 + describe('RecalcEngine — incremental graph updates', () => { 380 + it('updateCell removes old edges when formula changes', () => { 381 + const store = makeCellStore({ 382 + A1: { v: 10, f: '' }, 383 + B1: { v: 20, f: '' }, 384 + C1: { v: '', f: 'A1+1' }, // depends on A1 385 + }); 386 + const engine = new RecalcEngine(store); 387 + engine.buildFullGraph(); 388 + 389 + expect(engine.getPrecedents('C1')).toContain('A1'); 390 + expect(engine.getDependents('A1')).toContain('C1'); 391 + 392 + // Change C1 to reference B1 instead of A1 393 + store.set('C1', { v: '', f: 'B1+1' }); 394 + engine.updateCell('C1'); 395 + 396 + // Old edge removed 397 + expect(engine.getPrecedents('C1')).not.toContain('A1'); 398 + expect(engine.getDependents('A1')).not.toContain('C1'); 399 + // New edge added 400 + expect(engine.getPrecedents('C1')).toContain('B1'); 401 + expect(engine.getDependents('B1')).toContain('C1'); 402 + }); 403 + 404 + it('updateCell handles formula removal (cell becomes plain value)', () => { 405 + const store = makeCellStore({ 406 + A1: { v: 10, f: '' }, 407 + B1: { v: '', f: 'A1+1' }, 408 + }); 409 + const engine = new RecalcEngine(store); 410 + engine.buildFullGraph(); 411 + 412 + expect(engine.getDependents('A1')).toContain('B1'); 413 + 414 + // Change B1 to a plain value 415 + store.set('B1', { v: 99, f: '' }); 416 + engine.updateCell('B1'); 417 + 418 + expect(engine.getDependents('A1')).not.toContain('B1'); 419 + expect(engine.getPrecedents('B1').size).toBe(0); 420 + }); 421 + 422 + it('updateCell handles new formula added to a plain value cell', () => { 423 + const store = makeCellStore({ 424 + A1: { v: 10, f: '' }, 425 + B1: { v: 50, f: '' }, 426 + }); 427 + const engine = new RecalcEngine(store); 428 + engine.buildFullGraph(); 429 + 430 + // B1 was plain, now add a formula 431 + store.set('B1', { v: '', f: 'A1*5' }); 432 + engine.updateCell('B1'); 433 + 434 + expect(engine.getPrecedents('B1')).toContain('A1'); 435 + expect(engine.getDependents('A1')).toContain('B1'); 436 + }); 437 + 438 + it('recalculate after updateCell uses updated graph', () => { 439 + const store = makeCellStore({ 440 + A1: { v: 10, f: '' }, 441 + B1: { v: 20, f: '' }, 442 + C1: { v: '', f: 'A1+1' }, 443 + }); 444 + const engine = new RecalcEngine(store); 445 + engine.buildFullGraph(); 446 + engine.recalculate('A1'); 447 + expect(store.get('C1').v).toBe(11); 448 + 449 + // Change C1's formula to reference B1 450 + store.set('C1', { v: '', f: 'B1+1' }); 451 + engine.updateCell('C1'); 452 + engine.recalculate('C1'); 453 + expect(store.get('C1').v).toBe(21); 454 + }); 455 + }); 456 + 457 + // ===================================================================== 458 + // 5. CROSS-SHEET DEPENDENCIES 459 + // ===================================================================== 460 + 461 + describe('RecalcEngine — cross-sheet references', () => { 462 + it('tracks cross-sheet references as SheetName!CellId in graph', () => { 463 + const store = makeCellStore({ 464 + A1: { v: '', f: 'Sheet2!A1+1' }, 465 + }); 466 + const engine = new RecalcEngine(store); 467 + engine.buildFullGraph(); 468 + 469 + expect(engine.getPrecedents('A1')).toContain('Sheet2!A1'); 470 + }); 471 + 472 + it('recalculates dependents when cross-sheet source changes', () => { 473 + const store = makeCellStore({ 474 + A1: { v: '', f: 'Sheet2!B1*2' }, 475 + }); 476 + const engine = new RecalcEngine(store); 477 + engine.buildFullGraph(); 478 + 479 + // Simulate that Sheet2!B1 changed 480 + const changed = engine.recalculate('Sheet2!B1'); 481 + // A1 should be in the dirty set 482 + expect(changed.has('A1') || engine.getDependents('Sheet2!B1').has('A1')).toBe(true); 483 + }); 484 + }); 485 + 486 + // ===================================================================== 487 + // 6. NAMED RANGE SUPPORT 488 + // ===================================================================== 489 + 490 + describe('RecalcEngine — named ranges', () => { 491 + it('resolves named range cells as dependencies', () => { 492 + const store = makeCellStore({ 493 + A1: { v: 10, f: '' }, 494 + A2: { v: 20, f: '' }, 495 + A3: { v: 30, f: '' }, 496 + B1: { v: '', f: 'SUM(myrange)' }, 497 + }); 498 + const namedRanges = { 499 + myrange: { range: 'A1:A3' }, 500 + }; 501 + const engine = new RecalcEngine(store, { namedRanges }); 502 + engine.buildFullGraph(); 503 + 504 + // B1 depends on A1, A2, A3 via the named range 505 + const precs = engine.getPrecedents('B1'); 506 + expect(precs).toContain('A1'); 507 + expect(precs).toContain('A2'); 508 + expect(precs).toContain('A3'); 509 + }); 510 + }); 511 + 512 + // ===================================================================== 513 + // 7. EDGE CASES 514 + // ===================================================================== 515 + 516 + describe('RecalcEngine — edge cases', () => { 517 + it('handles empty cell store', () => { 518 + const store = makeCellStore({}); 519 + const engine = new RecalcEngine(store); 520 + engine.buildFullGraph(); 521 + // Should not throw 522 + const changed = engine.recalculate('A1'); 523 + expect(changed).toBeInstanceOf(Set); 524 + expect(changed.size).toBe(0); 525 + }); 526 + 527 + it('handles formula referencing non-existent cell', () => { 528 + const store = makeCellStore({ 529 + A1: { v: '', f: 'Z99+1' }, 530 + }); 531 + const engine = new RecalcEngine(store); 532 + engine.buildFullGraph(); 533 + 534 + const changed = engine.recalculate('Z99'); 535 + // A1 should have been recalculated 536 + expect(changed.has('A1')).toBe(true); 537 + }); 538 + 539 + it('handles long chain (20 cells deep)', () => { 540 + const data = { A1: { v: 1, f: '' } }; 541 + for (let i = 2; i <= 20; i++) { 542 + data[`A${i}`] = { v: '', f: `A${i - 1}+1` }; 543 + } 544 + const store = makeCellStore(data); 545 + const engine = new RecalcEngine(store); 546 + engine.buildFullGraph(); 547 + 548 + store.set('A1', { v: 100, f: '' }); 549 + engine.recalculate('A1'); 550 + 551 + expect(store.get('A20').v).toBe(119); // 100 + 19 552 + }); 553 + 554 + it('handles multiple simultaneous edits', () => { 555 + const store = makeCellStore({ 556 + A1: { v: 1, f: '' }, 557 + B1: { v: 2, f: '' }, 558 + C1: { v: '', f: 'A1+B1' }, 559 + }); 560 + const engine = new RecalcEngine(store); 561 + engine.buildFullGraph(); 562 + 563 + store.set('A1', { v: 10, f: '' }); 564 + store.set('B1', { v: 20, f: '' }); 565 + const changed = engine.recalculateMultiple(['A1', 'B1']); 566 + 567 + expect(changed.has('C1')).toBe(true); 568 + expect(store.get('C1').v).toBe(30); 569 + }); 570 + 571 + it('buildFullGraph can be called multiple times without duplicating edges', () => { 572 + const store = makeCellStore({ 573 + A1: { v: 10, f: '' }, 574 + B1: { v: '', f: 'A1+1' }, 575 + }); 576 + const engine = new RecalcEngine(store); 577 + engine.buildFullGraph(); 578 + engine.buildFullGraph(); // second call 579 + 580 + // Should still have exactly one dependent 581 + const deps = engine.getDependents('A1'); 582 + expect([...deps].filter(d => d === 'B1').length).toBe(1); 583 + }); 584 + });