actions: simplify usage of Matchable

This removes use of the pseudo-internal toString() function and also
removes extraneous uses of ReturnType<>. Also, a new when() method
is added to simplify additional uses.
This commit is contained in:
David Lechner
2022-02-26 12:43:10 -06:00
parent 3cbfc4ca68
commit a9f4eaa32d
13 changed files with 125 additions and 142 deletions
+40 -5
View File
@@ -12,8 +12,26 @@ type MatchFunction<A extends AnyAction> = (action: AnyAction) => action is A;
/** The extra members that are attached to a function by createAction(). */
type MatchableExtensions<F extends ActionCreationFunction<A>, 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<F>['type'];
/**
* Type guard to ensure an action matches this type.
*/
matches: MatchFunction<ReturnType<F>>;
/**
* 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<F>) => boolean): MatchFunction<ReturnType<F>>;
};
/** An action creation function that includes MatchableExtensions. */
@@ -26,14 +44,31 @@ type Matchable<F extends ActionCreationFunction<A>, A extends AnyAction> = F &
* @param actionCreator The action creation function.
* @returns actionCreator with type property and match method added.
*/
export function createAction<T extends ActionCreationFunction<A>, A extends AnyAction>(
actionCreator: T,
): Matchable<T, A> {
export function createAction<F extends ActionCreationFunction<A>, A extends AnyAction>(
actionCreator: F,
): Matchable<F, A> {
// create a default action so we can get the type string.
const type = actionCreator().type;
return Object.assign(actionCreator, <MatchableExtensions<T, A>>{
function matches(action: AnyAction): action is ReturnType<F> {
return action.type === type;
}
function when(
predicate: (action: ReturnType<F>) => boolean,
): MatchFunction<ReturnType<F>> {
return (a: AnyAction): a is ReturnType<F> => {
if (!matches(a)) {
return false;
}
return predicate(a);
};
}
return Object.assign(actionCreator, <MatchableExtensions<F, A>>{
toString: () => type,
matches: (action) => action.type === type,
matches,
when,
});
}
+2 -3
View File
@@ -57,9 +57,8 @@ function* encodeRequest(): Generator {
}
const { failedToSend } = yield* race({
sent: take<ReturnType<typeof didWriteCommand>>(didWriteCommand),
failedToSend:
take<ReturnType<typeof didFailToWriteCommand>>(didFailToWriteCommand),
sent: take(didWriteCommand),
failedToSend: take(didFailToWriteCommand),
});
if (failedToSend) {
+1 -4
View File
@@ -63,10 +63,7 @@ import { BleConnectionState } from './reducers';
const decoder = new TextDecoder();
function handleDisconnect(
server: BluetoothRemoteGATTServer,
_action: ReturnType<typeof disconnect>,
): void {
function handleDisconnect(server: BluetoothRemoteGATTServer): void {
server.disconnect();
}
+4 -8
View File
@@ -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<typeof setEditSession>): Gener
yield* put(fileStorageReadFile(currentFileName));
const { result } = yield* race({
result: take<ReturnType<typeof fileStorageDidReadFile>>(
(a: AnyAction) =>
fileStorageDidReadFile.matches(a) && a.fileName === currentFileName,
result: take(
fileStorageDidReadFile.when((a) => a.fileName === currentFileName),
),
error: take<ReturnType<typeof fileStorageDidFailToReadFile>>(
(a: AnyAction) =>
fileStorageDidFailToReadFile.matches(a) &&
a.fileName === currentFileName,
error: take(
fileStorageDidFailToReadFile.when((a) => a.fileName === currentFileName),
),
});
+1 -1
View File
@@ -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();
});
+19 -34
View File
@@ -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<void> {
function* waitForDidRequest(id: number): SagaGenerator<ReturnType<typeof didRequest>> {
const { requested, failedToRequest } = yield* race({
requested: take<ReturnType<typeof didRequest>>(
(a: AnyAction) => didRequest.matches(a) && a.id === id,
),
failedToRequest: take<ReturnType<typeof didFailToRequest>>(
(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<ReturnType<typeof didRequ
/**
* Waits for a response action, an error response or timeout, whichever comes
* first.
* @param type The action type to wait for.
* @param pattern The action type to wait for.
* @param timeout The timeout in milliseconds.
*/
function* waitForResponse<T extends AnyAction>(
type: string,
function* waitForResponse<A extends AnyAction>(
pattern: ActionPattern<A>,
timeout = 500,
): SagaGenerator<T> {
): SagaGenerator<A> {
const { response, error, disconnected, timedOut } = yield* race({
response: take<T>(type),
error: take<ReturnType<typeof errorResponse>>(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<ReturnType<typeof didCompile>>(didCompile),
mpyFail: take<ReturnType<typeof didFailToCompile>>(didFailToCompile),
mpy: take(didCompile),
mpyFail: take(didFailToCompile),
});
if (mpyFail) {
@@ -310,9 +308,7 @@ function* handleFlashFirmware(action: ReturnType<typeof flashFirmware>): Generat
const infoAction = yield* put(infoRequest(nextMessageId()));
const { info } = yield* all({
sent: waitForDidRequest(infoAction.id),
info: waitForResponse<ReturnType<typeof infoResponse>>(
infoResponse.toString(),
),
info: waitForResponse(infoResponse),
});
if (deviceId !== undefined && info.hubType !== deviceId) {
@@ -353,10 +349,7 @@ function* handleFlashFirmware(action: ReturnType<typeof flashFirmware>): Generat
);
const { erase } = yield* all({
sent: waitForDidRequest(eraseAction.id),
erase: waitForResponse<ReturnType<typeof eraseResponse>>(
eraseResponse.toString(),
5000,
),
erase: waitForResponse(eraseResponse, 5000),
});
if (erase.result !== Result.OK) {
yield* put(
@@ -368,9 +361,7 @@ function* handleFlashFirmware(action: ReturnType<typeof flashFirmware>): Generat
const initAction = yield* put(initRequest(nextMessageId(), firmware.length));
const { init } = yield* all({
sent: waitForDidRequest(initAction.id),
init: waitForResponse<ReturnType<typeof initResponse>>(
initResponse.toString(),
),
init: waitForResponse(initResponse),
});
if (init.result) {
yield* put(
@@ -420,10 +411,7 @@ function* handleFlashFirmware(action: ReturnType<typeof flashFirmware>): Generat
const { response } = yield* all({
sent: waitForDidRequest(checksumAction.id),
response: waitForResponse<ReturnType<typeof checksumResponse>>(
checksumResponse.toString(),
5000,
),
response: waitForResponse(checksumResponse, 5000),
});
if (response.checksum !== runningChecksum) {
@@ -447,10 +435,7 @@ function* handleFlashFirmware(action: ReturnType<typeof flashFirmware>): Generat
}
}
const flash = yield* waitForResponse<ReturnType<typeof programResponse>>(
programResponse.toString(),
5000,
);
const flash = yield* waitForResponse(programResponse, 5000);
if (flash.count !== firmware.length) {
yield* put(
+14 -24
View File
@@ -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<typeof write>).value.length).toBe(4);
saga.put(didWrite((writeAction as ReturnType<typeof write>).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<typeof didProgressDownload>).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<typeof write>).value.length).toBe(20);
saga.put(didWrite((writeAction2 as ReturnType<typeof write>).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<typeof didProgressDownload>).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<typeof write>).value.length).toBe(10);
saga.put(didWrite((writeAction3 as ReturnType<typeof write>).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<typeof sendStopUserProgramCommand>).id,
),
);
saga.put(didSendCommand(0));
await saga.end();
});
+7 -16
View File
@@ -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<typeof didFailToWrite> | undefined;
}> {
return yield* race({
didWrite: take<ReturnType<typeof didWrite>>(
(a: AnyAction) => didWrite.matches(a) && a.id === id,
),
didFailToWrite: take<ReturnType<typeof didFailToWrite>>(
(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<ReturnType<typeof didCompile>>(didCompile),
mpyFail: take<ReturnType<typeof didFailToCompile>>(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<ReturnType<typeof checksum>>(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<ReturnType<typeof didSendCommand>>(
(a: AnyAction) => didSendCommand.matches(a) && a.id === id,
),
failedToSend: take<ReturnType<typeof didFailToSendCommand>>(
(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
+10 -6
View File
@@ -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();
+2 -2
View File
@@ -116,8 +116,8 @@ function* encodeRequest(): Generator {
}
const { failedToSend } = yield* race({
sent: take<ReturnType<typeof didSend>>(didSend),
failedToSend: take<ReturnType<typeof didFailToSend>>(didFailToSend),
sent: take(didSend),
failedToSend: take(didFailToSend),
});
if (failedToSend) {
+2 -2
View File
@@ -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<typeof didCompile>;
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<typeof didFailToCompile>;
expect(err).toMatchInlineSnapshot(`
Array [
+22 -36
View File
@@ -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<typeof sendData>).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<typeof checksum>).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<typeof write>).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<typeof write>).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<typeof write>).id));
saga.put(didWrite(0));
const action2 = await saga.take();
expect(action2.type).toBe(write.toString());
expect((action2 as ReturnType<typeof write>).value).toEqual(expected);
expect(action2).toEqual(write(1, expected));
saga.put(didWrite((action2 as ReturnType<typeof write>).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<typeof write>).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<typeof write>).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<typeof write>).value).toEqual(expected);
expect(action2).toEqual(write(1, expected));
saga.put(didWrite((action2 as ReturnType<typeof write>).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<typeof write>).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<typeof write>).value.length).toEqual(20);
expect(action).toEqual(write(0, encoder.encode(testData.slice(0, 20))));
saga.put(didWrite((action as ReturnType<typeof write>).id));
saga.put(didWrite(0));
const action2 = await saga.take();
expect(action2.type).toBe(write.toString());
expect((action2 as ReturnType<typeof write>).value.length).toEqual(10);
expect(action2).toEqual(write(1, encoder.encode(testData.slice(20, 40))));
saga.put(didWrite((action2 as ReturnType<typeof write>).id));
saga.put(didWrite(1));
await saga.end();
});
+1 -1
View File
@@ -44,7 +44,7 @@ function* receiveUartData(action: ReturnType<typeof didNotify>): Generator {
}
function* receiveTerminalData(): Generator {
const channel = yield* actionChannel<ReturnType<typeof receiveData>>(receiveData);
const channel = yield* actionChannel(receiveData);
while (true) {
// wait for input from terminal
const action = yield* take(channel);