UNPKG

matterbridge-roborock-vacuum-plugin

Version:
445 lines 23.3 kB
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { CleanModeSetting } from '../../behaviors/roborock.vacuum/core/CleanModeSetting.js'; import { CleanSequenceType } from '../../behaviors/roborock.vacuum/enums/CleanSequenceType.js'; import { DeviceError } from '../../errors/index.js'; import { RequestMessage } from '../../roborockCommunication/models/index.js'; import { MessageRoutingService } from '../../services/messageRoutingService.js'; import { asPartial } from '../testUtils.js'; function createIntegrationLogger() { return { debug: vi.fn(), notice: vi.fn(), warn: vi.fn() }; } describe('MessageRoutingService (integration)', () => { let logger; beforeEach(() => { logger = createIntegrationLogger(); }); it('throws when getting an unregistered MessageDispatcher', () => { const service = new MessageRoutingService(logger); expect(() => service.getMessageDispatcher('missing')).toThrow(); }); it('registers and returns a MessageDispatcher', () => { const service = new MessageRoutingService(logger); const duid = 'dev1'; const mp = { some: 'processor' }; service.registerMessageDispatcher(duid, mp); expect(service.getMessageDispatcher(duid)).toBe(mp); }); it('throws when getCleanModeData returns no data', async () => { const duid = 'dev-nodata'; const mp = { getCleanModeData: vi.fn(async () => undefined) }; const service = new MessageRoutingService(logger); service.registerMessageDispatcher(duid, mp); await expect(service.getCleanModeData(duid)).rejects.toThrow(); }); }); describe('MessageRoutingService', () => { let messageService; let mockLogger; let mockIotApi; let mockDispatcher; function createMockLogger() { return asPartial({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), notice: vi.fn(), }); } function createMockIotApi() { return { getMapRoomDetail: vi.fn(), getCleaningSummaryForUpdatingDevice: vi.fn(), startScene: vi.fn(), }; } function createMockDispatcher() { return { getCleanModeData: vi.fn(), changeCleanMode: vi.fn(), getRooms: vi.fn(), startCleaning: vi.fn(), startRoomCleaning: vi.fn(), pauseCleaning: vi.fn(), resumeCleaning: vi.fn(), goHome: vi.fn(), findMyRobot: vi.fn(), getHomeMap: vi.fn(), getCustomMessage: vi.fn(), sendCustomMessage: vi.fn(), getSerialNumber: vi.fn(), stopCleaning: vi.fn(), }; } beforeEach(() => { mockLogger = createMockLogger(); mockIotApi = createMockIotApi(); mockDispatcher = createMockDispatcher(); messageService = new MessageRoutingService(mockLogger); }); afterEach(() => { vi.clearAllMocks(); }); describe('Initialization', () => { it('should initialize with logger only', () => { expect(messageService).toBeDefined(); expect(mockLogger.debug).not.toHaveBeenCalled(); }); it('should initialize with logger and iotApi', () => { const service = new MessageRoutingService(mockLogger, mockIotApi); expect(service).toBeDefined(); }); it('should set iotApi after initialization', () => { messageService.setIotApi(mockIotApi); expect(() => { messageService.setIotApi(mockIotApi); }).not.toThrow(); }); }); describe('Processor Registration and Retrieval', () => { const testDuid = 'test-device-123'; it('should register a message processor', () => { messageService.registerMessageDispatcher(testDuid, mockDispatcher); expect(() => messageService.getMessageDispatcher(testDuid)).not.toThrow(); }); it('should retrieve registered processor', () => { messageService.registerMessageDispatcher(testDuid, mockDispatcher); const processor = messageService.getMessageDispatcher(testDuid); expect(processor).toBe(mockDispatcher); }); it('should throw DeviceError when processor not found', () => { expect(() => messageService.getMessageDispatcher('unknown-device')).toThrow(DeviceError); expect(() => messageService.getMessageDispatcher('unknown-device')).toThrow('MessageDispatcher not initialized for device unknown-device'); }); it('should allow multiple processors to be registered', () => { const mockProcessor2 = createMockDispatcher(); messageService.registerMessageDispatcher('device-1', mockDispatcher); messageService.registerMessageDispatcher('device-2', mockProcessor2); expect(messageService.getMessageDispatcher('device-1')).toBe(mockDispatcher); expect(messageService.getMessageDispatcher('device-2')).toBe(mockProcessor2); }); }); describe('getSerialNumber', () => { const testDuid = 'test-device-sn'; beforeEach(() => { messageService.registerMessageDispatcher(testDuid, mockDispatcher); }); it('should return serial number from dispatcher', async () => { mockDispatcher.getSerialNumber.mockResolvedValue('SN-XYZ'); const result = await messageService.getSerialNumber(testDuid); expect(result).toBe('SN-XYZ'); expect(mockDispatcher.getSerialNumber).toHaveBeenCalledWith(testDuid); }); it('should fallback to duid when dispatcher returns undefined', async () => { mockDispatcher.getSerialNumber.mockResolvedValue(undefined); const result = await messageService.getSerialNumber(testDuid); expect(result).toBe(testDuid); }); it('should throw DeviceError when dispatcher not registered', async () => { await expect(messageService.getSerialNumber('unknown-device')).rejects.toThrow(DeviceError); }); }); describe('getCleanModeData', () => { const testDuid = 'test-device-456'; const mockCleanMode = new CleanModeSetting(100, 200, 0, 302, CleanSequenceType.Persist); beforeEach(() => { messageService.registerMessageDispatcher(testDuid, mockDispatcher); }); it('should retrieve clean mode data successfully', async () => { mockDispatcher.getCleanModeData.mockResolvedValue(mockCleanMode); const result = await messageService.getCleanModeData(testDuid); expect(result).toEqual(mockCleanMode); expect(mockDispatcher.getCleanModeData).toHaveBeenCalledWith(testDuid); expect(mockLogger.notice).toHaveBeenCalledWith('MessageRoutingService - getCleanModeData'); }); it('should throw DeviceError when processor not found', async () => { await expect(messageService.getCleanModeData('unknown-device')).rejects.toThrow(DeviceError); }); it('should propagate processor errors', async () => { mockDispatcher.getCleanModeData.mockRejectedValue(new Error('API failure')); await expect(messageService.getCleanModeData(testDuid)).rejects.toThrow('API failure'); }); }); describe('getRoomIdFromMap', () => { const testDuid = 'test-device-789'; const mockMapData = { vacuumRoom: 16, }; beforeEach(() => { messageService.registerMessageDispatcher(testDuid, mockDispatcher); }); it('should retrieve room ID from map successfully', async () => { mockDispatcher.getHomeMap.mockResolvedValue(mockMapData); const result = await messageService.getRoomIdFromMap(testDuid); expect(result).toEqual(16); expect(mockDispatcher.getHomeMap).toHaveBeenCalled(); }); it('should handle missing map data', async () => { mockDispatcher.getHomeMap.mockResolvedValue(undefined); const result = await messageService.getRoomIdFromMap(testDuid); expect(result).toBeUndefined(); }); it('should handle map data without vacuumRoom', async () => { mockDispatcher.getHomeMap.mockResolvedValue({}); const result = await messageService.getRoomIdFromMap(testDuid); expect(result).toBeUndefined(); }); it('should throw DeviceError when processor not found', async () => { await expect(messageService.getRoomIdFromMap('unknown-device')).rejects.toThrow(DeviceError); }); }); describe('changeCleanMode', () => { const testDuid = 'test-device-clean-mode'; beforeEach(() => { messageService.registerMessageDispatcher(testDuid, mockDispatcher); }); it('should change clean mode successfully', async () => { const settings = new CleanModeSetting(105, 203, 0, 302, CleanSequenceType.Persist); await messageService.changeCleanMode(testDuid, settings); expect(mockDispatcher.changeCleanMode).toHaveBeenCalledWith(testDuid, settings); expect(mockLogger.notice).toHaveBeenCalledWith('MessageRoutingService - changeCleanMode'); }); it('should handle zero values in clean mode', async () => { const settings = new CleanModeSetting(0, 0, 0, 0, CleanSequenceType.Persist); await messageService.changeCleanMode(testDuid, settings); expect(mockDispatcher.changeCleanMode).toHaveBeenCalledWith(testDuid, settings); }); it('should throw DeviceError when processor not found', async () => { const settings = new CleanModeSetting(105, 203, 0, 302, CleanSequenceType.Persist); await expect(messageService.changeCleanMode('unknown-device', settings)).rejects.toThrow(DeviceError); }); }); describe('startClean', () => { const testDuid = 'test-device-start-clean'; beforeEach(() => { messageService.registerMessageDispatcher(testDuid, mockDispatcher); }); it('should start global clean when no areas selected', async () => { await messageService.startClean(testDuid, { type: 'global' }); expect(mockDispatcher.startCleaning).toHaveBeenCalledWith(testDuid); expect(mockDispatcher.startRoomCleaning).not.toHaveBeenCalled(); expect(mockLogger.notice).toHaveBeenCalledWith(expect.stringContaining('Start global cleaning')); }); it('should start room-based clean with selected rooms', async () => { const selectedRooms = [16, 17]; await messageService.startClean(testDuid, { type: 'room', roomIds: selectedRooms }); expect(mockDispatcher.startRoomCleaning).toHaveBeenCalledWith(testDuid, selectedRooms, 1); expect(mockDispatcher.startCleaning).not.toHaveBeenCalled(); }); it('should start routine clean when routine is selected', async () => { mockIotApi.startScene = vi.fn().mockResolvedValue(undefined); messageService.setIotApi(mockIotApi); await messageService.startClean(testDuid, { type: 'routine', routineId: 1 }); expect(mockIotApi.startScene).toHaveBeenCalledWith(1); }); it('should fallback to room-based clean when room selected from routines', async () => { // When a routine area ID exists but also matches a room, it treats as room const selectedRooms = [999]; await messageService.startClean(testDuid, { type: 'room', roomIds: selectedRooms }); expect(mockDispatcher.startRoomCleaning).toHaveBeenCalledWith(testDuid, selectedRooms, 1); }); it('should handle multiple room selection', async () => { const selectedRooms = [16, 17, 18, 19]; const supportedRooms = [ { areaId: 16 }, { areaId: 17 }, { areaId: 18 }, { areaId: 19 }, { areaId: 20 }, ]; await messageService.startClean(testDuid, { type: 'room', roomIds: selectedRooms }); expect(mockDispatcher.startRoomCleaning).toHaveBeenCalledWith(testDuid, selectedRooms, 1); }); }); describe('pauseClean', () => { const testDuid = 'test-device-pause'; beforeEach(() => { messageService.registerMessageDispatcher(testDuid, mockDispatcher); }); it('should pause cleaning operation', async () => { await messageService.pauseClean(testDuid); expect(mockDispatcher.pauseCleaning).toHaveBeenCalledWith(testDuid); expect(mockLogger.debug).toHaveBeenCalledWith('MessageRoutingService - pauseClean'); }); it('should throw DeviceError when processor not found', async () => { await expect(messageService.pauseClean('unknown-device')).rejects.toThrow(DeviceError); }); }); describe('resumeClean', () => { const testDuid = 'test-device-resume'; beforeEach(() => { messageService.registerMessageDispatcher(testDuid, mockDispatcher); }); it('should resume cleaning operation', async () => { await messageService.resumeClean(testDuid); expect(mockDispatcher.resumeCleaning).toHaveBeenCalledWith(testDuid); expect(mockLogger.debug).toHaveBeenCalledWith('MessageRoutingService - resumeClean'); }); it('should throw DeviceError when processor not found', async () => { await expect(messageService.resumeClean('unknown-device')).rejects.toThrow(DeviceError); }); }); describe('stopAndGoHome', () => { const testDuid = 'test-device-stop'; beforeEach(() => { messageService.registerMessageDispatcher(testDuid, mockDispatcher); }); it('should stop cleaning and return to dock', async () => { await messageService.stopAndGoHome(testDuid); expect(mockDispatcher.goHome).toHaveBeenCalledWith(testDuid); expect(mockLogger.debug).toHaveBeenCalledWith('MessageRoutingService - stopAndGoHome'); }); it('should throw DeviceError when processor not found', async () => { await expect(messageService.stopAndGoHome('unknown-device')).rejects.toThrow(DeviceError); }); }); describe('playSoundToLocate', () => { const testDuid = 'test-device-locate'; beforeEach(() => { messageService.registerMessageDispatcher(testDuid, mockDispatcher); }); it('should play sound to locate vacuum', async () => { await messageService.playSoundToLocate(testDuid); expect(mockDispatcher.findMyRobot).toHaveBeenCalledWith(testDuid); expect(mockLogger.debug).toHaveBeenCalledWith('MessageRoutingService - findMe'); }); it('should throw DeviceError when processor not found', async () => { await expect(messageService.playSoundToLocate('unknown-device')).rejects.toThrow(DeviceError); }); }); describe('customGet', () => { const testDuid = 'test-device-custom-get'; const mockRequest = new RequestMessage({ method: 'get_status', params: [] }); const mockResponse = { status: 'cleaning' }; beforeEach(() => { messageService.registerMessageDispatcher(testDuid, mockDispatcher); }); it('should execute custom GET request with typed response', async () => { mockDispatcher.getCustomMessage.mockResolvedValue(mockResponse); const result = await messageService.customGet(testDuid, mockRequest); expect(result).toEqual(mockResponse); expect(mockDispatcher.getCustomMessage).toHaveBeenCalledWith(testDuid, mockRequest); expect(mockLogger.debug).toHaveBeenCalledWith('MessageRoutingService - customSend-message', 'get_status', [], false); }); it('should execute custom GET request with unknown response type', async () => { mockDispatcher.getCustomMessage.mockResolvedValue(mockResponse); const result = await messageService.customGet(testDuid, mockRequest); expect(result).toEqual(mockResponse); }); it('should handle secure request flag', async () => { const secureRequest = new RequestMessage({ method: 'get_status', params: [], secure: true }); mockDispatcher.getCustomMessage.mockResolvedValue(mockResponse); await messageService.customGet(testDuid, secureRequest); expect(mockLogger.debug).toHaveBeenCalledWith('MessageRoutingService - customSend-message', 'get_status', [], true); }); it('should throw DeviceError when processor not found', async () => { await expect(messageService.customGet('unknown-device', mockRequest)).rejects.toThrow(DeviceError); }); }); describe('customSend', () => { const testDuid = 'test-device-custom-send'; const mockRequest = new RequestMessage({ method: 'set_mop_mode', params: [302] }); beforeEach(() => { messageService.registerMessageDispatcher(testDuid, mockDispatcher); }); it('should send custom command successfully', async () => { await messageService.customSend(testDuid, mockRequest); expect(mockDispatcher.sendCustomMessage).toHaveBeenCalledWith(testDuid, mockRequest); }); it('should throw DeviceError when processor not found', async () => { await expect(messageService.customSend('unknown-device', mockRequest)).rejects.toThrow(DeviceError); }); it('should handle command without params', async () => { const simpleRequest = new RequestMessage({ method: 'app_start' }); await messageService.customSend(testDuid, simpleRequest); expect(mockDispatcher.sendCustomMessage).toHaveBeenCalledWith(testDuid, simpleRequest); }); }); describe('clearAll', () => { it('should clear all processors and MQTT devices', () => { const testDuid1 = 'device-1'; const testDuid2 = 'device-2'; messageService.registerMessageDispatcher(testDuid1, mockDispatcher); messageService.registerMessageDispatcher(testDuid2, mockDispatcher); messageService.clearAll(); expect(() => messageService.getMessageDispatcher(testDuid1)).toThrow(DeviceError); expect(() => messageService.getMessageDispatcher(testDuid2)).toThrow(DeviceError); expect(mockLogger.debug).toHaveBeenCalledWith('MessageRoutingService - All data cleared'); }); it('should be safe to call multiple times', () => { messageService.clearAll(); messageService.clearAll(); expect(() => { messageService.clearAll(); }).not.toThrow(); }); it('should clear empty service without errors', () => { const newService = new MessageRoutingService(mockLogger); expect(() => { newService.clearAll(); }).not.toThrow(); }); }); describe('Integration Scenarios', () => { const testDuid = 'integration-device'; beforeEach(() => { messageService.registerMessageDispatcher(testDuid, mockDispatcher); messageService.setIotApi(mockIotApi); }); it('should complete full cleaning workflow', async () => { const selectedRooms = [16, 17]; const supportedRooms = [ { areaId: 16 }, { areaId: 17 }, { areaId: 18 }, ]; // Start room clean await messageService.startClean(testDuid, { type: 'room', roomIds: [16, 17] }); expect(mockDispatcher.startRoomCleaning).toHaveBeenCalledWith(testDuid, selectedRooms, 1); // Pause await messageService.pauseClean(testDuid); expect(mockDispatcher.pauseCleaning).toHaveBeenCalledWith(testDuid); // Resume await messageService.resumeClean(testDuid); expect(mockDispatcher.resumeCleaning).toHaveBeenCalledWith(testDuid); // Stop and go home await messageService.stopAndGoHome(testDuid); expect(mockDispatcher.goHome).toHaveBeenCalledWith(testDuid); }); it('should handle clean mode adjustment workflow', async () => { const mockCleanMode = new CleanModeSetting(101, 202, 0, 302, CleanSequenceType.Persist); mockDispatcher.getCleanModeData.mockResolvedValue(mockCleanMode); // Get current clean mode const currentMode = await messageService.getCleanModeData(testDuid); expect(currentMode).toEqual(mockCleanMode); // Change clean mode const settings = new CleanModeSetting(105, 203, 0, 302, CleanSequenceType.Persist); await messageService.changeCleanMode(testDuid, settings); expect(mockDispatcher.changeCleanMode).toHaveBeenCalledWith(testDuid, settings); }); }); describe('Error Handling', () => { const testDuid = 'error-device'; beforeEach(() => { messageService.registerMessageDispatcher(testDuid, mockDispatcher); }); it('should propagate MessageDispatcher errors', async () => { const error = new Error('Communication timeout'); mockDispatcher.startCleaning.mockRejectedValue(error); await expect(messageService.startClean(testDuid, { type: 'global' })).rejects.toThrow('Communication timeout'); }); it('should throw DeviceError when routine requires iotApi but not initialized', async () => { const serviceWithoutApi = new MessageRoutingService(mockLogger); serviceWithoutApi.registerMessageDispatcher(testDuid, mockDispatcher); await expect(serviceWithoutApi.startClean(testDuid, { type: 'routine', routineId: 123 })).rejects.toThrow(DeviceError); await expect(serviceWithoutApi.startClean(testDuid, { type: 'routine', routineId: 123 })).rejects.toThrow('IoT API must be initialized to start scene'); }); it('should throw meaningful errors for missing processors', async () => { const unknownDuid = 'non-existent-device'; await expect(messageService.getCleanModeData(unknownDuid)).rejects.toThrow(`MessageDispatcher not initialized for device ${unknownDuid}`); await expect(messageService.startClean(unknownDuid, { type: 'global' })).rejects.toThrow(`MessageDispatcher not initialized for device ${unknownDuid}`); await expect(messageService.pauseClean(unknownDuid)).rejects.toThrow(`MessageDispatcher not initialized for device ${unknownDuid}`); }); }); }); //# sourceMappingURL=messageRoutingService.test.js.map