This commit is contained in:
wkh
2026-08-29 17:47:12 +02:00
commit c7885067e7
53 changed files with 13458 additions and 0 deletions
+129
View File
@@ -0,0 +1,129 @@
import { describe, it, expect } from 'vitest';
import { parseFormula } from '../parser';
import { evaluate } from '../evaluator';
import { FormulaError, isError, type CellValue, type EngineHost } from '../types';
/** Minimal in-memory host: two tables addressed by name. */
function makeHost(
data: Record<string, Record<string, CellValue>>,
home = 'S1',
): EngineHost {
const sizes: Record<string, { cols: number; rows: number }> = {
S1: { cols: 10, rows: 10 },
S2: { cols: 10, rows: 10 },
};
const idFor = (name: string): string | null => {
const n = name.trim().toLowerCase();
if (n === 's1') return 'S1';
if (n === 's2') return 'S2';
return null;
};
return {
homeTableId: home,
resolveTableName: idFor,
tableSize: (id) => sizes[id] ?? null,
getComputed(tableId, col, row) {
const cell = String.fromCharCode(65 + col) + (row + 1);
const v = data[tableId]?.[cell];
return v === undefined ? null : v;
},
};
}
const ev = (src: string, host: EngineHost) => evaluate(parseFormula(src), host);
describe('evaluator arithmetic', () => {
const host = makeHost({ S1: { B2: 10, C2: 5, D2: 8 } });
it('respects precedence and associativity', () => {
expect(ev('=(B2+C2)*2-D2/4', host)).toBe(28); // (15)*2 - 2
expect(ev('=2^3^2', host)).toBe(512);
expect(ev('=-2^2', host)).toBe(-4);
expect(ev('=2+3*4', host)).toBe(14);
});
it('treats an empty cell as 0', () => {
expect(ev('=B2+Z9', host)).toBe(10);
});
it('division by zero -> #DIV/0!', () => {
expect(ev('=1/0', host)).toEqual(new FormulaError('#DIV/0!'));
});
});
describe('evaluator functions', () => {
const host = makeHost({
S1: { B2: 1200, B3: 980, B4: 1550, B5: 1340, B6: 1105 },
});
it('SUM / AVG / MIN / MAX / COUNT over a range', () => {
expect(ev('=SUM(B2:B6)', host)).toBe(6175);
expect(ev('=AVG(B2:B6)', host)).toBe(1235);
expect(ev('=MIN(B2:B6)', host)).toBe(980);
expect(ev('=MAX(B2:B6)', host)).toBe(1550);
expect(ev('=COUNT(B2:B6)', host)).toBe(5);
});
it('AVERAGE is an alias of AVG', () => {
expect(ev('=AVERAGE(B2:B6)', host)).toBe(1235);
});
it('mixes ranges, literals and single cells', () => {
expect(ev('=SUM(B2:B3; 100; B4)', host)).toBe(1200 + 980 + 100 + 1550);
});
it('COUNT ignores empty cells', () => {
expect(ev('=COUNT(B2:B10)', host)).toBe(5);
});
it('AVG of no numbers -> #DIV/0!', () => {
const r = ev('=AVG(Z1:Z5)', host);
expect(isError(r) && r.code).toBe('#DIV/0!');
});
it('unknown function -> #NAME?', () => {
const r = ev('=FOO(1)', host);
expect(isError(r) && r.code).toBe('#NAME?');
});
});
describe('evaluator cross-sheet & errors', () => {
const host = makeHost({
S1: { A1: 3 },
S2: { A1: 7, B1: 100 },
});
it('resolves Name!Ref and is case-insensitive', () => {
expect(ev('=S2!A1 + S2!B1', host)).toBe(107);
expect(ev('=s2!a1', host)).toBe(7);
});
it('accepts "::" the same as "!"', () => {
expect(ev('=S2::A1 + S2::B1', host)).toBe(107);
expect(ev('=s2::a1', host)).toBe(ev('=S2!A1', host));
});
it('unknown sheet -> #REF', () => {
const r = ev('=Nix!A1', host);
expect(isError(r) && r.code).toBe('#REF');
});
it('propagates an error from a referenced cell', () => {
const h = makeHost({ S1: { A1: new FormulaError('#DIV/0!') } });
const r = ev('=A1 + 1', h);
expect(isError(r) && r.code).toBe('#DIV/0!');
const r2 = ev('=SUM(A1; 5)', h);
expect(isError(r2) && r2.code).toBe('#DIV/0!');
});
it('a bare range in scalar position -> #VALUE!', () => {
const r = ev('=A1:A3 + 1', host);
expect(isError(r) && r.code).toBe('#VALUE!');
});
it('text in arithmetic -> #VALUE!, numeric text is coerced', () => {
const h = makeHost({ S1: { A1: 'abc', A2: '12' } });
expect(isError(ev('=A1*2', h))).toBe(true);
expect(ev('=A2*2', h)).toBe(24);
});
});
+61
View File
@@ -0,0 +1,61 @@
import { describe, it, expect } from 'vitest';
import { DependencyGraph } from '../graph';
/**
* Helper: build a graph from "cell <- precedent, precedent" lines.
* Node names are arbitrary strings here (real code uses `keyOf`).
*/
function build(edges: Record<string, string[]>): DependencyGraph {
const g = new DependencyGraph();
for (const [cell, precs] of Object.entries(edges)) g.setPrecedents(cell, precs);
return g;
}
describe('DependencyGraph.affected', () => {
it('returns dirty cells plus their transitive dependents', () => {
// A -> B -> C ; A -> D
const g = build({ B: ['A'], C: ['B'], D: ['A'] });
expect([...g.affected(['A'])].sort()).toEqual(['A', 'B', 'C', 'D']);
expect([...g.affected(['B'])].sort()).toEqual(['B', 'C']);
});
});
describe('DependencyGraph.topoOrder', () => {
it('orders precedents before dependents', () => {
const g = build({ B: ['A'], C: ['A', 'B'], D: ['C'] });
const { order, circular } = g.topoOrder(g.affected(['A']));
expect(circular.size).toBe(0);
const pos = (n: string) => order.indexOf(n);
expect(pos('A')).toBeLessThan(pos('B'));
expect(pos('B')).toBeLessThan(pos('C'));
expect(pos('C')).toBeLessThan(pos('D'));
});
it('detects a direct self-reference', () => {
const g = build({ A: ['A'] });
const { circular } = g.topoOrder(g.affected(['A']));
expect([...circular]).toEqual(['A']);
});
it('detects a 2-cycle and flags both nodes', () => {
const g = build({ A: ['B'], B: ['A'] });
const { order, circular } = g.topoOrder(g.affected(['A']));
expect([...circular].sort()).toEqual(['A', 'B']);
expect(order).toEqual([]);
});
it('detects a longer cycle, leaves acyclic tail evaluable', () => {
// cycle A->B->C->A ; plus D reads C (D is not on the cycle)
const g = build({ A: ['C'], B: ['A'], C: ['B'], D: ['C'] });
const { order, circular } = g.topoOrder(g.affected(['A']));
expect([...circular].sort()).toEqual(['A', 'B', 'C']);
expect(order).toEqual(['D']);
});
it('rewiring precedents removes stale edges', () => {
const g = build({ B: ['A'] });
expect([...g.affected(['A'])].sort()).toEqual(['A', 'B']);
g.setPrecedents('B', []); // B no longer reads A
expect([...g.affected(['A'])].sort()).toEqual(['A']);
});
});
+66
View File
@@ -0,0 +1,66 @@
import { describe, it, expect } from 'vitest';
import { tokenize } from '../lexer';
const types = (s: string) => tokenize(s).map((t) => t.type);
const values = (s: string) => tokenize(s).map((t) => t.value);
describe('lexer', () => {
it('tokenizes numbers incl. decimals and exponents', () => {
expect(values('1 2.5 .5 1e3 1.2E-3')).toEqual([
'1',
'2.5',
'.5',
'1e3',
'1.2E-3',
'',
]);
expect(types('42')).toEqual(['NUMBER', 'EOF']);
});
it('tokenizes operators and punctuation', () => {
expect(types('+-*/^():;!')).toEqual([
'PLUS',
'MINUS',
'STAR',
'SLASH',
'CARET',
'LPAREN',
'RPAREN',
'COLON',
'SEMICOLON',
'BANG',
'EOF',
]);
});
it('reads bare words as IDENT (cell refs, functions, sheet names alike)', () => {
expect(types('SUM(A1:B2)')).toEqual([
'IDENT',
'LPAREN',
'IDENT',
'COLON',
'IDENT',
'RPAREN',
'EOF',
]);
});
it('reads single-quoted sheet names with spaces', () => {
const t = tokenize("'Kosten Q3'!B2");
expect(t[0]).toMatchObject({ type: 'QUOTED', value: 'Kosten Q3' });
expect(t[1].type).toBe('BANG');
expect(t[2]).toMatchObject({ type: 'IDENT', value: 'B2' });
});
it('handles escaped double-quoted strings', () => {
expect(tokenize('"a\\"b"')[0]).toMatchObject({ type: 'STRING', value: 'a"b' });
});
it('skips whitespace', () => {
expect(types(' A1\t+\n B2 ')).toEqual(['IDENT', 'PLUS', 'IDENT', 'EOF']);
});
it('throws on an unexpected character', () => {
expect(() => tokenize('A1 & B2')).toThrow();
});
});
+92
View File
@@ -0,0 +1,92 @@
import { describe, it, expect } from 'vitest';
import { parseFormula, collectRefs, ParseError, type Node } from '../parser';
/** Compact s-expression rendering of an AST for precedence assertions. */
function sexpr(n: Node): string {
switch (n.kind) {
case 'number':
return String(n.value);
case 'string':
return JSON.stringify(n.value);
case 'unary':
return `(${n.op} ${sexpr(n.operand)})`;
case 'binary':
return `(${n.op} ${sexpr(n.left)} ${sexpr(n.right)})`;
case 'func':
return `(${n.name} ${n.args.map(sexpr).join(' ')})`;
case 'cell':
return `${n.qualifier ? n.qualifier + '!' : ''}cell(${n.col},${n.row})`;
case 'range':
return `${n.qualifier ? n.qualifier + '!' : ''}range(${n.start.col},${n.start.row}:${n.end.col},${n.end.row})`;
}
}
const parse = (s: string) => sexpr(parseFormula(s));
describe('parser precedence & associativity', () => {
it('* / bind tighter than + -', () => {
expect(parse('=1+2*3')).toBe('(+ 1 (* 2 3))');
expect(parse('=1-2/2')).toBe('(- 1 (/ 2 2))');
});
it('+ - are left-associative', () => {
expect(parse('=1-2-3')).toBe('(- (- 1 2) 3)');
});
it('^ binds tighter than * / and is right-associative', () => {
expect(parse('=2*3^2')).toBe('(* 2 (^ 3 2))');
expect(parse('=2^3^2')).toBe('(^ 2 (^ 3 2))');
});
it('unary minus binds weaker than ^ (-2^2 = -(2^2))', () => {
expect(parse('=-2^2')).toBe('(- (^ 2 2))');
expect(parse('=2^-3')).toBe('(^ 2 (- 3))');
});
it('parentheses override precedence', () => {
expect(parse('=(1+2)*3')).toBe('(* (+ 1 2) 3)');
expect(parse('=(B2+C2)*2-D2/4')).toBe(
'(- (* (+ cell(1,1) cell(2,1)) 2) (/ cell(3,1) 4))',
);
});
});
describe('parser references', () => {
it('parses local cells and ranges', () => {
expect(parse('=A1')).toBe('cell(0,0)');
expect(parse('=A1:C5')).toBe('range(0,0:2,4)');
});
it('parses table-qualified refs, bare and quoted', () => {
expect(parse('=Umsatz!B2')).toBe('Umsatz!cell(1,1)');
expect(parse("='Kosten Q3'!B2:E2")).toBe('Kosten Q3!range(1,1:4,1)');
});
it('accepts the Numbers-style "::" qualifier interchangeably with "!"', () => {
expect(parse('=Umsatz::B2')).toBe('Umsatz!cell(1,1)');
expect(parse("='Kosten Q3'::B2:E2")).toBe('Kosten Q3!range(1,1:4,1)');
expect(parse('=Umsatz::B2')).toBe(parse('=Umsatz!B2'));
});
it('parses function calls with ; separator and mixed args', () => {
expect(parse('=SUM(Umsatz!B2:E2; 100; A1)')).toBe(
'(SUM Umsatz!range(1,1:4,1) 100 cell(0,0))',
);
});
it('collectRefs finds every reference', () => {
const refs = collectRefs(parseFormula('=SUM(A1:A3; Kosten!B2) + C4'));
expect(refs).toHaveLength(3);
});
});
describe('parser errors', () => {
it('throws ParseError on a dangling operator', () => {
expect(() => parseFormula('=A1+')).toThrow(ParseError);
});
it('throws on an unbalanced paren', () => {
expect(() => parseFormula('=SUM(A1')).toThrow(ParseError);
});
it('throws on a non-cell identifier used as a reference', () => {
expect(() => parseFormula('=foo + 1')).toThrow(ParseError);
});
});
+146
View File
@@ -0,0 +1,146 @@
import { describe, it, expect } from 'vitest';
import { Workbook, type TableModel } from '../workbook';
import { FormulaError, isError, keyOf } from '../types';
function sheet(id: string, name: string, cells: Record<string, string>): TableModel {
const inputs = new Map<string, string>();
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, 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);
});
});
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 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)));
});
});