Files
kuaishai2/test/device_protocol_test.dart
Developer 819889684f feat(device): 实现下位机 JSON 协议(data model 对齐)
按 docs/下位机交互数据模型.md 重构串口协议层:

协议层
- 新增 DeviceMessage 模型,对应 message_id/type/ack/need_ack/data
- 新增 JsonProtocolService,4 字节大端长度前缀 + UTF-8 JSON 帧
- 删除原二进制协议(serial_protocol.dart)

服务层
- 新增 DeviceMessageService,集中收发并按 type 分发
- 重写 SerialRunner 为 JsonSerialRunner,使用 create_task/control 消息

数据模型
- DeviceState 增加 doorStatus/lightStatus/taskStatus/lastInfoAt
- 新增 DeviceInfoNotifier 订阅 device_info 上行
- 灯光按钮接通 light_control 消息

测试
- 新增 device_protocol_test.dart(14 用例)
- 修复 models_test.dart 残留的 Step mixSpeed/blowSpeed 错误
2026-06-04 13:00:21 +08:00

222 lines
7.2 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_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);
});
});
}