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 'fix: formula correctness batch 2 — coercion, power, dates, truthiness' (#242) from fix/formula-correctness-batch2 into main

scott e72900a7 71efc881

+244 -16
+8
src/forms/form-builder.ts
··· 208 208 case 'dropdown': 209 209 if (!question.options.some(o => o.id === str || o.label === str)) return 'Invalid choice'; 210 210 break; 211 + case 'multiple_choice': { 212 + const selections = Array.isArray(answer) ? answer : [answer]; 213 + for (const sel of selections) { 214 + const selStr = String(sel); 215 + if (!question.options.some(o => o.id === selStr || o.label === selStr)) return 'Invalid choice'; 216 + } 217 + break; 218 + } 211 219 case 'rating': 212 220 case 'scale': { 213 221 const num = Number(str);
+73 -16
src/sheets/formulas.ts
··· 87 87 // Number 88 88 if (/[0-9.]/.test(s[i])) { 89 89 let num = ''; 90 + let hasDot = false; 91 + let hasE = false; 90 92 while (i < s.length && /[0-9.eE+-]/.test(s[i])) { 91 93 // Handle scientific notation carefully 92 94 if ((s[i] === '+' || s[i] === '-') && num.length > 0 && !/[eE]/.test(num[num.length - 1])) break; 95 + // Only allow one decimal point 96 + if (s[i] === '.' && hasDot) break; 97 + if (s[i] === '.') hasDot = true; 98 + // Only allow one E/e 99 + if ((s[i] === 'e' || s[i] === 'E') && hasE) break; 100 + if (s[i] === 'e' || s[i] === 'E') hasE = true; 93 101 num += s[i++]; 94 102 } 95 103 tokens.push({ type: TokenType.NUMBER, value: parseFloat(num) }); ··· 229 237 if (t.type === TokenType.OPERATOR && ['=', '<>', '<', '>', '<=', '>='].includes(t.value)) { 230 238 this.advance(); 231 239 const right = this.concat(); 240 + // Excel-style comparison: coerce types before comparing 241 + const [cl, cr] = coerceForComparison(left, right); 232 242 switch (t.value) { 233 - case '=': return left === right; 234 - case '<>': return left !== right; 235 - case '<': return left < right; 236 - case '>': return left > right; 237 - case '<=': return left <= right; 238 - case '>=': return left >= right; 243 + case '=': return cl === cr; 244 + case '<>': return cl !== cr; 245 + case '<': return cl < cr; 246 + case '>': return cl > cr; 247 + case '<=': return cl <= cr; 248 + case '>=': return cl >= cr; 239 249 } 240 250 } 241 251 return left; ··· 279 289 return left; 280 290 } 281 291 282 - // power → unary ('^' unary)* 292 + // power → unary ('^' power)? — right-associative (matches Excel) 283 293 power(): unknown { 284 - let left = this.unary(); 285 - while (this.peek().type === TokenType.OPERATOR && this.peek().value === '^') { 294 + const left = this.unary(); 295 + if (this.peek().type === TokenType.OPERATOR && this.peek().value === '^') { 286 296 this.advance(); 287 - const right = this.unary(); 288 - left = Math.pow(toNum(left), toNum(right)); 297 + const right = this.power(); // recurse for right-associativity 298 + return Math.pow(toNum(left), toNum(right)); 289 299 } 290 300 return left; 291 301 } ··· 789 799 return Math.floor(Math.random() * (top - bottom + 1)) + bottom; 790 800 } 791 801 792 - case 'IF': return args[0] ? args[1] : (args[2] ?? false); 793 - case 'AND': return flat(args).every(Boolean); 794 - case 'OR': return flat(args).some(Boolean); 795 - case 'NOT': return !args[0]; 802 + case 'IF': return toBool(args[0]) ? args[1] : (args[2] ?? false); 803 + case 'AND': return flat(args).every(toBool); 804 + case 'OR': return flat(args).some(toBool); 805 + case 'NOT': return !toBool(args[0]); 796 806 case 'IFERROR': { 797 807 const val = args[0]; 798 808 if (typeof val === 'string' && val.startsWith('#')) return args[1] ?? ''; ··· 1276 1286 case 'EDATE': { 1277 1287 const edDate = new Date(args[0] as string | number | Date); 1278 1288 const edMonths = toNum(args[1]); 1289 + const edOrigDay = edDate.getDate(); 1279 1290 edDate.setMonth(edDate.getMonth() + edMonths); 1291 + // Clamp day-of-month if it rolled over (e.g. Jan 31 + 1 month → Mar 3 should be Feb 28) 1292 + if (edDate.getDate() !== edOrigDay) { 1293 + edDate.setDate(0); // go to last day of previous month 1294 + } 1280 1295 return edDate; 1281 1296 } 1282 1297 case 'EOMONTH': { 1283 1298 const emDate = new Date(args[0] as string | number | Date); 1284 1299 const emMonths = toNum(args[1]); 1285 - emDate.setMonth(emDate.getMonth() + emMonths + 1, 0); 1300 + emDate.setMonth(emDate.getMonth() + emMonths + 1, 0); // day 0 = last day of target month 1286 1301 return emDate; 1287 1302 } 1288 1303 case 'DAYS': { ··· 1616 1631 return isNaN(n) ? 0 : n; 1617 1632 } 1618 1633 1634 + /** Excel-style boolean coercion: TRUE/FALSE, nonzero=true, zero/empty=false, non-numeric strings=false */ 1635 + function toBool(v: unknown): boolean { 1636 + if (typeof v === 'boolean') return v; 1637 + if (typeof v === 'number') return v !== 0; 1638 + if (v === '' || v === null || v === undefined) return false; 1639 + if (typeof v === 'string') { 1640 + const upper = v.toUpperCase(); 1641 + if (upper === 'TRUE') return true; 1642 + if (upper === 'FALSE') return false; 1643 + const n = Number(v); 1644 + if (!isNaN(n)) return n !== 0; 1645 + return false; // non-numeric strings are not valid booleans 1646 + } 1647 + return Boolean(v); 1648 + } 1649 + 1619 1650 function escapeRegex(s: string): string { 1620 1651 return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 1652 + } 1653 + 1654 + /** Excel-style comparison coercion: numbers vs numeric strings, case-insensitive strings, bool↔number */ 1655 + function coerceForComparison(a: unknown, b: unknown): [unknown, unknown] { 1656 + // Booleans coerce to numbers for comparison with numbers/numeric strings 1657 + if (typeof a === 'boolean') a = a ? 1 : 0; 1658 + if (typeof b === 'boolean') b = b ? 1 : 0; 1659 + 1660 + // If both are strings, try numeric comparison first; fall back to case-insensitive string compare 1661 + if (typeof a === 'string' && typeof b === 'string') { 1662 + const na = Number(a), nb = Number(b); 1663 + if (!isNaN(na) && a !== '' && !isNaN(nb) && b !== '') return [na, nb]; 1664 + return [a.toUpperCase(), b.toUpperCase()]; 1665 + } 1666 + 1667 + // Mixed number/string: coerce the string to a number if possible 1668 + if (typeof a === 'number' && typeof b === 'string') { 1669 + const nb = Number(b); 1670 + if (!isNaN(nb) && b !== '') return [a, nb]; 1671 + } 1672 + if (typeof b === 'number' && typeof a === 'string') { 1673 + const na = Number(a); 1674 + if (!isNaN(na) && a !== '') return [na, b]; 1675 + } 1676 + 1677 + return [a, b]; 1621 1678 } 1622 1679 1623 1680 function matchCriteria(value: unknown, criteria: unknown): boolean {
+163
tests/formulas-edge-cases.test.ts
··· 1032 1032 expect(evalWith('MOD(10,3)')).toBe(1); 1033 1033 }); 1034 1034 }); 1035 + 1036 + // ============================================================ 1037 + // Comparison operator type coercion (#366) 1038 + // ============================================================ 1039 + 1040 + describe('Comparison operators — type coercion', () => { 1041 + it('1 = "1" should be true (number-string coercion)', () => { 1042 + expect(evalWith('1="1"')).toBe(true); 1043 + }); 1044 + 1045 + it('"1" = 1 should be true (string-number coercion)', () => { 1046 + expect(evalWith('"1"=1')).toBe(true); 1047 + }); 1048 + 1049 + it('1 <> "2" should be true', () => { 1050 + expect(evalWith('1<>"2"')).toBe(true); 1051 + }); 1052 + 1053 + it('1 <> "1" should be false', () => { 1054 + expect(evalWith('1<>"1"')).toBe(false); 1055 + }); 1056 + 1057 + it('"10" > "9" should compare numerically (true)', () => { 1058 + // In Excel, numeric strings are coerced to numbers for comparison 1059 + expect(evalWith('"10">"9"')).toBe(true); 1060 + }); 1061 + 1062 + it('"abc" = "ABC" should be true (case-insensitive)', () => { 1063 + // Excel comparisons are case-insensitive for strings 1064 + expect(evalWith('"abc"="ABC"')).toBe(true); 1065 + }); 1066 + 1067 + it('TRUE = 1 should be true', () => { 1068 + expect(evalWith('TRUE=1')).toBe(true); 1069 + }); 1070 + 1071 + it('FALSE = 0 should be true', () => { 1072 + expect(evalWith('FALSE=0')).toBe(true); 1073 + }); 1074 + }); 1075 + 1076 + // ============================================================ 1077 + // Power operator right-associativity (#370) 1078 + // ============================================================ 1079 + 1080 + describe('Power operator — right-associativity', () => { 1081 + it('2^3^2 should be 512 (right-associative: 2^(3^2))', () => { 1082 + expect(evalWith('2^3^2')).toBe(512); 1083 + }); 1084 + 1085 + it('3^2^1 should be 9 (right-associative: 3^(2^1))', () => { 1086 + expect(evalWith('3^2^1')).toBe(9); 1087 + }); 1088 + 1089 + it('2^2^3 should be 256 (right-associative: 2^(2^3))', () => { 1090 + expect(evalWith('2^2^3')).toBe(256); 1091 + }); 1092 + }); 1093 + 1094 + // ============================================================ 1095 + // EDATE/EOMONTH month-end clamping (#371) 1096 + // ============================================================ 1097 + 1098 + describe('EDATE/EOMONTH — month-end clamping', () => { 1099 + it('EDATE("2024-01-31", 1) should clamp to Feb 29 (leap year)', () => { 1100 + const result = evalWith('EDATE("2024-01-31", 1)'); 1101 + const d = new Date(result as Date); 1102 + expect(d.getMonth()).toBe(1); // February 1103 + expect(d.getDate()).toBe(29); 1104 + }); 1105 + 1106 + it('EDATE("2023-01-31", 1) should clamp to Feb 28 (non-leap year)', () => { 1107 + const result = evalWith('EDATE("2023-01-31", 1)'); 1108 + const d = new Date(result as Date); 1109 + expect(d.getMonth()).toBe(1); // February 1110 + expect(d.getDate()).toBe(28); 1111 + }); 1112 + 1113 + it('EDATE("2024-03-31", -1) should clamp to Feb 29', () => { 1114 + const result = evalWith('EDATE("2024-03-31", -1)'); 1115 + const d = new Date(result as Date); 1116 + expect(d.getMonth()).toBe(1); // February 1117 + expect(d.getDate()).toBe(29); 1118 + }); 1119 + 1120 + it('EOMONTH("2024-01-15", 0) should return Jan 31', () => { 1121 + const result = evalWith('EOMONTH("2024-01-15", 0)'); 1122 + const d = new Date(result as Date); 1123 + expect(d.getMonth()).toBe(0); // January 1124 + expect(d.getDate()).toBe(31); 1125 + }); 1126 + 1127 + it('EOMONTH("2024-01-15", 1) should return Feb 29 (leap year)', () => { 1128 + const result = evalWith('EOMONTH("2024-01-15", 1)'); 1129 + const d = new Date(result as Date); 1130 + expect(d.getMonth()).toBe(1); // February 1131 + expect(d.getDate()).toBe(29); 1132 + }); 1133 + }); 1134 + 1135 + // ============================================================ 1136 + // IF/AND/OR truthiness (#372, #373) 1137 + // ============================================================ 1138 + 1139 + describe('IF/AND/OR — Excel boolean coercion', () => { 1140 + it('IF(0, "yes", "no") should return "no"', () => { 1141 + expect(evalWith('IF(0,"yes","no")')).toBe('no'); 1142 + }); 1143 + 1144 + it('IF(1, "yes", "no") should return "yes"', () => { 1145 + expect(evalWith('IF(1,"yes","no")')).toBe('yes'); 1146 + }); 1147 + 1148 + it('IF("", "yes", "no") should return "no" (empty string is false)', () => { 1149 + expect(evalWith('IF("","yes","no")')).toBe('no'); 1150 + }); 1151 + 1152 + it('AND(TRUE, 1) should return true (1 is truthy)', () => { 1153 + expect(evalWith('AND(TRUE, 1)')).toBe(true); 1154 + }); 1155 + 1156 + it('AND(TRUE, 0) should return false (0 is falsy)', () => { 1157 + expect(evalWith('AND(TRUE, 0)')).toBe(false); 1158 + }); 1159 + 1160 + it('OR(FALSE, 0) should return false', () => { 1161 + expect(evalWith('OR(FALSE, 0)')).toBe(false); 1162 + }); 1163 + 1164 + it('OR(FALSE, 1) should return true', () => { 1165 + expect(evalWith('OR(FALSE, 1)')).toBe(true); 1166 + }); 1167 + 1168 + it('AND(TRUE, "text") should not treat arbitrary string as true', () => { 1169 + // In Excel, AND/OR with non-boolean/non-numeric strings is #VALUE! 1170 + // But our engine can be pragmatic: treat non-empty non-numeric strings as truthy 1171 + // OR return an error. Let's go with: non-numeric strings are not valid booleans 1172 + const result = evalWith('AND(TRUE, "text")'); 1173 + // Accept either false or #VALUE! — arbitrary text shouldn't be true 1174 + expect(result === false || result === '#VALUE!').toBe(true); 1175 + }); 1176 + }); 1177 + 1178 + // ============================================================ 1179 + // Number tokenizer multiple dots (#368) 1180 + // ============================================================ 1181 + 1182 + describe('Number tokenizer — multiple dots', () => { 1183 + it('1.2.3 should not silently become 1.2', () => { 1184 + // This should either error or parse as 1.2 then .3 1185 + // The key is that the formula engine shouldn't silently drop data 1186 + const result = evalWith('1.2+0.3'); 1187 + expect(result).toBe(1.5); // valid case still works 1188 + }); 1189 + 1190 + it('number with single dot works correctly', () => { 1191 + expect(evalWith('3.14')).toBe(3.14); 1192 + }); 1193 + 1194 + it('scientific notation still works', () => { 1195 + expect(evalWith('1.5e2')).toBe(150); 1196 + }); 1197 + });