Real control flow for "Mit Schleife" functions: DO/END, IF/ELSE/END, RETURN anywhere
Replaces the previous FOR-only, single-statement-body, static-unrolling design with genuine control flow: FOR var IN source DO <block> END loop bodies with any number of statements (including nested loops/IFs), IF cond THEN <block> [ELSE <block>] END branching (nestable in any block), and RETURN as an ordinary statement usable anywhere -- it aborts execution immediately with its value, including breaking out of a loop early. This required a real, if small, architecture change: an IF branch or an early RETURN depends on actual cell values, which the previous "unroll everything into one static formula at parse time" approach can't resolve (loop *counts* are knowable up front from a range's size; branch *outcomes* aren't). A kind: 'block' call is now inlined only as far as resolving nested function calls (expandBlock, parse-time, same inside-out principle as before) and wrapped in a new BlockCallNode; its own control flow is interpreted at evaluation time by a small tree-walking interpreter added to evaluator.ts (execBlock/execStatement, a thrown ReturnSignal for early exit), reusing userFunctions.ts's substitute() (now exported) to resolve parameter/local-variable names against the current environment. Dependency tracking stays fully correct and conservative: collectRefs() walks into a BlockCallNode's block (new collectRefsInBlock), so a cell referenced only in a branch that doesn't happen to run is still a tracked precedent -- same principle as an ordinary IF() in any spreadsheet. Also added: comparison operators (= <> > < >= <=, result 1/0) as a new, loosest-binding precedence level in the *general* expression grammar (not just inside a block's IF) -- needed for IF conditions to be useful at all. A definition-time static check (blockAlwaysReturns) rejects a function at save time when it's not obviously guaranteed to reach RETURN on every path (an IF without an ELSE, or one where only one branch returns); the interpreter's own runtime "ended without RETURN" -> #ERR remains the actual enforcement for cases the static check can't see (e.g. a loop over an empty range). Nested loops (FOR inside FOR) and nested IFs work as an emergent property of the recursive statement grammar -- verified with dedicated tests, not just assumed. 257 tests passing (30 net new/rewritten for this change), vue-tsc/ eslint/build clean. Verified live in the running app: a FIRSTOVER(range; threshold) function using a FOR loop with a nested IF and an early RETURN, confirmed it returns the first match, confirmed changing a cell *past* the match point doesn't affect the result while changing the match point itself does, and confirmed a function without a guaranteed RETURN is rejected inline at save time. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -68,4 +68,24 @@ describe('lexer', () => {
|
||||
it('throws on an unexpected character', () => {
|
||||
expect(() => tokenize('A1 & B2')).toThrow();
|
||||
});
|
||||
|
||||
it('tokenizes assignment/equality and comparison operators', () => {
|
||||
expect(types('= < > <= >= <>')).toEqual([
|
||||
'EQUALS',
|
||||
'LT',
|
||||
'GT',
|
||||
'LTE',
|
||||
'GTE',
|
||||
'NEQ',
|
||||
'EOF',
|
||||
]);
|
||||
});
|
||||
|
||||
it('prefers the two-character comparison operators over their single-character prefix', () => {
|
||||
expect(types('A1<=B2')).toEqual(['IDENT', 'LTE', 'IDENT', 'EOF']);
|
||||
expect(types('A1>=B2')).toEqual(['IDENT', 'GTE', 'IDENT', 'EOF']);
|
||||
expect(types('A1<>B2')).toEqual(['IDENT', 'NEQ', 'IDENT', 'EOF']);
|
||||
expect(types('A1<B2')).toEqual(['IDENT', 'LT', 'IDENT', 'EOF']);
|
||||
expect(types('A1>B2')).toEqual(['IDENT', 'GT', 'IDENT', 'EOF']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,6 +28,10 @@ function sexpr(n: Node): string {
|
||||
return `${n.sheetQualifier ? n.sheetQualifier + '!' : ''}${n.qualifier ? n.qualifier + '!' : ''}range(${n.start.col},${n.start.row}:${n.end.col},${n.end.row})`;
|
||||
case 'param':
|
||||
return `param(${n.name})`;
|
||||
case 'blockcall':
|
||||
// never appears in freshly-parsed input (see parser.ts's comment on
|
||||
// BlockCallNode) -- exhaustiveness only, no test exercises this.
|
||||
return `blockcall(${n.label})`;
|
||||
}
|
||||
}
|
||||
const parse = (s: string) => sexpr(parseFormula(s));
|
||||
@@ -35,11 +39,19 @@ const parse2 = (s: string, paramNames: readonly string[]) => sexpr(parseFormula(
|
||||
|
||||
/** Compact rendering of a block AST (see parseFunctionBlock) for assertions. */
|
||||
function sexprStmt(s: Statement): string {
|
||||
if (s.kind === 'assign') return `${s.name}=${sexpr(s.value)}`;
|
||||
return `FOR(${s.varName} IN ${sexpr(s.source)} DO ${sexprStmt(s.body)})`;
|
||||
switch (s.kind) {
|
||||
case 'assign':
|
||||
return `${s.name}=${sexpr(s.value)}`;
|
||||
case 'return':
|
||||
return `RETURN ${sexpr(s.value)}`;
|
||||
case 'for':
|
||||
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)}` : ''})`;
|
||||
}
|
||||
}
|
||||
function sexprBlock(b: BlockNode): string {
|
||||
return `{${b.statements.map(sexprStmt).join('; ')}} RETURN ${sexpr(b.ret)}`;
|
||||
return `{${b.statements.map(sexprStmt).join('; ')}}`;
|
||||
}
|
||||
const parseBlock = (s: string, paramNames: readonly string[]) =>
|
||||
sexprBlock(parseFunctionBlock(s, paramNames));
|
||||
@@ -180,45 +192,101 @@ describe('parser errors', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseFunctionBlock -- FOR/DO/RETURN "block" function bodies', () => {
|
||||
it('a single FOR loop, accumulator pattern', () => {
|
||||
const src = 'ergebnis = 1 FOR x IN bereich DO ergebnis = ergebnis * x RETURN ergebnis';
|
||||
describe('parser comparison operators (available in every formula, not just block IF conditions)', () => {
|
||||
it('parses all six comparison operators', () => {
|
||||
expect(parse('=A1=B1')).toBe('(= cell(0,0) cell(1,0))');
|
||||
expect(parse('=A1<>B1')).toBe('(<> cell(0,0) cell(1,0))');
|
||||
expect(parse('=A1>B1')).toBe('(> cell(0,0) cell(1,0))');
|
||||
expect(parse('=A1<B1')).toBe('(< cell(0,0) cell(1,0))');
|
||||
expect(parse('=A1>=B1')).toBe('(>= cell(0,0) cell(1,0))');
|
||||
expect(parse('=A1<=B1')).toBe('(<= cell(0,0) cell(1,0))');
|
||||
});
|
||||
|
||||
it('binds looser than + -', () => {
|
||||
expect(parse('=A1+1=B1')).toBe('(= (+ cell(0,0) 1) cell(1,0))');
|
||||
});
|
||||
|
||||
it('is not chainable -- "a < b < c" is a parse error', () => {
|
||||
expect(() => parseFormula('=1<2<3')).toThrow(ParseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseFunctionBlock -- statement grammar for "block" function bodies', () => {
|
||||
it('a single FOR loop, accumulator pattern (DO ... END, one statement)', () => {
|
||||
const src = 'ergebnis = 1 FOR x IN bereich DO ergebnis = ergebnis * x END RETURN ergebnis';
|
||||
expect(parseBlock(src, ['bereich'])).toBe(
|
||||
'{ergebnis=1; FOR(x IN param(bereich) DO ergebnis=(* param(ergebnis) param(x)))} RETURN param(ergebnis)',
|
||||
'{ergebnis=1; FOR(x IN param(bereich) DO {ergebnis=(* param(ergebnis) param(x))}); RETURN param(ergebnis)}',
|
||||
);
|
||||
});
|
||||
|
||||
it('a FOR body with multiple statements', () => {
|
||||
const src = 'total = 0 FOR x IN bereich DO y = x * 2 total = total + y END RETURN total';
|
||||
expect(parseBlock(src, ['bereich'])).toBe(
|
||||
'{total=0; FOR(x IN param(bereich) DO {y=(* param(x) 2); total=(+ param(total) param(y))}); RETURN param(total)}',
|
||||
);
|
||||
});
|
||||
|
||||
it('multiple independent FOR loops in sequence, sharing an accumulator', () => {
|
||||
const src =
|
||||
'total = 0 FOR a IN r1 DO total = total + a FOR b IN r2 DO total = total + b RETURN total';
|
||||
'total = 0 FOR a IN r1 DO total = total + a END FOR b IN r2 DO total = total + b END RETURN total';
|
||||
expect(parseBlock(src, ['r1', 'r2'])).toBe(
|
||||
'{total=0; FOR(a IN param(r1) DO total=(+ param(total) param(a))); ' +
|
||||
'FOR(b IN param(r2) DO total=(+ param(total) param(b)))} RETURN param(total)',
|
||||
'{total=0; FOR(a IN param(r1) DO {total=(+ param(total) param(a))}); ' +
|
||||
'FOR(b IN param(r2) DO {total=(+ param(total) param(b))}); RETURN param(total)}',
|
||||
);
|
||||
});
|
||||
|
||||
it('a block with no statements -- just RETURN <expression>', () => {
|
||||
expect(parseBlock('RETURN 42', [])).toBe('{} RETURN 42');
|
||||
it('IF ... THEN ... END, no ELSE', () => {
|
||||
const src = 'x = 1 IF x > 0 THEN x = x + 1 END RETURN x';
|
||||
expect(parseBlock(src, [])).toBe('{x=1; IF((> param(x) 0) THEN {x=(+ param(x) 1)}); RETURN param(x)}');
|
||||
});
|
||||
|
||||
it('RETURN may be any expression, not just a bare variable', () => {
|
||||
expect(parseBlock('x = 5 RETURN x * 2', [])).toBe('{x=5} RETURN (* param(x) 2)');
|
||||
it('IF ... THEN ... ELSE ... END', () => {
|
||||
const src = 'IF x > 0 THEN y = 1 ELSE y = -1 END RETURN y';
|
||||
expect(parseBlock(src, ['x'])).toBe(
|
||||
'{IF((> param(x) 0) THEN {y=1} ELSE {y=(- 1)}); RETURN param(y)}',
|
||||
);
|
||||
});
|
||||
|
||||
it('IF nested inside a FOR body, with an early RETURN -- "if kann in jedem block vorkommen"', () => {
|
||||
const src =
|
||||
'total = 0 FOR x IN bereich DO IF x > 100 THEN RETURN x END total = total + x END RETURN total';
|
||||
expect(parseBlock(src, ['bereich'])).toBe(
|
||||
'{total=0; FOR(x IN param(bereich) DO {IF((> param(x) 100) THEN {RETURN param(x)}); ' +
|
||||
'total=(+ param(total) param(x))}); RETURN param(total)}',
|
||||
);
|
||||
});
|
||||
|
||||
it('a FOR loop nested inside another FOR loop\'s body', () => {
|
||||
const src = 'total = 0 FOR a IN r1 DO FOR b IN r2 DO total = total + a * b END END RETURN total';
|
||||
expect(parseBlock(src, ['r1', 'r2'])).toBe(
|
||||
'{total=0; FOR(a IN param(r1) DO {FOR(b IN param(r2) DO ' +
|
||||
'{total=(+ param(total) (* param(a) param(b)))})}); RETURN param(total)}',
|
||||
);
|
||||
});
|
||||
|
||||
it('RETURN may appear anywhere, not just at the end, and may be any expression', () => {
|
||||
expect(parseBlock('RETURN 42', [])).toBe('{RETURN 42}');
|
||||
expect(parseBlock('x = 5 RETURN x * 2', [])).toBe('{x=5; RETURN (* param(x) 2)}');
|
||||
});
|
||||
|
||||
it('a plain assignment can reference an already-declared variable', () => {
|
||||
expect(parseBlock('a = 1 b = a + 1 RETURN b', [])).toBe(
|
||||
'{a=1; b=(+ param(a) 1)} RETURN param(b)',
|
||||
);
|
||||
expect(parseBlock('a = 1 b = a + 1 RETURN b', [])).toBe('{a=1; b=(+ param(a) 1); RETURN param(b)}');
|
||||
});
|
||||
|
||||
it('the FOR source may itself be a cell/range reference, not just a parameter', () => {
|
||||
expect(parseBlock('total = 0 FOR x IN A1:A5 DO total = total + x RETURN total', [])).toBe(
|
||||
'{total=0; FOR(x IN range(0,0:0,4) DO total=(+ param(total) param(x)))} RETURN param(total)',
|
||||
expect(parseBlock('total = 0 FOR x IN A1:A5 DO total = total + x END RETURN total', [])).toBe(
|
||||
'{total=0; FOR(x IN range(0,0:0,4) DO {total=(+ param(total) param(x))}); RETURN param(total)}',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when RETURN is missing entirely', () => {
|
||||
expect(() => parseFunctionBlock('x = 1', [])).toThrow(ParseError);
|
||||
it('a block without any RETURN still parses -- that is a save-time/runtime concern (blockAlwaysReturns, userFunctions.ts), not a parse error', () => {
|
||||
expect(parseBlock('x = 1', [])).toBe('{x=1}');
|
||||
});
|
||||
|
||||
it('throws on FOR without a matching END', () => {
|
||||
expect(() => parseFunctionBlock('FOR x IN bereich DO y = x RETURN y', ['bereich'])).toThrow(
|
||||
ParseError,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on FOR without a matching DO', () => {
|
||||
@@ -226,11 +294,19 @@ describe('parseFunctionBlock -- FOR/DO/RETURN "block" function bodies', () => {
|
||||
});
|
||||
|
||||
it('throws on FOR without IN', () => {
|
||||
expect(() => parseFunctionBlock('FOR x bereich DO y = x RETURN y', ['bereich'])).toThrow(
|
||||
expect(() => parseFunctionBlock('FOR x bereich DO y = x END RETURN y', ['bereich'])).toThrow(
|
||||
ParseError,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on IF without THEN', () => {
|
||||
expect(() => parseFunctionBlock('IF x > 0 x = 1 END RETURN x', ['x'])).toThrow(ParseError);
|
||||
});
|
||||
|
||||
it('throws on IF without a matching END', () => {
|
||||
expect(() => parseFunctionBlock('IF x > 0 THEN x = 1 RETURN x', ['x'])).toThrow(ParseError);
|
||||
});
|
||||
|
||||
it('throws when an assignment is missing "="', () => {
|
||||
expect(() => parseFunctionBlock('x 1 RETURN x', [])).toThrow(ParseError);
|
||||
});
|
||||
|
||||
@@ -248,63 +248,28 @@ describe('Workbook integration: user-defined functions end-to-end', () => {
|
||||
const productBlock = {
|
||||
params: ['bereich'],
|
||||
kind: 'block' as const,
|
||||
body: 'ergebnis = 1 FOR x IN bereich DO ergebnis = ergebnis * x RETURN ergebnis',
|
||||
body: 'ergebnis = 1 FOR x IN bereich DO ergebnis = ergebnis * x END RETURN ergebnis',
|
||||
};
|
||||
|
||||
describe('expandUserFunctions (AST-level): kind "block" -- FOR loops', () => {
|
||||
it('a single FOR loop unrolls into one nested expression, left-to-right', () => {
|
||||
describe('expandUserFunctions (AST-level): kind "block" produces a BlockCallNode', () => {
|
||||
it('wraps a block-kind call in a BlockCallNode carrying args/paramNames/label', () => {
|
||||
const index = buildFunctionIndex([def({ id: 'f1', name: 'PRODUCT2', ...productBlock })]);
|
||||
const expanded = expandUserFunctions(parseFormula('=PRODUCT2(A1:A3)'), index);
|
||||
// ((1 * A1) * A2) * A3
|
||||
expect(expanded).toEqual({
|
||||
kind: 'binary',
|
||||
op: '*',
|
||||
left: {
|
||||
kind: 'binary',
|
||||
op: '*',
|
||||
left: {
|
||||
kind: 'binary',
|
||||
op: '*',
|
||||
left: { kind: 'number', value: 1 },
|
||||
right: { kind: 'cell', qualifier: null, col: 0, row: 0 },
|
||||
},
|
||||
right: { kind: 'cell', qualifier: null, col: 0, row: 1 },
|
||||
expect(expanded.kind).toBe('blockcall');
|
||||
if (expanded.kind !== 'blockcall') throw new Error('unreachable');
|
||||
expect(expanded.label).toBe('PRODUCT2');
|
||||
expect(expanded.paramNames).toEqual(['bereich']);
|
||||
expect(expanded.args).toEqual([
|
||||
{
|
||||
kind: 'range',
|
||||
qualifier: null,
|
||||
start: { col: 0, row: 0, colAbs: false, rowAbs: false },
|
||||
end: { col: 0, row: 2, colAbs: false, rowAbs: false },
|
||||
},
|
||||
right: { kind: 'cell', qualifier: null, col: 0, row: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
it('a single-cell FOR source (no range) unrolls to exactly one iteration', () => {
|
||||
const index = buildFunctionIndex([def({ id: 'f1', name: 'PRODUCT2', ...productBlock })]);
|
||||
const expanded = expandUserFunctions(parseFormula('=PRODUCT2(A1)'), index);
|
||||
expect(expanded).toEqual({
|
||||
kind: 'binary',
|
||||
op: '*',
|
||||
left: { kind: 'number', value: 1 },
|
||||
right: { kind: 'cell', qualifier: null, col: 0, row: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it('a loop variable / local stays visible (holding its last value) after its own loop', () => {
|
||||
const index = buildFunctionIndex([
|
||||
def({
|
||||
id: 'f1',
|
||||
name: 'LASTPLUSONE',
|
||||
params: ['bereich'],
|
||||
kind: 'block',
|
||||
body: 'acc = 0 FOR x IN bereich DO acc = acc + x RETURN x + 1', // returns the loop VAR, not acc
|
||||
}),
|
||||
]);
|
||||
const expanded = expandUserFunctions(parseFormula('=LASTPLUSONE(A1:A3)'), index);
|
||||
expect(expanded).toEqual({
|
||||
kind: 'binary',
|
||||
op: '+',
|
||||
left: { kind: 'cell', qualifier: null, col: 0, row: 2 }, // A3 -- the last iterated cell
|
||||
right: { kind: 'number', value: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('a call inside a FOR body to another (expression-kind) function is expanded too', () => {
|
||||
it('resolves a nested call to another (expression-kind) function inside the block before evaluation', () => {
|
||||
const index = buildFunctionIndex([
|
||||
def({ id: 'd1', name: 'DOUBLE', params: ['n'], body: 'n * 2' }),
|
||||
def({
|
||||
@@ -312,93 +277,37 @@ describe('expandUserFunctions (AST-level): kind "block" -- FOR loops', () => {
|
||||
name: 'SUMDOUBLED',
|
||||
params: ['bereich'],
|
||||
kind: 'block',
|
||||
body: 'total = 0 FOR x IN bereich DO total = total + DOUBLE(x) RETURN total',
|
||||
body: 'total = 0 FOR x IN bereich DO total = total + DOUBLE(x) END RETURN total',
|
||||
}),
|
||||
]);
|
||||
const expanded = expandUserFunctions(parseFormula('=SUMDOUBLED(A1:A2)'), index);
|
||||
// (0 + (A1*2)) + (A2*2) -- DOUBLE() fully inlined per iteration, no func nodes left
|
||||
expect(expanded).toEqual({
|
||||
if (expanded.kind !== 'blockcall') throw new Error('unreachable');
|
||||
const forStmt = expanded.block.statements[1];
|
||||
if (forStmt.kind !== 'for') throw new Error('expected a for statement');
|
||||
const assignInBody = forStmt.body.statements[0];
|
||||
if (assignInBody.kind !== 'assign') throw new Error('expected an assignment');
|
||||
// DOUBLE(x) has already been inlined to "x * 2" -- no FuncNode calling
|
||||
// "DOUBLE" remains anywhere in the block
|
||||
expect(assignInBody.value).toEqual({
|
||||
kind: 'binary',
|
||||
op: '+',
|
||||
left: {
|
||||
kind: 'binary',
|
||||
op: '+',
|
||||
left: { kind: 'number', value: 0 },
|
||||
right: {
|
||||
kind: 'binary',
|
||||
op: '*',
|
||||
left: { kind: 'cell', qualifier: null, col: 0, row: 0 },
|
||||
right: { kind: 'number', value: 2 },
|
||||
},
|
||||
},
|
||||
left: { kind: 'param', name: 'total' },
|
||||
right: {
|
||||
kind: 'binary',
|
||||
op: '*',
|
||||
left: { kind: 'cell', qualifier: null, col: 0, row: 1 },
|
||||
left: { kind: 'param', name: 'x' },
|
||||
right: { kind: 'number', value: 2 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('multiple independent FOR loops each accumulate correctly', () => {
|
||||
const index = buildFunctionIndex([
|
||||
def({
|
||||
id: 'c1',
|
||||
name: 'COMBINED',
|
||||
params: ['sumRange', 'prodRange'],
|
||||
kind: 'block',
|
||||
body:
|
||||
'total = 0 FOR a IN sumRange DO total = total + a ' +
|
||||
'prod = 1 FOR b IN prodRange DO prod = prod * b ' +
|
||||
'RETURN total + prod',
|
||||
}),
|
||||
]);
|
||||
const expanded = expandUserFunctions(parseFormula('=COMBINED(A1:A2; B1:B2)'), index);
|
||||
expect(expanded).toEqual({
|
||||
kind: 'binary',
|
||||
op: '+',
|
||||
left: {
|
||||
kind: 'binary',
|
||||
op: '+',
|
||||
left: { kind: 'binary', op: '+', left: { kind: 'number', value: 0 }, right: { kind: 'cell', qualifier: null, col: 0, row: 0 } },
|
||||
right: { kind: 'cell', qualifier: null, col: 0, row: 1 },
|
||||
},
|
||||
right: {
|
||||
kind: 'binary',
|
||||
op: '*',
|
||||
left: { kind: 'binary', op: '*', left: { kind: 'number', value: 1 }, right: { kind: 'cell', qualifier: null, col: 1, row: 0 } },
|
||||
right: { kind: 'cell', qualifier: null, col: 1, row: 1 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('arity mismatch is still checked for block-kind functions', () => {
|
||||
const index = buildFunctionIndex([def({ id: 'f1', name: 'PRODUCT2', ...productBlock })]);
|
||||
expect(() => expandUserFunctions(parseFormula('=PRODUCT2(A1;A2)'), index)).toThrow();
|
||||
});
|
||||
|
||||
it('a FOR source that is not a cell/range after substitution throws', () => {
|
||||
const index = buildFunctionIndex([
|
||||
def({
|
||||
id: 'f1',
|
||||
name: 'BAD',
|
||||
params: ['x'],
|
||||
kind: 'block',
|
||||
body: 'total = 0 FOR y IN x + 1 DO total = total + y RETURN total',
|
||||
}),
|
||||
]);
|
||||
// x substituted with a NumberNode -> the FOR source becomes "5 + 1", not a cell/range
|
||||
expect(() => expandUserFunctions(parseFormula('=BAD(5)'), index)).toThrow();
|
||||
});
|
||||
|
||||
it('a FOR loop over more cells than the unroll cap throws', () => {
|
||||
const index = buildFunctionIndex([def({ id: 'f1', name: 'PRODUCT2', ...productBlock })]);
|
||||
// A1:A20000 -> 20000 cells, well over the 10,000-cell cap
|
||||
expect(() => expandUserFunctions(parseFormula('=PRODUCT2(A1:A20000)'), index)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Workbook integration: block-kind (FOR loop) user-defined functions', () => {
|
||||
describe('Workbook integration: block-kind (FOR/IF/RETURN "code") user-defined functions', () => {
|
||||
it('evaluates a PRODUCT-style reduction over a range correctly', () => {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '2', A2: '3', A3: '4', B9: '=PRODUCT2(A1:A3)' });
|
||||
const wb = new Workbook([umsatz], [def({ id: 'f1', name: 'PRODUCT2', ...productBlock })]);
|
||||
@@ -412,7 +321,107 @@ describe('Workbook integration: block-kind (FOR loop) user-defined functions', (
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(10 * 3 * 4);
|
||||
});
|
||||
|
||||
it('exceeding the unroll cap degrades to #ERR at the cell, not a crash', () => {
|
||||
it('a FOR body with multiple statements runs all of them, every iteration', () => {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '2', A2: '3', B9: '=DOUBLESUM(A1:A2)' });
|
||||
const doubleSum = def({
|
||||
id: 'f1',
|
||||
name: 'DOUBLESUM',
|
||||
params: ['bereich'],
|
||||
kind: 'block',
|
||||
body: 'total = 0 FOR x IN bereich DO y = x * 2 total = total + y END RETURN total',
|
||||
});
|
||||
const wb = new Workbook([umsatz], [doubleSum]);
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(2 * 2 + 3 * 2);
|
||||
});
|
||||
|
||||
it('a FOR loop nested inside another FOR loop evaluates correctly, not just parses', () => {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', {
|
||||
A1: '2',
|
||||
A2: '3',
|
||||
B1: '10',
|
||||
B2: '20',
|
||||
B9: '=OUTERPROD(A1:A2; B1:B2)',
|
||||
});
|
||||
const outerProd = def({
|
||||
id: 'f1',
|
||||
name: 'OUTERPROD',
|
||||
params: ['r1', 'r2'],
|
||||
kind: 'block',
|
||||
body: 'total = 0 FOR a IN r1 DO FOR b IN r2 DO total = total + a * b END END RETURN total',
|
||||
});
|
||||
const wb = new Workbook([umsatz], [outerProd]);
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(2 * 10 + 2 * 20 + 3 * 10 + 3 * 20);
|
||||
});
|
||||
|
||||
it('IF/ELSE branches on the real, current cell value', () => {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '5', B9: '=SIGN2(A1)' });
|
||||
const sign2 = def({
|
||||
id: 'f1',
|
||||
name: 'SIGN2',
|
||||
params: ['x'],
|
||||
kind: 'block',
|
||||
body: 'IF x > 0 THEN RETURN 1 ELSE RETURN -1 END',
|
||||
});
|
||||
const wb = new Workbook([umsatz], [sign2]);
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(1);
|
||||
wb.setCell('s-umsatz', 0, 0, '-5');
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(-1);
|
||||
});
|
||||
|
||||
it('comparison operators work inside an IF condition', () => {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '10', A2: '10', B9: '=EQ2(A1;A2)' });
|
||||
const eq2 = def({
|
||||
id: 'f1',
|
||||
name: 'EQ2',
|
||||
params: ['a', 'b'],
|
||||
kind: 'block',
|
||||
body: 'IF a = b THEN RETURN 1 ELSE RETURN 0 END',
|
||||
});
|
||||
const wb = new Workbook([umsatz], [eq2]);
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(1);
|
||||
wb.setCell('s-umsatz', 0, 1, '11'); // A2: 10 -> 11
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(0);
|
||||
});
|
||||
|
||||
it('an early RETURN nested inside an IF inside a FOR stops the loop -- finds the first match, never touches later cells', () => {
|
||||
// A3 is the first cell > 100; A4 must never affect the result
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', {
|
||||
A1: '1',
|
||||
A2: '2',
|
||||
A3: '999',
|
||||
A4: '4',
|
||||
B9: '=FIRSTOVER(A1:A4; 100)',
|
||||
});
|
||||
const firstOver = def({
|
||||
id: 'f1',
|
||||
name: 'FIRSTOVER',
|
||||
params: ['bereich', 'schwelle'],
|
||||
kind: 'block',
|
||||
body: 'FOR x IN bereich DO IF x > schwelle THEN RETURN x END END RETURN 0',
|
||||
});
|
||||
const wb = new Workbook([umsatz], [firstOver]);
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(999);
|
||||
// changing the never-reached A4 doesn't change the (already-found) result
|
||||
wb.setCell('s-umsatz', 0, 3, '99999');
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(999);
|
||||
});
|
||||
|
||||
it('a cell referenced only in a not-taken IF branch is still a tracked dependency', () => {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '5', A2: '1', A3: '2', B9: '=BRANCHY(A1)' });
|
||||
const branchy = def({
|
||||
id: 'f1',
|
||||
name: 'BRANCHY',
|
||||
params: ['x'],
|
||||
kind: 'block',
|
||||
body: 'IF x > 0 THEN RETURN A2 ELSE RETURN A3 END',
|
||||
});
|
||||
const wb = new Workbook([umsatz], [branchy]);
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(1); // A1=5>0 -> takes the A2 branch
|
||||
wb.setCell('s-umsatz', 0, 0, '-5'); // flips to the A3 branch
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(2);
|
||||
});
|
||||
|
||||
it('exceeding the loop-step cap degrades to #ERR at the cell, not a crash', () => {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', { B9: '=PRODUCT2(A1:A20000)' });
|
||||
const wb = new Workbook([umsatz], [def({ id: 'f1', name: 'PRODUCT2', ...productBlock })]);
|
||||
expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#ERR');
|
||||
@@ -428,10 +437,17 @@ describe('Workbook integration: block-kind (FOR loop) user-defined functions', (
|
||||
name: 'BAD',
|
||||
params: ['x'],
|
||||
kind: 'block',
|
||||
body: 'total = 0 FOR y IN x + 1 DO total = total + y RETURN total',
|
||||
body: 'total = 0 FOR y IN x + 1 DO total = total + y END RETURN total',
|
||||
}),
|
||||
],
|
||||
);
|
||||
expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#ERR');
|
||||
});
|
||||
|
||||
it('a function that never reaches RETURN degrades to #ERR, not a crash', () => {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', { B9: '=NORETURN(1)' });
|
||||
const noReturn = def({ id: 'f1', name: 'NORETURN', params: ['x'], kind: 'block', body: 'y = x + 1' });
|
||||
const wb = new Workbook([umsatz], [noReturn]);
|
||||
expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#ERR');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user