Text-Vergleich, &-Verkettung und benannte Bereiche
- Vergleichsoperatoren (= <> > < >= <=) unterscheiden jetzt Text- von Zahlenvergleich statt jede Seite zwangsweise durch toNumber() zu schicken (=A1="Nord" gab vorher #VALUE! statt 1); ermöglicht Text-Kriterien in selbstgebauten COUNTIF/SUMIF/AVERAGEIF-Funktionen (neue README-Sektion "SUMIF/COUNTIF/AVERAGEIF nachbauen"). - Neuer &-Operator (Excel-Präzedenz zwischen Vergleich und +/-) plus CONCAT/CONCATENATE als Funktions-Äquivalent. - Benannte Bereiche: neuer Toolbar-Button "🏷" verwaltet dokumentweite Namen für eine feste Zelle/einen festen Bereich (z. B. Umsatz_Q1statt Umsatz!B2:B6), unqualifiziert in jeder Top-Level-Formel nutzbar. Live gegen die Tabellen-ID aufgelöst (übersteht Tabellen-Umbenennung), bewusst nicht innerhalb einer benutzerdefinierten Funktion selbst nutzbar (Aufrufer können sie aber als Argument übergeben). - Nebenbei: COUNT2s Autovervollständigungs-Hint korrigiert (zeigte "COUNT(...)" statt "COUNT2(...)"). 374 Tests, vue-tsc/eslint/build sauber, Playwright-live-verifiziert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,10 +3,15 @@ import { parseFormula } from '../parser';
|
||||
import { evaluate } from '../evaluator';
|
||||
import { FormulaError, isError, type CellValue, type EngineHost } from '../types';
|
||||
|
||||
/** Minimal in-memory host: two tables addressed by name. */
|
||||
type NamedRangeShape = { qualifier: string; sheetQualifier?: string; start: { col: number; row: number }; end: { col: number; row: number } };
|
||||
|
||||
/** Minimal in-memory host: two tables addressed by name, plus an optional
|
||||
* name -> named-range map (name lower-cased, same matching convention as
|
||||
* the real Workbook). */
|
||||
function makeHost(
|
||||
data: Record<string, Record<string, CellValue>>,
|
||||
home = 'S1',
|
||||
namedRanges: Record<string, NamedRangeShape> = {},
|
||||
): EngineHost {
|
||||
const sizes: Record<string, { cols: number; rows: number }> = {
|
||||
S1: { cols: 10, rows: 10 },
|
||||
@@ -21,6 +26,7 @@ function makeHost(
|
||||
return {
|
||||
homeTableId: home,
|
||||
resolveTableName: idFor,
|
||||
resolveNamedRange: (name) => namedRanges[name.trim().toLowerCase()] ?? null,
|
||||
tableSize: (id) => sizes[id] ?? null,
|
||||
getComputed(tableId, col, row) {
|
||||
const cell = String.fromCharCode(65 + col) + (row + 1);
|
||||
@@ -130,6 +136,122 @@ describe('evaluator IF', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluator comparisons', () => {
|
||||
const host = makeHost({ S1: { A1: 'Nord', B1: 5 } });
|
||||
|
||||
it('numeric comparisons work as before, empty cell counts as 0', () => {
|
||||
expect(ev('=B1>=5', host)).toBe(1);
|
||||
expect(ev('=B1<>5', host)).toBe(0);
|
||||
expect(ev('=Z9=0', host)).toBe(1);
|
||||
});
|
||||
|
||||
it('text equality is case-insensitive and trimmed, like XLOOKUP', () => {
|
||||
expect(ev('=A1="Nord"', host)).toBe(1);
|
||||
expect(ev('=A1=" nord "', host)).toBe(1);
|
||||
expect(ev('=A1="Süd"', host)).toBe(0);
|
||||
});
|
||||
|
||||
it('text ordering compares lexicographically', () => {
|
||||
expect(ev('=A1<"Süd"', host)).toBe(1);
|
||||
expect(ev('=A1>"Süd"', host)).toBe(0);
|
||||
});
|
||||
|
||||
it('an empty cell takes on whichever type the other side is', () => {
|
||||
expect(ev('=Z9=""', host)).toBe(1);
|
||||
expect(ev('=Z9="x"', host)).toBe(0);
|
||||
});
|
||||
|
||||
it('a real number against real text never crossmatches: = / <> answer definitively, ordering is #VALUE!', () => {
|
||||
expect(ev('=B1="5"', host)).toBe(0);
|
||||
expect(ev('=B1<>"5"', host)).toBe(1);
|
||||
expect(isError(ev('=B1>"5"', host))).toBe(true);
|
||||
expect(isError(ev('=A1<5', host))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluator & (text concatenation)', () => {
|
||||
const host = makeHost({ S1: { A1: 'Herr', B1: 5 } });
|
||||
|
||||
it('joins two strings', () => {
|
||||
expect(ev('="a"&"b"', host)).toBe('ab');
|
||||
});
|
||||
|
||||
it('renders a number plain, no locale formatting', () => {
|
||||
expect(ev('="Wert: "&B1', host)).toBe('Wert: 5');
|
||||
});
|
||||
|
||||
it('an empty cell contributes "" (not "0")', () => {
|
||||
expect(ev('="["&Z9&"]"', host)).toBe('[]');
|
||||
});
|
||||
|
||||
it('is left-associative and chains', () => {
|
||||
expect(ev('=A1&" "&"Wild"', host)).toBe('Herr Wild');
|
||||
});
|
||||
|
||||
it('binds looser than + but tighter than a comparison', () => {
|
||||
expect(ev('="a"&1+2', host)).toBe('a3');
|
||||
expect(ev('=A1&"x"="Herrx"', host)).toBe(1);
|
||||
});
|
||||
|
||||
it('propagates an error from either side', () => {
|
||||
expect(isError(ev('=(1/0)&"x"', host))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluator CONCAT/CONCATENATE', () => {
|
||||
const host = makeHost({ S1: { A1: 'x', B1: 'y' } });
|
||||
|
||||
it('joins any number of arguments, no separator', () => {
|
||||
expect(ev('=CONCAT("a";"b";"c")', host)).toBe('abc');
|
||||
expect(ev('=CONCATENATE(A1;" ";B1)', host)).toBe('x y');
|
||||
});
|
||||
|
||||
it('an empty cell contributes ""', () => {
|
||||
expect(ev('=CONCAT(A1;Z9;B1)', host)).toBe('xy');
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluator named ranges (NamedRefNode)', () => {
|
||||
it('a single-cell named range behaves exactly like that cell in scalar position', () => {
|
||||
const host = makeHost({ S1: { B2: 42 } }, 'S1', {
|
||||
preis: { qualifier: 'S1', start: { col: 1, row: 1 }, end: { col: 1, row: 1 } },
|
||||
});
|
||||
expect(ev('=Preis + 1', host)).toBe(43);
|
||||
});
|
||||
|
||||
it('matching is case-insensitive and trimmed, like a table qualifier', () => {
|
||||
const host = makeHost({ S1: { B2: 42 } }, 'S1', {
|
||||
preis: { qualifier: 'S1', start: { col: 1, row: 1 }, end: { col: 1, row: 1 } },
|
||||
});
|
||||
expect(ev('=PREIS', host)).toBe(42);
|
||||
});
|
||||
|
||||
it('a multi-cell named range works as a function argument (range position), same as SUM(B2:B3)', () => {
|
||||
const host = makeHost({ S1: { B2: 10, B3: 5 } }, 'S1', {
|
||||
bereich: { qualifier: 'S1', start: { col: 1, row: 1 }, end: { col: 1, row: 2 } },
|
||||
});
|
||||
expect(ev('=SUM(Bereich)', host)).toBe(15);
|
||||
});
|
||||
|
||||
it('a multi-cell named range in scalar position is #VALUE!, same as a bare range', () => {
|
||||
const host = makeHost({ S1: { B2: 10, B3: 5 } }, 'S1', {
|
||||
bereich: { qualifier: 'S1', start: { col: 1, row: 1 }, end: { col: 1, row: 2 } },
|
||||
});
|
||||
expect(isError(ev('=Bereich', host))).toBe(true);
|
||||
});
|
||||
|
||||
it('an unknown name is #NAME?', () => {
|
||||
expect(isError(ev('=DoesNotExist', makeHost({})))).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves through another table via its qualifier', () => {
|
||||
const host = makeHost({ S2: { C3: 7 } }, 'S1', {
|
||||
andernorts: { qualifier: 'S2', start: { col: 2, row: 2 }, end: { col: 2, row: 2 } },
|
||||
});
|
||||
expect(ev('=Andernorts * 2', host)).toBe(14);
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluator XLOOKUP', () => {
|
||||
const textHost = makeHost({
|
||||
S1: {
|
||||
|
||||
@@ -18,12 +18,13 @@ describe('lexer', () => {
|
||||
});
|
||||
|
||||
it('tokenizes operators and punctuation', () => {
|
||||
expect(types('+-*/^():;!')).toEqual([
|
||||
expect(types('+-*/^&():;!')).toEqual([
|
||||
'PLUS',
|
||||
'MINUS',
|
||||
'STAR',
|
||||
'SLASH',
|
||||
'CARET',
|
||||
'AMP',
|
||||
'LPAREN',
|
||||
'RPAREN',
|
||||
'COLON',
|
||||
@@ -66,7 +67,7 @@ describe('lexer', () => {
|
||||
});
|
||||
|
||||
it('throws on an unexpected character', () => {
|
||||
expect(() => tokenize('A1 & B2')).toThrow();
|
||||
expect(() => tokenize('A1 @ B2')).toThrow();
|
||||
});
|
||||
|
||||
it('tokenizes assignment/equality and comparison operators', () => {
|
||||
|
||||
@@ -28,6 +28,8 @@ 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 'namedref':
|
||||
return `namedref(${n.name})`;
|
||||
case 'blockcall':
|
||||
// never appears in freshly-parsed input (see parser.ts's comment on
|
||||
// BlockCallNode) -- exhaustiveness only, no test exercises this.
|
||||
@@ -84,6 +86,15 @@ describe('parser precedence & associativity', () => {
|
||||
'(- (* (+ cell(1,1) cell(2,1)) 2) (/ cell(3,1) 4))',
|
||||
);
|
||||
});
|
||||
|
||||
it('& binds looser than + - but tighter than a comparison', () => {
|
||||
expect(parse('="a"&1+2')).toBe('(& "a" (+ 1 2))');
|
||||
expect(parse('=A1&B1="ab"')).toBe('(= (& cell(0,0) cell(1,0)) "ab")');
|
||||
});
|
||||
|
||||
it('& is left-associative', () => {
|
||||
expect(parse('="a"&"b"&"c"')).toBe('(& (& "a" "b") "c")');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parser references', () => {
|
||||
@@ -176,9 +187,13 @@ describe('parseFormula(src, paramNames) -- user-defined function bodies', () =>
|
||||
expect(() => parseFormula('=revenue - cost', ['revenue'])).toThrow(ParseError);
|
||||
});
|
||||
|
||||
it('without paramNames, ordinary cell-formula behaviour is unchanged', () => {
|
||||
// same string, no param list -- "revenue" doesn't look like a cell -> still throws
|
||||
expect(() => parseFormula('=revenue')).toThrow(ParseError);
|
||||
it('without paramNames, a bare non-cell identifier is a named-range reference, not a parse error', () => {
|
||||
// same string, no param list -- "revenue" doesn't look like a cell, but
|
||||
// this is now an ordinary top-level formula, where that's a named-range
|
||||
// candidate (see the "named ranges" describe block below) rather than a
|
||||
// ParseError -- unlike inside a function body (the case just above),
|
||||
// where the fallback is deliberately off.
|
||||
expect(parse('=revenue')).toBe('namedref(revenue)');
|
||||
});
|
||||
|
||||
it('a param can mix with real cell references in the same formula', () => {
|
||||
@@ -198,8 +213,32 @@ describe('parser errors', () => {
|
||||
it('throws on an unbalanced paren', () => {
|
||||
expect(() => parseFormula('=SUM(A1')).toThrow(ParseError);
|
||||
});
|
||||
it('throws on a non-cell identifier used as a reference', () => {
|
||||
expect(() => parseFormula('=foo + 1')).toThrow(ParseError);
|
||||
});
|
||||
|
||||
describe('parser named-range references (NamedRefNode)', () => {
|
||||
it('a bare, unqualified, non-cell identifier parses as a namedref', () => {
|
||||
expect(parse('=foo + 1')).toBe('(+ namedref(foo) 1)');
|
||||
expect(parse('=Umsatz_Q1')).toBe('namedref(Umsatz_Q1)');
|
||||
});
|
||||
|
||||
it('mixes with real cell/table references and functions', () => {
|
||||
expect(parse('=SUM(Umsatz_Q1; A1; Umsatz!B2)')).toBe(
|
||||
'(SUM namedref(Umsatz_Q1) cell(0,0) Umsatz!cell(1,1))',
|
||||
);
|
||||
});
|
||||
|
||||
it('a qualified non-cell identifier is still a ParseError -- a named range is always unqualified', () => {
|
||||
expect(() => parseFormula('=Umsatz!foo')).toThrow(ParseError);
|
||||
expect(() => parseFormula("='Kosten Q3'!foo")).toThrow(ParseError);
|
||||
});
|
||||
|
||||
it('never a range-start: "Name:B2" is not supported', () => {
|
||||
expect(() => parseFormula('=foo:B2')).toThrow(ParseError);
|
||||
});
|
||||
|
||||
it('inside a function body, a bare non-cell/non-param name is still a hard ParseError (no named-range fallback there)', () => {
|
||||
expect(() => parseFormula('=foo', ['bar'])).toThrow(ParseError);
|
||||
expect(() => parseFunctionBlock('RETURN foo', ['bar'])).toThrow(ParseError);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -321,6 +321,18 @@ describe('Workbook integration: block-kind (FOR/IF/RETURN "code") user-defined f
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(10 * 3 * 4);
|
||||
});
|
||||
|
||||
it('a caller may pass a named range as the argument -- the FOR loop iterates it exactly like an ordinary range', () => {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '2', A2: '3', A3: '4', B9: '=PRODUCT2(Werte)' });
|
||||
const wb = new Workbook(
|
||||
[umsatz],
|
||||
[def({ id: 'f1', name: 'PRODUCT2', ...productBlock })],
|
||||
[{ id: 'n1', name: 'Werte', tableId: 's-umsatz', start: { col: 0, row: 0 }, end: { col: 0, row: 2 } }],
|
||||
);
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(24);
|
||||
wb.setCell('s-umsatz', 0, 0, '10'); // A1: 2 -> 10 -- still tracked as a precedent via the named range
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(10 * 3 * 4);
|
||||
});
|
||||
|
||||
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({
|
||||
|
||||
@@ -138,6 +138,61 @@ describe('Workbook structural changes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Workbook named ranges', () => {
|
||||
function namedRangeFixture(): Workbook {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', { B2: '100', B3: '200', B9: '=Preis + 1' });
|
||||
return new Workbook(
|
||||
[umsatz],
|
||||
[],
|
||||
[{ id: 'n1', name: 'Preis', tableId: 's-umsatz', start: { col: 1, row: 1 }, end: { col: 1, row: 1 } }],
|
||||
);
|
||||
}
|
||||
|
||||
it('resolves a single-cell named range like an ordinary cell reference', () => {
|
||||
const wb = namedRangeFixture();
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(101);
|
||||
});
|
||||
|
||||
it('is a dependency -- recalculates when the referenced cell changes', () => {
|
||||
const wb = namedRangeFixture();
|
||||
wb.setCell('s-umsatz', 1, 1, '500'); // B2: 100 -> 500
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(501);
|
||||
});
|
||||
|
||||
it('a multi-cell named range works as a range function argument', () => {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', { B2: '100', B3: '200', B9: '=SUM(Bereich)' });
|
||||
const wb = new Workbook(
|
||||
[umsatz],
|
||||
[],
|
||||
[{ id: 'n1', name: 'Bereich', tableId: 's-umsatz', start: { col: 1, row: 1 }, end: { col: 1, row: 2 } }],
|
||||
);
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(300);
|
||||
});
|
||||
|
||||
it('survives its target table being renamed (resolved live by id, not by the name baked into the formula)', () => {
|
||||
const wb = namedRangeFixture();
|
||||
wb.tables[0].name = 'Verkauf'; // rename the table Preis points at
|
||||
wb.rebuild();
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(101); // still resolves -- no #NAME?/#REF
|
||||
});
|
||||
|
||||
it('an unknown name is #NAME?', () => {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', { B9: '=DoesNotExist' });
|
||||
const wb = new Workbook([umsatz], [], []);
|
||||
expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#NAME?');
|
||||
});
|
||||
|
||||
it('a dangling tableId (target table deleted) is #NAME?, same as a name that never existed', () => {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', { B9: '=Preis' });
|
||||
const wb = new Workbook(
|
||||
[umsatz],
|
||||
[],
|
||||
[{ id: 'n1', name: 'Preis', tableId: 'no-such-table', start: { col: 1, row: 1 }, end: { col: 1, row: 1 } }],
|
||||
);
|
||||
expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#NAME?');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Workbook recalculation order', () => {
|
||||
it('evaluates precedents before dependents (chain across sheets)', () => {
|
||||
const wb = fixture();
|
||||
|
||||
Reference in New Issue
Block a user