matterbridge-roborock-vacuum-plugin
Version:
Matterbridge Roborock Vacuum Plugin
264 lines • 12 kB
JavaScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { HeaderMessage, RequestMessage, ResponseBody, ResponseMessage, } from '../../../../roborockCommunication/models/index.js';
import { B01PendingResponseTracker } from '../../../../roborockCommunication/routing/services/b01PendingResponseTracker.js';
import { createMockLogger } from '../../../helpers/testUtils.js';
function makeResponse(timestamp, protocol, data, duid = 'test-duid') {
const header = new HeaderMessage('B01', 1, 0, timestamp, protocol);
const body = new ResponseBody(data);
return new ResponseMessage(duid, header, body);
}
function makeRequest(timestamp, protocol) {
return new RequestMessage({ timestamp, protocol: protocol, messageId: 1234, nonce: 5678 });
}
describe('B01PendingResponseTracker', () => {
let logger;
let tracker;
const duid = 'test-duid';
beforeEach(() => {
vi.useFakeTimers();
logger = createMockLogger();
tracker = new B01PendingResponseTracker(logger, 500, 1);
});
afterEach(() => {
tracker.cancelAll();
vi.clearAllMocks();
vi.clearAllTimers();
vi.useRealTimers();
});
it('should resolve with a single response after collection window', async () => {
const request = makeRequest(100, 101);
const promise = tracker.waitFor(request, duid);
const response = makeResponse(101, 102, { '101': { '108': 4 } });
tracker.tryResolve(response);
vi.advanceTimersByTime(500);
const result = await promise;
expect(result.body?.data).toEqual({ rpc_request: { voice_version: 4 } });
});
it('should merge multiple responses into one', async () => {
const request = makeRequest(100, 101);
const promise = tracker.waitFor(request, duid);
tracker.tryResolve(makeResponse(101, 102, { '101': { '108': 4 } }));
tracker.tryResolve(makeResponse(101, 102, { '101': { '36': 105 } }));
tracker.tryResolve(makeResponse(101, 102, { '121': 10, '122': 100 }));
vi.advanceTimersByTime(500);
const result = await promise;
const rpcRequest = result.body?.data['rpc_request'];
expect(rpcRequest['voice_version']).toBe(4);
expect(rpcRequest['voice_language']).toBe(105);
expect(rpcRequest['state']).toBe(10);
expect(rpcRequest['battery']).toBe(100);
});
it('should reset collection window on each new response', async () => {
const request = makeRequest(100, 101);
const promise = tracker.waitFor(request, duid);
tracker.tryResolve(makeResponse(101, 102, { '101': { '108': 4 } }));
vi.advanceTimersByTime(300);
tracker.tryResolve(makeResponse(101, 102, { '101': { '36': 105 } }));
vi.advanceTimersByTime(300);
tracker.tryResolve(makeResponse(101, 102, { '121': 10 }));
vi.advanceTimersByTime(500);
const result = await promise;
const rpcRequest = result.body?.data['rpc_request'];
expect(rpcRequest['voice_version']).toBe(4);
expect(rpcRequest['voice_language']).toBe(105);
expect(rpcRequest['state']).toBe(10);
});
it('should reject on overall timeout when no responses arrive', async () => {
const request = makeRequest(100, 101);
const promise = tracker.waitFor(request, duid);
vi.advanceTimersByTime(10000);
await expect(promise).rejects.toThrow('Timeout');
});
it('should ignore responses with non-matching timestamp', async () => {
const request = makeRequest(100, 101);
const promise = tracker.waitFor(request, duid);
tracker.tryResolve(makeResponse(200, 102, { '101': { '108': 4 } }));
vi.advanceTimersByTime(10000);
await expect(promise).rejects.toThrow('Timeout');
});
it('should ignore responses with non-matching protocol', async () => {
const request = makeRequest(100, 101);
const promise = tracker.waitFor(request, duid);
tracker.tryResolve(makeResponse(101, 5, { '101': { '108': 4 } }));
vi.advanceTimersByTime(10000);
await expect(promise).rejects.toThrow('Timeout');
});
it('should respect configurable timestamp tolerance', async () => {
const tolerantTracker = new B01PendingResponseTracker(logger, 500, 2);
const request = makeRequest(100, 101);
const promise = tolerantTracker.waitFor(request, duid);
tolerantTracker.tryResolve(makeResponse(102, 102, { '101': { '36': 105 } }));
vi.advanceTimersByTime(500);
const result = await promise;
expect(result.body?.data['rpc_request']).toEqual({ voice_language: 105 });
tolerantTracker.cancelAll();
});
it('should reject timestamp outside tolerance range', async () => {
const tolerantTracker = new B01PendingResponseTracker(logger, 500, 2);
const request = makeRequest(100, 101);
const promise = tolerantTracker.waitFor(request, duid);
tolerantTracker.tryResolve(makeResponse(103, 102, { '101': { '36': 105 } }));
vi.advanceTimersByTime(10000);
await expect(promise).rejects.toThrow('Timeout');
tolerantTracker.cancelAll();
});
it('should cancel all pending entries', async () => {
const request = makeRequest(100, 101);
const promise = tracker.waitFor(request, duid);
tracker.cancelAll();
vi.advanceTimersByTime(10000);
await expect(promise).rejects.toThrow();
});
it('should merge real B01 multi-response data from tmp.log into final result', async () => {
const duid = 'device-abc';
// Request: timestamp=1771309419, protocol=101 (rpc_request)
const request = makeRequest(1771309419, 101);
const promise = tracker.waitFor(request, duid);
// Response 1: { 101: { 108: 4 } }
tracker.tryResolve(makeResponse(1771309420, 102, { '101': { '108': 4 } }, duid));
// Response 2: { 101: { 36: 105 } }
tracker.tryResolve(makeResponse(1771309420, 102, { '101': { '36': 105 } }, duid));
// Response 3: full state payload
tracker.tryResolve(makeResponse(1771309420, 102, {
'101': {
'6': 2,
'7': 0,
'25': 1,
'26': 41,
'29': 3838,
'30': 176,
'31': 4057,
'37': 1,
'40': 1,
'45': 1,
'47': 0,
'50': 0,
'51': true,
'53': false,
'60': 1,
'67': 16,
'76': 0,
'78': 0,
'79': { timeZoneCity: 'Europe/Paris', timeZoneSec: 3600 },
'80': 0,
'81': { ipAdress: '192.168.1.1', mac: 'AA:BB:CC:DD:EE:FF', signal: -40, wifiName: 'MyWifi' },
'83': 1,
'86': 1,
'87': 0,
'88': 0,
'90': 0,
'92': { disturb_dust_enable: 1, disturb_light: 0, disturb_resume_clean: 1, disturb_voice: 0 },
'93': 0,
'96': 0,
'104': 0,
'105': false,
'109': 'de',
'207': 0,
},
'121': 10,
'122': 100,
'123': 2,
'124': 0,
'125': 69,
'126': 78,
'127': 78,
'136': 1,
'137': 1,
'138': 1,
'139': 5,
}, duid));
vi.advanceTimersByTime(500);
const result = await promise;
const data = result.body?.data;
// Numeric keys mapped via Q10RequestCode enum; unmapped keys stay as numbers
expect(result.duid).toBe(duid);
expect(result.header).toEqual(expect.objectContaining({ version: 'B01', timestamp: 1771309420, protocol: 102 }));
expect(data).toEqual({
rpc_request: {
voice_version: 4,
voice_language: 105,
clean_time: 2,
clean_area: 0,
not_disturb: 1,
volume: 41,
total_clean_area: 3838,
total_clean_count: 176,
total_clean_time: 4057,
dust_switch: 1,
mop_state: 1,
auto_boost: 1,
child_lock: 0,
dust_setting: 0,
map_save_switch: true,
recent_clean_record: false,
multi_map_switch: 1,
sensor_life: 16,
carpet_clean_type: 0,
clean_line: 0,
timezone: { timeZoneCity: 'Europe/Paris', timeZoneSec: 3600 },
area_unit: 0,
networkinfo: { ipAdress: '192.168.1.1', mac: 'AA:BB:CC:DD:EE:FF', signal: -40, wifiName: 'MyWifi' },
robot_type: 1,
line_laser_obstacle_avoidance: 1,
clean_progress: 0,
ground_clean: 0,
fault: 0,
not_disturb_expand: { disturb_dust_enable: 1, disturb_light: 0, disturb_resume_clean: 1, disturb_voice: 0 },
timer_type: 0,
add_clean_state: 0,
breakpoint_clean: 0,
valley_point_charging: false,
robot_country_code: 'de',
ceip: 0,
state: 10,
battery: 100,
fan_power: 2,
water_box_mode: 0,
main_brush_life: 69,
side_brush_life: 78,
filter_life: 78,
clean_times: 1,
cleaning_reference: 1,
clean_task_type: 1,
back_type: 5,
},
});
});
it('should not resolve device1 entry with response from device2 when timestamp and protocol match', async () => {
const device1 = 'device-1';
const device2 = 'device-2';
const request = makeRequest(100, 101);
const device1Promise = tracker.waitFor(request, device1);
// Response arrives but from device2 — should NOT match device1's pending entry
tracker.tryResolve(makeResponse(101, 102, { '101': { '108': 4 } }, device2));
// device1's collectedData should remain empty (device2's response was rejected)
const pending = tracker['pending'];
expect(Object.keys(pending[0].collectedData).length).toBe(0);
// cleanup: advance to trigger overall timeout
vi.advanceTimersByTime(10000);
await expect(device1Promise).rejects.toThrow('Timeout');
});
it('should independently resolve two devices with same timestamp and protocol', async () => {
const device1 = 'device-1';
const device2 = 'device-2';
const request = makeRequest(100, 101);
const device1Promise = tracker.waitFor(request, device1);
const device2Promise = tracker.waitFor(request, device2);
tracker.tryResolve(makeResponse(101, 102, { '101': { '108': 4 } }, device1));
tracker.tryResolve(makeResponse(101, 102, { '101': { '36': 105 } }, device2));
vi.advanceTimersByTime(500);
const [result1, result2] = await Promise.all([device1Promise, device2Promise]);
expect((result1.body?.data['rpc_request'])['voice_version']).toBe(4);
expect((result2.body?.data['rpc_request'])['voice_language']).toBe(105);
});
it('should ignore responses with no body', async () => {
const request = makeRequest(100, 101);
const promise = tracker.waitFor(request, duid);
const header = new HeaderMessage('B01', 1, 0, 101, 102);
const noBodyResponse = new ResponseMessage(duid, header, undefined);
tracker.tryResolve(noBodyResponse);
vi.advanceTimersByTime(10000);
await expect(promise).rejects.toThrow('Timeout');
});
});
//# sourceMappingURL=b01PendingResponseTracker.test.js.map