|
| 1 | +import { describe, it, expect, beforeAll } from 'vitest'; |
| 2 | +import { FormulaEngine } from './formulaEngine'; |
| 3 | +import { excelExtraFunctionNames } from './excelFunctions'; |
| 4 | + |
| 5 | +// One engine for all cases: formulas go into column Z and read fixtures from |
| 6 | +// A:D, so each case is a single setCell + read. |
| 7 | +let e: FormulaEngine; |
| 8 | + |
| 9 | +const FIXTURE = [ |
| 10 | + // A B C D |
| 11 | + ['3', 'apple', '10', '2020-01-01'], |
| 12 | + ['1', 'banana', '20', '2021-01-01'], |
| 13 | + ['3', 'apple', '30', '2022-01-01'], |
| 14 | + ['7', 'cherry', '40', '2023-01-01'], |
| 15 | +]; |
| 16 | + |
| 17 | +const evalAt = (formula: string, row = 0, col = 25): string => e.setCell(row, col, formula).value; |
| 18 | + |
| 19 | +beforeAll(() => { |
| 20 | + e = new FormulaEngine(); |
| 21 | + FIXTURE.forEach((cells, r) => cells.forEach((raw, c) => e.setCell(r, c, raw))); |
| 22 | +}); |
| 23 | + |
| 24 | +describe('registration', () => { |
| 25 | + it('exposes the new functions to autocomplete', () => { |
| 26 | + const names = new FormulaEngine().functionNames(); |
| 27 | + for (const n of excelExtraFunctionNames) expect(names).toContain(n); |
| 28 | + }); |
| 29 | +}); |
| 30 | + |
| 31 | +describe('text', () => { |
| 32 | + it('CONCAT joins scalars and ranges', () => { |
| 33 | + expect(evalAt('=CONCAT("a",1,TRUE(),B1:B2)')).toBe('a1TRUEapplebanana'); |
| 34 | + }); |
| 35 | + |
| 36 | + it('TEXTBEFORE / TEXTAFTER pick by instance', () => { |
| 37 | + expect(evalAt('=TEXTBEFORE("a-b-c","-")')).toBe('a'); |
| 38 | + expect(evalAt('=TEXTBEFORE("a-b-c","-",2)')).toBe('a-b'); |
| 39 | + expect(evalAt('=TEXTBEFORE("a-b-c","-",-1)')).toBe('a-b'); |
| 40 | + expect(evalAt('=TEXTAFTER("a-b-c","-")')).toBe('b-c'); |
| 41 | + expect(evalAt('=TEXTAFTER("a-b-c","-",-1)')).toBe('c'); |
| 42 | + expect(evalAt('=TEXTAFTER("a-b-c","X",1,0,0,"none")')).toBe('none'); |
| 43 | + expect(evalAt('=TEXTBEFORE("aXb","x",1,1)')).toBe('a'); // case-insensitive |
| 44 | + expect(evalAt('=TEXTBEFORE("abc","-")')).toBe('#N/A'); |
| 45 | + expect(evalAt('=TEXTBEFORE("abc","-",1,0,1)')).toBe('abc'); // match_end |
| 46 | + }); |
| 47 | + |
| 48 | + it('NUMBERVALUE honours separators and percent signs', () => { |
| 49 | + expect(evalAt('=NUMBERVALUE("1.234,56",",",".")')).toBe('1234.56'); |
| 50 | + expect(evalAt('=NUMBERVALUE("50%")')).toBe('0.5'); |
| 51 | + expect(evalAt('=NUMBERVALUE("abc")')).toBe('#VALUE!'); |
| 52 | + }); |
| 53 | + |
| 54 | + it('FIXED and DOLLAR format numbers', () => { |
| 55 | + expect(evalAt('=FIXED(1234.567)')).toBe('1,234.57'); |
| 56 | + expect(evalAt('=FIXED(1234.567,1,TRUE())')).toBe('1234.6'); |
| 57 | + expect(evalAt('=FIXED(1234.567,-2)')).toBe('1,200'); |
| 58 | + expect(evalAt('=DOLLAR(1234.567)')).toBe('$1,234.57'); |
| 59 | + expect(evalAt('=DOLLAR(-1234.567)')).toBe('($1,234.57)'); |
| 60 | + }); |
| 61 | +}); |
| 62 | + |
| 63 | +describe('lookup', () => { |
| 64 | + it('XMATCH finds exact, closest and wildcard matches', () => { |
| 65 | + expect(evalAt('=XMATCH(7,A1:A4)')).toBe('4'); |
| 66 | + expect(evalAt('=XMATCH(3,A1:A4)')).toBe('1'); |
| 67 | + expect(evalAt('=XMATCH(3,A1:A4,0,-1)')).toBe('3'); // search from the end |
| 68 | + expect(evalAt('=XMATCH(5,A1:A4,-1)')).toBe('1'); // next smaller (3) |
| 69 | + expect(evalAt('=XMATCH(5,A1:A4,1)')).toBe('4'); // next larger (7) |
| 70 | + expect(evalAt('=XMATCH("ban*",B1:B4,2)')).toBe('2'); |
| 71 | + expect(evalAt('=XMATCH(99,A1:A4)')).toBe('#N/A'); |
| 72 | + }); |
| 73 | + |
| 74 | + it('LOOKUP handles the vector and the array form', () => { |
| 75 | + expect(evalAt('=LOOKUP(3,A1:A4,C1:C4)')).toBe('30'); |
| 76 | + expect(evalAt('=LOOKUP(4,A1:A4,C1:C4)')).toBe('30'); // largest value <= 4 |
| 77 | + expect(evalAt('=LOOKUP(3,A1:C4)')).toBe('30'); // taller than wide: last column |
| 78 | + expect(evalAt('=LOOKUP(0,A1:A4,C1:C4)')).toBe('#N/A'); |
| 79 | + }); |
| 80 | +}); |
| 81 | + |
| 82 | +describe('dynamic arrays', () => { |
| 83 | + // Array results spill; read the spilled cells directly. |
| 84 | + const spill = (formula: string, row: number, col: number): string => { |
| 85 | + e.setCell(10, 10, formula); // K11 |
| 86 | + return e.getValue(row, col).value; |
| 87 | + }; |
| 88 | + |
| 89 | + it('UNIQUE drops duplicate rows', () => { |
| 90 | + expect(spill('=UNIQUE(B1:B4)', 10, 10)).toBe('apple'); |
| 91 | + expect(spill('=UNIQUE(B1:B4)', 11, 10)).toBe('banana'); |
| 92 | + expect(spill('=UNIQUE(B1:B4)', 12, 10)).toBe('cherry'); |
| 93 | + expect(spill('=UNIQUE(B1:B4,FALSE(),TRUE())', 10, 10)).toBe('banana'); // exactly once |
| 94 | + }); |
| 95 | + |
| 96 | + it('SORT orders rows by a column', () => { |
| 97 | + expect(spill('=SORT(A1:A4)', 10, 10)).toBe('1'); |
| 98 | + expect(spill('=SORT(A1:A4,1,-1)', 10, 10)).toBe('7'); |
| 99 | + }); |
| 100 | + |
| 101 | + it('SORTBY orders one range by another', () => { |
| 102 | + expect(spill('=SORTBY(B1:B4,C1:C4,-1)', 10, 10)).toBe('cherry'); |
| 103 | + }); |
| 104 | + |
| 105 | + it('TAKE and DROP slice from either end', () => { |
| 106 | + expect(spill('=TAKE(A1:A4,2)', 11, 10)).toBe('1'); |
| 107 | + expect(spill('=TAKE(A1:A4,-1)', 10, 10)).toBe('7'); |
| 108 | + expect(spill('=DROP(A1:A4,3)', 10, 10)).toBe('7'); |
| 109 | + expect(spill('=DROP(A1:A4,-3)', 10, 10)).toBe('3'); |
| 110 | + }); |
| 111 | + |
| 112 | + it('VSTACK and HSTACK combine ranges', () => { |
| 113 | + expect(spill('=VSTACK(A1:A2,C1:C2)', 12, 10)).toBe('10'); |
| 114 | + expect(spill('=HSTACK(A1:A2,C1:C2)', 10, 11)).toBe('10'); |
| 115 | + }); |
| 116 | + |
| 117 | + it('TOCOL and TOROW flatten', () => { |
| 118 | + expect(spill('=TOROW(A1:A4)', 10, 13)).toBe('7'); |
| 119 | + expect(spill('=TOCOL(A1:C1)', 12, 10)).toBe('10'); |
| 120 | + }); |
| 121 | + |
| 122 | + it('CHOOSECOLS and CHOOSEROWS pick by index', () => { |
| 123 | + expect(spill('=CHOOSECOLS(A1:C1,3)', 10, 10)).toBe('10'); |
| 124 | + expect(spill('=CHOOSEROWS(A1:A4,-1)', 10, 10)).toBe('7'); |
| 125 | + expect(spill('=CHOOSECOLS(A1:C1,9)', 10, 10)).toBe('#VALUE!'); |
| 126 | + }); |
| 127 | + |
| 128 | + it('EXPAND pads to a larger size', () => { |
| 129 | + expect(spill('=EXPAND(A1:A2,3,1,0)', 12, 10)).toBe('0'); |
| 130 | + expect(spill('=EXPAND(A1:A2,1)', 10, 10)).toBe('#VALUE!'); // cannot shrink |
| 131 | + }); |
| 132 | + |
| 133 | + it('FREQUENCY buckets values', () => { |
| 134 | + e.setCell(20, 0, '5'); // A21 bin |
| 135 | + e.setCell(21, 0, '25'); // A22 bin |
| 136 | + expect(spill('=FREQUENCY(C1:C4,A21:A22)', 10, 10)).toBe('0'); // <=5 |
| 137 | + expect(spill('=FREQUENCY(C1:C4,A21:A22)', 11, 10)).toBe('2'); // <=25 |
| 138 | + expect(spill('=FREQUENCY(C1:C4,A21:A22)', 12, 10)).toBe('2'); // rest |
| 139 | + }); |
| 140 | +}); |
| 141 | + |
| 142 | +describe('statistics', () => { |
| 143 | + it('AVERAGEIFS averages with multiple criteria', () => { |
| 144 | + expect(evalAt('=AVERAGEIFS(C1:C4,A1:A4,3)')).toBe('20'); // (10+30)/2 |
| 145 | + expect(evalAt('=AVERAGEIFS(C1:C4,A1:A4,3,C1:C4,">15")')).toBe('30'); |
| 146 | + expect(evalAt('=AVERAGEIFS(C1:C4,B1:B4,"a*")')).toBe('20'); |
| 147 | + expect(evalAt('=AVERAGEIFS(C1:C4,B1:B4,"<>apple")')).toBe('30'); // (20+40)/2 |
| 148 | + expect(evalAt('=AVERAGEIFS(C1:C4,A1:A4,99)')).toBe('#DIV/0!'); |
| 149 | + }); |
| 150 | + |
| 151 | + it('RANK and RANK.AVG rank ties', () => { |
| 152 | + expect(evalAt('=RANK(7,A1:A4)')).toBe('1'); |
| 153 | + expect(evalAt('=RANK(3,A1:A4)')).toBe('2'); |
| 154 | + expect(evalAt('=RANK(3,A1:A4,1)')).toBe('2'); // ascending: 1 is smaller |
| 155 | + expect(evalAt('=RANK.AVG(3,A1:A4)')).toBe('2.5'); |
| 156 | + expect(evalAt('=RANK.EQ(3,A1:A4)')).toBe('2'); |
| 157 | + expect(evalAt('=RANK(99,A1:A4)')).toBe('#N/A'); |
| 158 | + }); |
| 159 | + |
| 160 | + it('MODE returns the most frequent value', () => { |
| 161 | + expect(evalAt('=MODE(A1:A4)')).toBe('3'); |
| 162 | + expect(evalAt('=MODE.SNGL(A1:A4)')).toBe('3'); |
| 163 | + expect(evalAt('=MODE(C1:C4)')).toBe('#N/A'); // all distinct |
| 164 | + }); |
| 165 | + |
| 166 | + it('TRIMMEAN drops the extremes', () => { |
| 167 | + expect(evalAt('=TRIMMEAN(C1:C4,0.5)')).toBe('25'); // trims 10 and 40 |
| 168 | + expect(evalAt('=TRIMMEAN(C1:C4,0)')).toBe('25'); |
| 169 | + }); |
| 170 | + |
| 171 | + it('PERMUT and PERMUTATIONA count arrangements', () => { |
| 172 | + expect(evalAt('=PERMUT(5,2)')).toBe('20'); |
| 173 | + expect(evalAt('=PERMUTATIONA(5,2)')).toBe('25'); |
| 174 | + expect(evalAt('=PERMUT(2,5)')).toBe('#NUM!'); |
| 175 | + }); |
| 176 | + |
| 177 | + it('INTERCEPT and FORECAST fit a line', () => { |
| 178 | + // C = 10, 20, 30, 40 against x = 1..4 in B21:B24 |
| 179 | + e.setCell(20, 1, '1'); |
| 180 | + e.setCell(21, 1, '2'); |
| 181 | + e.setCell(22, 1, '3'); |
| 182 | + e.setCell(23, 1, '4'); |
| 183 | + expect(evalAt('=INTERCEPT(C1:C4,B21:B24)')).toBe('0'); |
| 184 | + expect(evalAt('=FORECAST(5,C1:C4,B21:B24)')).toBe('50'); |
| 185 | + expect(evalAt('=FORECAST.LINEAR(5,C1:C4,B21:B24)')).toBe('50'); |
| 186 | + }); |
| 187 | +}); |
| 188 | + |
| 189 | +describe('information', () => { |
| 190 | + it('ERROR.TYPE maps error values to codes', () => { |
| 191 | + expect(evalAt('=ERROR.TYPE(1/0)')).toBe('2'); |
| 192 | + expect(evalAt('=ERROR.TYPE(NA())')).toBe('7'); |
| 193 | + expect(evalAt('=ERROR.TYPE(1)')).toBe('#N/A'); |
| 194 | + }); |
| 195 | + |
| 196 | + it('TYPE classifies values', () => { |
| 197 | + expect(evalAt('=TYPE(1)')).toBe('1'); |
| 198 | + expect(evalAt('=TYPE("x")')).toBe('2'); |
| 199 | + expect(evalAt('=TYPE(TRUE())')).toBe('4'); |
| 200 | + expect(evalAt('=TYPE(NA())')).toBe('16'); |
| 201 | + // ponytail: a range argument collapses to its first value (no array type 64) — |
| 202 | + // accepting errors (16) matters more in practice than TYPE of an array. |
| 203 | + expect(evalAt('=TYPE(A1:A2)')).toBe('1'); |
| 204 | + }); |
| 205 | +}); |
| 206 | + |
| 207 | +describe('financial', () => { |
| 208 | + it('XIRR solves for the irregular-interval rate', () => { |
| 209 | + e.setCell(30, 0, '-1000'); |
| 210 | + e.setCell(31, 0, '1100'); |
| 211 | + e.setCell(30, 1, '=DATE(2020,1,1)'); |
| 212 | + e.setCell(31, 1, '=DATE(2021,1,1)'); // 366 days later |
| 213 | + const r = Number(evalAt('=XIRR(A31:A32,B31:B32)')); |
| 214 | + expect(r).toBeCloseTo(0.0997, 3); // ~10% over 366/365 years |
| 215 | + }); |
| 216 | + |
| 217 | + it('reports #NUM! without a sign change', () => { |
| 218 | + e.setCell(30, 0, '-1000'); |
| 219 | + e.setCell(31, 0, '-1100'); |
| 220 | + expect(evalAt('=XIRR(A31:A32,B31:B32)')).toBe('#NUM!'); |
| 221 | + }); |
| 222 | +}); |
0 commit comments