Add user-defined functions, editable exclusively via a new UI

Document-wide, named formula functions (e.g. MARGE(revenue; cost) =
(revenue - cost) / revenue) callable from any cell like a built-in
(=MARGE(Umsatz!B2; Umsatz!C2)). Created, edited and deleted only through
a new "𝑓 Funktionen" toolbar button -> modal (list + shared edit form),
never via JSON handedit.

Engine: a call is inlined into the caller's AST at parse time
(src/engine/userFunctions.ts's expandUserFunctions), so evaluator.ts and
graph.ts need no changes -- dependencies on cells referenced only inside
a function body (not in the call) are picked up for free. parser.ts
gains an optional paramNames argument to parseFormula() producing a new
ParamNode leaf, used only when parsing a function body.

Store: addCustomFunction/updateCustomFunction/deleteCustomFunction with
inline (not toast) validation -- name/parameter shape, no collision with
a built-in or another function, the body parses, and a definition-time
cycle check across the whole call graph. Renaming/deleting a function
mirrors table rename/delete: existing calls are not rewritten, they
become #NAME? on the next rebuild. Fully undo/redo-able and part of the
JSON document (customFunctions: [], migrated for v1 and older v2 docs).

178 tests passing (32 new), vue-tsc and eslint clean. Manually verified
end-to-end in the running app via Playwright: define/call/recalculate,
rename -> #NAME?, a closing cycle rejected inline at save, undo restores
the function library.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
wkh
2026-09-02 15:37:04 +02:00
co-authored by Claude Sonnet 5
parent fb5915d5be
commit c3fde283c8
16 changed files with 1196 additions and 24 deletions
+134
View File
@@ -677,6 +677,128 @@ describe('fill handle (drag to copy/fill)', () => {
});
});
describe('custom functions (document-level library)', () => {
it('addCustomFunction stores the definition and a call evaluates through it', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
setCell(s, name, 'A1', '1200');
setCell(s, name, 'A2', '800');
const result = s.addCustomFunction('MARGE', ['revenue', 'cost'], '(revenue - cost) / revenue');
expect(typeof result).toBe('string'); // the new function's id, not { error }
expect(s.doc.customFunctions).toHaveLength(1);
expect(s.doc.customFunctions[0]).toMatchObject({ name: 'MARGE', params: ['revenue', 'cost'] });
setCell(s, name, 'A3', '=MARGE(A1;A2)');
expect(val(s, name, 'A3')).toBeCloseTo((1200 - 800) / 1200, 10);
// recalculates when a precedent changes
setCell(s, name, 'A1', '2000');
expect(val(s, name, 'A3')).toBeCloseTo((2000 - 800) / 2000, 10);
});
it('rejects a name colliding with a built-in function', () => {
const s = fresh();
const result = s.addCustomFunction('SUM', ['x'], 'x') as { error: string };
expect(result.error).toBeTruthy();
expect(s.doc.customFunctions).toHaveLength(0);
});
it('rejects a name colliding with another custom function (case-insensitive)', () => {
const s = fresh();
s.addCustomFunction('Marge', ['x'], 'x');
const result = s.addCustomFunction('MARGE', ['y'], 'y') as { error: string };
expect(result.error).toBeTruthy();
expect(s.doc.customFunctions).toHaveLength(1);
});
it('rejects a name or parameter that looks like a cell address', () => {
const s = fresh();
expect((s.addCustomFunction('A1', ['x'], 'x') as { error: string }).error).toBeTruthy();
expect((s.addCustomFunction('OK', ['B2'], 'B2') as { error: string }).error).toBeTruthy();
expect(s.doc.customFunctions).toHaveLength(0);
});
it('rejects duplicate parameter names', () => {
const s = fresh();
const result = s.addCustomFunction('F', ['x', 'x'], 'x') as { error: string };
expect(result.error).toBeTruthy();
});
it('rejects a body that fails to parse', () => {
const s = fresh();
const result = s.addCustomFunction('F', ['x'], 'x +') as { error: string };
expect(result.error).toBeTruthy();
});
it('rejects a direct self-reference cycle (A calls A)', () => {
const s = fresh();
const result = s.addCustomFunction('A', ['x'], 'A(x) + 1') as { error: string };
expect(result.error).toBeTruthy();
expect(s.doc.customFunctions).toHaveLength(0);
});
it('rejects a mutual cycle (A calls B, B calls A) at the point it would close', () => {
const s = fresh();
const a = s.addCustomFunction('A', ['x'], 'x') as string;
expect(typeof a).toBe('string');
// B calling A is fine so far -- no cycle yet
const b = s.addCustomFunction('B', ['x'], 'A(x)') as string;
expect(typeof b).toBe('string');
// now redefine A to call B -- this closes the cycle A -> B -> A
const result = s.updateCustomFunction(a, 'A', ['x'], 'B(x)') as { error: string } | undefined;
expect(result?.error).toBeTruthy();
// rejected -- A's body is unchanged
expect(s.doc.customFunctions.find((f) => f.id === a)!.body).toBe('x');
});
it('updateCustomFunction changes name/params/body and existing calls re-resolve', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
const fnId = s.addCustomFunction('MARGE', ['x', 'y'], 'x - y') as string;
setCell(s, name, 'A1', '=MARGE(10;3)');
expect(val(s, name, 'A1')).toBe(7);
s.updateCustomFunction(fnId, 'MARGE', ['x', 'y'], 'x + y');
expect(val(s, name, 'A1')).toBe(13);
});
it('deleteCustomFunction removes the definition; existing calls become #NAME?', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
const fnId = s.addCustomFunction('MARGE', ['x', 'y'], 'x - y') as string;
setCell(s, name, 'A1', '=MARGE(10;3)');
expect(val(s, name, 'A1')).toBe(7);
s.deleteCustomFunction(fnId);
expect(s.doc.customFunctions).toHaveLength(0);
expect(isError(val(s, name, 'A1'))).toBe(true);
});
it('undo/redo covers a custom-function change', () => {
vi.useFakeTimers();
try {
const s = fresh();
expect(s.canUndo.value).toBe(false);
s.addCustomFunction('MARGE', ['x', 'y'], 'x - y');
vi.advanceTimersByTime(600);
expect(s.doc.customFunctions).toHaveLength(1);
s.undo();
expect(s.doc.customFunctions).toHaveLength(0);
s.redo();
expect(s.doc.customFunctions).toHaveLength(1);
expect(s.doc.customFunctions[0].name).toBe('MARGE');
} finally {
vi.runOnlyPendingTimers();
vi.useRealTimers();
}
});
});
describe('undo / redo', () => {
// rapid-fire changes (a resize drag, or several store calls in one
// synchronous action) coalesce into a single undo step -- tests that want
@@ -1023,6 +1145,9 @@ describe('full-fidelity JSON export — backgrounds, formats, sheet settings', (
s.addToSelection(umsatzId, 2, 2);
s.addToSelection(umsatzId, 4, 5);
// a custom function
s.addCustomFunction('MARGE', ['revenue', 'cost'], '(revenue - cost) / revenue');
return { analyse, umsatzId, chartId, txtId };
}
@@ -1086,6 +1211,14 @@ describe('full-fidelity JSON export — backgrounds, formats, sheet settings', (
// multi-range selection
expect(doc.selection.ranges).toHaveLength(2);
// custom function
expect(doc.customFunctions).toHaveLength(1);
expect(doc.customFunctions[0]).toMatchObject({
name: 'MARGE',
params: ['revenue', 'cost'],
body: '(revenue - cost) / revenue',
});
});
it('export -> import is lossless for the fully-loaded document', () => {
@@ -1124,6 +1257,7 @@ describe('v1 -> v2 migration', () => {
expect(v2.version).toBe(2);
expect(v2.sheets).toHaveLength(2);
expect(v2.sheets[0].objects[0]).toMatchObject({ kind: 'table', name: 'Umsatz' });
expect(v2.customFunctions).toEqual([]); // the feature didn't exist in v1
// cross-sheet formula still resolves after loading it
const store = useDocument();
store.replaceDocument(v2);