|
@@ -0,0 +1,304 @@
|
|
|
+import { expect } from "chai";
|
|
|
+import { ExcalidrawElement } from "../element/types";
|
|
|
+import {
|
|
|
+ BroadcastedExcalidrawElement,
|
|
|
+ ReconciledElements,
|
|
|
+ reconcileElements,
|
|
|
+} from "../excalidraw-app/collab/reconciliation";
|
|
|
+import { randomInteger } from "../random";
|
|
|
+import { AppState } from "../types";
|
|
|
+
|
|
|
+type Id = string;
|
|
|
+type Ids = Id[];
|
|
|
+
|
|
|
+type Cache = Record<string, ExcalidrawElement | undefined>;
|
|
|
+
|
|
|
+const parseId = (uid: string) => {
|
|
|
+ const [, parent, id, version] = uid.match(
|
|
|
+ /^(?:\((\^|\w+)\))?(\w+)(?::(\d+))?(?:\((\w+)\))?$/,
|
|
|
+ )!;
|
|
|
+ return {
|
|
|
+ uid: version ? `${id}:${version}` : id,
|
|
|
+ id,
|
|
|
+ version: version ? parseInt(version) : null,
|
|
|
+ parent: parent || null,
|
|
|
+ };
|
|
|
+};
|
|
|
+
|
|
|
+const idsToElements = (
|
|
|
+ ids: Ids,
|
|
|
+ cache: Cache = {},
|
|
|
+): readonly ExcalidrawElement[] => {
|
|
|
+ return ids.reduce((acc, _uid, idx) => {
|
|
|
+ const { uid, id, version, parent } = parseId(_uid);
|
|
|
+ const cached = cache[uid];
|
|
|
+ const elem = {
|
|
|
+ id,
|
|
|
+ version: version ?? 0,
|
|
|
+ versionNonce: randomInteger(),
|
|
|
+ ...cached,
|
|
|
+ parent,
|
|
|
+ } as BroadcastedExcalidrawElement;
|
|
|
+ cache[uid] = elem;
|
|
|
+ acc.push(elem);
|
|
|
+ return acc;
|
|
|
+ }, [] as ExcalidrawElement[]);
|
|
|
+};
|
|
|
+
|
|
|
+const addParents = (elements: BroadcastedExcalidrawElement[]) => {
|
|
|
+ return elements.map((el, idx, els) => {
|
|
|
+ el.parent = els[idx - 1]?.id || "^";
|
|
|
+ return el;
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+const cleanElements = (elements: ReconciledElements) => {
|
|
|
+ return elements.map((el) => {
|
|
|
+ // @ts-ignore
|
|
|
+ delete el.parent;
|
|
|
+ // @ts-ignore
|
|
|
+ delete el.next;
|
|
|
+ // @ts-ignore
|
|
|
+ delete el.prev;
|
|
|
+ return el;
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+const cloneDeep = (data: any) => JSON.parse(JSON.stringify(data));
|
|
|
+
|
|
|
+const test = <U extends `${string}:${"L" | "R"}`>(
|
|
|
+ local: Ids,
|
|
|
+ remote: Ids,
|
|
|
+ target: U[],
|
|
|
+ bidirectional = true,
|
|
|
+) => {
|
|
|
+ const cache: Cache = {};
|
|
|
+ const _local = idsToElements(local, cache);
|
|
|
+ const _remote = idsToElements(remote, cache);
|
|
|
+ const _target = (target.map((uid) => {
|
|
|
+ const [, id, source] = uid.match(/^(\w+):([LR])$/)!;
|
|
|
+ return (source === "L" ? _local : _remote).find((e) => e.id === id)!;
|
|
|
+ }) as any) as ReconciledElements;
|
|
|
+ const remoteReconciled = reconcileElements(_local, _remote, {} as AppState);
|
|
|
+ expect(cleanElements(remoteReconciled)).deep.equal(
|
|
|
+ cleanElements(_target),
|
|
|
+ "remote reconciliation",
|
|
|
+ );
|
|
|
+
|
|
|
+ const __local = cleanElements(cloneDeep(_remote));
|
|
|
+ const __remote = addParents(cleanElements(cloneDeep(remoteReconciled)));
|
|
|
+ if (bidirectional) {
|
|
|
+ try {
|
|
|
+ expect(
|
|
|
+ cleanElements(
|
|
|
+ reconcileElements(
|
|
|
+ cloneDeep(__local),
|
|
|
+ cloneDeep(__remote),
|
|
|
+ {} as AppState,
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ).deep.equal(cleanElements(remoteReconciled), "local re-reconciliation");
|
|
|
+ } catch (error) {
|
|
|
+ console.error("local original", __local);
|
|
|
+ console.error("remote reconciled", __remote);
|
|
|
+ throw error;
|
|
|
+ }
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+export const findIndex = <T>(
|
|
|
+ array: readonly T[],
|
|
|
+ cb: (element: T, index: number, array: readonly T[]) => boolean,
|
|
|
+ fromIndex: number = 0,
|
|
|
+) => {
|
|
|
+ if (fromIndex < 0) {
|
|
|
+ fromIndex = array.length + fromIndex;
|
|
|
+ }
|
|
|
+ fromIndex = Math.min(array.length, Math.max(fromIndex, 0));
|
|
|
+ let index = fromIndex - 1;
|
|
|
+ while (++index < array.length) {
|
|
|
+ if (cb(array[index], index, array)) {
|
|
|
+ return index;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return -1;
|
|
|
+};
|
|
|
+
|
|
|
+// -----------------------------------------------------------------------------
|
|
|
+
|
|
|
+describe("elements reconciliation", () => {
|
|
|
+ it("reconcileElements()", () => {
|
|
|
+ // -------------------------------------------------------------------------
|
|
|
+ //
|
|
|
+ // in following tests, we pass:
|
|
|
+ // (1) an array of local elements and their version (:1, :2...)
|
|
|
+ // (2) an array of remote elements and their version (:1, :2...)
|
|
|
+ // (3) expected reconciled elements
|
|
|
+ //
|
|
|
+ // in the reconciled array:
|
|
|
+ // :L means local element was resolved
|
|
|
+ // :R means remote element was resolved
|
|
|
+ //
|
|
|
+ // if a remote element is prefixed with parentheses, the enclosed string:
|
|
|
+ // (^) means the element is the first element in the array
|
|
|
+ // (<id>) means the element is preceded by <id> element
|
|
|
+ //
|
|
|
+ // if versions are missing, it defaults to version 0
|
|
|
+ // -------------------------------------------------------------------------
|
|
|
+
|
|
|
+ // non-annotated elements
|
|
|
+ // -------------------------------------------------------------------------
|
|
|
+ // usually when we sync elements they should always be annonated with
|
|
|
+ // their (preceding elements) parents, but let's test a couple of cases when
|
|
|
+ // they're not for whatever reason (remote clients are on older version...),
|
|
|
+ // in which case the first synced element either replaces existing element
|
|
|
+ // or is pushed at the end of the array
|
|
|
+
|
|
|
+ test(["A:1", "B:1", "C:1"], ["B:2"], ["A:L", "B:R", "C:L"]);
|
|
|
+ test(["A:1", "B:1", "C"], ["B:2", "A:2"], ["B:R", "A:R", "C:L"]);
|
|
|
+ test(["A:2", "B:1", "C"], ["B:2", "A:1"], ["A:L", "B:R", "C:L"]);
|
|
|
+ test(["A:1", "B:1"], ["C:1"], ["A:L", "B:L", "C:R"]);
|
|
|
+ test(["A", "B"], ["A:1"], ["A:R", "B:L"]);
|
|
|
+ test(["A"], ["A", "B"], ["A:L", "B:R"]);
|
|
|
+ test(["A"], ["A:1", "B"], ["A:R", "B:R"]);
|
|
|
+ test(["A:2"], ["A:1", "B"], ["A:L", "B:R"]);
|
|
|
+ test(["A:2"], ["B", "A:1"], ["A:L", "B:R"]);
|
|
|
+ test(["A:1"], ["B", "A:2"], ["B:R", "A:R"]);
|
|
|
+ test(["A"], ["A:1"], ["A:R"]);
|
|
|
+
|
|
|
+ // C isn't added to the end because it follows B (even if B was resolved
|
|
|
+ // to local version)
|
|
|
+ test(["A", "B:1", "D"], ["B", "C:2", "A"], ["B:L", "C:R", "A:R", "D:L"]);
|
|
|
+
|
|
|
+ // some of the following tests are kinda arbitrary and they're less
|
|
|
+ // likely to happen in real-world cases
|
|
|
+
|
|
|
+ test(["A", "B"], ["B:1", "A:1"], ["B:R", "A:R"]);
|
|
|
+ test(["A:2", "B:2"], ["B:1", "A:1"], ["A:L", "B:L"]);
|
|
|
+ test(["A", "B", "C"], ["A", "B:2", "G", "C"], ["A:L", "B:R", "G:R", "C:L"]);
|
|
|
+ test(["A", "B", "C"], ["A", "B:2", "G"], ["A:L", "B:R", "G:R", "C:L"]);
|
|
|
+ test(["A", "B", "C"], ["A", "B:2", "G"], ["A:L", "B:R", "G:R", "C:L"]);
|
|
|
+ test(
|
|
|
+ ["A:2", "B:2", "C"],
|
|
|
+ ["D", "B:1", "A:3"],
|
|
|
+ ["B:L", "A:R", "C:L", "D:R"],
|
|
|
+ );
|
|
|
+ test(
|
|
|
+ ["A:2", "B:2", "C"],
|
|
|
+ ["D", "B:2", "A:3", "C"],
|
|
|
+ ["D:R", "B:L", "A:R", "C:L"],
|
|
|
+ );
|
|
|
+ test(
|
|
|
+ ["A", "B", "C", "D", "E", "F"],
|
|
|
+ ["A", "B:2", "X", "E:2", "F", "Y"],
|
|
|
+ ["A:L", "B:R", "X:R", "E:R", "F:L", "Y:R", "C:L", "D:L"],
|
|
|
+ );
|
|
|
+
|
|
|
+ // annotated elements
|
|
|
+ // -------------------------------------------------------------------------
|
|
|
+
|
|
|
+ test(
|
|
|
+ ["A", "B", "C"],
|
|
|
+ ["(B)X", "(A)Y", "(Y)Z"],
|
|
|
+ ["A:L", "B:L", "X:R", "Y:R", "Z:R", "C:L"],
|
|
|
+ );
|
|
|
+
|
|
|
+ test(["A"], ["(^)X", "Y"], ["X:R", "Y:R", "A:L"]);
|
|
|
+ test(["A"], ["(^)X", "Y", "Z"], ["X:R", "Y:R", "Z:R", "A:L"]);
|
|
|
+
|
|
|
+ test(
|
|
|
+ ["A", "B"],
|
|
|
+ ["(A)C", "(^)D", "F"],
|
|
|
+ ["A:L", "C:R", "D:R", "F:R", "B:L"],
|
|
|
+ );
|
|
|
+
|
|
|
+ test(
|
|
|
+ ["A", "B", "C", "D"],
|
|
|
+ ["(B)C:1", "B", "D:1"],
|
|
|
+ ["A:L", "C:R", "B:L", "D:R"],
|
|
|
+ );
|
|
|
+
|
|
|
+ test(
|
|
|
+ ["A", "B", "C"],
|
|
|
+ ["(^)X", "(A)Y", "(B)Z"],
|
|
|
+ ["X:R", "A:L", "Y:R", "B:L", "Z:R", "C:L"],
|
|
|
+ );
|
|
|
+
|
|
|
+ test(
|
|
|
+ ["B", "A", "C"],
|
|
|
+ ["(^)X", "(A)Y", "(B)Z"],
|
|
|
+ ["X:R", "B:L", "A:L", "Y:R", "Z:R", "C:L"],
|
|
|
+ );
|
|
|
+
|
|
|
+ test(["A", "B"], ["(A)X", "(A)Y"], ["A:L", "X:R", "Y:R", "B:L"]);
|
|
|
+
|
|
|
+ test(
|
|
|
+ ["A", "B", "C", "D", "E"],
|
|
|
+ ["(A)X", "(C)Y", "(D)Z"],
|
|
|
+ ["A:L", "X:R", "B:L", "C:L", "Y:R", "D:L", "Z:R", "E:L"],
|
|
|
+ );
|
|
|
+
|
|
|
+ test(
|
|
|
+ ["X", "Y", "Z"],
|
|
|
+ ["(^)A", "(A)B", "(B)C", "(C)X", "(X)D", "(D)Y", "(Y)Z"],
|
|
|
+ ["A:R", "B:R", "C:R", "X:L", "D:R", "Y:L", "Z:L"],
|
|
|
+ );
|
|
|
+
|
|
|
+ test(
|
|
|
+ ["A", "B", "C", "D", "E"],
|
|
|
+ ["(C)X", "(A)Y", "(D)E:1"],
|
|
|
+ ["A:L", "B:L", "C:L", "X:R", "Y:R", "D:L", "E:R"],
|
|
|
+ );
|
|
|
+
|
|
|
+ test(
|
|
|
+ ["C:1", "B", "D:1"],
|
|
|
+ ["A", "B", "C:1", "D:1"],
|
|
|
+ ["A:R", "B:L", "C:L", "D:L"],
|
|
|
+ );
|
|
|
+
|
|
|
+ test(
|
|
|
+ ["A", "B", "C", "D"],
|
|
|
+ ["(A)C:1", "(C)B", "(B)D:1"],
|
|
|
+ ["A:L", "C:R", "B:L", "D:R"],
|
|
|
+ );
|
|
|
+
|
|
|
+ test(
|
|
|
+ ["A", "B", "C", "D"],
|
|
|
+ ["(A)C:1", "(C)B", "(B)D:1"],
|
|
|
+ ["A:L", "C:R", "B:L", "D:R"],
|
|
|
+ );
|
|
|
+
|
|
|
+ test(
|
|
|
+ ["C:1", "B", "D:1"],
|
|
|
+ ["(^)A", "(A)B", "(B)C:2", "(C)D:1"],
|
|
|
+ ["A:R", "B:L", "C:R", "D:L"],
|
|
|
+ );
|
|
|
+
|
|
|
+ test(
|
|
|
+ ["A", "B", "C", "D"],
|
|
|
+ ["(C)X", "(B)Y", "(A)Z"],
|
|
|
+ ["A:L", "B:L", "C:L", "X:R", "Y:R", "Z:R", "D:L"],
|
|
|
+ );
|
|
|
+
|
|
|
+ test(["A", "B", "C", "D"], ["(A)B:1", "C:1"], ["A:L", "B:R", "C:R", "D:L"]);
|
|
|
+ test(["A", "B", "C", "D"], ["(A)C:1", "B:1"], ["A:L", "C:R", "B:R", "D:L"]);
|
|
|
+ test(
|
|
|
+ ["A", "B", "C", "D"],
|
|
|
+ ["(A)C:1", "B", "D:1"],
|
|
|
+ ["A:L", "C:R", "B:L", "D:R"],
|
|
|
+ );
|
|
|
+
|
|
|
+ test(["A:1", "B:1", "C"], ["B:2"], ["A:L", "B:R", "C:L"]);
|
|
|
+ test(["A:1", "B:1", "C"], ["B:2", "C:2"], ["A:L", "B:R", "C:R"]);
|
|
|
+
|
|
|
+ test(["A", "B"], ["(A)C", "(B)D"], ["A:L", "C:R", "B:L", "D:R"]);
|
|
|
+ test(["A", "B"], ["(X)C", "(X)D"], ["A:L", "B:L", "C:R", "D:R"]);
|
|
|
+ test(["A", "B"], ["(X)C", "(A)D"], ["A:L", "D:R", "B:L", "C:R"]);
|
|
|
+ test(["A", "B"], ["(A)B:1"], ["A:L", "B:R"]);
|
|
|
+ test(["A:2", "B"], ["(A)B:1"], ["A:L", "B:R"]);
|
|
|
+ test(["A:2", "B:2"], ["B:1"], ["A:L", "B:L"]);
|
|
|
+ test(["A:2", "B:2"], ["B:1", "C"], ["A:L", "B:L", "C:R"]);
|
|
|
+ test(["A:2", "B:2"], ["(A)C", "B:1"], ["A:L", "C:R", "B:L"]);
|
|
|
+ test(["A:2", "B:2"], ["(A)C", "B:1"], ["A:L", "C:R", "B:L"]);
|
|
|
+ });
|
|
|
+});
|