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>
This commit is contained in:
wkh
2026-09-28 20:09:06 +02:00
co-authored by Claude Opus 5.5
parent 5a0f1fcd78
commit e14bada114
190 changed files with 6077 additions and 722 deletions
@@ -0,0 +1,125 @@
import { describe, it, expect } from 'vitest';
import { FUNCTIONS } from '../functions';
import { dateToSerial, timeSerial } from '../dates';
import { isError, type CellValue } from '../types';
const call = (name: string, ...args: CellValue[]) => FUNCTIONS[name](args);
const d = (y: number, m: number, day: number) => dateToSerial(y, m, day);
describe('date parts', () => {
const t = dateToSerial(2026, 9, 2, 14, 30, 15);
it('YEAR / MONTH / DAY / HOUR / MINUTE / SECOND', () => {
expect(call('YEAR', t)).toBe(2026);
expect(call('MONTH', t)).toBe(9);
expect(call('DAY', t)).toBe(2);
expect(call('HOUR', t)).toBe(14);
expect(call('MINUTE', t)).toBe(30);
expect(call('SECOND', t)).toBe(15);
});
it('rounds to the whole second, so float noise never shows as :59', () => {
const noisy = timeSerial(14, 29, 59) + 1 / 86400;
expect(call('HOUR', noisy)).toBe(14);
expect(call('MINUTE', noisy)).toBe(30);
expect(call('SECOND', noisy)).toBe(0);
});
it('checks argument count and type', () => {
expect(isError(call('YEAR'))).toBe(true);
expect(isError(call('YEAR', 1, 2))).toBe(true);
expect(isError(call('YEAR', 'kein Datum'))).toBe(true);
});
});
describe('WEEKDAY (ISO default: Monday = 1)', () => {
const wed = d(2026, 9, 2);
it('defaults to ISO numbering', () => {
expect(call('WEEKDAY', wed)).toBe(3);
expect(call('WEEKDAY', d(2026, 9, 6))).toBe(7); // Sunday
});
it('supports Excel types 1, 2, 3', () => {
expect(call('WEEKDAY', wed, 1)).toBe(4); // Sunday = 1
expect(call('WEEKDAY', wed, 2)).toBe(3);
expect(call('WEEKDAY', wed, 3)).toBe(2); // Monday = 0
expect(isError(call('WEEKDAY', wed, 7))).toBe(true);
});
});
describe('ISO week numbers', () => {
it('handles the year boundaries', () => {
expect(call('ISOWEEKNUM', d(2026, 1, 1))).toBe(1); // Thursday
expect(call('ISOWEEKNUM', d(2027, 1, 1))).toBe(53); // Friday -> last week of 2026
expect(call('ISOWEEKNUM', d(2021, 1, 3))).toBe(53); // Sunday -> week 53 of 2020
expect(call('ISOWEEKNUM', d(2024, 12, 30))).toBe(1); // Monday -> week 1 of 2025
expect(call('ISOWEEKNUM', d(2026, 9, 2))).toBe(36);
});
it('WEEKNUM is ISO by default, US-style with type 1/2', () => {
expect(call('WEEKNUM', d(2021, 1, 3))).toBe(53);
expect(call('WEEKNUM', d(2026, 1, 3), 1)).toBe(1); // Saturday, same week as 1 Jan
expect(call('WEEKNUM', d(2026, 1, 4), 1)).toBe(2); // Sunday starts week 2
expect(call('WEEKNUM', d(2026, 1, 4), 2)).toBe(1); // ... but not when weeks start Monday
expect(call('WEEKNUM', d(2026, 1, 5), 2)).toBe(2);
});
});
describe('month arithmetic', () => {
it('EDATE clamps to the end of a shorter month', () => {
expect(call('EDATE', d(2026, 1, 31), 1)).toBe(d(2026, 2, 28));
expect(call('EDATE', d(2024, 1, 31), 1)).toBe(d(2024, 2, 29));
expect(call('EDATE', d(2026, 3, 31), -1)).toBe(d(2026, 2, 28));
expect(call('EDATE', d(2026, 9, 2), 12)).toBe(d(2027, 9, 2));
});
it('EOMONTH', () => {
expect(call('EOMONTH', d(2026, 9, 2), 0)).toBe(d(2026, 9, 30));
expect(call('EOMONTH', d(2026, 9, 2), -1)).toBe(d(2026, 8, 31));
expect(call('EOMONTH', d(2026, 1, 15), 1)).toBe(d(2026, 2, 28));
});
});
describe('differences', () => {
it('DAYS counts whole calendar days, ignoring the time', () => {
expect(call('DAYS', d(2026, 12, 31), d(2026, 1, 1))).toBe(364);
expect(call('DAYS', d(2026, 1, 2) + 0.9, d(2026, 1, 1) + 0.1)).toBe(1);
});
it('DATEDIF in every unit', () => {
const s = d(2020, 2, 29);
const e = d(2026, 9, 2);
expect(call('DATEDIF', s, e, 'Y')).toBe(6);
expect(call('DATEDIF', s, e, 'M')).toBe(78);
expect(call('DATEDIF', s, e, 'D')).toBe(e - s);
expect(call('DATEDIF', s, e, 'YM')).toBe(6);
expect(call('DATEDIF', s, e, 'MD')).toBe(4); // 29.08. -> 02.09.
expect(call('DATEDIF', s, e, 'yd')).toBe(d(2026, 9, 2) - d(2026, 2, 28));
expect(isError(call('DATEDIF', e, s, 'D'))).toBe(true);
expect(isError(call('DATEDIF', s, e, 'W'))).toBe(true);
});
});
describe('working days (Monday-Friday)', () => {
it('NETWORKDAYS counts both ends and skips holidays on weekdays', () => {
expect(call('NETWORKDAYS', d(2026, 9, 1), d(2026, 9, 30))).toBe(22);
expect(call('NETWORKDAYS', d(2026, 9, 1), d(2026, 9, 30), d(2026, 9, 15))).toBe(21);
expect(call('NETWORKDAYS', d(2026, 9, 1), d(2026, 9, 30), d(2026, 9, 5))).toBe(22); // Saturday
expect(call('NETWORKDAYS', d(2026, 9, 30), d(2026, 9, 1))).toBe(-22);
});
it('WORKDAY steps over weekends and holidays, both directions', () => {
expect(call('WORKDAY', d(2026, 9, 4), 1)).toBe(d(2026, 9, 7)); // Fri -> Mon
expect(call('WORKDAY', d(2026, 9, 7), -1)).toBe(d(2026, 9, 4));
expect(call('WORKDAY', d(2026, 9, 4), 1, d(2026, 9, 7))).toBe(d(2026, 9, 8));
expect(call('WORKDAY', d(2026, 9, 2), 0)).toBe(d(2026, 9, 2));
});
});
describe('DATEVALUE / TIMEVALUE', () => {
it('read the same formats a cell entry is recognized in', () => {
expect(call('DATEVALUE', '2026-09-02')).toBe(d(2026, 9, 2));
expect(call('DATEVALUE', '02.09.2026 14:30')).toBe(d(2026, 9, 2));
expect(call('TIMEVALUE', '14:30')).toBeCloseTo(timeSerial(14, 30, 0), 12);
expect(call('TIMEVALUE', '2026-09-02 14:30')).toBeCloseTo(timeSerial(14, 30, 0), 12);
});
it('pass a number through and reject other text', () => {
expect(call('DATEVALUE', d(2026, 9, 2) + 0.5)).toBe(d(2026, 9, 2));
expect(isError(call('DATEVALUE', 'hallo'))).toBe(true);
});
});
+127
View File
@@ -0,0 +1,127 @@
import { describe, it, expect } from 'vitest';
import {
dateToSerial,
timeSerial,
serialToUtcDate,
todaySerial,
parseDateTimeLiteral,
} from '../dates';
describe('dateToSerial / serialToUtcDate', () => {
it('epoch is 1899-12-30: serial 1 = 1899-12-31, serial 2 = 1900-01-01', () => {
expect(dateToSerial(1899, 12, 31)).toBe(1);
expect(dateToSerial(1900, 1, 1)).toBe(2);
});
it('round-trips through serialToUtcDate', () => {
const s = dateToSerial(2026, 9, 2);
const d = serialToUtcDate(s);
expect(d.getUTCFullYear()).toBe(2026);
expect(d.getUTCMonth()).toBe(8); // 0-based
expect(d.getUTCDate()).toBe(2);
});
it('allows overflow, like Excel DATE() (month 13 -> next January)', () => {
const s = dateToSerial(2026, 13, 1);
const d = serialToUtcDate(s);
expect(d.getUTCFullYear()).toBe(2027);
expect(d.getUTCMonth()).toBe(0);
expect(d.getUTCDate()).toBe(1);
});
it('carries a fractional part for a time-of-day component', () => {
const s = dateToSerial(2026, 9, 2, 12, 0, 0);
expect(s % 1).toBeCloseTo(0.5, 10);
});
});
describe('timeSerial', () => {
it('noon = 0.5, midnight = 0', () => {
expect(timeSerial(12, 0, 0)).toBeCloseTo(0.5, 10);
expect(timeSerial(0, 0, 0)).toBe(0);
});
it('wraps past 24h like Excel TIME()', () => {
expect(timeSerial(25, 0, 0)).toBeCloseTo(1 / 24, 10);
});
});
describe('todaySerial', () => {
it('matches the local calendar date', () => {
const n = new Date();
expect(todaySerial()).toBe(Math.trunc(dateToSerial(n.getFullYear(), n.getMonth() + 1, n.getDate())));
});
});
describe('parseDateTimeLiteral', () => {
it('recognizes ISO dates', () => {
const r = parseDateTimeLiteral('2026-09-02');
expect(r).toEqual({ kind: 'date', serial: dateToSerial(2026, 9, 2) });
});
it('recognizes ISO datetimes (space or T separator, optional seconds)', () => {
expect(parseDateTimeLiteral('2026-09-02 14:30')).toEqual({
kind: 'datetime',
serial: dateToSerial(2026, 9, 2, 14, 30, 0),
});
expect(parseDateTimeLiteral('2026-09-02T14:30:15')).toEqual({
kind: 'datetime',
serial: dateToSerial(2026, 9, 2, 14, 30, 15),
});
});
it('recognizes dot dates, 4- and 2-digit years', () => {
expect(parseDateTimeLiteral('02.09.2026')).toEqual({
kind: 'date',
serial: dateToSerial(2026, 9, 2),
});
expect(parseDateTimeLiteral('02.09.26')).toEqual({
kind: 'date',
serial: dateToSerial(2026, 9, 2),
});
expect(parseDateTimeLiteral('02.09.99')).toEqual({
kind: 'date',
serial: dateToSerial(1999, 9, 2),
});
});
it('recognizes dot datetimes', () => {
expect(parseDateTimeLiteral('02.09.2026 14:30')).toEqual({
kind: 'datetime',
serial: dateToSerial(2026, 9, 2, 14, 30, 0),
});
});
it('recognizes plain times', () => {
expect(parseDateTimeLiteral('14:30')).toEqual({ kind: 'time', serial: timeSerial(14, 30, 0) });
expect(parseDateTimeLiteral('14:30:15')).toEqual({ kind: 'time', serial: timeSerial(14, 30, 15) });
});
it('does NOT recognize slash-separated dates (day-first/month-first ambiguity)', () => {
expect(parseDateTimeLiteral('02/09/2026')).toBeNull();
});
it('rejects an out-of-range or rolled-over date instead of silently guessing', () => {
expect(parseDateTimeLiteral('31.04.2026')).toBeNull(); // April has 30 days
expect(parseDateTimeLiteral('30.02.2026')).toBeNull(); // no Feb 30
expect(parseDateTimeLiteral('2026-13-01')).toBeNull(); // month 13
});
it('rejects an out-of-range minute/second', () => {
expect(parseDateTimeLiteral('12:60')).toBeNull();
expect(parseDateTimeLiteral('37:30:60')).toBeNull();
});
it('reads 24 hours or more as a duration, not a time of day', () => {
expect(parseDateTimeLiteral('25:00')).toEqual({ kind: 'duration', serial: 25 / 24 });
expect(parseDateTimeLiteral('37:30')).toEqual({ kind: 'duration', serial: 37.5 / 24 });
expect(parseDateTimeLiteral('100:00:30')!.serial).toBeCloseTo((100 * 3600 + 30) / 86400, 12);
expect(parseDateTimeLiteral('23:59')!.kind).toBe('time');
});
it('does not misfire on plain numbers or arbitrary text', () => {
expect(parseDateTimeLiteral('1234.5')).toBeNull();
expect(parseDateTimeLiteral('hello')).toBeNull();
expect(parseDateTimeLiteral('')).toBeNull();
});
});
@@ -0,0 +1,445 @@
import { describe, it, expect } from 'vitest';
import { parseFormula } from '../parser';
import { evaluate } from '../evaluator';
import { FormulaError, isError, type CellValue, type EngineHost } from '../types';
type NamedRangeShape = { qualifier: string; sheetQualifier?: string; start: { col: number; row: number }; end: { col: number; row: number } };
/** Minimal in-memory host: two tables addressed by name, plus an optional
* name -> named-range map (name lower-cased, same matching convention as
* the real Workbook). */
function makeHost(
data: Record<string, Record<string, CellValue>>,
home = 'S1',
namedRanges: Record<string, NamedRangeShape> = {},
): 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,
resolveNamedRange: (name) => namedRanges[name.trim().toLowerCase()] ?? null,
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?');
});
it('an omitted argument slot (e.g. SUM(1;;3)) behaves like an empty cell', () => {
expect(ev('=SUM(1;;3)', host)).toBe(4);
expect(ev('=COUNT(1;;3)', host)).toBe(2); // omitted, like an empty cell, isn't counted
});
});
describe('evaluator IF', () => {
const host = makeHost({ S1: { A1: 8, B1: 0 } });
it('picks the then-branch when the condition is truthy', () => {
expect(ev('=IF(1;2;3)', host)).toBe(2);
expect(ev('=IF(A1>5;"groß";"klein")', host)).toBe('groß');
});
it('picks the else-branch when the condition is falsy', () => {
expect(ev('=IF(0;2;3)', host)).toBe(3);
expect(ev('=IF(A1<5;"groß";"klein")', host)).toBe('klein');
});
it('returns 0 for a falsy condition with no else-branch', () => {
expect(ev('=IF(0;2)', host)).toBe(0);
});
it('never evaluates the untaken branch (short-circuit)', () => {
expect(ev('=IF(1;2;1/0)', host)).toBe(2);
expect(ev('=IF(B1=0;0;A1/B1)', host)).toBe(0);
});
it('propagates an error from the condition itself', () => {
const r = ev('=IF(1/0;2;3)', host);
expect(isError(r) && r.code).toBe('#DIV/0!');
});
it('wrong number of arguments -> #ERR', () => {
expect(isError(ev('=IF(1)', host))).toBe(true);
expect(isError(ev('=IF(1;2;3;4)', host))).toBe(true);
});
it('works with comparison operators as the condition', () => {
expect(ev('=IF(A1>=8;1;0)', host)).toBe(1);
expect(ev('=IF(A1<>8;1;0)', host)).toBe(0);
});
});
describe('evaluator AND/OR/NOT', () => {
const host = makeHost({ S1: { A1: 8, B1: 0, C1: 'x' } });
it('AND is true only when every argument is truthy', () => {
expect(ev('=AND(1;1;1)', host)).toBe(1);
expect(ev('=AND(1;0;1)', host)).toBe(0);
expect(ev('=AND(A1>0;A1<10)', host)).toBe(1);
expect(ev('=AND(A1>0;A1<5)', host)).toBe(0);
});
it('OR is true when at least one argument is truthy', () => {
expect(ev('=OR(0;0;1)', host)).toBe(1);
expect(ev('=OR(0;0;0)', host)).toBe(0);
});
it('NOT inverts a single condition', () => {
expect(ev('=NOT(1)', host)).toBe(0);
expect(ev('=NOT(0)', host)).toBe(1);
expect(ev('=NOT(B1=0)', host)).toBe(0);
});
it('a range argument checks every cell (Excel behaviour)', () => {
const h = makeHost({ S1: { A1: 1, A2: 1, A3: 0 } });
expect(ev('=AND(A1:A3)', h)).toBe(0);
expect(ev('=OR(A1:A3)', h)).toBe(1);
});
it('empty cell and non-empty text follow the shared truthy convention', () => {
expect(ev('=AND(Z9)', host)).toBe(0); // empty cell -> falsy
expect(ev('=AND(C1)', host)).toBe(1); // non-empty text -> truthy
});
it('composes inside IF, unlike a bare chained comparison', () => {
expect(ev('=IF(AND(A1>0;A1<10);"ok";"nein")', host)).toBe('ok');
});
it('does NOT short-circuit -- every argument is evaluated, same as real Excel', () => {
expect(isError(ev('=AND(1=2;1/0)', host))).toBe(true);
expect(isError(ev('=OR(1=1;1/0)', host))).toBe(true);
});
it('wrong arity -> #ERR', () => {
expect(isError(ev('=AND()', host))).toBe(true);
expect(isError(ev('=NOT(1;2)', host))).toBe(true);
expect(isError(ev('=NOT()', host))).toBe(true);
});
});
describe('evaluator error literals (ErrorLiteralNode)', () => {
const host = makeHost({ S1: {} });
it('evaluates directly to that FormulaError', () => {
const r = ev('=#REF', host);
expect(isError(r) && r.code).toBe('#REF');
});
it('propagates through arithmetic like any other error', () => {
const r = ev('=1+#DIV/0!', host);
expect(isError(r) && r.code).toBe('#DIV/0!');
});
it('usable as an IF branch, e.g. a deliberate #N/A', () => {
expect(ev('=IF(1=0;#N/A;5)', host)).toBe(5);
const r = ev('=IF(1=1;#N/A;5)', host);
expect(isError(r) && r.code).toBe('#N/A');
});
});
describe('evaluator comparisons', () => {
const host = makeHost({ S1: { A1: 'Nord', B1: 5 } });
it('numeric comparisons work as before, empty cell counts as 0', () => {
expect(ev('=B1>=5', host)).toBe(1);
expect(ev('=B1<>5', host)).toBe(0);
expect(ev('=Z9=0', host)).toBe(1);
});
it('text equality is case-insensitive and trimmed, like XLOOKUP', () => {
expect(ev('=A1="Nord"', host)).toBe(1);
expect(ev('=A1=" nord "', host)).toBe(1);
expect(ev('=A1="Süd"', host)).toBe(0);
});
it('text ordering compares lexicographically', () => {
expect(ev('=A1<"Süd"', host)).toBe(1);
expect(ev('=A1>"Süd"', host)).toBe(0);
});
it('an empty cell takes on whichever type the other side is', () => {
expect(ev('=Z9=""', host)).toBe(1);
expect(ev('=Z9="x"', host)).toBe(0);
});
it('a real number against real text never crossmatches: = / <> answer definitively, ordering is #VALUE!', () => {
expect(ev('=B1="5"', host)).toBe(0);
expect(ev('=B1<>"5"', host)).toBe(1);
expect(isError(ev('=B1>"5"', host))).toBe(true);
expect(isError(ev('=A1<5', host))).toBe(true);
});
});
describe('evaluator & (text concatenation)', () => {
const host = makeHost({ S1: { A1: 'Herr', B1: 5 } });
it('joins two strings', () => {
expect(ev('="a"&"b"', host)).toBe('ab');
});
it('renders a number plain, no locale formatting', () => {
expect(ev('="Wert: "&B1', host)).toBe('Wert: 5');
});
it('an empty cell contributes "" (not "0")', () => {
expect(ev('="["&Z9&"]"', host)).toBe('[]');
});
it('is left-associative and chains', () => {
expect(ev('=A1&" "&"Wild"', host)).toBe('Herr Wild');
});
it('binds looser than + but tighter than a comparison', () => {
expect(ev('="a"&1+2', host)).toBe('a3');
expect(ev('=A1&"x"="Herrx"', host)).toBe(1);
});
it('propagates an error from either side', () => {
expect(isError(ev('=(1/0)&"x"', host))).toBe(true);
});
});
describe('evaluator CONCAT/CONCATENATE', () => {
const host = makeHost({ S1: { A1: 'x', B1: 'y' } });
it('joins any number of arguments, no separator', () => {
expect(ev('=CONCAT("a";"b";"c")', host)).toBe('abc');
expect(ev('=CONCATENATE(A1;" ";B1)', host)).toBe('x y');
});
it('an empty cell contributes ""', () => {
expect(ev('=CONCAT(A1;Z9;B1)', host)).toBe('xy');
});
});
describe('evaluator named ranges (NamedRefNode)', () => {
it('a single-cell named range behaves exactly like that cell in scalar position', () => {
const host = makeHost({ S1: { B2: 42 } }, 'S1', {
preis: { qualifier: 'S1', start: { col: 1, row: 1 }, end: { col: 1, row: 1 } },
});
expect(ev('=Preis + 1', host)).toBe(43);
});
it('matching is case-insensitive and trimmed, like a table qualifier', () => {
const host = makeHost({ S1: { B2: 42 } }, 'S1', {
preis: { qualifier: 'S1', start: { col: 1, row: 1 }, end: { col: 1, row: 1 } },
});
expect(ev('=PREIS', host)).toBe(42);
});
it('a multi-cell named range works as a function argument (range position), same as SUM(B2:B3)', () => {
const host = makeHost({ S1: { B2: 10, B3: 5 } }, 'S1', {
bereich: { qualifier: 'S1', start: { col: 1, row: 1 }, end: { col: 1, row: 2 } },
});
expect(ev('=SUM(Bereich)', host)).toBe(15);
});
it('a multi-cell named range in scalar position is #VALUE!, same as a bare range', () => {
const host = makeHost({ S1: { B2: 10, B3: 5 } }, 'S1', {
bereich: { qualifier: 'S1', start: { col: 1, row: 1 }, end: { col: 1, row: 2 } },
});
expect(isError(ev('=Bereich', host))).toBe(true);
});
it('an unknown name is #NAME?', () => {
expect(isError(ev('=DoesNotExist', makeHost({})))).toBe(true);
});
it('resolves through another table via its qualifier', () => {
const host = makeHost({ S2: { C3: 7 } }, 'S1', {
andernorts: { qualifier: 'S2', start: { col: 2, row: 2 }, end: { col: 2, row: 2 } },
});
expect(ev('=Andernorts * 2', host)).toBe(14);
});
});
describe('evaluator XLOOKUP', () => {
const textHost = makeHost({
S1: {
A2: 'Apfel', B2: 1.5,
A3: 'Birne', B3: 2,
A4: 'Kirsche', B4: 5,
A5: 'Birne', B5: 99, // duplicate, for search-mode tests
},
});
const bracketHost = makeHost({
S1: { D2: 0, E2: 'A', D3: 10, E3: 'B', D4: 20, E4: 'C', D5: 30, E5: 'D' },
});
it('exact-matches text, case-insensitively and trimmed', () => {
expect(ev('=XLOOKUP("Birne";A2:A4;B2:B4)', textHost)).toBe(2);
expect(ev('=XLOOKUP(" BIRNE ";A2:A4;B2:B4)', textHost)).toBe(2);
});
it('exact-matches numbers', () => {
expect(ev('=XLOOKUP(10;D2:D5;E2:E5)', bracketHost)).toBe('B');
});
it('does not cross-match a number against text', () => {
const r = ev('=XLOOKUP(5;A2:A4;B2:B4)', textHost);
expect(isError(r) && r.code).toBe('#N/A');
});
it('no match and no if_not_found -> #N/A', () => {
const r = ev('=XLOOKUP("Traube";A2:A4;B2:B4)', textHost);
expect(isError(r) && r.code).toBe('#N/A');
});
it('no match falls back to if_not_found', () => {
expect(ev('=XLOOKUP("Traube";A2:A4;B2:B4;"?")', textHost)).toBe('?');
});
it('if_not_found is evaluated lazily (never touched on a match)', () => {
expect(ev('=XLOOKUP("Birne";A2:A4;B2:B4;1/0)', textHost)).toBe(2);
});
it('search_mode: 1 (default) finds the first match, -1 the last', () => {
expect(ev('=XLOOKUP("Birne";A2:A5;B2:B5)', textHost)).toBe(2);
expect(ev('=XLOOKUP("Birne";A2:A5;B2:B5;;0;-1)', textHost)).toBe(99);
});
it('match_mode -1/1: exact match still wins outright', () => {
expect(ev('=XLOOKUP(10;D2:D5;E2:E5;;-1)', bracketHost)).toBe('B');
expect(ev('=XLOOKUP(10;D2:D5;E2:E5;;1)', bracketHost)).toBe('B');
});
it('match_mode -1: nearest smaller', () => {
expect(ev('=XLOOKUP(15;D2:D5;E2:E5;;-1)', bracketHost)).toBe('B'); // 10
});
it('match_mode 1: nearest larger', () => {
expect(ev('=XLOOKUP(15;D2:D5;E2:E5;;1)', bracketHost)).toBe('C'); // 20
});
it('match_mode -1/1: no qualifying candidate -> #N/A (or if_not_found)', () => {
expect(isError(ev('=XLOOKUP(-5;D2:D5;E2:E5;;-1)', bracketHost))).toBe(true);
expect(ev('=XLOOKUP(-5;D2:D5;E2:E5;"keine";-1)', bracketHost)).toBe('keine');
});
it('unsupported match_mode (2) or search_mode (2/-2) -> #ERR', () => {
expect(isError(ev('=XLOOKUP("x";A2:A4;B2:B4;;2)', textHost))).toBe(true);
expect(isError(ev('=XLOOKUP("x";A2:A4;B2:B4;;0;2)', textHost))).toBe(true);
});
it('mismatched lookup/return range sizes -> #VALUE!', () => {
const r = ev('=XLOOKUP("x";A2:A4;B2:B5)', textHost);
expect(isError(r) && r.code).toBe('#VALUE!');
});
it('wrong number of arguments -> #ERR', () => {
expect(isError(ev('=XLOOKUP("x";A2:A4)', textHost))).toBe(true);
expect(isError(ev('=XLOOKUP("x";A2:A4;B2:B4;1;2;3;4)', textHost))).toBe(true);
});
it('works with single cells (a 1-cell "range") for lookup/return', () => {
expect(ev('=XLOOKUP("Apfel";A2;B2)', textHost)).toBe(1.5);
});
});
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);
});
});
@@ -0,0 +1,18 @@
import { describe, it, expect } from 'vitest';
import { FUNCTIONS, BUILTIN_SIGNATURES } from '../functions';
describe('BUILTIN_SIGNATURES', () => {
it('has exactly one entry per registered built-in function, names matching', () => {
const registered = Object.keys(FUNCTIONS).sort();
const signatures = BUILTIN_SIGNATURES.map((s) => s.name).sort();
// A future new built-in without a matching hint entry fails loudly here
// instead of just silently being missing from the autocomplete dropdown.
expect(signatures).toEqual(registered);
});
it('every hint actually starts with its own name', () => {
for (const s of BUILTIN_SIGNATURES) {
expect(s.hint.startsWith(s.name)).toBe(true);
}
});
});
@@ -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']);
});
});
+124
View File
@@ -0,0 +1,124 @@
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',
'AMP',
'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('reads $-locked cell references as one IDENT', () => {
expect(types('$A$1 A$1 $A1')).toEqual(['IDENT', 'IDENT', 'IDENT', 'EOF']);
expect(values('$A$1 A$1 $A1')).toEqual(['$A$1', 'A$1', '$A1', '']);
});
it('reads identifiers with non-ASCII letters (umlauts etc.) as a single IDENT', () => {
// a table/sheet name routinely contains "ä"/"ö"/"ü"/"ß" in this
// German-first app -- these must lex as ordinary IDENT characters, not
// throw, so an unquoted qualifier like "Übersicht::A1" actually works.
expect(types('Übersicht::B4')).toEqual(['IDENT', 'DBLCOLON', 'IDENT', 'EOF']);
expect(values('Übersicht')).toEqual(['Übersicht', '']);
expect(values('Größe_Prämie')).toEqual(['Größe_Prämie', '']);
});
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();
});
it('tokenizes assignment/equality and comparison operators', () => {
expect(types('= < > <= >= <>')).toEqual([
'EQUALS',
'LT',
'GT',
'LTE',
'GTE',
'NEQ',
'EOF',
]);
});
it('prefers the two-character comparison operators over their single-character prefix', () => {
expect(types('A1<=B2')).toEqual(['IDENT', 'LTE', 'IDENT', 'EOF']);
expect(types('A1>=B2')).toEqual(['IDENT', 'GTE', 'IDENT', 'EOF']);
expect(types('A1<>B2')).toEqual(['IDENT', 'NEQ', 'IDENT', 'EOF']);
expect(types('A1<B2')).toEqual(['IDENT', 'LT', 'IDENT', 'EOF']);
expect(types('A1>B2')).toEqual(['IDENT', 'GT', 'IDENT', 'EOF']);
});
it('tokenizes every error code as one ERRORLIT token', () => {
expect(types('#REF')).toEqual(['ERRORLIT', 'EOF']);
expect(values('#REF')).toEqual(['#REF', '']);
expect(types('#DIV/0!')).toEqual(['ERRORLIT', 'EOF']);
expect(types('#NAME?')).toEqual(['ERRORLIT', 'EOF']);
expect(types('#ERR')).toEqual(['ERRORLIT', 'EOF']);
expect(types('#CIRC')).toEqual(['ERRORLIT', 'EOF']);
expect(types('#VALUE!')).toEqual(['ERRORLIT', 'EOF']);
expect(types('#N/A')).toEqual(['ERRORLIT', 'EOF']);
});
it('an ERRORLIT is followed by whatever comes next, not swallowed into it', () => {
expect(types('1+#REF')).toEqual(['NUMBER', 'PLUS', 'ERRORLIT', 'EOF']);
// "#REF" matches greedily -- a trailing "!" some might expect (Excel
// spells it "#REF!") is its own BANG token, not part of the literal;
// this engine's own error code is "#REF" without "!" (see types.ts).
expect(types('#REF!')).toEqual(['ERRORLIT', 'BANG', 'EOF']);
});
it('throws on an unrecognized "#..." sequence', () => {
expect(() => tokenize('#FOO')).toThrow();
});
});
@@ -0,0 +1,456 @@
import { describe, it, expect } from 'vitest';
import { parseMacroBlock } from '../parser';
import { runMacroActions } from '../evaluator';
import { buildFunctionIndex, expandMacroBlock, type FunctionDef } from '../userFunctions';
import {
FormulaError,
type CellRef,
type CellValue,
type EngineHost,
type MacroNetworkHost,
type MacroWriteHost,
} from '../types';
type NamedRangeShape = { qualifier: string; sheetQualifier?: string; start: { col: number; row: number }; end: { col: number; row: number } };
/** Same shape as evaluator.spec.ts's makeHost, but resolves any of a small
* fixed set of table names ("Umsatz"/"Kosten" -- matching this file's test
* formulas, chosen to read naturally rather than the generic "S1"/"S2")
* case-insensitively to themselves, since a macro test cares about which
* name got resolved, not an opaque id. */
function makeHost(
data: Record<string, Record<string, CellValue>>,
home = 'Umsatz',
namedRanges: Record<string, NamedRangeShape> = {},
): EngineHost {
const known = ['Umsatz', 'Kosten'];
const idFor = (name: string): string | null => known.find((t) => t.toLowerCase() === name.trim().toLowerCase()) ?? null;
return {
homeTableId: home,
resolveTableName: idFor,
resolveNamedRange: (name) => namedRanges[name.trim().toLowerCase()] ?? null,
tableSize: () => ({ cols: 10, rows: 2000 }),
getComputed(tableId, col, row) {
const cell = String.fromCharCode(65 + col) + (row + 1);
const v = data[tableId]?.[cell];
return v === undefined ? null : v;
},
};
}
type RecordedCall = { op: string; args: unknown[] };
/** Records every call instead of mutating anything -- these tests are about
* the interpreter dispatching the right action with the right resolved
* arguments, not about a real document (that's store/document.spec.ts's
* job, against the real store primitives). */
function makeWriteHost(): { host: MacroWriteHost; calls: RecordedCall[] } {
const calls: RecordedCall[] = [];
const record =
(op: string) =>
(...args: unknown[]): void => {
calls.push({ op, args });
};
const host: MacroWriteHost = {
setValue: record('setValue'),
insertRow: record('insertRow'),
deleteRow: record('deleteRow'),
insertCol: record('insertCol'),
deleteCol: record('deleteCol'),
addTable: record('addTable'),
renameTable: record('renameTable'),
deleteTable: record('deleteTable'),
addSheet: record('addSheet'),
renameSheet: record('renameSheet'),
deleteSheet: record('deleteSheet'),
};
return { host, calls };
}
/** A mock `MacroNetworkHost` -- `responses` maps "METHOD url" to either a
* value to resolve with (wrapped as `{ok:true, value}`) or an Error to
* reject with (`{ok:false, error}`); an un-mapped request rejects with a
* loud "no mock for this request" error so a test that forgets to mock a
* call fails clearly instead of hanging. */
function makeNetHost(
responses: Record<string, { ok: true; value: unknown } | { ok: false; error: Error }>,
): { host: MacroNetworkHost; calls: { method: string; url: string; body?: string }[] } {
const calls: { method: string; url: string; body?: string }[] = [];
const host: MacroNetworkHost = {
async request(method, url, body) {
calls.push({ method, url, body });
const key = `${method} ${url}`;
const mocked = responses[key];
if (!mocked) throw new Error(`no mock for ${key}`);
if (!mocked.ok) throw mocked.error;
return mocked.value;
},
};
return { host, calls };
}
const emptyNetHost: MacroNetworkHost = { request: () => Promise.reject(new Error('no network calls expected')) };
const runMacro = (
src: string,
host: EngineHost,
writeHost: MacroWriteHost,
netHost: MacroNetworkHost = emptyNetHost,
) => runMacroActions(parseMacroBlock(src), host, writeHost, netHost);
describe('runMacroActions -- SETVALUE target resolution', () => {
it('a literal cell target writes exactly one cell', async () => {
const { host: writeHost, calls } = makeWriteHost();
await runMacro('SETVALUE(Umsatz::B2; 42)', makeHost({}), writeHost);
expect(calls).toEqual([{ op: 'setValue', args: [[{ tableId: 'Umsatz', col: 1, row: 1 }], '42'] }]);
});
it('a range target broadcasts the same value to every cell in one call', async () => {
const { host: writeHost, calls } = makeWriteHost();
await runMacro('SETVALUE(Umsatz::A1:A3; "x")', makeHost({}), writeHost);
expect(calls).toHaveLength(1);
const [cells, text] = calls[0].args as [CellRef[], string];
expect(cells).toEqual([
{ tableId: 'Umsatz', col: 0, row: 0 },
{ tableId: 'Umsatz', col: 0, row: 1 },
{ tableId: 'Umsatz', col: 0, row: 2 },
]);
expect(text).toBe('x');
});
it('a target resolved through a FOR loop variable (substitute() through env)', async () => {
const { host: writeHost, calls } = makeWriteHost();
await runMacro('FOR c IN Umsatz::A1:A2 DO SETVALUE(c; 0) END', makeHost({}), writeHost);
expect(calls).toEqual([
{ op: 'setValue', args: [[{ tableId: 'Umsatz', col: 0, row: 0 }], '0'] },
{ op: 'setValue', args: [[{ tableId: 'Umsatz', col: 0, row: 1 }], '0'] },
]);
});
it('a value that starts with "=" is stringified as-is -- the store applies it as a live formula, no special-casing needed here', async () => {
const { host: writeHost, calls } = makeWriteHost();
await runMacro('SETVALUE(Umsatz::A1; "=SUM(" & "A2:A3" & ")")', makeHost({}), writeHost);
expect(calls[0].args[1]).toBe('=SUM(A2:A3)');
});
it('rejects an unqualified target at runtime (the static save-time check in document.ts is the primary guard; this is the interpreter\'s own defensive backstop)', async () => {
const { host: writeHost } = makeWriteHost();
await expect(runMacro('SETVALUE(A1; 1)', makeHost({}), writeHost)).rejects.toThrow(/Tabelle nennen/);
});
it('rejects a computed (non-reference) target', async () => {
const { host: writeHost } = makeWriteHost();
await expect(runMacro('SETVALUE(1+1; 1)', makeHost({}), writeHost)).rejects.toThrow(/Zell- oder Bereichsbezug/);
});
it('rejects an unknown table name', async () => {
const { host: writeHost } = makeWriteHost();
await expect(runMacro('SETVALUE(Nirgendwo::A1; 1)', makeHost({}), writeHost)).rejects.toThrow(/unbekannte Tabelle/);
});
});
describe('runMacroActions -- RETURN / fall-through', () => {
it('RETURN short-circuits and its value is returned', async () => {
const { host: writeHost } = makeWriteHost();
await expect(runMacro('SETVALUE(Umsatz::A1; 1) RETURN "fertig"', makeHost({}), writeHost)).resolves.toBe('fertig');
});
it('a RETURN inside an IF stops the remaining statements from running', async () => {
const { host: writeHost, calls } = makeWriteHost();
await runMacro('IF 1 = 1 THEN RETURN "abgebrochen" END SETVALUE(Umsatz::A1; 1)', makeHost({}), writeHost);
expect(calls).toHaveLength(0);
});
it('falling off the end without RETURN yields null, not an error (unlike an ordinary block-kind custom function)', async () => {
const { host: writeHost } = makeWriteHost();
await expect(runMacro('SETVALUE(Umsatz::A1; 1)', makeHost({}), writeHost)).resolves.toBeNull();
});
});
describe('runMacroActions -- the full action vocabulary dispatches correctly', () => {
it('every non-SETVALUE action evaluates its (string/number) args and calls the matching writeHost method', async () => {
const { host: writeHost, calls } = makeWriteHost();
await runMacro(
[
'INSERTROW("Umsatz"; 3)',
'DELETEROW("Umsatz"; 4)',
'INSERTCOL("Umsatz"; 1)',
'DELETECOL("Umsatz"; 2)',
'ADDTABLE("Blatt1"; "Neu")',
'RENAMETABLE("Umsatz"; "Verkauf")',
'DELETETABLE("Alt")',
'ADDSHEET("Blatt2")',
'RENAMESHEET("Blatt1"; "Analyse")',
'DELETESHEET("Blatt3")',
].join(' '),
makeHost({}),
writeHost,
);
expect(calls).toEqual([
{ op: 'insertRow', args: ['Umsatz', 3] },
{ op: 'deleteRow', args: ['Umsatz', 4] },
{ op: 'insertCol', args: ['Umsatz', 1] },
{ op: 'deleteCol', args: ['Umsatz', 2] },
{ op: 'addTable', args: ['Blatt1', 'Neu'] },
{ op: 'renameTable', args: ['Umsatz', 'Verkauf'] },
{ op: 'deleteTable', args: ['Alt'] },
{ op: 'addSheet', args: ['Blatt2'] },
{ op: 'renameSheet', args: ['Blatt1', 'Analyse'] },
{ op: 'deleteSheet', args: ['Blatt3'] },
]);
});
it('a string argument built via & concatenation and a local variable both work, since every non-target argument goes through the ordinary expression pipeline', async () => {
const { host: writeHost, calls } = makeWriteHost();
await runMacro('n = "Kopie" ADDTABLE("Blatt1"; "Neu " & n)', makeHost({}), writeHost);
expect(calls).toEqual([{ op: 'addTable', args: ['Blatt1', 'Neu Kopie'] }]);
});
it('an unknown action name throws', async () => {
const { host: writeHost } = makeWriteHost();
await expect(runMacro('FOOBAR(1)', makeHost({}), writeHost)).rejects.toThrow(/Unbekannte Aktion/);
});
it('a wrong argument count throws', async () => {
const { host: writeHost } = makeWriteHost();
await expect(runMacro('ADDSHEET("x"; "y")', makeHost({}), writeHost)).rejects.toThrow(/erwartet 1 Argument/);
});
});
describe('runMacroActions -- macro-specific step budget', () => {
it('a loop that executes many structural actions is stopped by the (smaller) macro budget, not the ordinary 10,000-step FOR budget', async () => {
const { host: writeHost } = makeWriteHost();
// 2,000 iterations, each one action call -- exceeds MAX_MACRO_STEPS
// (1,000) but would be nowhere near the ordinary MAX_LOOP_STEPS
// (10,000) an ordinary block-kind function loop gets.
await expect(
runMacro('FOR c IN Umsatz::A1:A2000 DO INSERTROW("Umsatz"; 1) END', makeHost({}), writeHost),
).rejects.toThrow(/zu viele Schritte|zu viele Zellen/);
});
it('sequential GET calls in a loop also count toward the budget', async () => {
const { host: writeHost } = makeWriteHost();
const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value: 1 } });
await expect(
runMacro('FOR c IN Umsatz::A1:A2000 DO r = GET("https://x") END', makeHost({}), writeHost, netHost),
).rejects.toThrow(/zu viele Schritte|zu viele Zellen/);
});
});
describe('runMacroActions -- nested custom-function calls inside a macro expression', () => {
it('a SETVALUE value can call a custom function, resolved via expandMacroBlock before running', async () => {
const { host: writeHost, calls } = makeWriteHost();
const defs: FunctionDef[] = [
{ id: 'fn-1', name: 'MARGE', params: ['a', 'b'], body: 'a - b', kind: 'expression' },
];
const index = buildFunctionIndex(defs);
const block = expandMacroBlock(parseMacroBlock('SETVALUE(Umsatz::A1; MARGE(10; 3))'), index);
await runMacroActions(block, makeHost({}), writeHost, emptyNetHost);
expect(calls).toEqual([{ op: 'setValue', args: [[{ tableId: 'Umsatz', col: 0, row: 0 }], '7'] }]);
});
it('an ActionNode can never appear inside a nested custom-function body -- parseFunctionBlock (always allowWrites:false) rejects write syntax even if a macro author tried to sneak one in', () => {
expect(() => {
const defs: FunctionDef[] = [
{ id: 'fn-1', name: 'BAD', params: [], body: 'SETVALUE(A1;1) RETURN 1', kind: 'block' },
];
const index = buildFunctionIndex(defs);
expandMacroBlock(parseMacroBlock('SETVALUE(Umsatz::A1; BAD())'), index);
}).toThrow();
});
});
describe('runMacroActions -- error propagation', () => {
it('an error value in a value expression aborts with a clear message, not a silently-written "#ERR" text', async () => {
const { host: writeHost } = makeWriteHost();
await expect(runMacro('SETVALUE(Umsatz::A1; 1/0)', makeHost({}), writeHost)).rejects.toThrow(
new FormulaError('#DIV/0!').code,
);
});
});
describe('runMacroActions -- GET/POST', () => {
it('GET binds the parsed JSON response, usable as a subsequent SETVALUE value', async () => {
const { host: writeHost, calls } = makeWriteHost();
const { host: netHost, calls: netCalls } = makeNetHost({
'GET https://api/kunde': { ok: true, value: { name: 'Nord' } },
});
await runMacro('r = GET("https://api/kunde") SETVALUE(Umsatz::A1; JSONGET(r; "name"))', makeHost({}), writeHost, netHost);
expect(netCalls).toEqual([{ method: 'GET', url: 'https://api/kunde', body: undefined }]);
expect(calls).toEqual([{ op: 'setValue', args: [[{ tableId: 'Umsatz', col: 0, row: 0 }], 'Nord'] }]);
});
it('POST sends a body and binds the parsed JSON response', async () => {
const { host: writeHost, calls } = makeWriteHost();
const { host: netHost, calls: netCalls } = makeNetHost({
'POST https://api/kunden': { ok: true, value: { id: 42 } },
});
await runMacro(
'r = POST("https://api/kunden"; "{\\"name\\":\\"Nord\\"}") SETVALUE(Umsatz::A1; JSONGET(r; "id"))',
makeHost({}),
writeHost,
netHost,
);
expect(netCalls).toEqual([{ method: 'POST', url: 'https://api/kunden', body: '{"name":"Nord"}' }]);
expect(calls).toEqual([{ op: 'setValue', args: [[{ tableId: 'Umsatz', col: 0, row: 0 }], '42'] }]);
});
it('GET/POST used anywhere other than the whole RHS of a plain assignment fall through to the ordinary path and become #NAME? -- they need a real await, so (unlike JSONGET below) they cannot be embedded in an arbitrary expression', async () => {
const { host: writeHost } = makeWriteHost();
// nested inside an expression -- SETVALUE's error wrapping only
// surfaces the FormulaError *code*, not its detail text, so #NAME?
// (not the literal words "unknown function") is what's actually visible
await expect(
runMacro('SETVALUE(Umsatz::A1; 1 + GET("https://x"))', makeHost({}), writeHost),
).rejects.toThrow(/NAME/);
// inside an IF condition -- an error in a condition resolves the whole
// macro run with that FormulaError as its "value" (same as any other
// ifstmt-condition error, existing behaviour predating this feature),
// it does not reject the promise
const result = await runMacro('IF GET("https://x") = 1 THEN SETVALUE(Umsatz::A1; 1) END', makeHost({}), writeHost);
expect(result).toEqual(new FormulaError('#NAME?', 'unknown function GET'));
});
it('JSONGET, unlike GET/POST, is purely synchronous and works nested anywhere an expression can appear (evalScalar special-case, not just the assignment-RHS form)', async () => {
const { host: writeHost, calls } = makeWriteHost();
const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value: { name: 'Nord' } } });
await runMacro(
'r = GET("https://x") IF JSONGET(r; "name") = "Nord" THEN SETVALUE(Umsatz::A1; JSONGET(r; "name")) END',
makeHost({}),
writeHost,
netHost,
);
expect(calls).toEqual([{ op: 'setValue', args: [[{ tableId: 'Umsatz', col: 0, row: 0 }], 'Nord'] }]);
});
it('a network failure aborts the run cleanly with the failure message', async () => {
const { host: writeHost } = makeWriteHost();
const { host: netHost } = makeNetHost({
'GET https://down': { ok: false, error: new Error('GET https://down: HTTP 500 Internal Server Error') },
});
await expect(runMacro('r = GET("https://down")', makeHost({}), writeHost, netHost)).rejects.toThrow(/HTTP 500/);
});
it('a declined confirmation (surfaced by the host as a rejection) aborts without writing anything', async () => {
const { host: writeHost, calls } = makeWriteHost();
const { host: netHost } = makeNetHost({
'GET https://x': { ok: false, error: new Error("GET: Verbindung zu 'x' abgelehnt") },
});
await expect(
runMacro('r = GET("https://x") SETVALUE(Umsatz::A1; 1)', makeHost({}), writeHost, netHost),
).rejects.toThrow(/abgelehnt/);
expect(calls).toHaveLength(0);
});
});
describe('runMacroActions -- JSON value collapse rule (evalScalar\'s case "json")', () => {
const cases: Array<[unknown, CellValue]> = [
[null, null],
['Nord', 'Nord'],
[42, 42],
[true, 1],
[false, 0],
[{ a: 1 }, '{"a":1}'],
[[1, 2], '[1,2]'],
];
for (const [value, expected] of cases) {
it(`a JSON ${JSON.stringify(value)} used directly as a SETVALUE value collapses to ${JSON.stringify(expected)}`, async () => {
const { host: writeHost, calls } = makeWriteHost();
const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value } });
await runMacro('r = GET("https://x") SETVALUE(Umsatz::A1; r)', makeHost({}), writeHost, netHost);
expect(calls[0].args[1]).toBe(String(expected ?? ''));
});
}
});
describe('runMacroActions -- JSONGET path resolution', () => {
const data = { items: [{ name: 'Nord', tags: ['a', 'b'] }, { name: 'Süd' }], meta: { count: 2 } };
it('resolves a nested object path', async () => {
const { host: writeHost, calls } = makeWriteHost();
const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value: data } });
await runMacro('r = GET("https://x") SETVALUE(Umsatz::A1; JSONGET(r; "meta.count"))', makeHost({}), writeHost, netHost);
expect(calls[0].args[1]).toBe('2');
});
it('resolves an array index within the path', async () => {
const { host: writeHost, calls } = makeWriteHost();
const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value: data } });
await runMacro('r = GET("https://x") SETVALUE(Umsatz::A1; JSONGET(r; "items.0.name"))', makeHost({}), writeHost, netHost);
expect(calls[0].args[1]).toBe('Nord');
});
it('a nested array-of-strings path resolves too', async () => {
const { host: writeHost, calls } = makeWriteHost();
const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value: data } });
await runMacro('r = GET("https://x") SETVALUE(Umsatz::A1; JSONGET(r; "items.0.tags.1"))', makeHost({}), writeHost, netHost);
expect(calls[0].args[1]).toBe('b');
});
it('a missing path resolves to JSON null, not a throw', async () => {
const { host: writeHost, calls } = makeWriteHost();
const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value: data } });
await runMacro('r = GET("https://x") SETVALUE(Umsatz::A1; JSONGET(r; "items.9.name"))', makeHost({}), writeHost, netHost);
expect(calls[0].args[1]).toBe(''); // null collapses to '' via toText(), same as an empty cell
});
it('JSONGET on a non-JSON value throws a clear error when used as an assignment RHS', async () => {
const { host: writeHost } = makeWriteHost();
await expect(
runMacro('x = 5 y = JSONGET(x; "a")', makeHost({}), writeHost),
).rejects.toThrow(/JSON-Ergebnis/);
});
it('JSONGET on a non-JSON value nested in an expression degrades to a formula error (#VALUE!), same as any other evalScalar function misuse', async () => {
const { host: writeHost } = makeWriteHost();
await expect(
runMacro('x = 5 SETVALUE(Umsatz::A1; JSONGET(x; "a"))', makeHost({}), writeHost),
).rejects.toThrow(/VALUE/);
});
});
describe('runMacroActions -- FOR over a JSON array', () => {
it('iterates each element, usable via JSONGET', async () => {
const { host: writeHost, calls } = makeWriteHost();
const { host: netHost } = makeNetHost({
'GET https://x': { ok: true, value: [{ name: 'Nord' }, { name: 'Süd' }] },
});
await runMacro(
'r = GET("https://x") FOR item IN r DO SETVALUE(Umsatz::A1; JSONGET(item; "name")) END',
makeHost({}),
writeHost,
netHost,
);
expect(calls.map((c) => c.args[1])).toEqual(['Nord', 'Süd']);
});
it('FOR over a JSON object (not an array) throws a clear error', async () => {
const { host: writeHost } = makeWriteHost();
const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value: { a: 1 } } });
await expect(
runMacro('r = GET("https://x") FOR item IN r DO SETVALUE(Umsatz::A1; 1) END', makeHost({}), writeHost, netHost),
).rejects.toThrow(/Array/);
});
it('FOR over a JSON primitive throws the same clear error', async () => {
const { host: writeHost } = makeWriteHost();
const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value: 5 } });
await expect(
runMacro('r = GET("https://x") FOR item IN r DO SETVALUE(Umsatz::A1; 1) END', makeHost({}), writeHost, netHost),
).rejects.toThrow(/Array/);
});
it('a JSONGET(...) call used directly as the FOR source (not first bound to a variable) parses and runs -- found live: the shared FOR-source shape check did not originally know about this new source kind', async () => {
const { host: writeHost, calls } = makeWriteHost();
const { host: netHost } = makeNetHost({
'GET https://x': { ok: true, value: { items: [{ name: 'Nord' }, { name: 'Süd' }] } },
});
await runMacro(
'r = GET("https://x") FOR item IN JSONGET(r; "items") DO SETVALUE(Umsatz::A1; JSONGET(item; "name")) END',
makeHost({}),
writeHost,
netHost,
);
expect(calls.map((c) => c.args[1])).toEqual(['Nord', 'Süd']);
});
});
@@ -0,0 +1,491 @@
import { describe, it, expect } from 'vitest';
import {
parseFormula,
parseFunctionBlock,
parseMacroBlock,
collectRefs,
collectRefsInBlock,
ParseError,
type Node,
type Statement,
type BlockNode,
} 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 'errorliteral':
return n.code;
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.sheetQualifier ? n.sheetQualifier + '!' : ''}${n.qualifier ? n.qualifier + '!' : ''}cell(${n.col},${n.row})`;
case 'range':
return `${n.sheetQualifier ? n.sheetQualifier + '!' : ''}${n.qualifier ? n.qualifier + '!' : ''}range(${n.start.col},${n.start.row}:${n.end.col},${n.end.row})`;
case 'param':
return `param(${n.name})`;
case 'namedref':
return `namedref(${n.name})`;
case 'blockcall':
// never appears in freshly-parsed input (see parser.ts's comment on
// BlockCallNode) -- exhaustiveness only, no test exercises this.
return `blockcall(${n.label})`;
case 'omitted':
return 'omitted';
case 'json':
// never produced by the parser (see parser.ts's comment on
// JsonLiteralNode) -- exhaustiveness only, no test exercises this.
return `json(${JSON.stringify(n.value)})`;
}
}
const parse = (s: string) => sexpr(parseFormula(s));
const parse2 = (s: string, paramNames: readonly string[]) => sexpr(parseFormula(s, paramNames));
/** Compact rendering of a block AST (see parseFunctionBlock) for assertions. */
function sexprStmt(s: Statement): string {
switch (s.kind) {
case 'assign':
return `${s.name}=${sexpr(s.value)}`;
case 'return':
return `RETURN ${sexpr(s.value)}`;
case 'for':
return `FOR(${s.varName} IN ${sexpr(s.source)} DO ${sexprBlock(s.body)})`;
case 'ifstmt':
return `IF(${sexpr(s.cond)} THEN ${sexprBlock(s.then)}${s.else ? ` ELSE ${sexprBlock(s.else)}` : ''})`;
case 'action':
return `ACTION(${s.name};${s.args.map(sexpr).join(';')})`;
}
}
function sexprBlock(b: BlockNode): string {
return `{${b.statements.map(sexprStmt).join('; ')}}`;
}
const parseBlock = (s: string, paramNames: readonly string[]) =>
sexprBlock(parseFunctionBlock(s, paramNames));
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))',
);
});
it('& binds looser than + - but tighter than a comparison', () => {
expect(parse('="a"&1+2')).toBe('(& "a" (+ 1 2))');
expect(parse('=A1&B1="ab"')).toBe('(= (& cell(0,0) cell(1,0)) "ab")');
});
it('& is left-associative', () => {
expect(parse('="a"&"b"&"c"')).toBe('(& (& "a" "b") "c")');
});
});
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 $-locked (absolute/mixed) references to the same coords -- evaluation is positional', () => {
expect(parse('=$A$1')).toBe('cell(0,0)');
expect(parse('=A$1')).toBe('cell(0,0)');
expect(parse('=$A1')).toBe('cell(0,0)');
expect(parse('=$A$1:B$2')).toBe('range(0,0:1,1)');
});
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);
});
it('an empty argument slot parses as "omitted" -- leading, middle, trailing', () => {
expect(parse('=XLOOKUP(1;A1;B1;;-1)')).toBe(
'(XLOOKUP 1 cell(0,0) cell(1,0) omitted (- 1))',
);
expect(parse('=SUM(;5)')).toBe('(SUM omitted 5)');
expect(parse('=SUM(5;)')).toBe('(SUM 5 omitted)');
expect(parse('=SUM()')).toBe('(SUM )');
});
});
describe('parser whole-column / whole-row references', () => {
const range = (src: string) => parseFormula(src) as Extract<Node, { kind: 'range' }>;
it('B:B, A:C and $B:$B are whole columns', () => {
expect(range('B:B')).toMatchObject({ kind: 'range', whole: 'col', qualifier: null, start: { col: 1, row: 0 } });
expect(range('A:C')).toMatchObject({ whole: 'col', start: { col: 0 }, end: { col: 2 } });
expect(range('$B:$B')).toMatchObject({ whole: 'col', start: { col: 1 }, end: { col: 1 } });
expect(range('aa:ab')).toMatchObject({ whole: 'col', start: { col: 26 }, end: { col: 27 } });
});
it('1:1, 2:5 and $3:$3 are whole rows (1-based in the text)', () => {
expect(range('1:1')).toMatchObject({ kind: 'range', whole: 'row', start: { col: 0, row: 0 }, end: { row: 0 } });
expect(range('2:5')).toMatchObject({ whole: 'row', start: { row: 1 }, end: { row: 4 } });
expect(range('$3:$3')).toMatchObject({ whole: 'row', start: { row: 2 }, end: { row: 2 } });
});
it('take table and sheet qualifiers', () => {
expect(range('Umsatz!B:B')).toMatchObject({ qualifier: 'Umsatz', whole: 'col' });
expect(range('Umsatz::1:1')).toMatchObject({ qualifier: 'Umsatz', whole: 'row' });
expect(range("'Blatt 2'!Kosten::A:C")).toMatchObject({ sheetQualifier: 'Blatt 2', qualifier: 'Kosten', whole: 'col' });
});
it('work as function arguments and are collected as refs', () => {
const ast = parseFormula('SUM(B:B; 2:3) + 1');
expect(collectRefs(ast).map((r) => (r.kind === 'range' ? r.whole : r.kind))).toEqual(['col', 'row']);
});
it('reject mixed or invalid shapes', () => {
expect(() => parseFormula('A:1')).toThrow();
expect(() => parseFormula('0:0')).toThrow();
// a plain number still is one
expect(parseFormula('12')).toEqual({ kind: 'number', value: 12 });
});
});
describe('parser sheet qualifier -- "Blatt!Tabelle::Zelle"', () => {
it('parses the explicit 3-part form, bare and quoted', () => {
expect(parse('=Analyse!Umsatz::B2')).toBe('Analyse!Umsatz!cell(1,1)');
expect(parse("='Meine Analyse'!Umsatz::B2")).toBe('Meine Analyse!Umsatz!cell(1,1)');
expect(parse("=Analyse!'Kosten Q3'::B2:E2")).toBe('Analyse!Kosten Q3!range(1,1:4,1)');
expect(parse("='Meine Analyse'!'Kosten Q3'::B2")).toBe('Meine Analyse!Kosten Q3!cell(1,1)');
});
it('is unaffected in a range end / function call', () => {
expect(parse('=SUM(Analyse!Umsatz::B2:E2; 1)')).toBe('(SUM Analyse!Umsatz!range(1,1:4,1) 1)');
});
it('a bare "X!Y" (no further "::") keeps its original, table-only meaning -- fully backward-compatible', () => {
// no "::" ever follows -> "Analyse" is read as the TABLE, "A1" the cell,
// exactly as parseFormula() behaved before the sheet qualifier existed.
expect(parse('=Analyse!A1')).toBe('Analyse!cell(0,0)');
expect(parse('=Umsatz!B2')).toBe(parse('=Umsatz!B2')); // unchanged from the existing suite
});
it('"X::Y" (no leading sheet) is still the plain table qualifier, never a sheet one', () => {
expect(parse('=Umsatz::B2')).toBe('Umsatz!cell(1,1)');
});
it('"X!Y!Z" (a second "!" instead of "::") does not form a sheet qualifier -- "!" only introduces a sheet name when followed by NAME "::"', () => {
// no "::" anywhere ahead of "Y" -> "X!" is read as an ordinary (old-style)
// table qualifier, leaving "Y" to be parsed as the cell -- which it isn't
// shaped like (no digits) -> ParseError, same as e.g. "=X!Y" always was.
expect(() => parseFormula('=X!Y!Z1')).toThrow(ParseError);
});
it('rejects a malformed 3-part reference (missing cell after "::")', () => {
expect(() => parseFormula('=Analyse!Umsatz::')).toThrow(ParseError);
});
});
describe('parseFormula(src, paramNames) -- user-defined function bodies', () => {
it('a name in paramNames parses as a param reference, case-insensitively', () => {
expect(parse2('=revenue - cost', ['revenue', 'cost'])).toBe('(- param(revenue) param(cost))');
expect(parse2('=REVENUE * 2', ['revenue'])).toBe('(* param(REVENUE) 2)');
});
it('a bare name not in paramNames still behaves exactly as without the list', () => {
expect(() => parseFormula('=revenue - cost', ['revenue'])).toThrow(ParseError);
});
it('without paramNames, a bare non-cell identifier is a named-range reference, not a parse error', () => {
// same string, no param list -- "revenue" doesn't look like a cell, but
// this is now an ordinary top-level formula, where that's a named-range
// candidate (see the "named ranges" describe block below) rather than a
// ParseError -- unlike inside a function body (the case just above),
// where the fallback is deliberately off.
expect(parse('=revenue')).toBe('namedref(revenue)');
});
it('a param can mix with real cell references in the same formula', () => {
expect(parse2('=x + A1', ['x'])).toBe('(+ param(x) cell(0,0))');
});
it('a param is never qualified or part of a range -- those still need a real cell', () => {
expect(() => parseFormula('=Umsatz!x', ['x'])).toThrow(ParseError);
expect(() => parseFormula('=x:B2', ['x'])).toThrow(ParseError);
});
});
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);
});
});
describe('parser named-range references (NamedRefNode)', () => {
it('a bare, unqualified, non-cell identifier parses as a namedref', () => {
expect(parse('=foo + 1')).toBe('(+ namedref(foo) 1)');
expect(parse('=Umsatz_Q1')).toBe('namedref(Umsatz_Q1)');
});
it('mixes with real cell/table references and functions', () => {
expect(parse('=SUM(Umsatz_Q1; A1; Umsatz!B2)')).toBe(
'(SUM namedref(Umsatz_Q1) cell(0,0) Umsatz!cell(1,1))',
);
});
it('a qualified non-cell identifier is still a ParseError -- a named range is always unqualified', () => {
expect(() => parseFormula('=Umsatz!foo')).toThrow(ParseError);
expect(() => parseFormula("='Kosten Q3'!foo")).toThrow(ParseError);
});
it('never a range-start: "Name:B2" is not supported', () => {
expect(() => parseFormula('=foo:B2')).toThrow(ParseError);
});
it('inside a function body, a bare non-cell/non-param name is still a hard ParseError (no named-range fallback there)', () => {
expect(() => parseFormula('=foo', ['bar'])).toThrow(ParseError);
expect(() => parseFunctionBlock('RETURN foo', ['bar'])).toThrow(ParseError);
});
});
describe('parser error literals (ErrorLiteralNode)', () => {
it('every error code parses as a leaf', () => {
expect(parse('=#REF')).toBe('#REF');
expect(parse('=#DIV/0!')).toBe('#DIV/0!');
expect(parse('=#NAME?')).toBe('#NAME?');
expect(parse('=#ERR')).toBe('#ERR');
expect(parse('=#CIRC')).toBe('#CIRC');
expect(parse('=#VALUE!')).toBe('#VALUE!');
expect(parse('=#N/A')).toBe('#N/A');
});
it('composes with operators/functions like any other literal', () => {
expect(parse('=1+#REF')).toBe('(+ 1 #REF)');
expect(parse('=IF(1=0;#N/A;5)')).toBe('(IF (= 1 0) #N/A 5)');
});
});
describe('parser comparison operators (available in every formula, not just block IF conditions)', () => {
it('parses all six comparison operators', () => {
expect(parse('=A1=B1')).toBe('(= cell(0,0) cell(1,0))');
expect(parse('=A1<>B1')).toBe('(<> cell(0,0) cell(1,0))');
expect(parse('=A1>B1')).toBe('(> cell(0,0) cell(1,0))');
expect(parse('=A1<B1')).toBe('(< cell(0,0) cell(1,0))');
expect(parse('=A1>=B1')).toBe('(>= cell(0,0) cell(1,0))');
expect(parse('=A1<=B1')).toBe('(<= cell(0,0) cell(1,0))');
});
it('binds looser than + -', () => {
expect(parse('=A1+1=B1')).toBe('(= (+ cell(0,0) 1) cell(1,0))');
});
it('is not chainable -- "a < b < c" is a parse error', () => {
expect(() => parseFormula('=1<2<3')).toThrow(ParseError);
});
});
describe('parseFunctionBlock -- statement grammar for "block" function bodies', () => {
it('a single FOR loop, accumulator pattern (DO ... END, one statement)', () => {
const src = 'ergebnis = 1 FOR x IN bereich DO ergebnis = ergebnis * x END RETURN ergebnis';
expect(parseBlock(src, ['bereich'])).toBe(
'{ergebnis=1; FOR(x IN param(bereich) DO {ergebnis=(* param(ergebnis) param(x))}); RETURN param(ergebnis)}',
);
});
it('a FOR body with multiple statements', () => {
const src = 'total = 0 FOR x IN bereich DO y = x * 2 total = total + y END RETURN total';
expect(parseBlock(src, ['bereich'])).toBe(
'{total=0; FOR(x IN param(bereich) DO {y=(* param(x) 2); total=(+ param(total) param(y))}); RETURN param(total)}',
);
});
it('multiple independent FOR loops in sequence, sharing an accumulator', () => {
const src =
'total = 0 FOR a IN r1 DO total = total + a END FOR b IN r2 DO total = total + b END RETURN total';
expect(parseBlock(src, ['r1', 'r2'])).toBe(
'{total=0; FOR(a IN param(r1) DO {total=(+ param(total) param(a))}); ' +
'FOR(b IN param(r2) DO {total=(+ param(total) param(b))}); RETURN param(total)}',
);
});
it('IF ... THEN ... END, no ELSE', () => {
const src = 'x = 1 IF x > 0 THEN x = x + 1 END RETURN x';
expect(parseBlock(src, [])).toBe('{x=1; IF((> param(x) 0) THEN {x=(+ param(x) 1)}); RETURN param(x)}');
});
it('IF ... THEN ... ELSE ... END', () => {
const src = 'IF x > 0 THEN y = 1 ELSE y = -1 END RETURN y';
expect(parseBlock(src, ['x'])).toBe(
'{IF((> param(x) 0) THEN {y=1} ELSE {y=(- 1)}); RETURN param(y)}',
);
});
it('IF nested inside a FOR body, with an early RETURN -- "if kann in jedem block vorkommen"', () => {
const src =
'total = 0 FOR x IN bereich DO IF x > 100 THEN RETURN x END total = total + x END RETURN total';
expect(parseBlock(src, ['bereich'])).toBe(
'{total=0; FOR(x IN param(bereich) DO {IF((> param(x) 100) THEN {RETURN param(x)}); ' +
'total=(+ param(total) param(x))}); RETURN param(total)}',
);
});
it('a FOR loop nested inside another FOR loop\'s body', () => {
const src = 'total = 0 FOR a IN r1 DO FOR b IN r2 DO total = total + a * b END END RETURN total';
expect(parseBlock(src, ['r1', 'r2'])).toBe(
'{total=0; FOR(a IN param(r1) DO {FOR(b IN param(r2) DO ' +
'{total=(+ param(total) (* param(a) param(b)))})}); RETURN param(total)}',
);
});
it('RETURN may appear anywhere, not just at the end, and may be any expression', () => {
expect(parseBlock('RETURN 42', [])).toBe('{RETURN 42}');
expect(parseBlock('x = 5 RETURN x * 2', [])).toBe('{x=5; RETURN (* param(x) 2)}');
});
it('a plain assignment can reference an already-declared variable', () => {
expect(parseBlock('a = 1 b = a + 1 RETURN b', [])).toBe('{a=1; b=(+ param(a) 1); RETURN param(b)}');
});
it('the FOR source may itself be a cell/range reference, not just a parameter', () => {
expect(parseBlock('total = 0 FOR x IN A1:A5 DO total = total + x END RETURN total', [])).toBe(
'{total=0; FOR(x IN range(0,0:0,4) DO {total=(+ param(total) param(x))}); RETURN param(total)}',
);
});
it('a block without any RETURN still parses -- that is a save-time/runtime concern (blockAlwaysReturns, userFunctions.ts), not a parse error', () => {
expect(parseBlock('x = 1', [])).toBe('{x=1}');
});
it('throws on FOR without a matching END', () => {
expect(() => parseFunctionBlock('FOR x IN bereich DO y = x RETURN y', ['bereich'])).toThrow(
ParseError,
);
});
it('throws on FOR without a matching DO', () => {
expect(() => parseFunctionBlock('FOR x IN bereich RETURN x', ['bereich'])).toThrow(ParseError);
});
it('throws on FOR without IN', () => {
expect(() => parseFunctionBlock('FOR x bereich DO y = x END RETURN y', ['bereich'])).toThrow(
ParseError,
);
});
it('throws on IF without THEN', () => {
expect(() => parseFunctionBlock('IF x > 0 x = 1 END RETURN x', ['x'])).toThrow(ParseError);
});
it('throws on IF without a matching END', () => {
expect(() => parseFunctionBlock('IF x > 0 THEN x = 1 RETURN x', ['x'])).toThrow(ParseError);
});
it('throws when an assignment is missing "="', () => {
expect(() => parseFunctionBlock('x 1 RETURN x', [])).toThrow(ParseError);
});
it('a name is only usable after its own declaring statement (self-reference in its own initializer fails)', () => {
expect(() => parseFunctionBlock('x = x + 1 RETURN x', [])).toThrow(ParseError);
});
});
describe('parseMacroBlock -- write-action statements (ActionNode), macro-only', () => {
const parseMacro = (s: string) => sexprBlock(parseMacroBlock(s));
it('a bare NAME(args) at statement position parses as an action call', () => {
expect(parseMacro('SETVALUE(Umsatz::A1; 42)')).toBe(
'{ACTION(SETVALUE;Umsatz!cell(0,0);42)}',
);
});
it('an action with no arguments', () => {
expect(parseMacro('ADDSHEET("Neu")')).toBe('{ACTION(ADDSHEET;"Neu")}');
});
it('";"-separated arguments, including an omitted middle slot', () => {
expect(parseMacro('SETVALUE(Umsatz::A1;;)')).toBe('{ACTION(SETVALUE;Umsatz!cell(0,0);omitted;omitted)}');
});
it('an action call composes with local variables and & concatenation', () => {
expect(parseMacro('n = "Blatt" ADDSHEET("Neu " & n)')).toBe(
'{n="Blatt"; ACTION(ADDSHEET;(& "Neu " param(n)))}',
);
});
it('an action call can appear inside FOR/IF bodies', () => {
expect(
parseMacro('FOR c IN Umsatz::A1:A3 DO SETVALUE(c; 0) END'),
).toBe('{FOR(c IN Umsatz!range(0,0:0,2) DO {ACTION(SETVALUE;param(c);0)})}');
});
it('assignment (IDENT "=" expr) still wins over an action call -- disambiguated by the token after the name', () => {
expect(parseMacro('x = 1 RETURN x')).toBe('{x=1; RETURN param(x)}');
});
it('a bare NAME(args) at statement position is only recognised in a macro body, never a plain function body', () => {
// Same source: a function body has no `allowWrites`, so "SETVALUE(...)"
// at statement start falls through to parseAssignment(), which then
// fails immediately for lack of "=".
expect(() => parseFunctionBlock('SETVALUE(A1; 1) RETURN 1', [])).toThrow(ParseError);
});
it('rejects an action call missing its closing ")"', () => {
expect(() => parseMacroBlock('SETVALUE(Umsatz::A1; 1')).toThrow(ParseError);
});
});
describe('collectRefsInBlock -- reference walk used by the macro save-time qualifier check', () => {
it('collects a SETVALUE action\'s reference arguments too', () => {
const block = parseMacroBlock('SETVALUE(Umsatz::A1; Kosten::B2)');
const refs = collectRefsInBlock(block);
expect(refs.map((r) => r.kind)).toEqual(['cell', 'cell']);
expect(refs.map((r) => (r as { qualifier: string | null }).qualifier)).toEqual(['Umsatz', 'Kosten']);
});
it('collects refs from every action argument, not just the first', () => {
const block = parseMacroBlock('INSERTROW("Umsatz"; 1) SETVALUE(Umsatz::A1; Kosten::B2 + 1)');
const refs = collectRefsInBlock(block);
// INSERTROW's args are plain strings/numbers -- no refs from that
// statement; SETVALUE contributes both its target and its value's ref.
expect(refs).toHaveLength(2);
});
});
+123
View File
@@ -0,0 +1,123 @@
import { describe, it, expect } from 'vitest';
import { parseA1, toA1, cycleReferenceLocks, currentIdentifierAtCaret } from '../refs';
describe('parseA1 / toA1: $-locked (absolute/mixed) references', () => {
it('parseA1 reports which axes are $-locked', () => {
expect(parseA1('A1')).toEqual({ col: 0, row: 0, colAbs: false, rowAbs: false });
expect(parseA1('$A$1')).toEqual({ col: 0, row: 0, colAbs: true, rowAbs: true });
expect(parseA1('A$1')).toEqual({ col: 0, row: 0, colAbs: false, rowAbs: true });
expect(parseA1('$A1')).toEqual({ col: 0, row: 0, colAbs: true, rowAbs: false });
});
it('toA1 round-trips the lock markers', () => {
expect(toA1(0, 0)).toBe('A1');
expect(toA1(0, 0, true, true)).toBe('$A$1');
expect(toA1(0, 0, false, true)).toBe('A$1');
expect(toA1(0, 0, true, false)).toBe('$A1');
});
});
describe('cycleReferenceLocks (F4) -- collapsed caret', () => {
it('cycles relative -> absolute -> row-locked -> col-locked -> relative', () => {
let f = '=A1';
let pos = 3;
for (const expected of ['=$A$1', '=A$1', '=$A1', '=A1']) {
const res = cycleReferenceLocks(f, pos, pos)!;
expect(res.text).toBe(expected);
expect(res.start).toBe(res.end); // a collapsed caret in -> a collapsed caret out
f = res.text;
pos = res.end;
}
});
it('toggles only the reference the caret sits in/against', () => {
const res = cycleReferenceLocks('=A1+B2', 2, 2)!; // caret inside "A1"
expect(res.text).toBe('=$A$1+B2');
const res2 = cycleReferenceLocks('=A1+B2', 6, 6)!; // caret inside "B2"
expect(res2.text).toBe('=A1+$B$2');
});
it('picks the right side of a range depending on the caret', () => {
const left = cycleReferenceLocks('=A1:B2', 2, 2)!;
expect(left.text).toBe('=$A$1:B2');
const right = cycleReferenceLocks('=A1:B2', 6, 6)!;
expect(right.text).toBe('=A1:$B$2');
});
it('returns null when the caret is not on a reference, or the text is not a formula', () => {
expect(cycleReferenceLocks('=SUM(1;2)', 5, 5)).toBeNull();
expect(cycleReferenceLocks('42', 1, 1)).toBeNull();
});
it('never toggles a table-name qualifier, even one shaped like a cell', () => {
// "AB12" reads as a cell reference on its own, but here it's a table name
const formula = '=AB12!A1';
expect(cycleReferenceLocks(formula, 2, 2)).toBeNull(); // caret inside "AB12"
expect(cycleReferenceLocks(formula, 7, 7)?.text).toBe('=AB12!$A$1'); // caret inside the actual cell
});
});
describe('cycleReferenceLocks (F4) -- a real text selection spanning several refs', () => {
it('toggles every reference the selection overlaps, each from its own state', () => {
// select "A1+$B2" inside "=A1+$B2+C3" -- C3 sits outside the selection.
// A1 (relative) -> $A$1; $B2 (already col-locked-only, the cycle's last
// stop before relative) -> B2, its next step back to fully relative.
const formula = '=A1+$B2+C3';
const start = formula.indexOf('A1');
const end = formula.indexOf('$B2') + '$B2'.length;
const res = cycleReferenceLocks(formula, start, end)!;
expect(res.text).toBe('=$A$1+B2+C3');
});
it('a selection covering the whole formula adjusts all of it in one press', () => {
const res = cycleReferenceLocks('=A1+B2', 1, 7)!;
expect(res.text).toBe('=$A$1+$B$2');
});
it('re-cycling the same (now resized) selection keeps advancing every reference', () => {
let f = '=A1+B2';
let sel = cycleReferenceLocks(f, 1, 7)!; // -> $A$1+$B$2
f = sel.text;
sel = cycleReferenceLocks(f, sel.start, sel.end)!; // -> A$1+B$2
expect(sel.text).toBe('=A$1+B$2');
});
it('a selection touching no reference returns null', () => {
expect(cycleReferenceLocks('=SUM(1;2)', 4, 8)).toBeNull();
});
});
describe('currentIdentifierAtCaret -- function-name autocomplete detection', () => {
it('right after "=", right after an operator, right after "(" or ";"', () => {
expect(currentIdentifierAtCaret('=S', 2)).toEqual({ text: 'S', start: 1 });
expect(currentIdentifierAtCaret('=A1+S', 5)).toEqual({ text: 'S', start: 4 });
expect(currentIdentifierAtCaret('=SUM(A', 6)).toEqual({ text: 'A', start: 5 });
expect(currentIdentifierAtCaret('=SUM(1;A', 8)).toEqual({ text: 'A', start: 7 });
});
it('null outside a formula (no leading "=")', () => {
expect(currentIdentifierAtCaret('SUM', 3)).toBeNull();
});
it('null once the word looks like a cell reference (has a digit)', () => {
expect(currentIdentifierAtCaret('=SUM(A1', 7)).toBeNull();
expect(currentIdentifierAtCaret('=A1', 3)).toBeNull();
});
it('null right after an already-open, complete function call', () => {
expect(currentIdentifierAtCaret('=SUM(', 5)).toBeNull();
});
it('null once the caret has moved past the word (trailing whitespace)', () => {
expect(currentIdentifierAtCaret('=SUM ', 5)).toBeNull();
});
it('only looks at text up to the caret, ignoring what follows it', () => {
// caret between "SUM" and "X" in "SUMX" -- same result as if "X" weren't there
expect(currentIdentifierAtCaret('=SUMX', 4)).toEqual({ text: 'SUM', start: 1 });
});
it('null on a malformed prefix (unterminated string) rather than throwing', () => {
expect(currentIdentifierAtCaret('=SUM("x', 7)).toBeNull();
});
});
@@ -0,0 +1,506 @@
import { describe, it, expect } from 'vitest';
import { parseFormula, parseMacroBlock } from '../parser';
import {
buildFunctionIndex,
collectCalledFunctionNames,
collectCalledFunctionNamesInBlock,
expandMacroBlock,
expandUserFunctions,
type FunctionDef,
} from '../userFunctions';
import { Workbook, type TableModel } from '../workbook';
import { FormulaError, isError, keyOf } from '../types';
// same small A1 helper used by workbook.spec.ts
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 };
}
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);
};
const def = (
partial: Partial<FunctionDef> & Pick<FunctionDef, 'id' | 'name' | 'params' | 'body'>,
): FunctionDef => ({ kind: 'expression', ...partial });
describe('expandUserFunctions (AST-level)', () => {
it('substitutes parameters with the caller\'s argument subtrees', () => {
const index = buildFunctionIndex([def({ id: 'f1', name: 'MARGE', params: ['x', 'y'], body: 'x - y' })]);
const ast = parseFormula('=MARGE(A1; 5)');
const expanded = expandUserFunctions(ast, index);
expect(expanded).toEqual({
kind: 'binary',
op: '-',
left: { kind: 'cell', qualifier: null, col: 0, row: 0 },
right: { kind: 'number', value: 5 },
});
});
it('an unknown name (built-in or not) is left untouched for evaluate() to turn into #NAME?', () => {
const index = buildFunctionIndex([]);
const ast = parseFormula('=FOO(1;2)');
expect(expandUserFunctions(ast, index)).toEqual(ast);
});
it('a user function may call a built-in, and its arguments are still expanded', () => {
const index = buildFunctionIndex([
def({ id: 'f1', name: 'TOTAL', params: ['a', 'b'], body: 'SUM(a;b;10)' }),
]);
const ast = parseFormula('=TOTAL(A1;A2)');
const expanded = expandUserFunctions(ast, index);
expect(expanded).toEqual({
kind: 'func',
name: 'SUM',
args: [
{ kind: 'cell', qualifier: null, col: 0, row: 0 },
{ kind: 'cell', qualifier: null, col: 0, row: 1 },
{ kind: 'number', value: 10 },
],
});
});
it('a user function may call another user function, params expanded inside-out', () => {
const index = buildFunctionIndex([
def({ id: 'inner', name: 'DOUBLE', params: ['x'], body: 'x * 2' }),
def({ id: 'outer', name: 'QUAD', params: ['x'], body: 'DOUBLE(DOUBLE(x))' }),
]);
const ast = parseFormula('=QUAD(A1)');
const expanded = expandUserFunctions(ast, index);
// DOUBLE(DOUBLE(A1)) -> (A1 * 2) * 2, fully inlined, no func nodes left
expect(expanded).toEqual({
kind: 'binary',
op: '*',
left: {
kind: 'binary',
op: '*',
left: { kind: 'cell', qualifier: null, col: 0, row: 0 },
right: { kind: 'number', value: 2 },
},
right: { kind: 'number', value: 2 },
});
});
it('nested functions with colliding parameter names resolve to the correct scope', () => {
// OUTER(x) = INNER(x) + 1, INNER(x) = x * 100 -- both call their param "x"
const index = buildFunctionIndex([
def({ id: 'inner', name: 'INNER', params: ['x'], body: 'x * 100' }),
def({ id: 'outer', name: 'OUTER', params: ['x'], body: 'INNER(x) + 1' }),
]);
const ast = parseFormula('=OUTER(A1)');
const expanded = expandUserFunctions(ast, index);
expect(expanded).toEqual({
kind: 'binary',
op: '+',
left: {
kind: 'binary',
op: '*',
left: { kind: 'cell', qualifier: null, col: 0, row: 0 },
right: { kind: 'number', value: 100 },
},
right: { kind: 'number', value: 1 },
});
});
it('throws on arity mismatch', () => {
const index = buildFunctionIndex([def({ id: 'f1', name: 'MARGE', params: ['x', 'y'], body: 'x - y' })]);
expect(() => expandUserFunctions(parseFormula('=MARGE(A1)'), index)).toThrow();
});
it('throws once expansion depth exceeds the recursion guard (defence in depth for a cycle)', () => {
// A calls B, B calls A -- should never be saved (store-level cycle check),
// but the engine must still degrade to an error, not a stack overflow.
const index = buildFunctionIndex([
def({ id: 'a', name: 'A', params: ['x'], body: 'B(x)' }),
def({ id: 'b', name: 'B', params: ['x'], body: 'A(x)' }),
]);
expect(() => expandUserFunctions(parseFormula('=A(1)'), index)).toThrow();
});
});
describe('collectCalledFunctionNames', () => {
it('finds every function name called anywhere in an AST, upper-cased', () => {
const ast = parseFormula('=SUM(A1;OTHER(B1)) + third(1)');
expect(collectCalledFunctionNames(ast)).toEqual(new Set(['SUM', 'OTHER', 'THIRD']));
});
});
describe('expandMacroBlock / collectCalledFunctionNamesInBlock: the new "action" statement arm', () => {
it('expandMacroBlock resolves a custom-function call nested inside an action argument', () => {
const index = buildFunctionIndex([def({ id: 'f1', name: 'MARGE', params: ['x', 'y'], body: 'x - y' })]);
const block = parseMacroBlock('SETVALUE(Umsatz::A1; MARGE(10; 3))');
const expanded = expandMacroBlock(block, index);
const stmt = expanded.statements[0];
expect(stmt.kind).toBe('action');
if (stmt.kind !== 'action') throw new Error('unreachable');
// MARGE(10;3) was inlined (kind:'expression') into its (10 - 3) body --
// no more 'func' node calling MARGE survives in the expanded tree.
expect(collectCalledFunctionNames(stmt.args[1])).toEqual(new Set());
});
it('expandMacroBlock is a no-op fast path when no custom functions are defined', () => {
const block = parseMacroBlock('SETVALUE(Umsatz::A1; 1)');
expect(expandMacroBlock(block, buildFunctionIndex([]))).toBe(block); // same reference, not just equal
});
it('collectCalledFunctionNamesInBlock finds a function called inside an action\'s arguments', () => {
const block = parseMacroBlock('SETVALUE(Umsatz::A1; MARGE(10; 3)) INSERTROW("Umsatz"; COUNT(Umsatz::A1:A3))');
expect(collectCalledFunctionNamesInBlock(block)).toEqual(new Set(['MARGE', 'COUNT']));
});
});
describe('Workbook integration: user-defined functions end-to-end', () => {
function fixture(functionDefs: FunctionDef[] = []): Workbook {
const umsatz = sheet('s-umsatz', 'Umsatz', {
B2: '1200', C2: '800',
B9: '=MARGE(B2;C2)',
});
return new Workbook([umsatz], functionDefs);
}
it('a call is evaluated using the function body', () => {
const wb = fixture([def({ id: 'f1', name: 'MARGE', params: ['revenue', 'cost'], body: '(revenue - cost) / revenue' })]);
expect(val(wb, 's-umsatz', 'B9')).toBeCloseTo((1200 - 800) / 1200, 10);
});
it('recalculates through the function body when a precedent changes', () => {
const wb = fixture([def({ id: 'f1', name: 'MARGE', params: ['revenue', 'cost'], body: '(revenue - cost) / revenue' })]);
wb.setCell('s-umsatz', 1, 1, '2000'); // B2: 1200 -> 2000
expect(val(wb, 's-umsatz', 'B9')).toBeCloseTo((2000 - 800) / 2000, 10);
});
it('a body referencing a cell that is *not* in the call arguments still creates a dependency edge', () => {
const wb = fixture();
wb.tables[0].inputs.set('4,0', '3'); // E1 = 3, a "rate" cell not passed as an argument
wb.tables[0].inputs.set('1,8', '=WITHRATE(B2)'); // B9 = WITHRATE(B2)
const functionDefs = [def({ id: 'f1', name: 'WITHRATE', params: ['x'], body: 'x * E1' })];
const wb2 = new Workbook(wb.tables, functionDefs);
expect(val(wb2, 's-umsatz', 'B9')).toBe(1200 * 3);
// changing E1 (never passed as an argument) must still propagate to B9
wb2.setCell('s-umsatz', 4, 0, '5');
expect(val(wb2, 's-umsatz', 'B9')).toBe(1200 * 5);
});
it('unknown function name (never defined) yields #NAME?, exactly like an unknown built-in', () => {
const wb = fixture(); // no functionDefs at all -- MARGE stays unresolved
expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#NAME?');
});
it('wrong argument count yields #ERR', () => {
const wb = fixture([def({ id: 'f1', name: 'MARGE', params: ['revenue', 'cost', 'extra'], body: 'revenue' })]);
expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#ERR');
});
it('a built-in name can never be shadowed -- SUM keeps behaving like SUM', () => {
// even if a document somehow ended up with a "SUM" custom function
// (the store rejects this at save time), the engine still prefers the
// built-in -- buildFunctionIndex()/expandUserFunctions() never override it
// because functions.ts's own registry is consulted at evaluation time and
// wins whenever expandUserFunctions leaves a func node in place... so make
// sure the custom "SUM" is simply never invoked from a real formula that
// hits the built-in path first is out of scope here; this test instead
// documents that a *user* SUM does get inlined when explicitly defined,
// since the store -- not the engine -- is what forbids the collision.
const index = buildFunctionIndex([def({ id: 'x', name: 'SUM', params: ['a'], body: 'a + 1' })]);
const expanded = expandUserFunctions(parseFormula('=SUM(A1)'), index);
expect(expanded).toEqual({
kind: 'binary',
op: '+',
left: { kind: 'cell', qualifier: null, col: 0, row: 0 },
right: { kind: 'number', value: 1 },
});
});
it('errors inside a function body propagate to dependents', () => {
const wb = fixture([def({ id: 'f1', name: 'MARGE', params: ['revenue', 'cost'], body: 'revenue / 0' })]);
expect(isError(val(wb, 's-umsatz', 'B9'))).toBe(true);
expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#DIV/0!');
});
it('a parameter can be a range, as long as it lands in a function-call argument position', () => {
// SPANNE(bereich) = MAX(bereich) - MIN(bereich) -- "bereich" (ParamNode)
// gets substituted with the caller's RangeNode, splicing a normal
// MAX(Umsatz!B2:E2) / MIN(Umsatz!B2:E2) call into the expanded AST.
const umsatz = sheet('s-umsatz', 'Umsatz', {
B2: '1200', C2: '1450', D2: '1600', E2: '1720',
B9: '=SPANNE(B2:E2)',
});
const wb = new Workbook(
[umsatz],
[def({ id: 'f1', name: 'SPANNE', params: ['bereich'], body: 'MAX(bereich) - MIN(bereich)' })],
);
expect(val(wb, 's-umsatz', 'B9')).toBe(1720 - 1200);
// recalculates when a cell *inside* the range argument changes
wb.setCell('s-umsatz', 1, 1, '1000'); // B2: 1200 -> 1000
expect(val(wb, 's-umsatz', 'B9')).toBe(1720 - 1000);
});
it('a range parameter used outside a function-call argument position is #VALUE!, same as a bare range in an ordinary formula', () => {
const umsatz = sheet('s-umsatz', 'Umsatz', {
B2: '1200', C2: '1450',
B9: '=BAD(B2:C2)',
});
const wb = new Workbook([umsatz], [def({ id: 'f1', name: 'BAD', params: ['bereich'], body: 'bereich + 1' })]);
expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#VALUE!');
});
it('debugOrder places a function-body-only precedent before its dependent', () => {
const umsatz = sheet('s-umsatz', 'Umsatz', {
B2: '1200', E1: '3',
B9: '=WITHRATE(B2)',
});
const wb = new Workbook([umsatz], [def({ id: 'f1', name: 'WITHRATE', params: ['x'], body: 'x * E1' })]);
const { order, circular } = wb.debugOrder([keyOf('s-umsatz', 4, 0)]); // dirty: E1
expect(circular).toEqual([]);
expect(order).toContain(keyOf('s-umsatz', 1, 8)); // B9 depends on E1 too
});
});
const productBlock = {
params: ['bereich'],
kind: 'block' as const,
body: 'ergebnis = 1 FOR x IN bereich DO ergebnis = ergebnis * x END RETURN ergebnis',
};
describe('expandUserFunctions (AST-level): kind "block" produces a BlockCallNode', () => {
it('wraps a block-kind call in a BlockCallNode carrying args/paramNames/label', () => {
const index = buildFunctionIndex([def({ id: 'f1', name: 'PRODUCT2', ...productBlock })]);
const expanded = expandUserFunctions(parseFormula('=PRODUCT2(A1:A3)'), index);
expect(expanded.kind).toBe('blockcall');
if (expanded.kind !== 'blockcall') throw new Error('unreachable');
expect(expanded.label).toBe('PRODUCT2');
expect(expanded.paramNames).toEqual(['bereich']);
expect(expanded.args).toEqual([
{
kind: 'range',
qualifier: null,
start: { col: 0, row: 0, colAbs: false, rowAbs: false },
end: { col: 0, row: 2, colAbs: false, rowAbs: false },
},
]);
});
it('resolves a nested call to another (expression-kind) function inside the block before evaluation', () => {
const index = buildFunctionIndex([
def({ id: 'd1', name: 'DOUBLE', params: ['n'], body: 'n * 2' }),
def({
id: 's1',
name: 'SUMDOUBLED',
params: ['bereich'],
kind: 'block',
body: 'total = 0 FOR x IN bereich DO total = total + DOUBLE(x) END RETURN total',
}),
]);
const expanded = expandUserFunctions(parseFormula('=SUMDOUBLED(A1:A2)'), index);
if (expanded.kind !== 'blockcall') throw new Error('unreachable');
const forStmt = expanded.block.statements[1];
if (forStmt.kind !== 'for') throw new Error('expected a for statement');
const assignInBody = forStmt.body.statements[0];
if (assignInBody.kind !== 'assign') throw new Error('expected an assignment');
// DOUBLE(x) has already been inlined to "x * 2" -- no FuncNode calling
// "DOUBLE" remains anywhere in the block
expect(assignInBody.value).toEqual({
kind: 'binary',
op: '+',
left: { kind: 'param', name: 'total' },
right: {
kind: 'binary',
op: '*',
left: { kind: 'param', name: 'x' },
right: { kind: 'number', value: 2 },
},
});
});
it('arity mismatch is still checked for block-kind functions', () => {
const index = buildFunctionIndex([def({ id: 'f1', name: 'PRODUCT2', ...productBlock })]);
expect(() => expandUserFunctions(parseFormula('=PRODUCT2(A1;A2)'), index)).toThrow();
});
});
describe('Workbook integration: block-kind (FOR/IF/RETURN "code") user-defined functions', () => {
it('evaluates a PRODUCT-style reduction over a range correctly', () => {
const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '2', A2: '3', A3: '4', B9: '=PRODUCT2(A1:A3)' });
const wb = new Workbook([umsatz], [def({ id: 'f1', name: 'PRODUCT2', ...productBlock })]);
expect(val(wb, 's-umsatz', 'B9')).toBe(24);
});
it('recalculates when a cell inside the passed range changes', () => {
const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '2', A2: '3', A3: '4', B9: '=PRODUCT2(A1:A3)' });
const wb = new Workbook([umsatz], [def({ id: 'f1', name: 'PRODUCT2', ...productBlock })]);
wb.setCell('s-umsatz', 0, 0, '10'); // A1: 2 -> 10
expect(val(wb, 's-umsatz', 'B9')).toBe(10 * 3 * 4);
});
it('a caller may pass a named range as the argument -- the FOR loop iterates it exactly like an ordinary range', () => {
const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '2', A2: '3', A3: '4', B9: '=PRODUCT2(Werte)' });
const wb = new Workbook(
[umsatz],
[def({ id: 'f1', name: 'PRODUCT2', ...productBlock })],
[{ id: 'n1', name: 'Werte', tableId: 's-umsatz', start: { col: 0, row: 0 }, end: { col: 0, row: 2 } }],
);
expect(val(wb, 's-umsatz', 'B9')).toBe(24);
wb.setCell('s-umsatz', 0, 0, '10'); // A1: 2 -> 10 -- still tracked as a precedent via the named range
expect(val(wb, 's-umsatz', 'B9')).toBe(10 * 3 * 4);
});
it('a FOR body with multiple statements runs all of them, every iteration', () => {
const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '2', A2: '3', B9: '=DOUBLESUM(A1:A2)' });
const doubleSum = def({
id: 'f1',
name: 'DOUBLESUM',
params: ['bereich'],
kind: 'block',
body: 'total = 0 FOR x IN bereich DO y = x * 2 total = total + y END RETURN total',
});
const wb = new Workbook([umsatz], [doubleSum]);
expect(val(wb, 's-umsatz', 'B9')).toBe(2 * 2 + 3 * 2);
});
it('a FOR loop nested inside another FOR loop evaluates correctly, not just parses', () => {
const umsatz = sheet('s-umsatz', 'Umsatz', {
A1: '2',
A2: '3',
B1: '10',
B2: '20',
B9: '=OUTERPROD(A1:A2; B1:B2)',
});
const outerProd = def({
id: 'f1',
name: 'OUTERPROD',
params: ['r1', 'r2'],
kind: 'block',
body: 'total = 0 FOR a IN r1 DO FOR b IN r2 DO total = total + a * b END END RETURN total',
});
const wb = new Workbook([umsatz], [outerProd]);
expect(val(wb, 's-umsatz', 'B9')).toBe(2 * 10 + 2 * 20 + 3 * 10 + 3 * 20);
});
it('IF/ELSE branches on the real, current cell value', () => {
const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '5', B9: '=SIGN2(A1)' });
const sign2 = def({
id: 'f1',
name: 'SIGN2',
params: ['x'],
kind: 'block',
body: 'IF x > 0 THEN RETURN 1 ELSE RETURN -1 END',
});
const wb = new Workbook([umsatz], [sign2]);
expect(val(wb, 's-umsatz', 'B9')).toBe(1);
wb.setCell('s-umsatz', 0, 0, '-5');
expect(val(wb, 's-umsatz', 'B9')).toBe(-1);
});
it('comparison operators work inside an IF condition', () => {
const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '10', A2: '10', B9: '=EQ2(A1;A2)' });
const eq2 = def({
id: 'f1',
name: 'EQ2',
params: ['a', 'b'],
kind: 'block',
body: 'IF a = b THEN RETURN 1 ELSE RETURN 0 END',
});
const wb = new Workbook([umsatz], [eq2]);
expect(val(wb, 's-umsatz', 'B9')).toBe(1);
wb.setCell('s-umsatz', 0, 1, '11'); // A2: 10 -> 11
expect(val(wb, 's-umsatz', 'B9')).toBe(0);
});
it('AND/OR compose a compound condition inside an IF, same as any other function call', () => {
const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '5', B9: '=INRANGE(A1)' });
const inRange = def({
id: 'f1',
name: 'INRANGE',
params: ['x'],
kind: 'block',
body: 'IF AND(x > 0; x < 10) THEN RETURN 1 ELSE RETURN 0 END',
});
const wb = new Workbook([umsatz], [inRange]);
expect(val(wb, 's-umsatz', 'B9')).toBe(1);
wb.setCell('s-umsatz', 0, 0, '20'); // A1: 5 -> 20, out of range
expect(val(wb, 's-umsatz', 'B9')).toBe(0);
});
it('an early RETURN nested inside an IF inside a FOR stops the loop -- finds the first match, never touches later cells', () => {
// A3 is the first cell > 100; A4 must never affect the result
const umsatz = sheet('s-umsatz', 'Umsatz', {
A1: '1',
A2: '2',
A3: '999',
A4: '4',
B9: '=FIRSTOVER(A1:A4; 100)',
});
const firstOver = def({
id: 'f1',
name: 'FIRSTOVER',
params: ['bereich', 'schwelle'],
kind: 'block',
body: 'FOR x IN bereich DO IF x > schwelle THEN RETURN x END END RETURN 0',
});
const wb = new Workbook([umsatz], [firstOver]);
expect(val(wb, 's-umsatz', 'B9')).toBe(999);
// changing the never-reached A4 doesn't change the (already-found) result
wb.setCell('s-umsatz', 0, 3, '99999');
expect(val(wb, 's-umsatz', 'B9')).toBe(999);
});
it('a cell referenced only in a not-taken IF branch is still a tracked dependency', () => {
const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '5', A2: '1', A3: '2', B9: '=BRANCHY(A1)' });
const branchy = def({
id: 'f1',
name: 'BRANCHY',
params: ['x'],
kind: 'block',
body: 'IF x > 0 THEN RETURN A2 ELSE RETURN A3 END',
});
const wb = new Workbook([umsatz], [branchy]);
expect(val(wb, 's-umsatz', 'B9')).toBe(1); // A1=5>0 -> takes the A2 branch
wb.setCell('s-umsatz', 0, 0, '-5'); // flips to the A3 branch
expect(val(wb, 's-umsatz', 'B9')).toBe(2);
});
it('exceeding the loop-step cap degrades to #ERR at the cell, not a crash', () => {
const umsatz = sheet('s-umsatz', 'Umsatz', { B9: '=PRODUCT2(A1:A20000)' });
const wb = new Workbook([umsatz], [def({ id: 'f1', name: 'PRODUCT2', ...productBlock })]);
expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#ERR');
});
it('an invalid FOR source degrades to #ERR at the cell, not a crash', () => {
const umsatz = sheet('s-umsatz', 'Umsatz', { B9: '=BAD(5)' });
const wb = new Workbook(
[umsatz],
[
def({
id: 'f1',
name: 'BAD',
params: ['x'],
kind: 'block',
body: 'total = 0 FOR y IN x + 1 DO total = total + y END RETURN total',
}),
],
);
expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#ERR');
});
it('a function that never reaches RETURN degrades to #ERR, not a crash', () => {
const umsatz = sheet('s-umsatz', 'Umsatz', { B9: '=NORETURN(1)' });
const noReturn = def({ id: 'f1', name: 'NORETURN', params: ['x'], kind: 'block', body: 'y = x + 1' });
const wb = new Workbook([umsatz], [noReturn]);
expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#ERR');
});
});
@@ -0,0 +1,447 @@
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);
});
});