diff --git a/.eslintrc.js b/.eslintrc.js index 8039574a..e0d57795 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -28,6 +28,6 @@ module.exports = { react: { version: 'detect' }, }, ignorePatterns: [ - "test-env.js" + "test/env.js" ], }; diff --git a/.vscode/launch.json b/.vscode/launch.json index b8120989..6a555a21 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -20,7 +20,7 @@ "request": "launch", "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/react-scripts", "env": { "CI": "true" }, - "args": ["test", "--runInBand", "--no-cache", "--env=./test-env.js"], + "args": ["test", "--runInBand", "--no-cache", "--env=./test/env.js"], "cwd": "${workspaceRoot}", "protocol": "inspector", "console": "integratedTerminal", diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4a880e16..d2fb4d76 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -86,11 +86,7 @@ This will automatically fix most lint errors for you. ### `yarn test` -Launches the test runner in the interactive watch mode. - -See the section about [running tests][tests] for more information. - -[tests]: https://facebook.github.io/create-react-app/docs/running-tests +See [README](test/README.md) in the `test/` directory. ### `yarn build` diff --git a/package.json b/package.json index 6e5dad2f..debf677e 100644 --- a/package.json +++ b/package.json @@ -44,8 +44,9 @@ "prepare": "mkdir -p public/static/js && cp node_modules/@pybricks/mpy-cross-v5/build/mpy-cross.wasm public/static/js/", "start": "react-scripts start", "build": "react-scripts build", - "test": "react-scripts test --env=./test-env.js", - "coverage": "react-scripts test --env=./test-env.js --coverage --watchAll=false", + "test": "react-scripts test --env=./test/env.js", + "coverage": "react-scripts test --env=./test/env.js --coverage --watchAll=false", + "coverage:html": "yarn coverage --coverageReporters html", "eject": "react-scripts eject", "lint": "tsc --noEmit && eslint '*/**/*.{js,ts,tsx}' --quiet --fix" }, diff --git a/src/components/button.test.ts b/src/components/button.test.ts index 8b6ce7ff..02aedfe6 100644 --- a/src/components/button.test.ts +++ b/src/components/button.test.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2020 The Pybricks Authors -import { lookup } from '../utils'; +import { lookup } from '../../test'; import { TooltipId } from './button'; import en from './button.en.json'; diff --git a/src/components/editor.test.ts b/src/components/editor.test.ts index bc5d3eda..d30fa97c 100644 --- a/src/components/editor.test.ts +++ b/src/components/editor.test.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2020 The Pybricks Authors -import { lookup } from '../utils'; +import { lookup } from '../../test'; import { EditorStringId } from './editor'; import en from './editor.en.json'; diff --git a/src/components/notification.test.ts b/src/components/notification.test.ts index fe69432d..fac84841 100644 --- a/src/components/notification.test.ts +++ b/src/components/notification.test.ts @@ -1,8 +1,8 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2020 The Pybricks Authors +import { lookup } from '../../test'; import { MessageId } from '../reducers/notification'; -import { lookup } from '../utils'; import en from './notification.en.json'; describe('Ensure .json file has matches for MessageIds', () => { diff --git a/src/sagas/lwp3-bootloader.test.ts b/src/sagas/lwp3-bootloader.test.ts index ea73f5bb..6e7b7d59 100644 --- a/src/sagas/lwp3-bootloader.test.ts +++ b/src/sagas/lwp3-bootloader.test.ts @@ -1,8 +1,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2020 The Pybricks Authors -import { runSaga, stdChannel } from 'redux-saga'; -import { Action } from '../actions'; +import { AsyncSaga } from '../../test'; import { BootloaderRequestActionType, checksumRequest, @@ -121,67 +120,55 @@ describe('message encoder', () => { ], ], ])('encode %s request', async (_n, request, expected) => { - const channel = stdChannel(); - const dispatched = new Array(); - const task = runSaga( - { - channel, - dispatch: (action: Action) => dispatched.push(action), - }, - bootloader, - ); - channel.put(request); - task.cancel(); - await task.toPromise(); + const saga = new AsyncSaga(bootloader); + saga.put(request); const message = new Uint8Array(expected); - expect(dispatched[0]).toEqual( + const action = await saga.take(); + expect(action).toEqual( send( message, /* withResponse */ request.type !== BootloaderRequestActionType.Program, ), ); + await saga.end(); }); test('requests are serialized', async () => { - const channel = stdChannel(); - const dispatched = new Array(); - const task = runSaga( - { - channel, - dispatch: (action: Action) => dispatched.push(action), - }, - bootloader, - ); + const saga = new AsyncSaga(bootloader); // we send 4 requests - channel.put({ ...eraseRequest(), id: 0 }); - channel.put({ ...eraseRequest(), id: 1 }); - channel.put({ ...eraseRequest(), id: 2 }); - channel.put({ ...eraseRequest(), id: 3 }); + saga.put({ ...eraseRequest(), id: 0 }); + saga.put({ ...eraseRequest(), id: 1 }); + saga.put({ ...eraseRequest(), id: 2 }); + saga.put({ ...eraseRequest(), id: 3 }); // but only two didSend action meaning only the first two completed - channel.put(didSend()); - channel.put(didSend()); - - task.cancel(); - await task.toPromise(); + saga.put(didSend()); + saga.put(didSend()); // So only 3 requests were actually sent and two didRequests were // dispatched (making 5 total dispatches). The last request is still // buffered and has not been dispatched. - expect(dispatched.length).toEqual(5); + const numPending = saga.numPending(); + expect(numPending).toEqual(5); + + const message = new Uint8Array([Command.EraseFlash]); + const nextId = createCountFunc(); // every other action is the "send" action - const message = new Uint8Array([Command.EraseFlash]); - for (let i = 0; i < dispatched.length; i += 2) { - expect(dispatched[i]).toEqual(send(message, /* withResponse */ true)); - } - // and the interleaving actions are "did request" actions - const nextId = createCountFunc(); - for (let i = 1; i < dispatched.length; i += 2) { - expect(dispatched[i]).toEqual(didRequest(nextId())); - } + const action0 = await saga.take(); + expect(action0).toEqual(send(message, /* withResponse */ true)); + const action1 = await saga.take(); + expect(action1).toEqual(didRequest(nextId())); + const action2 = await saga.take(); + expect(action2).toEqual(send(message, /* withResponse */ true)); + const action3 = await saga.take(); + expect(action3).toEqual(didRequest(nextId())); + const action4 = await saga.take(); + expect(action4).toEqual(send(message, /* withResponse */ true)); + + await saga.end(); }); }); @@ -263,20 +250,15 @@ describe('message decoder', () => { errorResponse(Command.GetFlashState), ], ])('decode %s response', async (_n, message, expected) => { + const saga = new AsyncSaga(bootloader); const response = new Uint8Array(message); - const channel = stdChannel(); - const dispatched = new Array(); - const task = runSaga( - { - channel, - dispatch: (action: Action) => dispatched.push(action), - }, - bootloader, - ); - channel.put(didReceive(new DataView(response.buffer))); - task.cancel(); - await task.toPromise(); - expect(dispatched[0]).toEqual(expected); + + saga.put(didReceive(new DataView(response.buffer))); + + const action = await saga.take(); + expect(action).toEqual(expected); + + await saga.end(); }); test.each([ @@ -329,19 +311,14 @@ describe('message decoder', () => { ), ], ])('protocol error', async (_n, message, expected) => { + const saga = new AsyncSaga(bootloader); const response = new Uint8Array(message); - const channel = stdChannel(); - const dispatched = new Array(); - const task = runSaga( - { - channel, - dispatch: (action: Action) => dispatched.push(action), - }, - bootloader, - ); - channel.put(didReceive(new DataView(response.buffer))); - task.cancel(); - await task.toPromise(); - expect(dispatched[0]).toEqual(expected); + + saga.put(didReceive(new DataView(response.buffer))); + + const action = await saga.take(); + expect(action).toEqual(expected); + + await saga.end(); }); }); diff --git a/src/sagas/mpy.test.ts b/src/sagas/mpy.test.ts index 64908a6c..b4d81493 100644 --- a/src/sagas/mpy.test.ts +++ b/src/sagas/mpy.test.ts @@ -1,8 +1,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2020 The Pybricks Authors -import { runSaga, stdChannel } from 'redux-saga'; -import { Action } from '../actions'; +import { AsyncSaga } from '../../test'; import { MpyActionType, MpyDidCompileAction, @@ -17,27 +16,13 @@ enum MpyFeatureFlags { } test('compiler works', async () => { - const channel = stdChannel(); - const dispatched = new Array(); - const task = runSaga( - { - channel, - dispatch: (action: Action) => dispatched.push(action), - }, - mpy, - ); - channel.put(compile('print("hello!")')); + const saga = new AsyncSaga(mpy); - // TODO: not sure what the best way to handle this is. We could just wait - // for one dispatch, but then we could miss a bug where there is more than - // one dispatch. And if we make the time too short, we could get intermittent - // failures. - setTimeout(() => task.cancel(), 1000); - await task.toPromise(); + saga.put(compile('print("hello!")')); - expect(dispatched.length).toBe(1); - expect(dispatched[0].type).toBe(MpyActionType.DidCompile); - const { data } = dispatched[0] as MpyDidCompileAction; + const action = await saga.take(); + expect(action.type).toBe(MpyActionType.DidCompile); + const { data } = action as MpyDidCompileAction; expect(data[0]).toBe('M'.charCodeAt(0)); expect(data[1]).toBe(5); // ABI version expect(data[2]).toBe(MpyFeatureFlags.MICROPY_PY_BUILTINS_STR_UNICODE); @@ -45,26 +30,14 @@ test('compiler works', async () => { }); test('compiler error works', async () => { - const channel = stdChannel(); - const dispatched = new Array(); - const task = runSaga( - { - channel, - dispatch: (action: Action) => dispatched.push(action), - }, - mpy, - ); - channel.put(compile('syntax error!')); + const saga = new AsyncSaga(mpy); - // TODO: not sure what the best way to handle this is. We could just wait - // for one dispatch, but then we could miss a bug where there is more than - // one dispatch. And if we make the time too short, we could get intermittent - // failures. - setTimeout(() => task.cancel(), 1000); - await task.toPromise(); + saga.put(compile('syntax error!')); - expect(dispatched.length).toBe(1); - expect(dispatched[0].type).toBe(MpyActionType.DidFailToCompile); - const { err } = dispatched[0] as MpyDidFailToCompileAction; + const action = await saga.take(); + expect(action.type).toBe(MpyActionType.DidFailToCompile); + const { err } = action as MpyDidFailToCompileAction; expect(err).toContain('SyntaxError'); + + await saga.end(); }); diff --git a/src/sagas/terminal.test.ts b/src/sagas/terminal.test.ts index be17e25c..b85b747b 100644 --- a/src/sagas/terminal.test.ts +++ b/src/sagas/terminal.test.ts @@ -1,8 +1,8 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2020 The Pybricks Authors -import { END, MulticastChannel, Saga, Task, runSaga, stdChannel } from 'redux-saga'; -import { Action } from '../actions'; +import { AsyncSaga, delay } from '../../test'; + import { BLEDataActionType, BLEDataWriteAction, @@ -17,83 +17,6 @@ import { } from '../actions/terminal'; import terminal from './terminal'; -class AsyncSaga { - private dispatches: (Action | END)[]; - private takers: { put: (action: Action | END) => void }[]; - private channel: MulticastChannel; - private task: Task; - - public constructor(saga: Saga) { - this.dispatches = []; - this.takers = []; - this.channel = stdChannel(); - this.task = runSaga( - { - channel: this.channel, - dispatch: this.dispatch.bind(this), - onError: (e) => fail(e), - }, - saga, - ); - } - - public numPending(): number { - return this.dispatches.length; - } - - public put(action: Action): void { - this.channel.put(action); - } - - public take(): Promise { - const next = this.dispatches.shift(); - if (next === undefined) { - // if there are no dispatches queued, then queue the taker to be - // completed later - return new Promise((resolve, reject) => { - this.takers.push({ - put: (a: Action | END): void => { - if (a.type === END.type) { - reject(); - } else { - resolve(a); - } - }, - }); - }); - } - // otherwise complete immediately - if (next.type === END.type) { - return Promise.reject(); - } - return Promise.resolve(next); - } - - public async end(): Promise { - this.task.cancel(); - await this.task.toPromise(); - if (this.dispatches.some((x) => x.type !== END.type)) { - fail(`unhandled dispatches remain: ${JSON.stringify(this.dispatches)}`); - } - } - - private dispatch(action: Action | END): Action | END { - const taker = this.takers.shift(); - if (taker === undefined) { - // if there are no takers waiting, the queue the action - this.dispatches.push(action); - } else { - // otherwise complete the promise - taker.put(action); - } - return action; - } -} - -function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - test('Terminal data source responds to send data actions', async () => { const saga = new AsyncSaga(terminal); diff --git a/src/utils/index.test.ts b/src/utils/index.test.ts index 9f69757c..203ceede 100644 --- a/src/utils/index.test.ts +++ b/src/utils/index.test.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2020 The Pybricks Authors -import { assert, hex, lookup } from '.'; +import { assert, hex } from '.'; test('assert', () => { const assertTrue = jest.fn(() => assert(true, 'should not throw')); @@ -16,9 +16,3 @@ test('hex', () => { expect(hex(1, 4)).toBe('0x0001'); expect(hex(2, 8)).toBe('0x00000002'); }); - -test('lookup', () => { - const obj = { a: { b: { c: 'd' } } }; - expect(lookup(obj, 'a.b.c')).toBe('d'); - expect(lookup(obj, 'a.x.y')).toBeUndefined(); -}); diff --git a/src/utils/index.ts b/src/utils/index.ts index 7695b84a..ea7af95f 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -21,18 +21,3 @@ export function assert(condition: boolean, message: string): void { export function hex(n: number, pad: number): string { return `0x${n.toString(16).padStart(pad, '0')}`; } - -/** - * Looks up a nested property in an object. - * @param obj The object - * @param id The property path - */ -export function lookup(obj: object, id: string): string | undefined { - const value = id - .split('.') - .reduce((pv, cv) => pv && (pv as Record)[cv], obj); - if (typeof value === 'string') { - return value; - } - return undefined; -} diff --git a/test/README.md b/test/README.md new file mode 100644 index 00000000..160c9d3f --- /dev/null +++ b/test/README.md @@ -0,0 +1,36 @@ +# Testing + +NOTE: This directory contains test helpers. Actual tests are in the `src/` +directory. + + +## Running tests + + yarn test + +Launches the test runner in the interactive watch mode. + +See the section about [running tests][tests] for more information. + +[tests]: https://facebook.github.io/create-react-app/docs/running-tests + +## Code coverage + + yarn coverage + +Launches the test runner in the code coverage mode. Results are displayed in +the terminal. + + yarn coverage:html + xdg-open coverage/index.html + +Does the same thing except results are converted to more detailed html pages in +the `coverage/` directory. (On macOS, use `open` and on Windows use `explorer` +instead of `xdg-open` to open the html in your default web browser.) + + +## Writing tests + +Tests are written using the [Jest][jest] testing framework. + +[jest]: https://jestjs.io/ diff --git a/test-env.js b/test/env.js similarity index 100% rename from test-env.js rename to test/env.js diff --git a/test/index.ts b/test/index.ts new file mode 100644 index 00000000..48609ed1 --- /dev/null +++ b/test/index.ts @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2020 The Pybricks Authors + +import { END, MulticastChannel, Saga, Task, runSaga, stdChannel } from 'redux-saga'; +import { Action } from '../src/actions'; + +export class AsyncSaga { + private dispatches: (Action | END)[]; + private takers: { put: (action: Action | END) => void }[]; + private channel: MulticastChannel; + private task: Task; + + public constructor(saga: Saga) { + this.dispatches = []; + this.takers = []; + this.channel = stdChannel(); + this.task = runSaga( + { + channel: this.channel, + dispatch: this.dispatch.bind(this), + onError: (e) => fail(e), + }, + saga, + ); + } + + public numPending(): number { + return this.dispatches.length; + } + + public put(action: Action): void { + this.channel.put(action); + } + + public take(): Promise { + const next = this.dispatches.shift(); + if (next === undefined) { + // if there are no dispatches queued, then queue the taker to be + // completed later + return new Promise((resolve, reject) => { + this.takers.push({ + put: (a: Action | END): void => { + if (a.type === END.type) { + reject(); + } else { + resolve(a); + } + }, + }); + }); + } + // otherwise complete immediately + if (next.type === END.type) { + return Promise.reject(); + } + return Promise.resolve(next); + } + + public async end(): Promise { + this.task.cancel(); + await this.task.toPromise(); + if (this.dispatches.some((x) => x.type !== END.type)) { + fail(`unhandled dispatches remain: ${JSON.stringify(this.dispatches)}`); + } + } + + private dispatch(action: Action | END): Action | END { + const taker = this.takers.shift(); + if (taker === undefined) { + // if there are no takers waiting, the queue the action + this.dispatches.push(action); + } else { + // otherwise complete the promise + taker.put(action); + } + return action; + } +} + +export function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Looks up a nested property in an object. + * @param obj The object + * @param id The property path + */ +export function lookup(obj: object, id: string): string | undefined { + const value = id + .split('.') + .reduce((pv, cv) => pv && (pv as Record)[cv], obj); + if (typeof value === 'string') { + return value; + } + return undefined; +} diff --git a/tsconfig.json b/tsconfig.json index d9361a62..fe3caba5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,5 +17,5 @@ "downlevelIteration": true, "experimentalDecorators": true }, - "include": ["src"] + "include": ["src", "test"] }