restore.test.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536
  1. import * as restore from "../../data/restore";
  2. import {
  3. ExcalidrawElement,
  4. ExcalidrawFreeDrawElement,
  5. ExcalidrawLinearElement,
  6. ExcalidrawTextElement,
  7. } from "../../element/types";
  8. import * as sizeHelpers from "../../element/sizeHelpers";
  9. import { API } from "../helpers/api";
  10. import { getDefaultAppState } from "../../appState";
  11. import { ImportedDataState } from "../../data/types";
  12. import { NormalizedZoomValue } from "../../types";
  13. import { FONT_FAMILY } from "../../constants";
  14. import { newElementWith } from "../../element/mutateElement";
  15. const mockSizeHelper = jest.spyOn(sizeHelpers, "isInvisiblySmallElement");
  16. beforeEach(() => {
  17. mockSizeHelper.mockReset();
  18. });
  19. describe("restoreElements", () => {
  20. it("should return empty array when element is null", () => {
  21. expect(restore.restoreElements(null, null)).toStrictEqual([]);
  22. });
  23. it("should not call isInvisiblySmallElement when element is a selection element", () => {
  24. const selectionEl = { type: "selection" } as ExcalidrawElement;
  25. const restoreElements = restore.restoreElements([selectionEl], null);
  26. expect(restoreElements.length).toBe(0);
  27. expect(sizeHelpers.isInvisiblySmallElement).toBeCalledTimes(0);
  28. });
  29. it("should return empty array when input type is not supported", () => {
  30. const dummyNotSupportedElement: any = API.createElement({
  31. type: "text",
  32. });
  33. dummyNotSupportedElement.type = "not supported";
  34. expect(
  35. restore.restoreElements([dummyNotSupportedElement], null).length,
  36. ).toBe(0);
  37. });
  38. it("should return empty array when isInvisiblySmallElement is true", () => {
  39. const rectElement = API.createElement({ type: "rectangle" });
  40. mockSizeHelper.mockImplementation(() => true);
  41. expect(restore.restoreElements([rectElement], null).length).toBe(0);
  42. });
  43. it("should restore text element correctly passing value for each attribute", () => {
  44. const textElement = API.createElement({
  45. type: "text",
  46. fontSize: 14,
  47. fontFamily: FONT_FAMILY.Virgil,
  48. text: "text",
  49. textAlign: "center",
  50. verticalAlign: "middle",
  51. id: "id-text01",
  52. });
  53. const restoredText = restore.restoreElements(
  54. [textElement],
  55. null,
  56. )[0] as ExcalidrawTextElement;
  57. expect(restoredText).toMatchSnapshot({
  58. seed: expect.any(Number),
  59. });
  60. });
  61. it("should restore text element correctly with unknown font family, null text and undefined alignment", () => {
  62. const textElement: any = API.createElement({
  63. type: "text",
  64. textAlign: undefined,
  65. verticalAlign: undefined,
  66. id: "id-text01",
  67. });
  68. textElement.text = null;
  69. textElement.font = "10 unknown";
  70. const restoredText = restore.restoreElements(
  71. [textElement],
  72. null,
  73. )[0] as ExcalidrawTextElement;
  74. expect(restoredText).toMatchSnapshot({
  75. seed: expect.any(Number),
  76. });
  77. });
  78. it("should restore freedraw element correctly", () => {
  79. const freedrawElement = API.createElement({
  80. type: "freedraw",
  81. id: "id-freedraw01",
  82. });
  83. const restoredFreedraw = restore.restoreElements(
  84. [freedrawElement],
  85. null,
  86. )[0] as ExcalidrawFreeDrawElement;
  87. expect(restoredFreedraw).toMatchSnapshot({ seed: expect.any(Number) });
  88. });
  89. it("should restore line and draw elements correctly", () => {
  90. const lineElement = API.createElement({ type: "line", id: "id-line01" });
  91. const drawElement: any = API.createElement({
  92. type: "line",
  93. id: "id-draw01",
  94. });
  95. drawElement.type = "draw";
  96. const restoredElements = restore.restoreElements(
  97. [lineElement, drawElement],
  98. null,
  99. );
  100. const restoredLine = restoredElements[0] as ExcalidrawLinearElement;
  101. const restoredDraw = restoredElements[1] as ExcalidrawLinearElement;
  102. expect(restoredLine).toMatchSnapshot({ seed: expect.any(Number) });
  103. expect(restoredDraw).toMatchSnapshot({ seed: expect.any(Number) });
  104. });
  105. it("should restore arrow element correctly", () => {
  106. const arrowElement = API.createElement({ type: "arrow", id: "id-arrow01" });
  107. const restoredElements = restore.restoreElements([arrowElement], null);
  108. const restoredArrow = restoredElements[0] as ExcalidrawLinearElement;
  109. expect(restoredArrow).toMatchSnapshot({ seed: expect.any(Number) });
  110. });
  111. it("when arrow element has defined endArrowHead", () => {
  112. const arrowElement = API.createElement({ type: "arrow" });
  113. const restoredElements = restore.restoreElements([arrowElement], null);
  114. const restoredArrow = restoredElements[0] as ExcalidrawLinearElement;
  115. expect(arrowElement.endArrowhead).toBe(restoredArrow.endArrowhead);
  116. });
  117. it("when arrow element has undefined endArrowHead", () => {
  118. const arrowElement = API.createElement({ type: "arrow" });
  119. Object.defineProperty(arrowElement, "endArrowhead", {
  120. get: jest.fn(() => undefined),
  121. });
  122. const restoredElements = restore.restoreElements([arrowElement], null);
  123. const restoredArrow = restoredElements[0] as ExcalidrawLinearElement;
  124. expect(restoredArrow.endArrowhead).toBe("arrow");
  125. });
  126. it("when element.points of a line element is not an array", () => {
  127. const lineElement: any = API.createElement({
  128. type: "line",
  129. width: 100,
  130. height: 200,
  131. });
  132. lineElement.points = "not an array";
  133. const expectedLinePoints = [
  134. [0, 0],
  135. [lineElement.width, lineElement.height],
  136. ];
  137. const restoredLine = restore.restoreElements(
  138. [lineElement],
  139. null,
  140. )[0] as ExcalidrawLinearElement;
  141. expect(restoredLine.points).toMatchObject(expectedLinePoints);
  142. });
  143. it("when the number of points of a line is greater or equal 2", () => {
  144. const lineElement_0 = API.createElement({
  145. type: "line",
  146. width: 100,
  147. height: 200,
  148. x: 10,
  149. y: 20,
  150. });
  151. const lineElement_1 = API.createElement({
  152. type: "line",
  153. width: 200,
  154. height: 400,
  155. x: 30,
  156. y: 40,
  157. });
  158. const pointsEl_0 = [
  159. [0, 0],
  160. [1, 1],
  161. ];
  162. Object.defineProperty(lineElement_0, "points", {
  163. get: jest.fn(() => pointsEl_0),
  164. });
  165. const pointsEl_1 = [
  166. [3, 4],
  167. [5, 6],
  168. ];
  169. Object.defineProperty(lineElement_1, "points", {
  170. get: jest.fn(() => pointsEl_1),
  171. });
  172. const restoredElements = restore.restoreElements(
  173. [lineElement_0, lineElement_1],
  174. null,
  175. );
  176. const restoredLine_0 = restoredElements[0] as ExcalidrawLinearElement;
  177. const restoredLine_1 = restoredElements[1] as ExcalidrawLinearElement;
  178. expect(restoredLine_0.points).toMatchObject(pointsEl_0);
  179. const offsetX = pointsEl_1[0][0];
  180. const offsetY = pointsEl_1[0][1];
  181. const restoredPointsEl1 = [
  182. [pointsEl_1[0][0] - offsetX, pointsEl_1[0][1] - offsetY],
  183. [pointsEl_1[1][0] - offsetX, pointsEl_1[1][1] - offsetY],
  184. ];
  185. expect(restoredLine_1.points).toMatchObject(restoredPointsEl1);
  186. expect(restoredLine_1.x).toBe(lineElement_1.x + offsetX);
  187. expect(restoredLine_1.y).toBe(lineElement_1.y + offsetY);
  188. });
  189. it("should restore correctly with rectangle, ellipse and diamond elements", () => {
  190. const types = ["rectangle", "ellipse", "diamond"];
  191. const elements: ExcalidrawElement[] = [];
  192. let idCount = 0;
  193. types.forEach((elType) => {
  194. idCount += 1;
  195. const element = API.createElement({
  196. type: elType as "rectangle" | "ellipse" | "diamond",
  197. id: idCount.toString(),
  198. fillStyle: "cross-hatch",
  199. strokeWidth: 2,
  200. strokeStyle: "dashed",
  201. roughness: 2,
  202. opacity: 10,
  203. x: 10,
  204. y: 20,
  205. strokeColor: "red",
  206. backgroundColor: "blue",
  207. width: 100,
  208. height: 200,
  209. groupIds: ["1", "2", "3"],
  210. strokeSharpness: "round",
  211. });
  212. elements.push(element);
  213. });
  214. const restoredElements = restore.restoreElements(elements, null);
  215. expect(restoredElements[0]).toMatchSnapshot({ seed: expect.any(Number) });
  216. expect(restoredElements[1]).toMatchSnapshot({ seed: expect.any(Number) });
  217. expect(restoredElements[2]).toMatchSnapshot({ seed: expect.any(Number) });
  218. });
  219. it("bump versions of local duplicate elements when supplied", () => {
  220. const rectangle = API.createElement({ type: "rectangle" });
  221. const ellipse = API.createElement({ type: "ellipse" });
  222. const rectangle_modified = newElementWith(rectangle, { isDeleted: true });
  223. const restoredElements = restore.restoreElements(
  224. [rectangle, ellipse],
  225. [rectangle_modified],
  226. );
  227. expect(restoredElements[0].id).toBe(rectangle.id);
  228. expect(restoredElements[0].versionNonce).not.toBe(rectangle.versionNonce);
  229. expect(restoredElements).toEqual([
  230. expect.objectContaining({
  231. id: rectangle.id,
  232. version: rectangle_modified.version + 1,
  233. }),
  234. expect.objectContaining({
  235. id: ellipse.id,
  236. version: ellipse.version,
  237. versionNonce: ellipse.versionNonce,
  238. }),
  239. ]);
  240. });
  241. });
  242. describe("restoreAppState", () => {
  243. it("should restore with imported data", () => {
  244. const stubImportedAppState = getDefaultAppState();
  245. stubImportedAppState.activeTool.type = "selection";
  246. stubImportedAppState.cursorButton = "down";
  247. stubImportedAppState.name = "imported app state";
  248. const stubLocalAppState = getDefaultAppState();
  249. stubLocalAppState.activeTool.type = "rectangle";
  250. stubLocalAppState.cursorButton = "up";
  251. stubLocalAppState.name = "local app state";
  252. const restoredAppState = restore.restoreAppState(
  253. stubImportedAppState,
  254. stubLocalAppState,
  255. );
  256. expect(restoredAppState.activeTool).toEqual(
  257. stubImportedAppState.activeTool,
  258. );
  259. expect(restoredAppState.cursorButton).toBe(
  260. stubImportedAppState.cursorButton,
  261. );
  262. expect(restoredAppState.name).toBe(stubImportedAppState.name);
  263. });
  264. it("should restore with current app state when imported data state is undefined", () => {
  265. const stubImportedAppState = {
  266. ...getDefaultAppState(),
  267. cursorButton: undefined,
  268. name: undefined,
  269. };
  270. const stubLocalAppState = getDefaultAppState();
  271. stubLocalAppState.cursorButton = "down";
  272. stubLocalAppState.name = "local app state";
  273. const restoredAppState = restore.restoreAppState(
  274. stubImportedAppState,
  275. stubLocalAppState,
  276. );
  277. expect(restoredAppState.cursorButton).toBe(stubLocalAppState.cursorButton);
  278. expect(restoredAppState.name).toBe(stubLocalAppState.name);
  279. });
  280. it("should return imported data when local app state is null", () => {
  281. const stubImportedAppState = getDefaultAppState();
  282. stubImportedAppState.cursorButton = "down";
  283. stubImportedAppState.name = "imported app state";
  284. const restoredAppState = restore.restoreAppState(
  285. stubImportedAppState,
  286. null,
  287. );
  288. expect(restoredAppState.cursorButton).toBe(
  289. stubImportedAppState.cursorButton,
  290. );
  291. expect(restoredAppState.name).toBe(stubImportedAppState.name);
  292. });
  293. it("should return local app state when imported data state is null", () => {
  294. const stubLocalAppState = getDefaultAppState();
  295. stubLocalAppState.cursorButton = "down";
  296. stubLocalAppState.name = "local app state";
  297. const restoredAppState = restore.restoreAppState(null, stubLocalAppState);
  298. expect(restoredAppState.cursorButton).toBe(stubLocalAppState.cursorButton);
  299. expect(restoredAppState.name).toBe(stubLocalAppState.name);
  300. });
  301. it("should return default app state when imported data state and local app state are undefined", () => {
  302. const stubImportedAppState = {
  303. ...getDefaultAppState(),
  304. cursorButton: undefined,
  305. };
  306. const stubLocalAppState = {
  307. ...getDefaultAppState(),
  308. cursorButton: undefined,
  309. };
  310. const restoredAppState = restore.restoreAppState(
  311. stubImportedAppState,
  312. stubLocalAppState,
  313. );
  314. expect(restoredAppState.cursorButton).toBe(
  315. getDefaultAppState().cursorButton,
  316. );
  317. });
  318. it("should return default app state when imported data state and local app state are null", () => {
  319. const restoredAppState = restore.restoreAppState(null, null);
  320. expect(restoredAppState.cursorButton).toBe(
  321. getDefaultAppState().cursorButton,
  322. );
  323. });
  324. it("when imported data state has a not allowed Excalidraw Element Types", () => {
  325. const stubImportedAppState: any = getDefaultAppState();
  326. stubImportedAppState.activeTool = "not allowed Excalidraw Element Types";
  327. const stubLocalAppState = getDefaultAppState();
  328. const restoredAppState = restore.restoreAppState(
  329. stubImportedAppState,
  330. stubLocalAppState,
  331. );
  332. expect(restoredAppState.activeTool.type).toBe("selection");
  333. });
  334. describe("with zoom in imported data state", () => {
  335. it("when imported data state has zoom as a number", () => {
  336. const stubImportedAppState: any = getDefaultAppState();
  337. stubImportedAppState.zoom = 10;
  338. const stubLocalAppState = getDefaultAppState();
  339. const restoredAppState = restore.restoreAppState(
  340. stubImportedAppState,
  341. stubLocalAppState,
  342. );
  343. expect(restoredAppState.zoom.value).toBe(10);
  344. });
  345. it("when the zoom of imported data state is not a number", () => {
  346. const stubImportedAppState = getDefaultAppState();
  347. stubImportedAppState.zoom = {
  348. value: 10 as NormalizedZoomValue,
  349. };
  350. const stubLocalAppState = getDefaultAppState();
  351. const restoredAppState = restore.restoreAppState(
  352. stubImportedAppState,
  353. stubLocalAppState,
  354. );
  355. expect(restoredAppState.zoom.value).toBe(10);
  356. expect(restoredAppState.zoom).toMatchObject(stubImportedAppState.zoom);
  357. });
  358. it("when the zoom of imported data state zoom is null", () => {
  359. const stubImportedAppState = getDefaultAppState();
  360. Object.defineProperty(stubImportedAppState, "zoom", {
  361. get: jest.fn(() => null),
  362. });
  363. const stubLocalAppState = getDefaultAppState();
  364. const restoredAppState = restore.restoreAppState(
  365. stubImportedAppState,
  366. stubLocalAppState,
  367. );
  368. expect(restoredAppState.zoom).toMatchObject(getDefaultAppState().zoom);
  369. });
  370. });
  371. });
  372. describe("restore", () => {
  373. it("when imported data state is null it should return an empty array of elements", () => {
  374. const stubLocalAppState = getDefaultAppState();
  375. const restoredData = restore.restore(null, stubLocalAppState, null);
  376. expect(restoredData.elements.length).toBe(0);
  377. });
  378. it("when imported data state is null it should return the local app state property", () => {
  379. const stubLocalAppState = getDefaultAppState();
  380. stubLocalAppState.cursorButton = "down";
  381. stubLocalAppState.name = "local app state";
  382. const restoredData = restore.restore(null, stubLocalAppState, null);
  383. expect(restoredData.appState.cursorButton).toBe(
  384. stubLocalAppState.cursorButton,
  385. );
  386. expect(restoredData.appState.name).toBe(stubLocalAppState.name);
  387. });
  388. it("when imported data state has elements", () => {
  389. const stubLocalAppState = getDefaultAppState();
  390. const textElement = API.createElement({ type: "text" });
  391. const rectElement = API.createElement({ type: "rectangle" });
  392. const elements = [textElement, rectElement];
  393. const importedDataState = {} as ImportedDataState;
  394. importedDataState.elements = elements;
  395. const restoredData = restore.restore(
  396. importedDataState,
  397. stubLocalAppState,
  398. null,
  399. );
  400. expect(restoredData.elements.length).toBe(elements.length);
  401. });
  402. it("when local app state is null but imported app state is supplied", () => {
  403. const stubImportedAppState = getDefaultAppState();
  404. stubImportedAppState.cursorButton = "down";
  405. stubImportedAppState.name = "imported app state";
  406. const importedDataState = {} as ImportedDataState;
  407. importedDataState.appState = stubImportedAppState;
  408. const restoredData = restore.restore(importedDataState, null, null);
  409. expect(restoredData.appState.cursorButton).toBe(
  410. stubImportedAppState.cursorButton,
  411. );
  412. expect(restoredData.appState.name).toBe(stubImportedAppState.name);
  413. });
  414. it("bump versions of local duplicate elements when supplied", () => {
  415. const rectangle = API.createElement({ type: "rectangle" });
  416. const ellipse = API.createElement({ type: "ellipse" });
  417. const rectangle_modified = newElementWith(rectangle, { isDeleted: true });
  418. const restoredData = restore.restore(
  419. { elements: [rectangle, ellipse] },
  420. null,
  421. [rectangle_modified],
  422. );
  423. expect(restoredData.elements[0].id).toBe(rectangle.id);
  424. expect(restoredData.elements[0].versionNonce).not.toBe(
  425. rectangle.versionNonce,
  426. );
  427. expect(restoredData.elements).toEqual([
  428. expect.objectContaining({ version: rectangle_modified.version + 1 }),
  429. expect.objectContaining({
  430. id: ellipse.id,
  431. version: ellipse.version,
  432. versionNonce: ellipse.versionNonce,
  433. }),
  434. ]);
  435. });
  436. });