A first start (nothing in localStorage) now opens a blank document -- one sheet "Blatt 1" with an empty "Tabelle 1" -- instead of the pre-filled sample. The file menu gets "Neu" (blank document) and "Muster · Umsatzanalyse" (the former seed), both behind a confirm and both starting a fresh undo history. The toolbar's "Zurücksetzen" button is gone; its job is now Datei › Muster. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2895 lines
113 KiB
TypeScript
2895 lines
113 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
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('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('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('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' },
|
|
]);
|
|
});
|
|
});
|