AND/OR/NOT, Fehler-Literale und Zeilen/Spalten einfügen/löschen

- AND(...)/OR(...)/NOT(...) als gewöhnliche Funktionen (kein Kurzschluss-
  Sonderfall wie IF/XLOOKUP nötig, werten wie in echtem Excel alle
  Argumente eager aus). Dabei die bisher dreifach duplizierte
  Wahrheits-Prüfung zu functions.ts#isTruthy() zusammengezogen, jetzt
  von FUNCTIONS.IF, evalIf() und dem Block-IF-Statement gemeinsam genutzt.
  Schließt "kein AND/OR für zusammengesetzte Bedingungen" aus den
  Bekannten Grenzen, auch nutzbar in "Mit Schleife"-IF-Bedingungen.

- Neue Grammatik-Ergänzung: ErrorLiteralNode. Jeder der sieben Fehlercodes
  (#REF, #DIV/0!, #NAME?, #ERR, #CIRC, #VALUE!, #N/A) lässt sich jetzt
  direkt in eine Formel tippen (=IF(A1=0;#N/A;A1)) und wertet unbedingt zu
  genau diesem Fehler aus.

- Zeilen/Spalten einfügen/löschen mitten im Gitter (Rechtsklick auf einen
  Zeilen-/Spaltenkopf), nicht nur am Rand wie bisher: Zellinhalte, Formate
  und Zeilenhöhen/Spaltenbreiten verschieben sich innerhalb der Tabelle,
  und jede Formel im gesamten Dokument, die die Tabelle referenziert (auch
  über Tabelle!Zelle/Blatt!Tabelle::Zelle-Qualifier aus anderen Tabellen),
  wird automatisch mitgezogen. Ein Bereich, der die Stelle überspannt,
  wächst/schrumpft entsprechend; eine Referenz, die exakt die gelöschte
  Zeile/Spalte trifft, wird zu einem echten #REF-Literal im Formeltext
  (nutzt die neue ErrorLiteralNode-Grammatik). $-Sperren bleiben dabei
  bewusst wirkungslos (anders als beim Kopieren), unveränderte Referenzen
  bleiben byte-identisch. Token-Level-Splice wie shiftFormula (Kopieren/
  Ausschneiden), keine AST-Neuserialisierung.

406 Tests, vue-tsc/eslint/build sauber, Playwright-live-verifiziert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
wkh
2026-09-08 22:34:33 +02:00
co-authored by Claude Sonnet 5
parent 2c54537427
commit aef8f1349c
14 changed files with 937 additions and 22 deletions
+68
View File
@@ -136,6 +136,74 @@ describe('evaluator IF', () => {
});
});
describe('evaluator AND/OR/NOT', () => {
const host = makeHost({ S1: { A1: 8, B1: 0, C1: 'x' } });
it('AND is true only when every argument is truthy', () => {
expect(ev('=AND(1;1;1)', host)).toBe(1);
expect(ev('=AND(1;0;1)', host)).toBe(0);
expect(ev('=AND(A1>0;A1<10)', host)).toBe(1);
expect(ev('=AND(A1>0;A1<5)', host)).toBe(0);
});
it('OR is true when at least one argument is truthy', () => {
expect(ev('=OR(0;0;1)', host)).toBe(1);
expect(ev('=OR(0;0;0)', host)).toBe(0);
});
it('NOT inverts a single condition', () => {
expect(ev('=NOT(1)', host)).toBe(0);
expect(ev('=NOT(0)', host)).toBe(1);
expect(ev('=NOT(B1=0)', host)).toBe(0);
});
it('a range argument checks every cell (Excel behaviour)', () => {
const h = makeHost({ S1: { A1: 1, A2: 1, A3: 0 } });
expect(ev('=AND(A1:A3)', h)).toBe(0);
expect(ev('=OR(A1:A3)', h)).toBe(1);
});
it('empty cell and non-empty text follow the shared truthy convention', () => {
expect(ev('=AND(Z9)', host)).toBe(0); // empty cell -> falsy
expect(ev('=AND(C1)', host)).toBe(1); // non-empty text -> truthy
});
it('composes inside IF, unlike a bare chained comparison', () => {
expect(ev('=IF(AND(A1>0;A1<10);"ok";"nein")', host)).toBe('ok');
});
it('does NOT short-circuit -- every argument is evaluated, same as real Excel', () => {
expect(isError(ev('=AND(1=2;1/0)', host))).toBe(true);
expect(isError(ev('=OR(1=1;1/0)', host))).toBe(true);
});
it('wrong arity -> #ERR', () => {
expect(isError(ev('=AND()', host))).toBe(true);
expect(isError(ev('=NOT(1;2)', host))).toBe(true);
expect(isError(ev('=NOT()', host))).toBe(true);
});
});
describe('evaluator error literals (ErrorLiteralNode)', () => {
const host = makeHost({ S1: {} });
it('evaluates directly to that FormulaError', () => {
const r = ev('=#REF', host);
expect(isError(r) && r.code).toBe('#REF');
});
it('propagates through arithmetic like any other error', () => {
const r = ev('=1+#DIV/0!', host);
expect(isError(r) && r.code).toBe('#DIV/0!');
});
it('usable as an IF branch, e.g. a deliberate #N/A', () => {
expect(ev('=IF(1=0;#N/A;5)', host)).toBe(5);
const r = ev('=IF(1=1;#N/A;5)', host);
expect(isError(r) && r.code).toBe('#N/A');
});
});
describe('evaluator comparisons', () => {
const host = makeHost({ S1: { A1: 'Nord', B1: 5 } });
+23
View File
@@ -89,4 +89,27 @@ describe('lexer', () => {
expect(types('A1<B2')).toEqual(['IDENT', 'LT', 'IDENT', 'EOF']);
expect(types('A1>B2')).toEqual(['IDENT', 'GT', 'IDENT', 'EOF']);
});
it('tokenizes every error code as one ERRORLIT token', () => {
expect(types('#REF')).toEqual(['ERRORLIT', 'EOF']);
expect(values('#REF')).toEqual(['#REF', '']);
expect(types('#DIV/0!')).toEqual(['ERRORLIT', 'EOF']);
expect(types('#NAME?')).toEqual(['ERRORLIT', 'EOF']);
expect(types('#ERR')).toEqual(['ERRORLIT', 'EOF']);
expect(types('#CIRC')).toEqual(['ERRORLIT', 'EOF']);
expect(types('#VALUE!')).toEqual(['ERRORLIT', 'EOF']);
expect(types('#N/A')).toEqual(['ERRORLIT', 'EOF']);
});
it('an ERRORLIT is followed by whatever comes next, not swallowed into it', () => {
expect(types('1+#REF')).toEqual(['NUMBER', 'PLUS', 'ERRORLIT', 'EOF']);
// "#REF" matches greedily -- a trailing "!" some might expect (Excel
// spells it "#REF!") is its own BANG token, not part of the literal;
// this engine's own error code is "#REF" without "!" (see types.ts).
expect(types('#REF!')).toEqual(['ERRORLIT', 'BANG', 'EOF']);
});
it('throws on an unrecognized "#..." sequence', () => {
expect(() => tokenize('#FOO')).toThrow();
});
});
+19
View File
@@ -16,6 +16,8 @@ function sexpr(n: Node): string {
return String(n.value);
case 'string':
return JSON.stringify(n.value);
case 'errorliteral':
return n.code;
case 'unary':
return `(${n.op} ${sexpr(n.operand)})`;
case 'binary':
@@ -242,6 +244,23 @@ describe('parser named-range references (NamedRefNode)', () => {
});
});
describe('parser error literals (ErrorLiteralNode)', () => {
it('every error code parses as a leaf', () => {
expect(parse('=#REF')).toBe('#REF');
expect(parse('=#DIV/0!')).toBe('#DIV/0!');
expect(parse('=#NAME?')).toBe('#NAME?');
expect(parse('=#ERR')).toBe('#ERR');
expect(parse('=#CIRC')).toBe('#CIRC');
expect(parse('=#VALUE!')).toBe('#VALUE!');
expect(parse('=#N/A')).toBe('#N/A');
});
it('composes with operators/functions like any other literal', () => {
expect(parse('=1+#REF')).toBe('(+ 1 #REF)');
expect(parse('=IF(1=0;#N/A;5)')).toBe('(IF (= 1 0) #N/A 5)');
});
});
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))');
@@ -395,6 +395,21 @@ describe('Workbook integration: block-kind (FOR/IF/RETURN "code") user-defined f
expect(val(wb, 's-umsatz', 'B9')).toBe(0);
});
it('AND/OR compose a compound condition inside an IF, same as any other function call', () => {
const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '5', B9: '=INRANGE(A1)' });
const inRange = def({
id: 'f1',
name: 'INRANGE',
params: ['x'],
kind: 'block',
body: 'IF AND(x > 0; x < 10) THEN RETURN 1 ELSE RETURN 0 END',
});
const wb = new Workbook([umsatz], [inRange]);
expect(val(wb, 's-umsatz', 'B9')).toBe(1);
wb.setCell('s-umsatz', 0, 0, '20'); // A1: 5 -> 20, out of range
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', {