From 25e86c2f602cc1f8f4e31f5b82aa4ce5ea78183f Mon Sep 17 00:00:00 2001 From: David Lechner Date: Thu, 14 Jul 2022 20:43:29 -0500 Subject: [PATCH] ble/sagas: flatten tests This removes much of the nesting in tests to make the code easier to read. Also we can now add more coverage for all of the skipped error paths. --- ...t-mock-extended-npm-2.0.6-86ec410111.patch | 39 + package.json | 3 +- src/ble/sagas.test.ts | 744 ++++++++++++------ yarn.lock | 14 +- 4 files changed, 562 insertions(+), 238 deletions(-) create mode 100644 .yarn/patches/jest-mock-extended-npm-2.0.6-86ec410111.patch diff --git a/.yarn/patches/jest-mock-extended-npm-2.0.6-86ec410111.patch b/.yarn/patches/jest-mock-extended-npm-2.0.6-86ec410111.patch new file mode 100644 index 00000000..2f6f4e7c --- /dev/null +++ b/.yarn/patches/jest-mock-extended-npm-2.0.6-86ec410111.patch @@ -0,0 +1,39 @@ +diff --git a/lib/CalledWithFn.js b/lib/CalledWithFn.js +index 56674799caceb24951e71ab00f20eb53a52c5b6c..1aec49f8c67eb840dba54e937fe49f66a9c13872 100644 +--- a/lib/CalledWithFn.js ++++ b/lib/CalledWithFn.js +@@ -30,7 +30,7 @@ const calledWithFn = () => { + fn.mockImplementation((...args) => checkCalledWith(calledWithStack, args)); + calledWithStack = []; + } +- calledWithStack.push({ args, calledWithFn }); ++ calledWithStack.unshift({ args, calledWithFn }); + return calledWithFn; + }; + return fn; +diff --git a/lib/cjs/CalledWithFn.js b/lib/cjs/CalledWithFn.js +index 0cdfc48c26a86f4372e19b8abc2cd3c2dd32f357..d374b9bbe2d93ed27f2ea8d8443338dde970a33a 100644 +--- a/lib/cjs/CalledWithFn.js ++++ b/lib/cjs/CalledWithFn.js +@@ -30,7 +30,7 @@ const calledWithFn = () => { + fn.mockImplementation((...args) => checkCalledWith(calledWithStack, args)); + calledWithStack = []; + } +- calledWithStack.push({ args, calledWithFn }); ++ calledWithStack.unshift({ args, calledWithFn }); + return calledWithFn; + }; + return fn; +diff --git a/lib/mjs/CalledWithFn.js b/lib/mjs/CalledWithFn.js +index 4c90aeb893cf4c11cacc386d08aea6147cc8f9e1..5c4677c2d088392a067d94742415237f539b77f1 100644 +--- a/lib/mjs/CalledWithFn.js ++++ b/lib/mjs/CalledWithFn.js +@@ -27,7 +27,7 @@ export const calledWithFn = () => { + fn.mockImplementation((...args) => checkCalledWith(calledWithStack, args)); + calledWithStack = []; + } +- calledWithStack.push({ args, calledWithFn }); ++ calledWithStack.unshift({ args, calledWithFn }); + return calledWithFn; + }; + return fn; diff --git a/package.json b/package.json index 7fbcdfbd..a9299c6a 100644 --- a/package.json +++ b/package.json @@ -162,7 +162,8 @@ "resolutions": { "mq-polyfill@1.1.8": "patch:mq-polyfill@npm:1.1.8#.yarn/patches/mq-polyfill-npm-1.1.8-62fe162439.patch", "react-error-overlay": "6.0.9", - "react-dev-utils@^12.0.1": "patch:react-dev-utils@npm:12.0.1#.yarn/patches/react-dev-utils-npm-12.0.1-83ba06e3ee.patch" + "react-dev-utils@^12.0.1": "patch:react-dev-utils@npm:12.0.1#.yarn/patches/react-dev-utils-npm-12.0.1-83ba06e3ee.patch", + "jest-mock-extended@^2.0.6": "patch:jest-mock-extended@npm:2.0.6#.yarn/patches/jest-mock-extended-npm-2.0.6-86ec410111.patch" }, "jest": { "roots": [ diff --git a/src/ble/sagas.test.ts b/src/ble/sagas.test.ts index dbadcc6c..8f3eb3e2 100644 --- a/src/ble/sagas.test.ts +++ b/src/ble/sagas.test.ts @@ -29,8 +29,12 @@ import { BleDeviceFailToConnectReasonType, connect, didConnect, + didDisconnect, didFailToConnect, + disconnect, + toggleBluetooth, } from './actions'; +import { BleConnectionState } from './reducers'; import ble from './sagas'; const encoder = new TextEncoder(); @@ -39,6 +43,192 @@ afterEach(() => { jest.clearAllMocks(); }); +type Mocks = { + bluetooth: MockProxy; + device: MockProxy; + gatt: MockProxy; + deviceInfoService: MockProxy; + firmwareRevisionChar: MockProxy; + softwareRevisionChar: MockProxy; + pnpIdChar: MockProxy; + pybricksService: MockProxy; + pybricksChar: MockProxy; + uartService: MockProxy; + uartRxChar: MockProxy; + uartTxChar: MockProxy; +}; + +/** + * Creates mocks used in connect tests. + */ +function createMocks(): Mocks { + const firmwareRevisionChar = mock(); + firmwareRevisionChar.readValue.mockResolvedValue( + new DataView(encoder.encode('3.2.0b2').buffer), + ); + + const softwareRevisionChar = mock(); + softwareRevisionChar.readValue.mockResolvedValue( + new DataView(encoder.encode('1.1.0').buffer), + ); + + const pnpIdChar = mock(); + pnpIdChar.readValue.mockResolvedValue( + new DataView(encodeInfo(HubType.TechnicHub).buffer), + ); + + const deviceInfoService = mock(); + deviceInfoService.getCharacteristic + .calledWith(firmwareRevisionStringUUID) + .mockResolvedValue(firmwareRevisionChar); + deviceInfoService.getCharacteristic + .calledWith(softwareRevisionStringUUID) + .mockResolvedValue(softwareRevisionChar); + deviceInfoService.getCharacteristic + .calledWith(pnpIdUUID) + .mockResolvedValue(pnpIdChar); + + const pybricksCharEventTarget = new EventTarget(); + const pybricksChar = mock({ + addEventListener: pybricksCharEventTarget.addEventListener.bind( + pybricksCharEventTarget, + ), + removeEventListener: pybricksCharEventTarget.removeEventListener.bind( + pybricksCharEventTarget, + ), + dispatchEvent: pybricksCharEventTarget.dispatchEvent.bind( + pybricksCharEventTarget, + ), + }); + pybricksChar.startNotifications.mockResolvedValue(pybricksChar); + pybricksChar.stopNotifications.mockResolvedValue(pybricksChar); + + const pybricksService = mock(); + pybricksService.getCharacteristic + .calledWith(pybricksCommandCharacteristicUUID) + .mockResolvedValue(pybricksChar); + + const uartRxChar = mock(); + + const uartTxCharEventTarget = new EventTarget(); + const uartTxChar = mock({ + addEventListener: + uartTxCharEventTarget.addEventListener.bind(uartTxCharEventTarget), + removeEventListener: + uartTxCharEventTarget.removeEventListener.bind(uartTxCharEventTarget), + dispatchEvent: uartTxCharEventTarget.dispatchEvent.bind(uartTxCharEventTarget), + }); + + const uartService = mock(); + uartService.getCharacteristic + .calledWith(uartRxCharUUID) + .mockResolvedValue(uartRxChar); + uartService.getCharacteristic + .calledWith(uartTxCharUUID) + .mockResolvedValue(uartTxChar); + + const gatt = mock(); + gatt.connect.mockResolvedValue(gatt); + gatt.disconnect.mockImplementation(() => { + setTimeout(() => { + device.dispatchEvent(new Event('gattserverdisconnected')); + }, 10); + }); + gatt.getPrimaryService + .calledWith(deviceInfoServiceUUID) + .mockResolvedValue(deviceInfoService); + gatt.getPrimaryService + .calledWith(pybricksServiceUUID) + .mockResolvedValue(pybricksService); + gatt.getPrimaryService.calledWith(uartServiceUUID).mockResolvedValue(uartService); + + const deviceEvents = new EventTarget(); + const device = mock({ + id: 'test-id', + name: 'test name', + gatt, + addEventListener: deviceEvents.addEventListener.bind( + deviceEvents, + ) as BluetoothDevice['addEventListener'], + removeEventListener: deviceEvents.removeEventListener.bind(deviceEvents), + dispatchEvent: deviceEvents.dispatchEvent.bind(deviceEvents), + }); + + const bluetooth = mock(); + bluetooth.getAvailability.mockResolvedValue(true); + bluetooth.requestDevice.mockResolvedValue(device); + + return { + bluetooth, + device, + gatt, + deviceInfoService, + firmwareRevisionChar, + softwareRevisionChar, + pnpIdChar, + pybricksService, + pybricksChar, + uartService, + uartRxChar, + uartTxChar, + }; +} + +enum ConnectRunPoint { + Connect, + DidReceiveFirmwareRevision, + DidReceiveSoftwareRevision, + DidReceivePnpId, + DidConnect, +} + +/** + * Run the "success" path of the connect saga until a given point. + * + * This helps avoid duplicate code in tests. + * + * @param saga The saga. + * @param point The point at which to stop running. + */ +async function runConnectUntil(saga: AsyncSaga, point: ConnectRunPoint): Promise { + saga.put(connect()); + + if (point === ConnectRunPoint.Connect) { + return; + } + + await expect(saga.take()).resolves.toEqual( + bleDIServiceDidReceiveFirmwareRevision('3.2.0b2'), + ); + + if (point === ConnectRunPoint.DidReceiveFirmwareRevision) { + return; + } + + await expect(saga.take()).resolves.toEqual( + bleDIServiceDidReceiveSoftwareRevision('1.1.0'), + ); + + if (point === ConnectRunPoint.DidReceiveSoftwareRevision) { + return; + } + + await expect(saga.take()).resolves.toEqual( + bleDIServiceDidReceivePnPId({ + productId: 0x80, + productVersion: 0, + vendorId: 919, + vendorIdSource: 1, + }), + ); + + if (point === ConnectRunPoint.DidReceivePnpId) { + return; + } + + await expect(saga.take()).resolves.toEqual(didConnect('test-id', 'test name')); +} + describe('connect action is dispatched', () => { let saga: AsyncSaga; @@ -47,7 +237,7 @@ describe('connect action is dispatched', () => { }); it('should fail if no web bluetooth', async () => { - saga.put(connect()); + await runConnectUntil(saga, ConnectRunPoint.Connect); await expect(saga.take()).resolves.toEqual( didFailToConnect({ @@ -57,13 +247,16 @@ describe('connect action is dispatched', () => { }); describe('has web bluetooth', () => { + let mocks: Mocks; beforeEach(() => { - navigator.bluetooth = mock(); + mocks = createMocks(); + navigator.bluetooth = mocks.bluetooth; }); it('should fail if bluetooth is not available', async () => { jest.spyOn(navigator.bluetooth, 'getAvailability').mockResolvedValue(false); - saga.put(connect()); + + await runConnectUntil(saga, ConnectRunPoint.Connect); await expect(saga.take()).resolves.toEqual( didFailToConnect({ @@ -72,284 +265,341 @@ describe('connect action is dispatched', () => { ); }); - describe('bluetooth is available', () => { - beforeEach(() => { - jest.spyOn(navigator.bluetooth, 'getAvailability').mockResolvedValue( - true, - ); - }); + it('should fail if user canceled requestDevice', async () => { + jest.spyOn(navigator.bluetooth, 'requestDevice').mockRejectedValue( + new DOMException('test error', 'NotFoundError'), + ); - it('should fail if user canceled', async () => { - jest.spyOn(navigator.bluetooth, 'requestDevice').mockRejectedValue( - new DOMException('test error', 'NotFoundError'), - ); - saga.put(connect()); + await runConnectUntil(saga, ConnectRunPoint.Connect); - await expect(saga.take()).resolves.toEqual( - didFailToConnect({ - reason: BleDeviceFailToConnectReasonType.Canceled, - }), - ); - }); + await expect(saga.take()).resolves.toEqual( + didFailToConnect({ + reason: BleDeviceFailToConnectReasonType.Canceled, + }), + ); + }); - it('should fail on other exception', async () => { - const testError = new DOMException('test error', 'SecurityError'); - jest.spyOn(navigator.bluetooth, 'requestDevice').mockRejectedValue( - testError, - ); - saga.put(connect()); + it('should fail on other exception in requestDevice', async () => { + const testError = new DOMException('test error', 'SecurityError'); + jest.spyOn(navigator.bluetooth, 'requestDevice').mockRejectedValue( + testError, + ); - await expect(saga.take()).resolves.toEqual( - didFailToConnect({ - reason: BleDeviceFailToConnectReasonType.Unknown, - err: testError, - }), - ); - }); + await runConnectUntil(saga, ConnectRunPoint.Connect); - describe('device found', () => { - let device: MockProxy; + await expect(saga.take()).resolves.toEqual( + didFailToConnect({ + reason: BleDeviceFailToConnectReasonType.Unknown, + err: testError, + }), + ); + }); - beforeEach(() => { - const deviceEvents = new EventTarget(); + it('should fail if device has no gatt property', async () => { + Object.defineProperty(mocks.device, 'gatt', { value: undefined }); - device = mock({ - id: 'test-id', - name: 'test name', - gatt: undefined, - addEventListener: deviceEvents.addEventListener.bind( - deviceEvents, - ) as BluetoothDevice['addEventListener'], - removeEventListener: - deviceEvents.removeEventListener.bind(deviceEvents), - dispatchEvent: deviceEvents.dispatchEvent.bind(deviceEvents), - }); + await runConnectUntil(saga, ConnectRunPoint.Connect); - jest.spyOn(navigator.bluetooth, 'requestDevice').mockResolvedValue( - device, - ); - }); + await expect(saga.take()).resolves.toEqual( + didFailToConnect({ + reason: BleDeviceFailToConnectReasonType.NoGatt, + }), + ); + }); - it('should fail if no gatt', async () => { - saga.put(connect()); + it('should fail if gatt connect fails', async () => { + const testError = new DOMException('test error', 'NetworkError'); + mocks.gatt.connect.mockRejectedValueOnce(testError); - await expect(saga.take()).resolves.toEqual( - didFailToConnect({ - reason: BleDeviceFailToConnectReasonType.NoGatt, - }), - ); - }); + await runConnectUntil(saga, ConnectRunPoint.Connect); - describe('has gatt', () => { - let gatt: MockProxy; + await expect(saga.take()).resolves.toEqual( + didFailToConnect({ + reason: BleDeviceFailToConnectReasonType.Unknown, + err: testError, + }), + ); + }); - beforeEach(() => { - gatt = mock(); - Object.defineProperty(device, 'gatt', { value: gatt }); - }); + it('should fail if device does not have device info service', async () => { + const testError = new DOMException('test error', 'NotFoundError'); + mocks.gatt.getPrimaryService + .calledWith(deviceInfoServiceUUID) + .mockRejectedValueOnce(testError); - it('should fail if gatt connect fails', async () => { - const testError = new DOMException( - 'test error', - 'NetworkError', - ); - gatt.connect.mockRejectedValue(testError); + await runConnectUntil(saga, ConnectRunPoint.Connect); - saga.put(connect()); + await expect(saga.take()).resolves.toEqual( + didFailToConnect({ + reason: BleDeviceFailToConnectReasonType.NoDeviceInfoService, + }), + ); - await expect(saga.take()).resolves.toEqual( - didFailToConnect({ - reason: BleDeviceFailToConnectReasonType.Unknown, - err: testError, - }), - ); - }); + expect(mocks.gatt.disconnect).toHaveBeenCalled(); + }); - describe('gatt connect succeeded', () => { - beforeEach(() => { - gatt.connect.mockResolvedValue(gatt); - gatt.disconnect.mockImplementation(() => { - setTimeout(() => { - device.dispatchEvent( - new Event('gattserverdisconnected'), - ); - }, 10); - }); - }); + it('should fail if getting firmware revision characteristic fails', async () => { + const testError = new Error('test error'); + mocks.deviceInfoService.getCharacteristic + .calledWith(firmwareRevisionStringUUID) + .mockRejectedValue(testError); - it('should fail if device does not have device info service', async () => { - const testError = new DOMException( - 'test error', - 'NotFoundError', - ); - gatt.getPrimaryService.mockRejectedValue(testError); + await runConnectUntil(saga, ConnectRunPoint.Connect); - saga.put(connect()); + await expect(saga.take()).resolves.toEqual( + didFailToConnect({ + reason: BleDeviceFailToConnectReasonType.Unknown, + err: testError, + }), + ); - await expect(saga.take()).resolves.toEqual( - didFailToConnect({ - reason: BleDeviceFailToConnectReasonType.NoDeviceInfoService, - }), - ); + expect(mocks.gatt.disconnect).toHaveBeenCalled(); + }); - expect(gatt.disconnect).toHaveBeenCalled(); - }); + it('should fail if reading firmware revision characteristic fails', async () => { + const testError = new Error('test error'); + mocks.firmwareRevisionChar.readValue.mockRejectedValue(testError); - describe('has device info service', () => { - let deviceInfoService: MockProxy; + await runConnectUntil(saga, ConnectRunPoint.Connect); - beforeEach(() => { - deviceInfoService = mock(); - gatt.getPrimaryService - .calledWith(deviceInfoServiceUUID) - .mockResolvedValue(deviceInfoService); - }); + await expect(saga.take()).resolves.toEqual( + didFailToConnect({ + reason: BleDeviceFailToConnectReasonType.Unknown, + err: testError, + }), + ); - it('should fail if getting firmware version characteristic fails', async () => { - const testError = new Error('test error'); - deviceInfoService.getCharacteristic.mockRejectedValue( - testError, - ); + expect(mocks.gatt.disconnect).toHaveBeenCalled(); + }); - saga.put(connect()); + it('should fail if getting software revision characteristic fails', async () => { + const testError = new Error('test error'); + mocks.deviceInfoService.getCharacteristic + .calledWith(softwareRevisionStringUUID) + .mockRejectedValueOnce(testError); - await expect(saga.take()).resolves.toEqual( - didFailToConnect({ - reason: BleDeviceFailToConnectReasonType.Unknown, - err: testError, - }), - ); + await runConnectUntil(saga, ConnectRunPoint.DidReceiveFirmwareRevision); - expect(gatt.disconnect).toHaveBeenCalled(); - }); + await expect(saga.take()).resolves.toEqual( + didFailToConnect({ + reason: BleDeviceFailToConnectReasonType.Unknown, + err: testError, + }), + ); - describe('has firmware version', () => { - let firmwareRevisionChar: MockProxy; - let softwareRevisionChar: MockProxy; - let pnpIdChar: MockProxy; - let pybricksService: MockProxy; - let pybricksChar: MockProxy; - let uartService: MockProxy; - let uartRxChar: MockProxy; - let uartTxChar: MockProxy; + expect(mocks.gatt.disconnect).toHaveBeenCalled(); + }); - beforeEach(() => { - firmwareRevisionChar = - mock(); - firmwareRevisionChar.readValue.mockResolvedValue( - new DataView(encoder.encode('3.2.0b2').buffer), - ); + it('should fail if reading software revision characteristic fails', async () => { + const testError = new Error('test error'); + mocks.softwareRevisionChar.readValue.mockRejectedValue(testError); - softwareRevisionChar = - mock(); - softwareRevisionChar.readValue.mockResolvedValue( - new DataView(encoder.encode('1.1.0').buffer), - ); + await runConnectUntil(saga, ConnectRunPoint.DidReceiveFirmwareRevision); - pnpIdChar = - mock(); - pnpIdChar.readValue.mockResolvedValue( - new DataView( - encodeInfo(HubType.TechnicHub).buffer, - ), - ); + await expect(saga.take()).resolves.toEqual( + didFailToConnect({ + reason: BleDeviceFailToConnectReasonType.Unknown, + err: testError, + }), + ); - deviceInfoService.getCharacteristic - .calledWith(firmwareRevisionStringUUID) - .mockResolvedValue(firmwareRevisionChar); - deviceInfoService.getCharacteristic - .calledWith(softwareRevisionStringUUID) - .mockResolvedValue(softwareRevisionChar); - deviceInfoService.getCharacteristic - .calledWith(pnpIdUUID) - .mockResolvedValue(pnpIdChar); + expect(mocks.gatt.disconnect).toHaveBeenCalled(); + }); - pybricksService = - mock(); + it('should skip bleDIServiceDidReceivePnPId action if getting pnp id characteristic fails', async () => { + const testError = new Error('test error'); + mocks.deviceInfoService.getCharacteristic + .calledWith(pnpIdUUID) + .mockRejectedValueOnce(testError); - gatt.getPrimaryService - .calledWith(pybricksServiceUUID) - .mockResolvedValue(pybricksService); + await runConnectUntil(saga, ConnectRunPoint.DidReceiveSoftwareRevision); - const pybricksCharEventTarget = new EventTarget(); - pybricksChar = - mock({ - addEventListener: - pybricksCharEventTarget.addEventListener.bind( - pybricksCharEventTarget, - ), - removeEventListener: - pybricksCharEventTarget.removeEventListener.bind( - pybricksCharEventTarget, - ), - dispatchEvent: - pybricksCharEventTarget.dispatchEvent.bind( - pybricksCharEventTarget, - ), - }); - pybricksChar.startNotifications.mockResolvedValue( - pybricksChar, - ); - pybricksChar.stopNotifications.mockResolvedValue( - pybricksChar, - ); + await expect(saga.take()).resolves.toEqual( + didConnect('test-id', 'test name'), + ); + }); - pybricksService.getCharacteristic - .calledWith(pybricksCommandCharacteristicUUID) - .mockResolvedValue(pybricksChar); + it('should fail if reading pnp id characteristic fails', async () => { + const testError = new Error('test error'); + mocks.pnpIdChar.readValue.mockRejectedValue(testError); - uartService = mock(); + await runConnectUntil(saga, ConnectRunPoint.DidReceiveSoftwareRevision); - gatt.getPrimaryService - .calledWith(uartServiceUUID) - .mockResolvedValue(uartService); + await expect(saga.take()).resolves.toEqual( + didFailToConnect({ + reason: BleDeviceFailToConnectReasonType.Unknown, + err: testError, + }), + ); - uartRxChar = - mock(); - uartService.getCharacteristic - .calledWith(uartRxCharUUID) - .mockResolvedValue(uartRxChar); + expect(mocks.gatt.disconnect).toHaveBeenCalled(); + }); - uartTxChar = - mock(); - uartService.getCharacteristic - .calledWith(uartTxCharUUID) - .mockResolvedValue(uartTxChar); - }); + it('should fail if device does not have pybricks service', async () => { + const testError = new DOMException('test error', 'NotFoundError'); + mocks.gatt.getPrimaryService + .calledWith(pybricksServiceUUID) + .mockRejectedValueOnce(testError); - it('should put didConnection action', async () => { - saga.put(connect()); + await runConnectUntil(saga, ConnectRunPoint.DidReceivePnpId); - // TODO: there are a bunch of untested failure paths + await expect(saga.take()).resolves.toEqual( + didFailToConnect({ + reason: BleDeviceFailToConnectReasonType.NoPybricksService, + }), + ); - await expect(saga.take()).resolves.toEqual( - bleDIServiceDidReceiveFirmwareRevision( - '3.2.0b2', - ), - ); + expect(mocks.gatt.disconnect).toHaveBeenCalled(); + }); - await expect(saga.take()).resolves.toEqual( - bleDIServiceDidReceiveSoftwareRevision('1.1.0'), - ); + it('should fail if getting pybricks characteristic fails', async () => { + const testError = new Error('test error'); + mocks.pybricksService.getCharacteristic + .calledWith(pybricksCommandCharacteristicUUID) + .mockRejectedValue(testError); - await expect(saga.take()).resolves.toEqual( - bleDIServiceDidReceivePnPId({ - productId: 0x80, - productVersion: 0, - vendorId: 919, - vendorIdSource: 1, - }), - ); + await runConnectUntil(saga, ConnectRunPoint.DidReceivePnpId); - await expect(saga.take()).resolves.toEqual( - didConnect('test-id', 'test name'), - ); - }); - }); - }); - }); - }); - }); + await expect(saga.take()).resolves.toEqual( + didFailToConnect({ + reason: BleDeviceFailToConnectReasonType.Unknown, + err: testError, + }), + ); + + expect(mocks.gatt.disconnect).toHaveBeenCalled(); + }); + + it('should fail if stopping pybricks characteristic notifications fails', async () => { + const testError = new Error('test error'); + mocks.pybricksChar.stopNotifications.mockRejectedValue(testError); + + await runConnectUntil(saga, ConnectRunPoint.DidReceivePnpId); + + await expect(saga.take()).resolves.toEqual( + didFailToConnect({ + reason: BleDeviceFailToConnectReasonType.Unknown, + err: testError, + }), + ); + + expect(mocks.gatt.disconnect).toHaveBeenCalled(); + }); + + it('should fail if starting pybricks characteristic notifications fails', async () => { + const testError = new Error('test error'); + mocks.pybricksChar.startNotifications.mockRejectedValue(testError); + + await runConnectUntil(saga, ConnectRunPoint.DidReceivePnpId); + + await expect(saga.take()).resolves.toEqual( + didFailToConnect({ + reason: BleDeviceFailToConnectReasonType.Unknown, + err: testError, + }), + ); + + expect(mocks.gatt.disconnect).toHaveBeenCalled(); + }); + + it('should fail if device does not have nordic uart service', async () => { + const testError = new DOMException('test error', 'NotFoundError'); + mocks.gatt.getPrimaryService + .calledWith(uartServiceUUID) + .mockRejectedValueOnce(testError); + + await runConnectUntil(saga, ConnectRunPoint.DidReceivePnpId); + + await expect(saga.take()).resolves.toEqual( + didFailToConnect({ + // FIXME: this is wrong error + reason: BleDeviceFailToConnectReasonType.NoPybricksService, + }), + ); + + expect(mocks.gatt.disconnect).toHaveBeenCalled(); + }); + + it('should fail if getting nordic uart rx characteristic fails', async () => { + const testError = new Error('test error'); + mocks.uartService.getCharacteristic + .calledWith(uartRxCharUUID) + .mockRejectedValue(testError); + + await runConnectUntil(saga, ConnectRunPoint.DidReceivePnpId); + + await expect(saga.take()).resolves.toEqual( + didFailToConnect({ + reason: BleDeviceFailToConnectReasonType.Unknown, + err: testError, + }), + ); + + expect(mocks.gatt.disconnect).toHaveBeenCalled(); + }); + + it('should fail if getting nordic uart tx characteristic fails', async () => { + const testError = new Error('test error'); + mocks.uartService.getCharacteristic + .calledWith(uartTxCharUUID) + .mockRejectedValue(testError); + + await runConnectUntil(saga, ConnectRunPoint.DidReceivePnpId); + + await expect(saga.take()).resolves.toEqual( + didFailToConnect({ + reason: BleDeviceFailToConnectReasonType.Unknown, + err: testError, + }), + ); + + expect(mocks.gatt.disconnect).toHaveBeenCalled(); + }); + + it('should fail if stopping nordic uart tx characteristic notifications fails', async () => { + const testError = new Error('test error'); + mocks.uartTxChar.stopNotifications.mockRejectedValue(testError); + + await runConnectUntil(saga, ConnectRunPoint.DidReceivePnpId); + + await expect(saga.take()).resolves.toEqual( + didFailToConnect({ + reason: BleDeviceFailToConnectReasonType.Unknown, + err: testError, + }), + ); + + expect(mocks.gatt.disconnect).toHaveBeenCalled(); + }); + + it('should fail if starting nordic uart tx characteristic notifications fails', async () => { + const testError = new Error('test error'); + mocks.uartTxChar.startNotifications.mockRejectedValue(testError); + + await runConnectUntil(saga, ConnectRunPoint.DidReceivePnpId); + + await expect(saga.take()).resolves.toEqual( + didFailToConnect({ + reason: BleDeviceFailToConnectReasonType.Unknown, + err: testError, + }), + ); + + expect(mocks.gatt.disconnect).toHaveBeenCalled(); + }); + + it('should put didConnection action', async () => { + await runConnectUntil(saga, ConnectRunPoint.DidConnect); + }); + + it('should handle disconnect', async () => { + await runConnectUntil(saga, ConnectRunPoint.DidConnect); + + saga.put(disconnect()); + + await expect(saga.take()).resolves.toEqual(didDisconnect()); + + expect(mocks.gatt.disconnect).toHaveBeenCalled(); }); }); @@ -357,3 +607,25 @@ describe('connect action is dispatched', () => { await saga.end(); }); }); + +describe('toggleBluetooth action', () => { + it('should connect when disconnected', async () => { + const saga = new AsyncSaga(ble); + + saga.updateState({ ble: { connection: BleConnectionState.Disconnected } }); + + saga.put(toggleBluetooth()); + + await expect(saga.take()).resolves.toEqual(connect()); + }); + + it('should disconnect when connected', async () => { + const saga = new AsyncSaga(ble); + + saga.updateState({ ble: { connection: BleConnectionState.Connected } }); + + saga.put(toggleBluetooth()); + + await expect(saga.take()).resolves.toEqual(disconnect()); + }); +}); diff --git a/yarn.lock b/yarn.lock index 76cd5630..db1f1ded 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9681,7 +9681,7 @@ __metadata: languageName: node linkType: hard -"jest-mock-extended@npm:^2.0.6": +"jest-mock-extended@npm:2.0.6": version: 2.0.6 resolution: "jest-mock-extended@npm:2.0.6" dependencies: @@ -9693,6 +9693,18 @@ __metadata: languageName: node linkType: hard +"jest-mock-extended@patch:jest-mock-extended@npm:2.0.6#.yarn/patches/jest-mock-extended-npm-2.0.6-86ec410111.patch::locator=%40pybricks%2Fpybricks-code%40workspace%3A.": + version: 2.0.6 + resolution: "jest-mock-extended@patch:jest-mock-extended@npm%3A2.0.6#.yarn/patches/jest-mock-extended-npm-2.0.6-86ec410111.patch::version=2.0.6&hash=9057e0&locator=%40pybricks%2Fpybricks-code%40workspace%3A." + dependencies: + ts-essentials: ^7.0.3 + peerDependencies: + jest: ^24.0.0 || ^25.0.0 || ^26.0.0 || ^27.0.0 || ^28.0.0 + typescript: ^3.0.0 || ^4.0.0 + checksum: 02045304db317a929482d376a46d69d7987b7b5577f602620d678eb129070a87b8e61c213696dbc135eff970a43bb808ea5417d3554819b259aedc4f997eaded + languageName: node + linkType: hard + "jest-mock@npm:^28.1.3": version: 28.1.3 resolution: "jest-mock@npm:28.1.3"