diff --git a/tests/unit/device/deviceManager.spec.ts b/tests/unit/device/deviceManager.spec.ts index 77901675..62f146a9 100644 --- a/tests/unit/device/deviceManager.spec.ts +++ b/tests/unit/device/deviceManager.spec.ts @@ -1,8 +1,9 @@ -import {describe, it, expect, beforeEach} from "vitest"; +import {describe, it, expect, beforeEach, vi} from "vitest"; import {mock,mockClear} from "vitest-mock-extended"; import DeviceManager, { DeviceManagerEvent, DeviceDetectionInfo } from "../../../src/device/deviceManager.js"; +import DeviceOfferRejectedError from "../../../src/device/deviceOfferRejectedError.js"; import {EventEmitter} from "events"; -import Device from "../../../src/device/device.js"; +import Device, { AnyDevice } from "../../../src/device/device.js"; import TestDevice from "./testDevice.js"; import Logger from "../../../src/logging/Logger.js"; import { DeviceId } from "../../../src/device/deviceId.js"; @@ -15,9 +16,17 @@ describe('deviceManager', () => { // device as enabled - the desired default for tests unrelated to the enable/disable feature. const mockedSettingsManager = mock(); + // Announces the device and immediately offers it for connection - the only way to get a + // device registered through the public API now that addDevice() is private. + const connectDevice = async (manager: DeviceManager, deviceInfo: DeviceDetectionInfo, device: AnyDevice) => { + manager.announceDetectedDevice(deviceInfo); + return manager.offerDevice(deviceInfo, () => Promise.resolve(device)); + }; + it('it adds device to managed devices and emits an event', async () => { const mockedDeviceManagerEventEmitter = mock(); + mockedDeviceManagerEventEmitter.emit.mockReturnValue(true); const mockedLogger = mock(); mockedLogger.child.mockReturnValue(mockedLogger); @@ -26,18 +35,19 @@ describe('deviceManager', () => { const deviceId = DeviceId.create('test-device-id'); const device = new TestDevice(deviceId, 'Foo', new Date(), false, new EventEmitter()); + const deviceInfo: DeviceDetectionInfo = { type: 'test', detectionId: deviceId }; // New device connected expect(deviceManager.getConnectedDevices().length).toBe(0); - deviceManager.addDevice({ type: 'test', detectionId: deviceId }, device); + mockClear(mockedDeviceManagerEventEmitter); // drop the constructor-time noise, if any + await connectDevice(deviceManager, deviceInfo, device); let actualDevices = deviceManager.getConnectedDevices(); expect(actualDevices.length).toBe(1); expect(actualDevices[0]).toBe(device); - expect(mockedDeviceManagerEventEmitter.emit).toBeCalledTimes(1); expect(mockedDeviceManagerEventEmitter.emit).toBeCalledWith(DeviceManagerEvent.deviceConnected, device); expect(mockedLogger.child).toBeCalledWith({ name: DeviceManager.name }); }); @@ -48,25 +58,25 @@ describe('deviceManager', () => { const connectedDevices = new Map(); const deviceId = DeviceId.create('test-device-id'); const device = new TestDevice(deviceId, 'Foo', new Date(), false, new EventEmitter()); + const deviceInfo: DeviceDetectionInfo = { type: 'test', detectionId: deviceId }; const mockedDeviceManagerEventEmitter = mock(); + mockedDeviceManagerEventEmitter.emit.mockReturnValue(true); const mockedLogger = mock(); mockedLogger.child.mockReturnValue(mockedLogger); const deviceManager = new DeviceManager(mockedDeviceManagerEventEmitter, connectedDevices, mockedSettingsManager, mockedLogger); - deviceManager.addDevice({ type: 'test', detectionId: deviceId }, device); + await connectDevice(deviceManager, deviceInfo, device); + mockClear(mockedDeviceManagerEventEmitter); // Connected device refreshed await device.refresh(); - expect(mockedDeviceManagerEventEmitter.emit).toBeCalledTimes(2); - expect(mockedDeviceManagerEventEmitter.emit).toHaveBeenNthCalledWith(1, DeviceManagerEvent.deviceConnected, device); - expect(mockedDeviceManagerEventEmitter.emit).toHaveBeenNthCalledWith(2, DeviceManagerEvent.deviceRefreshed, device); + expect(mockedDeviceManagerEventEmitter.emit).toBeCalledTimes(1); + expect(mockedDeviceManagerEventEmitter.emit).toHaveBeenCalledWith(DeviceManagerEvent.deviceRefreshed, device); expect(mockedLogger.child).toBeCalledWith({ name: DeviceManager.name }); - - mockClear(mockedDeviceManagerEventEmitter); }); it('it emits an event on device update', async () => { @@ -74,24 +84,26 @@ describe('deviceManager', () => { const connectedDevices = new Map(); const deviceId = DeviceId.create('test-device-id'); const device = new TestDevice(deviceId, 'Foo', new Date(), false, new EventEmitter()); + const deviceInfo: DeviceDetectionInfo = { type: 'test', detectionId: deviceId }; const mockedDeviceManagerEventEmitter = mock(); + mockedDeviceManagerEventEmitter.emit.mockReturnValue(true); const mockedLogger = mock(); mockedLogger.child.mockReturnValue(mockedLogger); const deviceManager = new DeviceManager(mockedDeviceManagerEventEmitter, connectedDevices, mockedSettingsManager, mockedLogger); - deviceManager.addDevice({ type: 'test', detectionId: deviceId }, device); + await connectDevice(deviceManager, deviceInfo, device); + mockClear(mockedDeviceManagerEventEmitter); // Connected device closed await device.close(); expect(deviceManager.getConnectedDevices().length).toBe(0); - expect(mockedDeviceManagerEventEmitter.emit).toBeCalledTimes(2); - expect(mockedDeviceManagerEventEmitter.emit).toHaveBeenNthCalledWith(1, DeviceManagerEvent.deviceConnected, device); - expect(mockedDeviceManagerEventEmitter.emit).toHaveBeenNthCalledWith(2, DeviceManagerEvent.deviceDisconnected, device); + expect(mockedDeviceManagerEventEmitter.emit).toBeCalledTimes(1); + expect(mockedDeviceManagerEventEmitter.emit).toHaveBeenCalledWith(DeviceManagerEvent.deviceDisconnected, device); expect(mockedLogger.child).toBeCalledWith({ name: DeviceManager.name }); }); @@ -166,11 +178,17 @@ describe('deviceManager', () => { manager.announceDetectedDevice(deviceInfo); - const result = await manager.acquireDetectedDevice(deviceId); + const result = await manager.offerDevice(deviceInfo, () => Promise.resolve(new TestDevice(deviceId, 'Foo', new Date(), false, new EventEmitter()))); expect(result.successful).toBe(false); }); - it('does not emit deviceDetected for a device belonging to a disabled known device', () => { + it('still emits deviceDetected even when the detection id matches a disabled known device', () => { + // Detection id is preliminary/raw - e.g. for serial ports, multiple protocol + // providers share the same detectionId but each computes its own distinct canonical + // id via handshake. Gating here on detectionId's own enabled state would incorrectly + // block every provider, including ones whose real canonical id isn't disabled at + // all. The disabled check that actually matters happens per-canonical-id in + // addDevice(), once a provider has connected and learned the final id. mockedEventEmitter.emit.mockReturnValue(true); const settings = new Settings(); @@ -182,11 +200,11 @@ describe('deviceManager', () => { manager.announceDetectedDevice(deviceInfo); - expect(mockedEventEmitter.emit).not.toHaveBeenCalled(); + expect(mockedEventEmitter.emit).toHaveBeenCalledWith(DeviceManagerEvent.deviceDetected, deviceInfo); }); }); - describe('acquireDetectedDevice', () => { + describe('offerDevice', () => { let mockedLogger: ReturnType>; let mockedEventEmitter: ReturnType>; const deviceId = DeviceId.create('device-2'); @@ -199,64 +217,135 @@ describe('deviceManager', () => { mockedEventEmitter.emit.mockReturnValue(true); }); - it('returns failure when device is not in the detect queue', async () => { + it('rejects with DeviceOfferRejectedError when device is not in the detect queue', async () => { const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedSettingsManager, mockedLogger); - const result = await manager.acquireDetectedDevice(deviceId); + const result = await manager.offerDevice(deviceInfo, () => Promise.resolve(new TestDevice(deviceId, 'Foo', new Date(), false, new EventEmitter()))); expect(result.successful).toBe(false); + expect(!result.successful && result.reason).toBeInstanceOf(DeviceOfferRejectedError); }); - it('resolves immediately with success for the first caller', async () => { + it('runs the first offer immediately and adds the device on success', async () => { const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedSettingsManager, mockedLogger); manager.announceDetectedDevice(deviceInfo); - const result = await manager.acquireDetectedDevice(deviceId); + const device = new TestDevice(deviceId, 'Foo', new Date(), false, new EventEmitter()); + const result = await manager.offerDevice(deviceInfo, () => Promise.resolve(device)); - expect(result).toStrictEqual({ successful: true }); + expect(result).toStrictEqual({ successful: true, device }); + expect(manager.getConnectedDevices()).toContain(device); }); - it('queues the second caller until the first releases', async () => { + it('does not run a second offer while the first is still pending', async () => { const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedSettingsManager, mockedLogger); manager.announceDetectedDevice(deviceInfo); - await manager.acquireDetectedDevice(deviceId); - const secondCallerPromise = manager.acquireDetectedDevice(deviceId); - manager.releaseDetectedDevice(deviceId); + let resolveFirstOffer!: (device: AnyDevice | undefined) => void; + const firstOfferPromise = new Promise((resolve) => { resolveFirstOffer = resolve; }); + const secondOfferFn = vi.fn(() => Promise.resolve(new TestDevice(deviceId, 'Foo', new Date(), false, new EventEmitter()))); - const result = await secondCallerPromise; - expect(result).toStrictEqual({ successful: true }); + const firstResultPromise = manager.offerDevice(deviceInfo, () => firstOfferPromise); + manager.offerDevice(deviceInfo, secondOfferFn); + + expect(secondOfferFn).not.toHaveBeenCalled(); + + resolveFirstOffer(undefined); + await firstResultPromise; }); - }); - describe('releaseDetectedDevice', () => { - let mockedLogger: ReturnType>; - let mockedEventEmitter: ReturnType>; - const deviceId = DeviceId.create('device-3'); - const deviceInfo: DeviceDetectionInfo = { type: 'test', detectionId: deviceId }; + it('hands off to the next queued offer when the first one returns undefined', async () => { + const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedSettingsManager, mockedLogger); + manager.announceDetectedDevice(deviceInfo); - beforeEach(() => { - mockedLogger = mock(); - mockedLogger.child.mockReturnValue(mockedLogger); - mockedEventEmitter = mock(); - mockedEventEmitter.emit.mockReturnValue(true); + const device = new TestDevice(deviceId, 'Foo', new Date(), false, new EventEmitter()); + + const firstResultPromise = manager.offerDevice(deviceInfo, () => Promise.resolve(undefined)); + const secondResultPromise = manager.offerDevice(deviceInfo, () => Promise.resolve(device)); + + const [firstResult, secondResult] = await Promise.all([firstResultPromise, secondResultPromise]); + + expect(firstResult.successful).toBe(false); + expect(secondResult).toStrictEqual({ successful: true, device }); }); - it('is a no-op when device is not in the acquire queue', () => { + it('hands off to the next queued offer when the first one throws', async () => { const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedSettingsManager, mockedLogger); + manager.announceDetectedDevice(deviceInfo); + + const device = new TestDevice(deviceId, 'Foo', new Date(), false, new EventEmitter()); + const offerError = new Error('connection failed'); + + const firstResultPromise = manager.offerDevice(deviceInfo, () => Promise.reject(offerError)); + const secondResultPromise = manager.offerDevice(deviceInfo, () => Promise.resolve(device)); + + const [firstResult, secondResult] = await Promise.all([firstResultPromise, secondResultPromise]); + + expect(firstResult).toStrictEqual({ successful: false, reason: offerError }); + expect(secondResult).toStrictEqual({ successful: true, device }); + }); + + it('hands off to the next queued offer when the first device is disabled', async () => { + // Detection id itself must stay enabled/unknown so announce() actually creates the + // queue - only the canonical id of the first offered device (learned only once + // connected, e.g. during a handshake) is disabled. + const localDetectionId = DeviceId.create('device-2-detection'); + const localDeviceInfo: DeviceDetectionInfo = { type: 'test', detectionId: localDetectionId }; + const disabledCanonicalId = DeviceId.create('device-2-disabled-canonical'); + + const settings = new Settings(); + settings.addKnownDevice(new KnownDevice(disabledCanonicalId, 'Foo', 'test', 'test', {}, false)); + const settingsManager = mock(); + settingsManager.getSettings.mockReturnValue(settings); + + const manager = new DeviceManager(mockedEventEmitter, new Map(), settingsManager, mockedLogger); + manager.announceDetectedDevice(localDeviceInfo); + + const disabledDevice = new TestDevice(disabledCanonicalId, 'Foo', new Date(), false, new EventEmitter()); + const enabledDevice = new TestDevice(DeviceId.create('device-2-enabled-canonical'), 'Foo', new Date(), false, new EventEmitter()); + + const firstResultPromise = manager.offerDevice(localDeviceInfo, () => Promise.resolve(disabledDevice)); + const secondResultPromise = manager.offerDevice(localDeviceInfo, () => Promise.resolve(enabledDevice)); - expect(() => manager.releaseDetectedDevice(DeviceId.create('unknown'))).not.toThrow(); + const [firstResult, secondResult] = await Promise.all([firstResultPromise, secondResultPromise]); + + expect(firstResult.successful).toBe(false); + expect(!firstResult.successful && firstResult.reason).toBeInstanceOf(DeviceOfferRejectedError); + expect(secondResult).toStrictEqual({ successful: true, device: enabledDevice }); }); - it('removes device from queue after the only waiter releases', async () => { + it('clears the queue and re-allows announcing after the only offer fails', async () => { const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedSettingsManager, mockedLogger); manager.announceDetectedDevice(deviceInfo); - await manager.acquireDetectedDevice(deviceId); - manager.releaseDetectedDevice(deviceId); + await manager.offerDevice(deviceInfo, () => Promise.resolve(undefined)); - const result = await manager.acquireDetectedDevice(deviceId); - expect(result.successful).toBe(false); + mockClear(mockedEventEmitter); + mockedEventEmitter.emit.mockReturnValue(true); + + manager.announceDetectedDevice(deviceInfo); + + expect(mockedEventEmitter.emit).toHaveBeenCalledWith(DeviceManagerEvent.deviceDetected, deviceInfo); + }); + + it('rejects other queued offers with DeviceOfferRejectedError once a device is claimed', async () => { + const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedSettingsManager, mockedLogger); + manager.announceDetectedDevice(deviceInfo); + + let resolveFirstOffer!: (device: AnyDevice | undefined) => void; + const firstOfferPromise = new Promise((resolve) => { resolveFirstOffer = resolve; }); + const device = new TestDevice(deviceId, 'Foo', new Date(), false, new EventEmitter()); + + const firstResultPromise = manager.offerDevice(deviceInfo, () => firstOfferPromise); + const secondResultPromise = manager.offerDevice(deviceInfo, () => Promise.reject(new Error('should never run'))); + + resolveFirstOffer(device); + + const [firstResult, secondResult] = await Promise.all([firstResultPromise, secondResultPromise]); + + expect(firstResult).toStrictEqual({ successful: true, device }); + expect(secondResult.successful).toBe(false); + expect(!secondResult.successful && secondResult.reason).toBeInstanceOf(DeviceOfferRejectedError); }); }); @@ -273,16 +362,18 @@ describe('deviceManager', () => { mockedEventEmitter.emit.mockReturnValue(true); }); - it('resolves a pending second caller with failure', async () => { + it('resolves a pending offer with failure', async () => { const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedSettingsManager, mockedLogger); manager.announceDetectedDevice(deviceInfo); - await manager.acquireDetectedDevice(deviceId); // first caller holds - const pendingPromise = manager.acquireDetectedDevice(deviceId); // second waits + + // First offer never settles on its own, so it's still holding the queue when revoked + const pendingPromise = manager.offerDevice(deviceInfo, () => new Promise(() => {})); manager.revokeDetectedDevice(deviceInfo); const result = await pendingPromise; expect(result.successful).toBe(false); + expect(!result.successful && result.reason).toBeInstanceOf(DeviceOfferRejectedError); }); it('drops a disabled device from pending retry so it is not re-announced after re-enabling', async () => { @@ -294,9 +385,15 @@ describe('deviceManager', () => { const manager = new DeviceManager(mockedEventEmitter, new Map(), settingsManager, mockedLogger); - // Announced while disabled -> parked in pending retry, no deviceDetected emitted. + // Announced (detection id happens to match the disabled known device) - the provider + // still gets a chance to offer it, but addDevice() rejects it once connected since + // it's disabled, parking it in pending retry. manager.announceDetectedDevice(deviceInfo); - expect(mockedEventEmitter.emit).not.toHaveBeenCalled(); + const device = new TestDevice(deviceId, 'Foo', new Date(), false, new EventEmitter()); + const result = await manager.offerDevice(deviceInfo, () => Promise.resolve(device)); + expect(result.successful).toBe(false); + + mockClear(mockedEventEmitter); // Device physically disappears while still disabled. manager.revokeDetectedDevice(deviceInfo); @@ -309,32 +406,6 @@ describe('deviceManager', () => { }); }); - describe('claimDetectedDevice', () => { - let mockedLogger: ReturnType>; - let mockedEventEmitter: ReturnType>; - const deviceId = DeviceId.create('device-5'); - const deviceInfo: DeviceDetectionInfo = { type: 'test', detectionId: deviceId }; - - beforeEach(() => { - mockedLogger = mock(); - mockedLogger.child.mockReturnValue(mockedLogger); - mockedEventEmitter = mock(); - mockedEventEmitter.emit.mockReturnValue(true); - }); - - it('resolves a pending caller with failure', async () => { - const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedSettingsManager, mockedLogger); - manager.announceDetectedDevice(deviceInfo); - await manager.acquireDetectedDevice(deviceId); // first caller holds - const pendingPromise = manager.acquireDetectedDevice(deviceId); // second waits - - manager.claimDetectedDevice(deviceId); - - const result = await pendingPromise; - expect(result.successful).toBe(false); - }); - }); - describe('isDeviceEnabled', () => { let mockedLogger: ReturnType>; @@ -375,48 +446,60 @@ describe('deviceManager', () => { }); }); - describe('addDevice - disabled devices', () => { + describe('offerDevice - disabled devices', () => { let mockedLogger: ReturnType>; + let mockedEventEmitter: ReturnType>; beforeEach(() => { mockedLogger = mock(); mockedLogger.child.mockReturnValue(mockedLogger); + mockedEventEmitter = mock(); + mockedEventEmitter.emit.mockReturnValue(true); }); - it('does not register a device belonging to a disabled known device and closes it', () => { - const deviceId = DeviceId.create('disabled-device'); + it('does not register a device whose canonical id belongs to a disabled known device, and closes it', async () => { + // Detection id is unknown/enabled so announce() lets it through and the offer runs - + // the device only turns out to be disabled once its canonical id is learned, e.g. + // during a handshake. This is the only way to reach addDevice()'s own disabled-check + // through the public API now that it's private. + const detectionId = DeviceId.create('disabled-device-detection'); + const canonicalId = DeviceId.create('disabled-device-canonical'); + const deviceInfo: DeviceDetectionInfo = { type: 'test', detectionId }; + const settings = new Settings(); - settings.addKnownDevice(new KnownDevice(deviceId, 'Foo', 'test', 'test', {}, false)); + settings.addKnownDevice(new KnownDevice(canonicalId, 'Foo', 'test', 'test', {}, false)); const settingsManager = mock(); settingsManager.getSettings.mockReturnValue(settings); const connectedDevices = new Map(); - const manager = new DeviceManager(mock(), connectedDevices, settingsManager, mockedLogger); + const manager = new DeviceManager(mockedEventEmitter, connectedDevices, settingsManager, mockedLogger); - const device = new TestDevice(deviceId, 'Foo', new Date(), false, new EventEmitter()); + const device = new TestDevice(canonicalId, 'Foo', new Date(), false, new EventEmitter()); - const added = manager.addDevice({ type: 'test', detectionId: deviceId }, device); + const result = await connectDevice(manager, deviceInfo, device); - expect(added).toBe(false); + expect(result.successful).toBe(false); + expect(!result.successful && result.reason).toBeInstanceOf(DeviceOfferRejectedError); expect(manager.getConnectedDevices()).toHaveLength(0); }); - it('registers a device belonging to an enabled known device', () => { + it('registers a device belonging to an enabled known device', async () => { const deviceId = DeviceId.create('enabled-device'); + const deviceInfo: DeviceDetectionInfo = { type: 'test', detectionId: deviceId }; const settings = new Settings(); settings.addKnownDevice(new KnownDevice(deviceId, 'Foo', 'test', 'test', {}, true)); const settingsManager = mock(); settingsManager.getSettings.mockReturnValue(settings); - const manager = new DeviceManager(mock(), new Map(), settingsManager, mockedLogger); + const manager = new DeviceManager(mockedEventEmitter, new Map(), settingsManager, mockedLogger); const device = new TestDevice(deviceId, 'Foo', new Date(), false, new EventEmitter()); - const added = manager.addDevice({ type: 'test', detectionId: deviceId }, device); + const result = await connectDevice(manager, deviceInfo, device); - expect(added).toBe(true); + expect(result.successful).toBe(true); expect(manager.getConnectedDevices()).toHaveLength(1); }); }); @@ -431,6 +514,7 @@ describe('deviceManager', () => { it('closes connected devices whose known device has been disabled', async () => { const deviceId = DeviceId.create('device-to-disable'); + const deviceInfo: DeviceDetectionInfo = { type: 'test', detectionId: deviceId }; const enabledSettings = new Settings(); enabledSettings.addKnownDevice(new KnownDevice(deviceId, 'Foo', 'test', 'test', {}, true)); @@ -438,10 +522,12 @@ describe('deviceManager', () => { settingsManager.getSettings.mockReturnValue(enabledSettings); const connectedDevices = new Map(); - const manager = new DeviceManager(mock(), connectedDevices, settingsManager, mockedLogger); + const mockedEventEmitter = mock(); + mockedEventEmitter.emit.mockReturnValue(true); + const manager = new DeviceManager(mockedEventEmitter, connectedDevices, settingsManager, mockedLogger); const device = new TestDevice(deviceId, 'Foo', new Date(), false, new EventEmitter()); - manager.addDevice({ type: 'test', detectionId: deviceId }, device); + await connectDevice(manager, deviceInfo, device); expect(manager.getConnectedDevices()).toHaveLength(1); const disabledSettings = new Settings(); @@ -455,6 +541,7 @@ describe('deviceManager', () => { it('leaves devices belonging to still-enabled known devices connected', async () => { const deviceId = DeviceId.create('device-still-enabled'); + const deviceInfo: DeviceDetectionInfo = { type: 'test', detectionId: deviceId }; const settings = new Settings(); settings.addKnownDevice(new KnownDevice(deviceId, 'Foo', 'test', 'test', {}, true)); @@ -462,44 +549,19 @@ describe('deviceManager', () => { settingsManager.getSettings.mockReturnValue(settings); const connectedDevices = new Map(); - const manager = new DeviceManager(mock(), connectedDevices, settingsManager, mockedLogger); - - const device = new TestDevice(deviceId, 'Foo', new Date(), false, new EventEmitter()); - manager.addDevice({ type: 'test', detectionId: deviceId }, device); - - await manager.onSettingsChanged(); - - expect(manager.getConnectedDevices()).toHaveLength(1); - }); - - it('re-announces a device rejected by announceDetectedDevice once its known device gets re-enabled', async () => { - const deviceId = DeviceId.create('device-pending-1'); - const deviceInfo: DeviceDetectionInfo = { type: 'test', detectionId: deviceId }; - - const disabledSettings = new Settings(); - disabledSettings.addKnownDevice(new KnownDevice(deviceId, 'Foo', 'test', 'test', {}, false)); - - const settingsManager = mock(); - settingsManager.getSettings.mockReturnValue(disabledSettings); - const mockedEventEmitter = mock(); mockedEventEmitter.emit.mockReturnValue(true); + const manager = new DeviceManager(mockedEventEmitter, connectedDevices, settingsManager, mockedLogger); - const manager = new DeviceManager(mockedEventEmitter, new Map(), settingsManager, mockedLogger); - - manager.announceDetectedDevice(deviceInfo); - expect(mockedEventEmitter.emit).not.toHaveBeenCalled(); - - const enabledSettings = new Settings(); - enabledSettings.addKnownDevice(new KnownDevice(deviceId, 'Foo', 'test', 'test', {}, true)); - settingsManager.getSettings.mockReturnValue(enabledSettings); + const device = new TestDevice(deviceId, 'Foo', new Date(), false, new EventEmitter()); + await connectDevice(manager, deviceInfo, device); await manager.onSettingsChanged(); - expect(mockedEventEmitter.emit).toHaveBeenCalledWith(DeviceManagerEvent.deviceDetected, deviceInfo); + expect(manager.getConnectedDevices()).toHaveLength(1); }); - it('re-announces a device rejected by addDevice() only once its canonical known device gets re-enabled', async () => { + it('re-announces a device rejected by the offer only once its canonical known device gets re-enabled', async () => { // The device is detected under a preliminary id, but its final/canonical id (only // known after connecting, e.g. a serial number read during a handshake) is different. const detectionId = DeviceId.create('device-pending-2-detected'); @@ -521,8 +583,12 @@ describe('deviceManager', () => { // Simulate a provider that connected a device via the detected-device pipeline whose // final id turns out to belong to a disabled device. const device = new TestDevice(canonicalId, 'Foo', new Date(), false, new EventEmitter()); - const added = manager.addDevice(deviceInfo, device); - expect(added).toBe(false); + const result = await connectDevice(manager, deviceInfo, device); + expect(result.successful).toBe(false); + + // Drop the deviceDetected emit from announcing above - only the re-announce below is + // under test here, same as the original addDevice()-based version of this test. + mockClear(mockedEventEmitter); // An unrelated settings change while the canonical device is still disabled must NOT // retry it (it would if the retry were gated by the still-unknown detection id). @@ -552,7 +618,7 @@ describe('deviceManager', () => { const manager = new DeviceManager(mockedEventEmitter, new Map(), settingsManager, mockedLogger); const device = new TestDevice(deviceId, 'Foo', new Date(), false, new EventEmitter()); - manager.addDevice(deviceInfo, device); + await connectDevice(manager, deviceInfo, device); mockClear(mockedEventEmitter); @@ -561,4 +627,117 @@ describe('deviceManager', () => { expect(mockedEventEmitter.emit).not.toHaveBeenCalled(); }); }); + + describe('reset', () => { + let mockedLogger: ReturnType>; + let mockedEventEmitter: ReturnType>; + + beforeEach(() => { + mockedLogger = mock(); + mockedLogger.child.mockReturnValue(mockedLogger); + mockedEventEmitter = mock(); + mockedEventEmitter.emit.mockReturnValue(true); + }); + + it('closes every connected device', async () => { + const deviceId = DeviceId.create('device-reset-1'); + const deviceInfo: DeviceDetectionInfo = { type: 'test', detectionId: deviceId }; + const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedSettingsManager, mockedLogger); + + const device = new TestDevice(deviceId, 'Foo', new Date(), false, new EventEmitter()); + const closeSpy = vi.spyOn(device, 'close'); + await connectDevice(manager, deviceInfo, device); + + await manager.reset(); + + expect(closeSpy).toHaveBeenCalledTimes(1); + expect(manager.getConnectedDevices()).toHaveLength(0); + }); + + it('rejects every pending offer in the queue with a DeviceOfferRejectedError', async () => { + const deviceId = DeviceId.create('device-reset-2'); + const deviceInfo: DeviceDetectionInfo = { type: 'test', detectionId: deviceId }; + const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedSettingsManager, mockedLogger); + + manager.announceDetectedDevice(deviceInfo); + + // First offer never settles on its own, so both offers are still queued when reset() runs + const firstPendingPromise = manager.offerDevice(deviceInfo, () => new Promise(() => {})); + const secondPendingPromise = manager.offerDevice(deviceInfo, () => Promise.reject(new Error('should never run'))); + + await manager.reset(); + + const [firstResult, secondResult] = await Promise.all([firstPendingPromise, secondPendingPromise]); + + expect(firstResult.successful).toBe(false); + expect(!firstResult.successful && firstResult.reason).toBeInstanceOf(DeviceOfferRejectedError); + expect(secondResult.successful).toBe(false); + expect(!secondResult.successful && secondResult.reason).toBeInstanceOf(DeviceOfferRejectedError); + }); + + it('allows the same detection id to be announced again afterwards', async () => { + const deviceId = DeviceId.create('device-reset-3'); + const deviceInfo: DeviceDetectionInfo = { type: 'test', detectionId: deviceId }; + const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedSettingsManager, mockedLogger); + + manager.announceDetectedDevice(deviceInfo); + await manager.reset(); + + mockClear(mockedEventEmitter); + mockedEventEmitter.emit.mockReturnValue(true); + + manager.announceDetectedDevice(deviceInfo); + + expect(mockedEventEmitter.emit).toHaveBeenCalledWith(DeviceManagerEvent.deviceDetected, deviceInfo); + }); + + it('clears pending retries so a re-enable afterwards does not resurrect them', async () => { + const deviceId = DeviceId.create('device-reset-4'); + const deviceInfo: DeviceDetectionInfo = { type: 'test', detectionId: deviceId }; + + const settings = new Settings(); + settings.addKnownDevice(new KnownDevice(deviceId, 'Foo', 'test', 'test', {}, false)); + + const settingsManager = mock(); + settingsManager.getSettings.mockReturnValue(settings); + + const manager = new DeviceManager(mockedEventEmitter, new Map(), settingsManager, mockedLogger); + + manager.announceDetectedDevice(deviceInfo); + const device = new TestDevice(deviceId, 'Foo', new Date(), false, new EventEmitter()); + await manager.offerDevice(deviceInfo, () => Promise.resolve(device)); // rejected: disabled, parked as pending retry + + await manager.reset(); + + settings.addKnownDevice(new KnownDevice(deviceId, 'Foo', 'test', 'test', {}, true)); + mockClear(mockedEventEmitter); + + await manager.onSettingsChanged(); + + expect(mockedEventEmitter.emit).not.toHaveBeenCalledWith(DeviceManagerEvent.deviceDetected, deviceInfo); + }); + + it('still closes remaining devices and throws when closing one of them fails', async () => { + const deviceIdA = DeviceId.create('device-reset-5a'); + const deviceIdB = DeviceId.create('device-reset-5b'); + const deviceInfoA: DeviceDetectionInfo = { type: 'test', detectionId: deviceIdA }; + const deviceInfoB: DeviceDetectionInfo = { type: 'test', detectionId: deviceIdB }; + + const manager = new DeviceManager(mockedEventEmitter, new Map(), mockedSettingsManager, mockedLogger); + + const deviceA = new TestDevice(deviceIdA, 'Foo', new Date(), false, new EventEmitter()); + const closeError = new Error('failed to close'); + vi.spyOn(deviceA, 'close').mockRejectedValue(closeError); + + const deviceB = new TestDevice(deviceIdB, 'Bar', new Date(), false, new EventEmitter()); + const closeSpyB = vi.spyOn(deviceB, 'close'); + + await connectDevice(manager, deviceInfoA, deviceA); + await connectDevice(manager, deviceInfoB, deviceB); + + await expect(manager.reset()).rejects.toBe(closeError); + + expect(closeSpyB).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/tests/unit/device/deviceOfferRejectedError.spec.ts b/tests/unit/device/deviceOfferRejectedError.spec.ts new file mode 100644 index 00000000..e3cbdf0a --- /dev/null +++ b/tests/unit/device/deviceOfferRejectedError.spec.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; +import DeviceOfferRejectedError from '../../../src/device/deviceOfferRejectedError.js'; + +describe('DeviceOfferRejectedError', () => { + it('is an instance of Error', () => { + const error = new DeviceOfferRejectedError('some reason'); + + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(DeviceOfferRejectedError); + }); + + it('carries the given message', () => { + const error = new DeviceOfferRejectedError('Device with id \'foo\' has disappeared'); + + expect(error.message).toBe('Device with id \'foo\' has disappeared'); + }); + + it('is distinguishable from a plain Error via instanceof, as relied upon by DeviceProvider', () => { + const rejection: unknown = new DeviceOfferRejectedError('claimed by another provider'); + const genuineFailure: unknown = new Error('connect failed'); + + expect(rejection instanceof DeviceOfferRejectedError).toBe(true); + expect(genuineFailure instanceof DeviceOfferRejectedError).toBe(false); + }); +}); \ No newline at end of file diff --git a/tests/unit/device/protocol/airotic/airoticDeviceProvider.spec.ts b/tests/unit/device/protocol/airotic/airoticDeviceProvider.spec.ts new file mode 100644 index 00000000..71fe2c2c --- /dev/null +++ b/tests/unit/device/protocol/airotic/airoticDeviceProvider.spec.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import AiroticDeviceProvider from '../../../../../src/device/protocol/airotic/airoticDeviceProvider.js'; +import AiroticDeviceFactory from '../../../../../src/device/protocol/airotic/airoticDeviceFactory.js'; +import DeviceManager from '../../../../../src/device/deviceManager.js'; +import BleObserver from '../../../../../src/device/transport/bleObserver.js'; +import Logger from '../../../../../src/logging/Logger.js'; + +describe('AiroticDeviceProvider', () => { + it('constructs with the reduced constructor signature and names its child logger after the class', () => { + const deviceManager = mock(); + const bleObserver = mock(); + const deviceFactory = mock(); + const logger = mock(); + logger.child.mockReturnValue(logger); + + const provider = new AiroticDeviceProvider(deviceManager, bleObserver, deviceFactory, logger); + + expect(provider).toBeInstanceOf(AiroticDeviceProvider); + expect(logger.child).toHaveBeenCalledWith({ name: AiroticDeviceProvider.name }); + }); + + it('delegates doStart()/doStop() to the injected BleObserver', async () => { + const deviceManager = mock(); + const bleObserver = mock(); + const deviceFactory = mock(); + const logger = mock(); + logger.child.mockReturnValue(logger); + + const provider = new AiroticDeviceProvider(deviceManager, bleObserver, deviceFactory, logger); + + await provider.start(); + expect(bleObserver.start).toHaveBeenCalledTimes(1); + + await provider.stop(); + expect(bleObserver.stop).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file diff --git a/tests/unit/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.spec.ts b/tests/unit/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.spec.ts new file mode 100644 index 00000000..1d39c1d6 --- /dev/null +++ b/tests/unit/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.spec.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import ButtplugIoWebsocketDeviceProvider from '../../../../../src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.js'; +import ButtplugIoDeviceFactory from '../../../../../src/device/protocol/buttplugIo/buttplugIoDeviceFactory.js'; +import DeviceManager from '../../../../../src/device/deviceManager.js'; +import Logger from '../../../../../src/logging/Logger.js'; + +describe('ButtplugIoWebsocketDeviceProvider', () => { + it('constructs with the reduced constructor signature and names its child logger after the class', () => { + const deviceManager = mock(); + const deviceFactory = mock(); + const logger = mock(); + logger.child.mockReturnValue(logger); + + const provider = new ButtplugIoWebsocketDeviceProvider( + deviceManager, + deviceFactory, + 'localhost:12345', + true, + false, + logger + ); + + expect(provider).toBeInstanceOf(ButtplugIoWebsocketDeviceProvider); + expect(logger.child).toHaveBeenCalledWith({ name: ButtplugIoWebsocketDeviceProvider.name }); + }); +}); \ No newline at end of file diff --git a/tests/unit/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProviderFactory.spec.ts b/tests/unit/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProviderFactory.spec.ts new file mode 100644 index 00000000..3c1036aa --- /dev/null +++ b/tests/unit/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProviderFactory.spec.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import ButtplugIoWebsocketDeviceProviderFactory from '../../../../../src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProviderFactory.js'; +import ButtplugIoWebsocketDeviceProvider from '../../../../../src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.js'; +import ButtplugIoDeviceFactory from '../../../../../src/device/protocol/buttplugIo/buttplugIoDeviceFactory.js'; +import DeviceManager from '../../../../../src/device/deviceManager.js'; +import Logger from '../../../../../src/logging/Logger.js'; + +describe('ButtplugIoWebsocketDeviceProviderFactory', () => { + it('creates a provider wired with the given config, without requiring an eventEmitter', () => { + const deviceManager = mock(); + const deviceFactory = mock(); + const logger = mock(); + logger.child.mockReturnValue(logger); + + const factory = new ButtplugIoWebsocketDeviceProviderFactory(deviceManager, deviceFactory, logger); + + const provider = factory.create({ address: '127.0.0.1:12345', autoScan: true, useDeviceNameAsId: false }); + + expect(provider).toBeInstanceOf(ButtplugIoWebsocketDeviceProvider); + expect(logger.child).toHaveBeenCalledWith({ name: ButtplugIoWebsocketDeviceProvider.name }); + }); + + it('creates a fresh provider instance on every call', () => { + const deviceManager = mock(); + const deviceFactory = mock(); + const logger = mock(); + logger.child.mockReturnValue(logger); + + const factory = new ButtplugIoWebsocketDeviceProviderFactory(deviceManager, deviceFactory, logger); + + const providerA = factory.create({ address: 'localhost:1', autoScan: false, useDeviceNameAsId: false }); + const providerB = factory.create({ address: 'localhost:2', autoScan: false, useDeviceNameAsId: false }); + + expect(providerA).not.toBe(providerB); + }); +}); \ No newline at end of file diff --git a/tests/unit/device/protocol/estim2b/estim2bSerialDeviceProvider.spec.ts b/tests/unit/device/protocol/estim2b/estim2bSerialDeviceProvider.spec.ts new file mode 100644 index 00000000..361c2023 --- /dev/null +++ b/tests/unit/device/protocol/estim2b/estim2bSerialDeviceProvider.spec.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import EStim2bSerialDeviceProvider from '../../../../../src/device/protocol/estim2b/estim2bSerialDeviceProvider.js'; +import EStim2bDeviceFactory from '../../../../../src/device/protocol/estim2b/estim2bDeviceFactory.js'; +import SerialDeviceTransportFactory from '../../../../../src/device/transport/serialDeviceTransportFactory.js'; +import SerialPortFactory from '../../../../../src/factory/serialPortFactory.js'; +import SerialPortObserver from '../../../../../src/device/transport/serialPortObserver.js'; +import DeviceManager from '../../../../../src/device/deviceManager.js'; +import Logger from '../../../../../src/logging/Logger.js'; + +describe('EStim2bSerialDeviceProvider', () => { + const create = ( + deviceManager = mock(), + serialPortFactory = mock(), + serialPortObserver = mock(), + transportFactory = mock(), + deviceFactory = mock(), + logger = (() => { const l = mock(); l.child.mockReturnValue(l); return l; })() + ) => ({ + provider: new EStim2bSerialDeviceProvider( + deviceManager, + serialPortFactory, + serialPortObserver, + transportFactory, + deviceFactory, + logger + ), + serialPortObserver, + logger, + }); + + it('constructs with the reduced constructor signature and names its child logger after the class', () => { + const { provider, logger } = create(); + + expect(provider).toBeInstanceOf(EStim2bSerialDeviceProvider); + expect(logger.child).toHaveBeenCalledWith({ name: EStim2bSerialDeviceProvider.name }); + }); + + it('delegates doStart()/doStop() to the injected SerialPortObserver', async () => { + const { provider, serialPortObserver } = create(); + + await provider.start(); + expect(serialPortObserver.start).toHaveBeenCalledTimes(1); + + await provider.stop(); + expect(serialPortObserver.stop).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file diff --git a/tests/unit/device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.spec.ts b/tests/unit/device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.spec.ts new file mode 100644 index 00000000..7bd44e40 --- /dev/null +++ b/tests/unit/device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.spec.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import SlvCtrlPlusSerialDeviceProvider from '../../../../../src/device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.js'; +import SlvCtrlPlusDeviceFactory from '../../../../../src/device/protocol/slvCtrlPlus/slvCtrlPlusDeviceFactory.js'; +import SerialDeviceTransportFactory from '../../../../../src/device/transport/serialDeviceTransportFactory.js'; +import SerialPortFactory from '../../../../../src/factory/serialPortFactory.js'; +import SerialPortObserver from '../../../../../src/device/transport/serialPortObserver.js'; +import DeviceManager from '../../../../../src/device/deviceManager.js'; +import Logger from '../../../../../src/logging/Logger.js'; + +describe('SlvCtrlPlusSerialDeviceProvider', () => { + const create = ( + deviceManager = mock(), + serialPortFactory = mock(), + serialPortObserver = mock(), + deviceFactory = mock(), + deviceTransportFactory = mock(), + logger = (() => { const l = mock(); l.child.mockReturnValue(l); return l; })() + ) => ({ + provider: new SlvCtrlPlusSerialDeviceProvider( + deviceManager, + serialPortFactory, + serialPortObserver, + deviceFactory, + deviceTransportFactory, + logger + ), + serialPortObserver, + logger, + }); + + it('constructs with the reduced constructor signature and names its child logger after the class', () => { + const { provider, logger } = create(); + + expect(provider).toBeInstanceOf(SlvCtrlPlusSerialDeviceProvider); + expect(logger.child).toHaveBeenCalledWith({ name: SlvCtrlPlusSerialDeviceProvider.name }); + }); + + it('delegates doStart()/doStop() to the injected SerialPortObserver', async () => { + const { provider, serialPortObserver } = create(); + + await provider.start(); + expect(serialPortObserver.start).toHaveBeenCalledTimes(1); + + await provider.stop(); + expect(serialPortObserver.stop).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file diff --git a/tests/unit/device/protocol/virtual/virtualDeviceProvider.spec.ts b/tests/unit/device/protocol/virtual/virtualDeviceProvider.spec.ts new file mode 100644 index 00000000..fe4008cc --- /dev/null +++ b/tests/unit/device/protocol/virtual/virtualDeviceProvider.spec.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import VirtualDeviceProvider from '../../../../../src/device/protocol/virtual/virtualDeviceProvider.js'; +import VirtualDeviceFactory from '../../../../../src/device/protocol/virtual/virtualDeviceFactory.js'; +import SettingsManager from '../../../../../src/settings/settingsManager.js'; +import SettingsEventType from '../../../../../src/settings/settingsEventType.js'; +import DeviceManager from '../../../../../src/device/deviceManager.js'; +import Logger from '../../../../../src/logging/Logger.js'; + +describe('VirtualDeviceProvider', () => { + const create = ( + deviceManager = mock(), + deviceFactory = mock(), + settingsManager = mock(), + logger = (() => { const l = mock(); l.child.mockReturnValue(l); return l; })() + ) => ({ provider: new VirtualDeviceProvider(deviceManager, deviceFactory, settingsManager, logger), settingsManager, logger }); + + it('constructs with the reduced constructor signature and names its child logger after the class', () => { + const { provider, logger } = create(); + + expect(provider).toBeInstanceOf(VirtualDeviceProvider); + expect(logger.child).toHaveBeenCalledWith({ name: VirtualDeviceProvider.name }); + }); + + it('subscribes to settings changes on start() and unsubscribes on stop()', async () => { + const settingsManager = mock(); + settingsManager.getSettings.mockReturnValue(undefined); + const { provider } = create(undefined, undefined, settingsManager); + + await provider.start(); + expect(settingsManager.on).toHaveBeenCalledWith(SettingsEventType.changed, expect.any(Function)); + + await provider.stop(); + expect(settingsManager.off).toHaveBeenCalledWith(SettingsEventType.changed, expect.any(Function)); + }); + + it('scans for configured virtual devices via the injected SettingsManager on start()', async () => { + const settingsManager = mock(); + settingsManager.getSettings.mockReturnValue(undefined); + const { provider } = create(undefined, undefined, settingsManager); + + await provider.start(); + + expect(settingsManager.getSettings).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/tests/unit/device/protocol/virtual/virtualDeviceProviderFactory.spec.ts b/tests/unit/device/protocol/virtual/virtualDeviceProviderFactory.spec.ts new file mode 100644 index 00000000..9829b35f --- /dev/null +++ b/tests/unit/device/protocol/virtual/virtualDeviceProviderFactory.spec.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import VirtualDeviceProviderFactory from '../../../../../src/device/protocol/virtual/virtualDeviceProviderFactory.js'; +import VirtualDeviceProvider from '../../../../../src/device/protocol/virtual/virtualDeviceProvider.js'; +import VirtualDeviceFactory from '../../../../../src/device/protocol/virtual/virtualDeviceFactory.js'; +import DeviceManager from '../../../../../src/device/deviceManager.js'; +import SettingsManager from '../../../../../src/settings/settingsManager.js'; +import Logger from '../../../../../src/logging/Logger.js'; + +describe('VirtualDeviceProviderFactory', () => { + it('creates a provider wired with its dependencies, without requiring an eventEmitterFactory', () => { + const deviceManager = mock(); + const deviceFactory = mock(); + const settingsManager = mock(); + const logger = mock(); + logger.child.mockReturnValue(logger); + + const factory = new VirtualDeviceProviderFactory(deviceManager, deviceFactory, settingsManager, logger); + + const provider = factory.create(); + + expect(provider).toBeInstanceOf(VirtualDeviceProvider); + expect(logger.child).toHaveBeenCalledWith({ name: VirtualDeviceProvider.name }); + }); + + it('creates a fresh provider instance on every call', () => { + const deviceManager = mock(); + const deviceFactory = mock(); + const settingsManager = mock(); + const logger = mock(); + logger.child.mockReturnValue(logger); + + const factory = new VirtualDeviceProviderFactory(deviceManager, deviceFactory, settingsManager, logger); + + expect(factory.create()).not.toBe(factory.create()); + }); +}); \ No newline at end of file diff --git a/tests/unit/device/protocol/zc95/zc95SerialDeviceProvider.spec.ts b/tests/unit/device/protocol/zc95/zc95SerialDeviceProvider.spec.ts new file mode 100644 index 00000000..55444ba3 --- /dev/null +++ b/tests/unit/device/protocol/zc95/zc95SerialDeviceProvider.spec.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import Zc95SerialDeviceProvider from '../../../../../src/device/protocol/zc95/zc95SerialDeviceProvider.js'; +import Zc95DeviceFactory from '../../../../../src/device/protocol/zc95/zc95DeviceFactory.js'; +import SerialDeviceTransportFactory from '../../../../../src/device/transport/serialDeviceTransportFactory.js'; +import SerialPortFactory from '../../../../../src/factory/serialPortFactory.js'; +import SerialPortObserver from '../../../../../src/device/transport/serialPortObserver.js'; +import DeviceManager from '../../../../../src/device/deviceManager.js'; +import Logger from '../../../../../src/logging/Logger.js'; + +describe('Zc95SerialDeviceProvider', () => { + const create = ( + deviceManager = mock(), + serialPortFactory = mock(), + serialPortObserver = mock(), + transportFactory = mock(), + deviceFactory = mock(), + logger = (() => { const l = mock(); l.child.mockReturnValue(l); return l; })() + ) => ({ + provider: new Zc95SerialDeviceProvider( + deviceManager, + serialPortFactory, + serialPortObserver, + transportFactory, + deviceFactory, + logger + ), + serialPortObserver, + logger, + }); + + it('constructs with the reduced constructor signature and names its child logger after the class', () => { + const { provider, logger } = create(); + + expect(provider).toBeInstanceOf(Zc95SerialDeviceProvider); + expect(logger.child).toHaveBeenCalledWith({ name: Zc95SerialDeviceProvider.name }); + }); + + it('delegates doStart()/doStop() to the injected SerialPortObserver', async () => { + const { provider, serialPortObserver } = create(); + + await provider.start(); + expect(serialPortObserver.start).toHaveBeenCalledTimes(1); + + await provider.stop(); + expect(serialPortObserver.stop).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file diff --git a/tests/unit/device/provider/bleDeviceProvider.spec.ts b/tests/unit/device/provider/bleDeviceProvider.spec.ts new file mode 100644 index 00000000..e7194b63 --- /dev/null +++ b/tests/unit/device/provider/bleDeviceProvider.spec.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import BleDeviceProvider from '../../../../src/device/provider/bleDeviceProvider.js'; +import DeviceManager from '../../../../src/device/deviceManager.js'; +import BleObserver, { BleDeviceDetectionInfo } from '../../../../src/device/transport/bleObserver.js'; +import { AnyDevice } from '../../../../src/device/device.js'; +import Logger from '../../../../src/logging/Logger.js'; + +// BleDeviceProvider is abstract; this concrete subclass exists purely to exercise the base +// class's constructor and doStart()/doStop() delegation. +class TestBleDeviceProvider extends BleDeviceProvider +{ + public constructor(deviceManager: DeviceManager, bleObserver: BleObserver, logger: Logger) { + super(deviceManager, bleObserver, logger); + } + + protected connectBleDevice(_deviceDetectionInfo: BleDeviceDetectionInfo): Promise { + return Promise.resolve(undefined); + } +} + +describe('BleDeviceProvider', () => { + it('constructs with the reduced constructor signature', () => { + const deviceManager = mock(); + const bleObserver = mock(); + const logger = mock(); + + const provider = new TestBleDeviceProvider(deviceManager, bleObserver, logger); + + expect(provider).toBeInstanceOf(BleDeviceProvider); + }); + + it('delegates doStart()/doStop() to the injected BleObserver', async () => { + const deviceManager = mock(); + const bleObserver = mock(); + const logger = mock(); + + const provider = new TestBleDeviceProvider(deviceManager, bleObserver, logger); + + await provider.start(); + expect(bleObserver.start).toHaveBeenCalledTimes(1); + + await provider.stop(); + expect(bleObserver.stop).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file diff --git a/tests/unit/device/provider/serialDeviceProvider.spec.ts b/tests/unit/device/provider/serialDeviceProvider.spec.ts new file mode 100644 index 00000000..1f0b07f6 --- /dev/null +++ b/tests/unit/device/provider/serialDeviceProvider.spec.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { SerialPortStream } from '@serialport/stream'; +import { BindingInterface } from '@serialport/bindings-interface'; +import SerialDeviceProvider, { SerialDeviceProviderPortOpenOptions } from '../../../../src/device/provider/serialDeviceProvider.js'; +import SerialPortFactory from '../../../../src/factory/serialPortFactory.js'; +import SerialPortObserver, { SerialDeviceDetectionInfo } from '../../../../src/device/transport/serialPortObserver.js'; +import DeviceManager from '../../../../src/device/deviceManager.js'; +import { AnyDevice } from '../../../../src/device/device.js'; +import Logger from '../../../../src/logging/Logger.js'; + +// SerialDeviceProvider is abstract; this concrete subclass exists purely to exercise the base +// class's constructor and doStart()/doStop() delegation. +class TestSerialDeviceProvider extends SerialDeviceProvider +{ + public constructor( + deviceManager: DeviceManager, + serialPortFactory: SerialPortFactory, + serialPortObserver: SerialPortObserver, + logger: Logger + ) { + super(deviceManager, serialPortFactory, serialPortObserver, logger); + } + + protected connectSerialDevice( + _deviceDetectionInfo: SerialDeviceDetectionInfo, + _port: SerialPortStream + ): Promise { + return Promise.resolve(undefined); + } + + protected getSerialDeviceProviderPortOpenOptions(): SerialDeviceProviderPortOpenOptions { + return { baudRate: 9600 }; + } +} + +describe('SerialDeviceProvider', () => { + it('constructs with the reduced constructor signature', () => { + const deviceManager = mock(); + const serialPortFactory = mock(); + const serialPortObserver = mock(); + const logger = mock(); + + const provider = new TestSerialDeviceProvider(deviceManager, serialPortFactory, serialPortObserver, logger); + + expect(provider).toBeInstanceOf(SerialDeviceProvider); + }); + + it('delegates doStart()/doStop() to the injected SerialPortObserver', async () => { + const deviceManager = mock(); + const serialPortFactory = mock(); + const serialPortObserver = mock(); + const logger = mock(); + + const provider = new TestSerialDeviceProvider(deviceManager, serialPortFactory, serialPortObserver, logger); + + await provider.start(); + expect(serialPortObserver.start).toHaveBeenCalledTimes(1); + + await provider.stop(); + expect(serialPortObserver.stop).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file