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>
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user