179 lines
5.9 KiB
TypeScript
179 lines
5.9 KiB
TypeScript
/**
|
|
* Import/export acceptance checks at the pure (de)serialisation layer -- the
|
|
* `<a download>` glue is trivial and browser-only, so it is not exercised here.
|
|
*/
|
|
import { describe, expect, it } from 'vitest';
|
|
import { useDocument, type DocumentStore } from '../../store/document';
|
|
import { isError } from '../../engine';
|
|
import { serializeDocument, parseDocument, JsonImportError } from '../json';
|
|
import { tableToCsv, parseCsv, matrixToCells } from '../csv';
|
|
import { importJsonReplace } from '../operations';
|
|
|
|
function fresh(): DocumentStore {
|
|
const s = useDocument();
|
|
s.resetToSeed();
|
|
return s;
|
|
}
|
|
function tableId(s: DocumentStore, name: string): string {
|
|
for (const sh of s.doc.sheets) {
|
|
const o = sh.objects.find((x) => x.kind === 'table' && x.name === name);
|
|
if (o) return o.id;
|
|
}
|
|
throw new Error(name);
|
|
}
|
|
const coords = (a1: string) => {
|
|
const m = /^([A-Z]+)([0-9]+)$/.exec(a1)!;
|
|
return {
|
|
col: m[1].split('').reduce((n, c) => n * 26 + (c.charCodeAt(0) - 64), 0) - 1,
|
|
row: Number(m[2]) - 1,
|
|
};
|
|
};
|
|
|
|
describe('JSON native format', () => {
|
|
it('round-trips a document losslessly (export → reset → import → identical)', () => {
|
|
const s = fresh();
|
|
const before = s.snapshot();
|
|
const json = serializeDocument(before);
|
|
|
|
s.addSheet('scratch');
|
|
s.setCellInput(tableId(s, 'Umsatz'), 1, 1, '999999');
|
|
|
|
s.replaceDocument(parseDocument(json));
|
|
expect(s.snapshot()).toEqual(before);
|
|
});
|
|
|
|
it('rejects corrupt / empty input and leaves the document untouched', () => {
|
|
const s = fresh();
|
|
const before = s.snapshot();
|
|
|
|
expect(() => importJsonReplace(s, '{ not json')).toThrow(JsonImportError);
|
|
expect(() => importJsonReplace(s, JSON.stringify({ version: 2, sheets: [] }))).toThrow(
|
|
JsonImportError,
|
|
);
|
|
expect(() => importJsonReplace(s, JSON.stringify({ version: 7 }))).toThrow(JsonImportError);
|
|
|
|
expect(s.snapshot()).toEqual(before);
|
|
});
|
|
|
|
it('an unknown table name in an imported formula yields #REF, no crash', () => {
|
|
const s = fresh();
|
|
const doc = {
|
|
version: 2,
|
|
sheets: [
|
|
{
|
|
id: 's1',
|
|
name: 'S1',
|
|
objects: [
|
|
{
|
|
kind: 'table',
|
|
id: 't1',
|
|
name: 'T1',
|
|
x: 0,
|
|
y: 0,
|
|
rows: 4,
|
|
cols: 4,
|
|
colWidths: {},
|
|
cells: { '0,0': '=Nirgends!A1', '1,0': '=A1+1' },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
activeSheetId: 's1',
|
|
activeObjectId: null,
|
|
selection: null,
|
|
};
|
|
importJsonReplace(s, JSON.stringify(doc));
|
|
const a1 = s.cellValue('t1', 0, 0);
|
|
const b1 = s.cellValue('t1', 1, 0);
|
|
expect(isError(a1) && a1.code).toBe('#REF');
|
|
expect(isError(b1) && b1.code).toBe('#REF'); // error propagates
|
|
});
|
|
|
|
it('normalises a v2 doc saved by an older build (missing table.view)', () => {
|
|
const stale = {
|
|
version: 2,
|
|
sheets: [
|
|
{
|
|
id: 's1',
|
|
name: 'S1',
|
|
objects: [
|
|
// no `view` key -- as written before the frozen-panes feature
|
|
{ kind: 'table', id: 't1', name: 'T1', x: 10, y: 10, rows: 8, cols: 5, colWidths: {}, cells: {} },
|
|
],
|
|
},
|
|
],
|
|
activeSheetId: 's1',
|
|
activeObjectId: null,
|
|
selection: null,
|
|
};
|
|
const doc = parseDocument(JSON.stringify(stale));
|
|
const t = doc.sheets[0].objects[0];
|
|
expect(t.kind).toBe('table');
|
|
if (t.kind === 'table') {
|
|
expect(t.view).toEqual({ mode: 'auto', width: 640, height: 320, frozenRows: 0, frozenCols: 0 });
|
|
}
|
|
});
|
|
|
|
it('migrates a v1 document on import', () => {
|
|
const s = fresh();
|
|
const v1 = JSON.stringify({
|
|
version: 1,
|
|
sheets: [
|
|
{ id: 'a', name: 'Zahlen', rows: 5, cols: 3, colWidths: {}, cells: { '0,0': '10', '0,1': '=A1*3' } },
|
|
],
|
|
activeSheetId: 'a',
|
|
selection: null,
|
|
});
|
|
importJsonReplace(s, v1);
|
|
expect(s.doc.version).toBe(2);
|
|
const id = tableId(s, 'Zahlen');
|
|
expect(s.cellValue(id, 0, 1)).toBe(30);
|
|
});
|
|
});
|
|
|
|
describe('CSV', () => {
|
|
it('exports computed values of "Übersicht" and re-imports to the same numbers', () => {
|
|
const s = fresh();
|
|
const src = tableId(s, 'Übersicht');
|
|
const csv = tableToCsv(s, src, { delimiter: ',', bom: false, crlf: true });
|
|
|
|
expect(csv.includes('\r\n')).toBe(true);
|
|
// header row is text, body carries the computed numbers
|
|
expect(csv.split('\r\n')[0]).toBe('Region,Umsatz Jahr,Kosten Jahr,Deckungsbeitrag,Marge %');
|
|
|
|
const { cells, rows, cols } = matrixToCells(parseCsv(csv, ','), true);
|
|
const copyId = s.addTableFromCells(s.doc.sheets[1].id, 'Kopie', cells, rows, cols);
|
|
|
|
for (const a1 of ['B2', 'C2', 'D2', 'B8', 'C8', 'D8']) {
|
|
const { col, row } = coords(a1);
|
|
expect(s.cellValue(copyId, col, row)).toBe(s.cellValue(src, col, row));
|
|
}
|
|
});
|
|
|
|
it('RFC-4180 quoting: a field with a comma is wrapped and survives the round-trip', () => {
|
|
const s = fresh();
|
|
const id = s.addTable(s.doc.sheets[1].id);
|
|
s.setCellInput(id, 0, 0, 'Müller, Hans');
|
|
s.setCellInput(id, 1, 0, 'x"y');
|
|
const csv = tableToCsv(s, id, { delimiter: ',', bom: false, crlf: true });
|
|
expect(csv).toContain('"Müller, Hans"');
|
|
expect(csv).toContain('"x""y"');
|
|
const [line] = parseCsv(csv, ',');
|
|
expect(line).toEqual(['Müller, Hans', 'x"y']);
|
|
});
|
|
|
|
it('a CSV cell "=1+2" becomes a formula that evaluates to 3', () => {
|
|
const s = fresh();
|
|
const { cells, rows, cols } = matrixToCells(parseCsv('=1+2\n', ','), true);
|
|
const id = s.addTableFromCells(s.doc.sheets[1].id, 'Rechnen', cells, rows, cols);
|
|
expect(s.cellValue(id, 0, 0)).toBe(3);
|
|
});
|
|
|
|
it('locale number detection (de vs en)', () => {
|
|
const { cells } = matrixToCells(parseCsv('1.234,56;7,89;1,234.50\n', ';'), true);
|
|
expect(cells['0,0']).toBe('1234.56');
|
|
expect(cells['1,0']).toBe('7.89');
|
|
expect(cells['2,0']).toBe('1234.50');
|
|
});
|
|
});
|