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:
wkh
2026-09-09 09:35:02 +02:00
co-authored by Claude Sonnet 5
parent c7b316de1b
commit e670f13077
13 changed files with 1188 additions and 36 deletions
+209
View File
@@ -1402,6 +1402,215 @@ describe('functionSuggestions (formula autocomplete)', () => {
});
});
describe('macros (document-level, explicitly-triggered write scripts)', () => {
it('addMacro stores the definition; a bare (unqualified) cell reference is rejected at save time', () => {
const s = fresh();
const bad = s.addMacro('M', 'SETVALUE(A1; 1)') as { error: string };
expect(bad.error).toBeTruthy();
expect(s.doc.macros).toHaveLength(0);
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
const good = s.addMacro('M', `SETVALUE(${name}::A1; 1)`);
expect(typeof good).toBe('string');
expect(s.doc.macros).toHaveLength(1);
});
it('rejects a name colliding with another macro (case-insensitive)', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
s.addMacro('Setup', `SETVALUE(${name}::A1; 1)`);
const result = s.addMacro('SETUP', `SETVALUE(${name}::A1; 2)`) as { error: string };
expect(result.error).toBeTruthy();
expect(s.doc.macros).toHaveLength(1);
});
it('rejects one of the 11 write-action names used as a local variable', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
const result = s.addMacro('M', `SETVALUE = 1 SETVALUE(${name}::A1; SETVALUE)`) as { error: string };
expect(result.error).toBeTruthy();
});
it('rejects a FOR source or SETVALUE target that is a computed expression, not a real reference', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
expect((s.addMacro('M1', `SETVALUE(1+1; 1)`) as { error: string }).error).toBeTruthy();
expect(
(s.addMacro('M2', `FOR c IN 1+1 DO SETVALUE(${name}::A1; 0) END`) as { error: string }).error,
).toBeTruthy();
});
it('runMacro: SETVALUE writes a cell, reported as a success with a default message', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
const macroId = s.addMacro('Setzen', `SETVALUE(${name}::A1; 42)`) as string;
const result = s.runMacro(macroId);
expect(result).toEqual({ ok: true, message: 'Makro ausgeführt.' });
expect(s.cellInput(id, 0, 0)).toBe('42');
});
it('runMacro: a RETURNed string becomes the success message instead of the default', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
const macroId = s.addMacro('M', `SETVALUE(${name}::A1; 1) RETURN "fertig"`) as string;
expect(s.runMacro(macroId)).toEqual({ ok: true, message: 'fertig' });
});
it('runMacro: SETVALUE can write a live formula (a value starting with "=")', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
setCell(s, name, 'A1', '3');
setCell(s, name, 'A2', '4');
const macroId = s.addMacro('M', `SETVALUE(${name}::A3; "=" & ${name}::A1 & "+" & ${name}::A2)`) as string;
expect(s.runMacro(macroId).ok).toBe(true);
expect(s.cellInput(id, 0, 2)).toBe('=3+4');
expect(val(s, name, 'A3')).toBe(7);
});
it('runMacro: INSERTROW performs the same document-wide reference rewrite as the store primitive', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
setCell(s, name, 'A1', '10');
setCell(s, name, 'A2', '20');
setCell(s, name, 'B1', `=${name}::A1+${name}::A2`);
const macroId = s.addMacro('Zeile', `INSERTROW("${name}"; 0)`) as string;
expect(s.runMacro(macroId).ok).toBe(true);
expect(s.tableById(id)!.rows).toBe(13); // seed default 12 + 1
expect(s.cellInput(id, 0, 1)).toBe('10'); // A1's content slid down to A2
expect(s.cellInput(id, 1, 1)).toBe(`=${name}::A2+${name}::A3`); // the SUM formula's own refs shifted too
});
it('runMacro: a structural action mid-run does not leave later reads/writes stale (the thin-host-proxy fix)', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
setCell(s, name, 'A1', '5');
// SETVALUE writes B1, then INSERTROW at 0 shifts everything down one row
// (B1's write becomes B2), then a second SETVALUE writes C1 based on a
// READ of A1 (now at A2, post-insert) -- if the interpreter's host were
// a stale cached Workbook reference from before the INSERTROW, this
// read would silently return an empty/wrong value instead of 5.
const macroId = s.addMacro(
'M',
`SETVALUE(${name}::B1; 1) INSERTROW("${name}"; 0) SETVALUE(${name}::C1; ${name}::A2 + 100)`,
) as string;
expect(s.runMacro(macroId)).toEqual({ ok: true, message: 'Makro ausgeführt.' });
expect(s.cellInput(id, 1, 1)).toBe('1'); // B1's write, shifted to B2 by the insert
expect(val(s, name, 'C1')).toBe(105); // reads A2 (=5, shifted from A1) fresh, post-insert
});
it('runMacro: ADDTABLE/RENAMETABLE/DELETETABLE round-trip', () => {
const s = fresh();
const sheetId = s.doc.activeSheetId;
const sheetName = s.sheetById(sheetId)!.name;
const macroId = s.addMacro(
'M',
`ADDTABLE("${sheetName}"; "MakroTabelle") RENAMETABLE("MakroTabelle"; "Umbenannt") DELETETABLE("Umbenannt")`,
) as string;
const before = s.tablesOf(sheetId).length;
expect(s.runMacro(macroId).ok).toBe(true);
expect(s.tablesOf(sheetId).length).toBe(before); // added then deleted -- net zero
});
it('runMacro: ADDSHEET/RENAMESHEET/DELETESHEET round-trip', () => {
const s = fresh();
const before = s.doc.sheets.length;
const macroId = s.addMacro(
'M',
'ADDSHEET("MakroBlatt") RENAMESHEET("MakroBlatt"; "Umbenannt") DELETESHEET("Umbenannt")',
) as string;
expect(s.runMacro(macroId).ok).toBe(true);
expect(s.doc.sheets.length).toBe(before);
});
it('runMacro: DELETESHEET refuses the last sheet, surfaced as a real failure not a silent no-op', () => {
const s = fresh();
// collapse to exactly one sheet first
while (s.doc.sheets.length > 1) s.deleteSheet(s.doc.sheets[s.doc.sheets.length - 1].id);
const onlySheetName = s.doc.sheets[0].name;
const macroId = s.addMacro('M', `DELETESHEET("${onlySheetName}")`) as string;
const result = s.runMacro(macroId);
expect(result.ok).toBe(false);
expect(s.doc.sheets).toHaveLength(1);
});
it('runMacro: an unknown table reference fails cleanly, no throw escapes', () => {
const s = fresh();
const macroId = s.addMacro('M', 'INSERTROW("Nirgendwo"; 0)') as string;
const result = s.runMacro(macroId);
expect(result.ok).toBe(false);
expect(result.message).toMatch(/unbekannte Tabelle/);
});
it('runMacro: an unknown macro id fails cleanly', () => {
const s = fresh();
expect(s.runMacro('does-not-exist').ok).toBe(false);
});
it('updateMacro changes the body; deleteMacro removes it', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
const macroId = s.addMacro('M', `SETVALUE(${name}::A1; 1)`) as string;
s.updateMacro(macroId, 'M', `SETVALUE(${name}::A1; 2)`);
s.runMacro(macroId);
expect(s.cellInput(id, 0, 0)).toBe('2');
s.deleteMacro(macroId);
expect(s.doc.macros).toHaveLength(0);
});
it('a whole synchronous macro run (SETVALUE + a structural action) coalesces into exactly one undo step', () => {
vi.useFakeTimers();
try {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
const macroId = s.addMacro('M', `SETVALUE(${name}::A1; 1) INSERTROW("${name}"; 0)`) as string;
vi.advanceTimersByTime(600); // the addTable + addMacro setup settles first
s.runMacro(macroId);
vi.advanceTimersByTime(600);
expect(s.cellInput(id, 0, 1)).toBe('1'); // shifted down by the insert
expect(s.tableById(id)!.rows).toBe(13);
s.undo(); // one Ctrl+Z undoes the ENTIRE macro run
expect(s.cellInput(id, 0, 0)).toBe('');
expect(s.cellInput(id, 0, 1)).toBe('');
expect(s.tableById(id)!.rows).toBe(12);
} finally {
vi.runOnlyPendingTimers();
vi.useRealTimers();
}
});
it('JSON export/import round-trips macros byte-for-byte', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
s.addMacro('Meine Aktion', `SETVALUE(${name}::A1; 1)`);
const json = serializeDocument(s.doc);
const reimported = parseDocument(json);
expect(reimported.macros).toEqual(s.doc.macros);
});
});
describe('custom functions: "block" kind (FOR/IF/RETURN "code")', () => {
const productBody = 'ergebnis = 1 FOR x IN bereich DO ergebnis = ergebnis * x END RETURN ergebnis';