reconciliation.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421
  1. import { expect } from "chai";
  2. import { PRECEDING_ELEMENT_KEY } from "../../packages/excalidraw/constants";
  3. import { ExcalidrawElement } from "../../packages/excalidraw/element/types";
  4. import {
  5. BroadcastedExcalidrawElement,
  6. ReconciledElements,
  7. reconcileElements,
  8. } from "../../excalidraw-app/collab/reconciliation";
  9. import { randomInteger } from "../../packages/excalidraw/random";
  10. import { AppState } from "../../packages/excalidraw/types";
  11. import { cloneJSON } from "../../packages/excalidraw/utils";
  12. type Id = string;
  13. type ElementLike = {
  14. id: string;
  15. version: number;
  16. versionNonce: number;
  17. [PRECEDING_ELEMENT_KEY]?: string | null;
  18. };
  19. type Cache = Record<string, ExcalidrawElement | undefined>;
  20. const createElement = (opts: { uid: string } | ElementLike) => {
  21. let uid: string;
  22. let id: string;
  23. let version: number | null;
  24. let parent: string | null = null;
  25. let versionNonce: number | null = null;
  26. if ("uid" in opts) {
  27. const match = opts.uid.match(
  28. /^(?:\((\^|\w+)\))?(\w+)(?::(\d+))?(?:\((\w+)\))?$/,
  29. )!;
  30. parent = match[1];
  31. id = match[2];
  32. version = match[3] ? parseInt(match[3]) : null;
  33. uid = version ? `${id}:${version}` : id;
  34. } else {
  35. ({ id, version, versionNonce } = opts);
  36. parent = parent || null;
  37. uid = id;
  38. }
  39. return {
  40. uid,
  41. id,
  42. version,
  43. versionNonce: versionNonce || randomInteger(),
  44. [PRECEDING_ELEMENT_KEY]: parent || null,
  45. };
  46. };
  47. const idsToElements = (
  48. ids: (Id | ElementLike)[],
  49. cache: Cache = {},
  50. ): readonly ExcalidrawElement[] => {
  51. return ids.reduce((acc, _uid, idx) => {
  52. const {
  53. uid,
  54. id,
  55. version,
  56. [PRECEDING_ELEMENT_KEY]: parent,
  57. versionNonce,
  58. } = createElement(typeof _uid === "string" ? { uid: _uid } : _uid);
  59. const cached = cache[uid];
  60. const elem = {
  61. id,
  62. version: version ?? 0,
  63. versionNonce,
  64. ...cached,
  65. [PRECEDING_ELEMENT_KEY]: parent,
  66. } as BroadcastedExcalidrawElement;
  67. // @ts-ignore
  68. cache[uid] = elem;
  69. acc.push(elem);
  70. return acc;
  71. }, [] as ExcalidrawElement[]);
  72. };
  73. const addParents = (elements: BroadcastedExcalidrawElement[]) => {
  74. return elements.map((el, idx, els) => {
  75. el[PRECEDING_ELEMENT_KEY] = els[idx - 1]?.id || "^";
  76. return el;
  77. });
  78. };
  79. const cleanElements = (elements: ReconciledElements) => {
  80. return elements.map((el) => {
  81. // @ts-ignore
  82. delete el[PRECEDING_ELEMENT_KEY];
  83. // @ts-ignore
  84. delete el.next;
  85. // @ts-ignore
  86. delete el.prev;
  87. return el;
  88. });
  89. };
  90. const test = <U extends `${string}:${"L" | "R"}`>(
  91. local: (Id | ElementLike)[],
  92. remote: (Id | ElementLike)[],
  93. target: U[],
  94. bidirectional = true,
  95. ) => {
  96. const cache: Cache = {};
  97. const _local = idsToElements(local, cache);
  98. const _remote = idsToElements(remote, cache);
  99. const _target = target.map((uid) => {
  100. const [, id, source] = uid.match(/^(\w+):([LR])$/)!;
  101. return (source === "L" ? _local : _remote).find((e) => e.id === id)!;
  102. }) as any as ReconciledElements;
  103. const remoteReconciled = reconcileElements(_local, _remote, {} as AppState);
  104. expect(target.length).equal(remoteReconciled.length);
  105. expect(cleanElements(remoteReconciled)).deep.equal(
  106. cleanElements(_target),
  107. "remote reconciliation",
  108. );
  109. const __local = cleanElements(cloneJSON(_remote) as ReconciledElements);
  110. const __remote = addParents(cleanElements(cloneJSON(remoteReconciled)));
  111. if (bidirectional) {
  112. try {
  113. expect(
  114. cleanElements(
  115. reconcileElements(
  116. cloneJSON(__local),
  117. cloneJSON(__remote),
  118. {} as AppState,
  119. ),
  120. ),
  121. ).deep.equal(cleanElements(remoteReconciled), "local re-reconciliation");
  122. } catch (error: any) {
  123. console.error("local original", __local);
  124. console.error("remote reconciled", __remote);
  125. throw error;
  126. }
  127. }
  128. };
  129. export const findIndex = <T>(
  130. array: readonly T[],
  131. cb: (element: T, index: number, array: readonly T[]) => boolean,
  132. fromIndex: number = 0,
  133. ) => {
  134. if (fromIndex < 0) {
  135. fromIndex = array.length + fromIndex;
  136. }
  137. fromIndex = Math.min(array.length, Math.max(fromIndex, 0));
  138. let index = fromIndex - 1;
  139. while (++index < array.length) {
  140. if (cb(array[index], index, array)) {
  141. return index;
  142. }
  143. }
  144. return -1;
  145. };
  146. // -----------------------------------------------------------------------------
  147. describe("elements reconciliation", () => {
  148. it("reconcileElements()", () => {
  149. // -------------------------------------------------------------------------
  150. //
  151. // in following tests, we pass:
  152. // (1) an array of local elements and their version (:1, :2...)
  153. // (2) an array of remote elements and their version (:1, :2...)
  154. // (3) expected reconciled elements
  155. //
  156. // in the reconciled array:
  157. // :L means local element was resolved
  158. // :R means remote element was resolved
  159. //
  160. // if a remote element is prefixed with parentheses, the enclosed string:
  161. // (^) means the element is the first element in the array
  162. // (<id>) means the element is preceded by <id> element
  163. //
  164. // if versions are missing, it defaults to version 0
  165. // -------------------------------------------------------------------------
  166. // non-annotated elements
  167. // -------------------------------------------------------------------------
  168. // usually when we sync elements they should always be annotated with
  169. // their (preceding elements) parents, but let's test a couple of cases when
  170. // they're not for whatever reason (remote clients are on older version...),
  171. // in which case the first synced element either replaces existing element
  172. // or is pushed at the end of the array
  173. test(["A:1", "B:1", "C:1"], ["B:2"], ["A:L", "B:R", "C:L"]);
  174. test(["A:1", "B:1", "C"], ["B:2", "A:2"], ["B:R", "A:R", "C:L"]);
  175. test(["A:2", "B:1", "C"], ["B:2", "A:1"], ["A:L", "B:R", "C:L"]);
  176. test(["A:1", "B:1"], ["C:1"], ["A:L", "B:L", "C:R"]);
  177. test(["A", "B"], ["A:1"], ["A:R", "B:L"]);
  178. test(["A"], ["A", "B"], ["A:L", "B:R"]);
  179. test(["A"], ["A:1", "B"], ["A:R", "B:R"]);
  180. test(["A:2"], ["A:1", "B"], ["A:L", "B:R"]);
  181. test(["A:2"], ["B", "A:1"], ["A:L", "B:R"]);
  182. test(["A:1"], ["B", "A:2"], ["B:R", "A:R"]);
  183. test(["A"], ["A:1"], ["A:R"]);
  184. // C isn't added to the end because it follows B (even if B was resolved
  185. // to local version)
  186. test(["A", "B:1", "D"], ["B", "C:2", "A"], ["B:L", "C:R", "A:R", "D:L"]);
  187. // some of the following tests are kinda arbitrary and they're less
  188. // likely to happen in real-world cases
  189. test(["A", "B"], ["B:1", "A:1"], ["B:R", "A:R"]);
  190. test(["A:2", "B:2"], ["B:1", "A:1"], ["A:L", "B:L"]);
  191. test(["A", "B", "C"], ["A", "B:2", "G", "C"], ["A:L", "B:R", "G:R", "C:L"]);
  192. test(["A", "B", "C"], ["A", "B:2", "G"], ["A:L", "B:R", "G:R", "C:L"]);
  193. test(["A", "B", "C"], ["A", "B:2", "G"], ["A:L", "B:R", "G:R", "C:L"]);
  194. test(
  195. ["A:2", "B:2", "C"],
  196. ["D", "B:1", "A:3"],
  197. ["B:L", "A:R", "C:L", "D:R"],
  198. );
  199. test(
  200. ["A:2", "B:2", "C"],
  201. ["D", "B:2", "A:3", "C"],
  202. ["D:R", "B:L", "A:R", "C:L"],
  203. );
  204. test(
  205. ["A", "B", "C", "D", "E", "F"],
  206. ["A", "B:2", "X", "E:2", "F", "Y"],
  207. ["A:L", "B:R", "X:R", "E:R", "F:L", "Y:R", "C:L", "D:L"],
  208. );
  209. // annotated elements
  210. // -------------------------------------------------------------------------
  211. test(
  212. ["A", "B", "C"],
  213. ["(B)X", "(A)Y", "(Y)Z"],
  214. ["A:L", "B:L", "X:R", "Y:R", "Z:R", "C:L"],
  215. );
  216. test(["A"], ["(^)X", "Y"], ["X:R", "Y:R", "A:L"]);
  217. test(["A"], ["(^)X", "Y", "Z"], ["X:R", "Y:R", "Z:R", "A:L"]);
  218. test(
  219. ["A", "B"],
  220. ["(A)C", "(^)D", "F"],
  221. ["A:L", "C:R", "D:R", "F:R", "B:L"],
  222. );
  223. test(
  224. ["A", "B", "C", "D"],
  225. ["(B)C:1", "B", "D:1"],
  226. ["A:L", "C:R", "B:L", "D:R"],
  227. );
  228. test(
  229. ["A", "B", "C"],
  230. ["(^)X", "(A)Y", "(B)Z"],
  231. ["X:R", "A:L", "Y:R", "B:L", "Z:R", "C:L"],
  232. );
  233. test(
  234. ["B", "A", "C"],
  235. ["(^)X", "(A)Y", "(B)Z"],
  236. ["X:R", "B:L", "A:L", "Y:R", "Z:R", "C:L"],
  237. );
  238. test(["A", "B"], ["(A)X", "(A)Y"], ["A:L", "X:R", "Y:R", "B:L"]);
  239. test(
  240. ["A", "B", "C", "D", "E"],
  241. ["(A)X", "(C)Y", "(D)Z"],
  242. ["A:L", "X:R", "B:L", "C:L", "Y:R", "D:L", "Z:R", "E:L"],
  243. );
  244. test(
  245. ["X", "Y", "Z"],
  246. ["(^)A", "(A)B", "(B)C", "(C)X", "(X)D", "(D)Y", "(Y)Z"],
  247. ["A:R", "B:R", "C:R", "X:L", "D:R", "Y:L", "Z:L"],
  248. );
  249. test(
  250. ["A", "B", "C", "D", "E"],
  251. ["(C)X", "(A)Y", "(D)E:1"],
  252. ["A:L", "B:L", "C:L", "X:R", "Y:R", "D:L", "E:R"],
  253. );
  254. test(
  255. ["C:1", "B", "D:1"],
  256. ["A", "B", "C:1", "D:1"],
  257. ["A:R", "B:L", "C:L", "D:L"],
  258. );
  259. test(
  260. ["A", "B", "C", "D"],
  261. ["(A)C:1", "(C)B", "(B)D:1"],
  262. ["A:L", "C:R", "B:L", "D:R"],
  263. );
  264. test(
  265. ["A", "B", "C", "D"],
  266. ["(A)C:1", "(C)B", "(B)D:1"],
  267. ["A:L", "C:R", "B:L", "D:R"],
  268. );
  269. test(
  270. ["C:1", "B", "D:1"],
  271. ["(^)A", "(A)B", "(B)C:2", "(C)D:1"],
  272. ["A:R", "B:L", "C:R", "D:L"],
  273. );
  274. test(
  275. ["A", "B", "C", "D"],
  276. ["(C)X", "(B)Y", "(A)Z"],
  277. ["A:L", "B:L", "C:L", "X:R", "Y:R", "Z:R", "D:L"],
  278. );
  279. test(["A", "B", "C", "D"], ["(A)B:1", "C:1"], ["A:L", "B:R", "C:R", "D:L"]);
  280. test(["A", "B", "C", "D"], ["(A)C:1", "B:1"], ["A:L", "C:R", "B:R", "D:L"]);
  281. test(
  282. ["A", "B", "C", "D"],
  283. ["(A)C:1", "B", "D:1"],
  284. ["A:L", "C:R", "B:L", "D:R"],
  285. );
  286. test(["A:1", "B:1", "C"], ["B:2"], ["A:L", "B:R", "C:L"]);
  287. test(["A:1", "B:1", "C"], ["B:2", "C:2"], ["A:L", "B:R", "C:R"]);
  288. test(["A", "B"], ["(A)C", "(B)D"], ["A:L", "C:R", "B:L", "D:R"]);
  289. test(["A", "B"], ["(X)C", "(X)D"], ["A:L", "B:L", "C:R", "D:R"]);
  290. test(["A", "B"], ["(X)C", "(A)D"], ["A:L", "D:R", "B:L", "C:R"]);
  291. test(["A", "B"], ["(A)B:1"], ["A:L", "B:R"]);
  292. test(["A:2", "B"], ["(A)B:1"], ["A:L", "B:R"]);
  293. test(["A:2", "B:2"], ["B:1"], ["A:L", "B:L"]);
  294. test(["A:2", "B:2"], ["B:1", "C"], ["A:L", "B:L", "C:R"]);
  295. test(["A:2", "B:2"], ["(A)C", "B:1"], ["A:L", "C:R", "B:L"]);
  296. test(["A:2", "B:2"], ["(A)C", "B:1"], ["A:L", "C:R", "B:L"]);
  297. });
  298. it("test identical elements reconciliation", () => {
  299. const testIdentical = (
  300. local: ElementLike[],
  301. remote: ElementLike[],
  302. expected: Id[],
  303. ) => {
  304. const ret = reconcileElements(
  305. local as any as ExcalidrawElement[],
  306. remote as any as ExcalidrawElement[],
  307. {} as AppState,
  308. );
  309. if (new Set(ret.map((x) => x.id)).size !== ret.length) {
  310. throw new Error("reconcileElements: duplicate elements found");
  311. }
  312. expect(ret.map((x) => x.id)).to.deep.equal(expected);
  313. };
  314. // identical id/version/versionNonce
  315. // -------------------------------------------------------------------------
  316. testIdentical(
  317. [{ id: "A", version: 1, versionNonce: 1 }],
  318. [{ id: "A", version: 1, versionNonce: 1 }],
  319. ["A"],
  320. );
  321. testIdentical(
  322. [
  323. { id: "A", version: 1, versionNonce: 1 },
  324. { id: "B", version: 1, versionNonce: 1 },
  325. ],
  326. [
  327. { id: "B", version: 1, versionNonce: 1 },
  328. { id: "A", version: 1, versionNonce: 1 },
  329. ],
  330. ["B", "A"],
  331. );
  332. testIdentical(
  333. [
  334. { id: "A", version: 1, versionNonce: 1 },
  335. { id: "B", version: 1, versionNonce: 1 },
  336. ],
  337. [
  338. { id: "B", version: 1, versionNonce: 1 },
  339. { id: "A", version: 1, versionNonce: 1 },
  340. ],
  341. ["B", "A"],
  342. );
  343. // actually identical (arrays and element objects)
  344. // -------------------------------------------------------------------------
  345. const elements1 = [
  346. {
  347. id: "A",
  348. version: 1,
  349. versionNonce: 1,
  350. [PRECEDING_ELEMENT_KEY]: null,
  351. },
  352. {
  353. id: "B",
  354. version: 1,
  355. versionNonce: 1,
  356. [PRECEDING_ELEMENT_KEY]: null,
  357. },
  358. ];
  359. testIdentical(elements1, elements1, ["A", "B"]);
  360. testIdentical(elements1, elements1.slice(), ["A", "B"]);
  361. testIdentical(elements1.slice(), elements1, ["A", "B"]);
  362. testIdentical(elements1.slice(), elements1.slice(), ["A", "B"]);
  363. const el1 = {
  364. id: "A",
  365. version: 1,
  366. versionNonce: 1,
  367. [PRECEDING_ELEMENT_KEY]: null,
  368. };
  369. const el2 = {
  370. id: "B",
  371. version: 1,
  372. versionNonce: 1,
  373. [PRECEDING_ELEMENT_KEY]: null,
  374. };
  375. testIdentical([el1, el2], [el2, el1], ["A", "B"]);
  376. });
  377. });