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 MacroNetworkHost, 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>, home = 'Umsatz', namedRanges: Record = {}, ): 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 }; } /** A mock `MacroNetworkHost` -- `responses` maps "METHOD url" to either a * value to resolve with (wrapped as `{ok:true, value}`) or an Error to * reject with (`{ok:false, error}`); an un-mapped request rejects with a * loud "no mock for this request" error so a test that forgets to mock a * call fails clearly instead of hanging. */ function makeNetHost( responses: Record, ): { host: MacroNetworkHost; calls: { method: string; url: string; body?: string }[] } { const calls: { method: string; url: string; body?: string }[] = []; const host: MacroNetworkHost = { async request(method, url, body) { calls.push({ method, url, body }); const key = `${method} ${url}`; const mocked = responses[key]; if (!mocked) throw new Error(`no mock for ${key}`); if (!mocked.ok) throw mocked.error; return mocked.value; }, }; return { host, calls }; } const emptyNetHost: MacroNetworkHost = { request: () => Promise.reject(new Error('no network calls expected')) }; const runMacro = ( src: string, host: EngineHost, writeHost: MacroWriteHost, netHost: MacroNetworkHost = emptyNetHost, ) => runMacroActions(parseMacroBlock(src), host, writeHost, netHost); describe('runMacroActions -- SETVALUE target resolution', () => { it('a literal cell target writes exactly one cell', async () => { const { host: writeHost, calls } = makeWriteHost(); await 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', async () => { const { host: writeHost, calls } = makeWriteHost(); await 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)', async () => { const { host: writeHost, calls } = makeWriteHost(); await 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', async () => { const { host: writeHost, calls } = makeWriteHost(); await 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)', async () => { const { host: writeHost } = makeWriteHost(); await expect(runMacro('SETVALUE(A1; 1)', makeHost({}), writeHost)).rejects.toThrow(/Tabelle nennen/); }); it('rejects a computed (non-reference) target', async () => { const { host: writeHost } = makeWriteHost(); await expect(runMacro('SETVALUE(1+1; 1)', makeHost({}), writeHost)).rejects.toThrow(/Zell- oder Bereichsbezug/); }); it('rejects an unknown table name', async () => { const { host: writeHost } = makeWriteHost(); await expect(runMacro('SETVALUE(Nirgendwo::A1; 1)', makeHost({}), writeHost)).rejects.toThrow(/unbekannte Tabelle/); }); }); describe('runMacroActions -- RETURN / fall-through', () => { it('RETURN short-circuits and its value is returned', async () => { const { host: writeHost } = makeWriteHost(); await expect(runMacro('SETVALUE(Umsatz::A1; 1) RETURN "fertig"', makeHost({}), writeHost)).resolves.toBe('fertig'); }); it('a RETURN inside an IF stops the remaining statements from running', async () => { const { host: writeHost, calls } = makeWriteHost(); await 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)', async () => { const { host: writeHost } = makeWriteHost(); await expect(runMacro('SETVALUE(Umsatz::A1; 1)', makeHost({}), writeHost)).resolves.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', async () => { const { host: writeHost, calls } = makeWriteHost(); await 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', async () => { const { host: writeHost, calls } = makeWriteHost(); await runMacro('n = "Kopie" ADDTABLE("Blatt1"; "Neu " & n)', makeHost({}), writeHost); expect(calls).toEqual([{ op: 'addTable', args: ['Blatt1', 'Neu Kopie'] }]); }); it('an unknown action name throws', async () => { const { host: writeHost } = makeWriteHost(); await expect(runMacro('FOOBAR(1)', makeHost({}), writeHost)).rejects.toThrow(/Unbekannte Aktion/); }); it('a wrong argument count throws', async () => { const { host: writeHost } = makeWriteHost(); await expect(runMacro('ADDSHEET("x"; "y")', makeHost({}), writeHost)).rejects.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', async () => { 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. await expect( runMacro('FOR c IN Umsatz::A1:A2000 DO INSERTROW("Umsatz"; 1) END', makeHost({}), writeHost), ).rejects.toThrow(/zu viele Schritte|zu viele Zellen/); }); it('sequential GET calls in a loop also count toward the budget', async () => { const { host: writeHost } = makeWriteHost(); const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value: 1 } }); await expect( runMacro('FOR c IN Umsatz::A1:A2000 DO r = GET("https://x") END', makeHost({}), writeHost, netHost), ).rejects.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', async () => { 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); await runMacroActions(block, makeHost({}), writeHost, emptyNetHost); 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', async () => { const { host: writeHost } = makeWriteHost(); await expect(runMacro('SETVALUE(Umsatz::A1; 1/0)', makeHost({}), writeHost)).rejects.toThrow( new FormulaError('#DIV/0!').code, ); }); }); describe('runMacroActions -- GET/POST', () => { it('GET binds the parsed JSON response, usable as a subsequent SETVALUE value', async () => { const { host: writeHost, calls } = makeWriteHost(); const { host: netHost, calls: netCalls } = makeNetHost({ 'GET https://api/kunde': { ok: true, value: { name: 'Nord' } }, }); await runMacro('r = GET("https://api/kunde") SETVALUE(Umsatz::A1; JSONGET(r; "name"))', makeHost({}), writeHost, netHost); expect(netCalls).toEqual([{ method: 'GET', url: 'https://api/kunde', body: undefined }]); expect(calls).toEqual([{ op: 'setValue', args: [[{ tableId: 'Umsatz', col: 0, row: 0 }], 'Nord'] }]); }); it('POST sends a body and binds the parsed JSON response', async () => { const { host: writeHost, calls } = makeWriteHost(); const { host: netHost, calls: netCalls } = makeNetHost({ 'POST https://api/kunden': { ok: true, value: { id: 42 } }, }); await runMacro( 'r = POST("https://api/kunden"; "{\\"name\\":\\"Nord\\"}") SETVALUE(Umsatz::A1; JSONGET(r; "id"))', makeHost({}), writeHost, netHost, ); expect(netCalls).toEqual([{ method: 'POST', url: 'https://api/kunden', body: '{"name":"Nord"}' }]); expect(calls).toEqual([{ op: 'setValue', args: [[{ tableId: 'Umsatz', col: 0, row: 0 }], '42'] }]); }); it('GET/POST used anywhere other than the whole RHS of a plain assignment fall through to the ordinary path and become #NAME? -- they need a real await, so (unlike JSONGET below) they cannot be embedded in an arbitrary expression', async () => { const { host: writeHost } = makeWriteHost(); // nested inside an expression -- SETVALUE's error wrapping only // surfaces the FormulaError *code*, not its detail text, so #NAME? // (not the literal words "unknown function") is what's actually visible await expect( runMacro('SETVALUE(Umsatz::A1; 1 + GET("https://x"))', makeHost({}), writeHost), ).rejects.toThrow(/NAME/); // inside an IF condition -- an error in a condition resolves the whole // macro run with that FormulaError as its "value" (same as any other // ifstmt-condition error, existing behaviour predating this feature), // it does not reject the promise const result = await runMacro('IF GET("https://x") = 1 THEN SETVALUE(Umsatz::A1; 1) END', makeHost({}), writeHost); expect(result).toEqual(new FormulaError('#NAME?', 'unknown function GET')); }); it('JSONGET, unlike GET/POST, is purely synchronous and works nested anywhere an expression can appear (evalScalar special-case, not just the assignment-RHS form)', async () => { const { host: writeHost, calls } = makeWriteHost(); const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value: { name: 'Nord' } } }); await runMacro( 'r = GET("https://x") IF JSONGET(r; "name") = "Nord" THEN SETVALUE(Umsatz::A1; JSONGET(r; "name")) END', makeHost({}), writeHost, netHost, ); expect(calls).toEqual([{ op: 'setValue', args: [[{ tableId: 'Umsatz', col: 0, row: 0 }], 'Nord'] }]); }); it('a network failure aborts the run cleanly with the failure message', async () => { const { host: writeHost } = makeWriteHost(); const { host: netHost } = makeNetHost({ 'GET https://down': { ok: false, error: new Error('GET https://down: HTTP 500 Internal Server Error') }, }); await expect(runMacro('r = GET("https://down")', makeHost({}), writeHost, netHost)).rejects.toThrow(/HTTP 500/); }); it('a declined confirmation (surfaced by the host as a rejection) aborts without writing anything', async () => { const { host: writeHost, calls } = makeWriteHost(); const { host: netHost } = makeNetHost({ 'GET https://x': { ok: false, error: new Error("GET: Verbindung zu 'x' abgelehnt") }, }); await expect( runMacro('r = GET("https://x") SETVALUE(Umsatz::A1; 1)', makeHost({}), writeHost, netHost), ).rejects.toThrow(/abgelehnt/); expect(calls).toHaveLength(0); }); }); describe('runMacroActions -- JSON value collapse rule (evalScalar\'s case "json")', () => { const cases: Array<[unknown, CellValue]> = [ [null, null], ['Nord', 'Nord'], [42, 42], [true, 1], [false, 0], [{ a: 1 }, '{"a":1}'], [[1, 2], '[1,2]'], ]; for (const [value, expected] of cases) { it(`a JSON ${JSON.stringify(value)} used directly as a SETVALUE value collapses to ${JSON.stringify(expected)}`, async () => { const { host: writeHost, calls } = makeWriteHost(); const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value } }); await runMacro('r = GET("https://x") SETVALUE(Umsatz::A1; r)', makeHost({}), writeHost, netHost); expect(calls[0].args[1]).toBe(String(expected ?? '')); }); } }); describe('runMacroActions -- JSONGET path resolution', () => { const data = { items: [{ name: 'Nord', tags: ['a', 'b'] }, { name: 'Süd' }], meta: { count: 2 } }; it('resolves a nested object path', async () => { const { host: writeHost, calls } = makeWriteHost(); const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value: data } }); await runMacro('r = GET("https://x") SETVALUE(Umsatz::A1; JSONGET(r; "meta.count"))', makeHost({}), writeHost, netHost); expect(calls[0].args[1]).toBe('2'); }); it('resolves an array index within the path', async () => { const { host: writeHost, calls } = makeWriteHost(); const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value: data } }); await runMacro('r = GET("https://x") SETVALUE(Umsatz::A1; JSONGET(r; "items.0.name"))', makeHost({}), writeHost, netHost); expect(calls[0].args[1]).toBe('Nord'); }); it('a nested array-of-strings path resolves too', async () => { const { host: writeHost, calls } = makeWriteHost(); const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value: data } }); await runMacro('r = GET("https://x") SETVALUE(Umsatz::A1; JSONGET(r; "items.0.tags.1"))', makeHost({}), writeHost, netHost); expect(calls[0].args[1]).toBe('b'); }); it('a missing path resolves to JSON null, not a throw', async () => { const { host: writeHost, calls } = makeWriteHost(); const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value: data } }); await runMacro('r = GET("https://x") SETVALUE(Umsatz::A1; JSONGET(r; "items.9.name"))', makeHost({}), writeHost, netHost); expect(calls[0].args[1]).toBe(''); // null collapses to '' via toText(), same as an empty cell }); it('JSONGET on a non-JSON value throws a clear error when used as an assignment RHS', async () => { const { host: writeHost } = makeWriteHost(); await expect( runMacro('x = 5 y = JSONGET(x; "a")', makeHost({}), writeHost), ).rejects.toThrow(/JSON-Ergebnis/); }); it('JSONGET on a non-JSON value nested in an expression degrades to a formula error (#VALUE!), same as any other evalScalar function misuse', async () => { const { host: writeHost } = makeWriteHost(); await expect( runMacro('x = 5 SETVALUE(Umsatz::A1; JSONGET(x; "a"))', makeHost({}), writeHost), ).rejects.toThrow(/VALUE/); }); }); describe('runMacroActions -- FOR over a JSON array', () => { it('iterates each element, usable via JSONGET', async () => { const { host: writeHost, calls } = makeWriteHost(); const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value: [{ name: 'Nord' }, { name: 'Süd' }] }, }); await runMacro( 'r = GET("https://x") FOR item IN r DO SETVALUE(Umsatz::A1; JSONGET(item; "name")) END', makeHost({}), writeHost, netHost, ); expect(calls.map((c) => c.args[1])).toEqual(['Nord', 'Süd']); }); it('FOR over a JSON object (not an array) throws a clear error', async () => { const { host: writeHost } = makeWriteHost(); const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value: { a: 1 } } }); await expect( runMacro('r = GET("https://x") FOR item IN r DO SETVALUE(Umsatz::A1; 1) END', makeHost({}), writeHost, netHost), ).rejects.toThrow(/Array/); }); it('FOR over a JSON primitive throws the same clear error', async () => { const { host: writeHost } = makeWriteHost(); const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value: 5 } }); await expect( runMacro('r = GET("https://x") FOR item IN r DO SETVALUE(Umsatz::A1; 1) END', makeHost({}), writeHost, netHost), ).rejects.toThrow(/Array/); }); it('a JSONGET(...) call used directly as the FOR source (not first bound to a variable) parses and runs -- found live: the shared FOR-source shape check did not originally know about this new source kind', async () => { const { host: writeHost, calls } = makeWriteHost(); const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value: { items: [{ name: 'Nord' }, { name: 'Süd' }] } }, }); await runMacro( 'r = GET("https://x") FOR item IN JSONGET(r; "items") DO SETVALUE(Umsatz::A1; JSONGET(item; "name")) END', makeHost({}), writeHost, netHost, ); expect(calls.map((c) => c.args[1])).toEqual(['Nord', 'Süd']); }); });