Files
numolus/app/frontend/engine/__tests__/workbook.spec.ts
T
wkhandClaude Opus 5.5 e14bada114 Add Rails backend: accounts via magic link, server documents, sharing
The app becomes a Rails 8 application; the Vue frontend moves to
app/frontend and is served through vite_rails.

- Without an account documents stay in the browser (IndexedDB). Sign-in
  is by magic link or the 6-digit code from the same mail; afterwards
  local documents can be uploaded into the account.
- Server documents (/d/<id>) with versioned saves and a single-editor
  lock per browser tab; others see them read-only and follow by polling.
- Sharing via link (works without an account) and via e-mail invitation,
  roles viewer/editor; the owner manages links and people.
- Store gains a read-only mode that blocks all content changes.
- Solid Queue, Cache and Cable share the one PostgreSQL database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:09:06 +02:00

448 lines
18 KiB
TypeScript

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<string, string>,
sheetName = 'Blatt1',
): 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, 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);
});
});