- Help dialog (?, F1, Datei > Hilfe): user topics, all shortcuts and the formula functions with one search over everything. Topics and shortcuts are plain data in data/help.ts; the function list comes from codeReference.ts so new built-ins appear by themselves. - While a formula is edited, the grid outlines each referenced cell/range in the colour of its position in the text, and the outline can be dragged to move or resize the reference, as in Excel. Point-mode picks (click, drag, arrow keys) are outlined too, so keyboard picking is no longer blind. - Underline as a cell format, plus Mod+Shift+B/I/U toggles that switch on unless every selected cell already has the format. - A formula committed with open parentheses or an open text literal gets the missing closers appended; anything that still doesn't parse is left alone so the usual error shows instead of a guess. - Cells and tables with a fill but no text colour get dark or light text by WCAG contrast instead of the theme colour (no black on black).
511 lines
21 KiB
TypeScript
511 lines
21 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
parseFormula,
|
|
completeFormula,
|
|
parseFunctionBlock,
|
|
parseMacroBlock,
|
|
collectRefs,
|
|
collectRefsInBlock,
|
|
ParseError,
|
|
type Node,
|
|
type Statement,
|
|
type BlockNode,
|
|
} from '../parser';
|
|
|
|
/** Compact s-expression rendering of an AST for precedence assertions. */
|
|
function sexpr(n: Node): string {
|
|
switch (n.kind) {
|
|
case 'number':
|
|
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':
|
|
return `(${n.op} ${sexpr(n.left)} ${sexpr(n.right)})`;
|
|
case 'func':
|
|
return `(${n.name} ${n.args.map(sexpr).join(' ')})`;
|
|
case 'cell':
|
|
return `${n.sheetQualifier ? n.sheetQualifier + '!' : ''}${n.qualifier ? n.qualifier + '!' : ''}cell(${n.col},${n.row})`;
|
|
case 'range':
|
|
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.
|
|
return `blockcall(${n.label})`;
|
|
case 'omitted':
|
|
return 'omitted';
|
|
case 'json':
|
|
// never produced by the parser (see parser.ts's comment on
|
|
// JsonLiteralNode) -- exhaustiveness only, no test exercises this.
|
|
return `json(${JSON.stringify(n.value)})`;
|
|
}
|
|
}
|
|
const parse = (s: string) => sexpr(parseFormula(s));
|
|
const parse2 = (s: string, paramNames: readonly string[]) => sexpr(parseFormula(s, paramNames));
|
|
|
|
/** Compact rendering of a block AST (see parseFunctionBlock) for assertions. */
|
|
function sexprStmt(s: Statement): string {
|
|
switch (s.kind) {
|
|
case 'assign':
|
|
return `${s.name}=${sexpr(s.value)}`;
|
|
case 'return':
|
|
return `RETURN ${sexpr(s.value)}`;
|
|
case 'for':
|
|
return `FOR(${s.varName} IN ${sexpr(s.source)} DO ${sexprBlock(s.body)})`;
|
|
case 'ifstmt':
|
|
return `IF(${sexpr(s.cond)} THEN ${sexprBlock(s.then)}${s.else ? ` ELSE ${sexprBlock(s.else)}` : ''})`;
|
|
case 'action':
|
|
return `ACTION(${s.name};${s.args.map(sexpr).join(';')})`;
|
|
}
|
|
}
|
|
function sexprBlock(b: BlockNode): string {
|
|
return `{${b.statements.map(sexprStmt).join('; ')}}`;
|
|
}
|
|
const parseBlock = (s: string, paramNames: readonly string[]) =>
|
|
sexprBlock(parseFunctionBlock(s, paramNames));
|
|
|
|
describe('parser precedence & associativity', () => {
|
|
it('* / bind tighter than + -', () => {
|
|
expect(parse('=1+2*3')).toBe('(+ 1 (* 2 3))');
|
|
expect(parse('=1-2/2')).toBe('(- 1 (/ 2 2))');
|
|
});
|
|
|
|
it('+ - are left-associative', () => {
|
|
expect(parse('=1-2-3')).toBe('(- (- 1 2) 3)');
|
|
});
|
|
|
|
it('^ binds tighter than * / and is right-associative', () => {
|
|
expect(parse('=2*3^2')).toBe('(* 2 (^ 3 2))');
|
|
expect(parse('=2^3^2')).toBe('(^ 2 (^ 3 2))');
|
|
});
|
|
|
|
it('unary minus binds weaker than ^ (-2^2 = -(2^2))', () => {
|
|
expect(parse('=-2^2')).toBe('(- (^ 2 2))');
|
|
expect(parse('=2^-3')).toBe('(^ 2 (- 3))');
|
|
});
|
|
|
|
it('parentheses override precedence', () => {
|
|
expect(parse('=(1+2)*3')).toBe('(* (+ 1 2) 3)');
|
|
expect(parse('=(B2+C2)*2-D2/4')).toBe(
|
|
'(- (* (+ 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', () => {
|
|
it('parses local cells and ranges', () => {
|
|
expect(parse('=A1')).toBe('cell(0,0)');
|
|
expect(parse('=A1:C5')).toBe('range(0,0:2,4)');
|
|
});
|
|
|
|
it('parses $-locked (absolute/mixed) references to the same coords -- evaluation is positional', () => {
|
|
expect(parse('=$A$1')).toBe('cell(0,0)');
|
|
expect(parse('=A$1')).toBe('cell(0,0)');
|
|
expect(parse('=$A1')).toBe('cell(0,0)');
|
|
expect(parse('=$A$1:B$2')).toBe('range(0,0:1,1)');
|
|
});
|
|
|
|
it('parses table-qualified refs, bare and quoted', () => {
|
|
expect(parse('=Umsatz!B2')).toBe('Umsatz!cell(1,1)');
|
|
expect(parse("='Kosten Q3'!B2:E2")).toBe('Kosten Q3!range(1,1:4,1)');
|
|
});
|
|
|
|
it('accepts the Numbers-style "::" qualifier interchangeably with "!"', () => {
|
|
expect(parse('=Umsatz::B2')).toBe('Umsatz!cell(1,1)');
|
|
expect(parse("='Kosten Q3'::B2:E2")).toBe('Kosten Q3!range(1,1:4,1)');
|
|
expect(parse('=Umsatz::B2')).toBe(parse('=Umsatz!B2'));
|
|
});
|
|
|
|
it('parses function calls with ; separator and mixed args', () => {
|
|
expect(parse('=SUM(Umsatz!B2:E2; 100; A1)')).toBe(
|
|
'(SUM Umsatz!range(1,1:4,1) 100 cell(0,0))',
|
|
);
|
|
});
|
|
|
|
it('collectRefs finds every reference', () => {
|
|
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 whole-column / whole-row references', () => {
|
|
const range = (src: string) => parseFormula(src) as Extract<Node, { kind: 'range' }>;
|
|
|
|
it('B:B, A:C and $B:$B are whole columns', () => {
|
|
expect(range('B:B')).toMatchObject({ kind: 'range', whole: 'col', qualifier: null, start: { col: 1, row: 0 } });
|
|
expect(range('A:C')).toMatchObject({ whole: 'col', start: { col: 0 }, end: { col: 2 } });
|
|
expect(range('$B:$B')).toMatchObject({ whole: 'col', start: { col: 1 }, end: { col: 1 } });
|
|
expect(range('aa:ab')).toMatchObject({ whole: 'col', start: { col: 26 }, end: { col: 27 } });
|
|
});
|
|
|
|
it('1:1, 2:5 and $3:$3 are whole rows (1-based in the text)', () => {
|
|
expect(range('1:1')).toMatchObject({ kind: 'range', whole: 'row', start: { col: 0, row: 0 }, end: { row: 0 } });
|
|
expect(range('2:5')).toMatchObject({ whole: 'row', start: { row: 1 }, end: { row: 4 } });
|
|
expect(range('$3:$3')).toMatchObject({ whole: 'row', start: { row: 2 }, end: { row: 2 } });
|
|
});
|
|
|
|
it('take table and sheet qualifiers', () => {
|
|
expect(range('Umsatz!B:B')).toMatchObject({ qualifier: 'Umsatz', whole: 'col' });
|
|
expect(range('Umsatz::1:1')).toMatchObject({ qualifier: 'Umsatz', whole: 'row' });
|
|
expect(range("'Blatt 2'!Kosten::A:C")).toMatchObject({ sheetQualifier: 'Blatt 2', qualifier: 'Kosten', whole: 'col' });
|
|
});
|
|
|
|
it('work as function arguments and are collected as refs', () => {
|
|
const ast = parseFormula('SUM(B:B; 2:3) + 1');
|
|
expect(collectRefs(ast).map((r) => (r.kind === 'range' ? r.whole : r.kind))).toEqual(['col', 'row']);
|
|
});
|
|
|
|
it('reject mixed or invalid shapes', () => {
|
|
expect(() => parseFormula('A:1')).toThrow();
|
|
expect(() => parseFormula('0:0')).toThrow();
|
|
// a plain number still is one
|
|
expect(parseFormula('12')).toEqual({ kind: 'number', value: 12 });
|
|
});
|
|
});
|
|
|
|
describe('parser sheet qualifier -- "Blatt!Tabelle::Zelle"', () => {
|
|
it('parses the explicit 3-part form, bare and quoted', () => {
|
|
expect(parse('=Analyse!Umsatz::B2')).toBe('Analyse!Umsatz!cell(1,1)');
|
|
expect(parse("='Meine Analyse'!Umsatz::B2")).toBe('Meine Analyse!Umsatz!cell(1,1)');
|
|
expect(parse("=Analyse!'Kosten Q3'::B2:E2")).toBe('Analyse!Kosten Q3!range(1,1:4,1)');
|
|
expect(parse("='Meine Analyse'!'Kosten Q3'::B2")).toBe('Meine Analyse!Kosten Q3!cell(1,1)');
|
|
});
|
|
|
|
it('is unaffected in a range end / function call', () => {
|
|
expect(parse('=SUM(Analyse!Umsatz::B2:E2; 1)')).toBe('(SUM Analyse!Umsatz!range(1,1:4,1) 1)');
|
|
});
|
|
|
|
it('a bare "X!Y" (no further "::") keeps its original, table-only meaning -- fully backward-compatible', () => {
|
|
// no "::" ever follows -> "Analyse" is read as the TABLE, "A1" the cell,
|
|
// exactly as parseFormula() behaved before the sheet qualifier existed.
|
|
expect(parse('=Analyse!A1')).toBe('Analyse!cell(0,0)');
|
|
expect(parse('=Umsatz!B2')).toBe(parse('=Umsatz!B2')); // unchanged from the existing suite
|
|
});
|
|
|
|
it('"X::Y" (no leading sheet) is still the plain table qualifier, never a sheet one', () => {
|
|
expect(parse('=Umsatz::B2')).toBe('Umsatz!cell(1,1)');
|
|
});
|
|
|
|
it('"X!Y!Z" (a second "!" instead of "::") does not form a sheet qualifier -- "!" only introduces a sheet name when followed by NAME "::"', () => {
|
|
// no "::" anywhere ahead of "Y" -> "X!" is read as an ordinary (old-style)
|
|
// table qualifier, leaving "Y" to be parsed as the cell -- which it isn't
|
|
// shaped like (no digits) -> ParseError, same as e.g. "=X!Y" always was.
|
|
expect(() => parseFormula('=X!Y!Z1')).toThrow(ParseError);
|
|
});
|
|
|
|
it('rejects a malformed 3-part reference (missing cell after "::")', () => {
|
|
expect(() => parseFormula('=Analyse!Umsatz::')).toThrow(ParseError);
|
|
});
|
|
});
|
|
|
|
describe('parseFormula(src, paramNames) -- user-defined function bodies', () => {
|
|
it('a name in paramNames parses as a param reference, case-insensitively', () => {
|
|
expect(parse2('=revenue - cost', ['revenue', 'cost'])).toBe('(- param(revenue) param(cost))');
|
|
expect(parse2('=REVENUE * 2', ['revenue'])).toBe('(* param(REVENUE) 2)');
|
|
});
|
|
|
|
it('a bare name not in paramNames still behaves exactly as without the list', () => {
|
|
expect(() => parseFormula('=revenue - cost', ['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', () => {
|
|
expect(parse2('=x + A1', ['x'])).toBe('(+ param(x) cell(0,0))');
|
|
});
|
|
|
|
it('a param is never qualified or part of a range -- those still need a real cell', () => {
|
|
expect(() => parseFormula('=Umsatz!x', ['x'])).toThrow(ParseError);
|
|
expect(() => parseFormula('=x:B2', ['x'])).toThrow(ParseError);
|
|
});
|
|
});
|
|
|
|
describe('parser errors', () => {
|
|
it('throws ParseError on a dangling operator', () => {
|
|
expect(() => parseFormula('=A1+')).toThrow(ParseError);
|
|
});
|
|
it('throws on an unbalanced paren', () => {
|
|
expect(() => parseFormula('=SUM(A1')).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);
|
|
});
|
|
});
|
|
|
|
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))');
|
|
expect(parse('=A1<>B1')).toBe('(<> cell(0,0) cell(1,0))');
|
|
expect(parse('=A1>B1')).toBe('(> cell(0,0) cell(1,0))');
|
|
expect(parse('=A1<B1')).toBe('(< cell(0,0) cell(1,0))');
|
|
expect(parse('=A1>=B1')).toBe('(>= cell(0,0) cell(1,0))');
|
|
expect(parse('=A1<=B1')).toBe('(<= cell(0,0) cell(1,0))');
|
|
});
|
|
|
|
it('binds looser than + -', () => {
|
|
expect(parse('=A1+1=B1')).toBe('(= (+ cell(0,0) 1) cell(1,0))');
|
|
});
|
|
|
|
it('is not chainable -- "a < b < c" is a parse error', () => {
|
|
expect(() => parseFormula('=1<2<3')).toThrow(ParseError);
|
|
});
|
|
});
|
|
|
|
describe('parseFunctionBlock -- statement grammar for "block" function bodies', () => {
|
|
it('a single FOR loop, accumulator pattern (DO ... END, one statement)', () => {
|
|
const src = 'ergebnis = 1 FOR x IN bereich DO ergebnis = ergebnis * x END RETURN ergebnis';
|
|
expect(parseBlock(src, ['bereich'])).toBe(
|
|
'{ergebnis=1; FOR(x IN param(bereich) DO {ergebnis=(* param(ergebnis) param(x))}); RETURN param(ergebnis)}',
|
|
);
|
|
});
|
|
|
|
it('a FOR body with multiple statements', () => {
|
|
const src = 'total = 0 FOR x IN bereich DO y = x * 2 total = total + y END RETURN total';
|
|
expect(parseBlock(src, ['bereich'])).toBe(
|
|
'{total=0; FOR(x IN param(bereich) DO {y=(* param(x) 2); total=(+ param(total) param(y))}); RETURN param(total)}',
|
|
);
|
|
});
|
|
|
|
it('multiple independent FOR loops in sequence, sharing an accumulator', () => {
|
|
const src =
|
|
'total = 0 FOR a IN r1 DO total = total + a END FOR b IN r2 DO total = total + b END RETURN total';
|
|
expect(parseBlock(src, ['r1', 'r2'])).toBe(
|
|
'{total=0; FOR(a IN param(r1) DO {total=(+ param(total) param(a))}); ' +
|
|
'FOR(b IN param(r2) DO {total=(+ param(total) param(b))}); RETURN param(total)}',
|
|
);
|
|
});
|
|
|
|
it('IF ... THEN ... END, no ELSE', () => {
|
|
const src = 'x = 1 IF x > 0 THEN x = x + 1 END RETURN x';
|
|
expect(parseBlock(src, [])).toBe('{x=1; IF((> param(x) 0) THEN {x=(+ param(x) 1)}); RETURN param(x)}');
|
|
});
|
|
|
|
it('IF ... THEN ... ELSE ... END', () => {
|
|
const src = 'IF x > 0 THEN y = 1 ELSE y = -1 END RETURN y';
|
|
expect(parseBlock(src, ['x'])).toBe(
|
|
'{IF((> param(x) 0) THEN {y=1} ELSE {y=(- 1)}); RETURN param(y)}',
|
|
);
|
|
});
|
|
|
|
it('IF nested inside a FOR body, with an early RETURN -- "if kann in jedem block vorkommen"', () => {
|
|
const src =
|
|
'total = 0 FOR x IN bereich DO IF x > 100 THEN RETURN x END total = total + x END RETURN total';
|
|
expect(parseBlock(src, ['bereich'])).toBe(
|
|
'{total=0; FOR(x IN param(bereich) DO {IF((> param(x) 100) THEN {RETURN param(x)}); ' +
|
|
'total=(+ param(total) param(x))}); RETURN param(total)}',
|
|
);
|
|
});
|
|
|
|
it('a FOR loop nested inside another FOR loop\'s body', () => {
|
|
const src = 'total = 0 FOR a IN r1 DO FOR b IN r2 DO total = total + a * b END END RETURN total';
|
|
expect(parseBlock(src, ['r1', 'r2'])).toBe(
|
|
'{total=0; FOR(a IN param(r1) DO {FOR(b IN param(r2) DO ' +
|
|
'{total=(+ param(total) (* param(a) param(b)))})}); RETURN param(total)}',
|
|
);
|
|
});
|
|
|
|
it('RETURN may appear anywhere, not just at the end, and may be any expression', () => {
|
|
expect(parseBlock('RETURN 42', [])).toBe('{RETURN 42}');
|
|
expect(parseBlock('x = 5 RETURN x * 2', [])).toBe('{x=5; RETURN (* param(x) 2)}');
|
|
});
|
|
|
|
it('a plain assignment can reference an already-declared variable', () => {
|
|
expect(parseBlock('a = 1 b = a + 1 RETURN b', [])).toBe('{a=1; b=(+ param(a) 1); RETURN param(b)}');
|
|
});
|
|
|
|
it('the FOR source may itself be a cell/range reference, not just a parameter', () => {
|
|
expect(parseBlock('total = 0 FOR x IN A1:A5 DO total = total + x END RETURN total', [])).toBe(
|
|
'{total=0; FOR(x IN range(0,0:0,4) DO {total=(+ param(total) param(x))}); RETURN param(total)}',
|
|
);
|
|
});
|
|
|
|
it('a block without any RETURN still parses -- that is a save-time/runtime concern (blockAlwaysReturns, userFunctions.ts), not a parse error', () => {
|
|
expect(parseBlock('x = 1', [])).toBe('{x=1}');
|
|
});
|
|
|
|
it('throws on FOR without a matching END', () => {
|
|
expect(() => parseFunctionBlock('FOR x IN bereich DO y = x RETURN y', ['bereich'])).toThrow(
|
|
ParseError,
|
|
);
|
|
});
|
|
|
|
it('throws on FOR without a matching DO', () => {
|
|
expect(() => parseFunctionBlock('FOR x IN bereich RETURN x', ['bereich'])).toThrow(ParseError);
|
|
});
|
|
|
|
it('throws on FOR without IN', () => {
|
|
expect(() => parseFunctionBlock('FOR x bereich DO y = x END RETURN y', ['bereich'])).toThrow(
|
|
ParseError,
|
|
);
|
|
});
|
|
|
|
it('throws on IF without THEN', () => {
|
|
expect(() => parseFunctionBlock('IF x > 0 x = 1 END RETURN x', ['x'])).toThrow(ParseError);
|
|
});
|
|
|
|
it('throws on IF without a matching END', () => {
|
|
expect(() => parseFunctionBlock('IF x > 0 THEN x = 1 RETURN x', ['x'])).toThrow(ParseError);
|
|
});
|
|
|
|
it('throws when an assignment is missing "="', () => {
|
|
expect(() => parseFunctionBlock('x 1 RETURN x', [])).toThrow(ParseError);
|
|
});
|
|
|
|
it('a name is only usable after its own declaring statement (self-reference in its own initializer fails)', () => {
|
|
expect(() => parseFunctionBlock('x = x + 1 RETURN x', [])).toThrow(ParseError);
|
|
});
|
|
});
|
|
|
|
describe('parseMacroBlock -- write-action statements (ActionNode), macro-only', () => {
|
|
const parseMacro = (s: string) => sexprBlock(parseMacroBlock(s));
|
|
|
|
it('a bare NAME(args) at statement position parses as an action call', () => {
|
|
expect(parseMacro('SETVALUE(Umsatz::A1; 42)')).toBe(
|
|
'{ACTION(SETVALUE;Umsatz!cell(0,0);42)}',
|
|
);
|
|
});
|
|
|
|
it('an action with no arguments', () => {
|
|
expect(parseMacro('ADDSHEET("Neu")')).toBe('{ACTION(ADDSHEET;"Neu")}');
|
|
});
|
|
|
|
it('";"-separated arguments, including an omitted middle slot', () => {
|
|
expect(parseMacro('SETVALUE(Umsatz::A1;;)')).toBe('{ACTION(SETVALUE;Umsatz!cell(0,0);omitted;omitted)}');
|
|
});
|
|
|
|
it('an action call composes with local variables and & concatenation', () => {
|
|
expect(parseMacro('n = "Blatt" ADDSHEET("Neu " & n)')).toBe(
|
|
'{n="Blatt"; ACTION(ADDSHEET;(& "Neu " param(n)))}',
|
|
);
|
|
});
|
|
|
|
it('an action call can appear inside FOR/IF bodies', () => {
|
|
expect(
|
|
parseMacro('FOR c IN Umsatz::A1:A3 DO SETVALUE(c; 0) END'),
|
|
).toBe('{FOR(c IN Umsatz!range(0,0:0,2) DO {ACTION(SETVALUE;param(c);0)})}');
|
|
});
|
|
|
|
it('assignment (IDENT "=" expr) still wins over an action call -- disambiguated by the token after the name', () => {
|
|
expect(parseMacro('x = 1 RETURN x')).toBe('{x=1; RETURN param(x)}');
|
|
});
|
|
|
|
it('a bare NAME(args) at statement position is only recognised in a macro body, never a plain function body', () => {
|
|
// Same source: a function body has no `allowWrites`, so "SETVALUE(...)"
|
|
// at statement start falls through to parseAssignment(), which then
|
|
// fails immediately for lack of "=".
|
|
expect(() => parseFunctionBlock('SETVALUE(A1; 1) RETURN 1', [])).toThrow(ParseError);
|
|
});
|
|
|
|
it('rejects an action call missing its closing ")"', () => {
|
|
expect(() => parseMacroBlock('SETVALUE(Umsatz::A1; 1')).toThrow(ParseError);
|
|
});
|
|
});
|
|
|
|
describe('collectRefsInBlock -- reference walk used by the macro save-time qualifier check', () => {
|
|
it('collects a SETVALUE action\'s reference arguments too', () => {
|
|
const block = parseMacroBlock('SETVALUE(Umsatz::A1; Kosten::B2)');
|
|
const refs = collectRefsInBlock(block);
|
|
expect(refs.map((r) => r.kind)).toEqual(['cell', 'cell']);
|
|
expect(refs.map((r) => (r as { qualifier: string | null }).qualifier)).toEqual(['Umsatz', 'Kosten']);
|
|
});
|
|
|
|
it('collects refs from every action argument, not just the first', () => {
|
|
const block = parseMacroBlock('INSERTROW("Umsatz"; 1) SETVALUE(Umsatz::A1; Kosten::B2 + 1)');
|
|
const refs = collectRefsInBlock(block);
|
|
// INSERTROW's args are plain strings/numbers -- no refs from that
|
|
// statement; SETVALUE contributes both its target and its value's ref.
|
|
expect(refs).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
describe('completeFormula -- closing what Enter left open', () => {
|
|
it('appends missing closing parentheses', () => {
|
|
expect(completeFormula('=SUMME(A1:A5')).toBe('=SUMME(A1:A5)');
|
|
expect(completeFormula('=RUNDEN(SUMME(A1;B1)*(2+3')).toBe('=RUNDEN(SUMME(A1;B1)*(2+3))');
|
|
expect(completeFormula('=SUMME(A1 ')).toBe('=SUMME(A1 )');
|
|
});
|
|
it('closes an open text literal before the parentheses', () => {
|
|
expect(completeFormula('=WENN(A1>0;"ja')).toBe('=WENN(A1>0;"ja")');
|
|
});
|
|
it('leaves valid formulas, plain text and unfixable input alone', () => {
|
|
expect(completeFormula('=SUMME(A1:A5)')).toBe('=SUMME(A1:A5)');
|
|
expect(completeFormula('Hallo (Welt')).toBe('Hallo (Welt');
|
|
expect(completeFormula('=SUMME(A1+')).toBe('=SUMME(A1+');
|
|
expect(completeFormula('=A1)')).toBe('=A1)');
|
|
expect(completeFormula('=(')).toBe('=(');
|
|
});
|
|
});
|