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