Files
flutterApp/test/v2_work_task_tracker_test.dart

837 lines
27 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:async';
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:fpdart/fpdart.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:latlong2/latlong.dart';
import 'package:maibu_satabot_v2/core/error/failure.dart';
import 'package:maibu_satabot_v2/core/logging/work_trace_diagnostics.dart';
import 'package:maibu_satabot_v2/core/network/mqtt/domain/interfaces/mqtt_client.dart';
import 'package:maibu_satabot_v2/core/network/mqtt/domain/models/mqtt_config.dart';
import 'package:maibu_satabot_v2/core/network/mqtt/domain/models/mqtt_message.dart';
import 'package:maibu_satabot_v2/features/autonomous_work/data/v2_work_store.dart';
import 'package:maibu_satabot_v2/features/autonomous_work/data/v2_work_task_tracker.dart';
import 'package:maibu_satabot_v2/features/autonomous_work/domain/entities/work_session_models.dart';
import 'package:maibu_satabot_v2/features/devices/domain/entities/device_schedule_plan_entity.dart';
import 'package:maibu_satabot_v2/features/devices/domain/entities/device_task_entity.dart';
import 'package:maibu_satabot_v2/features/devices/domain/repositories/device_task_repository.dart';
import 'package:maibu_satabot_v2/features/devices/domain/repositories/path_repository.dart';
import 'package:maibu_satabot_v2/features/devices/domain/usecases/create_device_task_usecase.dart';
import 'package:maibu_satabot_v2/features/pv_control/presentation/models/work_trace_recorder.dart';
Future<void> flush() async {
for (var i = 0; i < 15; i++) {
await Future<void>.delayed(Duration.zero);
}
}
class FakeMqtt implements MqttClient {
final messages = StreamController<MqttMessage>.broadcast(sync: true);
final topics = <String>{};
final configs = <MqttConfig>[];
@override
bool isConnected = false;
@override
Stream<MqttMessage> get messageStream => messages.stream;
@override
Future<void> connect(MqttConfig config) async {
configs.add(config);
isConnected = true;
}
@override
Future<void> disconnect() async {
isConnected = false;
}
@override
Future<void> subscribe(String topic) async {
topics.add(topic);
}
@override
Future<void> unsubscribe(String topic) async {
topics.remove(topic);
}
@override
Future<void> publish(String topic, String message) async {}
void status(
String status, {
int taskId = 42,
String deviceId = 'MC700-test',
int? topicId,
}) {
messages.add(
MqttMessage(
topic: 'task/${topicId ?? taskId}/status',
payload: jsonEncode({
'taskId': taskId,
'deviceId': deviceId,
'status': status,
}),
),
);
}
void arrive(dynamic entity, {int taskId = 42}) => messages.add(
MqttMessage(
topic: 'task/$taskId/arrive',
payload: jsonEncode({
'type': 'device_task_arrive_point',
'deviceId': 'MC700-test',
'taskId': taskId,
'entity': entity,
}),
),
);
}
class FakeTasks implements DeviceTaskRepository {
List<DeviceTaskEntity> tasks = [entity()];
Completer<Either<Failure, List<DeviceTaskEntity>>>? pending;
String? error;
int? operatedTask;
bool? force;
static DeviceTaskEntity entity({
int id = 42,
String state = 'EXECUTING',
int routeId = 8,
}) => DeviceTaskEntity(
id: id,
deviceId: 'MC700-test',
taskStatus: state,
taskStatusTranslate: '',
routeId: routeId,
siteId: 2,
orgId: 3,
);
@override
Future<Either<Failure, List<DeviceTaskEntity>>> getDeviceTaskPool({
required String userId,
required int siteId,
required int orgId,
int pageNum = 1,
int pageSize = 99999999,
}) async =>
pending?.future ?? (error == null ? Right(tasks) : Left(Failure(error!)));
@override
Future<Either<Failure, bool>> cancelTask({
required String deviceId,
required int taskId,
required int orgId,
required int siteId,
bool forceCancel = true,
}) async {
operatedTask = taskId;
force = forceCancel;
return error == null ? const Right(true) : Left(Failure(error!));
}
@override
Future<Either<Failure, bool>> pauseTask({
required String deviceId,
required int taskId,
required int orgId,
required int siteId,
}) async {
operatedTask = taskId;
return error == null ? const Right(true) : Left(Failure(error!));
}
@override
Future<Either<Failure, Map<String, dynamic>>> recoveryTask({
required String deviceId,
required int taskId,
required int orgId,
required int siteId,
}) async {
operatedTask = taskId;
return error == null ? const Right({}) : Left(Failure(error!));
}
@override
Future<Either<Failure, bool>> saveDeviceSchedulePlan({
required DeviceSchedulePlanEntity plan,
}) async {
return error == null ? const Right(true) : Left(Failure(error!));
}
}
class FakePath implements PathRepository {
int creations = 0;
@override
Future<int> createDeviceTask({
required String deviceId,
required int routeId,
required int siteId,
required int orgId,
}) async {
creations++;
return 100;
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late FakeTasks tasks;
late FakePath path;
late FakeMqtt mqtt;
late V2WorkTaskTracker tracker;
late StreamSubscription<WorkTaskSnapshot?> sub;
late List<WorkTaskSnapshot?> updates;
late List<String> traceLogs;
late bool failTraceLogs;
late int failedTraceWrites;
setUp(() {
SharedPreferences.setMockInitialValues({
'saved_device_task_ids': '{"legacy":77}',
});
tasks = FakeTasks();
path = FakePath();
mqtt = FakeMqtt();
updates = [];
traceLogs = [];
failTraceLogs = false;
failedTraceWrites = 0;
tracker = V2WorkTaskTracker(
repository: tasks,
createTask: CreateDeviceTaskUseCase(path),
resolveScope: (_) async => const WorkScope(siteId: 2, orgId: 3),
userId: () => 'test-user',
store: V2WorkStore('test-user'),
mqtt: mqtt,
diagnostics: WorkTraceDiagnostics(
now: () => DateTime(2026, 11, 5),
write: (line) {
if (failTraceLogs) {
failedTraceWrites++;
throw StateError('诊断输出失败');
}
traceLogs.add(line);
},
),
);
sub = tracker.watch('MC700-test').listen(updates.add);
});
tearDown(() async {
await sub.cancel();
await flush();
await mqtt.messages.close();
});
test(
'restore task subscribes with task ID, filters other devices and tasks',
() async {
final result = await tracker.load('MC700-test');
await flush();
expect(result.isOk, isTrue);
expect(mqtt.topics, {'task/42/status', 'task/42/arrive'});
expect(mqtt.configs.single.clientId, 'v2_work_task');
mqtt.status('FINISH', taskId: 8); // routeId cannot be used as taskId
mqtt.status('FINISH', deviceId: 'other-device');
mqtt.status('FINISH', taskId: 99, topicId: 42);
await flush();
expect(tracker.current('MC700-test')!.state, WorkRunState.running);
mqtt.status('PAUSE');
await flush();
expect(updates.last!.state, WorkRunState.paused);
},
);
test(
'FINISH leaves terminal snapshot, removes active ID and preserves legacy cache',
() async {
await tracker.load('MC700-test');
await flush();
mqtt.status('EXECUTING');
mqtt.status('FINISH');
await flush();
expect(updates.last!.state, WorkRunState.finished);
expect(mqtt.topics, isEmpty);
expect(await tracker.history('MC700-test'), hasLength(1));
expect(
traceLogs.any((s) => s.contains('[MQTT收到]') && s.contains('FINISH')),
isTrue,
);
expect(
traceLogs.any(
(s) =>
s.contains('[结束消息收到]') &&
s.contains('"source":"mqtt_status"') &&
s.contains('task/42/status'),
),
isTrue,
);
expect(
traceLogs.any(
(s) =>
s.contains('[结束状态落地]') &&
s.contains('"removedFromActive":true') &&
s.contains('"inHistory":true'),
),
isTrue,
);
final prefs = await SharedPreferences.getInstance();
expect(prefs.getString('saved_device_task_ids'), '{"legacy":77}');
final stored = await V2WorkStore('test-user').read('tasks');
expect(stored!['current'], isEmpty);
final reloaded = await tracker.load('MC700-test');
expect(
reloaded.data,
isEmpty,
reason: 'stale pool cannot revive a finished task',
);
expect(updates.last!.state, WorkRunState.finished);
},
);
test('MQTT finish wins over an earlier pending HTTP response', () async {
await tracker.load('MC700-test');
await flush();
tasks.pending = Completer<Either<Failure, List<DeviceTaskEntity>>>();
final loading = tracker.load('MC700-test');
await flush();
mqtt.status('FINISH');
tasks.pending!.complete(Right([FakeTasks.entity()]));
expect((await loading).data, isEmpty);
expect(tracker.current('MC700-test')!.state, WorkRunState.finished);
});
test(
'arrival points accumulate only during execution and empty arrival completes',
() async {
await tracker.load('MC700-test');
await flush();
mqtt.arrive({'lat': 31.1, 'lng': 120.1});
await flush();
expect(tracker.current('MC700-test')!.completedPoints, hasLength(1));
mqtt.status('PAUSE');
mqtt.arrive({'lat': 31.2, 'lng': 120.2});
await flush();
expect(tracker.current('MC700-test')!.completedPoints, hasLength(1));
mqtt.arrive(null);
await flush();
expect(tracker.current('MC700-test')!.state, WorkRunState.finished);
expect(
traceLogs.any(
(s) =>
s.contains('[结束消息收到]') &&
s.contains('"source":"mqtt_arrive_empty"') &&
s.contains('"entityPresent":true'),
),
isTrue,
);
expect(
traceLogs.any(
(s) => s.contains('[结束状态落地]') && s.contains('task/42/arrive'),
),
isTrue,
);
},
);
test('NEW状态延迟时接受有效到达点,保留原始状态与诊断链路', () async {
tasks.tasks = [FakeTasks.entity(state: 'NEW')];
await tracker.load('MC700-test');
await flush();
expect(
traceLogs.any(
(s) => s.contains('[订阅登记]') && s.contains('task/42/arrive'),
),
isTrue,
);
mqtt.arrive({'lat': 31.1, 'lng': 120.1});
await flush();
expect(tracker.current('MC700-test')!.completedPoints, hasLength(1));
expect(tracker.current('MC700-test')!.rawStatus, 'NEW');
expect(tracker.current('MC700-test')!.executionStarted, isTrue);
expect(tracker.current('MC700-test')!.canRecordTrace, isTrue);
expect(
traceLogs.any(
(s) =>
s.contains('[MQTT收到]') &&
s.contains('task/42/arrive') &&
s.contains('31.1'),
),
isTrue,
);
mqtt.status('EXECUTING');
mqtt.arrive({'lat': 31.1, 'lng': 120.1});
mqtt.arrive({'lat': 31.2, 'lng': 120.2});
await flush();
expect(tracker.current('MC700-test')!.completedPoints, hasLength(2));
expect(
traceLogs.any((s) => s.contains('[状态收到]') && s.contains('EXECUTING')),
isTrue,
);
expect(
traceLogs.any((s) => s.contains('[轨迹追加]') && s.contains('"points":2')),
isTrue,
);
expect(
traceLogs.any(
(s) => s.contains('[任务快照]') && s.contains('"selected":true'),
),
isTrue,
);
});
test('过滤日志说明 topic、ID、type、坐标和解析失败,拒绝消息不修改轨迹', () async {
tasks.tasks = [FakeTasks.entity(state: 'NEW')];
await tracker.load('MC700-test');
await flush();
final valid = {
'type': 'device_task_arrive_point',
'taskId': 42,
'deviceId': 'MC700-test',
'entity': {'lat': 31.1, 'lng': 120.1},
};
void send(Map<String, dynamic> data, {String topic = 'task/42/arrive'}) =>
mqtt.messages.add(MqttMessage(topic: topic, payload: jsonEncode(data)));
send(valid, topic: 'task/99/arrive');
send({...valid, 'taskId': 8});
send({...valid, 'deviceId': 'other'});
send({...valid, 'type': 'unexpected'});
send({
...valid,
'entity': {'lat': 0, 'lng': 120.1},
});
send({...valid, 'entity': 'not a point'});
mqtt.messages.add(
MqttMessage(topic: 'task/42/arrive', payload: 'invalid json'),
);
await flush();
expect(tracker.current('MC700-test')!.completedPoints, isEmpty);
expect(tracker.current('MC700-test')!.executionStarted, isFalse);
expect(tracker.current('MC700-test')!.canRecordTrace, isFalse);
for (final reason in [
'topic格式不符或不在当前订阅列表',
'消息taskId与topic任务不一致',
'deviceId不匹配',
'到达点type不匹配',
'坐标为空、非数值、零或超出范围',
'entity不是坐标对象',
'[解析失败]',
]) {
expect(traceLogs.any((s) => s.contains(reason)), isTrue, reason: reason);
}
mqtt.arrive({'lat': 31.1, 'lng': 120.1});
mqtt.arrive({'lat': 31.1, 'lng': 120.1});
await flush();
expect(tracker.current('MC700-test')!.completedPoints, hasLength(1));
expect(traceLogs.any((s) => s.contains('与上一轨迹点重复')), isTrue);
});
test('诊断采样只减少日志,连续到达点完整保留;日志写入异常也不影响轨迹', () async {
await tracker.load('MC700-test');
await flush();
for (var i = 0; i < 20; i++) {
mqtt.arrive({'lat': 31.1 + i * 0.00001, 'lng': 120.1});
}
await flush();
expect(tracker.current('MC700-test')!.completedPoints, hasLength(20));
expect(traceLogs.where((s) => s.contains('[MQTT收到]')), hasLength(1));
failTraceLogs = true;
mqtt.status('PAUSE');
expect(tracker.current('MC700-test')!.state, WorkRunState.paused);
mqtt.status('EXECUTING');
mqtt.arrive({'lat': 31.2, 'lng': 120.2});
await flush();
expect(tracker.current('MC700-test')!.completedPoints, hasLength(21));
expect(failedTraceWrites, greaterThan(0));
});
test(
'new task survives empty pool delay and a new watcher receives its state',
() async {
tasks.tasks = [];
final created = await tracker.start(
const WorkStartRequest(
deviceId: 'MC700-test',
route: WorkRouteOption(id: '8', name: 'route'),
),
);
await flush();
expect(created.data!.taskId, '100');
expect(created.data!.rawStatus, 'NEW');
expect(created.data!.executionStarted, isTrue);
expect(created.data!.canRecordTrace, isTrue);
expect(mqtt.topics, {'task/100/status', 'task/100/arrive'});
final secondUpdates = <WorkTaskSnapshot?>[];
final second = tracker.watch('MC700-test').listen(secondUpdates.add);
await flush();
expect(secondUpdates.single!.taskId, '100');
await second.cancel();
await flush();
expect(mqtt.topics, isNotEmpty);
expect(path.creations, 1);
final refreshed = await tracker.load('MC700-test');
expect(refreshed.data!.single.taskId, '100');
expect(tracker.current('MC700-test')!.taskId, '100');
},
);
test(
'pause resume stop use current task ID and stop cleans subscriptions',
() async {
await tracker.load('MC700-test');
await flush();
var current = tracker.current('MC700-test')!;
current = (await tracker.command('MC700-test', current, 'pause')).data!;
expect(current.state, WorkRunState.paused);
current = (await tracker.command('MC700-test', current, 'resume')).data!;
expect(current.state, WorkRunState.running);
current = (await tracker.command('MC700-test', current, 'cancel')).data!;
await flush();
expect(tasks.operatedTask, 42);
expect(tasks.force, isTrue);
expect(current.rawStatus, 'CANCELED');
expect(
traceLogs.any(
(s) => s.contains('[结束状态落地]') && s.contains('"source":"http_cancel"'),
),
isTrue,
);
expect(traceLogs.where((s) => s.contains('[结束消息收到]')), isEmpty);
expect(mqtt.topics, isEmpty);
expect(
(await tracker.command('MC700-test', current, 'resume')).isOk,
isFalse,
);
},
);
test('只选中NEW任务不记录GPS,确认开始复用任务后才记录', () async {
tasks.tasks = [FakeTasks.entity(state: 'NEW')];
await tracker.load('MC700-test');
final selected = await tracker.select(tracker.current('MC700-test')!);
expect(selected.data!.canRecordTrace, isFalse);
final started = await tracker.start(
const WorkStartRequest(
deviceId: 'MC700-test',
route: WorkRouteOption(id: '8', name: 'route'),
reuseTaskId: '42',
),
);
expect(started.data!.rawStatus, 'NEW');
expect(started.data!.executionStarted, isTrue);
expect(started.data!.canRecordTrace, isTrue);
expect(path.creations, 0);
await tracker.load('MC700-test');
expect(tracker.current('MC700-test')!.canRecordTrace, isTrue);
});
test('开始标记持久化,重进页面遇到NEW也不会丢掉轨迹接收权限', () async {
tasks.tasks = [FakeTasks.entity(state: 'NEW')];
await tracker.load('MC700-test');
await tracker.start(
const WorkStartRequest(
deviceId: 'MC700-test',
route: WorkRouteOption(id: '8', name: 'route'),
reuseTaskId: '42',
),
);
final stored = await V2WorkStore('test-user').read('tasks');
expect((stored!['current'] as List).single['executionStarted'], isTrue);
await sub.cancel();
await flush();
tracker = V2WorkTaskTracker(
repository: tasks,
createTask: CreateDeviceTaskUseCase(path),
resolveScope: (_) async => const WorkScope(siteId: 2, orgId: 3),
userId: () => 'test-user',
store: V2WorkStore('test-user'),
mqtt: mqtt,
diagnostics: WorkTraceDiagnostics(write: traceLogs.add),
);
sub = tracker.watch('MC700-test').listen(updates.add);
await tracker.load('MC700-test');
await flush();
expect(tracker.current('MC700-test')!.rawStatus, 'NEW');
expect(tracker.current('MC700-test')!.canRecordTrace, isTrue);
mqtt.arrive({'lat': 31.1, 'lng': 120.1});
expect(tracker.current('MC700-test')!.completedPoints, hasLength(1));
});
for (final status in ['EXECUTING', 'PAUSE', 'FAILED']) {
test('$status之后的延迟NEW不能回退执行状态或解除暂停', () async {
await tracker.load('MC700-test');
await flush();
mqtt.status(status);
mqtt.status('NEW');
expect(tracker.current('MC700-test')!.rawStatus, status);
tasks.tasks = [FakeTasks.entity(state: 'NEW')];
await tracker.load('MC700-test');
expect(tracker.current('MC700-test')!.rawStatus, status);
mqtt.arrive({'lat': 31.1, 'lng': 120.1});
expect(
tracker.current('MC700-test')!.completedPoints,
hasLength(status == 'EXECUTING' ? 1 : 0),
);
mqtt.status('EXECUTING');
mqtt.arrive({'lat': 31.2, 'lng': 120.2});
expect(tracker.current('MC700-test')!.canRecordTrace, isTrue);
mqtt.status('FINISH');
mqtt.status('NEW');
expect(tracker.current('MC700-test')!.state, WorkRunState.finished);
});
}
test('开始、NEW到达、实时车位、暂停与结束连通,GPS不写入任务到达点', () async {
tasks.tasks = [];
await tracker.start(
const WorkStartRequest(
deviceId: 'MC700-test',
route: WorkRouteOption(id: '8', name: 'route'),
),
);
await flush();
final trace = WorkTraceRecorder();
void sync() {
final current = tracker.current('MC700-test')!;
trace.synchronize(
deviceId: current.deviceId,
input: WorkTraceInput(
taskId: current.taskId,
recording: current.canRecordTrace,
visible: current.state != WorkRunState.finished,
completedPoints: current.completedPoints,
),
);
}
const a = LatLng(31.1, 120.1);
const b = LatLng(31.1001, 120.1001);
const c = LatLng(31.1002, 120.1002);
sync();
trace.addPosition(a);
trace.addPosition(b);
expect(trace.points, [a, b]);
mqtt.arrive({'lat': b.latitude, 'lng': b.longitude}, taskId: 100);
sync();
trace.addPosition(c);
expect(trace.points, [b, c]);
expect(tracker.current('MC700-test')!.completedPoints, [b]);
await tracker.command(
'MC700-test',
tracker.current('MC700-test')!,
'pause',
);
mqtt.status('NEW', taskId: 100);
sync();
expect(trace.addPosition(a), isFalse);
expect(trace.points, [b, c]);
mqtt.status('FINISH', taskId: 100);
sync();
expect(trace.points, isEmpty);
expect((await tracker.history('MC700-test')).single.completedPoints, [b]);
});
test('failed query cannot create or erase a running task', () async {
await tracker.load('MC700-test');
await flush();
tasks.error = 'offline';
expect((await tracker.load('MC700-test')).isOk, isFalse);
expect(tracker.current('MC700-test')!.taskId, '42');
expect(path.creations, 0);
});
test(
'V2 draft store serializes writes and isolates users from legacy keys',
() async {
final store = V2WorkStore('first');
final points = <int>[1, 2, 3];
final first = store.write('editor/2/device', {'points': points});
points.clear();
await first;
expect((await store.read('editor/2/device'))!['points'], [1, 2, 3]);
expect(await V2WorkStore('second').read('editor/2/device'), isNull);
await store.write('editor/2/device', null);
expect(await store.read('editor/2/device'), isNull);
},
);
test(
'missed FINISH push is recovered from task pool with its arrival points',
() async {
await tracker.load('MC700-test');
await flush();
mqtt.arrive({'lat': 31.1, 'lng': 120.1});
await flush();
tasks.tasks = [FakeTasks.entity(state: 'FINISH')];
expect((await tracker.load('MC700-test')).data, isEmpty);
await flush();
expect(updates.last!.rawStatus, 'FINISH');
expect(updates.last!.completedPoints, hasLength(1));
expect(
traceLogs.any(
(s) => s.contains('[结束任务池确认]') && s.contains('"source":"task_pool"'),
),
isTrue,
);
expect(
traceLogs.any(
(s) => s.contains('[结束状态落地]') && s.contains('"source":"task_pool"'),
),
isTrue,
);
expect(traceLogs.where((s) => s.contains('[结束消息收到]')), isEmpty);
},
);
test(
'queue receives other task completion without replacing current task',
() async {
tasks.tasks = [
FakeTasks.entity(),
FakeTasks.entity(id: 43, state: 'NEW'),
];
final changes = <WorkTaskSnapshot?>[];
final queue = tracker
.watch('MC700-test', allTasks: true)
.listen(changes.add);
await tracker.load('MC700-test');
await flush();
mqtt.status('FINISH', taskId: 43);
await flush();
expect(changes.last!.taskId, '43');
expect(changes.last!.state, WorkRunState.finished);
expect(updates.last!.taskId, '42');
expect(updates.last!.state, WorkRunState.running);
expect(
traceLogs.any(
(s) =>
s.contains('[结束状态落地]') &&
s.contains('"taskId":"43"') &&
s.contains('"selected":false'),
),
isTrue,
);
expect(mqtt.topics, {'task/42/status', 'task/42/arrive'});
await queue.cancel();
},
);
for (final status in ['CANCELED', 'CANCELLED']) {
test('$status结束信号独立记录并保留原有结束处理', () async {
await tracker.load('MC700-test');
await flush();
mqtt.status(status);
await flush();
expect(tracker.current('MC700-test')!.state, WorkRunState.finished);
expect(
traceLogs.any((s) => s.contains('[结束消息收到]') && s.contains(status)),
isTrue,
);
expect(
traceLogs.any(
(s) => s.contains('[结束状态落地]') && s.contains('"source":"mqtt_status"'),
),
isTrue,
);
expect(mqtt.topics, isEmpty);
});
}
test('结束消息过滤记录ID不匹配及重复原因,不重复完成任务', () async {
await tracker.load('MC700-test');
await flush();
mqtt.status('FINISH', deviceId: 'other-device');
mqtt.status('FINISH', taskId: 99, topicId: 42);
expect(tracker.current('MC700-test')!.state, WorkRunState.running);
expect(
traceLogs.any((s) => s.contains('[结束消息过滤]') && s.contains('deviceId不匹配')),
isTrue,
);
expect(
traceLogs.any(
(s) => s.contains('[结束消息过滤]') && s.contains('消息taskId与topic任务不一致'),
),
isTrue,
);
mqtt.status('FINISH');
mqtt.status('FINISH');
await flush();
expect(
traceLogs.any(
(s) => s.contains('[结束消息过滤]') && s.contains('任务已结束,忽略重复或迟到消息'),
),
isTrue,
);
expect(traceLogs.where((s) => s.contains('[结束状态落地]')), hasLength(1));
expect(
updates.where((t) => t?.state == WorkRunState.finished),
hasLength(1),
);
expect(await tracker.history('MC700-test'), hasLength(1));
});
test('空到达点日志记录entity是否缺失,保持原有结束语义', () async {
await tracker.load('MC700-test');
await flush();
mqtt.messages.add(
MqttMessage(
topic: 'task/42/arrive',
payload: jsonEncode({
'type': 'device_task_arrive_point',
'deviceId': 'MC700-test',
'taskId': 42,
}),
),
);
await flush();
expect(tracker.current('MC700-test')!.state, WorkRunState.finished);
expect(
traceLogs.any(
(s) => s.contains('[结束消息收到]') && s.contains('"entityPresent":false'),
),
isTrue,
);
});
test('未收到结束消息时定期说明等待状态,不能把消息静默当成已结束', () async {
await tracker.load('MC700-test');
await flush();
expect(mqtt.isConnected, isTrue);
// 真实异步订阅与计时器共用同一时钟,验证生产中的10秒诊断周期。
await Future<void>.delayed(const Duration(seconds: 11));
expect(tracker.current('MC700-test')!.state, WorkRunState.running);
expect(
traceLogs.any(
(s) =>
s.contains('[结束确认等待]') &&
s.contains('task/42/status') &&
s.contains('"statusReceived":0'),
),
isTrue,
);
expect(traceLogs.where((s) => s.contains('[结束状态落地]')), isEmpty);
await sub.cancel();
await flush();
expect(mqtt.topics, isEmpty);
});
test(
'last page exit unsubscribes and reentry resubscribes the active task',
() async {
await tracker.load('MC700-test');
await flush();
await sub.cancel();
await flush();
expect(mqtt.topics, isEmpty);
expect(mqtt.isConnected, isFalse);
sub = tracker.watch('MC700-test').listen(updates.add);
await tracker.load('MC700-test');
await flush();
expect(mqtt.topics, {'task/42/status', 'task/42/arrive'});
mqtt.status('FINISH');
await flush();
expect(updates.last!.state, WorkRunState.finished);
},
);
}