diff --git a/src/ble-device-info-service/protocol.test.ts b/src/ble-device-info-service/protocol.test.ts index 33237599..8b82309b 100644 --- a/src/ble-device-info-service/protocol.test.ts +++ b/src/ble-device-info-service/protocol.test.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2021 The Pybricks Authors +// Copyright (c) 2021-2022 The Pybricks Authors import { HubType, @@ -8,7 +8,7 @@ import { } from '../ble-lwp3-service/protocol'; import { decodePnpId, getHubTypeName } from './protocol'; -function encodeInfo(id: HubType, variant?: number) { +export function encodeInfo(id: HubType, variant?: number) { return new DataView( new Uint8Array([ 1, // Bluetooth SIG diff --git a/src/ble/sagas.test.ts b/src/ble/sagas.test.ts new file mode 100644 index 00000000..dbadcc6c --- /dev/null +++ b/src/ble/sagas.test.ts @@ -0,0 +1,359 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { HubType } from '@pybricks/firmware'; +import { MockProxy, mock } from 'jest-mock-extended'; +import { AsyncSaga } from '../../test'; +import { + bleDIServiceDidReceiveFirmwareRevision, + bleDIServiceDidReceivePnPId, + bleDIServiceDidReceiveSoftwareRevision, +} from '../ble-device-info-service/actions'; +import { + serviceUUID as deviceInfoServiceUUID, + firmwareRevisionStringUUID, + pnpIdUUID, + softwareRevisionStringUUID, +} from '../ble-device-info-service/protocol'; +import { encodeInfo } from '../ble-device-info-service/protocol.test'; +import { + RxCharUUID as uartRxCharUUID, + ServiceUUID as uartServiceUUID, + TxCharUUID as uartTxCharUUID, +} from '../ble-nordic-uart-service/protocol'; +import { + ControlCharacteristicUUID as pybricksCommandCharacteristicUUID, + ServiceUUID as pybricksServiceUUID, +} from '../ble-pybricks-service/protocol'; +import { + BleDeviceFailToConnectReasonType, + connect, + didConnect, + didFailToConnect, +} from './actions'; +import ble from './sagas'; + +const encoder = new TextEncoder(); + +afterEach(() => { + jest.clearAllMocks(); +}); + +describe('connect action is dispatched', () => { + let saga: AsyncSaga; + + beforeEach(() => { + saga = new AsyncSaga(ble); + }); + + it('should fail if no web bluetooth', async () => { + saga.put(connect()); + + await expect(saga.take()).resolves.toEqual( + didFailToConnect({ + reason: BleDeviceFailToConnectReasonType.NoWebBluetooth, + }), + ); + }); + + describe('has web bluetooth', () => { + beforeEach(() => { + navigator.bluetooth = mock(); + }); + + it('should fail if bluetooth is not available', async () => { + jest.spyOn(navigator.bluetooth, 'getAvailability').mockResolvedValue(false); + saga.put(connect()); + + await expect(saga.take()).resolves.toEqual( + didFailToConnect({ + reason: BleDeviceFailToConnectReasonType.NoBluetooth, + }), + ); + }); + + describe('bluetooth is available', () => { + beforeEach(() => { + jest.spyOn(navigator.bluetooth, 'getAvailability').mockResolvedValue( + true, + ); + }); + + it('should fail if user canceled', async () => { + jest.spyOn(navigator.bluetooth, 'requestDevice').mockRejectedValue( + new DOMException('test error', 'NotFoundError'), + ); + saga.put(connect()); + + 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()); + + await expect(saga.take()).resolves.toEqual( + didFailToConnect({ + reason: BleDeviceFailToConnectReasonType.Unknown, + err: testError, + }), + ); + }); + + describe('device found', () => { + let device: MockProxy; + + beforeEach(() => { + const deviceEvents = new EventTarget(); + + 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), + }); + + jest.spyOn(navigator.bluetooth, 'requestDevice').mockResolvedValue( + device, + ); + }); + + it('should fail if no gatt', async () => { + saga.put(connect()); + + await expect(saga.take()).resolves.toEqual( + didFailToConnect({ + reason: BleDeviceFailToConnectReasonType.NoGatt, + }), + ); + }); + + describe('has gatt', () => { + let gatt: MockProxy; + + beforeEach(() => { + gatt = mock(); + Object.defineProperty(device, 'gatt', { value: gatt }); + }); + + it('should fail if gatt connect fails', async () => { + const testError = new DOMException( + 'test error', + 'NetworkError', + ); + gatt.connect.mockRejectedValue(testError); + + saga.put(connect()); + + await expect(saga.take()).resolves.toEqual( + didFailToConnect({ + reason: BleDeviceFailToConnectReasonType.Unknown, + err: testError, + }), + ); + }); + + describe('gatt connect succeeded', () => { + beforeEach(() => { + gatt.connect.mockResolvedValue(gatt); + gatt.disconnect.mockImplementation(() => { + setTimeout(() => { + device.dispatchEvent( + new Event('gattserverdisconnected'), + ); + }, 10); + }); + }); + + it('should fail if device does not have device info service', async () => { + const testError = new DOMException( + 'test error', + 'NotFoundError', + ); + gatt.getPrimaryService.mockRejectedValue(testError); + + saga.put(connect()); + + await expect(saga.take()).resolves.toEqual( + didFailToConnect({ + reason: BleDeviceFailToConnectReasonType.NoDeviceInfoService, + }), + ); + + expect(gatt.disconnect).toHaveBeenCalled(); + }); + + describe('has device info service', () => { + let deviceInfoService: MockProxy; + + beforeEach(() => { + deviceInfoService = mock(); + gatt.getPrimaryService + .calledWith(deviceInfoServiceUUID) + .mockResolvedValue(deviceInfoService); + }); + + it('should fail if getting firmware version characteristic fails', async () => { + const testError = new Error('test error'); + deviceInfoService.getCharacteristic.mockRejectedValue( + testError, + ); + + saga.put(connect()); + + await expect(saga.take()).resolves.toEqual( + didFailToConnect({ + reason: BleDeviceFailToConnectReasonType.Unknown, + err: testError, + }), + ); + + expect(gatt.disconnect).toHaveBeenCalled(); + }); + + 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; + + beforeEach(() => { + firmwareRevisionChar = + mock(); + firmwareRevisionChar.readValue.mockResolvedValue( + new DataView(encoder.encode('3.2.0b2').buffer), + ); + + softwareRevisionChar = + mock(); + softwareRevisionChar.readValue.mockResolvedValue( + new DataView(encoder.encode('1.1.0').buffer), + ); + + pnpIdChar = + mock(); + pnpIdChar.readValue.mockResolvedValue( + new DataView( + encodeInfo(HubType.TechnicHub).buffer, + ), + ); + + deviceInfoService.getCharacteristic + .calledWith(firmwareRevisionStringUUID) + .mockResolvedValue(firmwareRevisionChar); + deviceInfoService.getCharacteristic + .calledWith(softwareRevisionStringUUID) + .mockResolvedValue(softwareRevisionChar); + deviceInfoService.getCharacteristic + .calledWith(pnpIdUUID) + .mockResolvedValue(pnpIdChar); + + pybricksService = + mock(); + + gatt.getPrimaryService + .calledWith(pybricksServiceUUID) + .mockResolvedValue(pybricksService); + + 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, + ); + + pybricksService.getCharacteristic + .calledWith(pybricksCommandCharacteristicUUID) + .mockResolvedValue(pybricksChar); + + uartService = mock(); + + gatt.getPrimaryService + .calledWith(uartServiceUUID) + .mockResolvedValue(uartService); + + uartRxChar = + mock(); + uartService.getCharacteristic + .calledWith(uartRxCharUUID) + .mockResolvedValue(uartRxChar); + + uartTxChar = + mock(); + uartService.getCharacteristic + .calledWith(uartTxCharUUID) + .mockResolvedValue(uartTxChar); + }); + + it('should put didConnection action', async () => { + saga.put(connect()); + + // TODO: there are a bunch of untested failure paths + + await expect(saga.take()).resolves.toEqual( + bleDIServiceDidReceiveFirmwareRevision( + '3.2.0b2', + ), + ); + + await expect(saga.take()).resolves.toEqual( + bleDIServiceDidReceiveSoftwareRevision('1.1.0'), + ); + + await expect(saga.take()).resolves.toEqual( + bleDIServiceDidReceivePnPId({ + productId: 0x80, + productVersion: 0, + vendorId: 919, + vendorIdSource: 1, + }), + ); + + await expect(saga.take()).resolves.toEqual( + didConnect('test-id', 'test name'), + ); + }); + }); + }); + }); + }); + }); + }); + }); + + afterEach(async () => { + await saga.end(); + }); +});