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('custom functions: "block" kind (FOR loops)', () => { const productBody = 'ergebnis = 1 FOR x IN bereich DO ergebnis = ergebnis * x RETURN ergebnis'; it('adds a block-kind function and a call evaluates through the unrolled 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 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 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 RETURN bereich', '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 RETURN total', 'block', ) as { error: string }; expect(result.error).toBeTruthy(); }); it('rejects malformed code (missing RETURN)', () => { const s = fresh(); const result = s.addCustomFunction('F', ['bereich'], 'ergebnis = 1', 'block') as { error: string; }; expect(result.error).toBeTruthy(); }); 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) RETURN total', 'block', ); setCell(s, name, 'B1', '=SUMDOUBLED(A1:A2)'); expect(val(s, name, 'B1')).toBe(2 * 2 + 3 * 2); }); 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) 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('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('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('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 -- 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 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 }, }); 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' }, ]); }); });