import _state from 'state' import tld from 'utils/tld' import inputs from 'state/inputs' import { createShape, getShapeUtils } from 'state/shape-utils' import { Data, Shape, ShapeType, ShapeUtility } from 'types' import { deepCompareArrays, uniqueId, vec } from 'utils' import * as json from './__mocks__/document.json' type State = typeof _state export const rectangleId = '1f6c251c-e12e-40b4-8dd2-c1847d80b72f' export const arrowId = '5ca167d7-54de-47c9-aa8f-86affa25e44d' interface PointerOptions { id?: number x?: number y?: number shiftKey?: boolean altKey?: boolean ctrlKey?: boolean } class TestState { state: State constructor() { this.state = _state this.reset() } /** * Reset the test state. * * ### Example * *```ts * tt.reset() *``` */ reset(): TestState { this.state.reset() this.state .send('MOUNTED') .send('LOADED_FROM_FILE', { json: JSON.stringify(json) }) return this } /** * Reset the document state. Will remove all shapes and extra pages. * * ### Example * *```ts * tt.resetDocumentState() *``` */ resetDocumentState(): TestState { this.state.send('RESET_DOCUMENT_STATE') return this } /** * Send a message to the state. * * ### Example * *```ts * tt.send("MOVED_TO_FRONT") *``` */ send(eventName: string, payload?: unknown): TestState { this.state.send(eventName, payload) return this } /** * Create a new shape on the current page. Optionally provide an id. * * ### Example * *```ts * tt.createShape({ type: ShapeType.Rectangle, point: [100, 100]}) * tt.createShape({ type: ShapeType.Rectangle, point: [100, 100]}, "myId") *``` */ createShape(props: Partial, id = uniqueId()): TestState { const shape = createShape(props.type, props) getShapeUtils(shape).setProperty(shape, 'id', id) this.data.document.pages[this.data.currentPageId].shapes[shape.id] = shape return this } /** * Get the sorted ids of the page's children. * * ### Example * *```ts * tt.getSortedPageShapes() *``` */ getSortedPageShapeIds(): string[] { return Object.values( this.data.document.pages[this.data.currentParentId].shapes ) .sort((a, b) => a.childIndex - b.childIndex) .map((shape) => shape.id) } /** * 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. * * ### Example * *```ts * tt.idsAreSelected(state.data, ['rectangleId', 'ellipseId']) * tt.idsAreSelected(state.data, ['rectangleId', 'ellipseId'], true) *``` */ idsAreSelected(ids: string[], strict = true): boolean { const selectedIds = tld.getSelectedIds(this.data) return ( (strict ? selectedIds.size === ids.length : true) && ids.every((id) => selectedIds.has(id)) ) } /** * Get whether the shape with the provided id has the provided parent id. * * ### Example * *```ts * tt.hasParent('childId', 'parentId') *``` */ hasParent(childId: string, parentId: string): boolean { return tld.getShape(this.data, childId).parentId === parentId } /** * Get the only selected shape. If more than one shape is selected, the test will fail. * * ### Example * *```ts * tt.getOnlySelectedShape() *``` */ getOnlySelectedShape(): Shape { const selectedShapes = tld.getSelectedShapes(this.data) return selectedShapes.length === 1 ? selectedShapes[0] : undefined } /** * Assert that a shape has the provided type. * * ### Example * *```ts * tt.example *``` */ assertShapeType(shapeId: string, type: ShapeType): boolean { const shape = tld.getShape(this.data, shapeId) if (shape.type !== type) { throw new TypeError( `expected shape ${shapeId} to be of type ${type}, found ${shape?.type} instead` ) } return true } /** * Assert that the provided shape has the provided props. * * ### Example * *``` * tt.assertShapeProps(myShape, { point: [0,0], style: { color: ColorStyle.Blue } } ) *``` */ assertShapeProps( shape: T, props: { [K in keyof Partial]: T[K] } ): boolean { for (const key in props) { let result: boolean const value = props[key] if (Array.isArray(value)) { result = deepCompareArrays(value, shape[key] as typeof value) } else if (typeof value === 'object') { const target = shape[key] as typeof value result = target && Object.entries(value).every(([k, v]) => target[k] === props[key][v]) } else { result = shape[key] === value } if (!result) { throw new TypeError( `expected shape ${shape.id} to have property ${key}: ${props[key]}, found ${key}: ${shape[key]} instead` ) } } return true } /** * Click a shape. * * ### Example * *```ts * tt.clickShape("myShapeId") *``` */ clickShape(id: string, options: PointerOptions = {}): TestState { this.state .send('POINTED_SHAPE', inputs.pointerDown(TestState.point(options), id)) .send('STOPPED_POINTING', inputs.pointerUp(TestState.point(options), id)) return this } /** * Start a click (but do not stop it). * * ### Example * *```ts * tt.startClick("myShapeId") *``` */ startClick(id: string, options: PointerOptions = {}): TestState { this.state.send( 'POINTED_SHAPE', inputs.pointerDown(TestState.point(options), id) ) return this } /** * Stop a click (after starting it). * * ### Example * *```ts * tt.stopClick("myShapeId") *``` */ stopClick(id: string, options: PointerOptions = {}): TestState { this.state.send( 'STOPPED_POINTING', inputs.pointerUp(TestState.point(options), id) ) return this } /** * Double click a shape. * * ### Example * *```ts * tt.clickShape("myShapeId") *``` */ doubleClickShape(id: string, options: PointerOptions = {}): TestState { this.state .send( 'DOUBLE_POINTED_SHAPE', inputs.pointerDown(TestState.point(options), id) ) .send('STOPPED_POINTING', inputs.pointerUp(TestState.point(options), id)) return this } /** * Click the canvas. * * ### Example * *```ts * tt.clickCanvas("myShapeId") *``` */ clickCanvas(options: PointerOptions = {}): TestState { this.state .send( 'POINTED_CANVAS', inputs.pointerDown(TestState.point(options), 'canvas') ) .send( 'STOPPED_POINTING', inputs.pointerUp(TestState.point(options), 'canvas') ) return this } /** * Click the background / body of the bounding box. * * ### Example * *```ts * tt.clickBounds() *``` */ clickBounds(options: PointerOptions = {}): TestState { this.state .send( 'POINTED_BOUNDS', inputs.pointerDown(TestState.point(options), 'bounds') ) .send( 'STOPPED_POINTING', inputs.pointerUp(TestState.point(options), 'bounds') ) return this } /** * Move the pointer to a new point, or to several points in order. * * ### Example * *```ts * tt.movePointerTo([100, 100]) * tt.movePointerTo([100, 100], { shiftKey: true }) * tt.movePointerTo([[100, 100], [150, 150], [200, 200]]) *``` */ movePointerTo( to: number[] | number[][], options: Omit = {} ): TestState { if (Array.isArray(to[0])) { ;(to as number[][]).forEach(([x, y]) => { this.state.send( 'MOVED_POINTER', inputs.pointerMove(TestState.point({ x, y, ...options })) ) }) } else { const [x, y] = to as number[] this.state.send( 'MOVED_POINTER', inputs.pointerMove(TestState.point({ x, y, ...options })) ) } return this } /** * Move the pointer by a delta. * * ### Example * *```ts * tt.movePointerBy([10,10]) * tt.movePointerBy([10,10], { shiftKey: true }) *``` */ movePointerBy( by: number[] | number[][], options: Omit = {} ): TestState { let pt = inputs.pointer?.point || [0, 0] if (Array.isArray(by[0])) { ;(by as number[][]).forEach((delta) => { pt = vec.add(pt, delta) this.state.send( 'MOVED_POINTER', inputs.pointerMove( TestState.point({ x: pt[0], y: pt[1], ...options }) ) ) }) } else { pt = vec.add(pt, by as number[]) this.state.send( 'MOVED_POINTER', inputs.pointerMove(TestState.point({ x: pt[0], y: pt[1], ...options })) ) } return this } /** * Move pointer over a shape. Will move the pointer to the top-left corner of the shape. * * ### * ``` * tt.movePointerOverShape('myShapeId', [100, 100]) * ``` */ movePointerOverShape( id: string, options: Omit = {} ): TestState { const shape = tld.getShape(this.state.data, id) const [x, y] = vec.add(shape.point, [1, 1]) this.state.send( 'MOVED_OVER_SHAPE', inputs.pointerEnter(TestState.point({ x, y, ...options }), id) ) return this } /** * Move the pointer over a group. Will move the pointer to the top-left corner of the group. * * ### Example * *```ts * tt.movePointerOverHandle('myGroupId') * tt.movePointerOverHandle('myGroupId', { shiftKey: true }) *``` */ movePointerOverGroup( id: string, options: Omit = {} ): TestState { const shape = tld.getShape(this.state.data, id) const [x, y] = vec.add(shape.point, [1, 1]) this.state.send( 'MOVED_OVER_GROUP', inputs.pointerEnter(TestState.point({ x, y, ...options }), id) ) return this } /** * Move the pointer over a handle. Will move the pointer to the top-left corner of the handle. * * ### Example * *```ts * tt.movePointerOverHandle('bend') * tt.movePointerOverHandle('bend', { shiftKey: true }) *``` */ movePointerOverHandle( id: string, options: Omit = {} ): TestState { const shape = tld.getShape(this.state.data, id) const handle = shape.handles?.[id] const [x, y] = vec.add(handle.point, [1, 1]) this.state.send( 'MOVED_OVER_HANDLE', inputs.pointerEnter(TestState.point({ x, y, ...options }), id) ) return this } /** * Deselect all shapes. * * ### Example * *```ts * tt.deselectAll() *``` */ deselectAll(): TestState { this.state.send('DESELECTED_ALL') return this } /** * Delete the selected shapes * * ### Example * *```ts * tt.pressDelete() *``` */ pressDelete(): TestState { this.state.send('DELETED') return this } /** * Get a shape and test it. * * ### Example * *```ts * tt.testShape("myShapeId", myShape => myShape ) *``` */ testShape( id: string, fn: (shape: T, shapeUtils: ShapeUtility) => boolean ): boolean { const shape = this.getShape(id) return fn(shape, shape && getShapeUtils(shape)) } /** * Get a shape * * ### Example * *```ts * tt.getShape("myShapeId") *``` */ getShape(id: string): T { return tld.getShape(this.data, id) as T } /** * Undo. * * ### Example * *```ts * tt.undo() *``` */ undo(): TestState { this.state.send('UNDO') return this } /** * Redo. * * ### Example * *```ts * tt.redo() *``` */ redo(): TestState { this.state.send('REDO') return this } /** * Get the state's current data. * * ### Example * *```ts * tt.data *``` */ get data(): Readonly { return this.state.data } /** * Get a fake PointerEvent. * * ### Example * *```ts * tt.point() * tt.point({ x: 0, y: 0}) * tt.point({ x: 0, y: 0, shiftKey: true } ) *``` */ static point(options: PointerOptions = {} as PointerOptions): PointerEvent { const { id = 1, x = 0, y = 0, shiftKey = false, altKey = false, ctrlKey = false, } = options return { shiftKey, altKey, ctrlKey, pointerId: id, clientX: x, clientY: y, } as any } } export default TestState