Files
numolus/app/frontend/io/__tests__/roundtrip.spec.ts
wkhandClaude Opus 5.5 e14bada114 Add Rails backend: accounts via magic link, server documents, sharing
The app becomes a Rails 8 application; the Vue frontend moves to
app/frontend and is served through vite_rails.

- Without an account documents stay in the browser (IndexedDB). Sign-in
  is by magic link or the 6-digit code from the same mail; afterwards
  local documents can be uploaded into the account.
- Server documents (/d/<id>) with versioned saves and a single-editor
  lock per browser tab; others see them read-only and follow by polling.
- Sharing via link (works without an account) and via e-mail invitation,
  roles viewer/editor; the owner manages links and people.
- Store gains a read-only mode that blocks all content changes.
- Solid Queue, Cache and Cable share the one PostgreSQL database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:09:06 +02:00

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');
});
});