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

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