import { describe, it, expect } from 'vitest'; import { Workbook, literalValue, type TableModel } from '../workbook'; import { FormulaError, isError, keyOf } from '../types'; import { dateToSerial, timeSerial, todaySerial, nowSerial } from '../dates'; function sheet( id: string, name: string, cells: Record, sheetName = 'Blatt1', ): TableModel { const inputs = new Map(); for (const [a1, raw] of Object.entries(cells)) { const m = /^([A-Z]+)([0-9]+)$/.exec(a1)!; const col = m[1].split('').reduce((n, ch) => n * 26 + (ch.charCodeAt(0) - 64), 0) - 1; const row = Number(m[2]) - 1; inputs.set(`${col},${row}`, raw); } return { id, name, sheetName, cols: 8, rows: 30, inputs }; } /** Rebuilds the small "Umsatz / Kosten / Übersicht" fixture from the brief. */ function fixture(): Workbook { const umsatz = sheet('s-umsatz', 'Umsatz', { A2: 'Nord', B2: '1200', C2: '1450', D2: '1600', E2: '1720', A3: 'Süd', B3: '980', C3: '1100', D3: '1250', E3: '1310', A4: 'Ost', B4: '1550', C4: '1480', D4: '1620', E4: '1700', A5: 'West', B5: '1340', C5: '1390', D5: '1500', E5: '1580', A6: 'Zentral', B6: '1105', C6: '1205', D6: '1298', E6: '1402', B8: '=SUM(B2:B6)', B9: '=AVG(B2:B6)', B10: '=MIN(B2:B6)', B11: '=MAX(B2:B6)', B12: '=COUNT(B2:B6)', H2: '=SUM(B2:E2)', H3: '=SUM(B2:E2)/4', H4: '=E2-B2', }); const kosten = sheet('s-kosten', 'Kosten', { A2: 'Nord', B2: '800', C2: '820', D2: '900', E2: '880', }); const uebersicht = sheet('s-ueber', 'Übersicht', { B2: '=SUM(Umsatz!B2:E2)', C2: '=SUM(Kosten!B2:E2)', D2: '=B2-C2', E2: '=D2/B2*100', B8: '=SUM(B2:B6)', D8: '=SUM(D2:D6)', }); return new Workbook([umsatz, kosten, uebersicht]); } const val = (wb: Workbook, sheetId: string, a1: string) => { const m = /^([A-Z]+)([0-9]+)$/.exec(a1)!; const col = m[1].split('').reduce((n, ch) => n * 26 + (ch.charCodeAt(0) - 64), 0) - 1; return wb.cellValue(sheetId, col, Number(m[2]) - 1); }; describe('Workbook aggregates', () => { const wb = fixture(); it('SUM/AVG/MIN/MAX/COUNT of Umsatz!B2:B6', () => { expect(val(wb, 's-umsatz', 'B8')).toBe(6175); expect(val(wb, 's-umsatz', 'B9')).toBe(1235); expect(val(wb, 's-umsatz', 'B10')).toBe(980); expect(val(wb, 's-umsatz', 'B11')).toBe(1550); expect(val(wb, 's-umsatz', 'B12')).toBe(5); }); it('row helpers', () => { expect(val(wb, 's-umsatz', 'H2')).toBe(5970); // 1200+1450+1600+1720 expect(val(wb, 's-umsatz', 'H3')).toBe(1492.5); expect(val(wb, 's-umsatz', 'H4')).toBe(520); }); }); describe('Workbook cross-sheet', () => { it('resolves Name!Range across sheets', () => { const wb = fixture(); expect(val(wb, 's-ueber', 'B2')).toBe(5970); expect(val(wb, 's-ueber', 'C2')).toBe(3400); expect(val(wb, 's-ueber', 'D2')).toBe(2570); expect(val(wb, 's-ueber', 'E2')).toBeCloseTo((2570 / 5970) * 100, 6); }); it('editing Umsatz!B2 propagates to Übersicht B2/D2/E2 and the Gesamt row', () => { const wb = fixture(); wb.setCell('s-umsatz', 1, 1, '2200'); // B2: 1200 -> 2200 (+1000) expect(val(wb, 's-umsatz', 'B8')).toBe(7175); expect(val(wb, 's-ueber', 'B2')).toBe(6970); expect(val(wb, 's-ueber', 'D2')).toBe(3570); expect(val(wb, 's-ueber', 'B8')).toBe(6970); // only row 2 populated in fixture expect(val(wb, 's-ueber', 'D8')).toBe(3570); }); it('is tolerant about sheet-name case and quoting', () => { const wb = fixture(); wb.setCell('s-kosten', 0, 8, "='Kosten'!B2"); // A9 = 'Kosten'!B2 wb.setCell('s-kosten', 1, 8, '=kosten!b2'); // B9 lower-case expect(val(wb, 's-kosten', 'A9')).toBe(800); expect(val(wb, 's-kosten', 'B9')).toBe(800); }); it('a table name with non-ASCII letters works as a bare, unquoted qualifier', () => { // regression: "Übersicht" is the fixture's own table name, but nothing // above ever refers *into* it by name (it only ever refers out) -- so // this table has silently been unreferenceable from elsewhere the whole // time (the lexer rejected "Ü" outright; see engine/lexer.ts). const wb = fixture(); wb.setCell('s-umsatz', 6, 8, '=Übersicht!B2'); // G9, no quoting needed (no spaces) expect(val(wb, 's-umsatz', 'G9')).toBe(5970); expect(isError(val(wb, 's-umsatz', 'G9'))).toBe(false); }); }); describe('Workbook error values', () => { const wb = fixture(); it('classifies each error kind', () => { wb.setCell('s-umsatz', 6, 20, '=1/0'); wb.setCell('s-umsatz', 6, 21, '=FOO(1)'); wb.setCell('s-umsatz', 6, 22, '=A1+'); wb.setCell('s-umsatz', 6, 23, '=Nix!A1'); expect((val(wb, 's-umsatz', 'G21') as FormulaError).code).toBe('#DIV/0!'); expect((val(wb, 's-umsatz', 'G22') as FormulaError).code).toBe('#NAME?'); expect((val(wb, 's-umsatz', 'G23') as FormulaError).code).toBe('#ERR'); expect((val(wb, 's-umsatz', 'G24') as FormulaError).code).toBe('#REF'); }); it('detects a direct circular reference', () => { const wb2 = fixture(); wb2.setCell('s-umsatz', 0, 0, '=A1'); expect((val(wb2, 's-umsatz', 'A1') as FormulaError).code).toBe('#CIRC'); }); it('detects a cross-sheet circular reference', () => { const wb2 = fixture(); wb2.setCell('s-umsatz', 0, 0, "='Kosten'!Z9"); wb2.setCell('s-kosten', 25, 8, '=Umsatz!A1'); expect((val(wb2, 's-umsatz', 'A1') as FormulaError).code).toBe('#CIRC'); expect((val(wb2, 's-kosten', 'Z9') as FormulaError).code).toBe('#CIRC'); }); it('errors propagate to dependents', () => { const wb2 = fixture(); wb2.setCell('s-umsatz', 1, 1, '=1/0'); expect(isError(val(wb2, 's-umsatz', 'B8'))).toBe(true); expect(isError(val(wb2, 's-ueber', 'B2'))).toBe(true); }); }); describe('Workbook structural changes', () => { it('deleting a referenced sheet turns dependents into #REF', () => { const wb = fixture(); wb.tables = wb.tables.filter((t) => t.id !== 's-kosten'); wb.rebuild(); expect((val(wb, 's-ueber', 'C2') as FormulaError).code).toBe('#REF'); // unrelated cells still fine expect(val(wb, 's-ueber', 'B2')).toBe(5970); }); }); describe('Workbook named ranges', () => { function namedRangeFixture(): Workbook { const umsatz = sheet('s-umsatz', 'Umsatz', { B2: '100', B3: '200', B9: '=Preis + 1' }); return new Workbook( [umsatz], [], [{ id: 'n1', name: 'Preis', tableId: 's-umsatz', start: { col: 1, row: 1 }, end: { col: 1, row: 1 } }], ); } it('resolves a single-cell named range like an ordinary cell reference', () => { const wb = namedRangeFixture(); expect(val(wb, 's-umsatz', 'B9')).toBe(101); }); it('is a dependency -- recalculates when the referenced cell changes', () => { const wb = namedRangeFixture(); wb.setCell('s-umsatz', 1, 1, '500'); // B2: 100 -> 500 expect(val(wb, 's-umsatz', 'B9')).toBe(501); }); it('a multi-cell named range works as a range function argument', () => { const umsatz = sheet('s-umsatz', 'Umsatz', { B2: '100', B3: '200', B9: '=SUM(Bereich)' }); const wb = new Workbook( [umsatz], [], [{ id: 'n1', name: 'Bereich', tableId: 's-umsatz', start: { col: 1, row: 1 }, end: { col: 1, row: 2 } }], ); expect(val(wb, 's-umsatz', 'B9')).toBe(300); }); it('survives its target table being renamed (resolved live by id, not by the name baked into the formula)', () => { const wb = namedRangeFixture(); wb.tables[0].name = 'Verkauf'; // rename the table Preis points at wb.rebuild(); expect(val(wb, 's-umsatz', 'B9')).toBe(101); // still resolves -- no #NAME?/#REF }); it('an unknown name is #NAME?', () => { const umsatz = sheet('s-umsatz', 'Umsatz', { B9: '=DoesNotExist' }); const wb = new Workbook([umsatz], [], []); expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#NAME?'); }); it('a dangling tableId (target table deleted) is #NAME?, same as a name that never existed', () => { const umsatz = sheet('s-umsatz', 'Umsatz', { B9: '=Preis' }); const wb = new Workbook( [umsatz], [], [{ id: 'n1', name: 'Preis', tableId: 'no-such-table', start: { col: 1, row: 1 }, end: { col: 1, row: 1 } }], ); expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#NAME?'); }); }); describe('Workbook graph queries (graphPrecedents/graphDependents)', () => { const coords = (a1: string) => { const m = /^([A-Z]+)([0-9]+)$/.exec(a1)!; return { col: m[1].split('').reduce((n, ch) => n * 26 + (ch.charCodeAt(0) - 64), 0) - 1, row: Number(m[2]) - 1 }; }; it('graphPrecedents lists exactly the cells a formula reads, same-table', () => { const wb = fixture(); const { col, row } = coords('D2'); // Übersicht!D2 = B2-C2 const precs = wb.graphPrecedents('s-ueber', col, row); expect(precs).toHaveLength(2); expect(precs).toContainEqual({ tableId: 's-ueber', ...coords('B2') }); expect(precs).toContainEqual({ tableId: 's-ueber', ...coords('C2') }); }); it('graphPrecedents follows a cross-table range reference to every cell in it', () => { const wb = fixture(); const { col, row } = coords('B2'); // Übersicht!B2 = SUM(Umsatz!B2:E2) const precs = wb.graphPrecedents('s-ueber', col, row); expect(precs).toHaveLength(4); for (const a1 of ['B2', 'C2', 'D2', 'E2']) { expect(precs).toContainEqual({ tableId: 's-umsatz', ...coords(a1) }); } }); it('graphDependents is the exact reverse of graphPrecedents', () => { const wb = fixture(); const { col, row } = coords('B2'); // Übersicht!B2 -- read by D2 and (via D2) E2 directly reads B2 too const deps = wb.graphDependents('s-ueber', col, row); expect(deps).toContainEqual({ tableId: 's-ueber', ...coords('D2') }); expect(deps).toContainEqual({ tableId: 's-ueber', ...coords('E2') }); // E2 = D2/B2*100, reads B2 directly }); it('a cell with no formula (or a literal) has no precedents', () => { const wb = fixture(); const { col, row } = coords('B2'); // Umsatz!B2 is a plain literal, "1200" expect(wb.graphPrecedents('s-umsatz', col, row)).toEqual([]); }); it('a cell nothing reads has no dependents', () => { const wb = fixture(); const { col, row } = coords('A2'); // Umsatz!A2 = "Nord", never referenced by a formula expect(wb.graphDependents('s-umsatz', col, row)).toEqual([]); }); it('stays accurate for cells on a circular reference (edges exist even though topoOrder drops them)', () => { const a = sheet('s1', 'S1', { A1: '=B1', B1: '=A1' }); const wb = new Workbook([a]); const a1 = coords('A1'); expect(wb.graphPrecedents('s1', a1.col, a1.row)).toContainEqual({ tableId: 's1', ...coords('B1') }); expect(wb.graphDependents('s1', a1.col, a1.row)).toContainEqual({ tableId: 's1', ...coords('B1') }); }); it('updates live after a cell is re-edited to reference something else', () => { const wb = fixture(); const c1 = coords('C1'); wb.setCell('s-umsatz', c1.col, c1.row, '=B2'); expect(wb.graphPrecedents('s-umsatz', c1.col, c1.row)).toContainEqual({ tableId: 's-umsatz', ...coords('B2') }); wb.setCell('s-umsatz', c1.col, c1.row, '=B3'); const precs = wb.graphPrecedents('s-umsatz', c1.col, c1.row); expect(precs).toContainEqual({ tableId: 's-umsatz', ...coords('B3') }); expect(precs).not.toContainEqual({ tableId: 's-umsatz', ...coords('B2') }); }); }); describe('Workbook recalculation order', () => { it('evaluates precedents before dependents (chain across sheets)', () => { const wb = fixture(); // chain: Umsatz!B2 -> Übersicht!B2 -> Übersicht!D2 -> Übersicht!E2 const { order, circular } = wb.debugOrder([keyOf('s-umsatz', 1, 1)]); expect(circular).toEqual([]); const pos = (k: string) => order.indexOf(k); expect(pos(keyOf('s-ueber', 1, 1))).toBeGreaterThan(-1); expect(pos(keyOf('s-ueber', 1, 1))).toBeLessThan(pos(keyOf('s-ueber', 3, 1))); expect(pos(keyOf('s-ueber', 3, 1))).toBeLessThan(pos(keyOf('s-ueber', 4, 1))); }); }); describe('Workbook sheet qualifier ("Blatt!Tabelle::Zelle") -- table names unique per sheet only', () => { /** Two sheets ("Analyse", "Zweitblatt") each hold their own table called * "Umsatz" -- the same table NAME on two different sheets, only possible * now that table-name uniqueness is per-sheet rather than document-wide. */ function multiSheetFixture(): Workbook { const analyseUmsatz = sheet('s-a-umsatz', 'Umsatz', { B2: '100' }, 'Analyse'); const analyseOther = sheet( 's-a-other', 'Auswertung', { B2: '=Umsatz!B2', // ambiguous name, home sheet is Analyse -> Analyse's own Umsatz B3: '=Analyse!Umsatz::B2', // explicit sheet qualifier B4: '=Zweitblatt!Umsatz::B2', // explicit sheet qualifier, the OTHER sheet's Umsatz }, 'Analyse', ); const zweitUmsatz = sheet('s-b-umsatz', 'Umsatz', { B2: '999' }, 'Zweitblatt'); const zweitOther = sheet( 's-b-other', 'Check', { B2: '=Umsatz!B2' }, // ambiguous name, home sheet is Zweitblatt -> Zweitblatt's own Umsatz 'Zweitblatt', ); const drittOther = sheet( 's-c-other', 'Fremd', { B2: '=Umsatz!B2', // ambiguous, no same-sheet "Umsatz" on Dritt -> #REF B3: '=Auswertung!B2', // "Auswertung" itself is NOT ambiguous (only on Analyse) -> resolves from anywhere }, 'Dritt', ); return new Workbook([analyseUmsatz, analyseOther, zweitUmsatz, zweitOther, drittOther]); } it('an unqualified, otherwise-ambiguous name resolves to the table on the SAME sheet as the formula', () => { const wb = multiSheetFixture(); expect(val(wb, 's-a-other', 'B2')).toBe(100); expect(val(wb, 's-b-other', 'B2')).toBe(999); }); it('an explicit "Blatt!Tabelle::Zelle" always resolves to that sheet\'s table, regardless of home sheet', () => { const wb = multiSheetFixture(); expect(val(wb, 's-a-other', 'B3')).toBe(100); expect(val(wb, 's-a-other', 'B4')).toBe(999); }); it('an unqualified ambiguous name with no same-sheet match is #REF', () => { const wb = multiSheetFixture(); expect((val(wb, 's-c-other', 'B2') as FormulaError).code).toBe('#REF'); }); it('a non-ambiguous (single-match) name still resolves without any sheet qualifier, from any sheet', () => { const wb = multiSheetFixture(); expect(val(wb, 's-c-other', 'B3')).toBe(100); // Auswertung!B2, transitively Analyse's Umsatz!B2 }); it('recalculates correctly through a same-sheet-preferred resolution when the source cell changes', () => { const wb = multiSheetFixture(); wb.setCell('s-a-umsatz', 1, 1, '250'); // Analyse's Umsatz B2: 100 -> 250 expect(val(wb, 's-a-other', 'B2')).toBe(250); expect(val(wb, 's-a-other', 'B3')).toBe(250); // Auswertung!B2 chained through expect(val(wb, 's-b-other', 'B2')).toBe(999); // Zweitblatt's own Umsatz, unaffected }); it('an explicit sheet qualifier for an unknown sheet or table is #REF', () => { const wb2 = new Workbook([ sheet('s-x', 'X', { B2: '=Nirgendwo!Umsatz::A1' }, 'Analyse'), sheet('s-a-umsatz', 'Umsatz', { A1: '5' }, 'Analyse'), ]); expect((val(wb2, 's-x', 'B2') as FormulaError).code).toBe('#REF'); }); }); describe('literalValue: date/time literal recognition', () => { it('turns a recognized date/time literal into its serial number', () => { expect(literalValue('2026-09-02')).toBe(dateToSerial(2026, 9, 2)); expect(literalValue('02.09.2026')).toBe(dateToSerial(2026, 9, 2)); expect(literalValue('14:30')).toBe(timeSerial(14, 30, 0)); }); it('leaves ordinary numbers and text untouched', () => { expect(literalValue('1234.5')).toBe(1234.5); expect(literalValue('-3')).toBe(-3); expect(literalValue('hello')).toBe('hello'); expect(literalValue('02/09/2026')).toBe('02/09/2026'); // slash dates: not recognized, stays text }); it('empty input is null', () => { expect(literalValue('')).toBeNull(); expect(literalValue(' ')).toBeNull(); }); }); describe('Workbook: DATE/TIME/TODAY/NOW formulas', () => { it('DATE/TIME build a serial usable in cell arithmetic', () => { const wb = new Workbook([ sheet('s1', 'S1', { A1: '=DATE(2026;9;2)', A2: '=DATE(2026;9;1)', A3: '=A1-A2', // date arithmetic: just numbers B1: '=TIME(12;0;0)', }), ]); expect(val(wb, 's1', 'A1')).toBe(dateToSerial(2026, 9, 2)); expect(val(wb, 's1', 'A3')).toBe(1); expect(val(wb, 's1', 'B1')).toBeCloseTo(0.5, 10); }); it('DATE overflows like Excel, TIME wraps like Excel', () => { const wb = new Workbook([ sheet('s1', 'S1', { A1: '=DATE(2026;13;1)', B1: '=TIME(25;0;0)' }), ]); expect(val(wb, 's1', 'A1')).toBe(dateToSerial(2027, 1, 1)); expect(val(wb, 's1', 'B1')).toBeCloseTo(timeSerial(25, 0, 0), 10); }); it('DATE/TIME reject the wrong argument count or a non-numeric argument', () => { const wb = new Workbook([ sheet('s1', 'S1', { A1: '=DATE(2026;9)', A2: '=DATE(2026;9;"x")' }), ]); expect(isError(val(wb, 's1', 'A1'))).toBe(true); expect(isError(val(wb, 's1', 'A2'))).toBe(true); }); it('TODAY/NOW take no arguments and return a plausible current serial', () => { const wb = new Workbook([sheet('s1', 'S1', { A1: '=TODAY()', A2: '=NOW()', A3: '=TODAY(1)' })]); expect(val(wb, 's1', 'A1')).toBe(todaySerial()); expect(val(wb, 's1', 'A2') as number).toBeCloseTo(nowSerial(), 2); expect(isError(val(wb, 's1', 'A3'))).toBe(true); }); }); describe('Workbook: XLOOKUP recalculation', () => { it('rewires and recalculates when a cell inside either range changes', () => { const wb = new Workbook([ sheet('s1', 'S1', { A2: 'Apfel', B2: '1.5', A3: 'Birne', B3: '2', A4: 'Kirsche', B4: '5', C1: '=XLOOKUP("Birne";A2:A4;B2:B4)', }), ]); expect(val(wb, 's1', 'C1')).toBe(2); // a change to a cell only inside the RETURN range (not itself the match) wb.setCell('s1', 1, 2, '20'); // B3 expect(val(wb, 's1', 'C1')).toBe(20); // a change to a cell only inside the LOOKUP range moves the match wb.setCell('s1', 0, 1, 'Birne'); // A2 -> now also "Birne", first match wins expect(val(wb, 's1', 'C1')).toBe(1.5); }); it('an unrelated cell change does not trigger recalculation', () => { const wb = new Workbook([ sheet('s1', 'S1', { A2: 'Apfel', B2: '1.5', C1: '=XLOOKUP("Apfel";A2:A2;B2:B2)', D1: '1' }), ]); expect(val(wb, 's1', 'C1')).toBe(1.5); wb.setCell('s1', 3, 0, '999'); // D1, not referenced by C1 at all expect(val(wb, 's1', 'C1')).toBe(1.5); }); });