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:
wkh
2026-09-02 22:41:55 +02:00
co-authored by Claude Sonnet 5
parent 6daea9f956
commit 01ea18106c
13 changed files with 1143 additions and 484 deletions
+20
View File
@@ -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']);
});
});
+98 -22
View File
@@ -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);
});
+135 -119
View File
@@ -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');
});
});