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, 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 = (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); }); });