按 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 错误
222 lines
7.2 KiB
Dart
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);
|
|
});
|
|
});
|
|
}
|