Makros: GET/POST-Anfragen mit JSON-Ergebnis und FOR-Iteration
Erweitert die Makro-Sprache um Netzwerkzugriff: "x = GET(url)"/ "x = POST(url; body)" binden die geparste JSON-Antwort an eine Variable; FOR item IN x DO ... END iteriert, wenn x ein JSON-Array ist, über dessen Elemente; JSONGET(wert; pfad) liest ein Feld heraus (punktgetrennt, Zahl = Array-Index) und funktioniert – anders als GET/POST – überall in einem Ausdruck verschachtelt, nicht nur als ganze Zuweisung. Da ein echter Netzwerkaufruf nicht synchron laufen kann, ohne den Tab einzufrieren, bekommt der Makro-Interpreter einen separaten asynchronen Zweig (execBlockAsync/execStatementAsync); der bestehende synchrone Interpreter, den auch gewöhnliche "Mit Schleife"-Funktionen mitten in der synchronen Neuberechnung nutzen, bleibt unangetastet. Netzwerkaufrufe werden pro Makro einmalig mit Zielname bestätigt (dauerhaft gemerkt); die bestehende "ein Lauf = ein Undo-Schritt"-Garantie, die bislang auf Synchronität beruhte, wird jetzt explizit wiederhergestellt statt sich weiter auf die Zeit-Koaleszenz zu verlassen. Zwei echte Lücken erst beim eigenen Live-Test gefunden und behoben: (1) JSONGET direkt in eine Aktion verschachtelt (statt erst einer Variable zugewiesen) scheiterte zunächst an #NAME?; (2) JSONGET(...) direkt als FOR-Quelle (ohne Zwischenvariable) scheiterte sowohl an der Speicher-Zeit-Prüfung als auch zur Laufzeit, da substitute() einen Funktionsaufruf nie auswertet. Live gegen eine neue same-origin-Fixture (public/test-fixtures/sample.json) verifiziert: echter GET, echter Bestätigungsdialog, FOR/JSONGET über die echte Antwort, kein erneuter Dialog beim zweiten Lauf. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,14 @@ 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';
|
||||
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 } };
|
||||
|
||||
@@ -60,19 +67,47 @@ function makeWriteHost(): { host: MacroWriteHost; calls: RecordedCall[] } {
|
||||
return { host, calls };
|
||||
}
|
||||
|
||||
const runMacro = (src: string, host: EngineHost, writeHost: MacroWriteHost) =>
|
||||
runMacroActions(parseMacroBlock(src), host, writeHost);
|
||||
/** 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<string, { ok: true; value: unknown } | { ok: false; error: Error }>,
|
||||
): { 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', () => {
|
||||
it('a literal cell target writes exactly one cell', async () => {
|
||||
const { host: writeHost, calls } = makeWriteHost();
|
||||
runMacro('SETVALUE(Umsatz::B2; 42)', makeHost({}), writeHost);
|
||||
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', () => {
|
||||
it('a range target broadcasts the same value to every cell in one call', async () => {
|
||||
const { host: writeHost, calls } = makeWriteHost();
|
||||
runMacro('SETVALUE(Umsatz::A1:A3; "x")', makeHost({}), writeHost);
|
||||
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([
|
||||
@@ -83,59 +118,59 @@ describe('runMacroActions -- SETVALUE target resolution', () => {
|
||||
expect(text).toBe('x');
|
||||
});
|
||||
|
||||
it('a target resolved through a FOR loop variable (substitute() through env)', () => {
|
||||
it('a target resolved through a FOR loop variable (substitute() through env)', async () => {
|
||||
const { host: writeHost, calls } = makeWriteHost();
|
||||
runMacro('FOR c IN Umsatz::A1:A2 DO SETVALUE(c; 0) END', makeHost({}), writeHost);
|
||||
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', () => {
|
||||
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();
|
||||
runMacro('SETVALUE(Umsatz::A1; "=SUM(" & "A2:A3" & ")")', makeHost({}), writeHost);
|
||||
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)', () => {
|
||||
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();
|
||||
expect(() => runMacro('SETVALUE(A1; 1)', makeHost({}), writeHost)).toThrow(/Tabelle nennen/);
|
||||
await expect(runMacro('SETVALUE(A1; 1)', makeHost({}), writeHost)).rejects.toThrow(/Tabelle nennen/);
|
||||
});
|
||||
|
||||
it('rejects a computed (non-reference) target', () => {
|
||||
it('rejects a computed (non-reference) target', async () => {
|
||||
const { host: writeHost } = makeWriteHost();
|
||||
expect(() => runMacro('SETVALUE(1+1; 1)', makeHost({}), writeHost)).toThrow(/Zell- oder Bereichsbezug/);
|
||||
await expect(runMacro('SETVALUE(1+1; 1)', makeHost({}), writeHost)).rejects.toThrow(/Zell- oder Bereichsbezug/);
|
||||
});
|
||||
|
||||
it('rejects an unknown table name', () => {
|
||||
it('rejects an unknown table name', async () => {
|
||||
const { host: writeHost } = makeWriteHost();
|
||||
expect(() => runMacro('SETVALUE(Nirgendwo::A1; 1)', makeHost({}), writeHost)).toThrow(/unbekannte Tabelle/);
|
||||
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', () => {
|
||||
it('RETURN short-circuits and its value is returned', async () => {
|
||||
const { host: writeHost } = makeWriteHost();
|
||||
expect(runMacro('SETVALUE(Umsatz::A1; 1) RETURN "fertig"', makeHost({}), writeHost)).toBe('fertig');
|
||||
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', () => {
|
||||
it('a RETURN inside an IF stops the remaining statements from running', async () => {
|
||||
const { host: writeHost, calls } = makeWriteHost();
|
||||
runMacro('IF 1 = 1 THEN RETURN "abgebrochen" END SETVALUE(Umsatz::A1; 1)', makeHost({}), writeHost);
|
||||
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)', () => {
|
||||
it('falling off the end without RETURN yields null, not an error (unlike an ordinary block-kind custom function)', async () => {
|
||||
const { host: writeHost } = makeWriteHost();
|
||||
expect(runMacro('SETVALUE(Umsatz::A1; 1)', makeHost({}), writeHost)).toBeNull();
|
||||
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', () => {
|
||||
it('every non-SETVALUE action evaluates its (string/number) args and calls the matching writeHost method', async () => {
|
||||
const { host: writeHost, calls } = makeWriteHost();
|
||||
runMacro(
|
||||
await runMacro(
|
||||
[
|
||||
'INSERTROW("Umsatz"; 3)',
|
||||
'DELETEROW("Umsatz"; 4)',
|
||||
@@ -165,44 +200,52 @@ describe('runMacroActions -- the full action vocabulary dispatches correctly', (
|
||||
]);
|
||||
});
|
||||
|
||||
it('a string argument built via & concatenation and a local variable both work, since every non-target argument goes through the ordinary expression pipeline', () => {
|
||||
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();
|
||||
runMacro('n = "Kopie" ADDTABLE("Blatt1"; "Neu " & n)', makeHost({}), writeHost);
|
||||
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', () => {
|
||||
it('an unknown action name throws', async () => {
|
||||
const { host: writeHost } = makeWriteHost();
|
||||
expect(() => runMacro('FOOBAR(1)', makeHost({}), writeHost)).toThrow(/Unbekannte Aktion/);
|
||||
await expect(runMacro('FOOBAR(1)', makeHost({}), writeHost)).rejects.toThrow(/Unbekannte Aktion/);
|
||||
});
|
||||
|
||||
it('a wrong argument count throws', () => {
|
||||
it('a wrong argument count throws', async () => {
|
||||
const { host: writeHost } = makeWriteHost();
|
||||
expect(() => runMacro('ADDSHEET("x"; "y")', makeHost({}), writeHost)).toThrow(/erwartet 1 Argument/);
|
||||
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', () => {
|
||||
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.
|
||||
expect(() =>
|
||||
await expect(
|
||||
runMacro('FOR c IN Umsatz::A1:A2000 DO INSERTROW("Umsatz"; 1) END', makeHost({}), writeHost),
|
||||
).toThrow(/zu viele Schritte|zu viele Zellen/);
|
||||
).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', () => {
|
||||
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);
|
||||
runMacroActions(block, makeHost({}), writeHost);
|
||||
await runMacroActions(block, makeHost({}), writeHost, emptyNetHost);
|
||||
expect(calls).toEqual([{ op: 'setValue', args: [[{ tableId: 'Umsatz', col: 0, row: 0 }], '7'] }]);
|
||||
});
|
||||
|
||||
@@ -218,10 +261,196 @@ describe('runMacroActions -- nested custom-function calls inside a macro express
|
||||
});
|
||||
|
||||
describe('runMacroActions -- error propagation', () => {
|
||||
it('an error value in a value expression aborts with a clear message, not a silently-written "#ERR" text', () => {
|
||||
it('an error value in a value expression aborts with a clear message, not a silently-written "#ERR" text', async () => {
|
||||
const { host: writeHost } = makeWriteHost();
|
||||
expect(() => runMacro('SETVALUE(Umsatz::A1; 1/0)', makeHost({}), writeHost)).toThrow(
|
||||
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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,6 +40,10 @@ function sexpr(n: Node): string {
|
||||
return `blockcall(${n.label})`;
|
||||
case 'omitted':
|
||||
return 'omitted';
|
||||
case 'json':
|
||||
// never produced by the parser (see parser.ts's comment on
|
||||
// JsonLiteralNode) -- exhaustiveness only, no test exercises this.
|
||||
return `json(${JSON.stringify(n.value)})`;
|
||||
}
|
||||
}
|
||||
const parse = (s: string) => sexpr(parseFormula(s));
|
||||
|
||||
Reference in New Issue
Block a user