matterbridge-roborock-vacuum-plugin
Version:
Matterbridge Roborock Vacuum Plugin
902 lines (901 loc) • 60.7 kB
JavaScript
import { debugStringify } from 'matterbridge/logger';
import { ServiceArea } from 'matterbridge/matter/clusters';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { MapInfo, RoomIndexMap } from '../../core/application/models/index.js';
import { DeviceError } from '../../errors/index.js';
import { AreaManagementService } from '../../services/areaManagementService.js';
describe('AreaManagementService', () => {
let areaService;
let mockLogger;
let mockIotApi;
let mockMessageRoutingService;
const mockDeviceId = 'test-device-1';
const mockAreas = [
{ areaId: 0, mapId: 1 },
{ areaId: 1, mapId: 1 },
{ areaId: 2, mapId: 1 },
];
const mockRoutines = [
{ areaId: 100, mapId: 1 },
{ areaId: 101, mapId: 1 },
];
beforeEach(() => {
mockLogger = createMockLogger();
mockIotApi = createMockIotApi();
mockMessageRoutingService = {
getRoomMap: vi.fn(),
getMapInfo: vi.fn(),
};
areaService = new AreaManagementService(mockLogger, mockMessageRoutingService);
areaService.setIotApi(mockIotApi);
});
function createMockLogger() {
return {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
notice: vi.fn(),
};
}
function createMockIotApi() {
return {
getScenes: vi.fn(),
startScene: vi.fn(),
};
}
function createStartupMapInfo() {
const multimap = {
max_multi_map: 1,
max_bak_map: 0,
multi_map_count: 1,
map_info: [
{
mapFlag: 0,
add_time: 1771060270,
length: 5,
name: 'Home',
bak_maps: [],
rooms: [
{ id: 1, tag: 14, iot_name_id: '11100845', iot_name: 'Living' },
{ id: 2, tag: 9, iot_name_id: '11100849', iot_name: 'Bathroom' },
{ id: 3, tag: 6, iot_name_id: '11100842', iot_name: 'Kitchen' },
{ id: 4, tag: 1, iot_name_id: '11100847', iot_name: 'Bedroom' },
{ id: 5, tag: 10, iot_name_id: '12231095', iot_name: 'Hallway' },
],
},
],
};
return new MapInfo(multimap);
}
const startupRawRoomMapping = [
[1, '11100845', 14],
[2, '11100849', 9],
[3, '11100842', 6],
[4, '11100847', 1],
[5, '12231095', 10],
];
const partialDeviceRooms = [{ id: 12231095, name: 'Hallway from cloud' }];
afterEach(() => {
vi.clearAllMocks();
});
describe('Initialization', () => {
it('should initialize with all dependencies', () => {
expect(areaService).toBeDefined();
});
it('should initialize with logger only', () => {
const serviceWithoutDeps = new AreaManagementService(mockLogger, undefined);
expect(serviceWithoutDeps).toBeDefined();
});
it('should initialize with logger and message routing service', () => {
const service = new AreaManagementService(mockLogger, mockMessageRoutingService);
expect(service).toBeDefined();
});
it('should set IoT API after initialization', () => {
const service = new AreaManagementService(mockLogger, undefined);
const newIotApi = {};
expect(() => {
service.setIotApi(newIotApi);
}).not.toThrow();
});
});
describe('Supported Areas Management', () => {
it('should set and get supported areas', () => {
areaService.setSupportedAreas(mockDeviceId, mockAreas);
const areas = areaService.getSupportedAreas(mockDeviceId);
expect(areas).toBeDefined();
expect(areas).toHaveLength(3);
expect(areas).toEqual(mockAreas);
});
it('should return undefined for device without areas', () => {
const areas = areaService.getSupportedAreas('unknown-device');
expect(areas).toEqual([]);
});
it('should handle empty areas array', () => {
areaService.setSupportedAreas(mockDeviceId, []);
const areas = areaService.getSupportedAreas(mockDeviceId);
expect(areas).toBeDefined();
expect(areas).toHaveLength(0);
});
it('should support multiple devices', () => {
const device2 = 'test-device-2';
const areas2 = [{ areaId: 10, mapId: 2 }];
areaService.setSupportedAreas(mockDeviceId, mockAreas);
areaService.setSupportedAreas(device2, areas2);
expect(areaService.getSupportedAreas(mockDeviceId)).toEqual(mockAreas);
expect(areaService.getSupportedAreas(device2)).toEqual(areas2);
});
it('should update existing areas', () => {
areaService.setSupportedAreas(mockDeviceId, mockAreas);
const newAreas = [{ areaId: 99, mapId: 1 }];
areaService.setSupportedAreas(mockDeviceId, newAreas);
const areas = areaService.getSupportedAreas(mockDeviceId);
expect(areas).toEqual(newAreas);
});
});
describe('Area Index Map Management', () => {
it('should set and get area index map', () => {
const roomMapData = new Map([
[0, { roomId: 16, mapId: 1, roomName: 'rn-1' }],
[1, { roomId: 17, mapId: 1, roomName: 'rn-2' }],
]);
const roomInfo = new Map([]);
const indexMap = new RoomIndexMap(roomMapData, roomInfo);
areaService.setSupportedAreaIndexMap(mockDeviceId, indexMap);
const retrievedMap = areaService.getSupportedAreasIndexMap(mockDeviceId);
expect(retrievedMap).toBeDefined();
expect(retrievedMap).toBe(indexMap);
});
it('should return undefined for device without index map', () => {
const indexMap = areaService.getSupportedAreasIndexMap('unknown-device');
expect(indexMap).toBeUndefined();
});
});
describe('Selected Areas Management', () => {
it('should return empty array when no areas selected', () => {
const selectedAreas = areaService.getSelectedAreas(mockDeviceId);
expect(selectedAreas).toEqual([]);
});
it('should handle empty selection', () => {
const roomMapData = new Map([[0, { roomId: 16, mapId: 1, roomName: 'rn-1' }]]);
const roomInfo = new Map([]);
const indexMap = new RoomIndexMap(roomMapData, roomInfo);
areaService.setSupportedAreaIndexMap(mockDeviceId, indexMap);
areaService.setSelectedAreas(mockDeviceId, []);
const selectedAreas = areaService.getSelectedAreas(mockDeviceId);
expect(selectedAreas).toEqual([]);
});
it('should return empty array for unknown device', () => {
const selectedAreas = areaService.getSelectedAreas('unknown-device');
expect(selectedAreas).toEqual([]);
});
});
describe('Supported Routines Management', () => {
it('should set and get supported routines', () => {
areaService.setSupportedRoutines(mockDeviceId, mockRoutines);
const routines = areaService.getSupportedRoutines(mockDeviceId);
expect(routines).toBeDefined();
expect(routines).toHaveLength(2);
expect(routines).toEqual(mockRoutines);
});
it('should return undefined for device without routines', () => {
const routines = areaService.getSupportedRoutines('unknown-device');
expect(routines).toBeUndefined();
});
it('should handle empty routines array', () => {
areaService.setSupportedRoutines(mockDeviceId, []);
const routines = areaService.getSupportedRoutines(mockDeviceId);
expect(routines).toBeDefined();
expect(routines).toHaveLength(0);
});
});
describe('getMapInformation', () => {
const emptyMapInfo = new MapInfo({ max_multi_map: 0, max_bak_map: 0, multi_map_count: 0, map_info: [] });
it('should retrieve map information successfully', async () => {
mockMessageRoutingService.getMapInfo.mockResolvedValue(emptyMapInfo);
await areaService.getMapInfo(mockDeviceId);
expect(mockMessageRoutingService.getMapInfo).toHaveBeenCalled();
expect(mockLogger.debug).toHaveBeenCalledWith('AreaManagementService - getMapInfo', mockDeviceId);
});
it('should return MapInfo when no maps available', async () => {
mockMessageRoutingService.getMapInfo.mockResolvedValue(emptyMapInfo);
const mapInfo = await areaService.getMapInfo(mockDeviceId);
expect(mapInfo).toBeDefined();
expect(mapInfo?.maps.length).toBe(0);
});
it('should throw DeviceError when service routing not initialized', async () => {
const serviceWithoutClient = new AreaManagementService(mockLogger, undefined);
await expect(serviceWithoutClient.getMapInfo(mockDeviceId)).rejects.toThrow(DeviceError);
await expect(serviceWithoutClient.getMapInfo(mockDeviceId)).rejects.toThrow('Service routing not initialized');
});
});
describe('getRoomMappings', () => {
it('should retrieve room mappings with non-secure request', async () => {
mockMessageRoutingService.getRoomMap.mockResolvedValue([]);
await areaService.getRoomMap(mockDeviceId, 1);
expect(mockMessageRoutingService.getRoomMap).toHaveBeenCalledWith(mockDeviceId, 1);
});
it('should return undefined when message client not initialized', async () => {
const serviceWithoutClient = new AreaManagementService(mockLogger, undefined);
await expect(serviceWithoutClient.getRoomMap(mockDeviceId, 1)).rejects.toThrow(DeviceError);
});
it('should handle empty room mappings', async () => {
mockMessageRoutingService.getRoomMap.mockResolvedValue([]);
await areaService.getRoomMap(mockDeviceId, 1);
expect(mockMessageRoutingService.getRoomMap).toHaveBeenCalledWith(mockDeviceId, 1);
});
});
describe('getScenes', () => {
const mockHomeId = 12345;
const mockScenes = [{ id: 1, name: 'Morning Clean' }, { id: 2, name: 'Evening Clean' }];
it('should retrieve scenes successfully', async () => {
mockIotApi.getScenes.mockResolvedValue(mockScenes);
const scenes = await areaService.getScenes(mockHomeId);
expect(scenes).toEqual(mockScenes);
expect(mockIotApi.getScenes).toHaveBeenCalledWith(mockHomeId);
});
it('should throw DeviceError when IoT API not initialized', async () => {
const serviceWithoutApi = new AreaManagementService(mockLogger, undefined);
await expect(serviceWithoutApi.getScenes(mockHomeId)).rejects.toThrow(DeviceError);
await expect(serviceWithoutApi.getScenes(mockHomeId)).rejects.toThrow('IoT API not initialized');
});
it('should handle empty scenes array', async () => {
mockIotApi.getScenes.mockResolvedValue([]);
const scenes = await areaService.getScenes(mockHomeId);
expect(scenes).toEqual([]);
});
it('should handle undefined scenes', async () => {
mockIotApi.getScenes.mockResolvedValue(undefined);
const scenes = await areaService.getScenes(mockHomeId);
expect(scenes).toBeUndefined();
});
});
describe('startScene', () => {
const mockSceneId = 42;
it('should start scene successfully', async () => {
const mockResult = { success: true };
mockIotApi.startScene.mockResolvedValue(mockResult);
const result = await areaService.startScene(mockSceneId);
expect(result).toEqual(mockResult);
expect(mockIotApi.startScene).toHaveBeenCalledWith(mockSceneId);
});
it('should throw DeviceError when IoT API not initialized', async () => {
const serviceWithoutApi = new AreaManagementService(mockLogger, undefined);
await expect(serviceWithoutApi.startScene(mockSceneId)).rejects.toThrow(DeviceError);
await expect(serviceWithoutApi.startScene(mockSceneId)).rejects.toThrow('IoT API not initialized');
});
it('should propagate API errors', async () => {
const error = new Error('Scene execution failed');
mockIotApi.startScene.mockRejectedValue(error);
await expect(areaService.startScene(mockSceneId)).rejects.toThrow('Scene execution failed');
});
});
describe('clearAll', () => {
it('should clear all data', () => {
// Set up some data
areaService.setSupportedAreas(mockDeviceId, mockAreas);
areaService.setSupportedRoutines(mockDeviceId, mockRoutines);
const roomMapData = new Map([[0, { roomId: 16, mapId: 1, roomName: 'rn-1' }]]);
const roomInfo = new Map([]);
areaService.setSupportedAreaIndexMap(mockDeviceId, new RoomIndexMap(roomMapData, roomInfo));
areaService.setSelectedAreas(mockDeviceId, [0]);
areaService.setProgress(mockDeviceId, [{ areaId: 0, status: ServiceArea.OperationalStatus.Operating }]);
// Clear all
areaService.clearAll();
// Verify everything is cleared
expect(areaService.getSupportedAreas(mockDeviceId)).toEqual([]);
expect(areaService.getSupportedRoutines(mockDeviceId)).toBeUndefined();
expect(areaService.getSupportedAreasIndexMap(mockDeviceId)).toBeUndefined();
expect(areaService.getSelectedAreas(mockDeviceId)).toEqual([]);
expect(areaService.getProgress(mockDeviceId)).toEqual([]);
expect(mockLogger.debug).toHaveBeenCalledWith('AreaManagementService - All data cleared');
});
it('should be safe to call multiple times', () => {
areaService.clearAll();
areaService.clearAll();
expect(() => {
areaService.clearAll();
}).not.toThrow();
});
it('should clear empty service without errors', () => {
const newService = new AreaManagementService(mockLogger, undefined);
expect(() => {
newService.clearAll();
}).not.toThrow();
});
});
describe('setDeviceRooms', () => {
it('stores rooms per duid', () => {
const rooms = [{ id: 1, name: undefined }];
areaService.setDeviceRooms(mockDeviceId, rooms);
// No direct getter — verify indirectly via clearAll not throwing
expect(() => areaService.clearAll()).not.toThrow();
});
});
describe('Progress Management', () => {
it('should set and get progress array', () => {
const progress = [
{ areaId: 1, status: ServiceArea.OperationalStatus.Pending },
{ areaId: 2, status: ServiceArea.OperationalStatus.Operating },
];
areaService.setProgress(mockDeviceId, progress);
const retrieved = areaService.getProgress(mockDeviceId);
expect(retrieved).toEqual(progress);
expect(mockLogger.debug).toHaveBeenCalledWith('AreaManagementService - setProgress', debugStringify(progress));
});
it('should return empty array when no progress set for duid', () => {
const progress = areaService.getProgress('unknown-device');
expect(progress).toEqual([]);
});
it('should return empty array as default for new duid', () => {
const newDuid = 'new-device-456';
const progress = areaService.getProgress(newDuid);
expect(progress).toEqual([]);
});
it('should handle empty progress array', () => {
areaService.setProgress(mockDeviceId, []);
const progress = areaService.getProgress(mockDeviceId);
expect(progress).toEqual([]);
});
it('should update existing progress', () => {
const initialProgress = [{ areaId: 1, status: ServiceArea.OperationalStatus.Pending }];
areaService.setProgress(mockDeviceId, initialProgress);
const updatedProgress = [
{ areaId: 1, status: ServiceArea.OperationalStatus.Operating },
{ areaId: 2, status: ServiceArea.OperationalStatus.Completed },
];
areaService.setProgress(mockDeviceId, updatedProgress);
expect(areaService.getProgress(mockDeviceId)).toEqual(updatedProgress);
});
it('should support multiple devices independently', () => {
const device1 = 'device-1';
const device2 = 'device-2';
const progress1 = [{ areaId: 1, status: ServiceArea.OperationalStatus.Pending }];
const progress2 = [{ areaId: 10, status: ServiceArea.OperationalStatus.Operating }];
areaService.setProgress(device1, progress1);
areaService.setProgress(device2, progress2);
expect(areaService.getProgress(device1)).toEqual(progress1);
expect(areaService.getProgress(device2)).toEqual(progress2);
});
it('should clear progress in clearAll', () => {
const progress = [{ areaId: 1, status: ServiceArea.OperationalStatus.Operating }];
areaService.setProgress(mockDeviceId, progress);
expect(areaService.getProgress(mockDeviceId)).toEqual(progress);
areaService.clearAll();
expect(areaService.getProgress(mockDeviceId)).toEqual([]);
});
it('should handle all progress statuses', () => {
const progress = [
{ areaId: 1, status: ServiceArea.OperationalStatus.Pending },
{ areaId: 2, status: ServiceArea.OperationalStatus.Operating },
{ areaId: 3, status: ServiceArea.OperationalStatus.Completed },
{ areaId: 4, status: ServiceArea.OperationalStatus.Skipped },
];
areaService.setProgress(mockDeviceId, progress);
const retrieved = areaService.getProgress(mockDeviceId);
expect(retrieved).toHaveLength(4);
expect(retrieved[0].status).toBe(ServiceArea.OperationalStatus.Pending);
expect(retrieved[1].status).toBe(ServiceArea.OperationalStatus.Operating);
expect(retrieved[2].status).toBe(ServiceArea.OperationalStatus.Completed);
expect(retrieved[3].status).toBe(ServiceArea.OperationalStatus.Skipped);
});
});
describe('Last Actively Cleaning State Management', () => {
it('should return false when no entry exists (default)', () => {
const state = areaService.getLastActivelyCleaningState('unknown-device');
expect(state).toBe(false);
});
it('should set and get last actively cleaning state', () => {
const duid = 'test-device-cleaning-state';
areaService.setLastActivelyCleaningState(duid, true);
const state = areaService.getLastActivelyCleaningState(duid);
expect(state).toBe(true);
});
it('should return true after setting to true', () => {
const duid = 'test-device-state-true';
areaService.setLastActivelyCleaningState(duid, true);
expect(areaService.getLastActivelyCleaningState(duid)).toBe(true);
});
it('should return false after setting to false', () => {
const duid = 'test-device-state-false';
areaService.setLastActivelyCleaningState(duid, false);
expect(areaService.getLastActivelyCleaningState(duid)).toBe(false);
});
it('should update existing state', () => {
const duid = 'test-device-state-update';
areaService.setLastActivelyCleaningState(duid, true);
expect(areaService.getLastActivelyCleaningState(duid)).toBe(true);
areaService.setLastActivelyCleaningState(duid, false);
expect(areaService.getLastActivelyCleaningState(duid)).toBe(false);
areaService.setLastActivelyCleaningState(duid, true);
expect(areaService.getLastActivelyCleaningState(duid)).toBe(true);
});
it('should support multiple devices independently', () => {
const device1 = 'device-state-1';
const device2 = 'device-state-2';
areaService.setLastActivelyCleaningState(device1, true);
areaService.setLastActivelyCleaningState(device2, false);
expect(areaService.getLastActivelyCleaningState(device1)).toBe(true);
expect(areaService.getLastActivelyCleaningState(device2)).toBe(false);
});
it('should clear state in clearAll', () => {
const duid = 'test-device-state-clear';
areaService.setLastActivelyCleaningState(duid, true);
expect(areaService.getLastActivelyCleaningState(duid)).toBe(true);
areaService.clearAll();
expect(areaService.getLastActivelyCleaningState(duid)).toBe(false);
});
it('should clear state for multiple devices', () => {
const device1 = 'device-clear-1';
const device2 = 'device-clear-2';
areaService.setLastActivelyCleaningState(device1, true);
areaService.setLastActivelyCleaningState(device2, true);
expect(areaService.getLastActivelyCleaningState(device1)).toBe(true);
expect(areaService.getLastActivelyCleaningState(device2)).toBe(true);
areaService.clearAll();
expect(areaService.getLastActivelyCleaningState(device1)).toBe(false);
expect(areaService.getLastActivelyCleaningState(device2)).toBe(false);
});
it('should log debug message when setting state', () => {
const duid = 'test-device-state-log';
areaService.setLastActivelyCleaningState(duid, true);
expect(mockLogger.debug).toHaveBeenCalledWith('AreaManagementService - setLastActivelyCleaningState', {
duid,
isActivelyCleaning: true,
});
});
});
describe('registerAreasListener', () => {
it('registers callback per duid', () => {
const cb = vi.fn();
areaService.registerAreasListener(mockDeviceId, cb);
// callback registered — setSupportedAreas will invoke it
areaService.setSupportedAreas(mockDeviceId, mockAreas);
expect(cb).toHaveBeenCalledWith(mockAreas, []);
});
it('invokes callback when setSupportedAreas is called', () => {
const cb = vi.fn();
areaService.registerAreasListener(mockDeviceId, cb);
areaService.setSupportedAreas(mockDeviceId, mockAreas);
expect(cb).toHaveBeenCalledTimes(1);
});
it('invokes callback with current maps when setSupportedAreas is called', () => {
const cb = vi.fn();
const maps = [{ mapId: 1, name: 'Map 1' }];
areaService.setSupportedMaps(mockDeviceId, maps);
areaService.registerAreasListener(mockDeviceId, cb);
areaService.setSupportedAreas(mockDeviceId, mockAreas);
expect(cb).toHaveBeenCalledWith(mockAreas, maps);
});
});
describe('setSupportedMaps / getSupportedMaps', () => {
it('stores and retrieves maps by duid', () => {
const maps = [
{ mapId: 1, name: 'Map A' },
{ mapId: 2, name: 'Map B' },
];
areaService.setSupportedMaps(mockDeviceId, maps);
expect(areaService.getSupportedMaps(mockDeviceId)).toEqual(maps);
});
it('returns empty array for unknown duid', () => {
expect(areaService.getSupportedMaps('unknown-duid')).toEqual([]);
});
});
describe('getMapInfo — liveMapUpdates = true', () => {
it('calls serviceRouting.getMapInfoV2 and returns undefined', async () => {
const liveService = new AreaManagementService(mockLogger, mockMessageRoutingService, true);
mockMessageRoutingService.getMapInfoV2 = vi.fn().mockResolvedValue(undefined);
const result = await liveService.getMapInfo(mockDeviceId);
expect(mockMessageRoutingService.getMapInfoV2).toHaveBeenCalledWith(mockDeviceId);
expect(mockMessageRoutingService.getMapInfo).not.toHaveBeenCalled();
expect(result).toBeUndefined();
});
});
describe('getMapInfo — liveMapUpdates = false (standard path)', () => {
it('caches mapInfo in mapInfoCache', async () => {
const mapInfo = new MapInfo({ max_multi_map: 0, max_bak_map: 0, multi_map_count: 0, map_info: [] });
mockMessageRoutingService.getMapInfo.mockResolvedValue(mapInfo);
await areaService.getMapInfo(mockDeviceId);
// Call again — cache used: getMapInfo should be called each time (no cache bypass)
await areaService.getMapInfo(mockDeviceId);
expect(mockMessageRoutingService.getMapInfo).toHaveBeenCalledTimes(2);
});
it('calls setSupportedMaps when mapInfo.maps.length > 0', async () => {
const mapInfo = new MapInfo({
max_multi_map: 1,
max_bak_map: 0,
multi_map_count: 1,
map_info: [{ mapFlag: 1, add_time: 0, length: 0, bak_maps: [], name: 'Test Map', rooms: [] }],
});
mockMessageRoutingService.getMapInfo.mockResolvedValue(mapInfo);
await areaService.getMapInfo(mockDeviceId);
expect(areaService.getSupportedMaps(mockDeviceId)).toEqual([{ mapId: 1, name: 'Test Map' }]);
});
it('skips room setup when mapInfo.hasRooms = false', async () => {
const mapInfo = new MapInfo({ max_multi_map: 0, max_bak_map: 0, multi_map_count: 0, map_info: [] });
mockMessageRoutingService.getMapInfo.mockResolvedValue(mapInfo);
await areaService.getMapInfo(mockDeviceId);
expect(areaService.getSupportedAreas(mockDeviceId)).toEqual([]);
});
});
describe('getRoomMap — liveMapUpdates = true', () => {
it('calls serviceRouting.getRoomMapV2 and returns undefined', async () => {
const liveService = new AreaManagementService(mockLogger, mockMessageRoutingService, true);
mockMessageRoutingService.getRoomMapV2 = vi.fn().mockResolvedValue(undefined);
const result = await liveService.getRoomMap(mockDeviceId, 1);
expect(mockMessageRoutingService.getRoomMapV2).toHaveBeenCalledWith(mockDeviceId, 1);
expect(mockMessageRoutingService.getRoomMap).not.toHaveBeenCalled();
expect(result).toBeUndefined();
});
});
describe('getRoomMap — liveMapUpdates = false (rawData branch)', () => {
it('skips area setup when rawData is empty array', async () => {
mockMessageRoutingService.getRoomMap = vi.fn().mockResolvedValue([]);
await areaService.getRoomMap(mockDeviceId, 1);
// Empty rawData → no setSupportedAreas called
expect(areaService.getSupportedAreas(mockDeviceId)).toEqual([]);
});
it('uses cached mapInfoCache to resolve mapId when rawData is non-empty', async () => {
// Pre-populate mapInfo cache via getMapInfo call
const mapInfo = new MapInfo({ max_multi_map: 0, max_bak_map: 0, multi_map_count: 0, map_info: [] });
mockMessageRoutingService.getMapInfo.mockResolvedValue(mapInfo);
await areaService.getMapInfo(mockDeviceId);
// Now call getRoomMap with non-empty rawData
const rawData = [[16, 'room1']];
mockMessageRoutingService.getRoomMap = vi.fn().mockResolvedValue(rawData);
await areaService.getRoomMap(mockDeviceId, 1);
expect(mockMessageRoutingService.getRoomMap).toHaveBeenCalledWith(mockDeviceId, 1);
});
it('should preserve map_info room names after getMapInfo then getRoomMap when deviceRooms mismatch segments 1-4', async () => {
areaService.setDeviceRooms(mockDeviceId, partialDeviceRooms);
const mapInfo = createStartupMapInfo();
mockMessageRoutingService.getMapInfo.mockResolvedValue(mapInfo);
mockMessageRoutingService.getRoomMap.mockResolvedValue(startupRawRoomMapping);
await areaService.getMapInfo(mockDeviceId);
await areaService.getRoomMap(mockDeviceId, -1);
const areas = areaService.getSupportedAreas(mockDeviceId);
const expectedNames = ['Living', 'Bathroom', 'Kitchen', 'Bedroom', 'Hallway'];
expect(areas).toHaveLength(5);
for (let i = 0; i < 4; i++) {
expect(areas[i]?.areaInfo?.locationInfo?.locationName).toBe(expectedNames[i]);
}
expect(areas[4]?.areaInfo?.locationInfo?.locationName).toBe('Hallway from cloud');
});
it('should keep getMapInfo areas unchanged when getRoomMap returns empty rawData', async () => {
areaService.setDeviceRooms(mockDeviceId, partialDeviceRooms);
const mapInfo = createStartupMapInfo();
mockMessageRoutingService.getMapInfo.mockResolvedValue(mapInfo);
mockMessageRoutingService.getRoomMap.mockResolvedValue([]);
await areaService.getMapInfo(mockDeviceId);
const areasAfterMapInfo = areaService.getSupportedAreas(mockDeviceId);
await areaService.getRoomMap(mockDeviceId, -1);
const areasAfterRoomMap = areaService.getSupportedAreas(mockDeviceId);
expect(areasAfterRoomMap).toEqual(areasAfterMapInfo);
expect(areasAfterRoomMap[0]?.areaInfo?.locationInfo?.locationName).toBe('Living');
});
});
describe('startPeriodicRefresh / stopPeriodicRefresh', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('starts interval and calls getMapInfo periodically', async () => {
const mapInfo = new MapInfo({ max_multi_map: 0, max_bak_map: 0, multi_map_count: 0, map_info: [] });
mockMessageRoutingService.getMapInfo.mockResolvedValue(mapInfo);
areaService.startPeriodicRefresh(mockDeviceId, 5000);
await vi.advanceTimersByTimeAsync(5001);
expect(mockMessageRoutingService.getMapInfo).toHaveBeenCalled();
areaService.stopPeriodicRefresh(mockDeviceId);
});
it('stopPeriodicRefresh clears interval and deletes from map', async () => {
const mapInfo = new MapInfo({ max_multi_map: 0, max_bak_map: 0, multi_map_count: 0, map_info: [] });
mockMessageRoutingService.getMapInfo.mockResolvedValue(mapInfo);
areaService.startPeriodicRefresh(mockDeviceId, 5000);
areaService.stopPeriodicRefresh(mockDeviceId);
await vi.advanceTimersByTimeAsync(10000);
expect(mockMessageRoutingService.getMapInfo).not.toHaveBeenCalled();
});
it('startPeriodicRefresh replaces existing interval (stopPeriodicRefresh called first)', async () => {
const mapInfo = new MapInfo({ max_multi_map: 0, max_bak_map: 0, multi_map_count: 0, map_info: [] });
mockMessageRoutingService.getMapInfo.mockResolvedValue(mapInfo);
areaService.startPeriodicRefresh(mockDeviceId, 5000);
areaService.startPeriodicRefresh(mockDeviceId, 5000); // replace
await vi.advanceTimersByTimeAsync(5001);
// Only one interval active, so getMapInfo called once per tick
expect(mockMessageRoutingService.getMapInfo).toHaveBeenCalledTimes(1);
areaService.stopPeriodicRefresh(mockDeviceId);
});
it('logs error when periodic getMapInfo fails', async () => {
mockMessageRoutingService.getMapInfo.mockRejectedValue(new Error('network failure'));
areaService.startPeriodicRefresh(mockDeviceId, 5000);
await vi.advanceTimersByTimeAsync(5001);
expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining('network failure'));
areaService.stopPeriodicRefresh(mockDeviceId);
});
});
describe('clearAll — interval cleanup', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('stops all refresh intervals and clears all maps', async () => {
const mapInfo = new MapInfo({ max_multi_map: 0, max_bak_map: 0, multi_map_count: 0, map_info: [] });
mockMessageRoutingService.getMapInfo.mockResolvedValue(mapInfo);
areaService.startPeriodicRefresh(mockDeviceId, 5000);
areaService.clearAll();
await vi.advanceTimersByTimeAsync(10000);
expect(mockMessageRoutingService.getMapInfo).not.toHaveBeenCalled();
});
});
describe('Integration Scenarios', () => {
it('should handle complete area setup workflow', () => {
// Setup areas
areaService.setSupportedAreas(mockDeviceId, mockAreas);
// Setup index map
const roomMapData = new Map([
[0, { roomId: 16, mapId: 1, roomName: 'rn-1' }],
[1, { roomId: 17, mapId: 1, roomName: 'rn-2' }],
[2, { roomId: 18, mapId: 1, roomName: 'rn-3' }],
]);
const roomInfo = new Map([]);
areaService.setSupportedAreaIndexMap(mockDeviceId, new RoomIndexMap(roomMapData, roomInfo));
// Setup routines
areaService.setSupportedRoutines(mockDeviceId, mockRoutines);
// Select areas
areaService.setSelectedAreas(mockDeviceId, [0, 2]);
// Verify complete setup
expect(areaService.getSupportedAreas(mockDeviceId)).toEqual(mockAreas);
expect(areaService.getSupportedRoutines(mockDeviceId)).toEqual(mockRoutines);
expect(areaService.getSelectedAreas(mockDeviceId)).toEqual([0, 2]);
});
it('should handle multiple devices independently', () => {
const device1 = 'device-1';
const device2 = 'device-2';
const areas1 = [{ areaId: 1, mapId: 1 }];
const areas2 = [{ areaId: 2, mapId: 2 }];
areaService.setSupportedAreas(device1, areas1);
areaService.setSupportedAreas(device2, areas2);
expect(areaService.getSupportedAreas(device1)).toEqual(areas1);
expect(areaService.getSupportedAreas(device2)).toEqual(areas2);
});
it('should handle scene workflow with IoT API', async () => {
const homeId = 123;
const mockScenes = [{ id: 1, name: 'Test' }];
mockIotApi.getScenes.mockResolvedValue(mockScenes);
mockIotApi.startScene.mockResolvedValue({ success: true });
const scenes = await areaService.getScenes(homeId);
expect(scenes).toEqual(mockScenes);
const result = await areaService.startScene(1);
expect(result).toEqual({ success: true });
});
});
describe('Error Handling', () => {
it('should handle unknown device IDs gracefully', () => {
expect(areaService.getSupportedAreas('unknown-device')).toEqual([]);
expect(areaService.getSelectedAreas('unknown-device')).toEqual([]);
expect(areaService.getSupportedRoutines('unknown-device')).toBeUndefined();
});
it('should handle malformed area data', () => {
const malformedAreas = [{ areaId: 1 }, { areaId: 2 }];
expect(() => {
areaService.setSupportedAreas(mockDeviceId, malformedAreas);
}).not.toThrow();
const areas = areaService.getSupportedAreas(mockDeviceId);
expect(areas).toEqual(malformedAreas);
});
it('should propagate message client errors', async () => {
const error = new Error('Network error');
mockMessageRoutingService.getMapInfo.mockRejectedValue(error);
await expect(areaService.getMapInfo(mockDeviceId)).rejects.toThrow('Network error');
});
it('should propagate IoT API errors', async () => {
const error = new Error('API error');
mockIotApi.getScenes.mockRejectedValue(error);
await expect(areaService.getScenes(123)).rejects.toThrow('API error');
});
});
describe('resolveInitialAreas', () => {
it('should call getMapInfo then getRoomMap in sequence', async () => {
const mapInfo = new MapInfo({ max_multi_map: 0, max_bak_map: 0, multi_map_count: 0, map_info: [] });
mockMessageRoutingService.getMapInfo.mockResolvedValue(mapInfo);
mockMessageRoutingService.getRoomMap.mockResolvedValue([]);
await areaService.resolveInitialAreas(mockDeviceId);
expect(mockMessageRoutingService.getMapInfo).toHaveBeenCalledWith(mockDeviceId);
expect(mockMessageRoutingService.getRoomMap).toHaveBeenCalledWith(mockDeviceId, -1);
// Verify call order: getMapInfo should be called before getRoomMap
const getMapInfoCallOrder = vi.mocked(mockMessageRoutingService.getMapInfo).mock.invocationCallOrder[0];
const getRoomMapCallOrder = vi.mocked(mockMessageRoutingService.getRoomMap).mock.invocationCallOrder[0];
expect(getMapInfoCallOrder).toBeLessThan(getRoomMapCallOrder);
});
it('should return resolved supportedAreas when getMapInfo and getRoomMap succeed', async () => {
const mapInfo = new MapInfo({ max_multi_map: 0, max_bak_map: 0, multi_map_count: 0, map_info: [] });
mockMessageRoutingService.getMapInfo.mockResolvedValue(mapInfo);
mockMessageRoutingService.getRoomMap.mockResolvedValue([]);
areaService.setSupportedAreas(mockDeviceId, mockAreas);
const result = await areaService.resolveInitialAreas(mockDeviceId);
expect(result.supportedAreas).toEqual(mockAreas);
});
it('should return resolved supportedMaps when getMapInfo and getRoomMap succeed', async () => {
const mapInfo = new MapInfo({ max_multi_map: 0, max_bak_map: 0, multi_map_count: 0, map_info: [] });
mockMessageRoutingService.getMapInfo.mockResolvedValue(mapInfo);
mockMessageRoutingService.getRoomMap.mockResolvedValue([]);
const maps = [{ mapId: 1, name: 'Map A' }];
areaService.setSupportedMaps(mockDeviceId, maps);
const result = await areaService.resolveInitialAreas(mockDeviceId);
expect(result.supportedMaps).toEqual(maps);
});
it('should not throw when getMapInfo rejects', async () => {
mockMessageRoutingService.getMapInfo.mockRejectedValue(new Error('Network failure'));
await expect(areaService.resolveInitialAreas(mockDeviceId)).resolves.toBeDefined();
});
it('should not throw when getRoomMap rejects', async () => {
const mapInfo = new MapInfo({ max_multi_map: 0, max_bak_map: 0, multi_map_count: 0, map_info: [] });
mockMessageRoutingService.getMapInfo.mockResolvedValue(mapInfo);
mockMessageRoutingService.getRoomMap.mockRejectedValue(new Error('Network failure'));
await expect(areaService.resolveInitialAreas(mockDeviceId)).resolves.toBeDefined();
});
it('should return empty areas when both calls fail', async () => {
mockMessageRoutingService.getMapInfo.mockRejectedValue(new Error('Network failure'));
const result = await areaService.resolveInitialAreas(mockDeviceId);
expect(result.supportedAreas).toEqual([]);
expect(result.supportedMaps).toEqual([]);
});
it('should return fallback empty areas and maps for unknown device', async () => {
const mapInfo = new MapInfo({ max_multi_map: 0, max_bak_map: 0, multi_map_count: 0, map_info: [] });
mockMessageRoutingService.getMapInfo.mockResolvedValue(mapInfo);
mockMessageRoutingService.getRoomMap.mockResolvedValue([]);
const result = await areaService.resolveInitialAreas('unknown-device');
expect(result.supportedAreas).toEqual([]);
expect(result.supportedMaps).toEqual([]);
});
it('should log error when getMapInfo fails', async () => {
mockMessageRoutingService.getMapInfo.mockRejectedValue(new Error('Network error'));
await areaService.resolveInitialAreas(mockDeviceId);
expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining('resolveInitialAreas failed'));
});
it('should handle undefined serviceRouting gracefully', async () => {
const serviceWithoutClient = new AreaManagementService(mockLogger, undefined);
const result = await serviceWithoutClient.resolveInitialAreas(mockDeviceId);
expect(result.supportedAreas).toEqual([]);
expect(result.supportedMaps).toEqual([]);
});
it('should preserve map_info room names end-to-end via resolveInitialAreas', async () => {
areaService.setDeviceRooms(mockDeviceId, partialDeviceRooms);
const mapInfo = createStartupMapInfo();
mockMessageRoutingService.getMapInfo.mockResolvedValue(mapInfo);
mockMessageRoutingService.getRoomMap.mockResolvedValue(startupRawRoomMapping);
const result = await areaService.resolveInitialAreas(mockDeviceId);
const expectedNames = ['Living', 'Bathroom', 'Kitchen', 'Bedroom', 'Hallway from cloud'];
expect(result.supportedAreas).toHaveLength(5);
result.supportedAreas.forEach((area, index) => {
expect(area.areaInfo?.locationInfo?.locationName).toBe(expectedNames[index]);
});
});
it('should use sync getMapInfo and getRoomMap when liveMapUpdates is true', async () => {
const liveService = new AreaManagementService(mockLogger, mockMessageRoutingService, true);
liveService.setDeviceRooms(mockDeviceId, partialDeviceRooms);
mockMessageRoutingService.getMapInfoV2 = vi.fn();
mockMessageRoutingService.getRoomMapV2 = vi.fn();
mockMessageRoutingService.getMapInfo.mockResolvedValue(createStartupMapInfo());
mockMessageRoutingService.getRoomMap.mockResolvedValue(startupRawRoomMapping);
const result = await liveService.resolveInitialAreas(mockDeviceId);
expect(mockMessageRoutingService.getMapInfo).toHaveBeenCalledWith(mockDeviceId);
expect(mockMessageRoutingService.getRoomMap).toHaveBeenCalledWith(mockDeviceId, -1);
expect(mockMessageRoutingService.getMapInfoV2).not.toHaveBeenCalled();
expect(mockMessageRoutingService.getRoomMapV2).not.toHaveBeenCalled();
const expectedNames = ['Living', 'Bathroom', 'Kitchen', 'Bedroom', 'Hallway from cloud'];
expect(result.supportedAreas).toHaveLength(5);
result.supportedAreas.forEach((area, index) => {
expect(area.areaInfo?.locationInfo?.locationName).toBe(expectedNames[index]);
});
});
it('should populate supportedMaps from sync getMapInfo when liveMapUpdates is true', async () => {
const liveService = new AreaManagementService(mockLogger, mockMessageRoutingService, true);
mockMessageRoutingService.getMapInfo.mockResolvedValue(createStartupMapInfo());
mockMessageRoutingService.getRoomMap.mockResolvedValue(startupRawRoomMapping);
const result = await liveService.resolveInitialAreas(mockDeviceId);
expect(result.supportedMaps).toEqual([{ mapId: 0, name: 'Home' }]);
});
describe('Multi-map bootstrap with mapId sort (Bug 2)', () => {
it('should sort supportedAreas by mapId ascending when multi-map bootstrap fetches out-of-order', async () => {
const multiMapService = new AreaManagementService(mockLogger, mockMessageRoutingService, false, true);
multiMapService.setDeviceRooms(mockDeviceId, partialDeviceRooms);
// Create a multi-map MapInfo with mapId 0 and mapId 1
const multiMapInfo = {
max_multi_map: 2,
max_bak_map: 0,
multi_map_count: 2,
map_info: [
{
mapFlag: 0, // mapId 0
add_time: 1771060270,
length: 2,
name: 'First Map',
bak_maps: [],
rooms: [
{ id: 10, tag: 14, iot_name_id: '11100845', iot_name: 'Living' },
{ id: 11, tag: 9, iot_name_id: '11100849', iot_name: 'Bathroom' },
],
},
{
mapFlag: 1, // mapId 1
add_time: 1771060271,
length: 2,
name: 'Second Map',
bak_maps: [],
rooms: [
{ id: 20, tag: 14, iot_name_id: '21100845', iot_name: 'Office' },
{ id: 21, tag: 9, iot_name_id: '21100849', iot_name: 'Garage' },
],
},
],
};
const mapInfo = new MapInfo(multiMapInfo);
// Mock: first fetch returns mapId 1 rooms (the "currently active" map), then loop fetches mapId 0
const mapId1RoomData = [
[20, '21100845', 14],
[21, '21100849', 9],
];
const mapId0RoomData = [
[10, '11100845', 14],
[11, '11100849', 9],
];
// Setup: first getRoomMap (via fetchAndApplyRoomMap(duid, -1)) returns mapId 1 data
// second getRoomMap (via ensureAreasForMap) returns mapId 0 data
mockMessageRoutingService.getMapInfo.mockResolvedValue(mapInfo);
mockMessageRoutingService.switchMap = vi.fn().mockResolvedValue(undefined);
let callCount = 0;
mockMessageRoutingService.getRoomMap.mockImplementation(() => {
callCount++;
if (callCount === 1) {
return Promise.resolve(mapId1RoomData); // First call: mapId 1
}
else {
return Promise.resolve(mapId0RoomData); // Second call: mapId 0
}
});
const result = await multiMapService.resolveInitialAreas(mockDeviceId);
// Assert: supportedAreas should be sorted by mapId 0 first, then mapId 1
expect(result.supportedAreas).toHaveLength(4);
// mapId 0 areas should come first (areaId 0, 1)
expect(result.supportedAreas[0]).toMatchObject({ areaId: 0 });
expect(result.supportedAreas[1]).toMatchObject({ areaId: 1 });
// mapId 1 areas should come second (areaId 2, 3)
expect(result.supportedAreas[2]).toMatchObject({ areaId: 2 });
expect(result.supportedAreas[3]).toMatchObject({ areaId: 3 });
// Verify all areas have correct mapIds
const area0MapId = result.supportedAreas[0].mapId;
const area1MapId = result.supportedAreas[1].mapId;
const area2MapId = result.supportedAreas[2].mapId;
const area3MapId = result.supportedAreas[3].mapId;
expect(area0MapId).toBe(0);
expect(area1MapId).toBe(0);
expect(area2MapId).toBe(1);
expect(area3MapId).toBe(1);
});
it('should rebuild roomIndexMap to reflect new areaIds after sort', async () => {
const multiMapService = new AreaManagementService(mockLogger, mockMessageRoutingService, false, true);
multiMapService.setDeviceRooms(mockDeviceId, partialDeviceRooms);
const multiMapInfo = {
max_multi_map: 2,
max_bak_map: 0,
multi_map_count: 2,
map_info: [
{
mapFlag: 0, // mapId 0
add_time: 1771060270,
length: 2,
name: 'First Map',
bak_maps: [],
rooms: [
{ id: 10, tag: 14, iot_name_id: '11100845', iot_name: 'Living' },
{ id: 11, tag: 9, iot_name_id: '11100849', iot_name: 'Bathroom' },
],
},
{
mapFlag: 1, // mapId 1
add_time: 1771060271,