A custom function body could previously only be a single expression -- no way to iterate over a range's elements to build something like PRODUCT (multiply every cell together), since none of the built-ins (SUM/AVG/MIN/MAX/COUNT) do that and a body can only compose existing functions, not define a new reduction. This adds a second body kind, 'block': a sequence of `name = expression` assignments and `FOR var IN source DO name = expression` loops, ending in `RETURN expression`. Flat scope -- every introduced name stays visible for the rest of the body, including after its own loop, holding its last iteration's value. No runtime loop in the evaluator: a loop's source size is already known once the caller's real argument is substituted in, so userFunctions.ts's new compileBlock() unrolls every FOR loop into a single nested expression (a left fold) at the same parse-time inlining point Phase 1 already used -- the result is exactly the same shape of static AST as any other inlined function call, so evaluator.ts and graph.ts need no changes, dependency edges on every unrolled cell are picked up automatically, and a bounded MAX_UNROLL_STEPS (10,000) keeps it crash/hang-free. parser.ts's parseFunctionBlock() parses the new grammar (a purely additive EQUALS lexer token; FOR/IN/DO/RETURN are plain case-matched IDENTs, not new reserved tokens) into a BlockNode kept outside the existing Node union, so no existing switch needs a new case. Store: CustomFunction/FunctionDef gain `kind: 'expression' | 'block'` (defaulted for every function saved before this existed, including via JSON import). validateCustomFunction validates the new grammar's local/ loop-variable names the same way as parameters (not cell-shaped, no reserved words, no shadowing a parameter -- but reusing the same local across statements, the accumulator pattern, is correctly not a collision) and that every FOR source is structurally a parameter, cell, or range. UI: a "𝑓 Funktionen" form toggle between "Einfache Formel" and "Mit Schleife", with its own placeholder/hint (a PRODUCT example) and a small badge on block-kind rows in the list. 237 tests passing (34 new), vue-tsc/eslint/build clean. Verified live in the running app: defined PRODUCT via the loop UI, called =PRODUCT(Umsatz!B2:E6), confirmed the correct value and recalculation on a change to a cell inside the range, and an inline rejection for an invalid FOR source at save time. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1545 lines
60 KiB
TypeScript
1545 lines
60 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('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' },
|
|
]);
|
|
});
|
|
});
|