|
|
|
@@ -0,0 +1,201 @@
|
|
|
|
|
// Two people on one document: what Yjs merges, and what the janitor repairs
|
|
|
|
|
// after a merge (plan §5.3). Each case starts from one example in two
|
|
|
|
|
// replicas, changes them apart, exchanges their updates both ways and looks
|
|
|
|
|
// at the project they hold.
|
|
|
|
|
|
|
|
|
|
import { readFileSync } from 'node:fs';
|
|
|
|
|
import { dirname, join } from 'node:path';
|
|
|
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
|
import * as Y from 'yjs';
|
|
|
|
|
import { openProjectArchive, type ImportedProject } from '@mdg-filius/fls';
|
|
|
|
|
import { readProject, repair, ROOTS, writeProject } from '../src/index.js';
|
|
|
|
|
|
|
|
|
|
const corpus = join(dirname(fileURLToPath(import.meta.url)), '../../../corpus');
|
|
|
|
|
|
|
|
|
|
function opened(file: string): ImportedProject {
|
|
|
|
|
let counter = 0;
|
|
|
|
|
return openProjectArchive(new Uint8Array(readFileSync(join(corpus, file))), () => `00000000-0000-0000-0000-${(++counter).toString(16).padStart(12, '0')}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Two replicas of the example, alike. */
|
|
|
|
|
function replicas(file = 'java-examples/zwei_rechnernetze_komplett.fls'): [Y.Doc, Y.Doc] {
|
|
|
|
|
const first = new Y.Doc();
|
|
|
|
|
writeProject(first, opened(file));
|
|
|
|
|
const second = new Y.Doc();
|
|
|
|
|
Y.applyUpdate(second, Y.encodeStateAsUpdate(first));
|
|
|
|
|
return [first, second];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Each replica gets what the other has. */
|
|
|
|
|
function exchange(first: Y.Doc, second: Y.Doc): void {
|
|
|
|
|
const toSecond = Y.encodeStateAsUpdate(first, Y.encodeStateVector(second));
|
|
|
|
|
const toFirst = Y.encodeStateAsUpdate(second, Y.encodeStateVector(first));
|
|
|
|
|
Y.applyUpdate(second, toSecond);
|
|
|
|
|
Y.applyUpdate(first, toFirst);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const nodes = (doc: Y.Doc) => doc.getMap<Y.Map<unknown>>(ROOTS.nodes);
|
|
|
|
|
const links = (doc: Y.Doc) => doc.getMap<Y.Map<unknown>>(ROOTS.links);
|
|
|
|
|
const nodeNamed = (doc: Y.Doc, name: string) => [...nodes(doc).entries()].find(([, node]) => node.get('name') === name)!;
|
|
|
|
|
const settings = (doc: Y.Doc, name: string) => nodeNamed(doc, name)[1].get('settings') as Y.Map<Y.Map<unknown>>;
|
|
|
|
|
const files = (doc: Y.Doc, name: string) => nodeNamed(doc, name)[1].get('files') as Y.Map<Y.Map<unknown>>;
|
|
|
|
|
|
|
|
|
|
/** A switch's port no cable uses. */
|
|
|
|
|
function freePort(doc: Y.Doc, switchName: string): [string, string] {
|
|
|
|
|
const [id, node] = nodeNamed(doc, switchName);
|
|
|
|
|
const used = new Set([...links(doc).values()].flatMap((link) => [`${link.get('sourceNode')}/${link.get('sourcePort')}`, `${link.get('targetNode')}/${link.get('targetPort')}`]));
|
|
|
|
|
const port = (node.get('ports') as Y.Array<Y.Map<unknown>>).toArray().find((each) => !used.has(`${id}/${each.get('id')}`))!;
|
|
|
|
|
return [id, port.get('id') as string];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function plug(doc: Y.Doc, id: string, [sourceNode, sourcePort]: [string, string], [targetNode, targetPort]: [string, string]): void {
|
|
|
|
|
const link = new Y.Map<unknown>();
|
|
|
|
|
link.set('order', links(doc).size);
|
|
|
|
|
link.set('sourceNode', sourceNode);
|
|
|
|
|
link.set('sourcePort', sourcePort);
|
|
|
|
|
link.set('targetNode', targetNode);
|
|
|
|
|
link.set('targetPort', targetPort);
|
|
|
|
|
links(doc).set(id, link);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('two people on one document', () => {
|
|
|
|
|
it('keeps both changes to two fields of one device', () => {
|
|
|
|
|
const [first, second] = replicas();
|
|
|
|
|
settings(first, 'Notebook 1.10').get('device')!.set('ipAddress', '141.99.1.20');
|
|
|
|
|
settings(second, 'Notebook 1.10').get('device')!.set('defaultGateway', '141.99.1.2');
|
|
|
|
|
exchange(first, second);
|
|
|
|
|
for (const doc of [first, second]) {
|
|
|
|
|
const project = readProject(doc);
|
|
|
|
|
const id = project.graph.nodes.find((node) => node.displayName === 'Notebook 1.10')!.id;
|
|
|
|
|
expect(project.deviceConfigurations.get(id)).toMatchObject({ ipAddress: '141.99.1.20', defaultGateway: '141.99.1.2' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('keeps both rows two people add to a list at once', () => {
|
|
|
|
|
const [first, second] = replicas();
|
|
|
|
|
const row = (name: string) => {
|
|
|
|
|
const map = new Y.Map<unknown>();
|
|
|
|
|
map.set('name', name);
|
|
|
|
|
map.set('type', 'A');
|
|
|
|
|
map.set('ttlSeconds', 3600);
|
|
|
|
|
map.set('target', '141.99.1.10');
|
|
|
|
|
return map;
|
|
|
|
|
};
|
|
|
|
|
const records = (doc: Y.Doc) => settings(doc, 'DNS-Server 2.10').get('dnsServer')!.get('records') as Y.Array<Y.Map<unknown>>;
|
|
|
|
|
records(first).push([row('eins.filius.de')]);
|
|
|
|
|
records(second).push([row('zwei.filius.de')]);
|
|
|
|
|
exchange(first, second);
|
|
|
|
|
const names = (doc: Y.Doc) => {
|
|
|
|
|
const project = readProject(doc);
|
|
|
|
|
const id = project.graph.nodes.find((node) => node.displayName === 'DNS-Server 2.10')!.id;
|
|
|
|
|
return project.dnsServerConfigurations.get(id)!.records.map((record) => record.name);
|
|
|
|
|
};
|
|
|
|
|
expect(names(first)).toEqual(names(second));
|
|
|
|
|
expect(names(first)).toEqual(expect.arrayContaining(['www.filius.de', 'eins.filius.de', 'zwei.filius.de']));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('writes text two people type into one file together', () => {
|
|
|
|
|
const [first, second] = replicas();
|
|
|
|
|
const text = (doc: Y.Doc) => [...files(doc, 'Notebook 1.11').values()].find((entry) => entry.get('kind') === 'text')!.get('text') as Y.Text;
|
|
|
|
|
const before = text(first).toString();
|
|
|
|
|
text(first).insert(0, 'A');
|
|
|
|
|
text(second).insert(before.length, 'B');
|
|
|
|
|
exchange(first, second);
|
|
|
|
|
expect(text(first).toString()).toBe(`A${before}B`);
|
|
|
|
|
expect(text(second).toString()).toBe(`A${before}B`);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('the janitor', () => {
|
|
|
|
|
it('removes a cable to a device removed at the same time (I1)', () => {
|
|
|
|
|
const [first, second] = replicas();
|
|
|
|
|
const [notebook, notebookNode] = nodeNamed(second, 'Notebook 1.11');
|
|
|
|
|
const notebookPort = ((notebookNode.get('ports') as Y.Array<Y.Map<unknown>>).get(0).get('id') as string);
|
|
|
|
|
// One person removes the notebook with its cable, the other plugs a second cable into it.
|
|
|
|
|
first.transact(() => {
|
|
|
|
|
for (const [id, link] of links(first)) if (link.get('sourceNode') === notebook || link.get('targetNode') === notebook) links(first).delete(id);
|
|
|
|
|
nodes(first).delete(notebook);
|
|
|
|
|
});
|
|
|
|
|
plug(second, 'ffffffff-0000-0000-0000-000000000001', freePort(second, 'Switch 1'), [notebook, notebookPort]);
|
|
|
|
|
exchange(first, second);
|
|
|
|
|
expect(links(first).has('ffffffff-0000-0000-0000-000000000001')).toBe(true);
|
|
|
|
|
|
|
|
|
|
expect(repair(first)).toEqual({ danglingLinks: ['ffffffff-0000-0000-0000-000000000001'], sharedPortLinks: [], madeFolders: [] });
|
|
|
|
|
expect(links(first).has('ffffffff-0000-0000-0000-000000000001')).toBe(false);
|
|
|
|
|
// Idempotent: nothing left to do, and nothing written.
|
|
|
|
|
let updates = 0;
|
|
|
|
|
first.on('update', () => (updates += 1));
|
|
|
|
|
expect(repair(first)).toEqual({ danglingLinks: [], sharedPortLinks: [], madeFolders: [] });
|
|
|
|
|
expect(updates).toBe(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('keeps the cable with the smaller id where two were plugged into one port at once (I2)', () => {
|
|
|
|
|
const [first, second] = replicas();
|
|
|
|
|
const port = freePort(first, 'Switch 1');
|
|
|
|
|
plug(first, 'eeeeeeee-0000-0000-0000-000000000002', port, freePort(first, 'Switch 2'));
|
|
|
|
|
plug(second, 'eeeeeeee-0000-0000-0000-000000000001', port, freePort(second, 'Switch 2'));
|
|
|
|
|
exchange(first, second);
|
|
|
|
|
// Both replicas repair, as two janitors would: they end alike.
|
|
|
|
|
expect(repair(first).sharedPortLinks).toEqual(['eeeeeeee-0000-0000-0000-000000000002']);
|
|
|
|
|
expect(repair(second).sharedPortLinks).toEqual(['eeeeeeee-0000-0000-0000-000000000002']);
|
|
|
|
|
exchange(first, second);
|
|
|
|
|
expect(readProject(first)).toEqual(readProject(second));
|
|
|
|
|
expect(links(first).has('eeeeeeee-0000-0000-0000-000000000001')).toBe(true);
|
|
|
|
|
expect(links(first).has('eeeeeeee-0000-0000-0000-000000000002')).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('makes the folder of a file written into a folder removed at the same time (I4)', () => {
|
|
|
|
|
const [first, second] = replicas();
|
|
|
|
|
const folder = [...files(first, 'Notebook 1.11').entries()].find(([path, entry]) => entry.get('kind') === 'directory' && path.split('/').length === 2)![0];
|
|
|
|
|
first.transact(() => {
|
|
|
|
|
for (const path of [...files(first, 'Notebook 1.11').keys()]) if (path === folder || path.startsWith(`${folder}/`)) files(first, 'Notebook 1.11').delete(path);
|
|
|
|
|
});
|
|
|
|
|
const note = new Y.Map<unknown>();
|
|
|
|
|
note.set('order', 1000);
|
|
|
|
|
note.set('kind', 'text');
|
|
|
|
|
note.set('text', new Y.Text('neu'));
|
|
|
|
|
files(second, 'Notebook 1.11').set(`${folder}/sub/notiz.txt`, note);
|
|
|
|
|
exchange(first, second);
|
|
|
|
|
|
|
|
|
|
// Read tolerantly before the janitor: the file is left out, the project is whole.
|
|
|
|
|
const id = nodeNamed(first, 'Notebook 1.11')[0];
|
|
|
|
|
expect(readProject(first).virtualFileSystems.get(id)!.contains(`${folder}/sub/notiz.txt`)).toBe(false);
|
|
|
|
|
expect(repair(first).madeFolders).toEqual([`${id}:${folder}`, `${id}:${folder}/sub`]);
|
|
|
|
|
expect(readProject(first).virtualFileSystems.get(id)!.textFile(`${folder}/sub/notiz.txt`)).toBe('neu');
|
|
|
|
|
expect(repair(first).madeFolders).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('takes a folder in another case as the folder (I4)', () => {
|
|
|
|
|
const [doc] = replicas();
|
|
|
|
|
const [folder] = [...files(doc, 'Notebook 1.11').entries()].find(([path, entry]) => entry.get('kind') === 'directory' && path.split('/').length === 2)!;
|
|
|
|
|
const note = new Y.Map<unknown>();
|
|
|
|
|
note.set('order', 1000);
|
|
|
|
|
note.set('kind', 'text');
|
|
|
|
|
note.set('text', new Y.Text('neu'));
|
|
|
|
|
files(doc, 'Notebook 1.11').set(`${folder.toUpperCase()}/notiz.txt`, note);
|
|
|
|
|
expect(repair(doc).madeFolders).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('a project in its document, beyond the corpus', () => {
|
|
|
|
|
it('keeps a Java long of an own application, binary files and an optional field left out', () => {
|
|
|
|
|
const project = opened('java-examples/anwendung_komplett.fls');
|
|
|
|
|
const [nodeId, applications] = [...project.ownApplicationsByNodeId][0]!;
|
|
|
|
|
project.ownApplicationsByNodeId.set(nodeId, [
|
|
|
|
|
...applications,
|
|
|
|
|
{ key: 'eigene', javaClass: 'demo.Eigene', bean: { kind: 'object', className: 'demo.Eigene', statements: [{ set: 'zahl', value: { kind: 'long', value: 12_345_678_901_234_567_890n } }] } },
|
|
|
|
|
]);
|
|
|
|
|
const fileSystem = project.virtualFileSystems.get(nodeId)!;
|
|
|
|
|
fileSystem.writeBinaryFile('/daten.bin', new Uint8Array([0, 1, 2, 255]));
|
|
|
|
|
fileSystem.writeBinaryFile('/typ.bin', new Uint8Array([7]), 'application/octet-stream');
|
|
|
|
|
const doc = new Y.Doc();
|
|
|
|
|
writeProject(doc, project);
|
|
|
|
|
const other = new Y.Doc();
|
|
|
|
|
Y.applyUpdate(other, Y.encodeStateAsUpdate(doc));
|
|
|
|
|
const back = readProject(other);
|
|
|
|
|
expect(back.ownApplicationsByNodeId).toEqual(project.ownApplicationsByNodeId);
|
|
|
|
|
expect(back.virtualFileSystems.get(nodeId)!.entry('/daten.bin').content).toEqual({ kind: 'binary', data: new Uint8Array([0, 1, 2, 255]) });
|
|
|
|
|
expect(back.virtualFileSystems.get(nodeId)!.entry('/typ.bin').content).toEqual({ kind: 'binary', data: new Uint8Array([7]), mediaType: 'application/octet-stream' });
|
|
|
|
|
});
|
|
|
|
|
});
|