- init
This commit is contained in:
@@ -0,0 +1,540 @@
|
||||
import { describe, expect, it } 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);
|
||||
expect(s.activeCellName.value).toBe(`Umsatz::${toA1(1, 5)}`);
|
||||
});
|
||||
|
||||
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('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('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' });
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,107 @@
|
||||
import { isError, type CellValue } from '../engine';
|
||||
import type { CellNumFmt } from './types';
|
||||
|
||||
/**
|
||||
* Locale used to render numeric values. `'plain'` is not a real locale -- it
|
||||
* renders the bare JS number (`.` decimal, no grouping).
|
||||
*
|
||||
* A *display* setting only. `rawString()` (CSV / XLSX export) stays
|
||||
* locale-neutral so round-trips are lossless.
|
||||
*/
|
||||
export type NumberLocale =
|
||||
| 'plain'
|
||||
| 'de-DE'
|
||||
| 'de-CH'
|
||||
| 'en-US'
|
||||
| 'en-GB'
|
||||
| 'fr-FR'
|
||||
| 'it-IT';
|
||||
|
||||
export const NUMBER_LOCALES: ReadonlyArray<{ id: NumberLocale; label: string }> = [
|
||||
{ id: 'plain', label: 'Ohne Trennzeichen · 1234.5' },
|
||||
{ id: 'de-DE', label: 'Deutsch (DE) · 1.234,5' },
|
||||
{ id: 'de-CH', label: 'Schweiz (CH) · 1’234.5' },
|
||||
{ id: 'en-US', label: 'English (US) · 1,234.5' },
|
||||
{ id: 'en-GB', label: 'English (UK) · 1,234.5' },
|
||||
{ id: 'fr-FR', label: 'Français (FR) · 1 234,5' },
|
||||
{ id: 'it-IT', label: 'Italiano (IT) · 1.234,5' },
|
||||
];
|
||||
|
||||
const LOCALE_IDS = new Set(NUMBER_LOCALES.map((l) => l.id));
|
||||
export function isNumberLocale(x: unknown): x is NumberLocale {
|
||||
return typeof x === 'string' && LOCALE_IDS.has(x as NumberLocale);
|
||||
}
|
||||
export const DEFAULT_LOCALE: NumberLocale = 'de-DE';
|
||||
|
||||
/** Cell number-format options offered in the UI. */
|
||||
export const NUM_FORMATS: ReadonlyArray<{ id: CellNumFmt; label: string }> = [
|
||||
{ id: 'auto', label: 'Automatisch' },
|
||||
{ id: 'plain', label: 'Ohne Formatierung' },
|
||||
{ id: 'int', label: 'Ganzzahl · 1.235' },
|
||||
{ id: 'dec1', label: '1 Nachkommastelle · 1.234,6' },
|
||||
{ id: 'dec2', label: '2 Nachkommastellen · 1.234,56' },
|
||||
{ id: 'pct0', label: 'Prozent · 12 %' },
|
||||
{ id: 'pct2', label: 'Prozent · 12,34 %' },
|
||||
];
|
||||
const NUM_FMT_IDS = new Set(NUM_FORMATS.map((f) => f.id));
|
||||
export function isCellNumFmt(x: unknown): x is CellNumFmt {
|
||||
return typeof x === 'string' && NUM_FMT_IDS.has(x as CellNumFmt);
|
||||
}
|
||||
|
||||
const round10 = (n: number) => Math.round(n * 1e10) / 1e10;
|
||||
|
||||
function nf(locale: NumberLocale, opts: Intl.NumberFormatOptions): Intl.NumberFormat {
|
||||
// for the pseudo-locale 'plain' use en-US digits but turn grouping off
|
||||
return new Intl.NumberFormat(locale === 'plain' ? 'en-US' : locale, {
|
||||
useGrouping: locale !== 'plain',
|
||||
...opts,
|
||||
});
|
||||
}
|
||||
|
||||
/** Render a computed value for display (grid cell / status bar / chart). */
|
||||
export function formatValue(
|
||||
v: CellValue,
|
||||
locale: NumberLocale = DEFAULT_LOCALE,
|
||||
numFmt: CellNumFmt = 'auto',
|
||||
): string {
|
||||
if (v === null) return '';
|
||||
if (isError(v)) return v.code;
|
||||
if (typeof v !== 'number') return v;
|
||||
if (!Number.isFinite(v)) return '#NUM!';
|
||||
|
||||
const n = round10(v);
|
||||
switch (numFmt) {
|
||||
case 'plain':
|
||||
return String(n);
|
||||
case 'int':
|
||||
return nf(locale, { maximumFractionDigits: 0 }).format(n);
|
||||
case 'dec1':
|
||||
return nf(locale, { minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(n);
|
||||
case 'dec2':
|
||||
return nf(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n);
|
||||
case 'pct0':
|
||||
return nf(locale, { style: 'percent', maximumFractionDigits: 0 }).format(n);
|
||||
case 'pct2':
|
||||
return nf(locale, {
|
||||
style: 'percent',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(n);
|
||||
default: // 'auto'
|
||||
return locale === 'plain'
|
||||
? String(n)
|
||||
: nf(locale, { maximumFractionDigits: 10 }).format(n);
|
||||
}
|
||||
}
|
||||
|
||||
/** Plain (ungrouped, `.`-decimal) string -- machine-readable, for CSV / XLSX. */
|
||||
export function rawString(v: CellValue): string {
|
||||
if (v === null) return '';
|
||||
if (isError(v)) return v.code;
|
||||
if (typeof v === 'number') return String(round10(v));
|
||||
return v;
|
||||
}
|
||||
|
||||
export function isNumeric(v: CellValue): boolean {
|
||||
return typeof v === 'number' && Number.isFinite(v);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Persisted / JSON-exported document shape. **Version 2** — Numbers-style
|
||||
* canvas model: a sheet is a free surface holding table, chart and text *objects*.
|
||||
*
|
||||
* (Version 1 was one grid per sheet; `io/json.ts` migrates v1 -> v2 on import.)
|
||||
*/
|
||||
|
||||
import type { NumberLocale } from './format';
|
||||
|
||||
export interface CellCoord {
|
||||
col: number;
|
||||
row: number;
|
||||
}
|
||||
|
||||
export type HAlign = 'left' | 'center' | 'right';
|
||||
export type VAlign = 'top' | 'middle' | 'bottom';
|
||||
|
||||
/** Per-cell number-display rule, combined with the effective locale. */
|
||||
export type CellNumFmt =
|
||||
| 'auto'
|
||||
| 'plain'
|
||||
| 'int' // 0 decimals, grouped
|
||||
| 'dec1' // 1 decimal
|
||||
| 'dec2' // 2 decimals
|
||||
| 'pct0' // percent, 0 decimals (value 0.5 -> "50 %")
|
||||
| 'pct2'; // percent, 2 decimals
|
||||
|
||||
/** Explicit formatting for a single cell (all fields optional = inherit). */
|
||||
export interface CellFormat {
|
||||
align?: HAlign;
|
||||
valign?: VAlign;
|
||||
numFmt?: CellNumFmt;
|
||||
/** text colour (#rgb/#rrggbb/#rrggbbaa) */
|
||||
color?: string;
|
||||
/** fill colour */
|
||||
bg?: string;
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
}
|
||||
|
||||
/** How a table object is sized on the canvas. */
|
||||
export interface TableView {
|
||||
mode: 'auto' | 'fixed';
|
||||
width: number;
|
||||
height: number;
|
||||
frozenRows: number;
|
||||
frozenCols: number;
|
||||
}
|
||||
|
||||
export function defaultTableView(): TableView {
|
||||
return { mode: 'auto', width: 640, height: 320, frozenRows: 0, frozenCols: 0 };
|
||||
}
|
||||
|
||||
export const DEFAULT_ROW_H = 26;
|
||||
|
||||
/** A table object placed on a sheet canvas. */
|
||||
export interface TableObject {
|
||||
kind: 'table';
|
||||
id: string;
|
||||
/** document-unique name; the qualifier used in formulas (`Umsatz!B2`) */
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
rows: number;
|
||||
cols: number;
|
||||
/** column index -> pixel width (only non-default widths are stored) */
|
||||
colWidths: Record<number, number>;
|
||||
/** default row height (px); unset = DEFAULT_ROW_H */
|
||||
rowHeight?: number;
|
||||
/** row index -> pixel height override */
|
||||
rowHeights?: Record<number, number>;
|
||||
/** "col,row" -> raw user input (formula string or literal). Empty cells absent. */
|
||||
cells: Record<string, string>;
|
||||
/** "col,row" -> explicit cell formatting */
|
||||
formats?: Record<string, CellFormat>;
|
||||
/** sizing + frozen rows/cols */
|
||||
view: TableView;
|
||||
/** number-format locale override; falls back to the sheet, then the document */
|
||||
locale?: NumberLocale;
|
||||
/** object background colour override (grid surface) */
|
||||
bg?: string;
|
||||
/** object default text colour override */
|
||||
fg?: string;
|
||||
}
|
||||
|
||||
/** A chart object placed on a sheet canvas. Freely positioned and resized. */
|
||||
export interface ChartObject {
|
||||
kind: 'chart';
|
||||
id: string;
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
view: 'bar' | 'line';
|
||||
sourceTableId: string | null;
|
||||
locale?: NumberLocale;
|
||||
bg?: string;
|
||||
fg?: string;
|
||||
}
|
||||
|
||||
/** A free-floating formatted text box, a companion to tables and charts. */
|
||||
export interface TextObject {
|
||||
kind: 'text';
|
||||
id: string;
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
text: string;
|
||||
fontSize: number;
|
||||
align: HAlign;
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
color?: string;
|
||||
bg?: string;
|
||||
}
|
||||
|
||||
export type SheetObject = TableObject | ChartObject | TextObject;
|
||||
|
||||
export interface Sheet {
|
||||
id: string;
|
||||
name: string;
|
||||
/** array order == z-order; the last element renders in front */
|
||||
objects: SheetObject[];
|
||||
locale?: NumberLocale;
|
||||
/** canvas backdrop colour; unset = themed default */
|
||||
background?: string;
|
||||
/** default text colour for this sheet's grids/labels; unset = themed default */
|
||||
foreground?: string;
|
||||
/** show the canvas dot-grid; unset / true = shown */
|
||||
showGrid?: boolean;
|
||||
/** per-sheet canvas zoom (0.4 – 2); unset = 1 */
|
||||
zoom?: number;
|
||||
}
|
||||
|
||||
export interface SelectionRange {
|
||||
anchor: CellCoord;
|
||||
focus: CellCoord;
|
||||
}
|
||||
|
||||
export interface SelectionState {
|
||||
tableId: string;
|
||||
/** primary rectangle; `focus` is the active cell (formula bar / keyboard) */
|
||||
anchor: CellCoord;
|
||||
focus: CellCoord;
|
||||
/** additional rectangles for a discontiguous multi-selection */
|
||||
ranges?: SelectionRange[];
|
||||
}
|
||||
|
||||
export interface DocumentState {
|
||||
version: 2;
|
||||
sheets: Sheet[];
|
||||
activeSheetId: string;
|
||||
activeObjectId: string | null;
|
||||
selection: SelectionState | null;
|
||||
locale: NumberLocale;
|
||||
csvDelimiter: ',' | ';';
|
||||
}
|
||||
Reference in New Issue
Block a user