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 15KB

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