IF-Funktion als Formel: echte Kurzschluss-Auswertung

Neue eingebaute Funktion IF(Bedingung; Dann; Sonst?), nutzbar in jeder
gewöhnlichen Zellformel (nicht nur in "Mit Schleife"-Funktionskörpern).

- functions.ts: IF als Platzhalter-Eintrag registriert, nur damit
  hasFunction('IF') greift (Namensreservierung -- ein Benutzer kann
  keine gleichnamige eigene Funktion mehr anlegen) und als defensiver
  Fallback. Trägt einen Kommentar, der auf die echte Implementierung
  verweist.
- evaluator.ts: die echte, kurzschließende Auswertung sitzt in
  evalScalar()s case 'func', VOR dem generischen eager-args-Pfad.
  evalIf() wertet nur die Bedingung immer aus, danach ausschließlich
  den genommenen Zweig -- der andere Zweig wird nie evaluiert, ein
  Fehler darin (z. B. =IF(B1=0; 0; A1/B1)) schlägt also nie durch.
  Gleiche Wahrheits-Konvention wie überall sonst im Engine (Zahl != 0
  bzw. nicht-leerer Text = wahr).
- collectRefs() bleibt unverändert: sein generischer case 'func' läuft
  bereits konservativ durch alle Argumente, unabhängig vom zur
  Laufzeit genommenen Zweig -- exakt wie Excels IF().

Tests: 8 neue (evaluator.spec.ts: beide Zweige, 2-/3-Argument-Form,
Kurzschluss-Beweis, Fehler in der Bedingung, Arity-Fehler, Vergleiche
als Bedingung; document.spec.ts: Namenskollision mit eingebautem IF).
257 -> 265 Tests, alle grün. vue-tsc/eslint/build sauber. Live per
Playwright verifiziert (inkl. Kurzschluss- und Neuberechnungsverhalten).

READMEs aktualisiert (Funktionen-Liste, Auswertungsmodell, Testzahl).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
wkh
2026-09-02 23:00:45 +02:00
co-authored by Claude Sonnet 5
parent 3fd5a84701
commit ec85c371e8
6 changed files with 129 additions and 11 deletions
+21 -8
View File
@@ -13,7 +13,7 @@ Vorbild von **Apple Numbers**. Zwei Dinge stehen im Mittelpunkt:
```bash ```bash
npm install npm install
npm run dev # Vite-Devserver npm run dev # Vite-Devserver
npm run test # Vitest-Suite (Engine + Store + IO), 257 Tests npm run test # Vitest-Suite (Engine + Store + IO), 265 Tests
npm run build # Typecheck + Produktionsbuild npm run build # Typecheck + Produktionsbuild
npm run lint # ESLint (flat config) npm run lint # ESLint (flat config)
``` ```
@@ -399,11 +399,22 @@ darauf, `$` hin oder her. `F4` schaltet den Bezug unter dem Cursor durch die
vier Kombinationen. vier Kombinationen.
**Funktionen** (case-insensitiv, Trenner `;`): `SUM`, `AVG` (Alias `AVERAGE`), **Funktionen** (case-insensitiv, Trenner `;`): `SUM`, `AVG` (Alias `AVERAGE`),
`MIN`, `MAX`, `COUNT` — plus alle über **„𝑓 Funktionen"** angelegten `MIN`, `MAX`, `COUNT`, `IF(Bedingung; Dann; Sonst?)` — plus alle über
benutzerdefinierten Funktionen (siehe „Benutzerdefinierte Funktionen" oben), **„𝑓 Funktionen"** angelegten benutzerdefinierten Funktionen (siehe
die sich für den Aufrufer identisch verhalten. Argumente gemischt: Einzelzellen, „Benutzerdefinierte Funktionen" oben), die sich für den Aufrufer identisch
Bereiche (lokal oder `Name!…`), Zahl-Literale — z. B. verhalten. Argumente gemischt: Einzelzellen, Bereiche (lokal oder `Name!…`),
`=SUM(Umsatz!B2:E2; 100; A1)`. Zahl-Literale — z. B. `=SUM(Umsatz!B2:E2; 100; A1)`.
`IF` ist die einzige Funktion, die ihre Argumente **nicht** vorab auswertet:
nur die Bedingung wird immer berechnet, danach ausschließlich der tatsächlich
genommene Zweig — der andere Zweig wird gar nicht ausgewertet, ein Fehler
darin (z. B. `=IF(B1=0; 0; A1/B1)`, wenn `B1` `0` ist) schlägt also nie
durch. Die Bedingung nutzt dieselbe Zahl-Semantik wie die Vergleichsoperatoren
`= <> > < >= <=` (Ergebnis `1`/`0`, keinen eigenen Wahrheitstyp): jede
Zahl außer `0` sowie jeder nicht-leere Text gilt als wahr, z. B.
`=IF(A1>=100; "groß"; "klein")`. Ohne Sonst-Zweig liefert eine falsche
Bedingung `0`. Ein benutzerdefiniertes „IF" ist nicht anlegbar — der Name
kollidiert mit der eingebauten Funktion.
**Fehlermodell** (propagiert an abhängige Zellen): **Fehlermodell** (propagiert an abhängige Zellen):
`#REF` (ungültiger/gelöschter Bezug, unbekannter Tabellen-/Blattname, oder ein `#REF` (ungültiger/gelöschter Bezug, unbekannter Tabellen-/Blattname, oder ein
@@ -521,7 +532,7 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
## Tests ## Tests
`npm run test` — Vitest, 257 Tests: `npm run test` — Vitest, 265 Tests:
- **lexer / parser** — Präzedenz & Assoziativität (`^` vor `*/` vor `+-`, `^` - **lexer / parser** — Präzedenz & Assoziativität (`^` vor `*/` vor `+-`, `^`
rechts, `-2^2 = -(2^2)`), lokale/qualifizierte/quoted Bezüge, `!` **und** `::`, rechts, `-2^2 = -(2^2)`), lokale/qualifizierte/quoted Bezüge, `!` **und** `::`,
@@ -533,7 +544,9 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
einmal um (jeden vom eigenen Zustand aus) und lässt Tabellen-Qualifier, die einmal um (jeden vom eigenen Zustand aus) und lässt Tabellen-Qualifier, die
wie eine Zelle aussehen (`AB12!A1`), unangetastet. wie eine Zelle aussehen (`AB12!A1`), unangetastet.
- **evaluator** — Arithmetik, leere Zelle = 0, alle Funktionen inkl. Alias, - **evaluator** — Arithmetik, leere Zelle = 0, alle Funktionen inkl. Alias,
Cross-Table, Case-/`::`-Toleranz, jeder Fehlerfall, Fehler-Propagierung. Cross-Table, Case-/`::`-Toleranz, jeder Fehlerfall, Fehler-Propagierung,
`IF` (beide Zweige, 2-/3-Argument-Form, Kurzschluss-Beweis, Vergleiche als
Bedingung, Fehler in der Bedingung).
- **graph** — `affected`, topologische Ordnung, Selbst-/2er-/längerer Zyklus. - **graph** — `affected`, topologische Ordnung, Selbst-/2er-/längerer Zyklus.
- **workbook / store** — Seed-Aggregate, Cross-Table-Summen, Edit-Ripple durch - **workbook / store** — Seed-Aggregate, Cross-Table-Summen, Edit-Ripple durch
die ganze Kette, `#REF` nach Tabellen-/Blatt-Löschung, neues Tabellen-Objekt die ganze Kette, `#REF` nach Tabellen-/Blatt-Löschung, neues Tabellen-Objekt
+15 -1
View File
@@ -19,7 +19,7 @@ genau die genannte Tabelle. Siehe "Blatt-Qualifier" weiter unten.
| `evaluator.ts` | AST + `EngineHost` → `CellValue`. Numerisches Auswertungsmodell, Fehler-Propagierung. Enthält auch den kleinen Interpreter für `kind: 'block'`-Funktionsaufrufe (`BlockCallNode`) – siehe „Schleifen" unten. | | `evaluator.ts` | AST + `EngineHost` → `CellValue`. Numerisches Auswertungsmodell, Fehler-Propagierung. Enthält auch den kleinen Interpreter für `kind: 'block'`-Funktionsaufrufe (`BlockCallNode`) – siehe „Schleifen" unten. |
| `graph.ts` | Dokumentweiter Abhängigkeitsgraph: `affected()`, `topoOrder()`, Zirkel-Erkennung (iterative DFS, white/grey/black). | | `graph.ts` | Dokumentweiter Abhängigkeitsgraph: `affected()`, `topoOrder()`, Zirkel-Erkennung (iterative DFS, white/grey/black). |
| `refs.ts` | A1 ↔ `(col,row)` inkl. `$`-Sperrachsen, Spaltenbuchstaben, Bereichs-Expansion, `F4`-Zyklus (`cycleReferenceLocks`). | | `refs.ts` | A1 ↔ `(col,row)` inkl. `$`-Sperrachsen, Spaltenbuchstaben, Bereichs-Expansion, `F4`-Zyklus (`cycleReferenceLocks`). |
| `functions.ts` | `SUM/AVG/MIN/MAX/COUNT` (+ Alias `AVERAGE`) als Tabelle `{ NAME → fn(values) }`. Eingebaute Namen sind nie überschreibbar. | | `functions.ts` | `SUM/AVG/MIN/MAX/COUNT` (+ Alias `AVERAGE`) als Tabelle `{ NAME → fn(values) }`. `IF` ist hier nur als Platzhalter registriert (für `hasFunction()`/Namensreservierung); die echte, kurzschließende Auswertung sitzt in `evaluator.ts`, s. u. Eingebaute Namen sind nie überschreibbar. |
| `userFunctions.ts` | Benutzerdefinierte Funktionen (dokumentweite Bibliothek, angelegt über die UI — siehe Haupt-`README.md`). `expandUserFunctions()` setzt einen `kind: 'expression'`-Aufruf beim Parsen der aufrufenden Zelle als Kopie des rekursiv expandierten, parametersubstituierten Funktionskörpers in deren AST ein; einen `kind: 'block'`-Aufruf (FOR/IF/RETURN-Code) verpackt sie stattdessen in einen `BlockCallNode` mit bereits aufgelösten verschachtelten Aufrufen, siehe „Schleifen" unten. | | `userFunctions.ts` | Benutzerdefinierte Funktionen (dokumentweite Bibliothek, angelegt über die UI — siehe Haupt-`README.md`). `expandUserFunctions()` setzt einen `kind: 'expression'`-Aufruf beim Parsen der aufrufenden Zelle als Kopie des rekursiv expandierten, parametersubstituierten Funktionskörpers in deren AST ein; einen `kind: 'block'`-Aufruf (FOR/IF/RETURN-Code) verpackt sie stattdessen in einen `BlockCallNode` mit bereits aufgelösten verschachtelten Aufrufen, siehe „Schleifen" unten. |
| `workbook.ts` | Bindeglied: flache Liste von **Tabellen** + rohe Eingaben + ASTs + Werte + Graph. Inkrementelle Neuberechnung. Implementiert `EngineHost`. Ruft `expandUserFunctions()` direkt nach `parseFormula()`. | | `workbook.ts` | Bindeglied: flache Liste von **Tabellen** + rohe Eingaben + ASTs + Werte + Graph. Inkrementelle Neuberechnung. Implementiert `EngineHost`. Ruft `expandUserFunctions()` direkt nach `parseFormula()`. |
| `types.ts` | Gemeinsame Typen, `FormulaError`, `keyOf()`. | | `types.ts` | Gemeinsame Typen, `FormulaError`, `keyOf()`. |
@@ -300,6 +300,20 @@ dagegen erlaubt), `blockAlwaysReturns` sowie die Zyklus-Prüfung.
- `SUM/MIN/MAX` über „keine Zahlen“ → `0`; `AVG` über „keine Zahlen“ → `#DIV/0!`. - `SUM/MIN/MAX` über „keine Zahlen“ → `0`; `AVG` über „keine Zahlen“ → `#DIV/0!`.
- `COUNT` zählt nur echte Zahlen – leere Zellen und Text werden nicht gezählt. - `COUNT` zählt nur echte Zahlen – leere Zellen und Text werden nicht gezählt.
- `^` = `Math.pow`, rechts-assoziativ: `=2^3^2` → `2^(3^2)` = `512`. - `^` = `Math.pow`, rechts-assoziativ: `=2^3^2` → `2^(3^2)` = `512`.
- **`IF(Bedingung; Dann; Sonst?)` ist die einzige Funktion, die nicht eager
ausgewertet wird.** Jede andere Funktion sieht in `functions.ts` bereits
fertig berechnete `CellValue`s (der Evaluator wertet vorher jedes Argument
aus und short-circuited beim ersten Fehler); `IF` wird stattdessen schon in
`evalScalar`s `case 'func':` abgefangen, bevor `functions.ts` je erreicht
wird (`evalIf()`): nur die Bedingung wird immer ausgewertet, danach
ausschließlich der genommene Zweig – der andere Zweig wird gar nicht erst
evaluiert, ein Fehler darin schlägt also nie durch (klassisch:
`=IF(B1=0; 0; A1/B1)`). Ohne Sonst-Zweig liefert eine falsche Bedingung
`0`. `collectRefs()` bleibt für `IF` bewusst generisch/konservativ (läuft
durch alle 2–3 Argumente unabhängig vom Laufzeit-Zweig) – dieselbe
Konvention wie bei `kind: 'block'`s `IF`-Abhängigkeiten (s. u.). Ein
benutzerdefiniertes „IF" ist nicht anlegbar (Namenskollision mit dem
eingebauten `FUNCTIONS.IF`-Eintrag).
## Neuberechnung ## Neuberechnung
+38
View File
@@ -87,6 +87,44 @@ describe('evaluator functions', () => {
}); });
}); });
describe('evaluator IF', () => {
const host = makeHost({ S1: { A1: 8, B1: 0 } });
it('picks the then-branch when the condition is truthy', () => {
expect(ev('=IF(1;2;3)', host)).toBe(2);
expect(ev('=IF(A1>5;"groß";"klein")', host)).toBe('groß');
});
it('picks the else-branch when the condition is falsy', () => {
expect(ev('=IF(0;2;3)', host)).toBe(3);
expect(ev('=IF(A1<5;"groß";"klein")', host)).toBe('klein');
});
it('returns 0 for a falsy condition with no else-branch', () => {
expect(ev('=IF(0;2)', host)).toBe(0);
});
it('never evaluates the untaken branch (short-circuit)', () => {
expect(ev('=IF(1;2;1/0)', host)).toBe(2);
expect(ev('=IF(B1=0;0;A1/B1)', host)).toBe(0);
});
it('propagates an error from the condition itself', () => {
const r = ev('=IF(1/0;2;3)', host);
expect(isError(r) && r.code).toBe('#DIV/0!');
});
it('wrong number of arguments -> #ERR', () => {
expect(isError(ev('=IF(1)', host))).toBe(true);
expect(isError(ev('=IF(1;2;3;4)', host))).toBe(true);
});
it('works with comparison operators as the condition', () => {
expect(ev('=IF(A1>=8;1;0)', host)).toBe(1);
expect(ev('=IF(A1<>8;1;0)', host)).toBe(0);
});
});
describe('evaluator cross-sheet & errors', () => { describe('evaluator cross-sheet & errors', () => {
const host = makeHost({ const host = makeHost({
S1: { A1: 3 }, S1: { A1: 3 },
+27 -1
View File
@@ -27,7 +27,7 @@
* ordinary function call this can't be resolved any earlier. * ordinary function call this can't be resolved any earlier.
*/ */
import type { BlockCallNode, BlockNode, CellNode, Node, Statement } from './parser'; import type { BlockCallNode, BlockNode, CellNode, FuncNode, Node, Statement } from './parser';
import { getFunction } from './functions'; import { getFunction } from './functions';
import { expandRange } from './refs'; import { expandRange } from './refs';
import { substitute } from './userFunctions'; import { substitute } from './userFunctions';
@@ -101,6 +101,12 @@ function evalScalar(node: Node, host: EngineHost): CellValue {
return new FormulaError('#VALUE!', 'range used where a value is expected'); return new FormulaError('#VALUE!', 'range used where a value is expected');
case 'func': { case 'func': {
// IF is the one built-in that must NOT evaluate its arguments eagerly
// (see functions.ts's FUNCTIONS.IF comment): only the taken branch may
// be computed, so e.g. IF(B1=0; 0; A1/B1) never divides by zero when
// B1=0. Special-cased here, before the generic eager-args dispatch.
if (node.name.toUpperCase() === 'IF') return evalIf(node, host);
const fn = getFunction(node.name); const fn = getFunction(node.name);
if (!fn) return new FormulaError('#NAME?', `unknown function ${node.name}`); if (!fn) return new FormulaError('#NAME?', `unknown function ${node.name}`);
const values: CellValue[] = []; const values: CellValue[] = [];
@@ -127,6 +133,26 @@ function evalScalar(node: Node, host: EngineHost): CellValue {
} }
} }
/** IF(cond; then; else?) -- the real, short-circuiting implementation (see
* the `case 'func'` special-case above and functions.ts's FUNCTIONS.IF
* comment). Only `node.args[0]` is always evaluated; whichever of the
* other two branches is taken is evaluated next, and the other is never
* touched at all -- so an error (or, in a block-mode call, any other side
* effect) in the untaken branch never surfaces. Same truthy convention as
* everywhere else in this engine (`execStatement`'s `ifstmt` case, above):
* a number is truthy unless it's exactly 0; text/refs are truthy unless
* empty. */
function evalIf(node: FuncNode, host: EngineHost): CellValue {
if (node.args.length !== 2 && node.args.length !== 3) {
return new FormulaError('#ERR', 'IF expects 2 or 3 arguments');
}
const cond = evalScalar(node.args[0], host);
if (isError(cond)) return cond;
const truthy = typeof cond === 'number' ? cond !== 0 : cond !== null && cond !== '';
if (truthy) return evalScalar(node.args[1], host);
return node.args.length === 3 ? evalScalar(node.args[2], host) : 0;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// `kind: 'block'` custom functions: a small tree-walking interpreter for a // `kind: 'block'` custom functions: a small tree-walking interpreter for a
// BlockCallNode's `block` (see parser.ts's grammar comment and // BlockCallNode's `block` (see parser.ts's grammar comment and
+21 -1
View File
@@ -13,7 +13,7 @@
* - text -> ignored by the numeric aggregates * - text -> ignored by the numeric aggregates
*/ */
import { FormulaError, type CellValue } from './types'; import { FormulaError, isError, type CellValue } from './types';
export type FunctionFn = (values: CellValue[]) => CellValue; export type FunctionFn = (values: CellValue[]) => CellValue;
@@ -50,6 +50,26 @@ export const FUNCTIONS: Record<string, FunctionFn> = {
COUNT(values) { COUNT(values) {
return nums(values).length; return nums(values).length;
}, },
// IF(cond; then; else?) -- evaluator.ts special-cases this name *before*
// ever reaching FUNCTIONS, giving it real short-circuit evaluation (only
// the taken branch is computed, so e.g. IF(B1=0; 0; A1/B1) never runs the
// division when B1=0). This entry only exists so IF is a registered
// built-in for hasFunction()'s sake (the store's custom-function name
// check then correctly refuses to let a user shadow it) and as a
// defensive, best-effort fallback if this were ever reached directly
// (values are already-eagerly-computed here, so it can't short-circuit --
// see evaluator.ts's evalIf() for the real implementation).
IF(values) {
if (values.length !== 2 && values.length !== 3) {
return new FormulaError('#ERR', 'IF expects 2 or 3 arguments');
}
const [cond, thenValue, elseValue] = values;
if (isError(cond)) return cond;
const truthy = typeof cond === 'number' ? cond !== 0 : cond !== null && cond !== '';
if (truthy) return thenValue;
return values.length === 3 ? elseValue : 0;
},
}; };
// AVERAGE is an alias of AVG. // AVERAGE is an alias of AVG.
+7
View File
@@ -721,6 +721,13 @@ describe('custom functions (document-level library)', () => {
expect(s.doc.customFunctions).toHaveLength(0); expect(s.doc.customFunctions).toHaveLength(0);
}); });
it('rejects a name colliding with the built-in IF function (case-insensitive)', () => {
const s = fresh();
const result = s.addCustomFunction('if', ['x'], 'x') as { error: string };
expect(result.error).toBeTruthy();
expect(s.doc.customFunctions).toHaveLength(0);
});
it('rejects a name colliding with another custom function (case-insensitive)', () => { it('rejects a name colliding with another custom function (case-insensitive)', () => {
const s = fresh(); const s = fresh();
s.addCustomFunction('Marge', ['x'], 'x'); s.addCustomFunction('Marge', ['x'], 'x');