diff --git a/src/hub/actions.ts b/src/hub/actions.ts index 1dfdc4b2..c16fd36e 100644 --- a/src/hub/actions.ts +++ b/src/hub/actions.ts @@ -39,19 +39,33 @@ export const didFailToFinishDownload = createAction(() => ({ type: 'hub.action.didFailToFinishDownload', })); -export const stop = createAction(() => ({ - type: 'hub.action.stop', +/** Request to send the stop user program command to the hub. */ +export const hubStopUserProgram = createAction(() => ({ + type: 'hub.action.stopUserProgram', })); +/** Indicates the the stop user program command was sent to the hub. */ +export const hubDidStopUserProgram = createAction(() => ({ + type: 'hub.action.didStopUserProgram', +})); + +/** Indicates the the stop user program command failed to be sent to the hub. */ +export const hubDidFailToStopUserProgram = createAction(() => ({ + type: 'hub.action.didFailToStopUserProgram', +})); + +/** Request to send the start repl command to the hub. */ export const hubStartRepl = createAction((useLegacyDownload: boolean) => ({ type: 'hub.action.startRepl', useLegacyDownload, })); +/** Indicates the the start repl command was sent to the hub. */ export const hubDidStartRepl = createAction(() => ({ type: 'hub.action.didStartRepl', })); +/** Indicates the the start repl command failed to be sent to the hub. */ export const hubDidFailToStartRepl = createAction(() => ({ type: 'hub.action.didFailToStartRepl', })); diff --git a/src/hub/reducers.test.ts b/src/hub/reducers.test.ts index daa3e299..0dbd0e70 100644 --- a/src/hub/reducers.test.ts +++ b/src/hub/reducers.test.ts @@ -15,7 +15,9 @@ import { didProgressDownload, didStartDownload, hubDidFailToStartRepl, + hubDidFailToStopUserProgram, hubStartRepl, + hubStopUserProgram, } from './actions'; import reducers, { HubRuntimeState } from './reducers'; @@ -188,4 +190,22 @@ describe('runtime', () => { ).runtime, ).toBe(HubRuntimeState.Unknown); }); + + test('hubStopUserProgram', () => { + expect( + reducers( + { runtime: HubRuntimeState.Running } as State, + hubStopUserProgram(), + ).runtime, + ).toBe(HubRuntimeState.StoppingUserProgram); + }); + + test('hubDidFailToStopUserProgram', () => { + expect( + reducers( + { runtime: HubRuntimeState.Running } as State, + hubDidFailToStopUserProgram(), + ).runtime, + ).toBe(HubRuntimeState.Unknown); + }); }); diff --git a/src/hub/reducers.ts b/src/hub/reducers.ts index 34a8e51e..21f70a89 100644 --- a/src/hub/reducers.ts +++ b/src/hub/reducers.ts @@ -27,7 +27,9 @@ import { didProgressDownload, didStartDownload, hubDidFailToStartRepl, + hubDidFailToStopUserProgram, hubStartRepl, + hubStopUserProgram, } from './actions'; /** @@ -48,6 +50,8 @@ export enum HubRuntimeState { Running = 'hub.runtime.running', /** Busy starting the REPL. */ StartingRepl = 'hub.runtime.startingRepl', + /** Busy stopping user program. */ + StoppingUserProgram = 'hub.runtime.stoppingUserProgram', } const runtime: Reducer = ( @@ -119,6 +123,17 @@ const runtime: Reducer = ( return HubRuntimeState.Unknown; } + if (hubStopUserProgram.matches(action)) { + return HubRuntimeState.StoppingUserProgram; + } + + // NB: hubDidStopUserProgram will trigger user program running flag to clear, + // so we don't change state for both to avoid race condition + + if (hubDidFailToStopUserProgram.matches(action)) { + return HubRuntimeState.Unknown; + } + return state; }; diff --git a/src/hub/sagas.test.ts b/src/hub/sagas.test.ts index 16e4c65b..e1423224 100644 --- a/src/hub/sagas.test.ts +++ b/src/hub/sagas.test.ts @@ -21,9 +21,11 @@ import { didStartDownload, downloadAndRun, hubDidFailToStartRepl, + hubDidFailToStopUserProgram, hubDidStartRepl, + hubDidStopUserProgram, hubStartRepl, - stop, + hubStopUserProgram, } from './actions'; import hub from './sagas'; @@ -136,15 +138,36 @@ describe('hubStartRepl', () => { }); }); -test('stop', async () => { - const saga = new AsyncSaga(hub, { nextMessageId: createCountFunc() }); +describe('hubStopUserProgram', () => { + test('success', async () => { + const saga = new AsyncSaga(hub, { nextMessageId: createCountFunc() }); - saga.put(stop()); + saga.put(hubStopUserProgram()); - const pybricksServiceAction = await saga.take(); - expect(pybricksServiceAction).toEqual(sendStopUserProgramCommand(0)); + await expect(saga.take()).resolves.toEqual(sendStopUserProgramCommand(0)); - saga.put(didSendCommand(0)); + saga.put(didSendCommand(0)); - await saga.end(); + await expect(saga.take()).resolves.toEqual(hubDidStopUserProgram()); + + await saga.end(); + }); + + test('failure due to disconnect', async () => { + const saga = new AsyncSaga(hub, { nextMessageId: createCountFunc() }); + + saga.put(hubStopUserProgram()); + + await expect(saga.take()).resolves.toEqual(sendStopUserProgramCommand(0)); + + saga.put(didFailToSendCommand(0, new DOMException('test', 'NetworkError'))); + + await expect(saga.take()).resolves.toEqual( + alertsShowAlert('ble', 'disconnected'), + ); + + await expect(saga.take()).resolves.toEqual(hubDidFailToStopUserProgram()); + + await saga.end(); + }); }); diff --git a/src/hub/sagas.ts b/src/hub/sagas.ts index 005c1a78..6fefe22a 100644 --- a/src/hub/sagas.ts +++ b/src/hub/sagas.ts @@ -47,9 +47,11 @@ import { didStartDownload, downloadAndRun, hubDidFailToStartRepl, + hubDidFailToStopUserProgram, hubDidStartRepl, + hubDidStopUserProgram, hubStartRepl, - stop, + hubStopUserProgram, } from './actions'; const downloadChunkSize = 100; @@ -387,27 +389,46 @@ function* handleHubStartRepl(action: ReturnType): Generator yield* put(hubDidStartRepl()); } -function* handleStop(): Generator { +function* handleStopUserProgram(): Generator { const nextMessageId = yield* getContext<() => number>('nextMessageId'); const id = nextMessageId(); yield* put(sendStopUserProgramCommand(id)); // REVISIT: may want to disable button while attempting to send command // this would mean didSendStop() and didFailToSendStop() actions here - const { failedToSend } = yield* race({ - sent: take(didSendCommand.when((a) => a.id === id)), - failedToSend: take(didFailToSendCommand.when((a) => a.id === id)), + const { didFailToSend } = yield* race({ + didSend: take(didSendCommand.when((a) => a.id === id)), + didFailToSend: take(didFailToSendCommand.when((a) => a.id === id)), }); - if (failedToSend) { - // TODO: probably want to check error. If hub disconnected, ignore error - // otherwise indicate error to user - console.error(failedToSend.error); + + if (didFailToSend) { + if (process.env.NODE_ENV !== 'test') { + console.error(didFailToSend.error); + } + + if ( + didFailToSend.error instanceof DOMException && + didFailToSend.error.name === 'NetworkError' + ) { + yield* put(alertsShowAlert('ble', 'disconnected')); + } else { + yield* put( + alertsShowAlert('alerts', 'unexpectedError', { + error: didFailToSend.error, + }), + ); + } + + yield* put(hubDidFailToStopUserProgram()); + return; } + + yield* put(hubDidStopUserProgram()); } export default function* (): Generator { yield* takeEvery(downloadAndRun, handleDownloadAndRun); yield* takeEvery(hubStartRepl, handleHubStartRepl); - yield* takeEvery(stop, handleStop); + yield* takeEvery(hubStopUserProgram, handleStopUserProgram); // calling stop right after connecting should get the hub into a known state - yield* takeEvery(bleDidConnectPybricks, handleStop); + yield* takeEvery(bleDidConnectPybricks, handleStopUserProgram); } diff --git a/src/toolbar/buttons/stop/StopButton.test.tsx b/src/toolbar/buttons/stop/StopButton.test.tsx index 3f1bb60f..e87b1122 100644 --- a/src/toolbar/buttons/stop/StopButton.test.tsx +++ b/src/toolbar/buttons/stop/StopButton.test.tsx @@ -4,7 +4,7 @@ import { act, cleanup } from '@testing-library/react'; import React from 'react'; import { testRender } from '../../../../test'; -import { stop } from '../../../hub/actions'; +import { hubStopUserProgram } from '../../../hub/actions'; import { HubRuntimeState } from '../../../hub/reducers'; import StopButton from './StopButton'; @@ -19,5 +19,5 @@ it('should dispatch action when clicked', async () => { await act(() => user.click(button.getByRole('button', { name: 'Stop' }))); - expect(dispatch).toHaveBeenCalledWith(stop()); + expect(dispatch).toHaveBeenCalledWith(hubStopUserProgram()); }); diff --git a/src/toolbar/buttons/stop/StopButton.tsx b/src/toolbar/buttons/stop/StopButton.tsx index dfd2ade3..980c6eeb 100644 --- a/src/toolbar/buttons/stop/StopButton.tsx +++ b/src/toolbar/buttons/stop/StopButton.tsx @@ -1,9 +1,9 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2022 The Pybricks Authors +// Copyright (c) 2020-2023 The Pybricks Authors import React from 'react'; import { useDispatch } from 'react-redux'; -import { stop } from '../../../hub/actions'; +import { hubStopUserProgram } from '../../../hub/actions'; import { HubRuntimeState } from '../../../hub/reducers'; import { useSelector } from '../../../reducers'; import ActionButton, { ActionButtonProps } from '../../ActionButton'; @@ -27,7 +27,8 @@ const StopButton: React.VoidFunctionComponent = ({ id }) => { tooltip={i18n.translate('tooltip', { key: keyboardShortcut })} icon={icon} enabled={runtime === HubRuntimeState.Running} - onAction={() => dispatch(stop())} + showProgress={runtime === HubRuntimeState.StoppingUserProgram} + onAction={() => dispatch(hubStopUserProgram())} /> ); };