257 lines
8.4 KiB
Dart
257 lines
8.4 KiB
Dart
import 'dart:convert';
|
||
import 'dart:typed_data';
|
||
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:kuaishai2/features/device/models/device_state.dart';
|
||
import 'package:kuaishai2/features/device/services/device_log.dart';
|
||
import 'package:kuaishai2/features/device/services/device_message.dart';
|
||
import 'package:kuaishai2/features/device/services/json_protocol.dart';
|
||
import 'package:kuaishai2/features/device/services/task_payload.dart';
|
||
import 'package:kuaishai2/features/programs/models/program.dart';
|
||
import 'package:kuaishai2/features/programs/models/step.dart';
|
||
|
||
void main() {
|
||
group('DeviceMessageType', () {
|
||
test('wire names match protocol', () {
|
||
expect(DeviceMessageType.deviceInfo.wireName, 'device_info');
|
||
expect(DeviceMessageType.createTask.wireName, 'create_task');
|
||
expect(DeviceMessageType.lightControl.wireName, 'light_control');
|
||
expect(DeviceMessageType.control.wireName, 'control');
|
||
});
|
||
|
||
test('fromWire round-trip', () {
|
||
for (final t in DeviceMessageType.values) {
|
||
expect(DeviceMessageType.fromWire(t.wireName), t);
|
||
}
|
||
expect(DeviceMessageType.fromWire(null), isNull);
|
||
expect(DeviceMessageType.fromWire('nope'), isNull);
|
||
});
|
||
});
|
||
|
||
group('DeviceMessage', () {
|
||
test('toJson 与数据模型一致', () {
|
||
final msg = DeviceMessage(
|
||
messageId: 'abc-1',
|
||
type: DeviceMessageType.deviceInfo,
|
||
data: const {
|
||
'door_status': 'open',
|
||
'task_status': 'running',
|
||
'light_status': 'on',
|
||
},
|
||
);
|
||
final json = msg.toJson();
|
||
expect(json['message_id'], 'abc-1');
|
||
expect(json['type'], 'device_info');
|
||
expect(json['ack'], isNull);
|
||
expect(json['need_ack'], isFalse);
|
||
expect((json['data'] as Map)['door_status'], 'open');
|
||
});
|
||
|
||
test('encode/decode 闭环', () {
|
||
final src = DeviceMessage(
|
||
messageId: 'm-001',
|
||
type: DeviceMessageType.lightControl,
|
||
data: const {'status': 'on'},
|
||
ack: 'reply-001',
|
||
needAck: true,
|
||
);
|
||
final raw = src.encode();
|
||
final parsed = DeviceMessage.decode(raw);
|
||
expect(parsed, isNotNull);
|
||
expect(parsed!.messageId, 'm-001');
|
||
expect(parsed.type, DeviceMessageType.lightControl);
|
||
expect(parsed.ack, 'reply-001');
|
||
expect(parsed.needAck, isTrue);
|
||
expect(parsed.data['status'], 'on');
|
||
});
|
||
|
||
test('decode 非法 JSON 返回 null', () {
|
||
expect(DeviceMessage.decode('not-json'), isNull);
|
||
expect(DeviceMessage.decode('{"foo":1}'), isNull);
|
||
expect(DeviceMessage.decode(jsonEncode({
|
||
'message_id': '',
|
||
'type': 'device_info',
|
||
'data': {},
|
||
})), isNull);
|
||
});
|
||
|
||
test('ackFor 工厂生成应答消息', () {
|
||
final original = DeviceMessage(
|
||
messageId: 'orig-1',
|
||
type: DeviceMessageType.createTask,
|
||
data: const {'k': 'v'},
|
||
);
|
||
final ack = DeviceMessage.ackFor(original, {'result': 'ok'});
|
||
expect(ack.ack, 'orig-1');
|
||
expect(ack.type, DeviceMessageType.createTask);
|
||
expect(ack.data['result'], 'ok');
|
||
});
|
||
});
|
||
|
||
group('MessageIdGenerator', () {
|
||
test('连续生成的 ID 互不相同', () {
|
||
final gen = MessageIdGenerator();
|
||
final ids = List.generate(100, (_) => gen.next()).toSet();
|
||
expect(ids.length, 100);
|
||
});
|
||
});
|
||
|
||
group('JsonProtocolService', () {
|
||
test('encode + tryDecode 闭环', () {
|
||
final p = JsonProtocolService();
|
||
final msg = DeviceMessage(
|
||
messageId: 'p-1',
|
||
type: DeviceMessageType.control,
|
||
data: const {'status': 'stop'},
|
||
);
|
||
final bytes = p.encode(msg);
|
||
// 4 字节长度前缀 + 至少 1 字节 JSON + 1 字节换行
|
||
expect(bytes.length, greaterThan(5));
|
||
|
||
final (decoded, consumed) = p.tryDecode(bytes);
|
||
expect(consumed, bytes.length);
|
||
expect(decoded, isNotNull);
|
||
expect(decoded!.type, DeviceMessageType.control);
|
||
expect(decoded.data['status'], 'stop');
|
||
});
|
||
|
||
test('不足一帧时返回 null', () {
|
||
final p = JsonProtocolService();
|
||
final partial = Uint8List.fromList([0x00, 0x00, 0x00]); // 只有 3 字节长度前缀
|
||
final (decoded, consumed) = p.tryDecode(partial);
|
||
expect(decoded, isNull);
|
||
expect(consumed, 0);
|
||
});
|
||
|
||
test('分片到达也能解析', () {
|
||
final p = JsonProtocolService();
|
||
final msg = DeviceMessage(
|
||
messageId: 'frag-1',
|
||
type: DeviceMessageType.createTask,
|
||
data: const {'x': 1},
|
||
);
|
||
final bytes = p.encode(msg);
|
||
|
||
// 拆成 1 字节 / 剩余两段
|
||
final first = bytes.sublist(0, 1);
|
||
final second = bytes.sublist(1, 4);
|
||
final third = bytes.sublist(4);
|
||
|
||
expect(p.tryDecode(first).$1, isNull);
|
||
final (m1, _) = p.tryDecode(second);
|
||
expect(m1, isNull);
|
||
final (m2, consumed) = p.tryDecode(third);
|
||
expect(m2, isNotNull);
|
||
expect(m2!.messageId, 'frag-1');
|
||
// consumed = 4 字节长度前缀 + json 长度 + 1 字节换行
|
||
expect(consumed, bytes.length);
|
||
});
|
||
|
||
test('长度字段异常时丢弃首字节重新对齐', () {
|
||
final p = JsonProtocolService();
|
||
// 长度 = 0xFFFFFF 不可能
|
||
final corrupted = Uint8List.fromList([0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x01, 0x7B, 0x7D, 0x0A]);
|
||
final (decoded, _) = p.tryDecode(corrupted);
|
||
// 长度异常 -> 丢弃 1 字节 + 下一帧有效
|
||
// 第二帧 length=1, json='{', 缺少换行也允许
|
||
expect(decoded, isNull);
|
||
});
|
||
});
|
||
|
||
group('TaskPayload → DeviceMessage', () {
|
||
test('构造 create_task 消息字段与文档一致', () {
|
||
final program = Program(
|
||
id: 1,
|
||
code: 'P001',
|
||
name: 'Demo',
|
||
createdAt: '2026-06-04',
|
||
temperature: 50,
|
||
airflowTime: 60,
|
||
);
|
||
final steps = [
|
||
Step(
|
||
id: 1,
|
||
programId: 1,
|
||
stepNo: 1,
|
||
position: 'A1',
|
||
name: '混合',
|
||
mixTime: 60,
|
||
magnetTime: 30,
|
||
volume: 100,
|
||
speed: 5,
|
||
),
|
||
];
|
||
final payload = TaskPayload.fromProgram(program, steps);
|
||
final msg = payload.toMessage('mid-1', needAck: true);
|
||
|
||
expect(msg.type, DeviceMessageType.createTask);
|
||
expect(msg.needAck, isTrue);
|
||
expect(msg.data['temperature'], 50);
|
||
expect(msg.data['airflowtime'], 60);
|
||
|
||
final stepJson = (msg.data['steps'] as List).first as Map<String, dynamic>;
|
||
expect(stepJson['no'], 1);
|
||
expect(stepJson['slot'], 1); // A1 -> 1
|
||
expect(stepJson['mixtime'], 60);
|
||
expect(stepJson['pulltime'], 30);
|
||
expect(stepJson['volume'], 100);
|
||
expect(stepJson['speed'], 5);
|
||
});
|
||
});
|
||
|
||
group('DeviceState model', () {
|
||
test('copyWith 不会修改未传字段', () {
|
||
const s = DeviceState(
|
||
lightStatus: DeviceLightStatus.on,
|
||
doorStatus: DeviceDoorStatus.open,
|
||
taskStatus: DeviceTaskStatus.running,
|
||
lastInfoAt: null,
|
||
);
|
||
final s2 = s.copyWith(lightStatus: DeviceLightStatus.off);
|
||
expect(s2.lightStatus, DeviceLightStatus.off);
|
||
expect(s2.doorStatus, DeviceDoorStatus.open);
|
||
expect(s2.taskStatus, DeviceTaskStatus.running);
|
||
});
|
||
|
||
test('infoStale 在 10 秒后为 true', () {
|
||
final past = DateTime.now().subtract(const Duration(seconds: 30));
|
||
final s = DeviceState(lastInfoAt: past);
|
||
expect(s.infoStale, isTrue);
|
||
});
|
||
});
|
||
|
||
group('DeviceLog', () {
|
||
test('summarizeData 截断长字符串与多余键', () {
|
||
final data = {
|
||
'a': 1,
|
||
'b': 'short',
|
||
'c': 'x' * 200,
|
||
'd': [1, 2, 3],
|
||
'e': true,
|
||
'f': null,
|
||
'g': 'extra',
|
||
'h': 'extra2',
|
||
'i': 'extra3',
|
||
};
|
||
final summary = DeviceLog.summarizeData(data, maxKeys: 4);
|
||
expect(summary.contains('a=1'), isTrue);
|
||
expect(summary.contains('b="short"'), isTrue);
|
||
expect(summary.contains('…'), isTrue); // 长字符串被截断
|
||
expect(summary.contains('…'), isTrue);
|
||
expect(summary.contains(', ...'), isTrue); // 多余键被截断
|
||
});
|
||
|
||
test('summarizeData 空 Map / null 友好处理', () {
|
||
expect(DeviceLog.summarizeData(null), '{}');
|
||
expect(DeviceLog.summarizeData({}), '{}');
|
||
});
|
||
|
||
test('info / warn / severe 不抛异常', () {
|
||
// 仅验证调用不抛异常;dart:developer.log 在测试环境无可见输出
|
||
DeviceLog.info('hello');
|
||
DeviceLog.warn('warn', error: StateError('x'));
|
||
DeviceLog.severe('severe', error: 'err');
|
||
});
|
||
});
|
||
}
|