XLOOKUP als Formel, plus überspringbare optionale Argumente (;;)

Neue eingebaute Funktion XLOOKUP(Suchwert; Suchbereich; Rückgabebereich;
[WennNichtGefunden]; [Vergleichsmodus]; [Suchmodus]).

- Grammatik-Erweiterung (allgemein, nicht XLOOKUP-spezifisch): eine leere
  Argumentstelle zwischen zwei ";" (oder vor der schließenden ")") parst
  jetzt als neuer OmittedNode statt eines Parse-Fehlers -- Excels
  "dieses optionale Argument auslassen"-Idiom, z. B.
  XLOOKUP(x;a;b;;-1). Wertet überall wie eine leere Zelle aus (null);
  expand()/substitute() (userFunctions.ts) behandeln ihn als Blatt,
  collectRefs() ignoriert ihn stillschweigend.
- evaluator.ts: XLOOKUP wird -- wie IF -- vor dem generischen eager/
  flatten-Dispatch abgefangen (evalXLookup()), aus zwei Gründen: (1)
  WennNichtGefunden muss lazy ausgewertet werden (Kurzschluss wie bei
  IF), (2) Suchbereich/Rückgabebereich dürfen nicht in eine gemeinsame
  flache Werteliste geraten -- der generische Pfad würde die Grenze
  zwischen beiden Bereichen zerstören. Eigene xlookupEquals()
  (case-insensitiv/getrimmt für Text, kein Zahl-Text-Crossmatch) statt
  des `=`-Operators, der über toNumber() koerziert und nie auf Text
  träfe. Vergleichsmodus -1/1 (exakt/nächstkleiner/-größer, numerisch),
  Suchmodus 1/-1 (Richtung); Excels Platzhalter- und Binärsuch-Modi
  bewusst nicht unterstützt.
- functions.ts: Platzhalter-Eintrag für Namensreservierung (analog IF).
- types.ts: neuer Fehlercode #N/A (kein Treffer, kein
  WennNichtGefunden) -- dokumentierte Erweiterung, wie zuvor #VALUE!.
- collectRefs() brauchte keine Änderung -- sein generischer case 'func'
  läuft schon konservativ durch alle Argumente.

Tests: 22 neue (parser.spec.ts: OmittedNode leading/middle/trailing;
evaluator.spec.ts: SUM(1;;3)-Smoke plus 15 XLOOKUP-Fälle; workbook.spec.ts:
Neuberechnung bei Änderung in Such- ODER Rückgabebereich, keine bei
unbeteiligten Zellen; document.spec.ts: Namenskollision). 298 -> 318
Tests, alle grün. vue-tsc/eslint/build sauber. Live per Playwright
verifiziert (Treffer, #N/A, WennNichtGefunden, Kurzschluss-Beweis,
übersprungenes Argument für Vergleichsmodus, Neuberechnung nach Umbenennen
einer Suchbereich-Zelle inkl. nachträglich ausgelöstem #DIV/0! im vorher
nicht ausgewerteten WennNichtGefunden-Zweig).

READMEs aktualisiert (Funktionen, Fehlermodell, neue Abschnitte "Leere
Argumentstelle" und "Nachschlagen (XLOOKUP)" in engine/README.md,
Bekannte Grenzen, Testzahl; EBNF-Grammatik in parser.ts kommentiert).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
wkh
2026-09-03 08:21:11 +02:00
co-authored by Claude Sonnet 5
parent af01aa5e53
commit 552f1cfae8
11 changed files with 451 additions and 21 deletions
+88
View File
@@ -85,6 +85,11 @@ describe('evaluator functions', () => {
const r = ev('=FOO(1)', host);
expect(isError(r) && r.code).toBe('#NAME?');
});
it('an omitted argument slot (e.g. SUM(1;;3)) behaves like an empty cell', () => {
expect(ev('=SUM(1;;3)', host)).toBe(4);
expect(ev('=COUNT(1;;3)', host)).toBe(2); // omitted, like an empty cell, isn't counted
});
});
describe('evaluator IF', () => {
@@ -125,6 +130,89 @@ describe('evaluator IF', () => {
});
});
describe('evaluator XLOOKUP', () => {
const textHost = makeHost({
S1: {
A2: 'Apfel', B2: 1.5,
A3: 'Birne', B3: 2,
A4: 'Kirsche', B4: 5,
A5: 'Birne', B5: 99, // duplicate, for search-mode tests
},
});
const bracketHost = makeHost({
S1: { D2: 0, E2: 'A', D3: 10, E3: 'B', D4: 20, E4: 'C', D5: 30, E5: 'D' },
});
it('exact-matches text, case-insensitively and trimmed', () => {
expect(ev('=XLOOKUP("Birne";A2:A4;B2:B4)', textHost)).toBe(2);
expect(ev('=XLOOKUP(" BIRNE ";A2:A4;B2:B4)', textHost)).toBe(2);
});
it('exact-matches numbers', () => {
expect(ev('=XLOOKUP(10;D2:D5;E2:E5)', bracketHost)).toBe('B');
});
it('does not cross-match a number against text', () => {
const r = ev('=XLOOKUP(5;A2:A4;B2:B4)', textHost);
expect(isError(r) && r.code).toBe('#N/A');
});
it('no match and no if_not_found -> #N/A', () => {
const r = ev('=XLOOKUP("Traube";A2:A4;B2:B4)', textHost);
expect(isError(r) && r.code).toBe('#N/A');
});
it('no match falls back to if_not_found', () => {
expect(ev('=XLOOKUP("Traube";A2:A4;B2:B4;"?")', textHost)).toBe('?');
});
it('if_not_found is evaluated lazily (never touched on a match)', () => {
expect(ev('=XLOOKUP("Birne";A2:A4;B2:B4;1/0)', textHost)).toBe(2);
});
it('search_mode: 1 (default) finds the first match, -1 the last', () => {
expect(ev('=XLOOKUP("Birne";A2:A5;B2:B5)', textHost)).toBe(2);
expect(ev('=XLOOKUP("Birne";A2:A5;B2:B5;;0;-1)', textHost)).toBe(99);
});
it('match_mode -1/1: exact match still wins outright', () => {
expect(ev('=XLOOKUP(10;D2:D5;E2:E5;;-1)', bracketHost)).toBe('B');
expect(ev('=XLOOKUP(10;D2:D5;E2:E5;;1)', bracketHost)).toBe('B');
});
it('match_mode -1: nearest smaller', () => {
expect(ev('=XLOOKUP(15;D2:D5;E2:E5;;-1)', bracketHost)).toBe('B'); // 10
});
it('match_mode 1: nearest larger', () => {
expect(ev('=XLOOKUP(15;D2:D5;E2:E5;;1)', bracketHost)).toBe('C'); // 20
});
it('match_mode -1/1: no qualifying candidate -> #N/A (or if_not_found)', () => {
expect(isError(ev('=XLOOKUP(-5;D2:D5;E2:E5;;-1)', bracketHost))).toBe(true);
expect(ev('=XLOOKUP(-5;D2:D5;E2:E5;"keine";-1)', bracketHost)).toBe('keine');
});
it('unsupported match_mode (2) or search_mode (2/-2) -> #ERR', () => {
expect(isError(ev('=XLOOKUP("x";A2:A4;B2:B4;;2)', textHost))).toBe(true);
expect(isError(ev('=XLOOKUP("x";A2:A4;B2:B4;;0;2)', textHost))).toBe(true);
});
it('mismatched lookup/return range sizes -> #VALUE!', () => {
const r = ev('=XLOOKUP("x";A2:A4;B2:B5)', textHost);
expect(isError(r) && r.code).toBe('#VALUE!');
});
it('wrong number of arguments -> #ERR', () => {
expect(isError(ev('=XLOOKUP("x";A2:A4)', textHost))).toBe(true);
expect(isError(ev('=XLOOKUP("x";A2:A4;B2:B4;1;2;3;4)', textHost))).toBe(true);
});
it('works with single cells (a 1-cell "range") for lookup/return', () => {
expect(ev('=XLOOKUP("Apfel";A2;B2)', textHost)).toBe(1.5);
});
});
describe('evaluator cross-sheet & errors', () => {
const host = makeHost({
S1: { A1: 3 },
+11
View File
@@ -32,6 +32,8 @@ function sexpr(n: Node): string {
// never appears in freshly-parsed input (see parser.ts's comment on
// BlockCallNode) -- exhaustiveness only, no test exercises this.
return `blockcall(${n.label})`;
case 'omitted':
return 'omitted';
}
}
const parse = (s: string) => sexpr(parseFormula(s));
@@ -118,6 +120,15 @@ describe('parser references', () => {
const refs = collectRefs(parseFormula('=SUM(A1:A3; Kosten!B2) + C4'));
expect(refs).toHaveLength(3);
});
it('an empty argument slot parses as "omitted" -- leading, middle, trailing', () => {
expect(parse('=XLOOKUP(1;A1;B1;;-1)')).toBe(
'(XLOOKUP 1 cell(0,0) cell(1,0) omitted (- 1))',
);
expect(parse('=SUM(;5)')).toBe('(SUM omitted 5)');
expect(parse('=SUM(5;)')).toBe('(SUM 5 omitted)');
expect(parse('=SUM()')).toBe('(SUM )');
});
});
describe('parser sheet qualifier -- "Blatt!Tabelle::Zelle"', () => {
+31
View File
@@ -283,3 +283,34 @@ describe('Workbook: DATE/TIME/TODAY/NOW formulas', () => {
expect(isError(val(wb, 's1', 'A3'))).toBe(true);
});
});
describe('Workbook: XLOOKUP recalculation', () => {
it('rewires and recalculates when a cell inside either range changes', () => {
const wb = new Workbook([
sheet('s1', 'S1', {
A2: 'Apfel', B2: '1.5',
A3: 'Birne', B3: '2',
A4: 'Kirsche', B4: '5',
C1: '=XLOOKUP("Birne";A2:A4;B2:B4)',
}),
]);
expect(val(wb, 's1', 'C1')).toBe(2);
// a change to a cell only inside the RETURN range (not itself the match)
wb.setCell('s1', 1, 2, '20'); // B3
expect(val(wb, 's1', 'C1')).toBe(20);
// a change to a cell only inside the LOOKUP range moves the match
wb.setCell('s1', 0, 1, 'Birne'); // A2 -> now also "Birne", first match wins
expect(val(wb, 's1', 'C1')).toBe(1.5);
});
it('an unrelated cell change does not trigger recalculation', () => {
const wb = new Workbook([
sheet('s1', 'S1', { A2: 'Apfel', B2: '1.5', C1: '=XLOOKUP("Apfel";A2:A2;B2:B2)', D1: '1' }),
]);
expect(val(wb, 's1', 'C1')).toBe(1.5);
wb.setCell('s1', 3, 0, '999'); // D1, not referenced by C1 at all
expect(val(wb, 's1', 'C1')).toBe(1.5);
});
});