Makros: Engine- und Store-Fundament (Zellen/Zeilen/Spalten/Tabellen/Blätter per Skript)
Erster von zwei Commits (UI folgt separat): neue, explizit ausgelöste Makro-Sprache — dieselbe block-mode-Grammatik wie bei "Mit Schleife"- Funktionen (FOR/IF/RETURN), plus ein neues Schreib-Vokabular (SETVALUE, INSERTROW/DELETEROW/INSERTCOL/DELETECOL, ADDTABLE/RENAMETABLE/DELETETABLE, ADDSHEET/RENAMESHEET/DELETESHEET), adressiert mit der bestehenden Blatt!Tabelle::Zelle-Syntax. Bewusst außerhalb der normalen Neuberechnung: ein Makro läuft nie aus einer Zellformel heraus, sondern nur explizit getriggert (store.runMacro), und mutiert ausschließlich über die bereits vorhandenen Store-Primitiven (setCellInput, insertRow, addTable, addSheet, …) — Workbook/EngineHost/ DependencyGraph bleiben unverändert rein lesend. Ein ganzer synchroner Makro-Lauf wird automatisch zu einem einzigen Undo-Schritt. 50 neue Tests (Engine + Store), inkl. eines gezielten Regressionstests für die zentrale Korrektheits-Fallgrube: eine strukturelle Aktion mitten im Lauf ersetzt die Workbook-Instanz komplett, jeder Host-Zugriff muss daher frisch aufgelöst werden statt eine Referenz zu cachen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
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 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 };
|
||||
}
|
||||
|
||||
const runMacro = (src: string, host: EngineHost, writeHost: MacroWriteHost) =>
|
||||
runMacroActions(parseMacroBlock(src), host, writeHost);
|
||||
|
||||
describe('runMacroActions -- SETVALUE target resolution', () => {
|
||||
it('a literal cell target writes exactly one cell', () => {
|
||||
const { host: writeHost, calls } = makeWriteHost();
|
||||
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', () => {
|
||||
const { host: writeHost, calls } = makeWriteHost();
|
||||
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)', () => {
|
||||
const { host: writeHost, calls } = makeWriteHost();
|
||||
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', () => {
|
||||
const { host: writeHost, calls } = makeWriteHost();
|
||||
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)', () => {
|
||||
const { host: writeHost } = makeWriteHost();
|
||||
expect(() => runMacro('SETVALUE(A1; 1)', makeHost({}), writeHost)).toThrow(/Tabelle nennen/);
|
||||
});
|
||||
|
||||
it('rejects a computed (non-reference) target', () => {
|
||||
const { host: writeHost } = makeWriteHost();
|
||||
expect(() => runMacro('SETVALUE(1+1; 1)', makeHost({}), writeHost)).toThrow(/Zell- oder Bereichsbezug/);
|
||||
});
|
||||
|
||||
it('rejects an unknown table name', () => {
|
||||
const { host: writeHost } = makeWriteHost();
|
||||
expect(() => runMacro('SETVALUE(Nirgendwo::A1; 1)', makeHost({}), writeHost)).toThrow(/unbekannte Tabelle/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runMacroActions -- RETURN / fall-through', () => {
|
||||
it('RETURN short-circuits and its value is returned', () => {
|
||||
const { host: writeHost } = makeWriteHost();
|
||||
expect(runMacro('SETVALUE(Umsatz::A1; 1) RETURN "fertig"', makeHost({}), writeHost)).toBe('fertig');
|
||||
});
|
||||
|
||||
it('a RETURN inside an IF stops the remaining statements from running', () => {
|
||||
const { host: writeHost, calls } = makeWriteHost();
|
||||
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)', () => {
|
||||
const { host: writeHost } = makeWriteHost();
|
||||
expect(runMacro('SETVALUE(Umsatz::A1; 1)', makeHost({}), writeHost)).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', () => {
|
||||
const { host: writeHost, calls } = makeWriteHost();
|
||||
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', () => {
|
||||
const { host: writeHost, calls } = makeWriteHost();
|
||||
runMacro('n = "Kopie" ADDTABLE("Blatt1"; "Neu " & n)', makeHost({}), writeHost);
|
||||
expect(calls).toEqual([{ op: 'addTable', args: ['Blatt1', 'Neu Kopie'] }]);
|
||||
});
|
||||
|
||||
it('an unknown action name throws', () => {
|
||||
const { host: writeHost } = makeWriteHost();
|
||||
expect(() => runMacro('FOOBAR(1)', makeHost({}), writeHost)).toThrow(/Unbekannte Aktion/);
|
||||
});
|
||||
|
||||
it('a wrong argument count throws', () => {
|
||||
const { host: writeHost } = makeWriteHost();
|
||||
expect(() => runMacro('ADDSHEET("x"; "y")', makeHost({}), writeHost)).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', () => {
|
||||
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.
|
||||
expect(() =>
|
||||
runMacro('FOR c IN Umsatz::A1:A2000 DO INSERTROW("Umsatz"; 1) END', makeHost({}), writeHost),
|
||||
).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', () => {
|
||||
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);
|
||||
runMacroActions(block, makeHost({}), writeHost);
|
||||
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', () => {
|
||||
const { host: writeHost } = makeWriteHost();
|
||||
expect(() => runMacro('SETVALUE(Umsatz::A1; 1/0)', makeHost({}), writeHost)).toThrow(
|
||||
new FormulaError('#DIV/0!').code,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,9 @@ import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
parseFormula,
|
||||
parseFunctionBlock,
|
||||
parseMacroBlock,
|
||||
collectRefs,
|
||||
collectRefsInBlock,
|
||||
ParseError,
|
||||
type Node,
|
||||
type Statement,
|
||||
@@ -54,6 +56,8 @@ function sexprStmt(s: Statement): string {
|
||||
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 {
|
||||
@@ -384,3 +388,65 @@ describe('parseFunctionBlock -- statement grammar for "block" function bodies',
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseFormula } from '../parser';
|
||||
import { parseFormula, parseMacroBlock } from '../parser';
|
||||
import {
|
||||
buildFunctionIndex,
|
||||
collectCalledFunctionNames,
|
||||
collectCalledFunctionNamesInBlock,
|
||||
expandMacroBlock,
|
||||
expandUserFunctions,
|
||||
type FunctionDef,
|
||||
} from '../userFunctions';
|
||||
@@ -136,6 +138,30 @@ describe('collectCalledFunctionNames', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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', {
|
||||
|
||||
Reference in New Issue
Block a user