Files
numolus/app/frontend/collab/__tests__/ydoc.spec.ts
T
wkhandClaude Opus 5.5 7fce972fdc Live collaboration with Yjs over ActionCable
All editors now work on a server document at the same time instead of
taking turns behind an edit lock.

- The document's content is a sequence of Yjs updates (document_updates);
  DocumentChannel stores and relays them immediately, checks access on
  subscribe and again whenever links or memberships change.
- app/frontend/collab/: the store stays position-based; a binding maps it
  to a Yjs form with stable row/column ids, so a row inserted by one
  person doesn't misplace what another is typing. Remote changes are
  applied only after pending local ones went into Yjs.
- Per-person undo (Y.UndoManager), presence via awareness: the others'
  selections as coloured boxes with names, avatars in the toolbar.
- Existing JSON documents convert on first open, deterministically, so
  two people opening one at once end up with the same Yjs state.
- Editors compact the stored updates after 200 and keep a JSON snapshot
  current for export and duplicating. Edit lock and polling are gone.

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

179 lines
6.2 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import * as Y from 'yjs';
import { Binding, initialUpdate, readDocument, REMOTE } from '../ydoc';
import type { DocumentState, TableObject } from '../../store/types';
import { createSeedDocument } from '../../data/seed';
function docWithTable(cells: Record<string, string>, rows = 5, cols = 3): DocumentState {
const t: TableObject = {
kind: 'table',
id: 't1',
name: 'Tabelle 1',
x: 0,
y: 0,
rows,
cols,
colWidths: { 1: 120 },
rowHeights: { 2: 40 },
cells,
formats: { '0,0': { bold: true } },
view: { mode: 'auto', width: 640, height: 320, frozenRows: 0, frozenCols: 0 },
};
return {
version: 2,
sheets: [{ id: 's1', name: 'Blatt 1', objects: [t] }],
activeSheetId: 's1',
activeObjectId: null,
selection: null,
locale: 'de-DE',
csvDelimiter: ',',
customFunctions: [],
namedRanges: [],
macros: [],
};
}
const table = (d: DocumentState) => d.sheets[0].objects[0] as TableObject;
const clone = <T>(x: T): T => JSON.parse(JSON.stringify(x));
/** two people on the same document, updates exchanged explicitly */
function pair(start: DocumentState) {
const a = new Y.Doc();
const b = new Y.Doc();
Y.applyUpdate(a, initialUpdate(start), REMOTE);
Y.applyUpdate(b, initialUpdate(start), REMOTE);
const exchange = () => {
Y.applyUpdate(b, Y.encodeStateAsUpdate(a, Y.encodeStateVector(b)), REMOTE);
Y.applyUpdate(a, Y.encodeStateAsUpdate(b, Y.encodeStateVector(a)), REMOTE);
};
return { a, b, ba: new Binding(a), bb: new Binding(b), exchange };
}
describe('Yjs document form', () => {
it('round-trips the whole document (seed)', () => {
const seed = createSeedDocument();
const y = new Y.Doc();
Y.applyUpdate(y, initialUpdate(seed));
const back = readDocument(y);
const strip = (d: DocumentState) => ({
...d,
activeSheetId: '',
activeObjectId: null,
selection: null,
sheets: d.sheets.map((s) => ({ ...s, zoom: undefined })),
});
expect(JSON.parse(JSON.stringify(strip(back)))).toEqual(JSON.parse(JSON.stringify(strip(seed))));
});
it('initial update is deterministic, so two conversions merge into one', () => {
const d = docWithTable({ '0,0': 'x' });
expect(initialUpdate(d)).toEqual(initialUpdate(d));
const y = new Y.Doc();
Y.applyUpdate(y, initialUpdate(d));
Y.applyUpdate(y, initialUpdate(d));
expect(table(readDocument(y)).cells).toEqual({ '0,0': 'x' });
});
it('edits to different cells by two people merge', () => {
const { a, b, ba, bb, exchange } = pair(docWithTable({}));
const da = readDocument(a);
table(da).cells['0,0'] = 'Anna';
ba.sync(da);
const db = readDocument(b);
table(db).cells['2,4'] = 'Bob';
bb.sync(db);
exchange();
expect(table(readDocument(a)).cells).toEqual({ '0,0': 'Anna', '2,4': 'Bob' });
expect(readDocument(b)).toEqual(readDocument(a));
});
it('a row inserted by one person does not misplace what the other types', () => {
const start = docWithTable({ '0,3': 'row 4 before' });
const { a, b, ba, bb, exchange } = pair(start);
// Anna inserts a row at index 1 (the store shifts cells, and reports it)
const da = readDocument(a);
const ta = table(da);
ta.cells = { '0,4': 'row 4 before' };
ta.rowHeights = { 3: 40 };
ta.rows += 1;
ba.structure('t1', 'row', 1, 1);
ba.sync(da);
// meanwhile Bob, not having seen that, types next to "row 4 before"
const db = readDocument(b);
table(db).cells['1,3'] = 'Bob';
bb.sync(db);
exchange();
const cells = table(readDocument(a)).cells;
expect(cells).toEqual({ '0,4': 'row 4 before', '1,4': 'Bob' });
expect(table(readDocument(a)).rows).toBe(6);
expect(table(readDocument(a)).rowHeights).toEqual({ 3: 40 });
expect(readDocument(b)).toEqual(readDocument(a));
});
it('deleting a column removes its cells and widths', () => {
const { a, ba } = pair(docWithTable({ '0,0': 'a', '1,0': 'b', '2,0': 'c' }));
const d = readDocument(a);
const t = table(d);
t.cells = { '0,0': 'a', '1,0': 'c' };
t.colWidths = {};
t.cols = 2;
ba.structure('t1', 'col', 1, -1);
ba.sync(d);
const back = table(readDocument(a));
expect(back.cells).toEqual({ '0,0': 'a', '1,0': 'c' });
expect(back.colWidths).toEqual({});
expect(back.cols).toBe(2);
});
it('growing and shrinking at the edge needs no structure event', () => {
const { a, ba } = pair(docWithTable({ '0,4': 'last' }));
const d = readDocument(a);
table(d).rows = 8;
table(d).cells['0,7'] = 'new last';
ba.sync(d);
expect(table(readDocument(a)).rows).toBe(8);
const d2 = readDocument(a);
table(d2).rows = 5;
delete table(d2).cells['0,7'];
ba.sync(d2);
expect(table(readDocument(a)).rows).toBe(5);
expect(table(readDocument(a)).cells).toEqual({ '0,4': 'last' });
});
it('sheets, objects and lists: add, change, reorder, remove', () => {
const { a, b, ba, exchange } = pair(docWithTable({}));
const d = clone(readDocument(a));
d.sheets.push({ id: 's2', name: 'Zwei', objects: [] });
d.sheets[0].objects.push({ kind: 'text', id: 'x1', name: 'Text', x: 1, y: 2, width: 10, height: 10, text: 'Hi', fontSize: 14, align: 'left' });
d.customFunctions.push({ id: 'f1', name: 'DOPPELT', params: ['x'], body: 'x*2', kind: 'expression' });
ba.sync(d);
exchange();
const got = readDocument(b);
expect(got.sheets.map((s) => s.id)).toEqual(['s1', 's2']);
expect(got.sheets[0].objects.map((o) => o.id)).toEqual(['t1', 'x1']);
expect(got.customFunctions[0].name).toBe('DOPPELT');
const d2 = clone(readDocument(a));
d2.sheets.reverse();
d2.sheets[1].objects.reverse(); // z-order
d2.customFunctions = [];
ba.sync(d2);
exchange();
const got2 = readDocument(b);
expect(got2.sheets.map((s) => s.id)).toEqual(['s2', 's1']);
expect(got2.sheets[1].objects.map((o) => o.id)).toEqual(['x1', 't1']);
expect(got2.customFunctions).toEqual([]);
});
it('a sync without changes produces no update', () => {
const { a, ba } = pair(docWithTable({ '0,0': 'x' }));
let updates = 0;
a.on('update', () => updates++);
ba.sync(readDocument(a));
expect(updates).toBe(0);
});
});