regressionTests.test.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564
  1. import { reseed } from "../random";
  2. import React from "react";
  3. import ReactDOM from "react-dom";
  4. import * as Renderer from "../renderer/renderScene";
  5. import { render, fireEvent } from "./test-utils";
  6. import { App } from "../components/App";
  7. import { ToolName } from "./queries/toolQueries";
  8. import { KEYS, Key } from "../keys";
  9. import { setDateTimeForTests } from "../utils";
  10. import { ExcalidrawElement } from "../element/types";
  11. import { handlerRectangles } from "../element";
  12. const { h } = window;
  13. const renderScene = jest.spyOn(Renderer, "renderScene");
  14. let getByToolName: (name: string) => HTMLElement = null!;
  15. let canvas: HTMLCanvasElement = null!;
  16. function clickTool(toolName: ToolName) {
  17. fireEvent.click(getByToolName(toolName));
  18. }
  19. let lastClientX = 0;
  20. let lastClientY = 0;
  21. let pointerType: "mouse" | "pen" | "touch" = "mouse";
  22. function pointerDown(
  23. clientX: number = lastClientX,
  24. clientY: number = lastClientY,
  25. altKey: boolean = false,
  26. shiftKey: boolean = false,
  27. ) {
  28. lastClientX = clientX;
  29. lastClientY = clientY;
  30. fireEvent.pointerDown(canvas, {
  31. clientX,
  32. clientY,
  33. altKey,
  34. shiftKey,
  35. pointerId: 1,
  36. pointerType,
  37. });
  38. }
  39. function pointer2Down(clientX: number, clientY: number) {
  40. fireEvent.pointerDown(canvas, {
  41. clientX,
  42. clientY,
  43. pointerId: 2,
  44. pointerType,
  45. });
  46. }
  47. function pointer2Move(clientX: number, clientY: number) {
  48. fireEvent.pointerMove(canvas, {
  49. clientX,
  50. clientY,
  51. pointerId: 2,
  52. pointerType,
  53. });
  54. }
  55. function pointer2Up(clientX: number, clientY: number) {
  56. fireEvent.pointerUp(canvas, {
  57. clientX,
  58. clientY,
  59. pointerId: 2,
  60. pointerType,
  61. });
  62. }
  63. function pointerMove(
  64. clientX: number = lastClientX,
  65. clientY: number = lastClientY,
  66. altKey: boolean = false,
  67. shiftKey: boolean = false,
  68. ) {
  69. lastClientX = clientX;
  70. lastClientY = clientY;
  71. fireEvent.pointerMove(canvas, {
  72. clientX,
  73. clientY,
  74. altKey,
  75. shiftKey,
  76. pointerId: 1,
  77. pointerType,
  78. });
  79. }
  80. function pointerUp(
  81. clientX: number = lastClientX,
  82. clientY: number = lastClientY,
  83. altKey: boolean = false,
  84. shiftKey: boolean = false,
  85. ) {
  86. lastClientX = clientX;
  87. lastClientY = clientY;
  88. fireEvent.pointerUp(canvas, { pointerId: 1, pointerType, shiftKey, altKey });
  89. }
  90. function hotkeyDown(key: Key) {
  91. fireEvent.keyDown(document, { key: KEYS[key] });
  92. }
  93. function hotkeyUp(key: Key) {
  94. fireEvent.keyUp(document, {
  95. key: KEYS[key],
  96. });
  97. }
  98. function keyDown(
  99. key: string,
  100. ctrlKey: boolean = false,
  101. shiftKey: boolean = false,
  102. ) {
  103. fireEvent.keyDown(document, { key, ctrlKey, shiftKey });
  104. }
  105. function keyUp(
  106. key: string,
  107. ctrlKey: boolean = false,
  108. shiftKey: boolean = false,
  109. ) {
  110. fireEvent.keyUp(document, {
  111. key,
  112. ctrlKey,
  113. shiftKey,
  114. });
  115. }
  116. function hotkeyPress(key: Key) {
  117. hotkeyDown(key);
  118. hotkeyUp(key);
  119. }
  120. function keyPress(
  121. key: string,
  122. ctrlKey: boolean = false,
  123. shiftKey: boolean = false,
  124. ) {
  125. keyDown(key, ctrlKey, shiftKey);
  126. keyUp(key, ctrlKey, shiftKey);
  127. }
  128. function clickLabeledElement(label: string) {
  129. const element = document.querySelector(`[aria-label='${label}']`);
  130. if (!element) {
  131. throw new Error(`No labeled element found: ${label}`);
  132. }
  133. fireEvent.click(element);
  134. }
  135. function getSelectedElement(): ExcalidrawElement {
  136. const selectedElements = h.elements.filter(
  137. (element) => h.state.selectedElementIds[element.id],
  138. );
  139. if (selectedElements.length !== 1) {
  140. throw new Error(
  141. `expected 1 selected element; got ${selectedElements.length}`,
  142. );
  143. }
  144. return selectedElements[0];
  145. }
  146. function getResizeHandles() {
  147. const rects = handlerRectangles(
  148. getSelectedElement(),
  149. h.state.zoom,
  150. pointerType,
  151. );
  152. const rv: { [K in keyof typeof rects]: [number, number] } = {} as any;
  153. for (const handlePos in rects) {
  154. const [x, y, width, height] = rects[handlePos as keyof typeof rects];
  155. rv[handlePos as keyof typeof rects] = [x + width / 2, y + height / 2];
  156. }
  157. return rv;
  158. }
  159. /**
  160. * This is always called at the end of your test, so usually you don't need to call it.
  161. * However, if you have a long test, you might want to call it during the test so it's easier
  162. * to debug where a test failure came from.
  163. */
  164. function checkpoint(name: string) {
  165. expect(renderScene.mock.calls.length).toMatchSnapshot(
  166. `[${name}] number of renders`,
  167. );
  168. expect(h.state).toMatchSnapshot(`[${name}] appState`);
  169. expect(h.history.getSnapshotForTest()).toMatchSnapshot(`[${name}] history`);
  170. expect(h.elements.length).toMatchSnapshot(`[${name}] number of elements`);
  171. h.elements.forEach((element, i) =>
  172. expect(element).toMatchSnapshot(`[${name}] element ${i}`),
  173. );
  174. }
  175. beforeEach(() => {
  176. // Unmount ReactDOM from root
  177. ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
  178. localStorage.clear();
  179. renderScene.mockClear();
  180. h.history.clear();
  181. reseed(7);
  182. setDateTimeForTests("201933152653");
  183. pointerType = "mouse";
  184. const renderResult = render(<App />);
  185. getByToolName = renderResult.getByToolName;
  186. canvas = renderResult.container.querySelector("canvas")!;
  187. });
  188. afterEach(() => {
  189. checkpoint("end of test");
  190. });
  191. describe("regression tests", () => {
  192. it("draw every type of shape", () => {
  193. clickTool("rectangle");
  194. pointerDown(10, 10);
  195. pointerMove(20, 20);
  196. pointerUp();
  197. clickTool("diamond");
  198. pointerDown(30, 10);
  199. pointerMove(40, 20);
  200. pointerUp();
  201. clickTool("ellipse");
  202. pointerDown(50, 10);
  203. pointerMove(60, 20);
  204. pointerUp();
  205. clickTool("arrow");
  206. pointerDown(70, 10);
  207. pointerMove(80, 20);
  208. pointerUp();
  209. clickTool("line");
  210. pointerDown(90, 10);
  211. pointerMove(100, 20);
  212. pointerUp();
  213. clickTool("arrow");
  214. pointerDown(10, 30);
  215. pointerUp();
  216. pointerMove(20, 40);
  217. pointerUp();
  218. pointerMove(10, 50);
  219. pointerUp();
  220. hotkeyPress("ENTER");
  221. clickTool("line");
  222. pointerDown(30, 30);
  223. pointerUp();
  224. pointerMove(40, 40);
  225. pointerUp();
  226. pointerMove(30, 50);
  227. pointerUp();
  228. hotkeyPress("ENTER");
  229. });
  230. it("click to select a shape", () => {
  231. clickTool("rectangle");
  232. pointerDown(10, 10);
  233. pointerMove(20, 20);
  234. pointerUp();
  235. clickTool("rectangle");
  236. pointerDown(30, 10);
  237. pointerMove(40, 20);
  238. pointerUp();
  239. const prevSelectedId = getSelectedElement().id;
  240. pointerDown(10, 10);
  241. pointerUp();
  242. expect(getSelectedElement().id).not.toEqual(prevSelectedId);
  243. });
  244. for (const [keys, shape] of [
  245. ["2r", "rectangle"],
  246. ["3d", "diamond"],
  247. ["4e", "ellipse"],
  248. ["5a", "arrow"],
  249. ["6l", "line"],
  250. ] as [string, ExcalidrawElement["type"]][]) {
  251. for (const key of keys) {
  252. it(`hotkey ${key} selects ${shape} tool`, () => {
  253. keyPress(key);
  254. pointerDown(10, 10);
  255. pointerMove(20, 20);
  256. pointerUp();
  257. expect(getSelectedElement().type).toBe(shape);
  258. });
  259. }
  260. }
  261. it("change the properties of a shape", () => {
  262. clickTool("rectangle");
  263. pointerDown(10, 10);
  264. pointerMove(20, 20);
  265. pointerUp();
  266. clickLabeledElement("Background");
  267. clickLabeledElement("#fa5252");
  268. clickLabeledElement("Stroke");
  269. clickLabeledElement("#5f3dc4");
  270. expect(getSelectedElement().backgroundColor).toBe("#fa5252");
  271. expect(getSelectedElement().strokeColor).toBe("#5f3dc4");
  272. });
  273. it("resize an element, trying every resize handle", () => {
  274. clickTool("rectangle");
  275. pointerDown(10, 10);
  276. pointerMove(20, 20);
  277. pointerUp();
  278. const resizeHandles = getResizeHandles();
  279. for (const handlePos in resizeHandles) {
  280. const [x, y] = resizeHandles[handlePos as keyof typeof resizeHandles];
  281. const { width: prevWidth, height: prevHeight } = getSelectedElement();
  282. pointerDown(x, y);
  283. pointerMove(x - 5, y - 5);
  284. pointerUp();
  285. const {
  286. width: nextWidthNegative,
  287. height: nextHeightNegative,
  288. } = getSelectedElement();
  289. expect(
  290. prevWidth !== nextWidthNegative || prevHeight !== nextHeightNegative,
  291. ).toBeTruthy();
  292. checkpoint(`resize handle ${handlePos} (-5, -5)`);
  293. pointerDown();
  294. pointerMove(x, y);
  295. pointerUp();
  296. const { width, height } = getSelectedElement();
  297. expect(width).toBe(prevWidth);
  298. expect(height).toBe(prevHeight);
  299. checkpoint(`unresize handle ${handlePos} (-5, -5)`);
  300. pointerDown(x, y);
  301. pointerMove(x + 5, y + 5);
  302. pointerUp();
  303. const {
  304. width: nextWidthPositive,
  305. height: nextHeightPositive,
  306. } = getSelectedElement();
  307. expect(
  308. prevWidth !== nextWidthPositive || prevHeight !== nextHeightPositive,
  309. ).toBeTruthy();
  310. checkpoint(`resize handle ${handlePos} (+5, +5)`);
  311. pointerDown();
  312. pointerMove(x, y);
  313. pointerUp();
  314. const { width: finalWidth, height: finalHeight } = getSelectedElement();
  315. expect(finalWidth).toBe(prevWidth);
  316. expect(finalHeight).toBe(prevHeight);
  317. checkpoint(`unresize handle ${handlePos} (+5, +5)`);
  318. }
  319. });
  320. it("click on an element and drag it", () => {
  321. clickTool("rectangle");
  322. pointerDown(10, 10);
  323. pointerMove(20, 20);
  324. pointerUp();
  325. const { x: prevX, y: prevY } = getSelectedElement();
  326. pointerDown(10, 10);
  327. pointerMove(20, 20);
  328. pointerUp();
  329. const { x: nextX, y: nextY } = getSelectedElement();
  330. expect(nextX).toBeGreaterThan(prevX);
  331. expect(nextY).toBeGreaterThan(prevY);
  332. checkpoint("dragged");
  333. pointerDown();
  334. pointerMove(10, 10);
  335. pointerUp();
  336. const { x, y } = getSelectedElement();
  337. expect(x).toBe(prevX);
  338. expect(y).toBe(prevY);
  339. });
  340. it("alt-drag duplicates an element", () => {
  341. clickTool("rectangle");
  342. pointerDown(10, 10);
  343. pointerMove(20, 20);
  344. pointerUp();
  345. expect(
  346. h.elements.filter((element) => element.type === "rectangle").length,
  347. ).toBe(1);
  348. pointerDown(10, 10, true);
  349. pointerMove(20, 20, true);
  350. pointerUp(20, 20, true);
  351. expect(
  352. h.elements.filter((element) => element.type === "rectangle").length,
  353. ).toBe(2);
  354. });
  355. it("click-drag to select a group", () => {
  356. clickTool("rectangle");
  357. pointerDown(10, 10);
  358. pointerMove(20, 20);
  359. pointerUp();
  360. clickTool("rectangle");
  361. pointerDown(30, 10);
  362. pointerMove(40, 20);
  363. pointerUp();
  364. clickTool("rectangle");
  365. pointerDown(50, 10);
  366. pointerMove(60, 20);
  367. pointerUp();
  368. pointerDown(0, 0);
  369. pointerMove(45, 25);
  370. pointerUp();
  371. expect(
  372. h.elements.filter((element) => h.state.selectedElementIds[element.id])
  373. .length,
  374. ).toBe(2);
  375. });
  376. it("shift-click to select a group, then drag", () => {
  377. clickTool("rectangle");
  378. pointerDown(10, 10);
  379. pointerMove(20, 20);
  380. pointerUp();
  381. clickTool("rectangle");
  382. pointerDown(30, 10);
  383. pointerMove(40, 20);
  384. pointerUp();
  385. const prevRectsXY = h.elements
  386. .filter((element) => element.type === "rectangle")
  387. .map((element) => ({ x: element.x, y: element.y }));
  388. pointerDown(10, 10);
  389. pointerUp();
  390. pointerDown(30, 10, false, true);
  391. pointerUp();
  392. pointerDown(30, 10);
  393. pointerMove(40, 20);
  394. pointerUp();
  395. h.elements
  396. .filter((element) => element.type === "rectangle")
  397. .forEach((element, i) => {
  398. expect(element.x).toBeGreaterThan(prevRectsXY[i].x);
  399. expect(element.y).toBeGreaterThan(prevRectsXY[i].y);
  400. });
  401. });
  402. it("pinch-to-zoom works", () => {
  403. expect(h.state.zoom).toBe(1);
  404. pointerType = "touch";
  405. pointerDown(50, 50);
  406. pointer2Down(60, 50);
  407. pointerMove(40, 50);
  408. pointer2Move(60, 50);
  409. expect(h.state.zoom).toBeGreaterThan(1);
  410. const zoomed = h.state.zoom;
  411. pointerMove(45, 50);
  412. pointer2Move(55, 50);
  413. expect(h.state.zoom).toBeLessThan(zoomed);
  414. pointerUp(45, 50);
  415. pointer2Up(55, 50);
  416. });
  417. it("two-finger scroll works", () => {
  418. const startScrollY = h.state.scrollY;
  419. pointerDown(50, 50);
  420. pointer2Down(60, 50);
  421. pointerMove(50, 40);
  422. pointer2Move(60, 40);
  423. pointerUp(50, 40);
  424. pointer2Up(60, 40);
  425. expect(h.state.scrollY).toBeLessThan(startScrollY);
  426. const startScrollX = h.state.scrollX;
  427. pointerDown(50, 50);
  428. pointer2Down(50, 60);
  429. pointerMove(60, 50);
  430. pointer2Move(60, 60);
  431. pointerUp(60, 50);
  432. pointer2Up(60, 60);
  433. expect(h.state.scrollX).toBeGreaterThan(startScrollX);
  434. });
  435. it("spacebar + drag scrolls the canvas", () => {
  436. const { scrollX: startScrollX, scrollY: startScrollY } = h.state;
  437. hotkeyDown("SPACE");
  438. pointerDown(50, 50);
  439. pointerMove(60, 60);
  440. pointerUp();
  441. hotkeyUp("SPACE");
  442. const { scrollX, scrollY } = h.state;
  443. expect(scrollX).not.toEqual(startScrollX);
  444. expect(scrollY).not.toEqual(startScrollY);
  445. });
  446. it("arrow keys", () => {
  447. clickTool("rectangle");
  448. pointerDown(10, 10);
  449. pointerMove(20, 20);
  450. pointerUp();
  451. hotkeyPress("ARROW_LEFT");
  452. hotkeyPress("ARROW_LEFT");
  453. hotkeyPress("ARROW_RIGHT");
  454. hotkeyPress("ARROW_UP");
  455. hotkeyPress("ARROW_UP");
  456. hotkeyPress("ARROW_DOWN");
  457. });
  458. it("undo/redo drawing an element", () => {
  459. clickTool("rectangle");
  460. pointerDown(10, 10);
  461. pointerMove(20, 20);
  462. pointerUp();
  463. clickTool("rectangle");
  464. pointerDown(30, 10);
  465. pointerMove(40, 20);
  466. pointerUp();
  467. clickTool("rectangle");
  468. pointerDown(50, 10);
  469. pointerMove(60, 20);
  470. pointerUp();
  471. expect(h.elements.filter((element) => !element.isDeleted).length).toBe(3);
  472. keyPress("z", true);
  473. expect(h.elements.filter((element) => !element.isDeleted).length).toBe(2);
  474. keyPress("z", true);
  475. expect(h.elements.filter((element) => !element.isDeleted).length).toBe(1);
  476. keyPress("z", true, true);
  477. expect(h.elements.filter((element) => !element.isDeleted).length).toBe(2);
  478. });
  479. it("zoom hotkeys", () => {
  480. expect(h.state.zoom).toBe(1);
  481. fireEvent.keyDown(document, { code: "Equal", ctrlKey: true });
  482. fireEvent.keyUp(document, { code: "Equal", ctrlKey: true });
  483. expect(h.state.zoom).toBeGreaterThan(1);
  484. fireEvent.keyDown(document, { code: "Minus", ctrlKey: true });
  485. fireEvent.keyUp(document, { code: "Minus", ctrlKey: true });
  486. expect(h.state.zoom).toBe(1);
  487. });
  488. });