- 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).
151 lines
6.6 KiB
TypeScript
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([]);
|
|
});
|
|
});
|