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"