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:
wkh
2026-09-08 21:59:47 +02:00
co-authored by Claude Sonnet 5
parent 9015a991aa
commit 2c54537427
22 changed files with 1460 additions and 86 deletions
+132
View File
@@ -871,6 +871,138 @@ describe('custom functions (document-level library)', () => {
});
});
describe('named ranges (document-level names for a fixed cell/range)', () => {
it('addNamedRange stores the definition and a formula elsewhere resolves it unqualified', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
setCell(s, name, 'B2', '42');
const result = s.addNamedRange('Preis', id, coords('B2'), coords('B2'));
expect(typeof result).toBe('string'); // the new range's id, not { error }
expect(s.doc.namedRanges.find((r) => r.name === 'Preis')).toMatchObject({ tableId: id });
setCell(s, name, 'A1', '=Preis + 1');
expect(val(s, name, 'A1')).toBe(43);
});
it('a multi-cell named range works as a range function argument', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
setCell(s, name, 'B2', '10');
setCell(s, name, 'B3', '20');
s.addNamedRange('Bereich', id, coords('B2'), coords('B3'));
setCell(s, name, 'A1', '=SUM(Bereich)');
expect(val(s, name, 'A1')).toBe(30);
});
it('rejects a name colliding with a built-in function', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const result = s.addNamedRange('SUM', id, coords('B2'), coords('B2')) as { error: string };
expect(result.error).toBeTruthy();
});
it('rejects a name colliding with an existing custom function', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
s.addCustomFunction('MARGE', ['x'], 'x');
const result = s.addNamedRange('MARGE', id, coords('B2'), coords('B2')) as { error: string };
expect(result.error).toBeTruthy();
});
it('rejects a name colliding with another named range (case-insensitive)', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
s.addNamedRange('Preis', id, coords('B2'), coords('B2'));
const result = s.addNamedRange('preis', id, coords('C2'), coords('C2')) as { error: string };
expect(result.error).toBeTruthy();
});
it('rejects a name that looks like a cell address', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const result = s.addNamedRange('B2', id, coords('C2'), coords('C2')) as { error: string };
expect(result.error).toBeTruthy();
});
it('updateNamedRange changes name/coords and existing formulas re-resolve', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
setCell(s, name, 'B2', '10');
setCell(s, name, 'C2', '99');
const rid = s.addNamedRange('Preis', id, coords('B2'), coords('B2')) as string;
setCell(s, name, 'A1', '=Preis');
expect(val(s, name, 'A1')).toBe(10);
s.updateNamedRange(rid, 'Preis', id, coords('C2'), coords('C2'));
expect(val(s, name, 'A1')).toBe(99);
});
it('deleteNamedRange removes the definition; existing formulas become #NAME?', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
const before = s.doc.namedRanges.length; // the seed already ships one ("Umsatz_Q1")
setCell(s, name, 'B2', '10');
const rid = s.addNamedRange('Preis', id, coords('B2'), coords('B2')) as string;
setCell(s, name, 'A1', '=Preis');
expect(val(s, name, 'A1')).toBe(10);
s.deleteNamedRange(rid);
expect(s.doc.namedRanges).toHaveLength(before);
expect(s.doc.namedRanges.find((r) => r.id === rid)).toBeUndefined();
expect(isError(val(s, name, 'A1'))).toBe(true);
});
it('survives its target table being renamed', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
setCell(s, name, 'B2', '10');
s.addNamedRange('Preis', id, coords('B2'), coords('B2'));
setCell(s, name, 'A1', '=Preis');
expect(val(s, name, 'A1')).toBe(10);
s.renameObject(id, 'Verkauf');
expect(val(s, 'Verkauf', 'A1')).toBe(10); // still resolves, no #NAME?/#REF
});
it('undo/redo covers a named-range change', () => {
vi.useFakeTimers();
try {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
vi.advanceTimersByTime(600); // let addTable settle into its own history step first
const before = s.doc.namedRanges.length; // the seed already ships one ("Umsatz_Q1")
s.addNamedRange('Preis', id, coords('B2'), coords('B2'));
vi.advanceTimersByTime(600);
expect(s.doc.namedRanges).toHaveLength(before + 1);
s.undo();
expect(s.doc.namedRanges).toHaveLength(before);
s.redo();
expect(s.doc.namedRanges).toHaveLength(before + 1);
expect(s.doc.namedRanges.find((r) => r.name === 'Preis')).toBeTruthy();
} finally {
vi.runOnlyPendingTimers();
vi.useRealTimers();
}
});
it('JSON export/import round-trips namedRanges losslessly', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
s.addNamedRange('Preis', id, coords('B2'), coords('B2'));
const before = JSON.stringify(s.doc.namedRanges);
s.replaceDocument(parseDocument(serializeDocument(s.doc)));
expect(JSON.stringify(s.doc.namedRanges)).toBe(before);
});
});
describe('functionSuggestions (formula autocomplete)', () => {
it('filters built-ins by prefix, case-insensitively', () => {
const s = fresh();