diff --git a/__tests__/README.md b/__tests__/README.md new file mode 100644 index 000000000..0176185a2 --- /dev/null +++ b/__tests__/README.md @@ -0,0 +1,216 @@ +# Testing Guide + +Writing tests for tldraw? This guide will get you started. + +- [Getting Started](#getting-started) +- [How to Test](#how-to-test) +- [What to Test](#what-to-test) +- [TestUtils](#test-utils) + +## Getting Started + +This project uses Jest for its unit tests. + +- To run the test suite, run `yarn test` in your terminal. +- To start the test watcher, run `yarn test:watch`. +- To update the test snapshots, run `yarn test:update`. + +Tests live inside of the `__tests__` folder. + +To create a new test, create a file named `myTest.test.ts` inside of the `__tests__` folder. + +## How to Test + +In tldraw, we write our tests against application's _state_. + +Remember that in tldraw, user interactions send _events_ to the app's _state_, where they produce a change (or not) depending on the state's configuration and current status. To test a feature, we "manually" send those same events to the state machine and then check whether the events produced the change we expected. + +To test a feature, we'll need to: + +- learn how the features works in tldraw +- identify the events involved +- identify the outcome of the events +- reproduce the events in our test +- test the outcome + +### Example + +Let's say we want to test the "create a page" feature of the app. + +We'd start by creating a new file named `create-page.test.ts`. Here's some boilerplace to get us started. + +```ts +// __tests__/create-page.test.ts + +import TestState from '../test-utils' +const tt = new TestState() + +it('creates a new page', () => {}) +``` + +In the code above, we import our `TestState` class, create a new instance for this test file, then have a unit test that will assert something about our app's behavior. + +In the app's UI, we can find a button labelled "create page". + +```tsx +// page-panel.tsx + + state.send('CREATED_PAGE')} /> +``` + +Because we're only testing the state machine, we don't have to worry about how the `DropdownMenuButton` component works, or whether `onSelect` is implemented correctly. Instead, our only concern is the call to `state.send`, where we "send" the `CREATED_PAGE` event to the app's central state machine. + +Back in our test, we can send that the `CREATED_PAGE` event ourselves and check whether it's produced the correct outcome. + +```ts +// __tests__/create-page.test.ts + +import TestState from '../test-utils' +const tt = new TestState() + +it('creates a new page', () => { + const pageCountBefore = Object.keys(tt.state.data.document.pages).length + + tt.state.send('CREATED_PAGE') + + const pageCountAfter = Object.keys(tt.state.data.document.pages).length + + expect(pageCountAfter).toEqual(pageCountBefore + 1) +}) +``` + +If we run our tests (with `yarn test`) or if we're already in watch mode (`yarn test:watch`) then our tests should update. If it worked, hooray! Now try to make it fail and see what that looks like, too. + +## What to Test + +While a test like "create a page" is pretty self-explanatory, most features are at least a little complex. + +To _fully_ test a feature, we would need to: + +- test the entire outcome +- testing every circumstance under which the outcome could be different + +Let's take another look at the `CREATED_PAGE` event. + +If we search for the event in the state machine itself, we can find where and how event is being handled. + +```ts +// state/state.ts + +ready: { + on: { + // ... + CREATED_PAGE: { + unless: ['isReadOnly', 'isInSession'], + do: 'createPage', + } + } +} +``` + +Here's where we can see what exactly we need to test. The event can tell us a few things: + +- It should only run when the "ready" state is active +- It never run when the app is in read only mode +- It should never run when the app is in a session (like drawing or rotating) + +These are all things that we could test. For example: + +```ts +// __tests__/create-page.test.ts + +import TestState from '../test-utils' +const tt = new TestState() + +it('does not create a new page in read only mode', () => { + tt.state.send('TOGGLED_READ_ONLY') + + expect(tt.state.data.isReadOnly).toBe(true) + + const pageCountBefore = Object.keys(tt.state.data.document.pages).length + + tt.state.send('CREATED_PAGE') + + const pageCountAfter = Object.keys(tt.state.data.document.pages).length + + expect(pageCountAfter).toEqual(pageCountBefore) +}) +``` + +> Note that we're using a different event, `TOGGLED_READ_ONLY`, in order to get the state into the correct condition to make our test. When using events like this, it's a good idea to assert that the state is how you expect it to be before you make your "real" test. Here that means testing that the state's `data.isReadOnly` boolean is `true` before we test the `CREATED_PAGE` event. + +We can also look at the `createPage` action. + +```ts +// state/state.ts + +createPage(data) { + commands.createPage(data, true) +}, +``` + +If we follow this call, we'll find the `createPage` command (`state/commands/create-page.ts`). This command is more complex, but it gives us more to test: + +- did we correctly iterate the numbers in the new page's name? +- did we get the correct child index for the new page? +- did we save the current page to local storage? +- did we save the new page to local storage? +- did we add the new page to the document? +- did we add the new page state to the document? +- did we go to the new page? +- when we undo the command, will we remove the new page / page state? +- when we redo the command, will we put the new page / page state back? + +To _fully_ test a feature, we'll need to write tests that cover all of these. + +### Todo Tests + +...but while full test coverage is a goal, it's not always within reach. If you're not able to test everything about a feature, it's a good idea to write "placeholders" for the tests that need to be written. + +```ts +describe('when creating a new page...', () => { + it('sets the correct child index for the new page', () => { + // TODO + }) + + it('sets the correct name for the new page', () => { + // TODO + }) + + it('saves the document to local storage', () => { + // TODO + }) +}) +``` + +### Snapshots + +An even better way to improve coverage when dealing with complex tests is to write "snapshot" tests. + +```ts +describe('when creating a new page...', () => { + it('updates the document', () => { + tt.state.send('CREATED_PAGE') + expect(tt.state.data).toMatchSnapshot() + }) +}) +``` + +While snapshot tests don't assert specific things about a feature's implementation, they will at least help flag changes in other parts of the app that might break the feature. For example, if we accidentally made the app start in read only mode, then the snapshot outcome of `CREATED_PAGE` would be different—and the test would fail. + +## TestUtils + +While you can test every feature in tldraw by sending events to the state, the `TestUtils` class is designed to make certain things easier. By convention, I'll refer to an instance of the `TestUtils` class as `tt`. + +```ts +import TestState from '../test-utils' +const tt = new TestState() +``` + +The `TestUtils` instance wraps an instance of the app's state machine (`tt.state`). It also exposes the state's data as `tt.data`, as well as the state's helper methods (`tt.send`, `tt.isIn`, etc.) + +- `tt.resetDocumentState` will clear the document and reset the app state. +- `tt.createShape` will create a new shape on the page. +- `tt.clickShape` will click a the indicated shape + +Check the `test-utils.ts` file for the rest of the API. Feel free to add your own methods if you have a reason for doing so. diff --git a/__tests__/test-utils.ts b/__tests__/test-utils.ts index 5234ee700..2d99cbc50 100644 --- a/__tests__/test-utils.ts +++ b/__tests__/test-utils.ts @@ -21,16 +21,133 @@ interface PointerOptions { } class TestState { - state: State + _state: State snapshot: Data constructor() { - this.state = _state + this._state = _state this.state.send('TOGGLED_TEST_MODE') this.snapshot = deepClone(this.state.data) this.reset() } + /** + * Get the underlying state-designer state. + * + * ### Example + * + *```ts + * tt.state + *``` + */ + get state(): State { + return this._state + } + + /** + * Get the state's current data. + * + * ### Example + * + *```ts + * tt.data + *``` + */ + get data(): Readonly { + return this.state.data + } + + /* -------- Reimplemenation of State Methods -------- */ + + /** + * 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 + } + + /** + * 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. + * + * ### Example + * + *```ts + * tt.isIn("ready") // true + * tt.isIn("ready", "selecting") // true + * tt.isInAny("ready", "notReady") // false + *``` + */ + isIn(...ids: string[]): boolean { + return this.state.isIn(...ids) + } + + /** + * 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. + * + * ### Example + * + *```ts + * tt.isIn("ready") // true + * tt.isIn("ready", "selecting") // true + * tt.isInAny("ready", "notReady") // true + *``` + */ + isInAny(...ids: string[]): boolean { + return this.state.isInAny(...ids) + } + + /** + * 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. + * + * ### Example + * + *```ts + * example + *``` + */ + can(eventName: string, payload?: unknown): boolean { + return this.state.can(eventName, payload) + } + + /* -------------------- Specific -------------------- */ + + /** + * Save a snapshot of the state's current data. + * + * ### Example + * + *```ts + * tt.save() + * tt.restore() + *``` + */ + save(): TestState { + this.snapshot = deepClone(this.data) + return this + } + + /** + * Restore the state's saved data. + * + * ### Example + * + *```ts + * tt.save() + * tt.restore() + *``` + */ + restore(): TestState { + this.state.forceData(this.snapshot) + return this + } + /** * Reset the test state. * @@ -65,20 +182,6 @@ class TestState { 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. * @@ -100,144 +203,6 @@ class TestState { 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.currentPageId].shapes - ) - .sort((a, b) => a.childIndex - b.childIndex) - .map((shape) => shape.id) - } - - /** - * Get shapes for the current page. - * - * ### Example - * - *```ts - * tt.getShapes() - *``` - */ - getShapes(): Shape[] { - return Object.values( - this.data.document.pages[this.data.currentPageId].shapes - ) - } - - /** - * 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.length === ids.length : true) && - ids.every((id) => selectedIds.includes(id)) - ) - } - - get selectedIds(): string[] { - return tld.getSelectedIds(this.data) - } - - /** - * 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. * @@ -589,6 +554,20 @@ class TestState { return this } + /** + * Select all shapes. + * + * ### Example + * + *```ts + * tt.deselectAll() + *``` + */ + selectAll(): TestState { + this.state.send('SELECTED_ALL') + return this + } + /** * Deselect all shapes. * @@ -604,7 +583,7 @@ class TestState { } /** - * Delete the selected shapes + * Delete the selected shapes. * * ### Example * @@ -617,36 +596,6 @@ class TestState { 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. * @@ -675,46 +624,181 @@ class TestState { return this } + /* ---------------- Getting Data Out ---------------- */ + /** - * Save a snapshot of the state's current data. + * Get a shape by its id. Note: the shape must be in the current page. * * ### Example * *```ts - * tt.save() + * tt.getShape("myShapeId") *``` */ - save(): TestState { - this.snapshot = deepClone(this.data) - return this + getShape(id: string): T { + return tld.getShape(this.data, id) as T } /** - * Restore the state's saved data. + * Get the current selected ids. * * ### Example * *```ts - * tt.save() - * tt.restore() + * example *``` */ - restore(): TestState { - this.state.forceData(this.snapshot) - return this + get selectedIds(): string[] { + return tld.getSelectedIds(this.data) } /** - * Get the state's current data. + * Get shapes for the current page. * * ### Example * *```ts - * tt.data + * tt.getShapes() *``` */ - get data(): Readonly { - return this.state.data + getShapes(): Shape[] { + return Object.values( + this.data.document.pages[this.data.currentPageId].shapes + ) + } + + /** + * Get ids of the page's children sorted by their child index. + * + * ### Example + * + *```ts + * tt.getSortedPageShapes() + *``` + */ + getSortedPageShapeIds(): string[] { + return this.getShapes() + .sort((a, b) => a.childIndex - b.childIndex) + .map((shape) => shape.id) + } + + /** + * 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 + } + + /** + * 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.length === ids.length : true) && + ids.every((id) => selectedIds.includes(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 + } + + /** + * 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 + } + + /** + * Get a shape and test it. + * + * ### Example + * + *```ts + * tt.testShape("myShapeId", (myShape, utils) => expect(utils(myShape).getBounds()).toMatchSnapshot() ) + *``` + */ + testShape( + id: string, + fn: (shape: T, shapeUtils: ShapeUtility) => boolean + ): boolean { + const shape = this.getShape(id) + return fn(shape, shape && getShapeUtils(shape)) } /** diff --git a/components/page-panel/page-panel.tsx b/components/page-panel/page-panel.tsx index 1a9c3e260..2a42534ff 100644 --- a/components/page-panel/page-panel.tsx +++ b/components/page-panel/page-panel.tsx @@ -14,6 +14,10 @@ import { PlusIcon, CheckIcon } from '@radix-ui/react-icons' import state, { useSelector } from 'state' import { useEffect, useRef, useState } from 'react' +function handleCreatePage() { + state.send('CREATED_PAGE') +} + export default function PagePanel(): JSX.Element { const rIsOpen = useRef(false) const [isOpen, setIsOpen] = useState(false) @@ -76,7 +80,7 @@ export default function PagePanel(): JSX.Element { ))} - state.send('CREATED_PAGE')}> + Create Page