Files
numolus/app/frontend/engine/__tests__/refs.spec.ts
T
wkh 0fca61eb65 Add in-app help, formula reference outlines, underline and auto-closing
- 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).
2026-10-24 00:38:01 +02:00

151 lines
6.6 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { parseA1, toA1, cycleReferenceLocks, currentIdentifierAtCaret, formulaRefSpans } from '../refs';
describe('parseA1 / toA1: $-locked (absolute/mixed) references', () => {
it('parseA1 reports which axes are $-locked', () => {
expect(parseA1('A1')).toEqual({ col: 0, row: 0, colAbs: false, rowAbs: false });
expect(parseA1('$A$1')).toEqual({ col: 0, row: 0, colAbs: true, rowAbs: true });
expect(parseA1('A$1')).toEqual({ col: 0, row: 0, colAbs: false, rowAbs: true });
expect(parseA1('$A1')).toEqual({ col: 0, row: 0, colAbs: true, rowAbs: false });
});
it('toA1 round-trips the lock markers', () => {
expect(toA1(0, 0)).toBe('A1');
expect(toA1(0, 0, true, true)).toBe('$A$1');
expect(toA1(0, 0, false, true)).toBe('A$1');
expect(toA1(0, 0, true, false)).toBe('$A1');
});
});
describe('cycleReferenceLocks (F4) -- collapsed caret', () => {
it('cycles relative -> absolute -> row-locked -> col-locked -> relative', () => {
let f = '=A1';
let pos = 3;
for (const expected of ['=$A$1', '=A$1', '=$A1', '=A1']) {
const res = cycleReferenceLocks(f, pos, pos)!;
expect(res.text).toBe(expected);
expect(res.start).toBe(res.end); // a collapsed caret in -> a collapsed caret out
f = res.text;
pos = res.end;
}
});
it('toggles only the reference the caret sits in/against', () => {
const res = cycleReferenceLocks('=A1+B2', 2, 2)!; // caret inside "A1"
expect(res.text).toBe('=$A$1+B2');
const res2 = cycleReferenceLocks('=A1+B2', 6, 6)!; // caret inside "B2"
expect(res2.text).toBe('=A1+$B$2');
});
it('picks the right side of a range depending on the caret', () => {
const left = cycleReferenceLocks('=A1:B2', 2, 2)!;
expect(left.text).toBe('=$A$1:B2');
const right = cycleReferenceLocks('=A1:B2', 6, 6)!;
expect(right.text).toBe('=A1:$B$2');
});
it('returns null when the caret is not on a reference, or the text is not a formula', () => {
expect(cycleReferenceLocks('=SUM(1;2)', 5, 5)).toBeNull();
expect(cycleReferenceLocks('42', 1, 1)).toBeNull();
});
it('never toggles a table-name qualifier, even one shaped like a cell', () => {
// "AB12" reads as a cell reference on its own, but here it's a table name
const formula = '=AB12!A1';
expect(cycleReferenceLocks(formula, 2, 2)).toBeNull(); // caret inside "AB12"
expect(cycleReferenceLocks(formula, 7, 7)?.text).toBe('=AB12!$A$1'); // caret inside the actual cell
});
});
describe('cycleReferenceLocks (F4) -- a real text selection spanning several refs', () => {
it('toggles every reference the selection overlaps, each from its own state', () => {
// select "A1+$B2" inside "=A1+$B2+C3" -- C3 sits outside the selection.
// A1 (relative) -> $A$1; $B2 (already col-locked-only, the cycle's last
// stop before relative) -> B2, its next step back to fully relative.
const formula = '=A1+$B2+C3';
const start = formula.indexOf('A1');
const end = formula.indexOf('$B2') + '$B2'.length;
const res = cycleReferenceLocks(formula, start, end)!;
expect(res.text).toBe('=$A$1+B2+C3');
});
it('a selection covering the whole formula adjusts all of it in one press', () => {
const res = cycleReferenceLocks('=A1+B2', 1, 7)!;
expect(res.text).toBe('=$A$1+$B$2');
});
it('re-cycling the same (now resized) selection keeps advancing every reference', () => {
let f = '=A1+B2';
let sel = cycleReferenceLocks(f, 1, 7)!; // -> $A$1+$B$2
f = sel.text;
sel = cycleReferenceLocks(f, sel.start, sel.end)!; // -> A$1+B$2
expect(sel.text).toBe('=A$1+B$2');
});
it('a selection touching no reference returns null', () => {
expect(cycleReferenceLocks('=SUM(1;2)', 4, 8)).toBeNull();
});
});
describe('currentIdentifierAtCaret -- function-name autocomplete detection', () => {
it('right after "=", right after an operator, right after "(" or ";"', () => {
expect(currentIdentifierAtCaret('=S', 2)).toEqual({ text: 'S', start: 1 });
expect(currentIdentifierAtCaret('=A1+S', 5)).toEqual({ text: 'S', start: 4 });
expect(currentIdentifierAtCaret('=SUM(A', 6)).toEqual({ text: 'A', start: 5 });
expect(currentIdentifierAtCaret('=SUM(1;A', 8)).toEqual({ text: 'A', start: 7 });
});
it('null outside a formula (no leading "=")', () => {
expect(currentIdentifierAtCaret('SUM', 3)).toBeNull();
});
it('null once the word looks like a cell reference (has a digit)', () => {
expect(currentIdentifierAtCaret('=SUM(A1', 7)).toBeNull();
expect(currentIdentifierAtCaret('=A1', 3)).toBeNull();
});
it('null right after an already-open, complete function call', () => {
expect(currentIdentifierAtCaret('=SUM(', 5)).toBeNull();
});
it('null once the caret has moved past the word (trailing whitespace)', () => {
expect(currentIdentifierAtCaret('=SUM ', 5)).toBeNull();
});
it('only looks at text up to the caret, ignoring what follows it', () => {
// caret between "SUM" and "X" in "SUMX" -- same result as if "X" weren't there
expect(currentIdentifierAtCaret('=SUMX', 4)).toEqual({ text: 'SUM', start: 1 });
});
it('null on a malformed prefix (unterminated string) rather than throwing', () => {
expect(currentIdentifierAtCaret('=SUM("x', 7)).toBeNull();
});
});
describe('formulaRefSpans -- references with their position in the text', () => {
const texts = (f: string) => formulaRefSpans(f).map((s) => f.slice(s.start, s.end));
it('finds cells, ranges and whole lines in text order', () => {
expect(texts('=SUMME(A1:B3;$C$2)*D4+B:B')).toEqual(['A1:B3', '$C$2', 'D4', 'B:B']);
const [r] = formulaRefSpans('=A1:$B$3');
expect(r.a).toEqual({ col: 0, row: 0, colAbs: false, rowAbs: false });
expect(r.b).toEqual({ col: 1, row: 2, colAbs: true, rowAbs: true });
expect(formulaRefSpans('=2:5')[0]).toMatchObject({ whole: 'row', a: { row: 1 }, b: { row: 4 } });
});
it('includes the qualifier in the span and reports it separately', () => {
const f = "='Kosten Q3'!A1+Analyse!Umsatz::B2:C4";
expect(texts(f)).toEqual(["'Kosten Q3'!A1", 'Analyse!Umsatz::B2:C4']);
const [q, s] = formulaRefSpans(f);
expect(q).toMatchObject({ qualifier: 'Kosten Q3', refStart: f.indexOf('A1') });
expect(s).toMatchObject({ qualifier: 'Umsatz', sheetQualifier: 'Analyse' });
});
it('skips function names, strings and named ranges; tolerates unfinished input', () => {
expect(texts('=LOG10(A1)&"B2"&Umsatz_Q1')).toEqual(['A1']);
expect(texts('=SUMME(A1:A5;B')).toEqual(['A1:A5']);
expect(texts('=WENN(A1;"offen')).toEqual([]);
expect(texts('A1')).toEqual([]);
});
});