- 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).
3117 lines
122 KiB
TypeScript
3117 lines
122 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { ref } from 'vue';
|
|
import { useDocument, type DocumentStore } from '../document';
|
|
import { isError, toA1 } from '../../engine';
|
|
import { serializeDocument, parseDocument } from '../../io/json';
|
|
|
|
/** The store is a module-level singleton; `resetToSeed()` gives each test a
|
|
* clean, fully-defined starting document (no localStorage needed here). */
|
|
function fresh(): DocumentStore {
|
|
const store = useDocument();
|
|
store.resetToSeed();
|
|
return store;
|
|
}
|
|
|
|
/** id of a table object by its (document-unique) name */
|
|
function tableId(store: DocumentStore, name: string): string {
|
|
for (const s of store.doc.sheets) {
|
|
const o = s.objects.find((x) => x.kind === 'table' && x.name === name);
|
|
if (o) return o.id;
|
|
}
|
|
throw new Error(`no table named ${name}`);
|
|
}
|
|
|
|
function coords(a1: string) {
|
|
const m = /^([A-Z]+)([0-9]+)$/.exec(a1)!;
|
|
const col = m[1].split('').reduce((n, c) => n * 26 + (c.charCodeAt(0) - 64), 0) - 1;
|
|
return { col, row: Number(m[2]) - 1 };
|
|
}
|
|
const val = (s: DocumentStore, table: string, a1: string) => {
|
|
const { col, row } = coords(a1);
|
|
return s.cellValue(tableId(s, table), col, row);
|
|
};
|
|
const setCell = (s: DocumentStore, table: string, a1: string, raw: string) => {
|
|
const { col, row } = coords(a1);
|
|
s.setCellInput(tableId(s, table), col, row, raw);
|
|
};
|
|
|
|
describe('seed document acceptance (canvas model)', () => {
|
|
it('the seed lays three tables + a chart on one sheet, plus an empty sheet', () => {
|
|
const s = fresh();
|
|
expect(s.doc.version).toBe(2);
|
|
expect(s.doc.sheets).toHaveLength(2);
|
|
const analyse = s.doc.sheets[0];
|
|
expect(analyse.objects.filter((o) => o.kind === 'table')).toHaveLength(3);
|
|
expect(analyse.objects.filter((o) => o.kind === 'chart')).toHaveLength(1);
|
|
expect(s.doc.sheets[1].objects).toHaveLength(0);
|
|
});
|
|
|
|
it('aggregates on Umsatz', () => {
|
|
const s = fresh();
|
|
expect(val(s, 'Umsatz', 'B8')).toBe(6175);
|
|
expect(val(s, 'Umsatz', 'B9')).toBe(1235);
|
|
expect(val(s, 'Umsatz', 'B10')).toBe(980);
|
|
expect(val(s, 'Umsatz', 'B11')).toBe(1550);
|
|
expect(val(s, 'Umsatz', 'B12')).toBe(5);
|
|
});
|
|
|
|
it('Übersicht cross-table totals (Umsatz!B2:E2 = 5970, see seed.ts note)', () => {
|
|
const s = fresh();
|
|
expect(val(s, 'Übersicht', 'B2')).toBe(5970);
|
|
expect(val(s, 'Übersicht', 'C2')).toBe(3400);
|
|
expect(val(s, 'Übersicht', 'D2')).toBe(2570);
|
|
expect(val(s, 'Übersicht', 'E2')).toBeCloseTo((2570 / 5970) * 100, 6);
|
|
});
|
|
|
|
it('editing Umsatz!B2 ripples through the whole chain', () => {
|
|
const s = fresh();
|
|
setCell(s, 'Umsatz', 'B2', '2200'); // +1000
|
|
expect(val(s, 'Umsatz', 'B8')).toBe(7175);
|
|
expect(val(s, 'Übersicht', 'B2')).toBe(6970);
|
|
expect(val(s, 'Übersicht', 'D2')).toBe(3570);
|
|
const perRegion = [2, 3, 4, 5, 6].map((r) => val(s, 'Übersicht', `B${r}`) as number);
|
|
expect(val(s, 'Übersicht', 'B8')).toBe(perRegion.reduce((a, b) => a + b, 0));
|
|
expect(perRegion[0]).toBe(6970);
|
|
});
|
|
|
|
it('precedence & associativity in a live cell', () => {
|
|
const s = fresh();
|
|
setCell(s, 'Umsatz', 'G20', '=(B2+C2)*2-D2/4');
|
|
expect(val(s, 'Umsatz', 'G20')).toBe((1200 + 1450) * 2 - 1600 / 4);
|
|
setCell(s, 'Umsatz', 'G21', '=2^3^2');
|
|
expect(val(s, 'Umsatz', 'G21')).toBe(512);
|
|
});
|
|
|
|
it('table-name matching is case/quote tolerant and accepts "::"', () => {
|
|
const s = fresh();
|
|
setCell(s, 'Umsatz', 'G16', "='Kosten'!B2");
|
|
setCell(s, 'Umsatz', 'G17', '=kosten!b2');
|
|
setCell(s, 'Umsatz', 'G18', '=Kosten::B2');
|
|
expect(val(s, 'Umsatz', 'G16')).toBe(800);
|
|
expect(val(s, 'Umsatz', 'G17')).toBe(800);
|
|
expect(val(s, 'Umsatz', 'G18')).toBe(800);
|
|
});
|
|
|
|
it('every error kind is classified', () => {
|
|
const s = fresh();
|
|
setCell(s, 'Umsatz', 'G14', '=1/0');
|
|
setCell(s, 'Umsatz', 'G15', '=FOO(1)');
|
|
setCell(s, 'Umsatz', 'G13', '=A1+');
|
|
setCell(s, 'Umsatz', 'G12', '=Nix!A1');
|
|
setCell(s, 'Umsatz', 'A1', '=A1');
|
|
expect((val(s, 'Umsatz', 'G14') as { code: string }).code).toBe('#DIV/0!');
|
|
expect((val(s, 'Umsatz', 'G15') as { code: string }).code).toBe('#NAME?');
|
|
expect((val(s, 'Umsatz', 'G13') as { code: string }).code).toBe('#ERR');
|
|
expect((val(s, 'Umsatz', 'G12') as { code: string }).code).toBe('#REF');
|
|
expect((val(s, 'Umsatz', 'A1') as { code: string }).code).toBe('#CIRC');
|
|
});
|
|
|
|
it('deleting the "Kosten" table turns dependents into #REF, no crash', () => {
|
|
const s = fresh();
|
|
s.deleteObject(tableId(s, 'Kosten'));
|
|
for (let r = 2; r <= 6; r++) expect(isError(val(s, 'Übersicht', `C${r}`))).toBe(true);
|
|
expect(val(s, 'Übersicht', 'B2')).toBe(5970); // unrelated cell still fine
|
|
});
|
|
|
|
it('resetToSeed restores the prefilled document', () => {
|
|
const s = fresh();
|
|
setCell(s, 'Umsatz', 'B2', '99999');
|
|
s.resetToSeed();
|
|
expect(val(s, 'Umsatz', 'B2')).toBe(1200);
|
|
expect(val(s, 'Übersicht', 'B2')).toBe(5970);
|
|
});
|
|
|
|
it('newDocument replaces everything with one empty sheet + table and a fresh history', () => {
|
|
const s = fresh();
|
|
setCell(s, 'Umsatz', 'B2', '99999');
|
|
s.newDocument();
|
|
expect(s.doc.sheets).toHaveLength(1);
|
|
expect(s.doc.sheets[0].name).toBe('Blatt 1');
|
|
const [table] = s.doc.sheets[0].objects;
|
|
expect(table.kind === 'table' && table.name).toBe('Tabelle 1');
|
|
expect(table.kind === 'table' && Object.keys(table.cells)).toEqual([]);
|
|
expect(s.doc.namedRanges).toEqual([]);
|
|
expect(s.canUndo.value).toBe(false);
|
|
});
|
|
|
|
it('a formula over times shows as a duration, past 24 hours', () => {
|
|
const s = fresh();
|
|
s.newDocument();
|
|
const t = tableId(s, 'Tabelle 1');
|
|
setCell(s, 'Tabelle 1', 'A1', '08:30');
|
|
setCell(s, 'Tabelle 1', 'A2', '09:15');
|
|
setCell(s, 'Tabelle 1', 'A3', '20:00');
|
|
setCell(s, 'Tabelle 1', 'A4', '=SUM(A1:A3)');
|
|
setCell(s, 'Tabelle 1', 'B1', '=A2-A1');
|
|
setCell(s, 'Tabelle 1', 'B2', '=A4+B1'); // chain: a sum of formulas
|
|
expect(s.cellDisplay(t, 0, 3)).toBe('37:45');
|
|
expect(s.cellDisplay(t, 1, 0)).toBe('00:45');
|
|
expect(s.cellDisplay(t, 1, 1)).toBe('38:30');
|
|
// an explicit format always wins over the inferred one
|
|
s.setRangeFormat(t, { col: 0, row: 3 }, { col: 0, row: 3 }, { numFmt: 'time' });
|
|
expect(s.cellDisplay(t, 0, 3)).toBe('13:45');
|
|
});
|
|
|
|
it('a typed 24+ hour time becomes a duration; dates are not durations', () => {
|
|
const s = fresh();
|
|
s.newDocument();
|
|
const t = tableId(s, 'Tabelle 1');
|
|
setCell(s, 'Tabelle 1', 'A1', '37:30');
|
|
expect(s.cellDisplay(t, 0, 0)).toBe('37:30');
|
|
setCell(s, 'Tabelle 1', 'C1', '2026-09-02');
|
|
setCell(s, 'Tabelle 1', 'C2', '=C1+30');
|
|
expect(s.cellDisplay(t, 2, 1)).not.toContain(':');
|
|
});
|
|
|
|
it('inserting a new table and referencing it across sheets works', () => {
|
|
const s = fresh();
|
|
const sheet2 = s.doc.sheets[1].id;
|
|
const id = s.addTable(sheet2);
|
|
s.renameObject(id, 'Extra');
|
|
s.setCellInput(id, 0, 0, '=Umsatz!B8 + 25'); // A1 on the new table
|
|
expect(s.cellValue(id, 0, 0)).toBe(6175 + 25);
|
|
});
|
|
|
|
it('selection aggregate & cell name are live', () => {
|
|
const s = fresh();
|
|
const umsatz = tableId(s, 'Umsatz');
|
|
s.setSelection({ tableId: umsatz, anchor: { col: 1, row: 1 }, focus: { col: 1, row: 5 } });
|
|
expect(s.selectionAggregate.value.sum).toBe(6175);
|
|
expect(s.selectionAggregate.value.count).toBe(5);
|
|
// the active cell is the anchor -- the first cell of the selection -- not
|
|
// wherever the selection was extended to.
|
|
expect(s.activeCellName.value).toBe(`Umsatz::${toA1(1, 1)}`);
|
|
// selectionRangeName shows the full range instead (status bar / formula bar)
|
|
expect(s.selectionRangeName.value).toBe(`Umsatz::${toA1(1, 1)}:${toA1(1, 5)}`);
|
|
});
|
|
|
|
it('selectionRangeName shows a single address for a 1x1 selection and a "+N" suffix for a discontiguous multi-selection', () => {
|
|
const s = fresh();
|
|
const umsatz = tableId(s, 'Umsatz');
|
|
s.setSelection({ tableId: umsatz, anchor: { col: 0, row: 0 }, focus: { col: 0, row: 0 } });
|
|
expect(s.selectionRangeName.value).toBe(`Umsatz::${toA1(0, 0)}`);
|
|
|
|
s.addToSelection(umsatz, 3, 3);
|
|
s.addToSelection(umsatz, 5, 5);
|
|
// addToSelection() moves the primary rect to the just-clicked cell (see
|
|
// "Mehrfachauswahl" tests below) -- the range name follows it, plus a
|
|
// "+N" for the other rectangles now in the selection.
|
|
expect(s.selectionRangeName.value).toBe(`Umsatz::${toA1(5, 5)} +2`);
|
|
});
|
|
|
|
it('recalculation visits precedents before dependents across tables', () => {
|
|
const s = fresh();
|
|
const wb = s.engine();
|
|
const { order, circular } = wb.debugOrder([
|
|
// keyOf is internal; use the store to find the id then build the key shape
|
|
`${tableId(s, 'Umsatz')}␟1␟1`,
|
|
]);
|
|
expect(circular).toEqual([]);
|
|
const uid = tableId(s, 'Übersicht');
|
|
const pos = (k: string) => order.indexOf(k);
|
|
expect(pos(`${uid}␟1␟1`)).toBeGreaterThan(-1);
|
|
expect(pos(`${uid}␟1␟1`)).toBeLessThan(pos(`${uid}␟3␟1`));
|
|
});
|
|
});
|
|
|
|
describe('table view: frozen rows/cols + scroll', () => {
|
|
it('seed marks "Umsatz" as a fixed, scrollable table with frozen header row & column', () => {
|
|
const s = fresh();
|
|
const t = s.tableById(tableId(s, 'Umsatz'))!;
|
|
expect(t.view.mode).toBe('fixed');
|
|
expect(t.view.frozenRows).toBe(1);
|
|
expect(t.view.frozenCols).toBe(1);
|
|
});
|
|
|
|
it('setTableView clamps frozen counts to the grid size', () => {
|
|
const s = fresh();
|
|
const id = tableId(s, 'Kosten'); // 12 rows x 8 cols
|
|
s.setTableView(id, { mode: 'fixed', frozenRows: 999, frozenCols: 999 });
|
|
const t = s.tableById(id)!;
|
|
expect(t.view.frozenRows).toBe(11);
|
|
expect(t.view.frozenCols).toBe(7);
|
|
});
|
|
|
|
it('shrinking a table drops out-of-range cells and re-clamps frozen counts', () => {
|
|
const s = fresh();
|
|
const id = tableId(s, 'Kosten');
|
|
s.setTableView(id, { mode: 'fixed', frozenRows: 5, frozenCols: 4 });
|
|
s.setTableRows(id, 3);
|
|
s.setTableCols(id, 2);
|
|
const t = s.tableById(id)!;
|
|
expect(t.rows).toBe(3);
|
|
expect(t.cols).toBe(2);
|
|
expect(t.view.frozenRows).toBe(2);
|
|
expect(t.view.frozenCols).toBe(1);
|
|
// a cell that was at C6 is gone
|
|
expect(s.cellValue(id, 2, 5)).toBeNull();
|
|
});
|
|
|
|
it('view settings survive a JSON round-trip', () => {
|
|
const s = fresh();
|
|
const id = tableId(s, 'Übersicht');
|
|
s.setTableView(id, { mode: 'fixed', width: 500, height: 260, frozenRows: 1, frozenCols: 2 });
|
|
const before = s.snapshot();
|
|
s.replaceDocument(parseDocument(serializeDocument(before)));
|
|
expect(s.snapshot()).toEqual(before);
|
|
});
|
|
});
|
|
|
|
describe('number-format locale (object -> sheet -> document cascade)', () => {
|
|
it('cellDisplay uses the document locale by default', () => {
|
|
const s = fresh();
|
|
const id = tableId(s, 'Umsatz');
|
|
expect(s.doc.locale).toBe('de-DE');
|
|
// B8 = 6175 -> "6.175" in de-DE
|
|
expect(s.cellDisplay(id, 1, 7)).toBe('6.175');
|
|
});
|
|
|
|
it('a sheet-level override wins over the document', () => {
|
|
const s = fresh();
|
|
const id = tableId(s, 'Umsatz');
|
|
s.setSheetLocale(s.doc.sheets[0].id, 'en-US');
|
|
expect(s.cellDisplay(id, 1, 7)).toBe('6,175');
|
|
expect(s.effectiveLocale(id)).toBe('en-US');
|
|
});
|
|
|
|
it('an object-level override wins over the sheet', () => {
|
|
const s = fresh();
|
|
const id = tableId(s, 'Umsatz');
|
|
s.setSheetLocale(s.doc.sheets[0].id, 'en-US');
|
|
s.setObjectLocale(id, 'plain');
|
|
expect(s.cellDisplay(id, 1, 7)).toBe('6175');
|
|
// a different table on the same sheet still follows the sheet override
|
|
expect(s.cellDisplay(tableId(s, 'Übersicht'), 1, 1)).toBe('5,970');
|
|
});
|
|
|
|
it('clearing an override falls back down the cascade', () => {
|
|
const s = fresh();
|
|
const id = tableId(s, 'Umsatz');
|
|
s.setObjectLocale(id, 'fr-FR');
|
|
expect(s.effectiveLocale(id)).toBe('fr-FR');
|
|
s.setObjectLocale(id, null);
|
|
expect(s.effectiveLocale(id)).toBe('de-DE');
|
|
expect(s.tableById(id)!.locale).toBeUndefined();
|
|
});
|
|
|
|
it('locale overrides survive a JSON round-trip; invalid values are dropped', () => {
|
|
const s = fresh();
|
|
const id = tableId(s, 'Kosten');
|
|
s.setDocumentLocale('en-GB');
|
|
s.setSheetLocale(s.doc.sheets[0].id, 'de-CH');
|
|
s.setObjectLocale(id, 'it-IT');
|
|
const before = s.snapshot();
|
|
s.replaceDocument(parseDocument(serializeDocument(before)));
|
|
expect(s.snapshot()).toEqual(before);
|
|
expect(s.doc.locale).toBe('en-GB');
|
|
|
|
// a garbage locale in imported JSON is ignored, not fatal
|
|
const doc = JSON.parse(serializeDocument(s.snapshot()));
|
|
doc.locale = 'kl-KL';
|
|
doc.sheets[0].objects[0].locale = 'zz';
|
|
s.replaceDocument(parseDocument(JSON.stringify(doc)));
|
|
expect(s.doc.locale).toBe('de-DE');
|
|
const first = s.doc.sheets[0].objects[0];
|
|
expect(first.kind === 'table' && first.locale).toBeFalsy();
|
|
});
|
|
});
|
|
|
|
describe('per-sheet appearance, zoom & CSV default (saved in the file)', () => {
|
|
it('seed ships "Blatt 2" with a backdrop colour and the grid hidden', () => {
|
|
const s = fresh();
|
|
const b2 = s.doc.sheets[1];
|
|
expect(b2.background).toBe('#f4f1ff');
|
|
expect(b2.showGrid).toBe(false);
|
|
});
|
|
|
|
it('background / foreground / grid are per-sheet and clearable', () => {
|
|
const s = fresh();
|
|
const a = s.doc.sheets[0].id;
|
|
const b = s.doc.sheets[1].id;
|
|
s.setSheetBackground(a, '#102030');
|
|
s.setSheetForeground(a, '#eef');
|
|
s.setSheetShowGrid(a, false);
|
|
expect(s.doc.sheets[0]).toMatchObject({ background: '#102030', foreground: '#eef', showGrid: false });
|
|
expect(s.doc.sheets[1].background).toBe('#f4f1ff'); // untouched
|
|
s.setSheetBackground(a, null);
|
|
s.setSheetForeground(a, null);
|
|
expect(s.doc.sheets[0].background).toBeUndefined();
|
|
expect(s.doc.sheets[0].foreground).toBeUndefined();
|
|
void b;
|
|
});
|
|
|
|
it('grid size (snap increment) is per-sheet, clamped and rounded', () => {
|
|
const s = fresh();
|
|
const a = s.doc.sheets[0].id;
|
|
const b = s.doc.sheets[1].id;
|
|
expect(s.doc.sheets[0].gridSize).toBeUndefined(); // unset = DEFAULT_GRID_SIZE at use-sites
|
|
s.setSheetGridSize(a, 40.6);
|
|
expect(s.doc.sheets[0].gridSize).toBe(41); // rounded
|
|
expect(s.doc.sheets[1].gridSize).toBeUndefined(); // untouched
|
|
s.setSheetGridSize(a, 1); // below MIN_GRID_SIZE
|
|
expect(s.doc.sheets[0].gridSize).toBe(4);
|
|
s.setSheetGridSize(a, 9999); // above MAX_GRID_SIZE
|
|
expect(s.doc.sheets[0].gridSize).toBe(200);
|
|
void b;
|
|
});
|
|
|
|
it('zoom is stored per sheet; the active-sheet zoom follows the tab', () => {
|
|
const s = fresh();
|
|
expect(s.activeZoom.value).toBe(1);
|
|
s.setZoom(1.5); // targets the active sheet
|
|
expect(s.activeZoom.value).toBe(1.5);
|
|
expect(s.doc.sheets[0].zoom).toBe(1.5);
|
|
s.setActiveSheet(s.doc.sheets[1].id);
|
|
expect(s.activeZoom.value).toBe(1); // sheet 2 still default
|
|
s.setZoom(0.8);
|
|
s.setActiveSheet(s.doc.sheets[0].id);
|
|
expect(s.activeZoom.value).toBe(1.5); // sheet 1 kept its own
|
|
});
|
|
|
|
it('appearance + zoom + gridSize + csvDelimiter survive a JSON round-trip; bad values dropped', () => {
|
|
const s = fresh();
|
|
const a = s.doc.sheets[0].id;
|
|
s.setSheetBackground(a, '#abcdef');
|
|
s.setSheetForeground(a, '#012');
|
|
s.setSheetShowGrid(a, false);
|
|
s.setSheetZoom(a, 1.25);
|
|
s.setSheetGridSize(a, 32);
|
|
s.setCsvDelimiter(';');
|
|
const before = s.snapshot();
|
|
s.replaceDocument(parseDocument(serializeDocument(before)));
|
|
expect(s.snapshot()).toEqual(before);
|
|
expect(s.doc.csvDelimiter).toBe(';');
|
|
expect(s.doc.sheets[0].gridSize).toBe(32);
|
|
|
|
const doc = JSON.parse(serializeDocument(s.snapshot()));
|
|
doc.sheets[0].background = 'not-a-color';
|
|
doc.sheets[0].zoom = 99;
|
|
doc.sheets[0].gridSize = 'huge';
|
|
doc.csvDelimiter = 'x';
|
|
s.replaceDocument(parseDocument(JSON.stringify(doc)));
|
|
expect(s.doc.sheets[0].background).toBeUndefined();
|
|
expect(s.doc.sheets[0].zoom).toBe(2); // clamped, not dropped
|
|
expect(s.doc.sheets[0].gridSize).toBeUndefined(); // not a number -> dropped
|
|
expect(s.doc.csvDelimiter).toBe(',');
|
|
});
|
|
});
|
|
|
|
describe('cell formatting, number formats, row height', () => {
|
|
it('per-cell number format combines with the locale', () => {
|
|
const s = fresh();
|
|
const id = tableId(s, 'Umsatz');
|
|
const norm = (x: unknown) => String(x).replace(/[\u00a0\u202f]/g, " ");
|
|
s.setCellInput(id, 6, 0, '0.1234'); // G1
|
|
expect(s.cellDisplay(id, 6, 0)).toBe('0,1234'); // auto, de-DE
|
|
s.setRangeFormat(id, { col: 6, row: 0 }, { col: 6, row: 0 }, { numFmt: 'pct2' });
|
|
expect(norm(s.cellDisplay(id, 6, 0))).toBe('12,34 %');
|
|
s.setRangeFormat(id, { col: 6, row: 0 }, { col: 6, row: 0 }, { numFmt: 'int' });
|
|
expect(s.cellDisplay(id, 6, 0)).toBe('0');
|
|
});
|
|
|
|
it('typing a recognizable date auto-applies the date format', () => {
|
|
const s = fresh();
|
|
const id = tableId(s, 'Umsatz');
|
|
s.setCellInput(id, 6, 0, '02.09.2026'); // G1, still 'auto' beforehand
|
|
expect(s.cellFormat(id, 6, 0)).toEqual({ numFmt: 'date' });
|
|
expect(s.cellDisplay(id, 6, 0)).toBe('02.09.2026');
|
|
});
|
|
|
|
it('typing a recognizable time auto-applies the time format', () => {
|
|
const s = fresh();
|
|
const id = tableId(s, 'Umsatz');
|
|
s.setCellInput(id, 6, 0, '14:30');
|
|
expect(s.cellFormat(id, 6, 0)).toEqual({ numFmt: 'time' });
|
|
expect(s.cellDisplay(id, 6, 0)).toBe('14:30');
|
|
});
|
|
|
|
it('date auto-recognition never overrides an explicit numFmt already on the cell', () => {
|
|
const s = fresh();
|
|
const id = tableId(s, 'Umsatz');
|
|
s.setRangeFormat(id, { col: 6, row: 0 }, { col: 6, row: 0 }, { numFmt: 'int' });
|
|
s.setCellInput(id, 6, 0, '02.09.2026');
|
|
expect(s.cellFormat(id, 6, 0)).toEqual({ numFmt: 'int' }); // untouched
|
|
});
|
|
|
|
it('a plain number or unrecognized text does not touch numFmt', () => {
|
|
const s = fresh();
|
|
const id = tableId(s, 'Umsatz');
|
|
s.setCellInput(id, 6, 0, '1234.5');
|
|
expect(s.cellFormat(id, 6, 0)).toEqual({});
|
|
s.setCellInput(id, 6, 1, '=1+1');
|
|
expect(s.cellFormat(id, 6, 1)).toEqual({});
|
|
});
|
|
|
|
it('setRangeFormat applies to a whole range and clears with falsy/auto', () => {
|
|
const s = fresh();
|
|
const id = tableId(s, 'Umsatz');
|
|
s.setRangeFormat(id, { col: 1, row: 1 }, { col: 2, row: 3 }, { align: 'center', bold: true });
|
|
expect(s.cellFormat(id, 1, 1)).toEqual({ align: 'center', bold: true });
|
|
expect(s.cellFormat(id, 2, 3)).toEqual({ align: 'center', bold: true });
|
|
s.setRangeFormat(id, { col: 1, row: 1 }, { col: 2, row: 3 }, { bold: false, align: 'auto' });
|
|
expect(s.cellFormat(id, 1, 1)).toEqual({});
|
|
});
|
|
|
|
it('selectionFormat reports only the values shared across the selection', () => {
|
|
const s = fresh();
|
|
const id = tableId(s, 'Umsatz');
|
|
s.setRangeFormat(id, { col: 1, row: 1 }, { col: 1, row: 1 }, { align: 'right', color: '#ff0000' });
|
|
s.setRangeFormat(id, { col: 1, row: 2 }, { col: 1, row: 2 }, { align: 'right', color: '#00ff00' });
|
|
s.setSelection({ tableId: id, anchor: { col: 1, row: 1 }, focus: { col: 1, row: 2 } });
|
|
expect(s.selectionFormat.value).toEqual({ align: 'right' }); // color differs -> dropped
|
|
});
|
|
|
|
it('row height: per-row override beats the table default', () => {
|
|
const s = fresh();
|
|
const id = tableId(s, 'Kosten');
|
|
expect(s.rowHeightOf(id, 0)).toBe(26); // DEFAULT_ROW_H
|
|
s.setTableRowHeight(id, 34);
|
|
expect(s.rowHeightOf(id, 0)).toBe(34);
|
|
s.setRowHeight(id, 2, 60);
|
|
expect(s.rowHeightOf(id, 2)).toBe(60);
|
|
expect(s.rowHeightOf(id, 1)).toBe(34);
|
|
});
|
|
|
|
it('column width can now go well below the old 48px floor', () => {
|
|
const s = fresh();
|
|
const id = tableId(s, 'Kosten');
|
|
s.setColWidth(id, 0, 8);
|
|
expect(s.tableById(id)!.colWidths[0]).toBe(24); // new minimum
|
|
});
|
|
|
|
it('shrinking a table prunes stale formats and row heights', () => {
|
|
const s = fresh();
|
|
const id = tableId(s, 'Kosten');
|
|
s.setRangeFormat(id, { col: 5, row: 8 }, { col: 5, row: 8 }, { bold: true });
|
|
s.setRowHeight(id, 9, 50);
|
|
s.setTableRows(id, 4);
|
|
s.setTableCols(id, 3);
|
|
expect(s.tableById(id)!.formats).toBeUndefined();
|
|
expect(s.tableById(id)!.rowHeights).toBeUndefined();
|
|
});
|
|
|
|
it('underline is a cell format like bold and survives a JSON round-trip', () => {
|
|
const s = fresh();
|
|
const id = tableId(s, 'Umsatz');
|
|
s.setRangeFormat(id, { col: 0, row: 0 }, { col: 1, row: 1 }, { underline: true, italic: true });
|
|
s.replaceDocument(parseDocument(serializeDocument(s.snapshot())));
|
|
expect(s.cellFormat(id, 1, 1)).toEqual({ underline: true, italic: true });
|
|
s.setRangeFormat(id, { col: 0, row: 0 }, { col: 1, row: 1 }, { underline: false });
|
|
expect(s.cellFormat(id, 0, 0)).toEqual({ italic: true });
|
|
});
|
|
|
|
it('formatting + row heights survive a JSON round-trip', () => {
|
|
const s = fresh();
|
|
const id = tableId(s, 'Umsatz');
|
|
s.setRangeFormat(id, { col: 1, row: 1 }, { col: 3, row: 4 }, { numFmt: 'dec2', bg: '#eef' });
|
|
s.setTableRowHeight(id, 30);
|
|
s.setRowHeight(id, 5, 48);
|
|
const before = s.snapshot();
|
|
s.replaceDocument(parseDocument(serializeDocument(before)));
|
|
expect(s.snapshot()).toEqual(before);
|
|
});
|
|
});
|
|
|
|
describe('multi-cell (discontiguous) selection', () => {
|
|
it('addToSelection keeps prior rectangles and moves the active cell', () => {
|
|
const s = fresh();
|
|
const id = tableId(s, 'Umsatz');
|
|
s.setSelection({ tableId: id, anchor: { col: 1, row: 1 }, focus: { col: 1, row: 3 } });
|
|
s.addToSelection(id, 4, 6);
|
|
const sel = s.doc.selection!;
|
|
expect(sel.focus).toEqual({ col: 4, row: 6 }); // active cell follows the click
|
|
expect(sel.ranges).toEqual([{ anchor: { col: 1, row: 1 }, focus: { col: 1, row: 3 } }]);
|
|
// two rectangles -> 3 + 1 cells
|
|
expect(s.selectionAggregate.value.cells).toBe(4);
|
|
});
|
|
|
|
it('ctrl-clicking an extra single cell again toggles it off', () => {
|
|
const s = fresh();
|
|
const id = tableId(s, 'Umsatz');
|
|
s.setSelection({ tableId: id, anchor: { col: 0, row: 0 }, focus: { col: 0, row: 0 } });
|
|
s.addToSelection(id, 2, 2); // add B..C? -> cell (2,2)
|
|
expect(s.doc.selection!.ranges).toHaveLength(1);
|
|
s.addToSelection(id, 2, 2); // click it again -> off
|
|
expect(s.doc.selection!.ranges).toBeUndefined();
|
|
});
|
|
|
|
it('extendActiveRange grows the most recent rectangle', () => {
|
|
const s = fresh();
|
|
const id = tableId(s, 'Umsatz');
|
|
s.setSelection({ tableId: id, anchor: { col: 0, row: 0 }, focus: { col: 0, row: 0 } });
|
|
s.addToSelection(id, 3, 3);
|
|
s.extendActiveRange(id, 5, 4);
|
|
expect(s.doc.selection!.focus).toEqual({ col: 5, row: 4 });
|
|
// primary rect 3..5 x 3..4 = 6 cells, plus the A1 cell = 7
|
|
expect(s.selectionAggregate.value.cells).toBe(7);
|
|
});
|
|
|
|
it('aggregate & formatting span every selected rectangle', () => {
|
|
const s = fresh();
|
|
const id = tableId(s, 'Umsatz'); // B2:B6 = 1200 980 1550 1340 1105
|
|
s.setSelection({ tableId: id, anchor: { col: 1, row: 1 }, focus: { col: 1, row: 1 } }); // B2
|
|
s.addToSelection(id, 1, 3); // + B4
|
|
s.addToSelection(id, 1, 5); // + B6 (active)
|
|
expect(s.selectionAggregate.value.sum).toBe(1200 + 1550 + 1105);
|
|
expect(s.selectionAggregate.value.count).toBe(3);
|
|
|
|
s.applySelectionFormat({ bold: true });
|
|
expect(s.cellFormat(id, 1, 1)).toEqual({ bold: true });
|
|
expect(s.cellFormat(id, 1, 3)).toEqual({ bold: true });
|
|
expect(s.cellFormat(id, 1, 5)).toEqual({ bold: true });
|
|
expect(s.cellFormat(id, 1, 2)).toEqual({}); // B3 was never selected
|
|
|
|
s.clearSelectionCells();
|
|
expect(s.cellValue(id, 1, 1)).toBeNull();
|
|
expect(s.cellValue(id, 1, 3)).toBeNull();
|
|
expect(s.cellValue(id, 1, 2)).toBe(980); // untouched
|
|
});
|
|
|
|
it('the multi-selection survives a JSON round-trip and is clamped on shrink', () => {
|
|
const s = fresh();
|
|
const id = tableId(s, 'Kosten');
|
|
s.setSelection({ tableId: id, anchor: { col: 0, row: 0 }, focus: { col: 0, row: 0 } });
|
|
s.addToSelection(id, 6, 9);
|
|
const before = s.snapshot();
|
|
s.replaceDocument(parseDocument(serializeDocument(before)));
|
|
expect(s.snapshot()).toEqual(before);
|
|
s.setTableRows(id, 4);
|
|
s.setTableCols(id, 3);
|
|
for (const r of [s.doc.selection!.focus, ...(s.doc.selection!.ranges ?? []).flatMap((x) => [x.anchor, x.focus])]) {
|
|
expect(r.col).toBeLessThan(3);
|
|
expect(r.row).toBeLessThan(4);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('clipboard: copy / cut / paste', () => {
|
|
it('copy adjusts every relative reference by the paste delta', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(id)!.name;
|
|
setCell(s, name, 'A1', '5');
|
|
setCell(s, name, 'A2', '=A1*2');
|
|
setCell(s, name, 'C4', '9'); // the shifted formula's new precedent
|
|
|
|
s.setSelection({ tableId: id, anchor: coords('A2'), focus: coords('A2') });
|
|
s.copySelection();
|
|
s.setSelection({ tableId: id, anchor: coords('C5'), focus: coords('C5') });
|
|
s.pasteClipboard();
|
|
|
|
expect(s.cellInput(id, coords('C5').col, coords('C5').row)).toBe('=C4*2');
|
|
expect(val(s, name, 'C5')).toBe(18);
|
|
});
|
|
|
|
it('copy fills a larger, exact-multiple selection (tiled paste)', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
setCell(s, s.tableById(id)!.name, 'A1', '1');
|
|
setCell(s, s.tableById(id)!.name, 'A2', '2');
|
|
const name = s.tableById(id)!.name;
|
|
|
|
s.setSelection({ tableId: id, anchor: coords('A1'), focus: coords('A2') }); // 1x2 block
|
|
s.copySelection();
|
|
s.setSelection({ tableId: id, anchor: coords('B1'), focus: coords('B4') }); // 1x4 -> tiles twice
|
|
s.pasteClipboard();
|
|
|
|
expect(val(s, name, 'B1')).toBe(1);
|
|
expect(val(s, name, 'B2')).toBe(2);
|
|
expect(val(s, name, 'B3')).toBe(1);
|
|
expect(val(s, name, 'B4')).toBe(2);
|
|
});
|
|
|
|
it('pasting a single copied cell into a range fills every cell, each adjusted', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(id)!.name;
|
|
setCell(s, name, 'A1', '10');
|
|
setCell(s, name, 'B1', '=A1');
|
|
|
|
s.setSelection({ tableId: id, anchor: coords('B1'), focus: coords('B1') });
|
|
s.copySelection();
|
|
s.setSelection({ tableId: id, anchor: coords('B2'), focus: coords('B4') });
|
|
s.pasteClipboard();
|
|
|
|
expect(s.cellInput(id, coords('B2').col, coords('B2').row)).toBe('=A2');
|
|
expect(s.cellInput(id, coords('B3').col, coords('B3').row)).toBe('=A3');
|
|
expect(s.cellInput(id, coords('B4').col, coords('B4').row)).toBe('=A4');
|
|
});
|
|
|
|
it('cut only follows references that moved with the cut block; outside refs stay put', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(id)!.name;
|
|
setCell(s, name, 'A1', '5'); // stays put -- not part of the cut
|
|
setCell(s, name, 'B1', '10'); // part of the cut block (B1:B2)
|
|
setCell(s, name, 'B2', '=B1+A1'); // one ref moves (B1), one doesn't (A1)
|
|
|
|
s.setSelection({ tableId: id, anchor: coords('B1'), focus: coords('B2') });
|
|
s.cutSelection();
|
|
s.setSelection({ tableId: id, anchor: coords('D1'), focus: coords('D1') });
|
|
s.pasteClipboard();
|
|
|
|
expect(s.cellInput(id, coords('D2').col, coords('D2').row)).toBe('=D1+A1');
|
|
expect(val(s, name, 'D1')).toBe(10);
|
|
expect(val(s, name, 'D2')).toBe(15);
|
|
// the source cells were cleared
|
|
expect(s.cellInput(id, coords('B1').col, coords('B1').row)).toBe('');
|
|
expect(s.cellInput(id, coords('B2').col, coords('B2').row)).toBe('');
|
|
// the untouched cell A1 is exactly that -- untouched
|
|
expect(val(s, name, 'A1')).toBe(5);
|
|
});
|
|
|
|
it('copies cell formatting along with the value, overwriting the destination', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
setCell(s, s.tableById(id)!.name, 'A1', 'x');
|
|
s.setRangeFormat(id, coords('A1'), coords('A1'), { bold: true, align: 'right' });
|
|
s.setRangeFormat(id, coords('C1'), coords('C1'), { italic: true });
|
|
|
|
s.setSelection({ tableId: id, anchor: coords('A1'), focus: coords('A1') });
|
|
s.copySelection();
|
|
s.setSelection({ tableId: id, anchor: coords('C1'), focus: coords('C1') });
|
|
s.pasteClipboard();
|
|
|
|
expect(s.cellFormat(id, coords('C1').col, coords('C1').row)).toEqual({ bold: true, align: 'right' });
|
|
});
|
|
|
|
it('Escape drops the buffer without pasting', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
setCell(s, s.tableById(id)!.name, 'A1', '1');
|
|
s.setSelection({ tableId: id, anchor: coords('A1'), focus: coords('A1') });
|
|
s.copySelection();
|
|
expect(s.hasClipboard.value).toBe(true);
|
|
s.cancelClipboard();
|
|
expect(s.hasClipboard.value).toBe(false);
|
|
});
|
|
|
|
it('clipboardRect keeps the copy source\'s own anchor after the live selection moves elsewhere', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
s.setSelection({ tableId: id, anchor: coords('B2'), focus: coords('B5') });
|
|
s.copySelection();
|
|
expect(s.clipboardRect.value?.anchor).toEqual(coords('B2'));
|
|
|
|
// picking a paste destination elsewhere must not move the source's anchor marker
|
|
s.setSelection({ tableId: id, anchor: coords('D2'), focus: coords('D5') });
|
|
expect(s.clipboardRect.value?.anchor).toEqual(coords('B2'));
|
|
expect(s.clipboardRect.value).toMatchObject({ tableId: id, c0: 1, c1: 1, r0: 1, r1: 4 });
|
|
});
|
|
|
|
it('the source anchor is the drag\'s own start cell, not the rectangle\'s top-left corner', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
// dragged bottom-right -> top-left: anchor is B5, not the normalised B2
|
|
s.setSelection({ tableId: id, anchor: coords('B5'), focus: coords('B2') });
|
|
s.copySelection();
|
|
expect(s.clipboardRect.value?.anchor).toEqual(coords('B5'));
|
|
expect(s.clipboardRect.value).toMatchObject({ c0: 1, c1: 1, r0: 1, r1: 4 });
|
|
});
|
|
|
|
it('copy leaves $-locked axes exactly as typed; a relative axis still shifts', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(id)!.name;
|
|
setCell(s, name, 'B2', '=$A$1+A$1+$A1+A1');
|
|
|
|
s.setSelection({ tableId: id, anchor: coords('B2'), focus: coords('B2') });
|
|
s.copySelection();
|
|
s.setSelection({ tableId: id, anchor: coords('D5'), focus: coords('D5') }); // +2 cols, +3 rows
|
|
s.pasteClipboard();
|
|
|
|
// fully locked: untouched -- row-locked: only the column shifts --
|
|
// col-locked: only the row shifts -- relative: both shift
|
|
expect(s.cellInput(id, coords('D5').col, coords('D5').row)).toBe('=$A$1+C$1+$A4+C4');
|
|
});
|
|
|
|
it('cutting a $-locked reference still follows the moved cell', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(id)!.name;
|
|
setCell(s, name, 'A1', '5'); // stays put -- referenced, not cut
|
|
setCell(s, name, 'B1', '10'); // part of the cut block (B1:B2)
|
|
setCell(s, name, 'B2', '=$B$1+$A$1'); // one $-locked ref moves with the cut, one doesn't
|
|
|
|
s.setSelection({ tableId: id, anchor: coords('B1'), focus: coords('B2') });
|
|
s.cutSelection();
|
|
s.setSelection({ tableId: id, anchor: coords('D1'), focus: coords('D1') });
|
|
s.pasteClipboard();
|
|
|
|
expect(s.cellInput(id, coords('D2').col, coords('D2').row)).toBe('=$D$1+$A$1');
|
|
expect(val(s, name, 'D2')).toBe(15);
|
|
});
|
|
});
|
|
|
|
describe('fill handle (drag to copy/fill)', () => {
|
|
it('fills down: repeats the source rows, formulas shifted per repetition', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(id)!.name;
|
|
setCell(s, name, 'A1', '10');
|
|
setCell(s, name, 'B1', '=A1*2');
|
|
|
|
s.fillSelection(id, { c0: 1, c1: 1, r0: 0, r1: 0 }, { c0: 1, c1: 1, r0: 1, r1: 3 });
|
|
|
|
expect(s.cellInput(id, coords('B2').col, coords('B2').row)).toBe('=A2*2');
|
|
expect(s.cellInput(id, coords('B3').col, coords('B3').row)).toBe('=A3*2');
|
|
expect(s.cellInput(id, coords('B4').col, coords('B4').row)).toBe('=A4*2');
|
|
});
|
|
|
|
it('fills right: a multi-column source cycles, not just repeats the last column', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(id)!.name;
|
|
setCell(s, name, 'A1', '1');
|
|
setCell(s, name, 'B1', '2');
|
|
|
|
// 2-wide source dragged 5 columns right -> C,D,E,F,G cycle 1,2,1,2,1
|
|
s.fillSelection(id, { c0: 0, c1: 1, r0: 0, r1: 0 }, { c0: 2, c1: 6, r0: 0, r1: 0 });
|
|
|
|
expect(val(s, name, 'C1')).toBe(1);
|
|
expect(val(s, name, 'D1')).toBe(2);
|
|
expect(val(s, name, 'E1')).toBe(1);
|
|
expect(val(s, name, 'F1')).toBe(2);
|
|
expect(val(s, name, 'G1')).toBe(1); // partial trailing cycle, still fills
|
|
});
|
|
|
|
it('fills up/left too (a drag past the source in either direction)', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(id)!.name;
|
|
setCell(s, name, 'C3', '9');
|
|
|
|
s.fillSelection(id, { c0: 2, c1: 2, r0: 2, r1: 2 }, { c0: 2, c1: 2, r0: 0, r1: 1 }); // up
|
|
expect(val(s, name, 'C1')).toBe(9);
|
|
expect(val(s, name, 'C2')).toBe(9);
|
|
});
|
|
|
|
it('carries formatting along, overwriting the destination', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
setCell(s, s.tableById(id)!.name, 'A1', 'x');
|
|
s.setRangeFormat(id, coords('A1'), coords('A1'), { bold: true });
|
|
|
|
s.fillSelection(id, { c0: 0, c1: 0, r0: 0, r1: 0 }, { c0: 0, c1: 0, r0: 1, r1: 1 });
|
|
|
|
expect(s.cellFormat(id, coords('A2').col, coords('A2').row)).toEqual({ bold: true });
|
|
});
|
|
});
|
|
|
|
describe('custom functions (document-level library)', () => {
|
|
it('addCustomFunction stores the definition and a call evaluates through it', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(id)!.name;
|
|
setCell(s, name, 'A1', '1200');
|
|
setCell(s, name, 'A2', '800');
|
|
|
|
const result = s.addCustomFunction('MARGE', ['revenue', 'cost'], '(revenue - cost) / revenue');
|
|
expect(typeof result).toBe('string'); // the new function's id, not { error }
|
|
expect(s.doc.customFunctions).toHaveLength(1);
|
|
expect(s.doc.customFunctions[0]).toMatchObject({ name: 'MARGE', params: ['revenue', 'cost'] });
|
|
|
|
setCell(s, name, 'A3', '=MARGE(A1;A2)');
|
|
expect(val(s, name, 'A3')).toBeCloseTo((1200 - 800) / 1200, 10);
|
|
|
|
// recalculates when a precedent changes
|
|
setCell(s, name, 'A1', '2000');
|
|
expect(val(s, name, 'A3')).toBeCloseTo((2000 - 800) / 2000, 10);
|
|
});
|
|
|
|
it('rejects a name colliding with a built-in function', () => {
|
|
const s = fresh();
|
|
const result = s.addCustomFunction('SUM', ['x'], 'x') as { error: string };
|
|
expect(result.error).toBeTruthy();
|
|
expect(s.doc.customFunctions).toHaveLength(0);
|
|
});
|
|
|
|
it('rejects a name colliding with the built-in IF function (case-insensitive)', () => {
|
|
const s = fresh();
|
|
const result = s.addCustomFunction('if', ['x'], 'x') as { error: string };
|
|
expect(result.error).toBeTruthy();
|
|
expect(s.doc.customFunctions).toHaveLength(0);
|
|
});
|
|
|
|
it('rejects a name colliding with a built-in date/time function', () => {
|
|
const s = fresh();
|
|
for (const name of ['DATE', 'time', 'Today', 'NOW']) {
|
|
const result = s.addCustomFunction(name, ['x'], 'x') as { error: string };
|
|
expect(result.error).toBeTruthy();
|
|
}
|
|
expect(s.doc.customFunctions).toHaveLength(0);
|
|
});
|
|
|
|
it('rejects a name colliding with the built-in XLOOKUP function (case-insensitive)', () => {
|
|
const s = fresh();
|
|
const result = s.addCustomFunction('xlookup', ['x'], 'x') as { error: string };
|
|
expect(result.error).toBeTruthy();
|
|
expect(s.doc.customFunctions).toHaveLength(0);
|
|
});
|
|
|
|
it('rejects a body calling GET/POST/JSONGET -- macro-only, in both an expression-kind and a block-kind function', () => {
|
|
const s = fresh();
|
|
for (const name of ['GET', 'POST', 'JSONGET']) {
|
|
const expr = s.addCustomFunction('F1', ['x'], `${name}(x)`, 'expression') as { error: string };
|
|
expect(expr.error).toBeTruthy();
|
|
const block = s.addCustomFunction('F2', ['x'], `r = ${name}(x) RETURN r`, 'block') as { error: string };
|
|
expect(block.error).toBeTruthy();
|
|
}
|
|
expect(s.doc.customFunctions).toHaveLength(0);
|
|
});
|
|
|
|
it('rejects a name colliding with another custom function (case-insensitive)', () => {
|
|
const s = fresh();
|
|
s.addCustomFunction('Marge', ['x'], 'x');
|
|
const result = s.addCustomFunction('MARGE', ['y'], 'y') as { error: string };
|
|
expect(result.error).toBeTruthy();
|
|
expect(s.doc.customFunctions).toHaveLength(1);
|
|
});
|
|
|
|
it('rejects a name or parameter that looks like a cell address', () => {
|
|
const s = fresh();
|
|
expect((s.addCustomFunction('A1', ['x'], 'x') as { error: string }).error).toBeTruthy();
|
|
expect((s.addCustomFunction('OK', ['B2'], 'B2') as { error: string }).error).toBeTruthy();
|
|
expect(s.doc.customFunctions).toHaveLength(0);
|
|
});
|
|
|
|
it('rejects duplicate parameter names', () => {
|
|
const s = fresh();
|
|
const result = s.addCustomFunction('F', ['x', 'x'], 'x') as { error: string };
|
|
expect(result.error).toBeTruthy();
|
|
});
|
|
|
|
it('rejects a body that fails to parse', () => {
|
|
const s = fresh();
|
|
const result = s.addCustomFunction('F', ['x'], 'x +') as { error: string };
|
|
expect(result.error).toBeTruthy();
|
|
});
|
|
|
|
it('rejects a direct self-reference cycle (A calls A)', () => {
|
|
const s = fresh();
|
|
const result = s.addCustomFunction('A', ['x'], 'A(x) + 1') as { error: string };
|
|
expect(result.error).toBeTruthy();
|
|
expect(s.doc.customFunctions).toHaveLength(0);
|
|
});
|
|
|
|
it('rejects a mutual cycle (A calls B, B calls A) at the point it would close', () => {
|
|
const s = fresh();
|
|
const a = s.addCustomFunction('A', ['x'], 'x') as string;
|
|
expect(typeof a).toBe('string');
|
|
// B calling A is fine so far -- no cycle yet
|
|
const b = s.addCustomFunction('B', ['x'], 'A(x)') as string;
|
|
expect(typeof b).toBe('string');
|
|
// now redefine A to call B -- this closes the cycle A -> B -> A
|
|
const result = s.updateCustomFunction(a, 'A', ['x'], 'B(x)') as { error: string } | undefined;
|
|
expect(result?.error).toBeTruthy();
|
|
// rejected -- A's body is unchanged
|
|
expect(s.doc.customFunctions.find((f) => f.id === a)!.body).toBe('x');
|
|
});
|
|
|
|
it('updateCustomFunction changes name/params/body and existing calls re-resolve', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(id)!.name;
|
|
const fnId = s.addCustomFunction('MARGE', ['x', 'y'], 'x - y') as string;
|
|
setCell(s, name, 'A1', '=MARGE(10;3)');
|
|
expect(val(s, name, 'A1')).toBe(7);
|
|
|
|
s.updateCustomFunction(fnId, 'MARGE', ['x', 'y'], 'x + y');
|
|
expect(val(s, name, 'A1')).toBe(13);
|
|
});
|
|
|
|
it('deleteCustomFunction removes the definition; existing calls become #NAME?', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(id)!.name;
|
|
const fnId = s.addCustomFunction('MARGE', ['x', 'y'], 'x - y') as string;
|
|
setCell(s, name, 'A1', '=MARGE(10;3)');
|
|
expect(val(s, name, 'A1')).toBe(7);
|
|
|
|
s.deleteCustomFunction(fnId);
|
|
expect(s.doc.customFunctions).toHaveLength(0);
|
|
expect(isError(val(s, name, 'A1'))).toBe(true);
|
|
});
|
|
|
|
it('undo/redo covers a custom-function change', () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const s = fresh();
|
|
expect(s.canUndo.value).toBe(false);
|
|
s.addCustomFunction('MARGE', ['x', 'y'], 'x - y');
|
|
vi.advanceTimersByTime(600);
|
|
expect(s.doc.customFunctions).toHaveLength(1);
|
|
|
|
s.undo();
|
|
expect(s.doc.customFunctions).toHaveLength(0);
|
|
s.redo();
|
|
expect(s.doc.customFunctions).toHaveLength(1);
|
|
expect(s.doc.customFunctions[0].name).toBe('MARGE');
|
|
} finally {
|
|
vi.runOnlyPendingTimers();
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
});
|
|
|
|
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('insertRow/deleteRow/insertColumn/deleteColumn (mid-grid, not just edge growth)', () => {
|
|
const raw = (s: DocumentStore, table: string, a1: string) => {
|
|
const { col, row } = coords(a1);
|
|
return s.cellInput(tableId(s, table), col, row);
|
|
};
|
|
|
|
it('insertRow shifts cell data down from the insertion point, leaves earlier rows alone', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(id)!.name;
|
|
setCell(s, name, 'A1', 'x');
|
|
setCell(s, name, 'A2', 'y');
|
|
s.insertRow(id, 1);
|
|
expect(val(s, name, 'A1')).toBe('x'); // row 0, before the insertion point -- untouched
|
|
expect(val(s, name, 'A2')).toBeNull(); // new empty row
|
|
expect(val(s, name, 'A3')).toBe('y'); // shifted down
|
|
expect(s.tableById(id)!.rows).toBe(13); // default 12 + 1
|
|
});
|
|
|
|
it('deleteRow removes that row and shifts the rest up', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(id)!.name;
|
|
setCell(s, name, 'A1', 'x');
|
|
setCell(s, name, 'A2', 'y');
|
|
setCell(s, name, 'A3', 'z');
|
|
s.deleteRow(id, 1);
|
|
expect(val(s, name, 'A1')).toBe('x');
|
|
expect(val(s, name, 'A2')).toBe('z'); // 'y' is gone, 'z' shifted up
|
|
expect(s.tableById(id)!.rows).toBe(11);
|
|
});
|
|
|
|
it('deleteRow refuses to remove the last remaining row', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const initialRows = s.tableById(id)!.rows;
|
|
for (let i = 0; i < initialRows - 1; i++) s.deleteRow(id, 0);
|
|
expect(s.tableById(id)!.rows).toBe(1);
|
|
s.deleteRow(id, 0);
|
|
expect(s.tableById(id)!.rows).toBe(1); // no-op, refused
|
|
});
|
|
|
|
it('insertColumn/deleteColumn mirror the row behaviour, incl. colWidths', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(id)!.name;
|
|
setCell(s, name, 'A1', 'x');
|
|
setCell(s, name, 'B1', 'y');
|
|
s.setColWidth(id, 1, 111); // column B
|
|
s.insertColumn(id, 1);
|
|
expect(val(s, name, 'A1')).toBe('x');
|
|
expect(val(s, name, 'B1')).toBeNull();
|
|
expect(val(s, name, 'C1')).toBe('y'); // shifted right, width override moved with it
|
|
expect(s.tableById(id)!.colWidths[2]).toBe(111);
|
|
|
|
s.deleteColumn(id, 1); // remove the now-empty B
|
|
expect(val(s, name, 'A1')).toBe('x');
|
|
expect(val(s, name, 'B1')).toBe('y'); // shifted back left
|
|
expect(s.tableById(id)!.colWidths[1]).toBe(111);
|
|
});
|
|
|
|
it('a self-referencing formula (same table) is rewritten to keep pointing at the same logical cell', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(id)!.name;
|
|
setCell(s, name, 'B5', '20');
|
|
setCell(s, name, 'C1', '=B5');
|
|
expect(val(s, name, 'C1')).toBe(20);
|
|
|
|
s.insertRow(id, 2); // B5 (row 4) shifts to B6; C1 (row 0) stays put
|
|
expect(raw(s, name, 'C1')).toBe('=B6');
|
|
expect(val(s, name, 'C1')).toBe(20);
|
|
});
|
|
|
|
it('a cross-table qualified reference is rewritten too, not just formulas inside the edited table', () => {
|
|
const s = fresh();
|
|
const src = s.addTable(s.doc.activeSheetId);
|
|
s.renameObject(src, 'Quelle');
|
|
const dst = s.addTable(s.doc.activeSheetId);
|
|
s.renameObject(dst, 'Ziel');
|
|
setCell(s, 'Quelle', 'B5', '42');
|
|
setCell(s, 'Ziel', 'A1', '=Quelle!B5');
|
|
expect(val(s, 'Ziel', 'A1')).toBe(42);
|
|
|
|
s.insertRow(src, 2);
|
|
expect(raw(s, 'Ziel', 'A1')).toBe('=Quelle!B6');
|
|
expect(val(s, 'Ziel', 'A1')).toBe(42);
|
|
});
|
|
|
|
it('a range that straddles the insertion point grows by one row instead of just sliding', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(id)!.name;
|
|
setCell(s, name, 'B1', '1');
|
|
setCell(s, name, 'B2', '2');
|
|
setCell(s, name, 'B3', '3');
|
|
setCell(s, name, 'D1', '=SUM(B1:B3)');
|
|
expect(val(s, name, 'D1')).toBe(6);
|
|
|
|
s.insertRow(id, 1); // inside the range (row 1, between B1 and what was B2)
|
|
expect(raw(s, name, 'D1')).toBe('=SUM(B1:B4)'); // grew by one
|
|
setCell(s, name, 'B2', '99'); // the newly inserted row is now part of the range
|
|
expect(val(s, name, 'D1')).toBe(1 + 99 + 2 + 3);
|
|
});
|
|
|
|
it('a range fully after the deletion point shifts down, keeps its size', () => {
|
|
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');
|
|
setCell(s, name, 'D5', '=SUM(B2:B3)'); // away from row 0, so it survives the delete itself
|
|
s.deleteRow(id, 0); // delete row 0, entirely before the range -- range shifts, doesn't shrink in size
|
|
expect(raw(s, name, 'D4')).toBe('=SUM(B1:B2)');
|
|
expect(val(s, name, 'D4')).toBe(30);
|
|
});
|
|
|
|
it('deleting a row *inside* a multi-row range shrinks it by one, keeps the rest', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(id)!.name;
|
|
setCell(s, name, 'B1', '1');
|
|
setCell(s, name, 'B2', '2');
|
|
setCell(s, name, 'B3', '3');
|
|
setCell(s, name, 'D1', '=SUM(B1:B3)');
|
|
s.deleteRow(id, 1); // remove the middle row of the range
|
|
expect(raw(s, name, 'D1')).toBe('=SUM(B1:B2)');
|
|
expect(val(s, name, 'D1')).toBe(1 + 3); // only 1 and (shifted) 3 remain
|
|
});
|
|
|
|
it('a single-cell reference exactly on the deleted row becomes a literal #REF', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(id)!.name;
|
|
setCell(s, name, 'B2', '10');
|
|
setCell(s, name, 'C1', '=B2+1');
|
|
s.deleteRow(id, 1); // row 1 is exactly B2's row
|
|
expect(raw(s, name, 'C1')).toBe('=#REF+1');
|
|
expect(isError(val(s, name, 'C1'))).toBe(true);
|
|
});
|
|
|
|
it('a range entirely on the deleted row collapses to a literal #REF', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(id)!.name;
|
|
setCell(s, name, 'B2', '1');
|
|
setCell(s, name, 'D2', '2');
|
|
setCell(s, name, 'D1', '=SUM(B2:D2)'); // a 1-row range, entirely row 1
|
|
s.deleteRow(id, 1);
|
|
expect(raw(s, name, 'D1')).toBe('=SUM(#REF)');
|
|
expect(isError(val(s, name, 'D1'))).toBe(true);
|
|
});
|
|
|
|
it('a $-locked reference still shifts -- unlike copy/paste, $ has no effect on a structural insert/delete', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(id)!.name;
|
|
setCell(s, name, 'B5', '7');
|
|
setCell(s, name, 'C1', '=$B$5');
|
|
s.insertRow(id, 1);
|
|
expect(raw(s, name, 'C1')).toBe('=$B$6');
|
|
expect(val(s, name, 'C1')).toBe(7);
|
|
});
|
|
|
|
it('a reference to a row before the insertion/deletion point is left untouched, byte-identical', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(id)!.name;
|
|
setCell(s, name, 'A1', '5');
|
|
// lower-case + extra spacing in the formula text -- proves an untouched
|
|
// reference (row 0, nowhere near the insertion point at row 5) is left
|
|
// completely alone, not just numerically equivalent
|
|
setCell(s, name, 'C1', '=a1 + 1');
|
|
s.insertRow(id, 5);
|
|
expect(raw(s, name, 'C1')).toBe('=a1 + 1');
|
|
s.deleteRow(id, 5);
|
|
expect(raw(s, name, 'C1')).toBe('=a1 + 1');
|
|
});
|
|
|
|
it('the sheet-qualified 3-part form ("Blatt!Tabelle::Zelle") is resolved and rewritten too', () => {
|
|
const s = fresh();
|
|
// fresh sheets, names that don't collide with the seed's own "Analyse"/
|
|
// "Umsatz" (the whole point of the 3-part form is disambiguating a name
|
|
// used on more than one sheet, but that's not what this test is about)
|
|
const sheetA = s.addSheet();
|
|
s.renameSheet(sheetA, 'ErsterBogen');
|
|
const src = s.addTable(sheetA);
|
|
s.renameObject(src, 'Quelle3');
|
|
const sheetB = s.addSheet();
|
|
s.renameSheet(sheetB, 'ZweiterBogen');
|
|
const dst = s.addTable(sheetB);
|
|
s.renameObject(dst, 'Ziel3');
|
|
|
|
setCell(s, 'Quelle3', 'B5', '42');
|
|
setCell(s, 'Ziel3', 'A1', '=ErsterBogen!Quelle3::B5');
|
|
expect(val(s, 'Ziel3', 'A1')).toBe(42);
|
|
|
|
s.insertRow(src, 2);
|
|
expect(raw(s, 'Ziel3', 'A1')).toBe('=ErsterBogen!Quelle3::B6');
|
|
expect(val(s, 'Ziel3', 'A1')).toBe(42);
|
|
});
|
|
|
|
it('undo/redo covers a row insert/delete, incl. the document-wide formula rewrite', () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(id)!.name;
|
|
setCell(s, name, 'B5', '10');
|
|
setCell(s, name, 'C1', '=B5');
|
|
vi.advanceTimersByTime(600);
|
|
|
|
s.insertRow(id, 2);
|
|
vi.advanceTimersByTime(600);
|
|
expect(raw(s, name, 'C1')).toBe('=B6');
|
|
|
|
s.undo();
|
|
expect(raw(s, name, 'C1')).toBe('=B5');
|
|
expect(s.tableById(id)!.rows).toBe(12);
|
|
|
|
s.redo();
|
|
expect(raw(s, name, 'C1')).toBe('=B6');
|
|
expect(s.tableById(id)!.rows).toBe(13);
|
|
} finally {
|
|
vi.runOnlyPendingTimers();
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('renaming a table rewrites every other formula\'s qualifier (document-wide, not just names re-resolving)', () => {
|
|
const rawInput = (s: DocumentStore, table: string, a1: string) => {
|
|
const { col, row } = coords(a1);
|
|
return s.cellInput(tableId(s, table), col, row);
|
|
};
|
|
|
|
it('a plain "Alt!B2" reference in ANOTHER table becomes "Neu!B2" and keeps evaluating correctly', () => {
|
|
const s = fresh();
|
|
const src = s.addTable(s.doc.activeSheetId);
|
|
const srcName = s.tableById(src)!.name;
|
|
const dst = s.addTable(s.doc.activeSheetId);
|
|
const dstName = s.tableById(dst)!.name;
|
|
setCell(s, srcName, 'B2', '10');
|
|
setCell(s, dstName, 'A1', `=${srcName}!B2`);
|
|
expect(val(s, dstName, 'A1')).toBe(10);
|
|
|
|
s.renameObject(src, 'Neu');
|
|
expect(rawInput(s, dstName, 'A1')).toBe('=Neu!B2');
|
|
expect(val(s, dstName, 'A1')).toBe(10); // still resolves -- no #REF
|
|
});
|
|
|
|
it('the "::" qualifier form is rewritten too', () => {
|
|
const s = fresh();
|
|
const src = s.addTable(s.doc.activeSheetId);
|
|
const srcName = s.tableById(src)!.name;
|
|
const dst = s.addTable(s.doc.activeSheetId);
|
|
const dstName = s.tableById(dst)!.name;
|
|
setCell(s, dstName, 'A1', `=${srcName}::B2+1`);
|
|
|
|
s.renameObject(src, 'Neu');
|
|
expect(rawInput(s, dstName, 'A1')).toBe('=Neu::B2+1');
|
|
});
|
|
|
|
it('a range reference keeps its range, only the qualifier changes', () => {
|
|
const s = fresh();
|
|
const src = s.addTable(s.doc.activeSheetId);
|
|
const srcName = s.tableById(src)!.name;
|
|
const dst = s.addTable(s.doc.activeSheetId);
|
|
const dstName = s.tableById(dst)!.name;
|
|
setCell(s, dstName, 'A1', `=SUM(${srcName}!B2:B6)`);
|
|
|
|
s.renameObject(src, 'Neu');
|
|
expect(rawInput(s, dstName, 'A1')).toBe('=SUM(Neu!B2:B6)');
|
|
});
|
|
|
|
it('only the TABLE part of a 3-part "Blatt!Tabelle::Zelle" reference is rewritten, the sheet stays', () => {
|
|
const s = fresh();
|
|
const sheet2 = s.addSheet('Zweites');
|
|
const src = s.addTable(sheet2);
|
|
const srcName = s.tableById(src)!.name;
|
|
s.setActiveSheet(s.doc.sheets[0].id);
|
|
const dst = s.addTable(s.doc.sheets[0].id);
|
|
const dstName = s.tableById(dst)!.name;
|
|
const sheet2Name = s.sheetById(sheet2)!.name;
|
|
setCell(s, dstName, 'A1', `=${sheet2Name}!${srcName}::B2`);
|
|
|
|
s.renameObject(src, 'Neu');
|
|
expect(rawInput(s, dstName, 'A1')).toBe(`=${sheet2Name}!Neu::B2`);
|
|
});
|
|
|
|
it('renaming to a name that needs quoting produces a properly quoted qualifier', () => {
|
|
const s = fresh();
|
|
const src = s.addTable(s.doc.activeSheetId);
|
|
const srcName = s.tableById(src)!.name;
|
|
const dst = s.addTable(s.doc.activeSheetId);
|
|
const dstName = s.tableById(dst)!.name;
|
|
setCell(s, dstName, 'A1', `=${srcName}!B2`);
|
|
|
|
s.renameObject(src, 'Neuer Name');
|
|
expect(rawInput(s, dstName, 'A1')).toBe("='Neuer Name'!B2");
|
|
});
|
|
|
|
it('a formula referencing a DIFFERENT, un-renamed table stays byte-identical', () => {
|
|
const s = fresh();
|
|
const src = s.addTable(s.doc.activeSheetId);
|
|
const other = s.addTable(s.doc.activeSheetId);
|
|
const otherName = s.tableById(other)!.name;
|
|
const dst = s.addTable(s.doc.activeSheetId);
|
|
const dstName = s.tableById(dst)!.name;
|
|
const untouched = `=${otherName}!B2 + 1`;
|
|
setCell(s, dstName, 'A1', untouched);
|
|
|
|
s.renameObject(src, 'Neu');
|
|
expect(rawInput(s, dstName, 'A1')).toBe(untouched);
|
|
});
|
|
|
|
it('a redundant self-qualified reference inside the renamed table itself is rewritten too', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(id)!.name;
|
|
setCell(s, name, 'B2', '10');
|
|
setCell(s, name, 'A1', `=${name}!B2`); // redundant explicit self-qualifier
|
|
|
|
s.renameObject(id, 'Neu');
|
|
expect(rawInput(s, 'Neu', 'A1')).toBe('=Neu!B2');
|
|
expect(val(s, 'Neu', 'A1')).toBe(10);
|
|
});
|
|
|
|
it('the rewrite and the rename coalesce into a single undo step', () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const s = fresh();
|
|
const src = s.addTable(s.doc.activeSheetId);
|
|
const srcName = s.tableById(src)!.name;
|
|
const dst = s.addTable(s.doc.activeSheetId);
|
|
const dstName = s.tableById(dst)!.name;
|
|
setCell(s, dstName, 'A1', `=${srcName}!B2`);
|
|
vi.advanceTimersByTime(600); // let the setup settle first
|
|
|
|
s.renameObject(src, 'Neu');
|
|
vi.advanceTimersByTime(600);
|
|
expect(rawInput(s, dstName, 'A1')).toBe('=Neu!B2');
|
|
|
|
s.undo(); // one Ctrl+Z undoes BOTH the rename and the ref rewrite together
|
|
expect(s.tableById(src)!.name).toBe(srcName);
|
|
expect(rawInput(s, dstName, 'A1')).toBe(`=${srcName}!B2`);
|
|
} finally {
|
|
vi.runOnlyPendingTimers();
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('dependency-graph view (cellGraphSelf/cellGraphNeighbors)', () => {
|
|
it('cellGraphSelf describes a formula cell: label, raw text, value, table/sheet name', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(id)!.name;
|
|
setCell(s, name, 'B5', '10');
|
|
setCell(s, name, 'C1', '=B5+1');
|
|
const { col, row } = coords('C1');
|
|
const node = s.cellGraphSelf(id, col, row)!;
|
|
expect(node).toMatchObject({
|
|
tableId: id,
|
|
tableName: name,
|
|
col,
|
|
row,
|
|
label: 'C1',
|
|
raw: '=B5+1',
|
|
value: 11,
|
|
isCircular: false,
|
|
});
|
|
});
|
|
|
|
it('cellGraphNeighbors.precedents lists exactly the cells a formula reads', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(id)!.name;
|
|
setCell(s, name, 'B2', '1');
|
|
setCell(s, name, 'B3', '2');
|
|
setCell(s, name, 'D1', '=SUM(B2:B3)');
|
|
const { col, row } = coords('D1');
|
|
const { precedents } = s.cellGraphNeighbors(id, col, row);
|
|
expect(precedents.map((n) => n.label).sort()).toEqual(['B2', 'B3']);
|
|
expect(precedents.every((n) => n.tableId === id)).toBe(true);
|
|
});
|
|
|
|
it('cellGraphNeighbors.dependents is the reverse: who reads this cell', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(id)!.name;
|
|
setCell(s, name, 'A1', '5');
|
|
setCell(s, name, 'B1', '=A1*2');
|
|
setCell(s, name, 'C1', '=A1+1');
|
|
const { col, row } = coords('A1');
|
|
const { dependents } = s.cellGraphNeighbors(id, col, row);
|
|
expect(dependents.map((n) => n.label).sort()).toEqual(['B1', 'C1']);
|
|
});
|
|
|
|
it('follows a cross-table reference, labelling with the other table/sheet name', () => {
|
|
const s = fresh();
|
|
const src = s.addTable(s.doc.activeSheetId);
|
|
s.renameObject(src, 'Quelle4');
|
|
const dst = s.addTable(s.doc.activeSheetId);
|
|
s.renameObject(dst, 'Ziel4');
|
|
setCell(s, 'Quelle4', 'B2', '42');
|
|
setCell(s, 'Ziel4', 'A1', '=Quelle4!B2');
|
|
const { col, row } = coords('A1');
|
|
const { precedents } = s.cellGraphNeighbors(dst, col, row);
|
|
expect(precedents).toHaveLength(1);
|
|
expect(precedents[0]).toMatchObject({ tableId: src, tableName: 'Quelle4', label: 'B2', value: 42 });
|
|
});
|
|
|
|
it('flags a cell on a circular reference via isCircular', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(id)!.name;
|
|
setCell(s, name, 'A1', '=B1');
|
|
setCell(s, name, 'B1', '=A1');
|
|
const { col, row } = coords('A1');
|
|
expect(s.cellGraphSelf(id, col, row)!.isCircular).toBe(true);
|
|
// the edge itself is still reported even though the cell is circular
|
|
const { precedents } = s.cellGraphNeighbors(id, col, row);
|
|
expect(precedents.map((n) => n.label)).toEqual(['B1']);
|
|
});
|
|
|
|
it('a literal (non-formula) cell has no precedents; an unreferenced cell has no dependents', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(id)!.name;
|
|
setCell(s, name, 'A1', '5');
|
|
const { col, row } = coords('A1');
|
|
const { precedents, dependents } = s.cellGraphNeighbors(id, col, row);
|
|
expect(precedents).toEqual([]);
|
|
expect(dependents).toEqual([]);
|
|
});
|
|
|
|
it('reflects a re-edit (e.g. after changing which cell a formula reads)', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(id)!.name;
|
|
setCell(s, name, 'B2', '1');
|
|
setCell(s, name, 'B3', '2');
|
|
setCell(s, name, 'C1', '=B2');
|
|
const { col, row } = coords('C1');
|
|
expect(s.cellGraphNeighbors(id, col, row).precedents.map((n) => n.label)).toEqual(['B2']);
|
|
setCell(s, name, 'C1', '=B3');
|
|
expect(s.cellGraphNeighbors(id, col, row).precedents.map((n) => n.label)).toEqual(['B3']);
|
|
});
|
|
});
|
|
|
|
describe('openDependencyGraph/closeDependencyGraph (the drawer, not just the data)', () => {
|
|
it('opens the drawer centered on the given cell', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const { col, row } = coords('B2');
|
|
expect(s.ui.depGraph).toBeNull();
|
|
s.openDependencyGraph(id, col, row);
|
|
expect(s.ui.depGraph).toEqual({ tableId: id, col, row });
|
|
});
|
|
|
|
it('also really selects the cell -- the rest of the app (selection) reflects where you navigated', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const { col, row } = coords('C3');
|
|
s.openDependencyGraph(id, col, row);
|
|
expect(s.doc.selection).toMatchObject({ tableId: id, anchor: { col, row }, focus: { col, row } });
|
|
expect(s.doc.activeObjectId).toBe(id);
|
|
});
|
|
|
|
it('switches the active sheet tab when navigating to a cell on a different sheet', () => {
|
|
const s = fresh();
|
|
const otherSheet = s.addSheet();
|
|
const id = s.addTable(otherSheet);
|
|
expect(s.doc.activeSheetId).toBe(otherSheet); // addTable already switched here
|
|
s.setActiveSheet(s.doc.sheets[0].id); // move away
|
|
expect(s.doc.activeSheetId).not.toBe(otherSheet);
|
|
|
|
s.openDependencyGraph(id, 0, 0);
|
|
expect(s.doc.activeSheetId).toBe(otherSheet); // navigated back automatically
|
|
});
|
|
|
|
it('closeDependencyGraph clears it without touching the current selection', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
s.openDependencyGraph(id, 0, 0);
|
|
s.closeDependencyGraph();
|
|
expect(s.ui.depGraph).toBeNull();
|
|
expect(s.doc.selection).toMatchObject({ tableId: id, focus: { col: 0, row: 0 } });
|
|
});
|
|
});
|
|
|
|
describe('functionSuggestions (formula autocomplete)', () => {
|
|
it('filters built-ins by prefix, case-insensitively', () => {
|
|
const s = fresh();
|
|
const names = s.functionSuggestions('su').map((x) => x.name);
|
|
expect(names).toContain('SUM');
|
|
expect(names).not.toContain('AVG');
|
|
});
|
|
|
|
it('an empty prefix suggests nothing', () => {
|
|
const s = fresh();
|
|
expect(s.functionSuggestions('')).toEqual([]);
|
|
});
|
|
|
|
it('mixes in custom functions, sorted alphabetically with built-ins', () => {
|
|
const s = fresh();
|
|
s.addCustomFunction('SUperMarge', ['x'], 'x');
|
|
const names = s.functionSuggestions('SU').map((x) => x.name);
|
|
expect(names).toEqual(['SUM', 'SUperMarge']); // SUM < SUperMarge
|
|
const custom = s.functionSuggestions('SU').find((x) => x.name === 'SUperMarge');
|
|
expect(custom).toEqual({ name: 'SUperMarge', hint: 'SUperMarge(x)', kind: 'custom' });
|
|
});
|
|
|
|
it('a built-in hint always starts with its own name', () => {
|
|
const s = fresh();
|
|
for (const x of s.functionSuggestions('X')) {
|
|
expect(x.hint.startsWith(x.name)).toBe(true);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('macros (document-level, explicitly-triggered write scripts)', () => {
|
|
it('addMacro stores the definition; a bare (unqualified) cell reference is rejected at save time', () => {
|
|
const s = fresh();
|
|
const bad = s.addMacro('M', 'SETVALUE(A1; 1)') as { error: string };
|
|
expect(bad.error).toBeTruthy();
|
|
expect(s.doc.macros).toHaveLength(0);
|
|
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(id)!.name;
|
|
const good = s.addMacro('M', `SETVALUE(${name}::A1; 1)`);
|
|
expect(typeof good).toBe('string');
|
|
expect(s.doc.macros).toHaveLength(1);
|
|
});
|
|
|
|
it('rejects a name colliding with another macro (case-insensitive)', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(id)!.name;
|
|
s.addMacro('Setup', `SETVALUE(${name}::A1; 1)`);
|
|
const result = s.addMacro('SETUP', `SETVALUE(${name}::A1; 2)`) as { error: string };
|
|
expect(result.error).toBeTruthy();
|
|
expect(s.doc.macros).toHaveLength(1);
|
|
});
|
|
|
|
it('rejects one of the 11 write-action names used as a local variable', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(id)!.name;
|
|
const result = s.addMacro('M', `SETVALUE = 1 SETVALUE(${name}::A1; SETVALUE)`) as { error: string };
|
|
expect(result.error).toBeTruthy();
|
|
});
|
|
|
|
it('rejects a FOR source or SETVALUE target that is a computed expression, not a real reference', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(id)!.name;
|
|
expect((s.addMacro('M1', `SETVALUE(1+1; 1)`) as { error: string }).error).toBeTruthy();
|
|
expect(
|
|
(s.addMacro('M2', `FOR c IN 1+1 DO SETVALUE(${name}::A1; 0) END`) as { error: string }).error,
|
|
).toBeTruthy();
|
|
});
|
|
|
|
it('accepts a JSONGET(...) call used directly as a FOR source (not first bound to a variable) -- found live: this needed its own fix in findInvalidForSource, distinct from the ordinary cell/range/param shape check', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(id)!.name;
|
|
const result = s.addMacro(
|
|
'M',
|
|
`r = GET("https://x") FOR item IN JSONGET(r; "items") DO SETVALUE(${name}::A1; JSONGET(item; "name")) END`,
|
|
);
|
|
expect(typeof result).toBe('string');
|
|
});
|
|
|
|
it('runMacro: SETVALUE writes a cell, reported as a success with a default message', async () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(id)!.name;
|
|
const macroId = s.addMacro('Setzen', `SETVALUE(${name}::A1; 42)`) as string;
|
|
|
|
const result = await s.runMacro(macroId);
|
|
expect(result).toEqual({ ok: true, message: 'Makro ausgeführt.' });
|
|
expect(s.cellInput(id, 0, 0)).toBe('42');
|
|
});
|
|
|
|
it('runMacro: a RETURNed string becomes the success message instead of the default', async () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(id)!.name;
|
|
const macroId = s.addMacro('M', `SETVALUE(${name}::A1; 1) RETURN "fertig"`) as string;
|
|
await expect(s.runMacro(macroId)).resolves.toEqual({ ok: true, message: 'fertig' });
|
|
});
|
|
|
|
it('runMacro: SETVALUE can write a live formula (a value starting with "=")', async () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(id)!.name;
|
|
setCell(s, name, 'A1', '3');
|
|
setCell(s, name, 'A2', '4');
|
|
const macroId = s.addMacro('M', `SETVALUE(${name}::A3; "=" & ${name}::A1 & "+" & ${name}::A2)`) as string;
|
|
expect((await s.runMacro(macroId)).ok).toBe(true);
|
|
expect(s.cellInput(id, 0, 2)).toBe('=3+4');
|
|
expect(val(s, name, 'A3')).toBe(7);
|
|
});
|
|
|
|
it('runMacro: INSERTROW performs the same document-wide reference rewrite as the store primitive', async () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(id)!.name;
|
|
setCell(s, name, 'A1', '10');
|
|
setCell(s, name, 'A2', '20');
|
|
setCell(s, name, 'B1', `=${name}::A1+${name}::A2`);
|
|
const macroId = s.addMacro('Zeile', `INSERTROW("${name}"; 0)`) as string;
|
|
|
|
expect((await s.runMacro(macroId)).ok).toBe(true);
|
|
expect(s.tableById(id)!.rows).toBe(13); // seed default 12 + 1
|
|
expect(s.cellInput(id, 0, 1)).toBe('10'); // A1's content slid down to A2
|
|
expect(s.cellInput(id, 1, 1)).toBe(`=${name}::A2+${name}::A3`); // the SUM formula's own refs shifted too
|
|
});
|
|
|
|
it('runMacro: a structural action mid-run does not leave later reads/writes stale (the thin-host-proxy fix)', async () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(id)!.name;
|
|
setCell(s, name, 'A1', '5');
|
|
// SETVALUE writes B1, then INSERTROW at 0 shifts everything down one row
|
|
// (B1's write becomes B2), then a second SETVALUE writes C1 based on a
|
|
// READ of A1 (now at A2, post-insert) -- if the interpreter's host were
|
|
// a stale cached Workbook reference from before the INSERTROW, this
|
|
// read would silently return an empty/wrong value instead of 5.
|
|
const macroId = s.addMacro(
|
|
'M',
|
|
`SETVALUE(${name}::B1; 1) INSERTROW("${name}"; 0) SETVALUE(${name}::C1; ${name}::A2 + 100)`,
|
|
) as string;
|
|
|
|
await expect(s.runMacro(macroId)).resolves.toEqual({ ok: true, message: 'Makro ausgeführt.' });
|
|
expect(s.cellInput(id, 1, 1)).toBe('1'); // B1's write, shifted to B2 by the insert
|
|
expect(val(s, name, 'C1')).toBe(105); // reads A2 (=5, shifted from A1) fresh, post-insert
|
|
});
|
|
|
|
it('runMacro: ADDTABLE/RENAMETABLE/DELETETABLE round-trip', async () => {
|
|
const s = fresh();
|
|
const sheetId = s.doc.activeSheetId;
|
|
const sheetName = s.sheetById(sheetId)!.name;
|
|
const macroId = s.addMacro(
|
|
'M',
|
|
`ADDTABLE("${sheetName}"; "MakroTabelle") RENAMETABLE("MakroTabelle"; "Umbenannt") DELETETABLE("Umbenannt")`,
|
|
) as string;
|
|
const before = s.tablesOf(sheetId).length;
|
|
|
|
expect((await s.runMacro(macroId)).ok).toBe(true);
|
|
expect(s.tablesOf(sheetId).length).toBe(before); // added then deleted -- net zero
|
|
});
|
|
|
|
it('runMacro: ADDTABLE on a DIFFERENT sheet does not yank the active sheet/selection there (found live: addTable() itself navigates, a macro must undo just that incidental side effect)', async () => {
|
|
const s = fresh();
|
|
const startSheetId = s.doc.activeSheetId;
|
|
const startSelection = s.doc.selection;
|
|
const otherSheetId = s.addSheet('Ziel');
|
|
s.setActiveSheet(startSheetId); // addSheet() itself also navigates -- go back
|
|
expect(s.doc.activeSheetId).toBe(startSheetId);
|
|
|
|
const macroId = s.addMacro('M', 'ADDTABLE("Ziel"; "Neu")') as string;
|
|
expect((await s.runMacro(macroId)).ok).toBe(true);
|
|
|
|
expect(s.doc.activeSheetId).toBe(startSheetId); // NOT switched to "Ziel"
|
|
expect(s.doc.selection).toEqual(startSelection);
|
|
expect(s.tablesOf(otherSheetId).map((t) => t.name)).toContain('Neu'); // the mutation itself still happened
|
|
});
|
|
|
|
it('runMacro: ADDSHEET/RENAMESHEET/DELETESHEET round-trip', async () => {
|
|
const s = fresh();
|
|
const before = s.doc.sheets.length;
|
|
const macroId = s.addMacro(
|
|
'M',
|
|
'ADDSHEET("MakroBlatt") RENAMESHEET("MakroBlatt"; "Umbenannt") DELETESHEET("Umbenannt")',
|
|
) as string;
|
|
|
|
expect((await s.runMacro(macroId)).ok).toBe(true);
|
|
expect(s.doc.sheets.length).toBe(before);
|
|
});
|
|
|
|
it('runMacro: DELETESHEET refuses the last sheet, surfaced as a real failure not a silent no-op', async () => {
|
|
const s = fresh();
|
|
// collapse to exactly one sheet first
|
|
while (s.doc.sheets.length > 1) s.deleteSheet(s.doc.sheets[s.doc.sheets.length - 1].id);
|
|
const onlySheetName = s.doc.sheets[0].name;
|
|
const macroId = s.addMacro('M', `DELETESHEET("${onlySheetName}")`) as string;
|
|
|
|
const result = await s.runMacro(macroId);
|
|
expect(result.ok).toBe(false);
|
|
expect(s.doc.sheets).toHaveLength(1);
|
|
});
|
|
|
|
it('runMacro: an unknown table reference fails cleanly, no throw escapes', async () => {
|
|
const s = fresh();
|
|
const macroId = s.addMacro('M', 'INSERTROW("Nirgendwo"; 0)') as string;
|
|
const result = await s.runMacro(macroId);
|
|
expect(result.ok).toBe(false);
|
|
expect(result.message).toMatch(/unbekannte Tabelle/);
|
|
});
|
|
|
|
it('runMacro: an unknown macro id fails cleanly', async () => {
|
|
const s = fresh();
|
|
expect((await s.runMacro('does-not-exist')).ok).toBe(false);
|
|
});
|
|
|
|
it('updateMacro changes the body; deleteMacro removes it', async () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(id)!.name;
|
|
const macroId = s.addMacro('M', `SETVALUE(${name}::A1; 1)`) as string;
|
|
|
|
s.updateMacro(macroId, 'M', `SETVALUE(${name}::A1; 2)`);
|
|
await s.runMacro(macroId);
|
|
expect(s.cellInput(id, 0, 0)).toBe('2');
|
|
|
|
s.deleteMacro(macroId);
|
|
expect(s.doc.macros).toHaveLength(0);
|
|
});
|
|
|
|
it('a whole synchronous macro run (SETVALUE + a structural action) coalesces into exactly one undo step', async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(id)!.name;
|
|
const macroId = s.addMacro('M', `SETVALUE(${name}::A1; 1) INSERTROW("${name}"; 0)`) as string;
|
|
vi.advanceTimersByTime(600); // the addTable + addMacro setup settles first
|
|
|
|
await s.runMacro(macroId);
|
|
vi.advanceTimersByTime(600);
|
|
expect(s.cellInput(id, 0, 1)).toBe('1'); // shifted down by the insert
|
|
expect(s.tableById(id)!.rows).toBe(13);
|
|
|
|
s.undo(); // one Ctrl+Z undoes the ENTIRE macro run
|
|
expect(s.cellInput(id, 0, 0)).toBe('');
|
|
expect(s.cellInput(id, 0, 1)).toBe('');
|
|
expect(s.tableById(id)!.rows).toBe(12);
|
|
} finally {
|
|
vi.runOnlyPendingTimers();
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
describe('GET/POST: confirmation gate + persistence, and the undo-invariant fix', () => {
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it('confirms the first GET to a new host, persists it, and does not re-prompt for a second run to the same host', async () => {
|
|
const confirmMock = vi.fn(() => true);
|
|
vi.stubGlobal('confirm', confirmMock);
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn(async () => ({ ok: true, json: async () => ({ v: 1 }) })),
|
|
);
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(id)!.name;
|
|
const macroId = s.addMacro('M', `r = GET("https://api.example.com/x") SETVALUE(${name}::A1; JSONGET(r; "v"))`) as string;
|
|
|
|
expect((await s.runMacro(macroId)).ok).toBe(true);
|
|
expect(confirmMock).toHaveBeenCalledTimes(1);
|
|
expect(s.doc.macros.find((m) => m.id === macroId)!.approvedHosts).toEqual(['api.example.com']);
|
|
|
|
expect((await s.runMacro(macroId)).ok).toBe(true);
|
|
expect(confirmMock).toHaveBeenCalledTimes(1); // NOT called again -- already approved
|
|
});
|
|
|
|
it('a different host on the same macro prompts again', async () => {
|
|
vi.stubGlobal('confirm', vi.fn(() => true));
|
|
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => 1 })));
|
|
const s = fresh();
|
|
const macroId = s.addMacro('M', 'a = GET("https://one.example.com") b = GET("https://two.example.com")') as string;
|
|
|
|
expect((await s.runMacro(macroId)).ok).toBe(true);
|
|
expect(s.doc.macros.find((m) => m.id === macroId)!.approvedHosts).toEqual([
|
|
'one.example.com',
|
|
'two.example.com',
|
|
]);
|
|
});
|
|
|
|
it('declining the confirmation aborts the macro and does NOT persist the host', async () => {
|
|
const confirmMock = vi.fn(() => false);
|
|
vi.stubGlobal('confirm', confirmMock);
|
|
vi.stubGlobal('fetch', vi.fn());
|
|
const s = fresh();
|
|
const macroId = s.addMacro('M', 'r = GET("https://api.example.com/x")') as string;
|
|
|
|
const result = await s.runMacro(macroId);
|
|
expect(result.ok).toBe(false);
|
|
expect(result.message).toMatch(/abgelehnt/);
|
|
expect(s.doc.macros.find((m) => m.id === macroId)!.approvedHosts ?? []).toEqual([]);
|
|
});
|
|
|
|
it('a non-2xx response aborts the macro cleanly', async () => {
|
|
vi.stubGlobal('confirm', vi.fn(() => true));
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn(async () => ({ ok: false, status: 500, statusText: 'Internal Server Error' })),
|
|
);
|
|
const s = fresh();
|
|
const macroId = s.addMacro('M', 'r = GET("https://api.example.com/x")') as string;
|
|
|
|
const result = await s.runMacro(macroId);
|
|
expect(result.ok).toBe(false);
|
|
expect(result.message).toMatch(/500/);
|
|
});
|
|
|
|
it('the mandatory test: SETVALUE -> a slow GET -> SETVALUE still produces exactly one undo step, proving the macroRunActive fix', async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
vi.stubGlobal('confirm', vi.fn(() => true));
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn(
|
|
() =>
|
|
new Promise((resolve) =>
|
|
// longer than HISTORY_COALESCE_MS (150ms) -- without the
|
|
// macroRunActive fix, the debounced auto-commit would fire
|
|
// *during* this wait and split the run into two undo steps
|
|
setTimeout(() => resolve({ ok: true, json: async () => 7 }), 300),
|
|
),
|
|
),
|
|
);
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(id)!.name;
|
|
const macroId = s.addMacro(
|
|
'M',
|
|
`SETVALUE(${name}::A1; 1) r = GET("https://api.example.com/x") SETVALUE(${name}::A2; r)`,
|
|
) as string;
|
|
vi.advanceTimersByTime(600); // let the addTable + addMacro setup settle into its OWN step first
|
|
|
|
const runPromise = s.runMacro(macroId);
|
|
await vi.advanceTimersByTimeAsync(300); // let the mocked fetch's internal setTimeout resolve
|
|
await runPromise;
|
|
expect(s.cellInput(id, 0, 0)).toBe('1');
|
|
expect(s.cellInput(id, 0, 1)).toBe('7');
|
|
|
|
// If the run had split into two undo steps (the bug this fix
|
|
// prevents), this single undo() would only revert the *second*
|
|
// SETVALUE, leaving A1 still '1' -- a direct, unambiguous proof of
|
|
// "one run = one step" without needing to peek at undoStack.length.
|
|
// (The table itself survives too, proving it settled as its own
|
|
// separate step beforehand, not bundled into the macro's step.)
|
|
s.undo();
|
|
expect(s.tableById(id)).toBeDefined();
|
|
expect(s.cellInput(id, 0, 0)).toBe('');
|
|
expect(s.cellInput(id, 0, 1)).toBe('');
|
|
} finally {
|
|
vi.runOnlyPendingTimers();
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
});
|
|
|
|
it('JSON export/import round-trips macros byte-for-byte', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(id)!.name;
|
|
s.addMacro('Meine Aktion', `SETVALUE(${name}::A1; 1)`);
|
|
|
|
const json = serializeDocument(s.doc);
|
|
const reimported = parseDocument(json);
|
|
expect(reimported.macros).toEqual(s.doc.macros);
|
|
});
|
|
|
|
it('JSON export/import round-trips approvedHosts too', async () => {
|
|
vi.stubGlobal('confirm', vi.fn(() => true));
|
|
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => 1 })));
|
|
try {
|
|
const s = fresh();
|
|
const macroId = s.addMacro('M', 'r = GET("https://api.example.com/x")') as string;
|
|
await s.runMacro(macroId);
|
|
expect(s.doc.macros.find((m) => m.id === macroId)!.approvedHosts).toEqual(['api.example.com']);
|
|
|
|
const json = serializeDocument(s.doc);
|
|
const reimported = parseDocument(json);
|
|
expect(reimported.macros.find((m) => m.id === macroId)!.approvedHosts).toEqual(['api.example.com']);
|
|
} finally {
|
|
vi.unstubAllGlobals();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('custom functions: "block" kind (FOR/IF/RETURN "code")', () => {
|
|
const productBody = 'ergebnis = 1 FOR x IN bereich DO ergebnis = ergebnis * x END RETURN ergebnis';
|
|
|
|
it('adds a block-kind function and a call evaluates through the loop', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(id)!.name;
|
|
setCell(s, name, 'A1', '2');
|
|
setCell(s, name, 'A2', '3');
|
|
setCell(s, name, 'A3', '4');
|
|
|
|
const result = s.addCustomFunction('PRODUCT2', ['bereich'], productBody, 'block');
|
|
expect(typeof result).toBe('string');
|
|
expect(s.doc.customFunctions[0]).toMatchObject({ name: 'PRODUCT2', kind: 'block' });
|
|
|
|
setCell(s, name, 'B1', '=PRODUCT2(A1:A3)');
|
|
expect(val(s, name, 'B1')).toBe(24);
|
|
});
|
|
|
|
it('recalculates when a cell inside the passed range changes', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(id)!.name;
|
|
setCell(s, name, 'A1', '2');
|
|
setCell(s, name, 'A2', '3');
|
|
s.addCustomFunction('PRODUCT2', ['bereich'], productBody, 'block');
|
|
setCell(s, name, 'B1', '=PRODUCT2(A1:A2)');
|
|
expect(val(s, name, 'B1')).toBe(6);
|
|
|
|
setCell(s, name, 'A1', '10');
|
|
expect(val(s, name, 'B1')).toBe(30);
|
|
});
|
|
|
|
it('defaults to "expression" when kind is omitted (backward-compatible call signature)', () => {
|
|
const s = fresh();
|
|
s.addCustomFunction('MARGE', ['x', 'y'], 'x - y');
|
|
expect(s.doc.customFunctions[0].kind).toBe('expression');
|
|
});
|
|
|
|
it('rejects a reserved word (FOR/IN/DO/RETURN) as a parameter name', () => {
|
|
const s = fresh();
|
|
const result = s.addCustomFunction('F', ['for'], 'for + 1', 'block') as { error: string };
|
|
expect(result.error).toBeTruthy();
|
|
expect(s.doc.customFunctions).toHaveLength(0);
|
|
});
|
|
|
|
it('rejects a reserved word as a local/loop variable name', () => {
|
|
const s = fresh();
|
|
// "do" parses fine as an ordinary name here (DO the keyword only ever
|
|
// appears in the one position the FOR grammar expects it) -- this
|
|
// specifically exercises the dedicated reserved-word name check, not a
|
|
// parser-level keyword clash.
|
|
const result = s.addCustomFunction(
|
|
'F',
|
|
['bereich'],
|
|
'do = 1 FOR x IN bereich DO do = do * x END RETURN do',
|
|
'block',
|
|
) as { error: string };
|
|
expect(result.error).toBeTruthy();
|
|
});
|
|
|
|
it('rejects a local/loop variable name that looks like a cell address', () => {
|
|
const s = fresh();
|
|
const result = s.addCustomFunction(
|
|
'F',
|
|
['bereich'],
|
|
'A1 = 1 FOR x IN bereich DO A1 = A1 * x END RETURN A1',
|
|
'block',
|
|
) as { error: string };
|
|
expect(result.error).toBeTruthy();
|
|
});
|
|
|
|
it('rejects a local name that shadows a parameter', () => {
|
|
const s = fresh();
|
|
const result = s.addCustomFunction(
|
|
'SHADOWTEST',
|
|
['bereich'],
|
|
'bereich = 1 FOR x IN bereich DO bereich = bereich * x END RETURN bereich',
|
|
'block',
|
|
) as { error: string };
|
|
expect(result.error).toBeTruthy();
|
|
});
|
|
|
|
it('rejects a local name (declared inside an IF branch) that looks like a cell address', () => {
|
|
const s = fresh();
|
|
const result = s.addCustomFunction(
|
|
'F',
|
|
['x'],
|
|
'IF x > 0 THEN B2 = 1 RETURN B2 ELSE RETURN 0 END',
|
|
'block',
|
|
) as { error: string };
|
|
expect(result.error).toBeTruthy();
|
|
});
|
|
|
|
it('re-assigning the same local across statements (the accumulator pattern) is not a collision', () => {
|
|
const s = fresh();
|
|
// "ergebnis" is declared once and re-assigned inside the loop -- exactly
|
|
// productBody's shape -- this must save cleanly, not be flagged as a
|
|
// duplicate name.
|
|
const result = s.addCustomFunction('REASSIGNTEST', ['bereich'], productBody, 'block');
|
|
expect(typeof result).toBe('string');
|
|
});
|
|
|
|
it('rejects a FOR source that is structurally not a parameter, cell, or range', () => {
|
|
const s = fresh();
|
|
const result = s.addCustomFunction(
|
|
'F',
|
|
['x'],
|
|
'total = 0 FOR y IN x + 1 DO total = total + y END RETURN total',
|
|
'block',
|
|
) as { error: string };
|
|
expect(result.error).toBeTruthy();
|
|
});
|
|
|
|
it('rejects a FOR source nested inside an IF that is structurally invalid', () => {
|
|
const s = fresh();
|
|
const result = s.addCustomFunction(
|
|
'F',
|
|
['x'],
|
|
'total = 0 IF x > 0 THEN FOR y IN x + 1 DO total = total + y END END RETURN total',
|
|
'block',
|
|
) as { error: string };
|
|
expect(result.error).toBeTruthy();
|
|
});
|
|
|
|
it('rejects malformed code (missing RETURN entirely)', () => {
|
|
const s = fresh();
|
|
const result = s.addCustomFunction('F', ['bereich'], 'ergebnis = 1', 'block') as {
|
|
error: string;
|
|
};
|
|
expect(result.error).toBeTruthy();
|
|
});
|
|
|
|
it('rejects code where RETURN is missing on one branch (IF without ELSE is never guaranteed to return)', () => {
|
|
const s = fresh();
|
|
const result = s.addCustomFunction('F', ['x'], 'IF x > 0 THEN RETURN 1 END', 'block') as {
|
|
error: string;
|
|
};
|
|
expect(result.error).toBeTruthy();
|
|
});
|
|
|
|
it('rejects code where only one IF/ELSE branch returns', () => {
|
|
const s = fresh();
|
|
const result = s.addCustomFunction(
|
|
'F',
|
|
['x'],
|
|
'IF x > 0 THEN RETURN 1 ELSE y = 1 END',
|
|
'block',
|
|
) as { error: string };
|
|
expect(result.error).toBeTruthy();
|
|
});
|
|
|
|
it('accepts code where BOTH IF/ELSE branches return, with nothing after', () => {
|
|
const s = fresh();
|
|
const result = s.addCustomFunction('F', ['x'], 'IF x > 0 THEN RETURN 1 ELSE RETURN 0 END', 'block');
|
|
expect(typeof result).toBe('string');
|
|
});
|
|
|
|
it('a block-kind function can call another custom function inside its loop', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(id)!.name;
|
|
setCell(s, name, 'A1', '2');
|
|
setCell(s, name, 'A2', '3');
|
|
|
|
s.addCustomFunction('DOUBLE', ['n'], 'n * 2');
|
|
s.addCustomFunction(
|
|
'SUMDOUBLED',
|
|
['bereich'],
|
|
'total = 0 FOR x IN bereich DO total = total + DOUBLE(x) END RETURN total',
|
|
'block',
|
|
);
|
|
setCell(s, name, 'B1', '=SUMDOUBLED(A1:A2)');
|
|
expect(val(s, name, 'B1')).toBe(2 * 2 + 3 * 2);
|
|
});
|
|
|
|
it('an early RETURN inside an IF inside a FOR finds the first match', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(id)!.name;
|
|
setCell(s, name, 'A1', '1');
|
|
setCell(s, name, 'A2', '999');
|
|
setCell(s, name, 'A3', '4');
|
|
|
|
s.addCustomFunction(
|
|
'FIRSTOVER',
|
|
['bereich', 'schwelle'],
|
|
'FOR x IN bereich DO IF x > schwelle THEN RETURN x END END RETURN 0',
|
|
'block',
|
|
);
|
|
setCell(s, name, 'B1', '=FIRSTOVER(A1:A3; 100)');
|
|
expect(val(s, name, 'B1')).toBe(999);
|
|
});
|
|
|
|
it('detects a cycle that runs through a block-kind function\'s loop body', () => {
|
|
const s = fresh();
|
|
const a = s.addCustomFunction(
|
|
'A',
|
|
['bereich'],
|
|
'total = 0 FOR x IN bereich DO total = total + B(x) END RETURN total',
|
|
'block',
|
|
) as string;
|
|
expect(typeof a).toBe('string');
|
|
// B calling A closes the cycle A -> B -> A
|
|
const result = s.addCustomFunction('B', ['x'], 'A(x) + 1') as { error: string };
|
|
expect(result.error).toBeTruthy();
|
|
expect(s.doc.customFunctions).toHaveLength(1);
|
|
});
|
|
|
|
it('undo/redo covers a block-kind function change', () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const s = fresh();
|
|
s.addCustomFunction('PRODUCT2', ['bereich'], productBody, 'block');
|
|
vi.advanceTimersByTime(600);
|
|
expect(s.doc.customFunctions).toHaveLength(1);
|
|
|
|
s.undo();
|
|
expect(s.doc.customFunctions).toHaveLength(0);
|
|
s.redo();
|
|
expect(s.doc.customFunctions[0]).toMatchObject({ name: 'PRODUCT2', kind: 'block' });
|
|
} finally {
|
|
vi.runOnlyPendingTimers();
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('undo / redo', () => {
|
|
// rapid-fire changes (a resize drag, or several store calls in one
|
|
// synchronous action) coalesce into a single undo step -- tests that want
|
|
// *separate* steps advance the fake clock between them, past the store's
|
|
// debounce window, the same way real, separately-timed user actions would.
|
|
beforeEach(() => vi.useFakeTimers());
|
|
afterEach(() => {
|
|
vi.runOnlyPendingTimers();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('undoes and redoes a single cell edit', () => {
|
|
const s = fresh();
|
|
expect(s.canUndo.value).toBe(false);
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(id)!.name;
|
|
vi.advanceTimersByTime(600); // creating the table settles as its own step
|
|
|
|
setCell(s, name, 'A1', '5');
|
|
s.undo();
|
|
expect(s.cellInput(id, 0, 0)).toBe('');
|
|
expect(s.canUndo.value).toBe(true); // the table-creation step is still there to undo
|
|
expect(s.canRedo.value).toBe(true);
|
|
|
|
s.redo();
|
|
expect(s.cellInput(id, 0, 0)).toBe('5');
|
|
expect(s.canRedo.value).toBe(false);
|
|
});
|
|
|
|
it('steps back through several edits one at a time, in order', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(id)!.name;
|
|
vi.advanceTimersByTime(600); // creating the table settles as its own step
|
|
setCell(s, name, 'A1', '1');
|
|
vi.advanceTimersByTime(600);
|
|
setCell(s, name, 'A1', '2');
|
|
vi.advanceTimersByTime(600);
|
|
setCell(s, name, 'A1', '3');
|
|
|
|
expect(s.cellInput(id, 0, 0)).toBe('3');
|
|
s.undo();
|
|
expect(s.cellInput(id, 0, 0)).toBe('2');
|
|
s.undo();
|
|
expect(s.cellInput(id, 0, 0)).toBe('1');
|
|
s.undo();
|
|
expect(s.cellInput(id, 0, 0)).toBe('');
|
|
expect(s.tableById(id)).toBeDefined(); // only the edits undid -- table itself is its own step
|
|
expect(s.canUndo.value).toBe(true);
|
|
s.undo(); // one further -- removes the table (the step before the edits)
|
|
expect(s.tableById(id)).toBeUndefined();
|
|
expect(s.canUndo.value).toBe(false);
|
|
s.undo(); // one further than there is history -- no-op, doesn't throw
|
|
expect(s.canUndo.value).toBe(false);
|
|
});
|
|
|
|
it('a new edit after undoing drops the redo branch', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(id)!.name;
|
|
vi.advanceTimersByTime(600); // creating the table settles as its own step
|
|
setCell(s, name, 'A1', '1');
|
|
setCell(s, name, 'A1', '2');
|
|
s.undo();
|
|
expect(s.canRedo.value).toBe(true);
|
|
|
|
setCell(s, name, 'A1', '9');
|
|
expect(s.canRedo.value).toBe(false);
|
|
s.redo(); // nothing to redo -- no-op
|
|
expect(s.cellInput(id, 0, 0)).toBe('9');
|
|
});
|
|
|
|
it('pure selection changes are never an undo step', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
vi.advanceTimersByTime(600); // creating the table settles as its own (the only) step
|
|
s.setSelection({ tableId: id, anchor: coords('A1'), focus: coords('A1') });
|
|
s.setSelection({ tableId: id, anchor: coords('B2'), focus: coords('C3') });
|
|
// exactly one undo fully reverts to "no table" -- proving the selection
|
|
// moves in between added no steps of their own
|
|
s.undo();
|
|
expect(s.tableById(id)).toBeUndefined();
|
|
expect(s.canUndo.value).toBe(false);
|
|
});
|
|
|
|
it('covers copy/paste, fill and formatting -- not just setCellInput', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(id)!.name;
|
|
setCell(s, name, 'A1', '7');
|
|
vi.advanceTimersByTime(600); // settle as its own step, separate from the paste below
|
|
|
|
s.setSelection({ tableId: id, anchor: coords('A1'), focus: coords('A1') });
|
|
s.copySelection();
|
|
s.setSelection({ tableId: id, anchor: coords('B1'), focus: coords('B1') });
|
|
s.pasteClipboard();
|
|
expect(val(s, name, 'B1')).toBe(7);
|
|
s.undo();
|
|
expect(s.cellInput(id, coords('B1').col, coords('B1').row)).toBe('');
|
|
|
|
s.fillSelection(id, { c0: 0, c1: 0, r0: 0, r1: 0 }, { c0: 0, c1: 0, r0: 1, r1: 1 });
|
|
expect(val(s, name, 'A2')).toBe(7);
|
|
s.undo();
|
|
expect(s.cellInput(id, 0, 1)).toBe('');
|
|
|
|
s.setRangeFormat(id, coords('A1'), coords('A1'), { bold: true });
|
|
expect(s.cellFormat(id, 0, 0)).toEqual({ bold: true });
|
|
s.undo();
|
|
expect(s.cellFormat(id, 0, 0)).toEqual({});
|
|
});
|
|
|
|
it('re-committing a cell\'s unchanged value is a true no-op -- no step, redo survives', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(id)!.name;
|
|
vi.advanceTimersByTime(600); // table creation is its own step
|
|
setCell(s, name, 'A1', '1');
|
|
vi.advanceTimersByTime(600);
|
|
setCell(s, name, 'A1', '2');
|
|
vi.advanceTimersByTime(600);
|
|
|
|
s.undo(); // undoes '2' -> back to '1'
|
|
expect(s.cellInput(id, 0, 0)).toBe('1');
|
|
expect(s.canRedo.value).toBe(true);
|
|
|
|
// F2, Enter without changing anything -- a true no-op, must not clear redo
|
|
s.setCellInput(id, 0, 0, '1');
|
|
expect(s.canRedo.value).toBe(true);
|
|
s.redo();
|
|
expect(s.cellInput(id, 0, 0)).toBe('2');
|
|
|
|
// clearing an already-empty cell is likewise a no-op -- creates no step
|
|
s.setCellInput(id, 5, 5, '');
|
|
vi.advanceTimersByTime(600);
|
|
s.undo(); // must undo the '1' -> '2' edit directly, not some no-op clear step
|
|
expect(s.cellInput(id, 0, 0)).toBe('1');
|
|
expect(s.cellInput(id, 5, 5)).toBe('');
|
|
});
|
|
|
|
it('resetToSeed starts a fresh history -- the prior document is not undoable back', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
setCell(s, s.tableById(id)!.name, 'A1', 'temp');
|
|
expect(s.canUndo.value).toBe(true);
|
|
|
|
s.resetToSeed();
|
|
expect(s.canUndo.value).toBe(false);
|
|
expect(s.canRedo.value).toBe(false);
|
|
});
|
|
});
|
|
|
|
/** A minimal in-memory stand-in for the in-cell editor / formula bar, for
|
|
* exercising store.pointClick/pointDrag without a DOM. */
|
|
function mockEditor(tableId: string, initialText: string) {
|
|
let text = initialText;
|
|
let start = text.length;
|
|
let end = text.length;
|
|
return {
|
|
handle: {
|
|
tableId,
|
|
getState: () => ({ text, start, end }),
|
|
replace: (s: number, e: number, value: string) => {
|
|
text = text.slice(0, s) + value + text.slice(e);
|
|
start = end = s + value.length;
|
|
},
|
|
},
|
|
get text() {
|
|
return text;
|
|
},
|
|
};
|
|
}
|
|
|
|
describe('formula "point mode" (click/drag a cell to insert a reference)', () => {
|
|
// registerFormulaEditor() is store-level state a real editor would always
|
|
// clear on blur/commit -- tests need the same discipline, since the store
|
|
// is a module-level singleton and fresh() doesn't touch it.
|
|
afterEach(() => useDocument().registerFormulaEditor(null));
|
|
|
|
it('a click inserts a local reference at the caret', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const ed = mockEditor(id, '=');
|
|
s.registerFormulaEditor(ed.handle);
|
|
expect(s.pointClick(id, 0, 0)).toBe(true);
|
|
expect(ed.text).toBe('=A1');
|
|
});
|
|
|
|
it('does nothing (returns false) when the buffer is not a formula', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const ed = mockEditor(id, 'plain text');
|
|
s.registerFormulaEditor(ed.handle);
|
|
expect(s.pointClick(id, 0, 0)).toBe(false);
|
|
expect(ed.text).toBe('plain text');
|
|
});
|
|
|
|
it('a second click before typing replaces the just-inserted reference', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const ed = mockEditor(id, '=');
|
|
s.registerFormulaEditor(ed.handle);
|
|
s.pointClick(id, 0, 0); // A1
|
|
s.pointClick(id, 1, 1); // replaces it -> B2
|
|
expect(ed.text).toBe('=B2');
|
|
});
|
|
|
|
it('pointRect outlines the picked cell/range until typing or closing the editor', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const ed = mockEditor(id, '=');
|
|
s.registerFormulaEditor(ed.handle);
|
|
expect(s.pointRect.value).toBeNull();
|
|
s.pointClick(id, 1, 2);
|
|
expect(s.pointRect.value).toEqual({ tableId: id, c0: 1, r0: 2, c1: 1, r1: 2 });
|
|
s.pointDrag(id, 2, 3, 0, 1); // dragged up-left: normalised
|
|
expect(s.pointRect.value).toEqual({ tableId: id, c0: 0, r0: 1, c1: 2, r1: 3 });
|
|
s.noteFormulaTyped();
|
|
expect(s.pointRect.value).toBeNull();
|
|
s.pointClick(id, 0, 0);
|
|
s.registerFormulaEditor(null);
|
|
expect(s.pointRect.value).toBeNull();
|
|
// no formula in the buffer -> nothing picked, nothing outlined
|
|
s.registerFormulaEditor(mockEditor(id, 'text').handle);
|
|
s.pointClick(id, 0, 0);
|
|
expect(s.pointRect.value).toBeNull();
|
|
});
|
|
|
|
it('typing between two clicks makes the second an insert, not a replace', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const ed = mockEditor(id, '=');
|
|
s.registerFormulaEditor(ed.handle);
|
|
s.pointClick(id, 0, 0); // =A1
|
|
s.noteFormulaTyped(); // the user typed something themselves (e.g. ";")
|
|
const end = ed.handle.getState().end;
|
|
ed.handle.replace(end, end, ';');
|
|
s.pointClick(id, 1, 0); // appends a fresh reference after the ";"
|
|
expect(ed.text).toBe('=A1;B1');
|
|
});
|
|
|
|
it('a drag inserts/grows a range reference, replacing itself as it grows', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const ed = mockEditor(id, '=SUM(');
|
|
s.registerFormulaEditor(ed.handle);
|
|
s.pointDrag(id, 0, 0, 0, 0); // A1
|
|
expect(ed.text).toBe('=SUM(A1');
|
|
s.pointDrag(id, 0, 0, 2, 3); // grows to A1:C4 -- replaces, doesn't append
|
|
expect(ed.text).toBe('=SUM(A1:C4');
|
|
});
|
|
|
|
it('a click on a different table qualifies the reference with its name', () => {
|
|
const s = fresh();
|
|
const home = s.addTable(s.doc.activeSheetId);
|
|
const other = s.addTable(s.doc.activeSheetId);
|
|
s.renameObject(other, 'Kosten Q3'); // needs quoting (space)
|
|
const ed = mockEditor(home, '=');
|
|
s.registerFormulaEditor(ed.handle);
|
|
s.pointClick(other, 0, 0);
|
|
expect(ed.text).toBe("='Kosten Q3'!A1");
|
|
});
|
|
|
|
it("a click on the editor's own table needs no qualifier", () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const ed = mockEditor(id, '=');
|
|
s.registerFormulaEditor(ed.handle);
|
|
s.pointClick(id, 3, 4);
|
|
expect(ed.text).toBe('=D5');
|
|
});
|
|
|
|
it('formulaPointModeActive reflects the registered editor, if any', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
expect(s.formulaPointModeActive()).toBe(false);
|
|
const ed = mockEditor(id, '=');
|
|
s.registerFormulaEditor(ed.handle);
|
|
expect(s.formulaPointModeActive()).toBe(true);
|
|
s.registerFormulaEditor(null);
|
|
expect(s.formulaPointModeActive()).toBe(false);
|
|
});
|
|
|
|
it('a click on a same-named table on a DIFFERENT sheet inserts the full "Blatt!Tabelle::" form', () => {
|
|
const s = fresh();
|
|
const home = s.addTable(s.doc.activeSheetId); // lives on "Analyse"
|
|
const blatt2 = s.doc.sheets[1].id; // "Blatt 2"
|
|
const other = s.addTable(blatt2);
|
|
s.renameObject(other, 'Umsatz'); // now ambiguous: Analyse already has an "Umsatz" too
|
|
const ed = mockEditor(home, '=');
|
|
s.registerFormulaEditor(ed.handle);
|
|
s.pointClick(other, 0, 0);
|
|
expect(ed.text).toBe("='Blatt 2'!Umsatz::A1"); // plain "Umsatz!A1" would be ambiguous
|
|
});
|
|
});
|
|
|
|
describe('Name Box (jumpToNameInput / nameBoxSuggestions)', () => {
|
|
it('a bare cell/range navigates on the currently active table', () => {
|
|
const s = fresh();
|
|
s.setSelection({ tableId: tableId(s, 'Umsatz'), anchor: coords('A1'), focus: coords('A1') });
|
|
expect(s.jumpToNameInput('B3')).toBe(true);
|
|
expect(s.doc.selection).toMatchObject({
|
|
tableId: tableId(s, 'Umsatz'),
|
|
anchor: coords('B3'),
|
|
focus: coords('B3'),
|
|
});
|
|
expect(s.jumpToNameInput('B2:D4')).toBe(true);
|
|
expect(s.doc.selection).toMatchObject({ anchor: coords('B2'), focus: coords('D4') });
|
|
});
|
|
|
|
it('a qualified reference ("Tabelle::A1") jumps to a different table, switching sheet/object', () => {
|
|
const s = fresh();
|
|
expect(s.jumpToNameInput('Kosten::B2')).toBe(true);
|
|
expect(s.doc.selection).toMatchObject({
|
|
tableId: tableId(s, 'Kosten'),
|
|
anchor: coords('B2'),
|
|
focus: coords('B2'),
|
|
});
|
|
expect(s.doc.activeObjectId).toBe(tableId(s, 'Kosten'));
|
|
});
|
|
|
|
it('a named range name jumps to and selects its target range', () => {
|
|
const s = fresh();
|
|
expect(s.jumpToNameInput('Umsatz_Q1')).toBe(true);
|
|
expect(s.doc.selection).toMatchObject({
|
|
tableId: tableId(s, 'Umsatz'),
|
|
anchor: coords('B2'),
|
|
focus: coords('B6'),
|
|
});
|
|
});
|
|
|
|
it('a qualified reference works when the table name has non-ASCII letters', () => {
|
|
// regression: the lexer used to reject "Ü" outright (an unquoted
|
|
// qualifier is tokenized the same as any other identifier), so
|
|
// "Übersicht::B4" threw instead of parsing -- see engine/lexer.ts.
|
|
const s = fresh();
|
|
expect(s.jumpToNameInput('Übersicht::B4')).toBe(true);
|
|
expect(s.doc.selection).toMatchObject({
|
|
tableId: tableId(s, 'Übersicht'),
|
|
anchor: coords('B4'),
|
|
focus: coords('B4'),
|
|
});
|
|
});
|
|
|
|
it('a bare table name selects the whole table', () => {
|
|
const s = fresh();
|
|
const id = tableId(s, 'Kosten');
|
|
const t = s.tableById(id)!;
|
|
expect(s.jumpToNameInput('Kosten')).toBe(true);
|
|
expect(s.doc.selection).toMatchObject({
|
|
tableId: id,
|
|
anchor: { col: 0, row: 0 },
|
|
focus: { col: t.cols - 1, row: t.rows - 1 },
|
|
});
|
|
});
|
|
|
|
it('a bare sheet name switches to that sheet', () => {
|
|
const s = fresh();
|
|
expect(s.doc.activeSheetId).not.toBe(s.doc.sheets[1].id);
|
|
expect(s.jumpToNameInput('Blatt 2')).toBe(true);
|
|
expect(s.doc.activeSheetId).toBe(s.doc.sheets[1].id);
|
|
});
|
|
|
|
it('garbage input, an unknown name, or a non-reference formula (e.g. a function call) resolves to nothing', () => {
|
|
const s = fresh();
|
|
const before = s.doc.selection;
|
|
expect(s.jumpToNameInput('')).toBe(false);
|
|
expect(s.jumpToNameInput(' ')).toBe(false);
|
|
expect(s.jumpToNameInput('NichtVorhanden')).toBe(false);
|
|
expect(s.jumpToNameInput('SUM(A1;A2)')).toBe(false); // navigates, doesn't evaluate
|
|
expect(s.jumpToNameInput('1+1')).toBe(false);
|
|
expect(s.doc.selection).toEqual(before); // no partial/incidental navigation on failure
|
|
});
|
|
|
|
it('out-of-bounds coordinates clamp into the target table instead of erroring', () => {
|
|
const s = fresh();
|
|
const id = tableId(s, 'Umsatz');
|
|
const t = s.tableById(id)!;
|
|
expect(s.jumpToNameInput('Z999')).toBe(true);
|
|
expect(s.doc.selection).toMatchObject({
|
|
tableId: id,
|
|
anchor: { col: t.cols - 1, row: t.rows - 1 },
|
|
focus: { col: t.cols - 1, row: t.rows - 1 },
|
|
});
|
|
});
|
|
|
|
it('nameBoxSuggestions offers named ranges, then tables, then sheets, prefix-matched', () => {
|
|
const s = fresh();
|
|
expect(s.nameBoxSuggestions('')).toEqual([]); // empty prefix -> no suggestions
|
|
const hits = s.nameBoxSuggestions('u');
|
|
expect(hits.map((h) => h.name)).toEqual(['Umsatz_Q1', 'Umsatz']); // namedrange before table
|
|
expect(hits[0]).toMatchObject({ kind: 'namedrange', hint: 'Umsatz!B2:B6' });
|
|
expect(hits[1]).toMatchObject({ kind: 'table' });
|
|
|
|
const blattHits = s.nameBoxSuggestions('blatt');
|
|
expect(blattHits).toEqual([{ name: 'Blatt 2', hint: 'Blatt', kind: 'sheet' }]);
|
|
});
|
|
});
|
|
|
|
describe('cross-sheet formulas: sheet qualifier + per-sheet table name uniqueness', () => {
|
|
it('renaming a table to a name already used on a DIFFERENT sheet is now allowed', () => {
|
|
const s = fresh();
|
|
const blatt2 = s.doc.sheets[1].id;
|
|
const id = s.addTable(blatt2);
|
|
s.renameObject(id, 'Umsatz'); // "Umsatz" already exists, but on the OTHER (Analyse) sheet
|
|
expect(s.tableById(id)!.name).toBe('Umsatz');
|
|
});
|
|
|
|
it('renaming a table to a name already used on the SAME sheet still gets uniquified', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId); // Analyse already has an "Umsatz"
|
|
s.renameObject(id, 'Umsatz');
|
|
expect(s.tableById(id)!.name).toBe('Umsatz 2');
|
|
});
|
|
|
|
it("inserting a new table defaults its name against only its OWN sheet's objects", () => {
|
|
const s = fresh();
|
|
const blatt2 = s.doc.sheets[1].id;
|
|
// Blatt 2 starts empty -- its first new table can be plain "Tabelle",
|
|
// unaffected by Analyse already having several named objects.
|
|
const id = s.addTable(blatt2);
|
|
expect(s.tableById(id)!.name).toBe('Tabelle');
|
|
});
|
|
|
|
it('an unqualified, cross-sheet-ambiguous table name resolves to the one on the SAME sheet as the formula', () => {
|
|
const s = fresh();
|
|
const analyse = s.doc.activeSheetId; // capture before addTable()'s activeSheetId side effect
|
|
const blatt2 = s.doc.sheets[1].id;
|
|
const dupId = s.addTable(blatt2);
|
|
s.renameObject(dupId, 'Umsatz'); // now two tables named "Umsatz", one per sheet
|
|
s.setCellInput(dupId, 1, 1, '777'); // Blatt 2's own Umsatz!B2
|
|
|
|
const blatt2RefId = s.addTable(blatt2);
|
|
s.setCellInput(blatt2RefId, 0, 0, '=Umsatz!B2');
|
|
expect(s.cellValue(blatt2RefId, 0, 0)).toBe(777); // resolves to Blatt 2's own Umsatz
|
|
|
|
const analyseRefId = s.addTable(analyse);
|
|
s.setCellInput(analyseRefId, 0, 0, '=Umsatz!B2');
|
|
expect(s.cellValue(analyseRefId, 0, 0)).toBe(1200); // resolves to Analyse's (seed) Umsatz
|
|
});
|
|
|
|
it('an explicit "Blatt!Tabelle::Zelle" reference always targets that sheet\'s table', () => {
|
|
const s = fresh();
|
|
const analyse = s.doc.activeSheetId; // capture before addTable()'s activeSheetId side effect
|
|
const blatt2 = s.doc.sheets[1].id;
|
|
const dupId = s.addTable(blatt2);
|
|
s.renameObject(dupId, 'Umsatz');
|
|
s.setCellInput(dupId, 1, 1, '777'); // Blatt 2's Umsatz!B2
|
|
|
|
const refId = s.addTable(analyse); // lives on Analyse
|
|
s.setCellInput(refId, 0, 0, "='Blatt 2'!Umsatz::B2"); // explicit -> Blatt 2's, not Analyse's own
|
|
expect(s.cellValue(refId, 0, 0)).toBe(777);
|
|
s.setCellInput(refId, 0, 1, '=Analyse!Umsatz::B2'); // explicit -> Analyse's own
|
|
expect(s.cellValue(refId, 0, 1)).toBe(1200);
|
|
});
|
|
|
|
it('deleting the sheet that owned one of two same-named tables leaves the other resolvable unqualified', () => {
|
|
const s = fresh();
|
|
const analyse = s.doc.activeSheetId; // capture before addTable()'s activeSheetId side effect
|
|
const blatt2 = s.doc.sheets[1].id;
|
|
const dupId = s.addTable(blatt2);
|
|
s.renameObject(dupId, 'Umsatz');
|
|
s.deleteSheet(blatt2); // the ambiguity is gone -- only Analyse's Umsatz remains
|
|
const refId = s.addTable(analyse);
|
|
s.setCellInput(refId, 0, 0, '=Umsatz!B2');
|
|
expect(s.cellValue(refId, 0, 0)).toBe(1200);
|
|
});
|
|
});
|
|
|
|
describe('text box objects', () => {
|
|
it('insert / edit / format / round-trip a text object', () => {
|
|
const s = fresh();
|
|
const sheetId = s.doc.sheets[0].id;
|
|
const id = s.addText(sheetId);
|
|
const o = s.objectById(id)!;
|
|
expect(o.kind).toBe('text');
|
|
s.setTextContent(id, 'Hallo\nWelt');
|
|
s.setTextFormat(id, { fontSize: 22, bold: true, align: 'center', color: '#123456' });
|
|
s.setObjectBg(id, '#ffffcc');
|
|
const t = s.objectById(id);
|
|
expect(t).toMatchObject({
|
|
kind: 'text',
|
|
text: 'Hallo\nWelt',
|
|
fontSize: 22,
|
|
bold: true,
|
|
align: 'center',
|
|
color: '#123456',
|
|
bg: '#ffffcc',
|
|
});
|
|
const before = s.snapshot();
|
|
s.replaceDocument(parseDocument(serializeDocument(before)));
|
|
expect(s.snapshot()).toEqual(before);
|
|
});
|
|
|
|
it('a garbage text object in imported JSON is rejected, document unchanged', () => {
|
|
const s = fresh();
|
|
const b = s.snapshot();
|
|
const doc = JSON.parse(serializeDocument(b));
|
|
doc.sheets[0].objects.push({ kind: 'text', id: 'x' }); // missing required fields
|
|
expect(() => s.replaceDocument(parseDocument(JSON.stringify(doc)))).toThrow();
|
|
expect(s.snapshot()).toEqual(b);
|
|
});
|
|
});
|
|
|
|
describe('per-object background & text colour', () => {
|
|
it('table / chart / text each carry their own bg + fg and round-trip', () => {
|
|
const s = fresh();
|
|
const tId = tableId(s, 'Umsatz');
|
|
const cId = s.doc.sheets[0].objects.find((o) => o.kind === 'chart')!.id;
|
|
s.setObjectBg(tId, '#101010');
|
|
s.setObjectTextColor(tId, '#fafafa');
|
|
s.setObjectBg(cId, '#202020');
|
|
s.setObjectTextColor(cId, '#eeeeee');
|
|
const tbl = s.tableById(tId)!;
|
|
expect([tbl.bg, tbl.fg]).toEqual(['#101010', '#fafafa']);
|
|
const before = s.snapshot();
|
|
s.replaceDocument(parseDocument(serializeDocument(before)));
|
|
expect(s.snapshot()).toEqual(before);
|
|
s.setObjectBg(tId, null);
|
|
expect(s.tableById(tId)!.bg).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('table "hide chrome when unfocused" (clean-look presentation option)', () => {
|
|
it('toggles hideChromeUnfocused and clears the field when turned off', () => {
|
|
const s = fresh();
|
|
const id = tableId(s, 'Umsatz');
|
|
expect(s.tableById(id)!.hideChromeUnfocused).toBeUndefined();
|
|
s.setTableHideChromeUnfocused(id, true);
|
|
expect(s.tableById(id)!.hideChromeUnfocused).toBe(true);
|
|
s.setTableHideChromeUnfocused(id, false);
|
|
expect(s.tableById(id)!.hideChromeUnfocused).toBeUndefined(); // "unset", not stored as false
|
|
});
|
|
|
|
it('survives a JSON round-trip; a non-boolean value on import is dropped', () => {
|
|
const s = fresh();
|
|
const id = tableId(s, 'Umsatz');
|
|
s.setTableHideChromeUnfocused(id, true);
|
|
const before = s.snapshot();
|
|
s.replaceDocument(parseDocument(serializeDocument(before)));
|
|
expect(s.snapshot()).toEqual(before);
|
|
expect(s.tableById(id)!.hideChromeUnfocused).toBe(true);
|
|
|
|
const doc = JSON.parse(serializeDocument(s.snapshot()));
|
|
const tbl = doc.sheets[0].objects.find((o: { id: string }) => o.id === id);
|
|
tbl.hideChromeUnfocused = 'yes';
|
|
s.replaceDocument(parseDocument(JSON.stringify(doc)));
|
|
expect(s.tableById(id)!.hideChromeUnfocused).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('full-fidelity JSON export — backgrounds, formats, sheet settings', () => {
|
|
/** Populate *every* persisted setting, then verify the export string carries
|
|
* it and a round-trip is byte-for-byte identical. */
|
|
function loadEverything(s: DocumentStore) {
|
|
const analyse = s.doc.sheets[0].id;
|
|
const blatt2 = s.doc.sheets[1].id;
|
|
const umsatzId = tableId(s, 'Umsatz');
|
|
const chartId = s.doc.sheets[0].objects.find((o) => o.kind === 'chart')!.id;
|
|
|
|
// document
|
|
s.setDocumentLocale('en-GB');
|
|
s.setCsvDelimiter(';');
|
|
|
|
// sheet ("Arbeitsblatt") settings — on both sheets
|
|
s.setSheetLocale(analyse, 'de-CH');
|
|
s.setSheetBackground(analyse, '#eef2ff');
|
|
s.setSheetForeground(analyse, '#101828');
|
|
s.setSheetShowGrid(analyse, false);
|
|
s.setSheetZoom(analyse, 1.25);
|
|
s.renameSheet(blatt2, 'Notizen');
|
|
s.setSheetBackground(blatt2, '#fff7ed');
|
|
|
|
// table object: colours + geometry + view + per-row/col + cell formats
|
|
s.setObjectBg(umsatzId, '#0b1220');
|
|
s.setObjectTextColor(umsatzId, '#f8fafc');
|
|
s.setObjectLocale(umsatzId, 'fr-FR');
|
|
s.setTableView(umsatzId, { mode: 'fixed', width: 520, height: 300, frozenRows: 1, frozenCols: 1 });
|
|
s.setTableRowHeight(umsatzId, 30);
|
|
s.setTableHideChromeUnfocused(umsatzId, true);
|
|
s.setRowHeight(umsatzId, 4, 52);
|
|
s.setColWidth(umsatzId, 0, 130);
|
|
s.setRangeFormat(umsatzId, { col: 1, row: 1 }, { col: 3, row: 4 }, {
|
|
align: 'right',
|
|
valign: 'middle',
|
|
numFmt: 'pct2',
|
|
color: '#dc2626',
|
|
bg: '#fee2e2',
|
|
bold: true,
|
|
italic: true,
|
|
});
|
|
|
|
// chart object
|
|
s.setObjectBg(chartId, '#111827');
|
|
s.setObjectTextColor(chartId, '#e5e7eb');
|
|
s.setChartView(chartId, 'line');
|
|
s.resizeChart(chartId, 600, 360);
|
|
|
|
// text object with full formatting
|
|
const txtId = s.addText(analyse);
|
|
s.setTextContent(txtId, 'Bericht\nZeile 2');
|
|
s.setTextFormat(txtId, { fontSize: 20, bold: true, italic: true, align: 'center' });
|
|
s.setObjectTextColor(txtId, '#334155');
|
|
s.setObjectBg(txtId, '#f1f5f9');
|
|
|
|
// multi-range selection
|
|
s.setSelection({ tableId: umsatzId, anchor: { col: 0, row: 0 }, focus: { col: 0, row: 0 } });
|
|
s.addToSelection(umsatzId, 2, 2);
|
|
s.addToSelection(umsatzId, 4, 5);
|
|
|
|
// a custom function -- one of each kind
|
|
s.addCustomFunction('MARGE', ['revenue', 'cost'], '(revenue - cost) / revenue');
|
|
s.addCustomFunction(
|
|
'PRODUCT2',
|
|
['bereich'],
|
|
'ergebnis = 1 FOR x IN bereich DO ergebnis = ergebnis * x END RETURN ergebnis',
|
|
'block',
|
|
);
|
|
|
|
return { analyse, umsatzId, chartId, txtId };
|
|
}
|
|
|
|
it('the exported JSON text literally contains every setting', () => {
|
|
const s = fresh();
|
|
loadEverything(s);
|
|
const json = serializeDocument(s.snapshot());
|
|
const doc = JSON.parse(json);
|
|
|
|
// document
|
|
expect(doc.locale).toBe('en-GB');
|
|
expect(doc.csvDelimiter).toBe(';');
|
|
|
|
// sheet settings
|
|
const a = doc.sheets[0];
|
|
expect(a).toMatchObject({
|
|
locale: 'de-CH',
|
|
background: '#eef2ff',
|
|
foreground: '#101828',
|
|
showGrid: false,
|
|
zoom: 1.25,
|
|
});
|
|
expect(doc.sheets[1]).toMatchObject({ name: 'Notizen', background: '#fff7ed' });
|
|
|
|
// table object
|
|
const tbl = a.objects.find((o: { kind: string }) => o.kind === 'table');
|
|
expect(tbl).toMatchObject({
|
|
bg: '#0b1220',
|
|
fg: '#f8fafc',
|
|
locale: 'fr-FR',
|
|
rowHeight: 30,
|
|
rowHeights: { '4': 52 },
|
|
colWidths: { '0': 130 },
|
|
view: { mode: 'fixed', width: 520, height: 300, frozenRows: 1, frozenCols: 1 },
|
|
hideChromeUnfocused: true,
|
|
});
|
|
expect(tbl.formats['1,1']).toEqual({
|
|
align: 'right',
|
|
valign: 'middle',
|
|
numFmt: 'pct2',
|
|
color: '#dc2626',
|
|
bg: '#fee2e2',
|
|
bold: true,
|
|
italic: true,
|
|
});
|
|
|
|
// chart object
|
|
const cht = a.objects.find((o: { kind: string }) => o.kind === 'chart');
|
|
expect(cht).toMatchObject({ bg: '#111827', fg: '#e5e7eb', view: 'line', width: 600, height: 360 });
|
|
|
|
// text object
|
|
const txt = a.objects.find((o: { kind: string }) => o.kind === 'text');
|
|
expect(txt).toMatchObject({
|
|
text: 'Bericht\nZeile 2',
|
|
fontSize: 20,
|
|
bold: true,
|
|
italic: true,
|
|
align: 'center',
|
|
color: '#334155',
|
|
bg: '#f1f5f9',
|
|
});
|
|
|
|
// multi-range selection
|
|
expect(doc.selection.ranges).toHaveLength(2);
|
|
|
|
// custom functions -- one of each kind
|
|
expect(doc.customFunctions).toHaveLength(2);
|
|
expect(doc.customFunctions[0]).toMatchObject({
|
|
name: 'MARGE',
|
|
params: ['revenue', 'cost'],
|
|
body: '(revenue - cost) / revenue',
|
|
kind: 'expression',
|
|
});
|
|
expect(doc.customFunctions[1]).toMatchObject({ name: 'PRODUCT2', kind: 'block' });
|
|
});
|
|
|
|
it('export -> import is lossless for the fully-loaded document', () => {
|
|
const s = fresh();
|
|
loadEverything(s);
|
|
const before = s.snapshot();
|
|
s.replaceDocument(parseDocument(serializeDocument(before)));
|
|
expect(s.snapshot()).toEqual(before);
|
|
});
|
|
|
|
it('resetToSeed then re-import the exported file restores the exact state', () => {
|
|
const s = fresh();
|
|
loadEverything(s);
|
|
const exported = serializeDocument(s.snapshot());
|
|
const before = s.snapshot();
|
|
s.resetToSeed();
|
|
expect(s.snapshot()).not.toEqual(before);
|
|
s.replaceDocument(parseDocument(exported));
|
|
expect(s.snapshot()).toEqual(before);
|
|
});
|
|
});
|
|
|
|
describe('v1 -> v2 migration', () => {
|
|
it('turns each old sheet into a canvas sheet holding one table of the same name', async () => {
|
|
const { migrateToV2 } = await import('../../io/json');
|
|
const v1 = {
|
|
version: 1,
|
|
sheets: [
|
|
{ id: 'a', name: 'Umsatz', rows: 5, cols: 3, colWidths: {}, cells: { '0,0': '7', '1,0': '=A1*2' } },
|
|
{ id: 'b', name: 'Report', rows: 4, cols: 2, colWidths: {}, cells: { '0,0': '=Umsatz!A1' } },
|
|
],
|
|
activeSheetId: 'b',
|
|
selection: { sheetId: 'b', anchor: { col: 0, row: 0 }, focus: { col: 0, row: 0 } },
|
|
};
|
|
const v2 = migrateToV2(v1);
|
|
expect(v2.version).toBe(2);
|
|
expect(v2.sheets).toHaveLength(2);
|
|
expect(v2.sheets[0].objects[0]).toMatchObject({ kind: 'table', name: 'Umsatz' });
|
|
expect(v2.customFunctions).toEqual([]); // the feature didn't exist in v1
|
|
// cross-sheet formula still resolves after loading it
|
|
const store = useDocument();
|
|
store.replaceDocument(v2);
|
|
const rep = tableId(store, 'Report');
|
|
expect(store.cellValue(rep, 0, 0)).toBe(7);
|
|
});
|
|
|
|
it('an older v2 document\'s customFunctions without a "kind" field default to "expression"', () => {
|
|
const s = fresh();
|
|
const before = s.snapshot();
|
|
const legacy = {
|
|
...before,
|
|
customFunctions: [{ id: 'fn-1', name: 'MARGE', params: ['x', 'y'], body: 'x - y' }], // no "kind"
|
|
};
|
|
const parsed = parseDocument(JSON.stringify(legacy));
|
|
expect(parsed.customFunctions).toEqual([
|
|
{ id: 'fn-1', name: 'MARGE', params: ['x', 'y'], body: 'x - y', kind: 'expression' },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('whole-column / whole-row references', () => {
|
|
/** A blank document's "Tabelle 1" (12 x 6) with B2:B4 = 1, 2, 3 and row 2 = 10, 1, 20. */
|
|
function setup() {
|
|
const s = fresh();
|
|
s.newDocument();
|
|
const t = tableId(s, 'Tabelle 1');
|
|
setCell(s, 'Tabelle 1', 'B2', '1');
|
|
setCell(s, 'Tabelle 1', 'B3', '2');
|
|
setCell(s, 'Tabelle 1', 'B4', '3');
|
|
setCell(s, 'Tabelle 1', 'A2', '10');
|
|
setCell(s, 'Tabelle 1', 'C2', '20');
|
|
return { s, t };
|
|
}
|
|
const input = (s: DocumentStore, t: string, a1: string) => {
|
|
const { col, row } = coords(a1);
|
|
return s.cellInput(t, col, row);
|
|
};
|
|
|
|
it('SUM over a whole column and a whole row', () => {
|
|
const { s } = setup();
|
|
setCell(s, 'Tabelle 1', 'E1', '=SUM(B:B)');
|
|
setCell(s, 'Tabelle 1', 'F1', '=SUM(2:2)');
|
|
setCell(s, 'Tabelle 1', 'E3', '=COUNT(A:C)');
|
|
expect(val(s, 'Tabelle 1', 'E1')).toBe(6);
|
|
expect(val(s, 'Tabelle 1', 'F1')).toBe(31);
|
|
expect(val(s, 'Tabelle 1', 'E3')).toBe(5);
|
|
});
|
|
|
|
it('a formula inside the column it sums is a circular reference', () => {
|
|
const { s } = setup();
|
|
setCell(s, 'Tabelle 1', 'B8', '=SUM(B:B)');
|
|
const v = val(s, 'Tabelle 1', 'B8');
|
|
expect(isError(v) && v.code).toBe('#CIRC');
|
|
});
|
|
|
|
it('recalculates when a cell in the column changes, and picks up rows typed past the end', () => {
|
|
const { s } = setup();
|
|
setCell(s, 'Tabelle 1', 'E1', '=SUM(B:B)');
|
|
setCell(s, 'Tabelle 1', 'B3', '12');
|
|
expect(val(s, 'Tabelle 1', 'E1')).toBe(16);
|
|
setCell(s, 'Tabelle 1', 'B20', '100'); // beyond the 12 rows -> the table grows
|
|
expect(val(s, 'Tabelle 1', 'E1')).toBe(116);
|
|
});
|
|
|
|
it('works qualified from another table', () => {
|
|
const { s } = setup();
|
|
const other = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(other)!.name;
|
|
setCell(s, name, 'A1', '=SUM(Tabelle 1!B:B)'.replace('Tabelle 1', "'Tabelle 1'"));
|
|
expect(val(s, name, 'A1')).toBe(6);
|
|
});
|
|
|
|
it('inserting/deleting columns moves and shrinks B:B, rows leave it alone', () => {
|
|
const { s, t } = setup();
|
|
setCell(s, 'Tabelle 1', 'F1', '=SUM(B:B)');
|
|
s.insertColumn(t, 0); // before A: B -> C, and the formula itself moves F -> G
|
|
expect(input(s, t, 'G1')).toBe('=SUM(C:C)');
|
|
expect(val(s, 'Tabelle 1', 'G1')).toBe(6);
|
|
s.insertRow(t, 0);
|
|
expect(input(s, t, 'G2')).toBe('=SUM(C:C)');
|
|
s.deleteColumn(t, 2); // delete column C itself -> #REF
|
|
expect(input(s, t, 'F2')).toBe('=SUM(#REF)');
|
|
});
|
|
|
|
it('inserting a row moves a whole-row reference', () => {
|
|
const { s, t } = setup();
|
|
setCell(s, 'Tabelle 1', 'F1', '=SUM($2:$2)');
|
|
s.insertRow(t, 1); // before row 2
|
|
expect(input(s, t, 'F1')).toBe('=SUM($3:$3)');
|
|
expect(val(s, 'Tabelle 1', 'F1')).toBe(31);
|
|
});
|
|
|
|
it('copying shifts a relative whole column but not a $-locked one', () => {
|
|
const { s, t } = setup();
|
|
setCell(s, 'Tabelle 1', 'E1', '=SUM(B:B)+SUM($B:$B)+SUM(2:2)');
|
|
s.setSelection({ tableId: t, anchor: coords('E1'), focus: coords('E1') });
|
|
s.copySelection();
|
|
s.setSelection({ tableId: t, anchor: coords('F2'), focus: coords('F2') });
|
|
s.pasteClipboard();
|
|
expect(input(s, t, 'F2')).toBe('=SUM(C:C)+SUM($B:$B)+SUM(3:3)');
|
|
});
|
|
|
|
it('renaming the table rewrites a qualified whole-column reference', () => {
|
|
const { s, t } = setup();
|
|
const other = s.addTable(s.doc.activeSheetId);
|
|
const name = s.tableById(other)!.name;
|
|
setCell(s, name, 'A1', "=SUM('Tabelle 1'!B:B)");
|
|
setCell(s, name, 'A2', "='Tabelle 1'!B2*2");
|
|
s.renameObject(t, 'Daten');
|
|
expect(s.cellInput(other, 0, 0)).toBe('=SUM(Daten!B:B)');
|
|
expect(s.cellInput(other, 0, 1)).toBe('=Daten!B2*2'); // quoted name: whole token replaced
|
|
expect(val(s, name, 'A1')).toBe(6);
|
|
});
|
|
});
|
|
|
|
describe('references of the formula being edited (coloured, draggable)', () => {
|
|
afterEach(() => useDocument().registerFormulaEditor(null));
|
|
|
|
/** Like mockEditor(), but its text is a ref -- as in the real editors,
|
|
* which is what keeps store.formulaRefs live. */
|
|
function reactiveEditor(tableId: string, initialText: string) {
|
|
const text = ref(initialText);
|
|
return {
|
|
handle: {
|
|
tableId,
|
|
getState: () => ({ text: text.value, start: text.value.length, end: text.value.length }),
|
|
replace: (s: number, e: number, value: string) => {
|
|
text.value = text.value.slice(0, s) + value + text.value.slice(e);
|
|
},
|
|
},
|
|
text,
|
|
};
|
|
}
|
|
|
|
it('lists each reference with its table, rectangle and colour, following the text', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const other = s.addTable(s.doc.activeSheetId);
|
|
s.renameObject(other, 'Reisen');
|
|
const ed = reactiveEditor(id, '=SUMME(B2:C3)+A1+Reisen!A2+B2:C3');
|
|
s.registerFormulaEditor(ed.handle);
|
|
const refs = s.formulaRefs.value;
|
|
expect(refs.map((r) => [r.tableId, r.c0, r.r0, r.c1, r.r1])).toEqual([
|
|
[id, 1, 1, 2, 2],
|
|
[id, 0, 0, 0, 0],
|
|
[other, 0, 1, 0, 1],
|
|
[id, 1, 1, 2, 2],
|
|
]);
|
|
expect(refs[0].color).not.toBe(refs[1].color);
|
|
expect(refs[3].color).toBe(refs[0].color); // same reference, same colour
|
|
ed.text.value = '=D4';
|
|
expect(s.formulaRefs.value.map((r) => [r.c0, r.r0])).toEqual([[3, 3]]);
|
|
s.registerFormulaEditor(null);
|
|
expect(s.formulaRefs.value).toEqual([]);
|
|
});
|
|
|
|
it('whole columns are clamped to the table and marked as not draggable', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
s.registerFormulaEditor(reactiveEditor(id, '=SUMME(B:B)').handle);
|
|
const [r] = s.formulaRefs.value;
|
|
expect(r).toMatchObject({ c0: 1, r0: 0, c1: 1, r1: s.tableById(id)!.rows - 1, whole: true });
|
|
expect(s.moveFormulaRef(0, 2, 0, 2, 3)).toBe(false);
|
|
});
|
|
|
|
it('moveFormulaRef rewrites one reference, keeping qualifier and $ locks', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.activeSheetId);
|
|
const other = s.addTable(s.doc.activeSheetId);
|
|
s.renameObject(other, 'Reisen');
|
|
const ed = reactiveEditor(id, '=SUMME($A$1:B2)*Reisen!C3');
|
|
s.registerFormulaEditor(ed.handle);
|
|
s.moveFormulaRef(0, 1, 1, 2, 3); // moved/resized
|
|
expect(ed.text.value).toBe('=SUMME($B$2:C4)*Reisen!C3');
|
|
s.moveFormulaRef(1, 0, 0, 1, 1); // a single cell grown into a range
|
|
expect(ed.text.value).toBe('=SUMME($B$2:C4)*Reisen!A1:B2');
|
|
s.moveFormulaRef(0, 3, 3, 3, 3); // shrunk to one cell
|
|
expect(ed.text.value).toBe('=SUMME($D$4)*Reisen!A1:B2');
|
|
});
|
|
});
|