Files
numolus/src/store/__tests__/document.spec.ts
T
wkhandClaude Sonnet 5 1046b22e96 Show the selected range, not just the active cell, in status/formula bar
New store computed selectionRangeName: for a single cell it's identical
to activeCellName ("Umsatz::B2"), for a multi-cell selection it shows
the full primary rectangle ("Umsatz::B2:E6"), and a discontiguous
multi-selection gets a "+N" suffix for the further rectangles.
activeCellName itself is untouched (still anchor-only, since that's what
a formula/edit actually targets) -- only the formula bar's name field and
the status bar's range label switch to the new computed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 15:49:35 +02:00

1284 lines
50 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('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('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 + 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.setCsvDelimiter(';');
const before = s.snapshot();
s.replaceDocument(parseDocument(serializeDocument(before)));
expect(s.snapshot()).toEqual(before);
expect(s.doc.csvDelimiter).toBe(';');
const doc = JSON.parse(serializeDocument(s.snapshot()));
doc.sheets[0].background = 'not-a-color';
doc.sheets[0].zoom = 99;
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.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('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 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('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('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);
});
});
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('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.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
s.addCustomFunction('MARGE', ['revenue', 'cost'], '(revenue - cost) / revenue');
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 },
});
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 function
expect(doc.customFunctions).toHaveLength(1);
expect(doc.customFunctions[0]).toMatchObject({
name: 'MARGE',
params: ['revenue', 'cost'],
body: '(revenue - cost) / revenue',
});
});
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);
});
});