UNPKG

matterbridge-roborock-vacuum-plugin

Version:
1,003 lines 85.7 kB
import { RvcOperationalState, ServiceArea } from 'matterbridge/matter/clusters'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { INVALID_SEGMENT_ID } from '../../../constants/index.js'; import { RoomIndexMap } from '../../../core/application/models/index.js'; import { OperationStatusCode, ProtocolVersion } from '../../../roborockCommunication/enums/index.js'; import { buildProgressUpdate, getNextPendingArea, handleActiveMapChanged, handleServiceAreaUpdate, markAreaSkipped, } from '../../../runtimes/handlers/serviceAreaHandler.js'; import { asPartial, createMockLogger } from '../../helpers/testUtils.js'; function createMockConfigManager(estimatedEndTimeEnabled = false) { return asPartial({ isEstimatedEndTimeEnabled: estimatedEndTimeEnabled, }); } function createMockPlatform(areas = [], indexMap, estimatedEndTimeEnabled = false) { return asPartial({ log: createMockLogger(), configManager: createMockConfigManager(estimatedEndTimeEnabled), roborockService: asPartial({ getSupportedAreas: vi.fn().mockReturnValue(areas), getSupportedAreasIndexMap: vi.fn().mockReturnValue(indexMap), getSelectedAreas: vi.fn().mockReturnValue([]), getProgress: vi.fn().mockReturnValue([]), setProgress: vi.fn(), getLastActivelyCleaningState: vi.fn().mockReturnValue(false), setLastActivelyCleaningState: vi.fn(), ensureAreasForMap: vi.fn().mockResolvedValue(false), }), }); } function createRoomIndexMapForSegment(segmentId, areaId, mapId = 100) { const roomMapData = new Map([[areaId, { roomId: segmentId, mapId, roomName: `Room ${areaId}` }]]); const roomInfo = new Map([ [`${segmentId}-${mapId}`, { areaId, mapId, roomName: `Room ${areaId}` }], ]); return new RoomIndexMap(roomMapData, roomInfo); } function createMockRobot(duid, activeMapId = 0, matterSupportedAreas, operationalState) { return asPartial({ device: asPartial({ duid }), homeInFo: asPartial({ activeMapId }), updateAttribute: vi.fn().mockResolvedValue(undefined), getAttribute: vi.fn().mockImplementation((_clusterId, attrName) => { if (attrName === 'supportedAreas') { return matterSupportedAreas; } if (attrName === 'operationalState') { return operationalState; } return undefined; }), }); } describe('handleActiveMapChanged', () => { let robot; beforeEach(() => { vi.clearAllMocks(); robot = createMockRobot('test-duid', 100); }); it('does nothing when no areas found for mapId', async () => { const platform = createMockPlatform([]); // no areas await handleActiveMapChanged(robot, 100, platform); expect(robot.updateAttribute).not.toHaveBeenCalled(); }); it('sets selectedAreas to all area IDs on the active map', async () => { const areas = [ { areaId: 1, mapId: 100 }, { areaId: 2, mapId: 100 }, { areaId: 3, mapId: 999 }, ]; robot = createMockRobot('test-duid', 100, [ { areaId: 1, mapId: 100 }, { areaId: 2, mapId: 100 }, ]); const platform = createMockPlatform(areas); await handleActiveMapChanged(robot, 100, platform); expect(robot.updateAttribute).toHaveBeenCalledWith(ServiceArea.id, 'selectedAreas', [1, 2], expect.anything()); }); it('sets currentArea to null', async () => { const areas = [{ areaId: 5, mapId: 100 }]; robot = createMockRobot('test-duid', 100, [{ areaId: 5, mapId: 100 }]); const platform = createMockPlatform(areas); await handleActiveMapChanged(robot, 100, platform); expect(robot.updateAttribute).toHaveBeenCalledWith(ServiceArea.id, 'currentArea', null, expect.anything()); }); it('should clear estimatedEndTime to null on map change', async () => { const areas = [{ areaId: 5, mapId: 100 }]; robot = createMockRobot('test-duid', 100, [{ areaId: 5, mapId: 100 }]); const platform = createMockPlatform(areas); await handleActiveMapChanged(robot, 100, platform); expect(robot.updateAttribute).toHaveBeenCalledWith(ServiceArea.id, 'estimatedEndTime', null, expect.anything()); }); it('resets progress to empty array on map change', async () => { const areas = [ { areaId: 1, mapId: 100 }, { areaId: 2, mapId: 100 }, ]; robot = createMockRobot('test-duid', 100, [ { areaId: 1, mapId: 100 }, { areaId: 2, mapId: 100 }, ]); const platform = createMockPlatform(areas); await handleActiveMapChanged(robot, 100, platform); expect(platform.roborockService?.setProgress).toHaveBeenCalledWith(robot.device.duid, []); expect(robot.updateAttribute).toHaveBeenCalledWith(ServiceArea.id, 'progress', [], expect.anything()); }); it('should skip selectedAreas when Matter supportedAreas is routines-only', async () => { const areas = [ { areaId: 0, mapId: 0 }, { areaId: 1, mapId: 0 }, { areaId: 2, mapId: 0 }, { areaId: 3, mapId: 0 }, ]; robot = createMockRobot('test-duid', 0, [{ areaId: 100, mapId: 999 }]); const platform = createMockPlatform(areas); await handleActiveMapChanged(robot, 0, platform); expect(robot.updateAttribute).not.toHaveBeenCalledWith(ServiceArea.id, 'selectedAreas', expect.anything(), expect.anything()); }); it('should set selectedAreas when Matter supportedAreas contains matching IDs', async () => { const areas = [ { areaId: 0, mapId: 0 }, { areaId: 1, mapId: 0 }, { areaId: 2, mapId: 0 }, { areaId: 3, mapId: 0 }, ]; robot = createMockRobot('test-duid', 0, areas); const platform = createMockPlatform(areas); await handleActiveMapChanged(robot, 0, platform); expect(robot.updateAttribute).toHaveBeenCalledWith(ServiceArea.id, 'selectedAreas', [0, 1, 2, 3], expect.anything()); }); it('handles empty roborockService (no supportedAreas)', async () => { const platform = asPartial({ log: createMockLogger(), configManager: createMockConfigManager(), roborockService: undefined, }); await expect(handleActiveMapChanged(robot, 100, platform)).resolves.toBeUndefined(); expect(robot.updateAttribute).not.toHaveBeenCalled(); }); }); describe('handleServiceAreaUpdate with progress', () => { let robot; beforeEach(() => { vi.clearAllMocks(); robot = createMockRobot('test-duid-progress', 100); }); it('should clear progress when state is Idle with Operating area', async () => { const selectedAreas = [1, 2]; const initialProgress = [ { areaId: 1, status: ServiceArea.OperationalStatus.Pending }, { areaId: 2, status: ServiceArea.OperationalStatus.Operating }, ]; const mockRoborockService = asPartial({ getSelectedAreas: vi.fn().mockReturnValue(selectedAreas), getProgress: vi.fn().mockReturnValue(initialProgress), setProgress: vi.fn(), getLastActivelyCleaningState: vi.fn().mockReturnValue(false), setLastActivelyCleaningState: vi.fn(), }); const platform = asPartial({ log: createMockLogger(), configManager: createMockConfigManager(), roborockService: mockRoborockService, }); const message = { duid: 'test-duid-progress', state: OperationStatusCode.Idle, cleaningInfo: undefined, cleaningProcess: { clean_area: 0, clean_time: 0 }, }; await handleServiceAreaUpdate(robot, message, platform); expect(mockRoborockService?.setProgress).toHaveBeenCalledWith(robot.device.duid, []); expect(robot.updateAttribute).toHaveBeenCalledWith(ServiceArea.id, 'progress', [], expect.anything()); }); it('should initialize progress when cleaning starts with single selected area', async () => { const selectedAreas = [5]; const mockRoborockService = asPartial({ getSelectedAreas: vi.fn().mockReturnValue(selectedAreas), getProgress: vi.fn().mockReturnValue([]), setProgress: vi.fn(), getLastActivelyCleaningState: vi.fn().mockReturnValue(false), setLastActivelyCleaningState: vi.fn(), }); const platform = asPartial({ log: createMockLogger(), configManager: createMockConfigManager(), roborockService: mockRoborockService, }); vi.mocked(robot.getAttribute).mockReturnValue(selectedAreas); const message = { duid: 'test-duid-progress', state: OperationStatusCode.Cleaning, cleaningInfo: undefined, cleaningProcess: { clean_area: 100, clean_time: 50 }, }; await handleServiceAreaUpdate(robot, message, platform); // Verify progress was set with Operating status for the single area expect(mockRoborockService?.setProgress).toHaveBeenCalledWith(robot.device.duid, expect.arrayContaining([ expect.objectContaining({ areaId: 5, status: ServiceArea.OperationalStatus.Operating, }), ])); expect(robot.updateAttribute).toHaveBeenCalledWith(ServiceArea.id, 'progress', expect.arrayContaining([ expect.objectContaining({ areaId: 5, status: ServiceArea.OperationalStatus.Operating, }), ]), expect.anything()); }); it('should handle progress when no prior progress exists on clean start', async () => { const selectedAreas = [10]; const mockRoborockService = asPartial({ getSelectedAreas: vi.fn().mockReturnValue(selectedAreas), getProgress: vi.fn().mockReturnValue([]), setProgress: vi.fn(), getLastActivelyCleaningState: vi.fn().mockReturnValue(true), setLastActivelyCleaningState: vi.fn(), }); const platform = asPartial({ log: createMockLogger(), configManager: createMockConfigManager(), roborockService: mockRoborockService, }); vi.mocked(robot.getAttribute).mockReturnValue(selectedAreas); const message = { duid: 'test-duid-progress', state: OperationStatusCode.RoomClean, cleaningInfo: undefined, cleaningProcess: { clean_area: 50, clean_time: 30 }, }; await handleServiceAreaUpdate(robot, message, platform); // With empty prior progress, should initialize with Pending and set to Operating const setProgressCall = vi.mocked(mockRoborockService?.setProgress).mock.calls[0]; expect(setProgressCall).toBeDefined(); // estimatedTime is null because estimatedEndTimeEnabled is false in createMockConfigManager() expect(setProgressCall[1]).toEqual([ { areaId: 10, status: ServiceArea.OperationalStatus.Operating, estimatedTime: null }, ]); }); it('should clear progress when state is Idle without Operating areas', async () => { const selectedAreas = [1, 2]; const initialProgress = [ { areaId: 1, status: ServiceArea.OperationalStatus.Pending }, { areaId: 2, status: ServiceArea.OperationalStatus.Pending }, ]; const mockRoborockService = asPartial({ getSelectedAreas: vi.fn().mockReturnValue(selectedAreas), getProgress: vi.fn().mockReturnValue(initialProgress), setProgress: vi.fn(), getLastActivelyCleaningState: vi.fn().mockReturnValue(false), setLastActivelyCleaningState: vi.fn(), }); const platform = asPartial({ log: createMockLogger(), configManager: createMockConfigManager(), roborockService: mockRoborockService, }); const message = { duid: 'test-duid-progress', state: OperationStatusCode.Idle, cleaningInfo: undefined, cleaningProcess: { clean_area: 0, clean_time: 0 }, }; await handleServiceAreaUpdate(robot, message, platform); expect(mockRoborockService?.setProgress).toHaveBeenCalledWith(robot.device.duid, []); expect(robot.updateAttribute).toHaveBeenCalledWith(ServiceArea.id, 'progress', [], expect.anything()); }); it('should not update progress when cleaning_info is undefined and clean_area is 0', async () => { const selectedAreas = [1]; const mockRoborockService = asPartial({ getSelectedAreas: vi.fn().mockReturnValue(selectedAreas), getProgress: vi.fn().mockReturnValue([]), setProgress: vi.fn(), getLastActivelyCleaningState: vi.fn().mockReturnValue(true), setLastActivelyCleaningState: vi.fn(), }); const platform = asPartial({ log: createMockLogger(), configManager: createMockConfigManager(), roborockService: mockRoborockService, }); vi.mocked(robot.getAttribute).mockReturnValue(selectedAreas); const message = { duid: 'test-duid-progress', state: OperationStatusCode.Cleaning, cleaningInfo: undefined, cleaningProcess: { clean_area: 0, clean_time: 0 }, }; await handleServiceAreaUpdate(robot, message, platform); // Progress should not be set when clean hasn't started (clean_area === 0) expect(mockRoborockService?.setProgress).not.toHaveBeenCalled(); }); }); describe('handleServiceAreaUpdate idle clears currentArea', () => { let robot; beforeEach(() => { vi.clearAllMocks(); robot = createMockRobot('test-duid-idle-clear', 100); }); it('should clear currentArea and estimatedEndTime when state is Idle', async () => { const platform = asPartial({ log: createMockLogger(), configManager: createMockConfigManager(), roborockService: asPartial({ getSelectedAreas: vi.fn().mockReturnValue([1]), getProgress: vi.fn().mockReturnValue([]), setProgress: vi.fn(), getLastActivelyCleaningState: vi.fn().mockReturnValue(false), setLastActivelyCleaningState: vi.fn(), }), }); const message = { duid: 'test-duid-idle-clear', state: OperationStatusCode.Idle, cleaningInfo: undefined, cleaningProcess: { clean_area: 0, clean_time: 0 }, }; await handleServiceAreaUpdate(robot, message, platform); expect(robot.updateAttribute).toHaveBeenCalledWith(ServiceArea.id, 'currentArea', null, expect.anything()); expect(robot.updateAttribute).toHaveBeenCalledWith(ServiceArea.id, 'estimatedEndTime', null, expect.anything()); }); }); describe('handleServiceAreaUpdate multi-room without cleaning_info', () => { let robot; beforeEach(() => { vi.clearAllMocks(); robot = createMockRobot('test-duid-multi', 100); }); it('should keep selectedAreas and set currentArea to first selected when clean_time > 0', async () => { const selectedAreas = [10, 20]; const mockRoborockService = asPartial({ getSelectedAreas: vi.fn().mockReturnValue(selectedAreas), getProgress: vi.fn().mockReturnValue([]), setProgress: vi.fn(), getLastActivelyCleaningState: vi.fn().mockReturnValue(false), setLastActivelyCleaningState: vi.fn(), }); const platform = asPartial({ log: createMockLogger(), configManager: createMockConfigManager(), roborockService: mockRoborockService, }); vi.mocked(robot.getAttribute).mockReturnValue(selectedAreas); const message = { duid: 'test-duid-multi', state: OperationStatusCode.Cleaning, cleaningInfo: undefined, cleaningProcess: { clean_area: 100, clean_time: 50 }, }; await handleServiceAreaUpdate(robot, message, platform); expect(robot.updateAttribute).toHaveBeenCalledWith(ServiceArea.id, 'selectedAreas', selectedAreas, expect.anything()); expect(robot.updateAttribute).toHaveBeenCalledWith(ServiceArea.id, 'currentArea', 10, expect.anything()); }); }); describe('markAreaSkipped and getNextPendingArea', () => { it('should mark skipped area Skipped and advance next pending area to Operating', () => { const selectedAreas = [1, 2, 3]; const progress = [ { areaId: 1, status: ServiceArea.OperationalStatus.Completed }, { areaId: 2, status: ServiceArea.OperationalStatus.Operating }, { areaId: 3, status: ServiceArea.OperationalStatus.Pending }, ]; const nextAreaId = getNextPendingArea(selectedAreas, progress, 2); const updated = markAreaSkipped(progress, selectedAreas, 2, nextAreaId); expect(nextAreaId).toBe(3); expect(updated).toEqual(expect.arrayContaining([ expect.objectContaining({ areaId: 2, status: ServiceArea.OperationalStatus.Skipped }), expect.objectContaining({ areaId: 3, status: ServiceArea.OperationalStatus.Operating }), ])); }); }); describe('resolveAreaFromCleaningInfo — progress updates with area resolution', () => { let robot; beforeEach(() => { vi.clearAllMocks(); robot = createMockRobot('test-duid-resolve', 100); }); it('should transition area from Pending to Operating when cleaningInfo resolves a new area', async () => { const selectedAreas = [1, 2]; const initialProgress = [ { areaId: 1, status: ServiceArea.OperationalStatus.Pending }, { areaId: 2, status: ServiceArea.OperationalStatus.Pending }, ]; const roomMapData = new Map([ [1, { roomId: 10, mapId: 100, roomName: 'Room 1' }], [2, { roomId: 20, mapId: 100, roomName: 'Room 2' }], ]); const roomInfo = new Map([ ['10-100', { areaId: 1, mapId: 100, roomName: 'Room 1' }], ['20-100', { areaId: 2, mapId: 100, roomName: 'Room 2' }], ]); const indexMap = new RoomIndexMap(roomMapData, roomInfo); const mockRoborockService = asPartial({ getSupportedAreas: vi.fn().mockReturnValue([]), getSupportedAreasIndexMap: vi.fn().mockReturnValue(indexMap), getSelectedAreas: vi.fn().mockReturnValue(selectedAreas), getProgress: vi.fn().mockReturnValue(initialProgress), setProgress: vi.fn(), getLastActivelyCleaningState: vi.fn().mockReturnValue(false), setLastActivelyCleaningState: vi.fn(), }); const platform = asPartial({ log: createMockLogger(), configManager: createMockConfigManager(), roborockService: mockRoborockService, }); vi.mocked(robot.getAttribute).mockReturnValue(selectedAreas); const cleaningInfo = { segment_id: 10, target_segment_id: INVALID_SEGMENT_ID, fan_power: 0, water_box_status: 0, mop_mode: 0, }; const message = { duid: 'test-duid-resolve', state: OperationStatusCode.RoomClean, cleaningInfo, cleaningProcess: { clean_area: 100, clean_time: 60 }, }; await handleServiceAreaUpdate(robot, message, platform); // Verify area 1 transitioned to Operating expect(mockRoborockService?.setProgress).toHaveBeenCalledWith(robot.device.duid, expect.arrayContaining([ expect.objectContaining({ areaId: 1, status: ServiceArea.OperationalStatus.Operating, }), ])); }); it('should mark previous Operating area as Completed when new area becomes Operating', async () => { const selectedAreas = [1, 2]; const initialProgress = [ { areaId: 1, status: ServiceArea.OperationalStatus.Operating }, { areaId: 2, status: ServiceArea.OperationalStatus.Pending }, ]; const roomMapData = new Map([ [1, { roomId: 10, mapId: 100, roomName: 'Room 1' }], [2, { roomId: 20, mapId: 100, roomName: 'Room 2' }], ]); const roomInfo = new Map([ ['10-100', { areaId: 1, mapId: 100, roomName: 'Room 1' }], ['20-100', { areaId: 2, mapId: 100, roomName: 'Room 2' }], ]); const indexMap = new RoomIndexMap(roomMapData, roomInfo); const mockRoborockService = asPartial({ getSupportedAreas: vi.fn().mockReturnValue([]), getSupportedAreasIndexMap: vi.fn().mockReturnValue(indexMap), getSelectedAreas: vi.fn().mockReturnValue(selectedAreas), getProgress: vi.fn().mockReturnValue(initialProgress), setProgress: vi.fn(), getLastActivelyCleaningState: vi.fn().mockReturnValue(false), setLastActivelyCleaningState: vi.fn(), }); const platform = asPartial({ log: createMockLogger(), configManager: createMockConfigManager(), roborockService: mockRoborockService, }); vi.mocked(robot.getAttribute).mockReturnValue(selectedAreas); const cleaningInfo = { segment_id: 20, target_segment_id: INVALID_SEGMENT_ID, fan_power: 0, water_box_status: 0, mop_mode: 0, }; const message = { duid: 'test-duid-resolve', state: OperationStatusCode.RoomClean, cleaningInfo, cleaningProcess: { clean_area: 100, clean_time: 60 }, }; await handleServiceAreaUpdate(robot, message, platform); // Verify area 1 → Completed and area 2 → Operating expect(mockRoborockService?.setProgress).toHaveBeenCalledWith(robot.device.duid, expect.arrayContaining([ expect.objectContaining({ areaId: 1, status: ServiceArea.OperationalStatus.Completed }), expect.objectContaining({ areaId: 2, status: ServiceArea.OperationalStatus.Operating }), ])); }); it('should not update progress when cleaningInfo resolves to null mappedArea', async () => { const selectedAreas = [1]; const roomMapData = new Map([[10, { roomId: 10, mapId: 100, roomName: 'Room 1' }]]); const roomInfo = new Map([]); const indexMap = new RoomIndexMap(roomMapData, roomInfo); // No mapping for segment 99 const mockRoborockService = asPartial({ getSupportedAreas: vi.fn().mockReturnValue([]), getSupportedAreasIndexMap: vi.fn().mockReturnValue(indexMap), getSelectedAreas: vi.fn().mockReturnValue(selectedAreas), getProgress: vi.fn().mockReturnValue([]), setProgress: vi.fn(), getLastActivelyCleaningState: vi.fn().mockReturnValue(true), setLastActivelyCleaningState: vi.fn(), }); const platform = asPartial({ log: createMockLogger(), configManager: createMockConfigManager(), roborockService: mockRoborockService, }); vi.mocked(robot.getAttribute).mockReturnValue(selectedAreas); const cleaningInfo = { segment_id: 99, target_segment_id: INVALID_SEGMENT_ID, fan_power: 0, water_box_status: 0, mop_mode: 0, }; const message = { duid: 'test-duid-resolve', state: OperationStatusCode.RoomClean, cleaningInfo, cleaningProcess: { clean_area: 100, clean_time: 60 }, }; await handleServiceAreaUpdate(robot, message, platform); // Progress should not be set when mapped area is not found expect(mockRoborockService?.setProgress).not.toHaveBeenCalled(); }); }); describe('handleServiceAreaUpdate estimatedEndTime', () => { let robot; beforeEach(() => { vi.clearAllMocks(); robot = createMockRobot('test-duid-eta', 100); }); afterEach(() => { vi.useRealTimers(); }); function createResolvePlatform(indexMap, estimatedEndTimeEnabled, selectedAreas = [1]) { const mockRoborockService = asPartial({ getSupportedAreas: vi.fn().mockReturnValue([]), getSupportedAreasIndexMap: vi.fn().mockReturnValue(indexMap), getSelectedAreas: vi.fn().mockReturnValue(selectedAreas), getProgress: vi.fn().mockReturnValue([]), setProgress: vi.fn(), getLastActivelyCleaningState: vi.fn().mockReturnValue(false), setLastActivelyCleaningState: vi.fn(), }); return asPartial({ log: createMockLogger(), configManager: createMockConfigManager(estimatedEndTimeEnabled), roborockService: mockRoborockService, }); } it('should publish null estimatedEndTime when config is disabled', async () => { const indexMap = createRoomIndexMapForSegment(10, 1); const platform = createResolvePlatform(indexMap, false); vi.mocked(robot.getAttribute).mockReturnValue([1]); const message = { duid: 'test-duid-eta', state: OperationStatusCode.RoomClean, cleaningInfo: { segment_id: 10, target_segment_id: INVALID_SEGMENT_ID, fan_power: 0, water_box_status: 0, mop_mode: 0, }, cleaningProcess: { clean_area: 100, clean_time: 60, clean_percent: 25 }, }; await handleServiceAreaUpdate(robot, message, platform); expect(robot.updateAttribute).toHaveBeenCalledWith(ServiceArea.id, 'currentArea', 1, expect.anything()); expect(robot.updateAttribute).toHaveBeenCalledWith(ServiceArea.id, 'estimatedEndTime', null, expect.anything()); }); it('should publish computed estimatedEndTime via resolveAreaFromCleaningInfo when config is enabled', async () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2020-01-01T00:00:00Z')); const expectedNow = Math.floor(Date.now() / 1000); const indexMap = createRoomIndexMapForSegment(10, 1); const platform = createResolvePlatform(indexMap, true); vi.mocked(robot.getAttribute).mockReturnValue([1]); const message = { duid: 'test-duid-eta', state: OperationStatusCode.RoomClean, cleaningInfo: { segment_id: 10, target_segment_id: INVALID_SEGMENT_ID, fan_power: 0, water_box_status: 0, mop_mode: 0, }, cleaningProcess: { clean_area: 100, clean_time: 60, clean_percent: 25 }, }; await handleServiceAreaUpdate(robot, message, platform); expect(robot.updateAttribute).toHaveBeenCalledWith(ServiceArea.id, 'currentArea', 1, expect.anything()); expect(robot.updateAttribute).toHaveBeenCalledWith(ServiceArea.id, 'estimatedEndTime', expectedNow + 180, expect.anything()); }); it('should publish computed estimatedEndTime via handleCleaningWithoutInfo for multi-room', async () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2020-01-01T00:00:00Z')); const expectedNow = Math.floor(Date.now() / 1000); const selectedAreas = [10, 20]; const mockRoborockService = asPartial({ getSelectedAreas: vi.fn().mockReturnValue(selectedAreas), getProgress: vi.fn().mockReturnValue([]), setProgress: vi.fn(), getLastActivelyCleaningState: vi.fn().mockReturnValue(false), setLastActivelyCleaningState: vi.fn(), }); const platform = asPartial({ log: createMockLogger(), configManager: createMockConfigManager(true), roborockService: mockRoborockService, }); vi.mocked(robot.getAttribute).mockReturnValue(selectedAreas); const message = { duid: 'test-duid-eta', state: OperationStatusCode.Cleaning, cleaningInfo: undefined, cleaningProcess: { clean_area: 100, clean_time: 60, clean_percent: 25 }, }; await handleServiceAreaUpdate(robot, message, platform); expect(robot.updateAttribute).toHaveBeenCalledWith(ServiceArea.id, 'currentArea', 10, expect.anything()); expect(robot.updateAttribute).toHaveBeenCalledWith(ServiceArea.id, 'estimatedEndTime', expectedNow + 180, expect.anything()); }); it('should publish null estimatedEndTime when clean_percent is 0', async () => { const indexMap = createRoomIndexMapForSegment(10, 1); const platform = createResolvePlatform(indexMap, true); vi.mocked(robot.getAttribute).mockReturnValue([1]); const message = { duid: 'test-duid-eta', state: OperationStatusCode.RoomClean, cleaningInfo: { segment_id: 10, target_segment_id: INVALID_SEGMENT_ID, fan_power: 0, water_box_status: 0, mop_mode: 0, }, cleaningProcess: { clean_area: 100, clean_time: 60, clean_percent: 0 }, }; await handleServiceAreaUpdate(robot, message, platform); expect(robot.updateAttribute).toHaveBeenCalledWith(ServiceArea.id, 'currentArea', 1, expect.anything()); expect(robot.updateAttribute).toHaveBeenCalledWith(ServiceArea.id, 'estimatedEndTime', null, expect.anything()); }); it('should publish null estimatedEndTime when clean_percent is omitted', async () => { const indexMap = createRoomIndexMapForSegment(10, 1); const platform = createResolvePlatform(indexMap, true); vi.mocked(robot.getAttribute).mockReturnValue([1]); const message = { duid: 'test-duid-eta', state: OperationStatusCode.RoomClean, cleaningInfo: { segment_id: 10, target_segment_id: INVALID_SEGMENT_ID, fan_power: 0, water_box_status: 0, mop_mode: 0, }, cleaningProcess: { clean_area: 100, clean_time: 60 }, }; await handleServiceAreaUpdate(robot, message, platform); expect(robot.updateAttribute).toHaveBeenCalledWith(ServiceArea.id, 'currentArea', 1, expect.anything()); expect(robot.updateAttribute).toHaveBeenCalledWith(ServiceArea.id, 'estimatedEndTime', null, expect.anything()); }); it('should clear currentArea and estimatedEndTime when traveling with clean_time 0', async () => { const selectedAreas = [10]; const mockRoborockService = asPartial({ getSelectedAreas: vi.fn().mockReturnValue(selectedAreas), getProgress: vi.fn().mockReturnValue([]), setProgress: vi.fn(), getLastActivelyCleaningState: vi.fn().mockReturnValue(false), setLastActivelyCleaningState: vi.fn(), }); const platform = asPartial({ log: createMockLogger(), configManager: createMockConfigManager(true), roborockService: mockRoborockService, }); vi.mocked(robot.getAttribute).mockReturnValue(selectedAreas); const message = { duid: 'test-duid-eta', state: OperationStatusCode.Cleaning, cleaningInfo: undefined, cleaningProcess: { clean_area: 0, clean_time: 0 }, }; await handleServiceAreaUpdate(robot, message, platform); expect(robot.updateAttribute).toHaveBeenCalledWith(ServiceArea.id, 'currentArea', null, expect.anything()); expect(robot.updateAttribute).toHaveBeenCalledWith(ServiceArea.id, 'estimatedEndTime', null, expect.anything()); }); it('should publish null estimatedEndTime when state is outside CLEANING_STATES', async () => { const indexMap = createRoomIndexMapForSegment(10, 1); const platform = createResolvePlatform(indexMap, true); vi.mocked(robot.getAttribute).mockReturnValue([1]); const message = { duid: 'test-duid-eta', state: OperationStatusCode.Charging, cleaningInfo: { segment_id: 10, target_segment_id: INVALID_SEGMENT_ID, fan_power: 0, water_box_status: 0, mop_mode: 0, }, cleaningProcess: { clean_area: 100, clean_time: 60, clean_percent: 25 }, }; await handleServiceAreaUpdate(robot, message, platform); expect(robot.updateAttribute).toHaveBeenCalledWith(ServiceArea.id, 'currentArea', 1, expect.anything()); expect(robot.updateAttribute).toHaveBeenCalledWith(ServiceArea.id, 'estimatedEndTime', null, expect.anything()); }); }); describe('handleActiveMapChanged — operational state guard (Bug 3)', () => { let robot; beforeEach(() => { vi.clearAllMocks(); }); it('should skip updateAttribute when robot is actively running', async () => { robot = createMockRobot('test-duid-guard', 100, [{ areaId: 5, mapId: 100 }], RvcOperationalState.OperationalState.Running); const areas = [{ areaId: 5, mapId: 100 }]; const platform = createMockPlatform(areas, undefined, false); await handleActiveMapChanged(robot, 100, platform); expect(robot.updateAttribute).not.toHaveBeenCalled(); expect(platform.roborockService?.setProgress).not.toHaveBeenCalled(); }); it('should skip updateAttribute when robot is paused', async () => { robot = createMockRobot('test-duid-guard-paused', 100, [{ areaId: 5, mapId: 100 }], RvcOperationalState.OperationalState.Paused); const areas = [{ areaId: 5, mapId: 100 }]; const platform = createMockPlatform(areas, undefined, false); await handleActiveMapChanged(robot, 100, platform); expect(robot.updateAttribute).not.toHaveBeenCalled(); expect(platform.roborockService?.setProgress).not.toHaveBeenCalled(); }); it('should skip updateAttribute when robot is seeking charger', async () => { robot = createMockRobot('test-duid-guard-seeking', 100, [{ areaId: 5, mapId: 100 }], RvcOperationalState.OperationalState.SeekingCharger); const areas = [{ areaId: 5, mapId: 100 }]; const platform = createMockPlatform(areas, undefined, false); await handleActiveMapChanged(robot, 100, platform); expect(robot.updateAttribute).not.toHaveBeenCalled(); expect(platform.roborockService?.setProgress).not.toHaveBeenCalled(); }); it('should allow updateAttribute when robot is docked', async () => { robot = createMockRobot('test-duid-guard-docked', 100, [{ areaId: 5, mapId: 100 }], RvcOperationalState.OperationalState.Docked); const areas = [{ areaId: 5, mapId: 100 }]; const platform = createMockPlatform(areas, undefined, false); await handleActiveMapChanged(robot, 100, platform); expect(robot.updateAttribute).toHaveBeenCalledWith(ServiceArea.id, 'selectedAreas', [5], expect.anything()); expect(robot.updateAttribute).toHaveBeenCalledWith(ServiceArea.id, 'currentArea', null, expect.anything()); }); it('should allow updateAttribute when robot is stopped', async () => { robot = createMockRobot('test-duid-guard-stopped', 100, [{ areaId: 5, mapId: 100 }], RvcOperationalState.OperationalState.Stopped); const areas = [{ areaId: 5, mapId: 100 }]; const platform = createMockPlatform(areas, undefined, false); await handleActiveMapChanged(robot, 100, platform); expect(robot.updateAttribute).toHaveBeenCalledWith(ServiceArea.id, 'selectedAreas', [5], expect.anything()); expect(robot.updateAttribute).toHaveBeenCalledWith(ServiceArea.id, 'currentArea', null, expect.anything()); }); it('should allow updateAttribute when robot is in error state', async () => { robot = createMockRobot('test-duid-guard-error', 100, [{ areaId: 5, mapId: 100 }], RvcOperationalState.OperationalState.Error); const areas = [{ areaId: 5, mapId: 100 }]; const platform = createMockPlatform(areas, undefined, false); await handleActiveMapChanged(robot, 100, platform); expect(robot.updateAttribute).toHaveBeenCalledWith(ServiceArea.id, 'selectedAreas', [5], expect.anything()); expect(robot.updateAttribute).toHaveBeenCalledWith(ServiceArea.id, 'currentArea', null, expect.anything()); }); it('should allow updateAttribute when operationalState is undefined', async () => { robot = createMockRobot('test-duid-guard-undefined', 100, [{ areaId: 5, mapId: 100 }], undefined); const areas = [{ areaId: 5, mapId: 100 }]; const platform = createMockPlatform(areas, undefined, false); await handleActiveMapChanged(robot, 100, platform); expect(robot.updateAttribute).toHaveBeenCalledWith(ServiceArea.id, 'selectedAreas', [5], expect.anything()); expect(robot.updateAttribute).toHaveBeenCalledWith(ServiceArea.id, 'currentArea', null, expect.anything()); }); it('should still respect Matter supportedAreas filter when guard does not trip (idle case)', async () => { robot = createMockRobot('test-duid-guard-matter-filter', 100, [{ areaId: 5, mapId: 100 }], RvcOperationalState.OperationalState.Docked); const roborockServiceAreas = [ { areaId: 5, mapId: 100 }, { areaId: 6, mapId: 100 }, ]; const platform = createMockPlatform(roborockServiceAreas, undefined, false); await handleActiveMapChanged(robot, 100, platform); // Should only set area 5 (which is in Matter supportedAreas), not area 6 expect(robot.updateAttribute).toHaveBeenCalledWith(ServiceArea.id, 'selectedAreas', [5], expect.anything()); }); }); describe('resolveAreaFromCleaningInfo — fallback to getAreaIdV2 (Bug 1)', () => { let robot; beforeEach(() => { vi.clearAllMocks(); robot = createMockRobot('test-duid-fallback', 0); }); it('should use composite-key lookup when activeMapId matches existing entry (no regression)', async () => { // Composite key "10-100" exists in roomInfo const roomMapData = new Map([[1, { roomId: 10, mapId: 100, roomName: 'Room 1' }]]); const roomInfo = new Map([['10-100', { areaId: 1, mapId: 100, roomName: 'Room 1' }]]); const indexMap = new RoomIndexMap(roomMapData, roomInfo); robot = createMockRobot('test-duid-composite', 100); // activeMapId = 100 matches const mockRoborockService = asPartial({ getSupportedAreas: vi.fn().mockReturnValue([{ areaId: 1, mapId: 100 }]), getSupportedAreasIndexMap: vi.fn().mockReturnValue(indexMap), getSelectedAreas: vi.fn().mockReturnValue([1]), getProgress: vi.fn().mockReturnValue([]), setProgress: vi.fn(), getLastActivelyCleaningState: vi.fn().mockReturnValue(false), setLastActivelyCleaningState: vi.fn(), }); const platform = asPartial({ log: createMockLogger(), configManager: createMockConfigManager(), roborockService: mockRoborockService, }); vi.mocked(robot.getAttribute).mockReturnValue([1]); const message = { duid: 'test-duid-composite', state: OperationStatusCode.RoomClean, cleaningInfo: { segment_id: 10, target_segment_id: INVALID_SEGMENT_ID, fan_power: 0, water_box_status: 0, mop_mode: 0, }, cleaningProcess: { clean_area: 100, clean_time: 60 }, }; await handleServiceAreaUpdate(robot, message, platform); expect(robot.updateAttribute).toHaveBeenCalledWith(ServiceArea.id, 'currentArea', 1, expect.anything()); }); it('should fall back to getAreaIdV2 when composite-key lookup misses (Bug 1 fix)', async () => { // Composite key "10--1" does not exist (activeMapId = -1) // But "10-0" exists under a different mapId, which getAreaIdV2 should find const roomMapData = new Map([[1, { roomId: 10, mapId: 0, roomName: 'Room 1' }]]); const roomInfo = new Map([['10-0', { areaId: 1, mapId: 0, roomName: 'Room 1' }]]); const indexMap = new RoomIndexMap(roomMapData, roomInfo); robot = createMockRobot('test-duid-fallback', -1); // activeMapId = -1 (V10/V1 case) const mockRoborockService = asPartial({ getSupportedAreas: vi.fn().mockReturnValue([{ areaId: 1, mapId: 0 }]), getSupportedAreasIndexMap: vi.fn().mockReturnValue(indexMap), getSelectedAreas: vi.fn().mockReturnValue([1]), getProgress: vi.fn().mockReturnValue([]), setProgress: vi.fn(), getLastActivelyCleaningState: vi.fn().mockReturnValue(false), setLastActivelyCleaningState: vi.fn(), }); const platform = asPartial({ log: createMockLogger(), configManager: createMockConfigManager(), roborockService: mockRoborockService, }); vi.mocked(robot.getAttribute).mockReturnValue([1]); const message = { duid: 'test-duid-fallback', state: OperationStatusCode.RoomClean, cleaningInfo: { segment_id: 10, target_segment_id: INVALID_SEGMENT_ID, fan_power: 0, water_box_status: 0, mop_mode: 0, }, cleaningProcess: { clean_area: 100, clean_time: 60 }, }; await handleServiceAreaUpdate(robot, message, platform); // Despite activeMapId=-1 mismatch, fallback should resolve area 1 via getAreaIdV2 expect(robot.updateAttribute).toHaveBeenCalledWith(ServiceArea.id, 'currentArea', 1, expect.anything()); }); it('should set currentArea to null when both composite and fallback lookups miss', async () => { // No entry for roomId 99 under any mapId const roomMapData = new Map([[1, { roomId: 10, mapId: 0, roomName: 'Room 1' }]]); const roomInfo = new Map([['10-0', { areaId: 1, mapId: 0, roomName: 'Room 1' }]]); const indexMap = new RoomIndexMap(roomMapData, roomInfo); robot = createMockRobot('test-duid-both-miss', -1); const mockRoborockService = asPartial({ getSupportedAreas: vi.fn().mockReturnValue([{ areaId: 1, mapId: 0 }]), getSupportedAreasIndexMap: vi.fn().mockReturnValue(indexMap), getSelectedAreas: vi.fn().mockReturnValue([1]), getProgress: vi.fn().mockReturnValue([]), setProgress: vi.fn(), getLastActivelyCleaningState: vi.fn().mockReturnValue(false), setLastActivelyCleaningState: vi.fn(), }); const platform = asPartial({ log: createMockLogger(), configManager: createMockConfigManager(), roborockService: mockRoborockService, }); vi.mocked(robot.getAttribute).mockReturnValue([1]); const message = { duid: 'test-duid-both-miss', state: OperationStatusCode.RoomClean, cleaningInfo: { segment_id: 99, // This segment does not exist in the map target_segment_id: INVALID_SEGMENT_ID, fan_power: 0, water_box_status: 0, mop_mode: 0, }, cleaningProcess: { clean_area: 100, clean_time: 60 }, }; await handleServiceAreaUpdate(robot, message, platform); expect(robot.updateAttribute).toHaveBeenCalledWith(ServiceArea.id, 'currentArea', null, expect.anything()); }); it('should preserve existing Pending/Completed progress when using fallback', async () => { const selectedAreas = [1, 2]; const initialProgress = [ { areaId: 1, status: ServiceArea.OperationalStatus.Pending }, { areaId: 2, status: ServiceArea.OperationalStatus.Pending }, ]; const roomMapData = new Map([ [1, { roomId: 10, mapId: 0, roomName: 'Room 1' }], [2, { roomId: 20, mapId: 0, roomName: 'Room 2' }], ]); const roomInfo = new Map([ ['10-0', { areaId: 1, mapId: 0, roomName: 'Room 1' }], ['20-0', { areaId: 2, mapId: 0, roomName: 'Room 2' }], ]); const indexMap = new RoomIndexMap(roomMapData, roomInfo); robot = createMockRobot('test-duid-progress-fallback', -1); const mockRoborockService = asPartial({ getSupportedAreas: vi .fn() .mockReturnValue([{ areaId: 1, mapId: 0 }, { areaId: 2, mapId: 0 }]), getSupportedAreasIndexMap: vi.fn().mockReturnValue(indexMap), getSelectedAreas: vi.fn().mockReturnValue(selectedAreas), getProgress: vi.fn().mockReturnValue(initialProgress), setProgress: vi.fn(), getLastActivelyCleaningState: vi.fn().mockReturnValue(false), setLastActivelyCleaningState: vi.fn(), }); const platform = asPartial({ log: createMockLogger(), configManager: createMockConfigManager(), roborockService: mockRoborockService, }); vi.mocked(robot.getAttribute).mockReturnValue(selectedAreas); const message = { duid: 'test-duid-progress-fallback', state: OperationStatusCode.RoomClean, cleaningInfo: { segment_id: 10, target_segment_id: INVALID_SEGMENT_ID, fan_power: 0, water_box_status: 0, mop_mode: 0, }, cleaningProcess: { clean_area: 100, clean_time: 60 }, }; await handleServiceAreaUpdate(robot, message, platform); // Verify area 1 transitioned to Operating, area 2 remained Pending expect(mockRoborockService?.setProgress).toHaveBeenCalledWith(robot.device.duid, expect.arrayContaining([ expect.objectContaining({ areaId: 1, status: ServiceArea.OperationalStatus.Operating, }), expect.objectContaining({ areaId: 2, status: ServiceArea.OperationalStatus.Pending, }), ])); }); }); describe('buildProgressUpdate', () => { describe('with estimatedTimeForActiveArea', () => { it('should create new Pending entry for activeAreaId with estimatedTime=180 when estimatedTimeForActiveArea=180 passed', () => { const existing = []; const selectedAreas = [1]; const activeAreaId = 1; const estimatedTimeForActiveArea = 180; const result = buildProgressUpdate(existing, selectedAreas, activeAreaId, estimatedTimeForActiveArea); expect(result).toHaveLength(1); expect(result[0]).toMatchObject({ areaId: 1, status: ServiceArea.OperationalStatus.Operating, estimatedTime: 180, }); }); it('should set estimatedTime=null on new active area entry when estimatedTimeForActiveArea=null', () => { const existing = []; const selectedAreas = [1]; const activeAreaId = 1; const estimatedTimeForActiveArea = null; const result = buildProgressUpdate(existing, selectedAreas, activeAreaId, estimatedTimeForActiveArea); expect(result).toHaveLength(1); expect(result[0]).toMatchObject({ areaId: 1, status: ServiceArea.OperationalStatus.Operating, estimatedTime: null, }); }); it('should not set estimatedTime key on new entry for activeAreaId when estimatedTimeForActiveArea is undefined', () => { const existing = []; const selectedAreas = [1]; const activeAreaId = 1; const result = buildProgressUpdate(existing, selectedAreas, activeAreaId, undefined); expect(result).toHaveLength(1); expect(result[0]).toMatchObject({ areaId: 1, status: ServiceArea.OperationalStatus.Operating, }); expect(result[0]).not.toHaveProperty('estimatedTime'); }); }); describe('without estimatedTimeForActiveArea on non-active areas', () => { it('should NOT set estimatedTime on new Pending entries for non-active areas', () => { const existing = []; const selectedAreas = [1, 2, 3]; const activeAreaId = 1; const estimatedTimeForActiveArea = 180; const result = buildProgressUpdate(existing, selectedAreas, activeAreaId, estimatedTimeForActiveArea); // Active area (areaId=1) gets estimatedTime const activeEntry = result.find((p) => p.areaId === 1); expect(activeEntry).toMatchObject({ areaId: 1, status: ServiceArea.OperationalStatus.Operating, estimatedTime: 180, }); // Non-active areas (areaId=2,3) should NOT have estimate