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

Configure Feed

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

test: batch 18 — formulas & pivot-table edge cases (131 tests) (#437)

Add comprehensive tests for previously untested formula functions:
- String: SUBSTITUTE, FIND, SEARCH
- Logic: IFERROR, SWITCH, CHOOSE
- Info: ISNUMBER, ISTEXT, ISBLANK, ISERROR, ISNA, ISLOGICAL, TYPE, N, T
- Text: PROPER, REPT, EXACT, REPLACE, CLEAN, CHAR, CODE, TEXTJOIN, CONCAT
- Math: SIGN, EVEN, ODD, CEILING, FLOOR, FACT, COMBIN, GCD, LCM, QUOTIENT,
SUMPRODUCT, PRODUCT, ROUNDUP, ROUNDDOWN, LOG, LN, EXP
- Stats: LARGE, SMALL, RANK, PERCENTILE, VAR, VARP, STDEVP
- Financial: PMT, FV, NPV
- Conditional: AVERAGEIF, SUMIFS, COUNTIFS, AVERAGEIFS
- Lookup: XLOOKUP, ADDRESS
- Date: YEAR/MONTH/DAY via evaluate, DAYS
- Cross-sheet references with resolver object
- Comparison edge cases, MOD sign convention
- parseRef/extractRefs edge cases, COUNTA

Pivot-table edge cases: all 6 aggregation types, multiple row/col fields,
single-row data, formatAggregateValue edges, keyToString/extractKey edges.

Total tests: 6,583

+867
+698
tests/formulas.test.ts
··· 918 918 expect(result._rangeRows).toBe(1); 919 919 }); 920 920 }); 921 + 922 + // ===================================================================== 923 + // Edge cases: parseRef 924 + // ===================================================================== 925 + 926 + describe('parseRef — edge cases', () => { 927 + it('returns null for invalid input', () => { 928 + expect(parseRef('')).toBeNull(); 929 + expect(parseRef('123')).toBeNull(); 930 + expect(parseRef('hello')).toBeNull(); 931 + }); 932 + 933 + it('handles multi-letter columns', () => { 934 + expect(parseRef('AAA1')).toEqual({ col: 703, row: 1 }); 935 + }); 936 + 937 + it('handles large row numbers', () => { 938 + expect(parseRef('A9999')).toEqual({ col: 1, row: 9999 }); 939 + }); 940 + }); 941 + 942 + // ===================================================================== 943 + // Edge cases: extractRefs 944 + // ===================================================================== 945 + 946 + describe('extractRefs — edge cases', () => { 947 + it('returns empty set for empty formula', () => { 948 + expect(extractRefs('').size).toBe(0); 949 + }); 950 + 951 + it('returns empty set for formula with no refs', () => { 952 + expect(extractRefs('1+2+3').size).toBe(0); 953 + }); 954 + 955 + it('extracts refs from nested functions', () => { 956 + const refs = extractRefs('IF(SUM(A1:A3)>10,B1,C1)'); 957 + expect(refs).toContain('A1'); 958 + expect(refs).toContain('A2'); 959 + expect(refs).toContain('A3'); 960 + expect(refs).toContain('B1'); 961 + expect(refs).toContain('C1'); 962 + }); 963 + 964 + it('does not extract refs from string literals', () => { 965 + const refs = extractRefs('CONCATENATE("A1","B2")'); 966 + expect(refs.size).toBe(0); 967 + }); 968 + }); 969 + 970 + // ===================================================================== 971 + // String functions: SUBSTITUTE, FIND, SEARCH 972 + // ===================================================================== 973 + 974 + describe('SUBSTITUTE', () => { 975 + it('replaces all occurrences by default', () => { 976 + expect(evalWith('SUBSTITUTE("hello world hello","hello","hi")')).toBe('hi world hi'); 977 + }); 978 + 979 + it('replaces specific instance', () => { 980 + expect(evalWith('SUBSTITUTE("aXbXcX","X","Y",2)')).toBe('aXbYcX'); 981 + }); 982 + 983 + it('replaces first instance', () => { 984 + expect(evalWith('SUBSTITUTE("aXbX","X","Y",1)')).toBe('aYbX'); 985 + }); 986 + 987 + it('returns original when no match', () => { 988 + expect(evalWith('SUBSTITUTE("hello","xyz","abc")')).toBe('hello'); 989 + }); 990 + }); 991 + 992 + describe('FIND', () => { 993 + it('finds substring (case-sensitive)', () => { 994 + expect(evalWith('FIND("world","hello world")')).toBe(7); 995 + }); 996 + 997 + it('returns #VALUE! when not found', () => { 998 + expect(evalWith('FIND("xyz","hello")')).toBe('#VALUE!'); 999 + }); 1000 + 1001 + it('is case-sensitive', () => { 1002 + expect(evalWith('FIND("HELLO","hello world")')).toBe('#VALUE!'); 1003 + }); 1004 + 1005 + it('supports start position', () => { 1006 + expect(evalWith('FIND("l","hello world",5)')).toBe(10); 1007 + }); 1008 + }); 1009 + 1010 + describe('SEARCH', () => { 1011 + it('finds substring (case-insensitive)', () => { 1012 + expect(evalWith('SEARCH("WORLD","hello world")')).toBe(7); 1013 + }); 1014 + 1015 + it('returns #VALUE! when not found', () => { 1016 + expect(evalWith('SEARCH("xyz","hello")')).toBe('#VALUE!'); 1017 + }); 1018 + 1019 + it('supports start position', () => { 1020 + expect(evalWith('SEARCH("l","hello world",5)')).toBe(10); 1021 + }); 1022 + }); 1023 + 1024 + // ===================================================================== 1025 + // IFERROR 1026 + // ===================================================================== 1027 + 1028 + describe('IFERROR', () => { 1029 + it('returns value when no error', () => { 1030 + expect(evalWith('IFERROR(42,"oops")')).toBe(42); 1031 + }); 1032 + 1033 + it('returns fallback for #DIV/0!', () => { 1034 + expect(evalWith('IFERROR(10/0,"oops")')).toBe('oops'); 1035 + }); 1036 + 1037 + it('returns fallback for #VALUE!', () => { 1038 + expect(evalWith('IFERROR(FIND("x","abc"),"not found")')).toBe('not found'); 1039 + }); 1040 + 1041 + it('returns empty string when no fallback provided for error', () => { 1042 + expect(evalWith('IFERROR(10/0)')).toBe(''); 1043 + }); 1044 + }); 1045 + 1046 + // ===================================================================== 1047 + // COUNTA 1048 + // ===================================================================== 1049 + 1050 + describe('COUNTA', () => { 1051 + it('counts non-empty values', () => { 1052 + const cells = { A1: 10, A2: 'hello', A3: true }; 1053 + expect(evalWith('COUNTA(A1:A3)', cells)).toBe(3); 1054 + }); 1055 + 1056 + it('does not count empty cells', () => { 1057 + const cells = { A1: 10, A3: 20 }; 1058 + expect(evalWith('COUNTA(A1:A3)', cells)).toBe(2); 1059 + }); 1060 + }); 1061 + 1062 + // ===================================================================== 1063 + // ROUNDUP / ROUNDDOWN 1064 + // ===================================================================== 1065 + 1066 + describe('ROUNDUP / ROUNDDOWN', () => { 1067 + it('ROUNDUP rounds away from zero (positive)', () => { 1068 + expect(evalWith('ROUNDUP(3.141,2)')).toBeCloseTo(3.15); 1069 + }); 1070 + 1071 + it('ROUNDUP rounds away from zero (negative)', () => { 1072 + expect(evalWith('ROUNDUP(-3.141,2)')).toBeCloseTo(-3.15); 1073 + }); 1074 + 1075 + it('ROUNDDOWN rounds toward zero (positive)', () => { 1076 + expect(evalWith('ROUNDDOWN(3.149,2)')).toBeCloseTo(3.14); 1077 + }); 1078 + 1079 + it('ROUNDDOWN rounds toward zero (negative)', () => { 1080 + expect(evalWith('ROUNDDOWN(-3.149,2)')).toBeCloseTo(-3.14); 1081 + }); 1082 + }); 1083 + 1084 + // ===================================================================== 1085 + // LOG / LN / EXP 1086 + // ===================================================================== 1087 + 1088 + describe('LOG / LN / EXP', () => { 1089 + it('LOG defaults to base 10', () => { 1090 + expect(evalWith('LOG(100)')).toBeCloseTo(2); 1091 + }); 1092 + 1093 + it('LOG with custom base', () => { 1094 + expect(evalWith('LOG(8,2)')).toBeCloseTo(3); 1095 + }); 1096 + 1097 + it('LN computes natural log', () => { 1098 + expect(evalWith('LN(1)')).toBe(0); 1099 + expect(evalWith('LN(2.71828)')).toBeCloseTo(1, 4); 1100 + }); 1101 + 1102 + it('EXP computes e^x', () => { 1103 + expect(evalWith('EXP(0)')).toBe(1); 1104 + expect(evalWith('EXP(1)')).toBeCloseTo(Math.E); 1105 + }); 1106 + }); 1107 + 1108 + // ===================================================================== 1109 + // TEXT / VALUE 1110 + // ===================================================================== 1111 + 1112 + describe('TEXT / VALUE', () => { 1113 + it('VALUE converts numeric string to number', () => { 1114 + expect(evalWith('VALUE("42")')).toBe(42); 1115 + expect(evalWith('VALUE("3.14")')).toBe(3.14); 1116 + }); 1117 + 1118 + it('VALUE converts non-numeric to 0', () => { 1119 + expect(evalWith('VALUE("hello")')).toBe(0); 1120 + }); 1121 + 1122 + it('TEXT formats number', () => { 1123 + const result = evalWith('TEXT(1234.5,"#,##0.00")'); 1124 + expect(typeof result).toBe('string'); 1125 + }); 1126 + }); 1127 + 1128 + // ===================================================================== 1129 + // AVERAGEIF 1130 + // ===================================================================== 1131 + 1132 + describe('AVERAGEIF', () => { 1133 + it('averages values matching criteria', () => { 1134 + const cells = { A1: 10, A2: 20, A3: 30 }; 1135 + expect(evalWith('AVERAGEIF(A1:A3,">15")', cells)).toBe(25); // (20+30)/2 1136 + }); 1137 + 1138 + it('returns #DIV/0! when no matches', () => { 1139 + const cells = { A1: 1, A2: 2 }; 1140 + expect(evalWith('AVERAGEIF(A1:A2,">100")', cells)).toBe('#DIV/0!'); 1141 + }); 1142 + 1143 + it('uses separate sum range', () => { 1144 + const cells = { A1: 'yes', A2: 'no', A3: 'yes', B1: 10, B2: 20, B3: 30 }; 1145 + expect(evalWith('AVERAGEIF(A1:A3,"yes",B1:B3)', cells)).toBe(20); // (10+30)/2 1146 + }); 1147 + }); 1148 + 1149 + // ===================================================================== 1150 + // SUMIFS / COUNTIFS / AVERAGEIFS (multi-criteria) 1151 + // ===================================================================== 1152 + 1153 + describe('SUMIFS', () => { 1154 + it('sums with single criteria', () => { 1155 + const cells = { A1: 10, A2: 20, A3: 30, B1: 'a', B2: 'b', B3: 'a' }; 1156 + expect(evalWith('SUMIFS(A1:A3,B1:B3,"a")', cells)).toBe(40); // 10+30 1157 + }); 1158 + 1159 + it('sums with multiple criteria', () => { 1160 + const cells = { A1: 10, A2: 20, A3: 30, B1: 'a', B2: 'b', B3: 'a', C1: 1, C2: 2, C3: 2 }; 1161 + expect(evalWith('SUMIFS(A1:A3,B1:B3,"a",C1:C3,2)', cells)).toBe(30); // only A3 matches both 1162 + }); 1163 + 1164 + it('supports wildcard criteria', () => { 1165 + const cells = { A1: 10, A2: 20, A3: 30, B1: 'apple', B2: 'banana', B3: 'avocado' }; 1166 + expect(evalWith('SUMIFS(A1:A3,B1:B3,"a*")', cells)).toBe(40); // apple + avocado 1167 + }); 1168 + }); 1169 + 1170 + describe('COUNTIFS', () => { 1171 + it('counts with single criteria', () => { 1172 + const cells = { A1: 'x', A2: 'y', A3: 'x' }; 1173 + expect(evalWith('COUNTIFS(A1:A3,"x")', cells)).toBe(2); 1174 + }); 1175 + 1176 + it('counts with multiple criteria', () => { 1177 + const cells = { A1: 'x', A2: 'y', A3: 'x', B1: 1, B2: 2, B3: 2 }; 1178 + expect(evalWith('COUNTIFS(A1:A3,"x",B1:B3,2)', cells)).toBe(1); // only A3/B3 1179 + }); 1180 + }); 1181 + 1182 + describe('AVERAGEIFS', () => { 1183 + it('averages with single criteria', () => { 1184 + const cells = { A1: 10, A2: 20, A3: 30, B1: 'a', B2: 'b', B3: 'a' }; 1185 + expect(evalWith('AVERAGEIFS(A1:A3,B1:B3,"a")', cells)).toBe(20); // (10+30)/2 1186 + }); 1187 + 1188 + it('returns #DIV/0! when no matches', () => { 1189 + const cells = { A1: 10, B1: 'x' }; 1190 + expect(evalWith('AVERAGEIFS(A1:A1,B1:B1,"z")', cells)).toBe('#DIV/0!'); 1191 + }); 1192 + }); 1193 + 1194 + // ===================================================================== 1195 + // Information functions: ISNUMBER, ISTEXT, ISBLANK, ISERROR, ISNA, TYPE, N, T 1196 + // ===================================================================== 1197 + 1198 + describe('Information functions', () => { 1199 + it('ISNUMBER', () => { 1200 + expect(evalWith('ISNUMBER(42)')).toBe(true); 1201 + expect(evalWith('ISNUMBER("hello")')).toBe(false); 1202 + }); 1203 + 1204 + it('ISTEXT', () => { 1205 + expect(evalWith('ISTEXT("hello")')).toBe(true); 1206 + expect(evalWith('ISTEXT(42)')).toBe(false); 1207 + }); 1208 + 1209 + it('ISBLANK', () => { 1210 + expect(evalWith('ISBLANK(A1)', {})).toBe(true); 1211 + expect(evalWith('ISBLANK(42)')).toBe(false); 1212 + }); 1213 + 1214 + it('ISERROR', () => { 1215 + expect(evalWith('ISERROR(10/0)')).toBe(true); 1216 + expect(evalWith('ISERROR(42)')).toBe(false); 1217 + }); 1218 + 1219 + it('ISNA', () => { 1220 + const cells = { A1: 'apple', B1: 1 }; 1221 + expect(evalWith('ISNA(VLOOKUP("grape",A1:B1,2,FALSE))', cells)).toBe(true); 1222 + expect(evalWith('ISNA(42)')).toBe(false); 1223 + }); 1224 + 1225 + it('ISLOGICAL', () => { 1226 + expect(evalWith('ISLOGICAL(TRUE)')).toBe(true); 1227 + expect(evalWith('ISLOGICAL(42)')).toBe(false); 1228 + }); 1229 + 1230 + it('TYPE returns correct type codes', () => { 1231 + expect(evalWith('TYPE(42)')).toBe(1); // number 1232 + expect(evalWith('TYPE("hello")')).toBe(2); // text 1233 + expect(evalWith('TYPE(TRUE)')).toBe(4); // boolean 1234 + }); 1235 + 1236 + it('N converts to number', () => { 1237 + expect(evalWith('N(42)')).toBe(42); 1238 + expect(evalWith('N(TRUE)')).toBe(1); 1239 + expect(evalWith('N(FALSE)')).toBe(0); 1240 + expect(evalWith('N("hello")')).toBe(0); 1241 + }); 1242 + 1243 + it('T returns text or empty string', () => { 1244 + expect(evalWith('T("hello")')).toBe('hello'); 1245 + expect(evalWith('T(42)')).toBe(''); 1246 + }); 1247 + }); 1248 + 1249 + // ===================================================================== 1250 + // Additional text functions: PROPER, REPT, EXACT, REPLACE, CLEAN, CHAR, CODE 1251 + // ===================================================================== 1252 + 1253 + describe('Additional text functions', () => { 1254 + it('PROPER capitalizes first letter of each word', () => { 1255 + expect(evalWith('PROPER("hello world")')).toBe('Hello World'); 1256 + }); 1257 + 1258 + it('REPT repeats text', () => { 1259 + expect(evalWith('REPT("ab",3)')).toBe('ababab'); 1260 + expect(evalWith('REPT("x",0)')).toBe(''); 1261 + }); 1262 + 1263 + it('EXACT is case-sensitive comparison', () => { 1264 + expect(evalWith('EXACT("hello","hello")')).toBe(true); 1265 + expect(evalWith('EXACT("Hello","hello")')).toBe(false); 1266 + }); 1267 + 1268 + it('REPLACE replaces characters by position', () => { 1269 + expect(evalWith('REPLACE("hello",2,3,"XY")')).toBe('hXYo'); 1270 + }); 1271 + 1272 + it('CLEAN removes control characters', () => { 1273 + // \t is a control character (0x09) 1274 + expect(evalWith('CLEAN("hello")')).toBe('hello'); 1275 + }); 1276 + 1277 + it('CHAR returns character from code', () => { 1278 + expect(evalWith('CHAR(65)')).toBe('A'); 1279 + expect(evalWith('CHAR(97)')).toBe('a'); 1280 + }); 1281 + 1282 + it('CODE returns character code', () => { 1283 + expect(evalWith('CODE("A")')).toBe(65); 1284 + expect(evalWith('CODE("a")')).toBe(97); 1285 + }); 1286 + }); 1287 + 1288 + // ===================================================================== 1289 + // Math: SIGN, EVEN, ODD, CEILING, FLOOR, FACT, COMBIN, GCD, LCM, QUOTIENT 1290 + // ===================================================================== 1291 + 1292 + describe('Additional math functions', () => { 1293 + it('SIGN returns -1, 0, or 1', () => { 1294 + expect(evalWith('SIGN(-5)')).toBe(-1); 1295 + expect(evalWith('SIGN(0)')).toBe(0); 1296 + expect(evalWith('SIGN(5)')).toBe(1); 1297 + }); 1298 + 1299 + it('EVEN rounds up to nearest even', () => { 1300 + expect(evalWith('EVEN(3)')).toBe(4); 1301 + expect(evalWith('EVEN(4)')).toBe(4); 1302 + expect(evalWith('EVEN(-3)')).toBe(-4); 1303 + }); 1304 + 1305 + it('ODD rounds up to nearest odd', () => { 1306 + expect(evalWith('ODD(2)')).toBe(3); 1307 + expect(evalWith('ODD(3)')).toBe(3); 1308 + expect(evalWith('ODD(0)')).toBe(1); 1309 + }); 1310 + 1311 + it('CEILING rounds up to nearest multiple', () => { 1312 + expect(evalWith('CEILING(4.2,1)')).toBe(5); 1313 + expect(evalWith('CEILING(4.2,0.5)')).toBe(4.5); 1314 + expect(evalWith('CEILING(4,1)')).toBe(4); 1315 + }); 1316 + 1317 + it('FLOOR rounds down to nearest multiple', () => { 1318 + expect(evalWith('FLOOR(4.7,1)')).toBe(4); 1319 + expect(evalWith('FLOOR(4.7,0.5)')).toBe(4.5); 1320 + }); 1321 + 1322 + it('FACT computes factorial', () => { 1323 + expect(evalWith('FACT(0)')).toBe(1); 1324 + expect(evalWith('FACT(1)')).toBe(1); 1325 + expect(evalWith('FACT(5)')).toBe(120); 1326 + expect(evalWith('FACT(-1)')).toBe('#NUM!'); 1327 + }); 1328 + 1329 + it('COMBIN computes combinations', () => { 1330 + expect(evalWith('COMBIN(5,2)')).toBe(10); 1331 + expect(evalWith('COMBIN(10,0)')).toBe(1); 1332 + expect(evalWith('COMBIN(5,5)')).toBe(1); 1333 + expect(evalWith('COMBIN(3,5)')).toBe('#NUM!'); 1334 + }); 1335 + 1336 + it('GCD finds greatest common divisor', () => { 1337 + expect(evalWith('GCD(12,8)')).toBe(4); 1338 + expect(evalWith('GCD(15,25,35)')).toBe(5); 1339 + }); 1340 + 1341 + it('LCM finds least common multiple', () => { 1342 + expect(evalWith('LCM(4,6)')).toBe(12); 1343 + expect(evalWith('LCM(3,5,7)')).toBe(105); 1344 + }); 1345 + 1346 + it('QUOTIENT returns integer quotient', () => { 1347 + expect(evalWith('QUOTIENT(7,2)')).toBe(3); 1348 + expect(evalWith('QUOTIENT(-7,2)')).toBe(-3); 1349 + expect(evalWith('QUOTIENT(7,0)')).toBe('#DIV/0!'); 1350 + }); 1351 + 1352 + it('SUMPRODUCT multiplies and sums arrays', () => { 1353 + const cells = { A1: 1, A2: 2, A3: 3, B1: 4, B2: 5, B3: 6 }; 1354 + expect(evalWith('SUMPRODUCT(A1:A3,B1:B3)', cells)).toBe(32); // 1*4+2*5+3*6 1355 + }); 1356 + 1357 + it('PRODUCT multiplies all values', () => { 1358 + expect(evalWith('PRODUCT(2,3,4)')).toBe(24); 1359 + const cells = { A1: 2, A2: 5, A3: 10 }; 1360 + expect(evalWith('PRODUCT(A1:A3)', cells)).toBe(100); 1361 + }); 1362 + }); 1363 + 1364 + // ===================================================================== 1365 + // Trig functions 1366 + // ===================================================================== 1367 + 1368 + describe('Trigonometric functions', () => { 1369 + it('SIN / COS / TAN', () => { 1370 + expect(evalWith('SIN(0)')).toBe(0); 1371 + expect(evalWith('COS(0)')).toBe(1); 1372 + expect(evalWith('TAN(0)')).toBe(0); 1373 + }); 1374 + 1375 + it('ASIN / ACOS / ATAN', () => { 1376 + expect(evalWith('ASIN(0)')).toBe(0); 1377 + expect(evalWith('ACOS(1)')).toBe(0); 1378 + expect(evalWith('ATAN(0)')).toBe(0); 1379 + }); 1380 + 1381 + it('DEGREES / RADIANS roundtrip', () => { 1382 + expect(evalWith('DEGREES(3.14159265358979)')).toBeCloseTo(180, 4); 1383 + expect(evalWith('RADIANS(180)')).toBeCloseTo(Math.PI, 5); 1384 + }); 1385 + }); 1386 + 1387 + // ===================================================================== 1388 + // Statistical: LARGE, SMALL, RANK, PERCENTILE, VAR, VARP, STDEVP 1389 + // ===================================================================== 1390 + 1391 + describe('Statistical functions', () => { 1392 + it('LARGE returns kth largest', () => { 1393 + const cells = { A1: 10, A2: 30, A3: 20 }; 1394 + expect(evalWith('LARGE(A1:A3,1)', cells)).toBe(30); 1395 + expect(evalWith('LARGE(A1:A3,2)', cells)).toBe(20); 1396 + expect(evalWith('LARGE(A1:A3,4)', cells)).toBe('#NUM!'); 1397 + }); 1398 + 1399 + it('SMALL returns kth smallest', () => { 1400 + const cells = { A1: 10, A2: 30, A3: 20 }; 1401 + expect(evalWith('SMALL(A1:A3,1)', cells)).toBe(10); 1402 + expect(evalWith('SMALL(A1:A3,2)', cells)).toBe(20); 1403 + }); 1404 + 1405 + it('RANK returns rank of value', () => { 1406 + const cells = { A1: 10, A2: 30, A3: 20 }; 1407 + expect(evalWith('RANK(30,A1:A3)', cells)).toBe(1); // descending by default 1408 + expect(evalWith('RANK(30,A1:A3,1)', cells)).toBe(3); // ascending 1409 + }); 1410 + 1411 + it('PERCENTILE computes percentile', () => { 1412 + const cells = { A1: 1, A2: 2, A3: 3, A4: 4, A5: 5 }; 1413 + expect(evalWith('PERCENTILE(A1:A5,0.5)', cells)).toBe(3); 1414 + expect(evalWith('PERCENTILE(A1:A5,0)', cells)).toBe(1); 1415 + expect(evalWith('PERCENTILE(A1:A5,1)', cells)).toBe(5); 1416 + }); 1417 + 1418 + it('VAR computes sample variance', () => { 1419 + const cells = { A1: 2, A2: 4, A3: 6 }; 1420 + expect(evalWith('VAR(A1:A3)', cells)).toBeCloseTo(4); // var([2,4,6]) = 4 1421 + }); 1422 + 1423 + it('VARP computes population variance', () => { 1424 + const cells = { A1: 2, A2: 4, A3: 6 }; 1425 + expect(evalWith('VARP(A1:A3)', cells)).toBeCloseTo(8/3); // 2.667 1426 + }); 1427 + 1428 + it('STDEVP computes population standard deviation', () => { 1429 + const cells = { A1: 2, A2: 4, A3: 6 }; 1430 + expect(evalWith('STDEVP(A1:A3)', cells)).toBeCloseTo(Math.sqrt(8/3)); 1431 + }); 1432 + }); 1433 + 1434 + // ===================================================================== 1435 + // Financial: PMT, FV, PV, NPV 1436 + // ===================================================================== 1437 + 1438 + describe('Financial functions', () => { 1439 + it('PMT computes payment', () => { 1440 + // $200,000 mortgage at 5% annual for 30 years: PMT(0.05/12, 360, 200000) 1441 + const result = evalWith('PMT(0.05/12,360,200000)'); 1442 + expect(result).toBeCloseTo(-1073.64, 1); 1443 + }); 1444 + 1445 + it('PMT with zero rate', () => { 1446 + expect(evalWith('PMT(0,12,1200)')).toBe(-100); 1447 + }); 1448 + 1449 + it('FV computes future value', () => { 1450 + // $100/month at 5% annual for 10 years 1451 + const result = evalWith('FV(0.05/12,120,-100,0)'); 1452 + expect(result).toBeCloseTo(15528.23, 0); 1453 + }); 1454 + 1455 + it('NPV computes net present value', () => { 1456 + // NPV discounts all cash flows starting at period 1 1457 + const cells = { A1: 100, A2: 200, A3: 300 }; 1458 + const result = evalWith('NPV(0.1,A1:A3)', cells); 1459 + expect(typeof result).toBe('number'); 1460 + // 100/1.1 + 200/1.21 + 300/1.331 ≈ 90.91 + 165.29 + 225.39 ≈ 481.59 1461 + expect(result).toBeCloseTo(481.59, 0); 1462 + }); 1463 + }); 1464 + 1465 + // ===================================================================== 1466 + // Logic: SWITCH, CHOOSE, ADDRESS 1467 + // ===================================================================== 1468 + 1469 + describe('SWITCH / CHOOSE / ADDRESS', () => { 1470 + it('SWITCH finds matching case', () => { 1471 + expect(evalWith('SWITCH(2,1,"one",2,"two",3,"three")')).toBe('two'); 1472 + }); 1473 + 1474 + it('SWITCH returns default when no match', () => { 1475 + expect(evalWith('SWITCH(9,1,"one",2,"two","other")')).toBe('other'); 1476 + }); 1477 + 1478 + it('SWITCH returns #N/A when no match and no default', () => { 1479 + expect(evalWith('SWITCH(9,1,"one",2,"two")')).toBe('#N/A'); 1480 + }); 1481 + 1482 + it('CHOOSE selects by index', () => { 1483 + expect(evalWith('CHOOSE(2,"a","b","c")')).toBe('b'); 1484 + expect(evalWith('CHOOSE(0,"a","b")')).toBe('#VALUE!'); 1485 + }); 1486 + 1487 + it('ADDRESS creates cell reference string', () => { 1488 + expect(evalWith('ADDRESS(1,1)')).toBe('$A$1'); 1489 + expect(evalWith('ADDRESS(1,1,4)')).toBe('A1'); 1490 + expect(evalWith('ADDRESS(3,2,2)')).toBe('B$3'); 1491 + expect(evalWith('ADDRESS(3,2,3)')).toBe('$B3'); 1492 + }); 1493 + }); 1494 + 1495 + // ===================================================================== 1496 + // TEXTJOIN / CONCAT 1497 + // ===================================================================== 1498 + 1499 + describe('TEXTJOIN / CONCAT', () => { 1500 + it('TEXTJOIN joins with delimiter', () => { 1501 + expect(evalWith('TEXTJOIN(",",TRUE,"a","b","c")')).toBe('a,b,c'); 1502 + }); 1503 + 1504 + it('TEXTJOIN ignores empty when flag is true', () => { 1505 + const cells = { A1: 'a', A3: 'c' }; 1506 + expect(evalWith('TEXTJOIN(",",TRUE,A1:A3)', cells)).toBe('a,c'); 1507 + }); 1508 + 1509 + it('CONCAT joins without delimiter', () => { 1510 + expect(evalWith('CONCAT("hello"," ","world")')).toBe('hello world'); 1511 + }); 1512 + }); 1513 + 1514 + // ===================================================================== 1515 + // XLOOKUP 1516 + // ===================================================================== 1517 + 1518 + describe('XLOOKUP', () => { 1519 + it('exact match', () => { 1520 + const cells = { A1: 'a', A2: 'b', A3: 'c', B1: 10, B2: 20, B3: 30 }; 1521 + expect(evalWith('XLOOKUP("b",A1:A3,B1:B3)', cells)).toBe(20); 1522 + }); 1523 + 1524 + it('returns default when not found', () => { 1525 + const cells = { A1: 'a', B1: 10 }; 1526 + expect(evalWith('XLOOKUP("z",A1:A1,B1:B1,"N/A")', cells)).toBe('N/A'); 1527 + }); 1528 + 1529 + it('returns #N/A when not found and no default', () => { 1530 + const cells = { A1: 'a', B1: 10 }; 1531 + expect(evalWith('XLOOKUP("z",A1:A1,B1:B1)', cells)).toBe('#N/A'); 1532 + }); 1533 + }); 1534 + 1535 + // ===================================================================== 1536 + // Date functions: HOUR, MINUTE, SECOND, WEEKDAY, EDATE, EOMONTH, DAYS 1537 + // ===================================================================== 1538 + 1539 + describe('Additional date functions', () => { 1540 + it('YEAR/MONTH/DAY via evaluate', () => { 1541 + expect(evalWith('YEAR(DATE(2024,6,15))')).toBe(2024); 1542 + expect(evalWith('MONTH(DATE(2024,6,15))')).toBe(6); 1543 + expect(evalWith('DAY(DATE(2024,6,15))')).toBe(15); 1544 + }); 1545 + 1546 + it('DAYS computes difference', () => { 1547 + expect(evalWith('DAYS(DATE(2024,1,10),DATE(2024,1,1))')).toBe(9); 1548 + }); 1549 + }); 1550 + 1551 + // ===================================================================== 1552 + // Cross-sheet references 1553 + // ===================================================================== 1554 + 1555 + describe('Cross-sheet references', () => { 1556 + it('resolves unquoted cross-sheet ref (Sheet2!A1)', () => { 1557 + const resolver = { 1558 + sheetExists: (name: string) => name === 'Sheet2', 1559 + getSheetCellValue: (sheetName: string, ref: string) => { 1560 + if (sheetName === 'Sheet2' && ref === 'A1') return 5; 1561 + return ''; 1562 + }, 1563 + }; 1564 + const result = evaluate('Sheet2!A1+10', () => '', resolver); 1565 + expect(result).toBe(15); 1566 + }); 1567 + 1568 + it('resolves quoted cross-sheet ref with spaces', () => { 1569 + const resolver = { 1570 + sheetExists: (name: string) => name === 'My Sheet', 1571 + getSheetCellValue: (sheetName: string, ref: string) => { 1572 + if (sheetName === 'My Sheet' && ref === 'B2') return 7; 1573 + return ''; 1574 + }, 1575 + }; 1576 + const result = evaluate("'My Sheet'!B2*2", () => '', resolver); 1577 + expect(result).toBe(14); 1578 + }); 1579 + 1580 + it('returns a result when resolver is null', () => { 1581 + const result = evaluate('Sheet2!A1', () => '', null); 1582 + expect(result).toBeDefined(); 1583 + }); 1584 + }); 1585 + 1586 + // ===================================================================== 1587 + // Comparison edge cases via evaluate 1588 + // ===================================================================== 1589 + 1590 + describe('Comparison edge cases', () => { 1591 + it('compares string vs string case-insensitively', () => { 1592 + expect(evalWith('"abc"="ABC"')).toBe(true); 1593 + }); 1594 + 1595 + it('compares numeric strings as numbers', () => { 1596 + expect(evalWith('"10">"9"')).toBe(true); 1597 + }); 1598 + 1599 + it('boolean TRUE equals 1 in comparison', () => { 1600 + expect(evalWith('TRUE=1')).toBe(true); 1601 + expect(evalWith('FALSE=0')).toBe(true); 1602 + }); 1603 + }); 1604 + 1605 + // ===================================================================== 1606 + // MOD edge cases 1607 + // ===================================================================== 1608 + 1609 + describe('MOD edge cases', () => { 1610 + it('MOD with negative dividend follows Excel sign convention', () => { 1611 + // Excel: MOD(-3,2) = 1 (sign of divisor) 1612 + expect(evalWith('MOD(-3,2)')).toBe(1); 1613 + }); 1614 + 1615 + it('MOD division by zero returns #DIV/0!', () => { 1616 + expect(evalWith('MOD(10,0)')).toBe('#DIV/0!'); 1617 + }); 1618 + });
+169
tests/pivot-table.test.ts
··· 137 137 expect(values).toContain(20); 138 138 }); 139 139 }); 140 + 141 + // ===================================================================== 142 + // Edge cases 143 + // ===================================================================== 144 + 145 + describe('aggregate — edge cases', () => { 146 + it('countDistinct with all unique', () => { 147 + expect(aggregate([1, 2, 3, 4, 5], 'countDistinct')).toBe(5); 148 + }); 149 + 150 + it('countDistinct with all same', () => { 151 + expect(aggregate([7, 7, 7], 'countDistinct')).toBe(1); 152 + }); 153 + 154 + it('avg of single value', () => { 155 + expect(aggregate([42], 'avg')).toBe(42); 156 + }); 157 + 158 + it('min/max of single value', () => { 159 + expect(aggregate([42], 'min')).toBe(42); 160 + expect(aggregate([42], 'max')).toBe(42); 161 + }); 162 + 163 + it('sum of negative values', () => { 164 + expect(aggregate([-1, -2, -3], 'sum')).toBe(-6); 165 + }); 166 + 167 + it('handles very large numbers', () => { 168 + expect(aggregate([1e15, 2e15], 'sum')).toBe(3e15); 169 + }); 170 + }); 171 + 172 + describe('extractKey — edge cases', () => { 173 + it('extracts multiple fields', () => { 174 + const row = new Map<string, unknown>([['A1', 'East'], ['B1', 'Q1'], ['C1', 100]]); 175 + expect(extractKey(row, [0, 1, 2], colToLetter, 1)).toEqual(['East', 'Q1', '100']); 176 + }); 177 + 178 + it('coerces numeric values to string', () => { 179 + const row = new Map<string, unknown>([['A1', 42]]); 180 + expect(extractKey(row, [0], colToLetter, 1)).toEqual(['42']); 181 + }); 182 + }); 183 + 184 + describe('keyToString — edge cases', () => { 185 + it('handles single element', () => { 186 + expect(keyToString(['a'])).toBe('a'); 187 + }); 188 + 189 + it('handles empty array', () => { 190 + expect(keyToString([])).toBe(''); 191 + }); 192 + 193 + it('handles empty string elements', () => { 194 + expect(keyToString(['', ''])).toBe('\0'); 195 + }); 196 + }); 197 + 198 + describe('computePivot — edge cases', () => { 199 + const colToLetter = (c: number) => String.fromCharCode(65 + c); 200 + 201 + it('works with min aggregation', () => { 202 + const rows: Map<string, unknown>[] = [ 203 + new Map([['A1', 'X'], ['B1', 'Y'], ['C1', 10]]), 204 + new Map([['A2', 'X'], ['B2', 'Y'], ['C2', 5]]), 205 + new Map([['A3', 'X'], ['B3', 'Y'], ['C3', 20]]), 206 + ]; 207 + const config: PivotConfig = { rowFields: [0], colFields: [1], valueField: 2, aggregation: 'min' }; 208 + const result = computePivot(rows, config, colToLetter); 209 + expect(result.cells[0][0]!.value).toBe(5); 210 + }); 211 + 212 + it('works with max aggregation', () => { 213 + const rows: Map<string, unknown>[] = [ 214 + new Map([['A1', 'X'], ['B1', 'Y'], ['C1', 10]]), 215 + new Map([['A2', 'X'], ['B2', 'Y'], ['C2', 5]]), 216 + new Map([['A3', 'X'], ['B3', 'Y'], ['C3', 20]]), 217 + ]; 218 + const config: PivotConfig = { rowFields: [0], colFields: [1], valueField: 2, aggregation: 'max' }; 219 + const result = computePivot(rows, config, colToLetter); 220 + expect(result.cells[0][0]!.value).toBe(20); 221 + }); 222 + 223 + it('works with count aggregation', () => { 224 + const rows: Map<string, unknown>[] = [ 225 + new Map([['A1', 'X'], ['B1', 'Y'], ['C1', 10]]), 226 + new Map([['A2', 'X'], ['B2', 'Y'], ['C2', 5]]), 227 + ]; 228 + const config: PivotConfig = { rowFields: [0], colFields: [1], valueField: 2, aggregation: 'count' }; 229 + const result = computePivot(rows, config, colToLetter); 230 + expect(result.cells[0][0]!.value).toBe(2); 231 + }); 232 + 233 + it('works with countDistinct aggregation', () => { 234 + const rows: Map<string, unknown>[] = [ 235 + new Map([['A1', 'X'], ['B1', 'Y'], ['C1', 10]]), 236 + new Map([['A2', 'X'], ['B2', 'Y'], ['C2', 10]]), 237 + new Map([['A3', 'X'], ['B3', 'Y'], ['C3', 20]]), 238 + ]; 239 + const config: PivotConfig = { rowFields: [0], colFields: [1], valueField: 2, aggregation: 'countDistinct' }; 240 + const result = computePivot(rows, config, colToLetter); 241 + expect(result.cells[0][0]!.value).toBe(2); // 10 and 20 242 + }); 243 + 244 + it('handles multiple row fields', () => { 245 + const rows: Map<string, unknown>[] = [ 246 + new Map([['A1', 'East'], ['B1', 'Sales'], ['C1', 'Q1'], ['D1', 100]]), 247 + new Map([['A2', 'East'], ['B2', 'Marketing'], ['C2', 'Q1'], ['D2', 50]]), 248 + new Map([['A3', 'West'], ['B3', 'Sales'], ['C3', 'Q1'], ['D3', 200]]), 249 + ]; 250 + const config: PivotConfig = { rowFields: [0, 1], colFields: [2], valueField: 3, aggregation: 'sum' }; 251 + const result = computePivot(rows, config, colToLetter); 252 + expect(result.rowKeys).toHaveLength(3); // East/Sales, East/Marketing, West/Sales 253 + expect(result.colKeys).toHaveLength(1); // Q1 254 + }); 255 + 256 + it('handles multiple column fields', () => { 257 + const rows: Map<string, unknown>[] = [ 258 + new Map([['A1', 'X'], ['B1', 'Y'], ['C1', 'Z'], ['D1', 10]]), 259 + new Map([['A2', 'X'], ['B2', 'Y'], ['C2', 'W'], ['D2', 20]]), 260 + ]; 261 + const config: PivotConfig = { rowFields: [0], colFields: [1, 2], valueField: 3, aggregation: 'sum' }; 262 + const result = computePivot(rows, config, colToLetter); 263 + expect(result.rowKeys).toHaveLength(1); 264 + expect(result.colKeys).toHaveLength(2); // Y/Z and Y/W 265 + }); 266 + 267 + it('handles single row of data', () => { 268 + const rows: Map<string, unknown>[] = [ 269 + new Map([['A1', 'Only'], ['B1', 'One'], ['C1', 42]]), 270 + ]; 271 + const config: PivotConfig = { rowFields: [0], colFields: [1], valueField: 2, aggregation: 'sum' }; 272 + const result = computePivot(rows, config, colToLetter); 273 + expect(result.rowKeys).toHaveLength(1); 274 + expect(result.colKeys).toHaveLength(1); 275 + expect(result.grandTotal.value).toBe(42); 276 + expect(result.grandTotal.count).toBe(1); 277 + }); 278 + }); 279 + 280 + describe('formatAggregateValue — edge cases', () => { 281 + it('formats countDistinct as integer', () => { 282 + expect(formatAggregateValue(5, 'countDistinct')).toBe('5'); 283 + }); 284 + 285 + it('formats min with decimals', () => { 286 + expect(formatAggregateValue(3.14, 'min')).toBe('3.14'); 287 + }); 288 + 289 + it('formats max as integer when whole number', () => { 290 + expect(formatAggregateValue(100, 'max')).toBe('100'); 291 + }); 292 + 293 + it('formats zero', () => { 294 + expect(formatAggregateValue(0, 'sum')).toBe('0'); 295 + }); 296 + }); 297 + 298 + describe('flatPivotValues — edge cases', () => { 299 + it('returns empty array for empty pivot', () => { 300 + const result = computePivot( 301 + [], 302 + { rowFields: [0], colFields: [1], valueField: 2, aggregation: 'sum' }, 303 + colToLetter, 304 + ); 305 + const values = flatPivotValues(result); 306 + expect(values).toEqual([]); 307 + }); 308 + });