matterbridge-roborock-vacuum-plugin
Version:
Matterbridge Roborock Vacuum Plugin
1,003 lines • 85.7 kB
JavaScript
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