diff --git a/src/actions.ts b/src/actions.ts index d88d7564..7e231378 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -12,8 +12,26 @@ type MatchFunction = (action: AnyAction) => action is A; /** The extra members that are attached to a function by createAction(). */ type MatchableExtensions, A extends AnyAction> = { + /** + * This should not usually be used directly. It allows Matchable action + * functions to be passed directly to redux saga effects as an action pattern. + */ toString(): ReturnType['type']; + /** + * Type guard to ensure an action matches this type. + */ matches: MatchFunction>; + /** + * Type guard creation function with addition filtering. + * + * This is useful for creating a guard function to pass to redux saga + * effects. + * + * @example const action = yield* take(someAction.when((a) => a.property === value)); + * + * @param predicate An predicate to filter actions. + */ + when(predicate: (action: ReturnType) => boolean): MatchFunction>; }; /** An action creation function that includes MatchableExtensions. */ @@ -26,14 +44,31 @@ type Matchable, A extends AnyAction> = F & * @param actionCreator The action creation function. * @returns actionCreator with type property and match method added. */ -export function createAction, A extends AnyAction>( - actionCreator: T, -): Matchable { +export function createAction, A extends AnyAction>( + actionCreator: F, +): Matchable { // create a default action so we can get the type string. const type = actionCreator().type; - return Object.assign(actionCreator, >{ + function matches(action: AnyAction): action is ReturnType { + return action.type === type; + } + + function when( + predicate: (action: ReturnType) => boolean, + ): MatchFunction> { + return (a: AnyAction): a is ReturnType => { + if (!matches(a)) { + return false; + } + + return predicate(a); + }; + } + + return Object.assign(actionCreator, >{ toString: () => type, - matches: (action) => action.type === type, + matches, + when, }); } diff --git a/src/ble-pybricks-service/sagas.ts b/src/ble-pybricks-service/sagas.ts index a8f50c37..6554f7b9 100644 --- a/src/ble-pybricks-service/sagas.ts +++ b/src/ble-pybricks-service/sagas.ts @@ -57,9 +57,8 @@ function* encodeRequest(): Generator { } const { failedToSend } = yield* race({ - sent: take>(didWriteCommand), - failedToSend: - take>(didFailToWriteCommand), + sent: take(didWriteCommand), + failedToSend: take(didFailToWriteCommand), }); if (failedToSend) { diff --git a/src/ble/sagas.ts b/src/ble/sagas.ts index b2edee5a..44a5d09d 100644 --- a/src/ble/sagas.ts +++ b/src/ble/sagas.ts @@ -63,10 +63,7 @@ import { BleConnectionState } from './reducers'; const decoder = new TextDecoder(); -function handleDisconnect( - server: BluetoothRemoteGATTServer, - _action: ReturnType, -): void { +function handleDisconnect(server: BluetoothRemoteGATTServer): void { server.disconnect(); } diff --git a/src/editor/sagas.ts b/src/editor/sagas.ts index 5a6afd00..69a3678a 100644 --- a/src/editor/sagas.ts +++ b/src/editor/sagas.ts @@ -2,7 +2,6 @@ // Copyright (c) 2020-2022 The Pybricks Authors import FileSaver from 'file-saver'; -import { AnyAction } from 'redux'; import { call, put, @@ -109,14 +108,11 @@ function* handleSetEditSession(action: ReturnType): Gener yield* put(fileStorageReadFile(currentFileName)); const { result } = yield* race({ - result: take>( - (a: AnyAction) => - fileStorageDidReadFile.matches(a) && a.fileName === currentFileName, + result: take( + fileStorageDidReadFile.when((a) => a.fileName === currentFileName), ), - error: take>( - (a: AnyAction) => - fileStorageDidFailToReadFile.matches(a) && - a.fileName === currentFileName, + error: take( + fileStorageDidFailToReadFile.when((a) => a.fileName === currentFileName), ), }); diff --git a/src/fileStorage/sagas.test.ts b/src/fileStorage/sagas.test.ts index 29ee3bdc..414fa2e5 100644 --- a/src/fileStorage/sagas.test.ts +++ b/src/fileStorage/sagas.test.ts @@ -82,7 +82,7 @@ it('should dispatch fail action if file does not exist', async () => { saga.put(fileStorageReadFile(testFileName)); action = await saga.take(); - expect(action).toHaveProperty('type', fileStorageDidFailToReadFile.toString()); + expect(fileStorageDidFailToReadFile.matches(action)).toBeTruthy(); await saga.end(); }); diff --git a/src/firmware/sagas.ts b/src/firmware/sagas.ts index 22923d1f..b6bcf9b9 100644 --- a/src/firmware/sagas.ts +++ b/src/firmware/sagas.ts @@ -11,6 +11,7 @@ import cityHubZip from '@pybricks/firmware/build/cityhub.zip'; import moveHubZip from '@pybricks/firmware/build/movehub.zip'; import technicHubZip from '@pybricks/firmware/build/technichub.zip'; import { AnyAction } from 'redux'; +import { ActionPattern } from 'redux-saga/effects'; import { SagaGenerator, all, @@ -85,12 +86,8 @@ function* disconnectAndCancel(): SagaGenerator { function* waitForDidRequest(id: number): SagaGenerator> { const { requested, failedToRequest } = yield* race({ - requested: take>( - (a: AnyAction) => didRequest.matches(a) && a.id === id, - ), - failedToRequest: take>( - (a: AnyAction) => didFailToRequest.matches(a) && a.id === id, - ), + requested: take(didRequest.when((a) => a.id === id)), + failedToRequest: take(didFailToRequest.when((a) => a.id === id)), }); if (failedToRequest) { @@ -108,30 +105,31 @@ function* waitForDidRequest(id: number): SagaGenerator( - type: string, +function* waitForResponse( + pattern: ActionPattern, timeout = 500, -): SagaGenerator { +): SagaGenerator { const { response, error, disconnected, timedOut } = yield* race({ - response: take(type), - error: take>(errorResponse), + response: take(pattern), + error: take(errorResponse), disconnected: take(didDisconnect), timedOut: delay(timeout), }); if (timedOut) { // istanbul ignore if: this hacks around a hardware/OS issue - if (type === errorResponse.toString()) { + if (pattern === (errorResponse as unknown)) { // It has been observed that sometimes this response is not received // or gets stuck in the Bluetooth stack until another request is sent. // So, we ignore the timeout and continue. If there really was a // problem, then the next request should fail anyway. console.warn('Timeout waiting for erase response, continuing anyway.'); - return eraseResponse(Result.OK) as unknown as T; + return eraseResponse(Result.OK) as unknown as A; } + yield* put(didFailToFinish(FailToFinishReasonType.TimedOut)); yield* disconnectAndCancel(); } @@ -209,8 +207,8 @@ function* loadFirmware( yield* put(compile(program, metadata['mpy-cross-options'])); const { mpy, mpyFail } = yield* race({ - mpy: take>(didCompile), - mpyFail: take>(didFailToCompile), + mpy: take(didCompile), + mpyFail: take(didFailToCompile), }); if (mpyFail) { @@ -310,9 +308,7 @@ function* handleFlashFirmware(action: ReturnType): Generat const infoAction = yield* put(infoRequest(nextMessageId())); const { info } = yield* all({ sent: waitForDidRequest(infoAction.id), - info: waitForResponse>( - infoResponse.toString(), - ), + info: waitForResponse(infoResponse), }); if (deviceId !== undefined && info.hubType !== deviceId) { @@ -353,10 +349,7 @@ function* handleFlashFirmware(action: ReturnType): Generat ); const { erase } = yield* all({ sent: waitForDidRequest(eraseAction.id), - erase: waitForResponse>( - eraseResponse.toString(), - 5000, - ), + erase: waitForResponse(eraseResponse, 5000), }); if (erase.result !== Result.OK) { yield* put( @@ -368,9 +361,7 @@ function* handleFlashFirmware(action: ReturnType): Generat const initAction = yield* put(initRequest(nextMessageId(), firmware.length)); const { init } = yield* all({ sent: waitForDidRequest(initAction.id), - init: waitForResponse>( - initResponse.toString(), - ), + init: waitForResponse(initResponse), }); if (init.result) { yield* put( @@ -420,10 +411,7 @@ function* handleFlashFirmware(action: ReturnType): Generat const { response } = yield* all({ sent: waitForDidRequest(checksumAction.id), - response: waitForResponse>( - checksumResponse.toString(), - 5000, - ), + response: waitForResponse(checksumResponse, 5000), }); if (response.checksum !== runningChecksum) { @@ -447,10 +435,7 @@ function* handleFlashFirmware(action: ReturnType): Generat } } - const flash = yield* waitForResponse>( - programResponse.toString(), - 5000, - ); + const flash = yield* waitForResponse(programResponse, 5000); if (flash.count !== firmware.length) { yield* put( diff --git a/src/hub/sagas.test.ts b/src/hub/sagas.test.ts index 9dd738d5..e3ac51fa 100644 --- a/src/hub/sagas.test.ts +++ b/src/hub/sagas.test.ts @@ -37,51 +37,45 @@ describe('downloadAndRun', () => { // first, it tries to compile the program in the current editor const compileAction = await saga.take(); - expect(compileAction.type).toBe(compile.toString()); + expect(compile.matches(compileAction)).toBeTruthy(); saga.put(didCompile(new Uint8Array(30))); // then it notifies that loading has begun const loadingStatusAction = await saga.take(); - expect(loadingStatusAction.type).toBe(didStartDownload.toString()); + expect(loadingStatusAction).toEqual(didStartDownload()); // first message is the length const writeAction = await saga.take(); - expect(writeAction.type).toBe(write.toString()); + expect(writeAction).toBeTruthy(); expect((writeAction as ReturnType).value.length).toBe(4); - saga.put(didWrite((writeAction as ReturnType).id)); + saga.put(didWrite(0)); saga.put(checksum(30)); // then progress is updated const progressAction = await saga.take(); - expect(progressAction.type).toBe(didProgressDownload.toString()); - expect( - (progressAction as ReturnType).progress, - ).toBe(0); + expect(progressAction).toEqual(didProgressDownload(0)); // then the first chunk of 20 bytes const writeAction2 = await saga.take(); - expect(writeAction2.type).toBe(write.toString()); + expect(write.matches(writeAction2)).toBeTruthy(); expect((writeAction2 as ReturnType).value.length).toBe(20); - saga.put(didWrite((writeAction2 as ReturnType).id)); + saga.put(didWrite(1)); saga.put(checksum(0)); // then progress is updated const progress2Action = await saga.take(); - expect(progress2Action.type).toBe(didProgressDownload.toString()); - expect( - (progress2Action as ReturnType).progress, - ).toBe(20 / 30); + expect(progress2Action).toEqual(didProgressDownload(20 / 30)); // then last chunk const writeAction3 = await saga.take(); - expect(writeAction3.type).toBe(write.toString()); + expect(write.matches(writeAction3)).toBeTruthy(); expect((writeAction3 as ReturnType).value.length).toBe(10); - saga.put(didWrite((writeAction3 as ReturnType).id)); + saga.put(didWrite(2)); saga.put(checksum(0)); // Then a status message saying that we are done const loadedStatusAction = await saga.take(); - expect(loadedStatusAction.type).toBe(didFinishDownload.toString()); + expect(loadedStatusAction).toEqual(didFinishDownload()); await saga.end(); }); @@ -95,7 +89,7 @@ test('repl', async () => { saga.put(repl()); const action = await saga.take(); - expect(action.type).toBe(write.toString()); + expect(action).toEqual(write(0, new Uint8Array([32, 32, 32, 32]))); await saga.end(); }); @@ -106,13 +100,9 @@ test('stop', async () => { saga.put(stop()); const pybricksServiceAction = await saga.take(); - expect(pybricksServiceAction.type).toBe(sendStopUserProgramCommand.toString()); + expect(pybricksServiceAction).toEqual(sendStopUserProgramCommand(0)); - saga.put( - didSendCommand( - (pybricksServiceAction as ReturnType).id, - ), - ); + saga.put(didSendCommand(0)); await saga.end(); }); diff --git a/src/hub/sagas.ts b/src/hub/sagas.ts index 77ce0511..d8d840ed 100644 --- a/src/hub/sagas.ts +++ b/src/hub/sagas.ts @@ -1,7 +1,6 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2020-2022 The Pybricks Authors -import { AnyAction } from 'redux'; import { SagaGenerator, actionChannel, @@ -43,12 +42,8 @@ function* waitForWrite(id: number): SagaGenerator<{ didFailToWrite: ReturnType | undefined; }> { return yield* race({ - didWrite: take>( - (a: AnyAction) => didWrite.matches(a) && a.id === id, - ), - didFailToWrite: take>( - (a: AnyAction) => didFailToWrite.matches(a) && a.id === id, - ), + didWrite: take(didWrite.when((a) => a.id === id)), + didFailToWrite: take(didFailToWrite.when((a) => a.id === id)), }); } @@ -64,8 +59,8 @@ function* handleDownloadAndRun(): Generator { const script = editor.getValue(); yield* put(compile(script, ['-mno-unicode'])); const { mpy, mpyFail } = yield* race({ - mpy: take>(didCompile), - mpyFail: take>(didFailToCompile), + mpy: take(didCompile), + mpyFail: take(didFailToCompile), }); if (mpyFail) { @@ -82,7 +77,7 @@ function* handleDownloadAndRun(): Generator { console.log(`Downloading ${mpy.data.byteLength} bytes`); } - const checksumChannel = yield* actionChannel>(checksum); + const checksumChannel = yield* actionChannel(checksum); const nextMessageId = yield* getContext<() => number>('nextMessageId'); @@ -180,12 +175,8 @@ function* handleStop(): Generator { // 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>( - (a: AnyAction) => didSendCommand.matches(a) && a.id === id, - ), - failedToSend: take>( - (a: AnyAction) => didFailToSendCommand.matches(a) && a.id === id, - ), + sent: take(didSendCommand.when((a) => a.id === id)), + failedToSend: take(didFailToSendCommand.when((a) => a.id === id)), }); if (failedToSend) { // TODO: probably want to check error. If hub disconnected, ignore error diff --git a/src/lwp3-bootloader/sagas.test.ts b/src/lwp3-bootloader/sagas.test.ts index ef04807e..10237754 100644 --- a/src/lwp3-bootloader/sagas.test.ts +++ b/src/lwp3-bootloader/sagas.test.ts @@ -114,20 +114,24 @@ describe('message encoder', () => { ], ], ])('encode %s request', async (_n, request, expected) => { - const messageTypesThatShouldBeCalledWithoutResponse = [ - eraseRequest.toString(), - programRequest.toString(), - rebootRequest.toString(), - disconnectRequest.toString(), + const requestsThatShouldBeCalledWithoutResponse = [ + eraseRequest, + programRequest, + rebootRequest, + disconnectRequest, ]; + const saga = new AsyncSaga(bootloader); saga.put(request); const message = new Uint8Array(expected); const action = await saga.take(); + expect(action).toEqual( send( message, - !messageTypesThatShouldBeCalledWithoutResponse.includes(request.type), + !requestsThatShouldBeCalledWithoutResponse.find((r) => + r.matches(request), + ), ), ); await saga.end(); diff --git a/src/lwp3-bootloader/sagas.ts b/src/lwp3-bootloader/sagas.ts index 161b33c3..da4a0ff7 100644 --- a/src/lwp3-bootloader/sagas.ts +++ b/src/lwp3-bootloader/sagas.ts @@ -116,8 +116,8 @@ function* encodeRequest(): Generator { } const { failedToSend } = yield* race({ - sent: take>(didSend), - failedToSend: take>(didFailToSend), + sent: take(didSend), + failedToSend: take(didFailToSend), }); if (failedToSend) { diff --git a/src/mpy/sagas.test.ts b/src/mpy/sagas.test.ts index 006bc140..b4c17c15 100644 --- a/src/mpy/sagas.test.ts +++ b/src/mpy/sagas.test.ts @@ -16,7 +16,7 @@ test('compiler works', async () => { saga.put(compile('print("hello!")', [])); const action = await saga.take(); - expect(action.type).toBe(didCompile.toString()); + expect(didCompile.matches(action)).toBeTruthy(); const { data } = action as ReturnType; expect(data[0]).toBe('M'.charCodeAt(0)); expect(data[1]).toBe(5); // ABI version @@ -30,7 +30,7 @@ test('compiler error works', async () => { saga.put(compile('syntax error!', [])); const action = await saga.take(); - expect(action.type).toBe(didFailToCompile.toString()); + expect(didFailToCompile.matches(action)).toBeTruthy(); const { err } = action as ReturnType; expect(err).toMatchInlineSnapshot(` Array [ diff --git a/src/terminal/sagas.test.ts b/src/terminal/sagas.test.ts index ed99fc3a..ae92edd2 100644 --- a/src/terminal/sagas.test.ts +++ b/src/terminal/sagas.test.ts @@ -15,6 +15,8 @@ import { createCountFunc } from '../utils/iter'; import { receiveData, sendData } from './actions'; import terminal from './sagas'; +const encoder = new TextEncoder(); + describe('Data receiver filters out hub status', () => { test('normal message - no status', async () => { const saga = new AsyncSaga( @@ -27,8 +29,7 @@ describe('Data receiver filters out hub status', () => { saga.put(didNotify(new DataView(new Uint8Array([0x20]).buffer))); const action = await saga.take(); - expect(action.type).toBe(sendData.toString()); - expect((action as ReturnType).value).toBe(' '); + expect(action).toEqual(sendData(' ')); await saga.end(); }); @@ -43,8 +44,7 @@ describe('Data receiver filters out hub status', () => { saga.put(didNotify(new DataView(new Uint8Array([0xaa]).buffer))); const action = await saga.take(); - expect(action.type).toBe(checksum.toString()); - expect((action as ReturnType).checksum).toBe(0xaa); + expect(action).toEqual(checksum(0xaa)); await saga.end(); }); @@ -74,8 +74,7 @@ test('Terminal data source responds to send data actions', async () => { }); describe('Terminal data source responds to receive data actions', () => { - // ASCII/UTF-8 encoding of 'test1234' - const expected = new Uint8Array([0x74, 0x65, 0x73, 0x74, 0x31, 0x32, 0x33, 0x34]); + const expected = encoder.encode('test1234'); test('basic function works', async () => { const saga = new AsyncSaga(terminal, {}, { nextMessageId: createCountFunc() }); @@ -83,8 +82,7 @@ describe('Terminal data source responds to receive data actions', () => { saga.put(receiveData('test1234')); const action = await saga.take(); - expect(action.type).toBe(write.toString()); - expect((action as ReturnType).value).toEqual(expected); + expect(action).toEqual(write(0, expected)); await saga.end(); }); @@ -100,19 +98,17 @@ describe('Terminal data source responds to receive data actions', () => { expect(saga.numPending()).toBe(1); const action = await saga.take(); - expect(action.type).toBe(write.toString()); - expect((action as ReturnType).value).toEqual(expected); + expect(action).toEqual(write(0, expected)); // second message is queued until didWrite or didFailToWrite expect(saga.numPending()).toBe(0); - saga.put(didWrite((action as ReturnType).id)); + saga.put(didWrite(0)); const action2 = await saga.take(); - expect(action2.type).toBe(write.toString()); - expect((action2 as ReturnType).value).toEqual(expected); + expect(action2).toEqual(write(1, expected)); - saga.put(didWrite((action2 as ReturnType).id)); + saga.put(didWrite(1)); await saga.end(); }); @@ -128,24 +124,17 @@ describe('Terminal data source responds to receive data actions', () => { expect(saga.numPending()).toBe(1); const action = await saga.take(); - expect(action.type).toBe(write.toString()); - expect((action as ReturnType).value).toEqual(expected); + expect(action).toEqual(write(0, expected)); // second message is queued until didWrite or didFailToWrite expect(saga.numPending()).toBe(0); - saga.put( - didFailToWrite( - (action as ReturnType).id, - new Error('test error'), - ), - ); + saga.put(didFailToWrite(0, new Error('test error'))); const action2 = await saga.take(); - expect(action2.type).toBe(write.toString()); - expect((action2 as ReturnType).value).toEqual(expected); + expect(action2).toEqual(write(1, expected)); - saga.put(didWrite((action2 as ReturnType).id)); + saga.put(didWrite(1)); await saga.end(); }); @@ -157,30 +146,27 @@ describe('Terminal data source responds to receive data actions', () => { saga.put(receiveData('test1234')); const action = await saga.take(); - expect(action.type).toBe(write.toString()); - expect((action as ReturnType).value).toEqual( - new Uint8Array([...expected, ...expected]), - ); + expect(action).toEqual(write(0, new Uint8Array([...expected, ...expected]))); await saga.end(); }); test('long messages are split', async () => { + const testData = '012345678901234567890123456789'; + const saga = new AsyncSaga(terminal, {}, { nextMessageId: createCountFunc() }); - saga.put(receiveData('012345678901234567890123456789')); + saga.put(receiveData(testData)); const action = await saga.take(); - expect(action.type).toBe(write.toString()); - expect((action as ReturnType).value.length).toEqual(20); + expect(action).toEqual(write(0, encoder.encode(testData.slice(0, 20)))); - saga.put(didWrite((action as ReturnType).id)); + saga.put(didWrite(0)); const action2 = await saga.take(); - expect(action2.type).toBe(write.toString()); - expect((action2 as ReturnType).value.length).toEqual(10); + expect(action2).toEqual(write(1, encoder.encode(testData.slice(20, 40)))); - saga.put(didWrite((action2 as ReturnType).id)); + saga.put(didWrite(1)); await saga.end(); }); diff --git a/src/terminal/sagas.ts b/src/terminal/sagas.ts index 6418c5bd..2cec0ad3 100644 --- a/src/terminal/sagas.ts +++ b/src/terminal/sagas.ts @@ -44,7 +44,7 @@ function* receiveUartData(action: ReturnType): Generator { } function* receiveTerminalData(): Generator { - const channel = yield* actionChannel>(receiveData); + const channel = yield* actionChannel(receiveData); while (true) { // wait for input from terminal const action = yield* take(channel);