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:
@@ -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();
|
||||
|
||||
+101
-2
@@ -39,6 +39,7 @@ import {
|
||||
type TableModel,
|
||||
type Token,
|
||||
type FunctionDef,
|
||||
type NamedRangeDef,
|
||||
} from '../engine';
|
||||
import {
|
||||
formatValue,
|
||||
@@ -104,6 +105,16 @@ function toFunctionDefs(doc: DocumentState): FunctionDef[] {
|
||||
}));
|
||||
}
|
||||
|
||||
function toNamedRangeDefs(doc: DocumentState): NamedRangeDef[] {
|
||||
return doc.namedRanges.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
tableId: r.tableId,
|
||||
start: r.start,
|
||||
end: r.end,
|
||||
}));
|
||||
}
|
||||
|
||||
/** A normalised rectangle (corners in either order) used by selection + clipboard. */
|
||||
type Rect = { c0: number; c1: number; r0: number; r1: number };
|
||||
|
||||
@@ -210,7 +221,7 @@ function loadInitial(): DocumentState {
|
||||
|
||||
function createStore() {
|
||||
const doc = reactive<DocumentState>(loadInitial());
|
||||
let wb = new Workbook(toTableModels(doc), toFunctionDefs(doc));
|
||||
let wb = new Workbook(toTableModels(doc), toFunctionDefs(doc), toNamedRangeDefs(doc));
|
||||
const recalcTick = ref(0);
|
||||
const bump = () => (recalcTick.value += 1);
|
||||
|
||||
@@ -230,7 +241,7 @@ function createStore() {
|
||||
const clampZoom = (z: number) => Math.min(2, Math.max(0.4, Math.round(z * 20) / 20));
|
||||
|
||||
function syncWorkbook() {
|
||||
wb = new Workbook(toTableModels(doc), toFunctionDefs(doc));
|
||||
wb = new Workbook(toTableModels(doc), toFunctionDefs(doc), toNamedRangeDefs(doc));
|
||||
bump();
|
||||
}
|
||||
|
||||
@@ -280,6 +291,7 @@ function createStore() {
|
||||
locale: d.locale,
|
||||
csvDelimiter: d.csvDelimiter,
|
||||
customFunctions: d.customFunctions,
|
||||
namedRanges: d.namedRanges,
|
||||
});
|
||||
let historySnapshot = snapshot();
|
||||
let historyFingerprint = contentFingerprint(historySnapshot);
|
||||
@@ -865,6 +877,89 @@ function createStore() {
|
||||
syncWorkbook(); // affected calls become #NAME? on rebuild, same as a deleted table
|
||||
}
|
||||
|
||||
// ---- named ranges (document-level names for a fixed cell/range) -------
|
||||
// Created/edited/removed exclusively through the "🏷 Namen" UI. Unlike a
|
||||
// custom function there is no expression of its own -- just fixed
|
||||
// coordinates on a specific table -- so there's nothing that could
|
||||
// cyclically reference itself, and no "does every path RETURN" check
|
||||
// either. `tableId` (not the table's name) is what's stored, so a named
|
||||
// range survives its target table being *renamed* (see
|
||||
// engine/workbook.ts's `resolveNamedRange`); only an outright *deletion*
|
||||
// of that table leaves it dangling -> #NAME? on the next rebuild, same
|
||||
// "nothing is auto-rewritten" convention as a plain formula reference to a
|
||||
// deleted table.
|
||||
function validateNamedRange(
|
||||
name: string,
|
||||
tableId: string,
|
||||
start: CellCoord,
|
||||
end: CellCoord,
|
||||
ignoreId: string | null,
|
||||
): string | null {
|
||||
const cleanName = name.trim();
|
||||
if (!IDENT_RE.test(cleanName)) {
|
||||
return `"${name}" ist kein gültiger Name.`;
|
||||
}
|
||||
if (looksLikeCell(cleanName)) {
|
||||
return `"${cleanName}" sieht wie ein Zellbezug aus und ist als Name nicht erlaubt.`;
|
||||
}
|
||||
if (RESERVED_NAMES.has(cleanName.toUpperCase())) {
|
||||
return `"${cleanName}" ist ein reserviertes Schlüsselwort (FOR/IN/DO/RETURN) und kann nicht als Name verwendet werden.`;
|
||||
}
|
||||
if (hasFunction(cleanName)) {
|
||||
return `"${cleanName}" ist bereits eine eingebaute Funktion und kann nicht überschrieben werden.`;
|
||||
}
|
||||
const fnCollision = doc.customFunctions.find(
|
||||
(f) => f.name.toLowerCase() === cleanName.toLowerCase(),
|
||||
);
|
||||
if (fnCollision) return `Es gibt bereits eine Funktion namens "${fnCollision.name}".`;
|
||||
const rangeCollision = doc.namedRanges.find(
|
||||
(r) => r.id !== ignoreId && r.name.toLowerCase() === cleanName.toLowerCase(),
|
||||
);
|
||||
if (rangeCollision) return `Es gibt bereits einen Namen "${rangeCollision.name}".`;
|
||||
if (!tableById(tableId)) return 'Tabelle nicht gefunden.';
|
||||
if (start.col < 0 || start.row < 0 || end.col < 0 || end.row < 0) {
|
||||
return 'Ungültiger Bereich.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function addNamedRange(
|
||||
name: string,
|
||||
tableId: string,
|
||||
start: CellCoord,
|
||||
end: CellCoord,
|
||||
): string | { error: string } {
|
||||
const error = validateNamedRange(name, tableId, start, end, null);
|
||||
if (error) return { error };
|
||||
const id = uid('nr');
|
||||
doc.namedRanges.push({ id, name: name.trim(), tableId, start: { ...start }, end: { ...end } });
|
||||
syncWorkbook();
|
||||
return id;
|
||||
}
|
||||
|
||||
function updateNamedRange(
|
||||
id: string,
|
||||
name: string,
|
||||
tableId: string,
|
||||
start: CellCoord,
|
||||
end: CellCoord,
|
||||
): void | { error: string } {
|
||||
const r = doc.namedRanges.find((x) => x.id === id);
|
||||
if (!r) return { error: 'Name nicht gefunden.' };
|
||||
const error = validateNamedRange(name, tableId, start, end, id);
|
||||
if (error) return { error };
|
||||
r.name = name.trim();
|
||||
r.tableId = tableId;
|
||||
r.start = { ...start };
|
||||
r.end = { ...end };
|
||||
syncWorkbook();
|
||||
}
|
||||
|
||||
function deleteNamedRange(id: string) {
|
||||
doc.namedRanges = doc.namedRanges.filter((r) => r.id !== id);
|
||||
syncWorkbook(); // affected formulas become #NAME? on rebuild, same as a deleted table
|
||||
}
|
||||
|
||||
function moveObject(objId: string, x: number, y: number) {
|
||||
const o = objectById(objId);
|
||||
if (!o) return;
|
||||
@@ -1616,6 +1711,7 @@ function createStore() {
|
||||
doc.locale = isNumberLocale(next.locale) ? next.locale : DEFAULT_LOCALE;
|
||||
doc.csvDelimiter = next.csvDelimiter === ';' ? ';' : ',';
|
||||
doc.customFunctions = next.customFunctions ?? [];
|
||||
doc.namedRanges = next.namedRanges ?? [];
|
||||
syncWorkbook();
|
||||
}
|
||||
/** Loading a different document outright (JSON import "replace", "reset to
|
||||
@@ -1710,6 +1806,9 @@ function createStore() {
|
||||
addCustomFunction,
|
||||
updateCustomFunction,
|
||||
deleteCustomFunction,
|
||||
addNamedRange,
|
||||
updateNamedRange,
|
||||
deleteNamedRange,
|
||||
resizeChart,
|
||||
bringToFront,
|
||||
setChartView,
|
||||
|
||||
@@ -173,6 +173,25 @@ export interface CustomFunction {
|
||||
kind: 'expression' | 'block';
|
||||
}
|
||||
|
||||
/** A document-wide name that stands for a fixed cell/range on a specific
|
||||
* table (`Umsatz_Q1` instead of `Umsatz!B2:B13`), usable unqualified in any
|
||||
* ordinary top-level formula (not inside a custom function's own body --
|
||||
* see engine/parser.ts's `namedRefFallback`). Created, edited and removed
|
||||
* exclusively through the "🏷 Namen" UI. Unlike a `CustomFunction`, there is
|
||||
* no expression to evaluate, just fixed coordinates -- so no cycle
|
||||
* detection is needed. `tableId` (not the table's *name*) so a named range
|
||||
* survives its target table being renamed -- see
|
||||
* engine/workbook.ts's `resolveNamedRange`. */
|
||||
export interface NamedRange {
|
||||
id: string;
|
||||
/** document-unique, compared case-insensitively; never a built-in/custom
|
||||
* function name, never shaped like a cell reference (A1, $B2, ...). */
|
||||
name: string;
|
||||
tableId: string;
|
||||
start: CellCoord;
|
||||
end: CellCoord;
|
||||
}
|
||||
|
||||
/** One entry in the formula-autocomplete dropdown (see document.ts's
|
||||
* `functionSuggestions()`). */
|
||||
export interface FnSuggestion {
|
||||
@@ -191,4 +210,5 @@ export interface DocumentState {
|
||||
locale: NumberLocale;
|
||||
csvDelimiter: ',' | ';';
|
||||
customFunctions: CustomFunction[];
|
||||
namedRanges: NamedRange[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user