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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user