Преглед на файлове

fix: rewrite collab element reconciliation to fix z-index issues (#4076)

David Luzar преди 3 години
родител
ревизия
d89fb3371b
променени са 6 файла, в които са добавени 562 реда и са изтрити 84 реда
  1. 2 0
      package.json
  2. 24 68
      src/excalidraw-app/collab/CollabWrapper.tsx
  3. 25 15
      src/excalidraw-app/collab/Portal.tsx
  4. 162 0
      src/excalidraw-app/collab/reconciliation.ts
  5. 304 0
      src/tests/reconciliation.test.ts
  6. 45 1
      yarn.lock

+ 2 - 0
package.json

@@ -57,9 +57,11 @@
   "devDependencies": {
     "@excalidraw/eslint-config": "1.0.0",
     "@excalidraw/prettier-config": "1.0.2",
+    "@types/chai": "4.2.22",
     "@types/lodash.throttle": "4.1.6",
     "@types/pako": "1.0.1",
     "@types/resize-observer-browser": "0.1.5",
+    "chai": "4.3.4",
     "eslint-config-prettier": "8.3.0",
     "eslint-plugin-prettier": "3.3.1",
     "firebase-tools": "9.9.0",

+ 24 - 68
src/excalidraw-app/collab/CollabWrapper.tsx

@@ -8,10 +8,7 @@ import {
   ExcalidrawElement,
   InitializedExcalidrawImageElement,
 } from "../../element/types";
-import {
-  getElementMap,
-  getSceneVersion,
-} from "../../packages/excalidraw/index";
+import { getSceneVersion } from "../../packages/excalidraw/index";
 import { Collaborator, Gesture } from "../../types";
 import {
   preventUnload,
@@ -64,6 +61,10 @@ import {
   isInitializedImageElement,
 } from "../../element/typeChecks";
 import { mutateElement } from "../../element/mutateElement";
+import {
+  ReconciledElements,
+  reconcileElements as _reconcileElements,
+} from "./reconciliation";
 
 interface CollabState {
   modalIsShown: boolean;
@@ -87,10 +88,6 @@ export interface CollabAPI {
   fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"];
 }
 
-type ReconciledElements = readonly ExcalidrawElement[] & {
-  _brand: "reconciledElements";
-};
-
 interface Props {
   excalidrawAPI: ExcalidrawImperativeAPI;
   onRoomClose?: () => void;
@@ -227,7 +224,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
   });
 
   saveCollabRoomToFirebase = async (
-    syncableElements: ExcalidrawElement[] = this.getSyncableElements(
+    syncableElements: readonly ExcalidrawElement[] = this.getSyncableElements(
       this.excalidrawAPI.getSceneElementsIncludingDeleted(),
     ),
   ) => {
@@ -484,65 +481,26 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
   };
 
   private reconcileElements = (
-    elements: readonly ExcalidrawElement[],
+    remoteElements: readonly ExcalidrawElement[],
   ): ReconciledElements => {
-    const currentElements = this.getSceneElementsIncludingDeleted();
-    // create a map of ids so we don't have to iterate
-    // over the array more than once.
-    const localElementMap = getElementMap(currentElements);
-
+    const localElements = this.getSceneElementsIncludingDeleted();
     const appState = this.excalidrawAPI.getAppState();
 
-    // Reconcile
-    const newElements: readonly ExcalidrawElement[] = elements
-      .reduce((elements, element) => {
-        // if the remote element references one that's currently
-        // edited on local, skip it (it'll be added in the next step)
-        if (
-          element.id === appState.editingElement?.id ||
-          element.id === appState.resizingElement?.id ||
-          element.id === appState.draggingElement?.id
-        ) {
-          return elements;
-        }
-
-        if (
-          localElementMap.hasOwnProperty(element.id) &&
-          localElementMap[element.id].version > element.version
-        ) {
-          elements.push(localElementMap[element.id]);
-          delete localElementMap[element.id];
-        } else if (
-          localElementMap.hasOwnProperty(element.id) &&
-          localElementMap[element.id].version === element.version &&
-          localElementMap[element.id].versionNonce !== element.versionNonce
-        ) {
-          // resolve conflicting edits deterministically by taking the one with the lowest versionNonce
-          if (localElementMap[element.id].versionNonce < element.versionNonce) {
-            elements.push(localElementMap[element.id]);
-          } else {
-            // it should be highly unlikely that the two versionNonces are the same. if we are
-            // really worried about this, we can replace the versionNonce with the socket id.
-            elements.push(element);
-          }
-          delete localElementMap[element.id];
-        } else {
-          elements.push(element);
-          delete localElementMap[element.id];
-        }
-
-        return elements;
-      }, [] as Mutable<typeof elements>)
-      // add local elements that weren't deleted or on remote
-      .concat(...Object.values(localElementMap));
+    const reconciledElements = _reconcileElements(
+      localElements,
+      remoteElements,
+      appState,
+    );
 
     // Avoid broadcasting to the rest of the collaborators the scene
     // we just received!
     // Note: this needs to be set before updating the scene as it
     // synchronously calls render.
-    this.setLastBroadcastedOrReceivedSceneVersion(getSceneVersion(newElements));
+    this.setLastBroadcastedOrReceivedSceneVersion(
+      getSceneVersion(reconciledElements),
+    );
 
-    return newElements as ReconciledElements;
+    return reconciledElements;
   };
 
   private loadImageFiles = throttle(async () => {
@@ -681,11 +639,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
       getSceneVersion(elements) >
       this.getLastBroadcastedOrReceivedSceneVersion()
     ) {
-      this.portal.broadcastScene(
-        SCENE.UPDATE,
-        this.getSyncableElements(elements),
-        false,
-      );
+      this.portal.broadcastScene(SCENE.UPDATE, elements, false);
       this.lastBroadcastedOrReceivedSceneVersion = getSceneVersion(elements);
       this.queueBroadcastAllElements();
     }
@@ -694,9 +648,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
   queueBroadcastAllElements = throttle(() => {
     this.portal.broadcastScene(
       SCENE.UPDATE,
-      this.getSyncableElements(
-        this.excalidrawAPI.getSceneElementsIncludingDeleted(),
-      ),
+      this.excalidrawAPI.getSceneElementsIncludingDeleted(),
       true,
     );
     const currentVersion = this.getLastBroadcastedOrReceivedSceneVersion();
@@ -722,8 +674,12 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
     });
   };
 
+  isSyncableElement = (element: ExcalidrawElement) => {
+    return element.isDeleted || !isInvisiblySmallElement(element);
+  };
+
   getSyncableElements = (elements: readonly ExcalidrawElement[]) =>
-    elements.filter((el) => el.isDeleted || !isInvisiblySmallElement(el));
+    elements.filter((element) => this.isSyncableElement(element));
 
   /** PRIVATE. Use `this.getContextValue()` instead. */
   private contextValue: CollabAPI | null = null;

+ 25 - 15
src/excalidraw-app/collab/Portal.tsx

@@ -12,6 +12,7 @@ import { UserIdleState } from "../../types";
 import { trackEvent } from "../../analytics";
 import { throttle } from "lodash";
 import { mutateElement } from "../../element/mutateElement";
+import { BroadcastedExcalidrawElement } from "./reconciliation";
 
 class Portal {
   collab: CollabWrapper;
@@ -40,9 +41,7 @@ class Portal {
     this.socket.on("new-user", async (_socketId: string) => {
       this.broadcastScene(
         SCENE.INIT,
-        this.collab.getSyncableElements(
-          this.collab.getSceneElementsIncludingDeleted(),
-        ),
+        this.collab.getSceneElementsIncludingDeleted(),
         /* syncAll */ true,
       );
     });
@@ -124,24 +123,35 @@ class Portal {
 
   broadcastScene = async (
     sceneType: SCENE.INIT | SCENE.UPDATE,
-    syncableElements: ExcalidrawElement[],
+    allElements: readonly ExcalidrawElement[],
     syncAll: boolean,
   ) => {
     if (sceneType === SCENE.INIT && !syncAll) {
       throw new Error("syncAll must be true when sending SCENE.INIT");
     }
 
-    if (!syncAll) {
-      // sync out only the elements we think we need to to save bandwidth.
-      // periodically we'll resync the whole thing to make sure no one diverges
-      // due to a dropped message (server goes down etc).
-      syncableElements = syncableElements.filter(
-        (syncableElement) =>
-          !this.broadcastedElementVersions.has(syncableElement.id) ||
-          syncableElement.version >
-            this.broadcastedElementVersions.get(syncableElement.id)!,
-      );
-    }
+    // sync out only the elements we think we need to to save bandwidth.
+    // periodically we'll resync the whole thing to make sure no one diverges
+    // due to a dropped message (server goes down etc).
+    const syncableElements = allElements.reduce(
+      (acc, element: BroadcastedExcalidrawElement, idx, elements) => {
+        if (
+          (syncAll ||
+            !this.broadcastedElementVersions.has(element.id) ||
+            element.version >
+              this.broadcastedElementVersions.get(element.id)!) &&
+          this.collab.isSyncableElement(element)
+        ) {
+          acc.push({
+            ...element,
+            // z-index info for the reconciler
+            parent: idx === 0 ? "^" : elements[idx - 1]?.id,
+          });
+        }
+        return acc;
+      },
+      [] as BroadcastedExcalidrawElement[],
+    );
 
     const data: SocketUpdateDataSource[typeof sceneType] = {
       type: sceneType,

+ 162 - 0
src/excalidraw-app/collab/reconciliation.ts

@@ -0,0 +1,162 @@
+import { ExcalidrawElement } from "../../element/types";
+import { AppState } from "../../types";
+
+export type ReconciledElements = readonly ExcalidrawElement[] & {
+  _brand: "reconciledElements";
+};
+
+export type BroadcastedExcalidrawElement = ExcalidrawElement & {
+  parent?: string;
+};
+
+const shouldDiscardRemoteElement = (
+  localAppState: AppState,
+  local: ExcalidrawElement | undefined,
+  remote: BroadcastedExcalidrawElement,
+): boolean => {
+  if (
+    local &&
+    // local element is being edited
+    (local.id === localAppState.editingElement?.id ||
+      local.id === localAppState.resizingElement?.id ||
+      local.id === localAppState.draggingElement?.id ||
+      // local element is newer
+      local.version > remote.version ||
+      // resolve conflicting edits deterministically by taking the one with
+      // the lowest versionNonce
+      (local.version === remote.version &&
+        local.versionNonce < remote.versionNonce))
+  ) {
+    return true;
+  }
+  return false;
+};
+
+const getElementsMapWithIndex = <T extends ExcalidrawElement>(
+  elements: readonly T[],
+) =>
+  elements.reduce(
+    (
+      acc: {
+        [key: string]: [element: T, index: number] | undefined;
+      },
+      element: T,
+      idx,
+    ) => {
+      acc[element.id] = [element, idx];
+      return acc;
+    },
+    {},
+  );
+
+export const reconcileElements = (
+  localElements: readonly ExcalidrawElement[],
+  remoteElements: readonly BroadcastedExcalidrawElement[],
+  localAppState: AppState,
+): ReconciledElements => {
+  const localElementsData = getElementsMapWithIndex<ExcalidrawElement>(
+    localElements,
+  );
+
+  const reconciledElements: ExcalidrawElement[] = localElements.slice();
+
+  const duplicates = new WeakMap<ExcalidrawElement, true>();
+
+  let cursor = 0;
+  let offset = 0;
+
+  let remoteElementIdx = -1;
+  for (const remoteElement of remoteElements) {
+    remoteElementIdx++;
+
+    const local = localElementsData[remoteElement.id];
+
+    if (shouldDiscardRemoteElement(localAppState, local?.[0], remoteElement)) {
+      if (remoteElement.parent) {
+        delete remoteElement.parent;
+      }
+
+      continue;
+    }
+
+    if (local) {
+      // mark for removal since it'll be replaced with the remote element
+      duplicates.set(local[0], true);
+    }
+
+    // parent may not be defined in case the remote client is running an older
+    // excalidraw version
+    const parent =
+      remoteElement.parent || remoteElements[remoteElementIdx - 1]?.id || null;
+
+    if (parent != null) {
+      delete remoteElement.parent;
+
+      // ^ indicates the element is the first in elements array
+      if (parent === "^") {
+        offset++;
+        if (cursor === 0) {
+          reconciledElements.unshift(remoteElement);
+          localElementsData[remoteElement.id] = [
+            remoteElement,
+            cursor - offset,
+          ];
+        } else {
+          reconciledElements.splice(cursor + 1, 0, remoteElement);
+          localElementsData[remoteElement.id] = [
+            remoteElement,
+            cursor + 1 - offset,
+          ];
+          cursor++;
+        }
+      } else {
+        let idx = localElementsData[parent]
+          ? localElementsData[parent]![1]
+          : null;
+        if (idx != null) {
+          idx += offset;
+        }
+        if (idx != null && idx >= cursor) {
+          reconciledElements.splice(idx + 1, 0, remoteElement);
+          offset++;
+          localElementsData[remoteElement.id] = [
+            remoteElement,
+            idx + 1 - offset,
+          ];
+          cursor = idx + 1;
+        } else if (idx != null) {
+          reconciledElements.splice(cursor + 1, 0, remoteElement);
+          offset++;
+          localElementsData[remoteElement.id] = [
+            remoteElement,
+            cursor + 1 - offset,
+          ];
+          cursor++;
+        } else {
+          reconciledElements.push(remoteElement);
+          localElementsData[remoteElement.id] = [
+            remoteElement,
+            reconciledElements.length - 1 - offset,
+          ];
+        }
+      }
+      // no parent z-index information, local element exists → replace in place
+    } else if (local) {
+      reconciledElements[local[1]] = remoteElement;
+      localElementsData[remoteElement.id] = [remoteElement, local[1]];
+      // otherwise push to the end
+    } else {
+      reconciledElements.push(remoteElement);
+      localElementsData[remoteElement.id] = [
+        remoteElement,
+        reconciledElements.length - 1 - offset,
+      ];
+    }
+  }
+
+  const ret: readonly ExcalidrawElement[] = reconciledElements.filter(
+    (element) => !duplicates.has(element),
+  );
+
+  return ret as ReconciledElements;
+};

+ 304 - 0
src/tests/reconciliation.test.ts

@@ -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"]);
+  });
+});

+ 45 - 1
yarn.lock

@@ -2207,6 +2207,11 @@
   dependencies:
     "@babel/types" "^7.3.0"
 
+"@types/chai@4.2.22":
+  version "4.2.22"
+  resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.2.22.tgz#47020d7e4cf19194d43b5202f35f75bd2ad35ce7"
+  integrity sha512-tFfcE+DSTzWAgifkjik9AySNqIyNoYwmR+uecPwwD/XRNfvOjmC/FjCxpiUGDkDVDphPfCUecSQVFw+lN3M3kQ==
+
 "@types/duplexify@^3.6.0":
   version "3.6.0"
   resolved "https://registry.npmjs.org/@types/duplexify/-/duplexify-3.6.0.tgz"
@@ -3160,6 +3165,11 @@ assert@^1.1.1:
     object-assign "^4.1.1"
     util "0.10.3"
 
+assertion-error@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b"
+  integrity sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==
+
 assign-symbols@^1.0.0:
   version "1.0.0"
   resolved "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz"
@@ -4051,6 +4061,18 @@ caseless@~0.12.0:
   resolved "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz"
   integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=
 
+chai@4.3.4:
+  version "4.3.4"
+  resolved "https://registry.yarnpkg.com/chai/-/chai-4.3.4.tgz#b55e655b31e1eac7099be4c08c21964fce2e6c49"
+  integrity sha512-yS5H68VYOCtN1cjfwumDSuzn/9c+yza4f3reKXlE5rUg7SFcCEy90gJvydNgOYtblyf4Zi6jIWRnXOgErta0KA==
+  dependencies:
+    assertion-error "^1.1.0"
+    check-error "^1.0.2"
+    deep-eql "^3.0.1"
+    get-func-name "^2.0.0"
+    pathval "^1.1.1"
+    type-detect "^4.0.5"
+
 chainsaw@~0.1.0:
   version "0.1.0"
   resolved "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz"
@@ -4104,6 +4126,11 @@ chardet@^0.7.0:
   resolved "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz"
   integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==
 
+check-error@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.2.tgz#574d312edd88bb5dd8912e9286dd6c0aed4aac82"
+  integrity sha1-V00xLt2Iu13YkS6Sht1sCu1KrII=
+
 check-types@^11.1.1:
   version "11.1.2"
   resolved "https://registry.npmjs.org/check-types/-/check-types-11.1.2.tgz"
@@ -5128,6 +5155,13 @@ dedent@^0.7.0:
   resolved "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz"
   integrity sha1-JJXduvbrh0q7Dhvp3yLS5aVEMmw=
 
+deep-eql@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-3.0.1.tgz#dfc9404400ad1c8fe023e7da1df1c147c4b444df"
+  integrity sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw==
+  dependencies:
+    type-detect "^4.0.0"
+
 deep-equal@^1.0.1:
   version "1.1.1"
   resolved "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.1.tgz"
@@ -6902,6 +6936,11 @@ get-caller-file@^2.0.1:
   resolved "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz"
   integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==
 
+get-func-name@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.0.tgz#ead774abee72e20409433a066366023dd6887a41"
+  integrity sha1-6td0q+5y4gQJQzoGY2YCPdaIekE=
+
 get-intrinsic@^1.0.2, get-intrinsic@^1.1.0, get-intrinsic@^1.1.1:
   version "1.1.1"
   resolved "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz"
@@ -10790,6 +10829,11 @@ path-type@^4.0.0:
   resolved "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz"
   integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==
 
+pathval@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.1.tgz#8534e77a77ce7ac5a2512ea21e0fdb8fcf6c3d8d"
+  integrity sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==
+
 pbkdf2@^3.0.3:
   version "3.1.1"
   resolved "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.1.tgz"
@@ -14154,7 +14198,7 @@ type-check@~0.3.2:
   dependencies:
     prelude-ls "~1.1.2"
 
-type-detect@4.0.8:
+type-detect@4.0.8, type-detect@^4.0.0, type-detect@^4.0.5:
   version "4.0.8"
   resolved "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz"
   integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==