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:
@@ -13,7 +13,7 @@ Vorbild von **Apple Numbers**. Zwei Dinge stehen im Mittelpunkt:
|
||||
```bash
|
||||
npm install
|
||||
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 lint # ESLint (flat config)
|
||||
```
|
||||
@@ -399,11 +399,22 @@ darauf, `$` hin oder her. `F4` schaltet den Bezug unter dem Cursor durch die
|
||||
vier Kombinationen.
|
||||
|
||||
**Funktionen** (case-insensitiv, Trenner `;`): `SUM`, `AVG` (Alias `AVERAGE`),
|
||||
`MIN`, `MAX`, `COUNT` — plus alle über **„𝑓 Funktionen"** angelegten
|
||||
benutzerdefinierten Funktionen (siehe „Benutzerdefinierte Funktionen" oben),
|
||||
die sich für den Aufrufer identisch verhalten. Argumente gemischt: Einzelzellen,
|
||||
Bereiche (lokal oder `Name!…`), Zahl-Literale — z. B.
|
||||
`=SUM(Umsatz!B2:E2; 100; A1)`.
|
||||
`MIN`, `MAX`, `COUNT`, `IF(Bedingung; Dann; Sonst?)` — plus alle über
|
||||
**„𝑓 Funktionen"** angelegten benutzerdefinierten Funktionen (siehe
|
||||
„Benutzerdefinierte Funktionen" oben), die sich für den Aufrufer identisch
|
||||
verhalten. Argumente gemischt: Einzelzellen, Bereiche (lokal oder `Name!…`),
|
||||
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):
|
||||
`#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
|
||||
|
||||
`npm run test` — Vitest, 257 Tests:
|
||||
`npm run test` — Vitest, 265 Tests:
|
||||
|
||||
- **lexer / parser** — Präzedenz & Assoziativität (`^` vor `*/` vor `+-`, `^`
|
||||
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
|
||||
wie eine Zelle aussehen (`AB12!A1`), unangetastet.
|
||||
- **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.
|
||||
- **workbook / store** — Seed-Aggregate, Cross-Table-Summen, Edit-Ripple durch
|
||||
die ganze Kette, `#REF` nach Tabellen-/Blatt-Löschung, neues Tabellen-Objekt
|
||||
|
||||
+15
-1
@@ -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. |
|
||||
| `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`). |
|
||||
| `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. |
|
||||
| `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()`. |
|
||||
@@ -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!`.
|
||||
- `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`.
|
||||
- **`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
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
const host = makeHost({
|
||||
S1: { A1: 3 },
|
||||
|
||||
+27
-1
@@ -27,7 +27,7 @@
|
||||
* 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 { expandRange } from './refs';
|
||||
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');
|
||||
|
||||
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);
|
||||
if (!fn) return new FormulaError('#NAME?', `unknown function ${node.name}`);
|
||||
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
|
||||
// BlockCallNode's `block` (see parser.ts's grammar comment and
|
||||
|
||||
+21
-1
@@ -13,7 +13,7 @@
|
||||
* - 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;
|
||||
|
||||
@@ -50,6 +50,26 @@ export const FUNCTIONS: Record<string, FunctionFn> = {
|
||||
COUNT(values) {
|
||||
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.
|
||||
|
||||
@@ -721,6 +721,13 @@ describe('custom functions (document-level library)', () => {
|
||||
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)', () => {
|
||||
const s = fresh();
|
||||
s.addCustomFunction('Marge', ['x'], 'x');
|
||||
|
||||
Reference in New Issue
Block a user