You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

test-utils.ts 12KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592
  1. import _state from 'state'
  2. import tld from 'utils/tld'
  3. import inputs from 'state/inputs'
  4. import { createShape, getShapeUtils } from 'state/shape-utils'
  5. import { Data, Shape, ShapeType, ShapeUtility } from 'types'
  6. import { deepCompareArrays, uniqueId, vec } from 'utils'
  7. import * as json from './__mocks__/document.json'
  8. type State = typeof _state
  9. export const rectangleId = '1f6c251c-e12e-40b4-8dd2-c1847d80b72f'
  10. export const arrowId = '5ca167d7-54de-47c9-aa8f-86affa25e44d'
  11. interface PointerOptions {
  12. id?: number
  13. x?: number
  14. y?: number
  15. shiftKey?: boolean
  16. altKey?: boolean
  17. ctrlKey?: boolean
  18. }
  19. class TestState {
  20. state: State
  21. constructor() {
  22. this.state = _state
  23. this.reset()
  24. }
  25. /**
  26. * Reset the test state.
  27. *
  28. * ### Example
  29. *
  30. *```ts
  31. * tt.reset()
  32. *```
  33. */
  34. reset(): TestState {
  35. this.state.reset()
  36. this.state
  37. .send('MOUNTED')
  38. .send('LOADED_FROM_FILE', { json: JSON.stringify(json) })
  39. return this
  40. }
  41. /**
  42. * Reset the document state. Will remove all shapes and extra pages.
  43. *
  44. * ### Example
  45. *
  46. *```ts
  47. * tt.resetDocumentState()
  48. *```
  49. */
  50. resetDocumentState(): TestState {
  51. this.state.send('RESET_DOCUMENT_STATE')
  52. return this
  53. }
  54. /**
  55. * Send a message to the state.
  56. *
  57. * ### Example
  58. *
  59. *```ts
  60. * tt.send("MOVED_TO_FRONT")
  61. *```
  62. */
  63. send(eventName: string, payload?: unknown): TestState {
  64. this.state.send(eventName, payload)
  65. return this
  66. }
  67. /**
  68. * Create a new shape on the current page. Optionally provide an id.
  69. *
  70. * ### Example
  71. *
  72. *```ts
  73. * tt.createShape({ type: ShapeType.Rectangle, point: [100, 100]})
  74. * tt.createShape({ type: ShapeType.Rectangle, point: [100, 100]}, "myId")
  75. *```
  76. */
  77. createShape(props: Partial<Shape>, id = uniqueId()): TestState {
  78. const shape = createShape(props.type, props)
  79. getShapeUtils(shape).setProperty(shape, 'id', id)
  80. this.data.document.pages[this.data.currentPageId].shapes[shape.id] = shape
  81. return this
  82. }
  83. /**
  84. * Get whether the provided ids are the current selected ids. If the `strict` argument is `true`, then the result will be false if the state has selected ids in addition to those provided.
  85. *
  86. * ### Example
  87. *
  88. *```ts
  89. * tt.idsAreSelected(state.data, ['rectangleId', 'ellipseId'])
  90. * tt.idsAreSelected(state.data, ['rectangleId', 'ellipseId'], true)
  91. *```
  92. */
  93. idsAreSelected(ids: string[], strict = true): boolean {
  94. const selectedIds = tld.getSelectedIds(this.data)
  95. return (
  96. (strict ? selectedIds.size === ids.length : true) &&
  97. ids.every((id) => selectedIds.has(id))
  98. )
  99. }
  100. /**
  101. * Get whether the shape with the provided id has the provided parent id.
  102. *
  103. * ### Example
  104. *
  105. *```ts
  106. * tt.hasParent('childId', 'parentId')
  107. *```
  108. */
  109. hasParent(childId: string, parentId: string): boolean {
  110. return tld.getShape(this.data, childId).parentId === parentId
  111. }
  112. /**
  113. * Get the only selected shape. If more than one shape is selected, the test will fail.
  114. *
  115. * ### Example
  116. *
  117. *```ts
  118. * tt.getOnlySelectedShape()
  119. *```
  120. */
  121. getOnlySelectedShape(): Shape {
  122. const selectedShapes = tld.getSelectedShapes(this.data)
  123. return selectedShapes.length === 1 ? selectedShapes[0] : undefined
  124. }
  125. /**
  126. * Assert that a shape has the provided type.
  127. *
  128. * ### Example
  129. *
  130. *```ts
  131. * tt.example
  132. *```
  133. */
  134. assertShapeType(shapeId: string, type: ShapeType): boolean {
  135. const shape = tld.getShape(this.data, shapeId)
  136. if (shape.type !== type) {
  137. throw new TypeError(
  138. `expected shape ${shapeId} to be of type ${type}, found ${shape?.type} instead`
  139. )
  140. }
  141. return true
  142. }
  143. /**
  144. * Assert that the provided shape has the provided props.
  145. *
  146. * ### Example
  147. *
  148. *```
  149. * tt.assertShapeProps(myShape, { point: [0,0], style: { color: ColorStyle.Blue } } )
  150. *```
  151. */
  152. assertShapeProps<T extends Shape>(
  153. shape: T,
  154. props: { [K in keyof Partial<T>]: T[K] }
  155. ): boolean {
  156. for (const key in props) {
  157. let result: boolean
  158. const value = props[key]
  159. if (Array.isArray(value)) {
  160. result = deepCompareArrays(value, shape[key] as typeof value)
  161. } else if (typeof value === 'object') {
  162. const target = shape[key] as typeof value
  163. result =
  164. target &&
  165. Object.entries(value).every(([k, v]) => target[k] === props[key][v])
  166. } else {
  167. result = shape[key] === value
  168. }
  169. if (!result) {
  170. throw new TypeError(
  171. `expected shape ${shape.id} to have property ${key}: ${props[key]}, found ${key}: ${shape[key]} instead`
  172. )
  173. }
  174. }
  175. return true
  176. }
  177. /**
  178. * Click a shape.
  179. *
  180. * ### Example
  181. *
  182. *```ts
  183. * tt.clickShape("myShapeId")
  184. *```
  185. */
  186. clickShape(id: string, options: PointerOptions = {}): TestState {
  187. this.state
  188. .send('POINTED_SHAPE', inputs.pointerDown(TestState.point(options), id))
  189. .send('STOPPED_POINTING', inputs.pointerUp(TestState.point(options), id))
  190. return this
  191. }
  192. /**
  193. * Start a click (but do not stop it).
  194. *
  195. * ### Example
  196. *
  197. *```ts
  198. * tt.startClick("myShapeId")
  199. *```
  200. */
  201. startClick(id: string, options: PointerOptions = {}): TestState {
  202. this.state.send(
  203. 'POINTED_SHAPE',
  204. inputs.pointerDown(TestState.point(options), id)
  205. )
  206. return this
  207. }
  208. /**
  209. * Stop a click (after starting it).
  210. *
  211. * ### Example
  212. *
  213. *```ts
  214. * tt.stopClick("myShapeId")
  215. *```
  216. */
  217. stopClick(id: string, options: PointerOptions = {}): TestState {
  218. this.state.send(
  219. 'STOPPED_POINTING',
  220. inputs.pointerUp(TestState.point(options), id)
  221. )
  222. return this
  223. }
  224. /**
  225. * Double click a shape.
  226. *
  227. * ### Example
  228. *
  229. *```ts
  230. * tt.clickShape("myShapeId")
  231. *```
  232. */
  233. doubleClickShape(id: string, options: PointerOptions = {}): TestState {
  234. this.state
  235. .send(
  236. 'DOUBLE_POINTED_SHAPE',
  237. inputs.pointerDown(TestState.point(options), id)
  238. )
  239. .send('STOPPED_POINTING', inputs.pointerUp(TestState.point(options), id))
  240. return this
  241. }
  242. /**
  243. * Click the canvas.
  244. *
  245. * ### Example
  246. *
  247. *```ts
  248. * tt.clickCanvas("myShapeId")
  249. *```
  250. */
  251. clickCanvas(options: PointerOptions = {}): TestState {
  252. this.state
  253. .send(
  254. 'POINTED_CANVAS',
  255. inputs.pointerDown(TestState.point(options), 'canvas')
  256. )
  257. .send(
  258. 'STOPPED_POINTING',
  259. inputs.pointerUp(TestState.point(options), 'canvas')
  260. )
  261. return this
  262. }
  263. /**
  264. * Click the background / body of the bounding box.
  265. *
  266. * ### Example
  267. *
  268. *```ts
  269. * tt.clickBounds()
  270. *```
  271. */
  272. clickBounds(options: PointerOptions = {}): TestState {
  273. this.state
  274. .send(
  275. 'POINTED_BOUNDS',
  276. inputs.pointerDown(TestState.point(options), 'bounds')
  277. )
  278. .send(
  279. 'STOPPED_POINTING',
  280. inputs.pointerUp(TestState.point(options), 'bounds')
  281. )
  282. return this
  283. }
  284. /**
  285. * Move the pointer to a new point, or to several points in order.
  286. *
  287. * ### Example
  288. *
  289. *```ts
  290. * tt.movePointerTo([100, 100])
  291. * tt.movePointerTo([100, 100], { shiftKey: true })
  292. * tt.movePointerTo([[100, 100], [150, 150], [200, 200]])
  293. *```
  294. */
  295. movePointerTo(
  296. to: number[] | number[][],
  297. options: Omit<PointerOptions, 'x' | 'y'> = {}
  298. ): TestState {
  299. if (Array.isArray(to[0])) {
  300. ;(to as number[][]).forEach(([x, y]) => {
  301. this.state.send(
  302. 'MOVED_POINTER',
  303. inputs.pointerMove(TestState.point({ x, y, ...options }))
  304. )
  305. })
  306. } else {
  307. const [x, y] = to as number[]
  308. this.state.send(
  309. 'MOVED_POINTER',
  310. inputs.pointerMove(TestState.point({ x, y, ...options }))
  311. )
  312. }
  313. return this
  314. }
  315. /**
  316. * Move the pointer by a delta.
  317. *
  318. * ### Example
  319. *
  320. *```ts
  321. * tt.movePointerBy([10,10])
  322. * tt.movePointerBy([10,10], { shiftKey: true })
  323. *```
  324. */
  325. movePointerBy(
  326. by: number[] | number[][],
  327. options: Omit<PointerOptions, 'x' | 'y'> = {}
  328. ): TestState {
  329. let pt = inputs.pointer?.point || [0, 0]
  330. if (Array.isArray(by[0])) {
  331. ;(by as number[][]).forEach((delta) => {
  332. pt = vec.add(pt, delta)
  333. this.state.send(
  334. 'MOVED_POINTER',
  335. inputs.pointerMove(
  336. TestState.point({ x: pt[0], y: pt[1], ...options })
  337. )
  338. )
  339. })
  340. } else {
  341. pt = vec.add(pt, by as number[])
  342. this.state.send(
  343. 'MOVED_POINTER',
  344. inputs.pointerMove(TestState.point({ x: pt[0], y: pt[1], ...options }))
  345. )
  346. }
  347. return this
  348. }
  349. /**
  350. * Move pointer over a shape. Will move the pointer to the top-left corner of the shape.
  351. *
  352. * ###
  353. * ```
  354. * tt.movePointerOverShape('myShapeId', [100, 100])
  355. * ```
  356. */
  357. movePointerOverShape(
  358. id: string,
  359. options: Omit<PointerOptions, 'x' | 'y'> = {}
  360. ): TestState {
  361. const shape = tld.getShape(this.state.data, id)
  362. const [x, y] = vec.add(shape.point, [1, 1])
  363. this.state.send(
  364. 'MOVED_OVER_SHAPE',
  365. inputs.pointerEnter(TestState.point({ x, y, ...options }), id)
  366. )
  367. return this
  368. }
  369. /**
  370. * Move the pointer over a group. Will move the pointer to the top-left corner of the group.
  371. *
  372. * ### Example
  373. *
  374. *```ts
  375. * tt.movePointerOverHandle('myGroupId')
  376. * tt.movePointerOverHandle('myGroupId', { shiftKey: true })
  377. *```
  378. */
  379. movePointerOverGroup(
  380. id: string,
  381. options: Omit<PointerOptions, 'x' | 'y'> = {}
  382. ): TestState {
  383. const shape = tld.getShape(this.state.data, id)
  384. const [x, y] = vec.add(shape.point, [1, 1])
  385. this.state.send(
  386. 'MOVED_OVER_GROUP',
  387. inputs.pointerEnter(TestState.point({ x, y, ...options }), id)
  388. )
  389. return this
  390. }
  391. /**
  392. * Move the pointer over a handle. Will move the pointer to the top-left corner of the handle.
  393. *
  394. * ### Example
  395. *
  396. *```ts
  397. * tt.movePointerOverHandle('bend')
  398. * tt.movePointerOverHandle('bend', { shiftKey: true })
  399. *```
  400. */
  401. movePointerOverHandle(
  402. id: string,
  403. options: Omit<PointerOptions, 'x' | 'y'> = {}
  404. ): TestState {
  405. const shape = tld.getShape(this.state.data, id)
  406. const handle = shape.handles?.[id]
  407. const [x, y] = vec.add(handle.point, [1, 1])
  408. this.state.send(
  409. 'MOVED_OVER_HANDLE',
  410. inputs.pointerEnter(TestState.point({ x, y, ...options }), id)
  411. )
  412. return this
  413. }
  414. /**
  415. * Deselect all shapes.
  416. *
  417. * ### Example
  418. *
  419. *```ts
  420. * tt.deselectAll()
  421. *```
  422. */
  423. deselectAll(): TestState {
  424. this.state.send('DESELECTED_ALL')
  425. return this
  426. }
  427. /**
  428. * Delete the selected shapes
  429. *
  430. * ### Example
  431. *
  432. *```ts
  433. * tt.pressDelete()
  434. *```
  435. */
  436. pressDelete(): TestState {
  437. this.state.send('DELETED')
  438. return this
  439. }
  440. /**
  441. * Get a shape and test it.
  442. *
  443. * ### Example
  444. *
  445. *```ts
  446. * tt.testShape("myShapeId", myShape => myShape )
  447. *```
  448. */
  449. testShape<T extends Shape>(
  450. id: string,
  451. fn: (shape: T, shapeUtils: ShapeUtility<T>) => boolean
  452. ): boolean {
  453. const shape = this.getShape<T>(id)
  454. return fn(shape, shape && getShapeUtils(shape))
  455. }
  456. /**
  457. * Get a shape
  458. *
  459. * ### Example
  460. *
  461. *```ts
  462. * tt.getShape("myShapeId")
  463. *```
  464. */
  465. getShape<T extends Shape>(id: string): T {
  466. return tld.getShape(this.data, id) as T
  467. }
  468. /**
  469. * Undo.
  470. *
  471. * ### Example
  472. *
  473. *```ts
  474. * tt.undo()
  475. *```
  476. */
  477. undo(): TestState {
  478. this.state.send('UNDO')
  479. return this
  480. }
  481. /**
  482. * Redo.
  483. *
  484. * ### Example
  485. *
  486. *```ts
  487. * tt.redo()
  488. *```
  489. */
  490. redo(): TestState {
  491. this.state.send('REDO')
  492. return this
  493. }
  494. /**
  495. * Get the state's current data.
  496. *
  497. * ### Example
  498. *
  499. *```ts
  500. * tt.data
  501. *```
  502. */
  503. get data(): Readonly<Data> {
  504. return this.state.data
  505. }
  506. /**
  507. * Get a fake PointerEvent.
  508. *
  509. * ### Example
  510. *
  511. *```ts
  512. * tt.point()
  513. * tt.point({ x: 0, y: 0})
  514. * tt.point({ x: 0, y: 0, shiftKey: true } )
  515. *```
  516. */
  517. static point(options: PointerOptions = {} as PointerOptions): PointerEvent {
  518. const {
  519. id = 1,
  520. x = 0,
  521. y = 0,
  522. shiftKey = false,
  523. altKey = false,
  524. ctrlKey = false,
  525. } = options
  526. return {
  527. shiftKey,
  528. altKey,
  529. ctrlKey,
  530. pointerId: id,
  531. clientX: x,
  532. clientY: y,
  533. } as any
  534. }
  535. }
  536. export default TestState