Files
flutterApp/test/work_home_task_test.dart

543 lines
21 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 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fpdart/fpdart.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart';
import 'package:maibu_satabot_v2/core/di/injection.dart';
import 'package:maibu_satabot_v2/core/domain/entities/user_entity.dart';
import 'package:maibu_satabot_v2/core/error/failure.dart';
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
import 'package:maibu_satabot_v2/core/logging/i_logger_service.dart';
import 'package:maibu_satabot_v2/features/devices/data/datasources/device_task_datasource.dart';
import 'package:maibu_satabot_v2/features/devices/data/repositories/device_task_repository_impl.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/usecases/get_device_task_pool_usecase.dart';
import 'package:maibu_satabot_v2/features/devices/domain/usecases/save_device_schedule_plan_usecase.dart';
import 'package:maibu_satabot_v2/features/devices/presentation/bloc/devices_cubit.dart';
import 'package:maibu_satabot_v2/features/devices/presentation/bloc/devices_state.dart';
import 'package:maibu_satabot_v2/features/v2/home/domain/entities/site_entity.dart';
import 'package:maibu_satabot_v2/features/v2/device_list/presentation/bloc/robot_list_bloc.dart';
import 'package:maibu_satabot_v2/features/v2/device_list/presentation/pages/robot_autonomous_work_page_v2.dart';
import 'package:maibu_satabot_v2/features/v2/site/presentation/cubit/site_cubit.dart';
import 'package:maibu_satabot_v2/features/v2/work/presentation/cubit/work_home_task_cubit.dart';
import 'package:maibu_satabot_v2/features/v2/work/presentation/cubit/schedule_plan_create_cubit.dart';
import 'package:maibu_satabot_v2/features/v2/work/presentation/pages/work_plan_create_page_v2.dart';
import 'package:maibu_satabot_v2/features/v2/work/presentation/pages/work_plan_page_v2.dart';
import 'package:maibu_satabot_v2/features/v2/work/presentation/pages/work_home_page_v2.dart';
class _Tasks implements DeviceTaskRepository {
final requests = <(String, int, int)>[];
Future<Either<Failure, List<DeviceTaskEntity>>> Function(int siteId)
response = (_) async => const Right([]);
@override
Future<Either<Failure, List<DeviceTaskEntity>>> getDeviceTaskPool({
required String userId,
required int siteId,
required int orgId,
int pageNum = 1,
int pageSize = 99999999,
}) {
requests.add((userId, siteId, orgId));
return response(siteId);
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
DeviceTaskEntity task(
int id,
String status, {
String? device,
int site = 18,
int org = 6,
}) => DeviceTaskEntity(
id: id,
deviceId: device ?? 'SN$id',
taskStatus: status,
taskStatusTranslate: '',
siteId: site,
orgId: org,
routeId: 256,
);
class _Devices extends Cubit<DevicesState> implements DevicesCubit {
_Devices() : super(const DevicesState());
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _Logger implements ILoggerService {
@override
void logWithLevel(
String message, {
String level = 'INFO',
Map<String, dynamic>? data,
bool shouldLog = false,
}) {}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _LocaleDelegate extends LocalizationsDelegate<AppLocalizations> {
const _LocaleDelegate(this.value);
final AppLocalizations value;
@override
bool isSupported(Locale locale) => true;
@override
Future<AppLocalizations> load(Locale locale) => SynchronousFuture(value);
@override
bool shouldReload(_LocaleDelegate old) => old.value != value;
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('场站任务池状态', () {
late _Tasks repository;
late WorkHomeTaskCubit cubit;
setUp(() {
repository = _Tasks();
cubit = WorkHomeTaskCubit(GetDeviceTaskPoolUseCase(repository));
});
tearDown(() => cubit.close());
test('单次查询包含整个场站设备,排除终态/跨场站/重复记录并按状态排序', () async {
repository.response = (_) async => Right([
task(1, 'NEW'),
task(2, 'EXECUTING'),
task(3, 'PAUSE'),
task(4, 'FAILED'),
task(5, 'CANCELING'),
task(6, 'FINISH'),
task(7, 'CANCELED'),
task(8, 'CANCELLED'),
task(9, 'EXECUTING', site: 99),
task(10, 'NEW', org: 99),
task(2, 'EXECUTING'),
task(11, ' new '),
]);
await cubit.load(userId: 'u1', siteId: 18, orgId: 6);
expect(repository.requests, [('u1', 18, 6)]);
expect(cubit.state.status, WorkHomeTaskStatus.ready);
expect(cubit.state.tasks.map((t) => t.id), [2, 3, 4, 5, 11, 1]);
expect(cubit.state.runningCount, 1);
expect(cubit.state.pendingCount, 2);
});
test('切换场站立即清除旧数据,忽略迟到的旧场站响应', () async {
final oldResponse = Completer<Either<Failure, List<DeviceTaskEntity>>>();
repository.response = (site) => site == 18
? oldResponse.future
: Future.value(Right([task(20, 'NEW', site: 19)]));
final old = cubit.load(userId: 'u1', siteId: 18, orgId: 6);
final current = cubit.load(userId: 'u1', siteId: 19, orgId: 6);
expect(cubit.state.tasks, isEmpty);
await current;
oldResponse.complete(Right([task(1, 'EXECUTING')]));
await old;
expect(cubit.state.tasks.single.id, 20);
expect(cubit.state.pendingCount, 1);
expect(cubit.state.runningCount, 0);
});
test('并发刷新复用同一请求,退出登录后不回填旧任务', () async {
final response = Completer<Either<Failure, List<DeviceTaskEntity>>>();
repository.response = (_) => response.future;
final first = cubit.load(userId: 'u1', siteId: 18, orgId: 6);
final second = cubit.load(userId: 'u1', siteId: 18, orgId: 6);
expect(repository.requests, hasLength(1));
await cubit.load(userId: null, siteId: 18, orgId: 6);
response.complete(Right([task(1, 'EXECUTING')]));
await Future.wait([first, second]);
expect(cubit.state.status, WorkHomeTaskStatus.noUser);
expect(cubit.state.tasks, isEmpty);
});
test('未选择场站不发送请求,接口失败可重试,空列表才显示真实的零', () async {
await cubit.load(userId: 'u1', siteId: null, orgId: null);
expect(repository.requests, isEmpty);
expect(cubit.state.status, WorkHomeTaskStatus.noSite);
repository.response = (_) async => Left(Failure('网络失败'));
await cubit.load(userId: 'u1', siteId: 18, orgId: 6);
expect(cubit.state.status, WorkHomeTaskStatus.failure);
repository.response = (_) async => const Right([]);
await cubit.load(userId: 'u1', siteId: 18, orgId: 6);
expect(cubit.state.status, WorkHomeTaskStatus.ready);
expect(cubit.state.runningCount, 0);
expect(cubit.state.pendingCount, 0);
});
test('同场站刷新保留内容,相同任务的新对象或返回顺序变化均不通知渲染', () async {
repository.response = (_) async =>
Right([task(1, 'NEW'), task(2, 'EXECUTING')]);
await cubit.load(userId: 'u1', siteId: 18, orgId: 6);
final original = cubit.state;
final events = <WorkHomeTaskState>[];
final subscription = cubit.stream.listen(events.add);
addTearDown(subscription.cancel);
final response = Completer<Either<Failure, List<DeviceTaskEntity>>>();
repository.response = (_) => response.future;
final refresh = cubit.load(userId: 'u1', siteId: 18, orgId: 6);
expect(identical(cubit.state, original), isTrue);
response.complete(Right([task(2, 'EXECUTING'), task(1, 'NEW')]));
await refresh;
await Future<void>.delayed(Duration.zero);
expect(identical(cubit.state, original), isTrue);
expect(events, isEmpty);
});
test('任务内容变化只发布一次 ready,真实空列表也能替换旧任务', () async {
repository.response = (_) async => Right([task(1, 'NEW')]);
await cubit.load(userId: 'u1', siteId: 18, orgId: 6);
final events = <WorkHomeTaskState>[];
final subscription = cubit.stream.listen(events.add);
addTearDown(subscription.cancel);
repository.response = (_) async => Right([task(1, 'EXECUTING')]);
await cubit.load(userId: 'u1', siteId: 18, orgId: 6);
await Future<void>.delayed(Duration.zero);
expect(events.map((s) => s.status), [WorkHomeTaskStatus.ready]);
expect(cubit.state.runningCount, 1);
expect(cubit.state.pendingCount, 0);
repository.response = (_) async => const Right([]);
await cubit.load(userId: 'u1', siteId: 18, orgId: 6);
expect(cubit.state.tasks, isEmpty);
expect(cubit.state.runningCount, 0);
});
test('刷新失败保留上次任务和计数,成功后清除刷新错误', () async {
repository.response = (_) async => Right([task(1, 'EXECUTING')]);
await cubit.load(userId: 'u1', siteId: 18, orgId: 6);
repository.response = (_) async => Left(Failure('临时断网'));
await cubit.load(userId: 'u1', siteId: 18, orgId: 6);
expect(cubit.state.status, WorkHomeTaskStatus.ready);
expect(cubit.state.runningCount, 1);
expect(cubit.state.tasks.single.id, 1);
expect(cubit.state.error, '临时断网');
repository.response = (_) async => Right([task(1, 'EXECUTING')]);
await cubit.load(userId: 'u1', siteId: 18, orgId: 6);
expect(cubit.state.error, isNull);
});
test('已有数据时切换账号仍立即清除任务,忽略旧账号刷新结果', () async {
repository.response = (_) async => Right([task(1, 'EXECUTING')]);
await cubit.load(userId: 'u1', siteId: 18, orgId: 6);
final old = Completer<Either<Failure, List<DeviceTaskEntity>>>();
repository.response = (_) => old.future;
final refresh = cubit.load(userId: 'u1', siteId: 18, orgId: 6);
repository.response = (_) async => const Right([]);
final switched = cubit.load(userId: 'u2', siteId: 18, orgId: 6);
expect(cubit.state.status, WorkHomeTaskStatus.loading);
expect(cubit.state.tasks, isEmpty);
await switched;
old.complete(Right([task(1, 'EXECUTING')]));
await refresh;
expect(cubit.state.status, WorkHomeTaskStatus.ready);
expect(cubit.state.tasks, isEmpty);
});
});
group('作业首页真实查询链路', () {
late Dio dio;
late SiteCubit sites;
late AppUserCubit users;
late _Devices devices;
late List<RequestOptions> requests;
late Map<String, dynamic> response;
setUp(() async {
SharedPreferences.setMockInitialValues({});
sites = SiteCubit(await SharedPreferences.getInstance());
sites.updateSites(const [
SiteEntity(id: 18, siteName: '测试场站', orgId: 6, status: 0),
]);
users = AppUserCubit()
..setAuth(
const UserEntity(
userId: 'u1',
username: 'test',
nickname: '',
token: 'test',
orgId: 999,
),
);
devices = _Devices();
requests = [];
response = {
'code': 200,
'rows': [
for (final t in [
task(1, 'EXECUTING'),
task(2, 'NEW'),
task(3, 'PAUSE'),
task(4, 'FAILED'),
task(5, 'FINISH'),
])
{
'id': t.id,
'deviceId': t.deviceId,
'routeId': t.routeId,
'siteId': 18,
'orgId': 6,
if (t.id == 2)
'taskStatus': t.taskStatus
else
'taskStaus': t.taskStatus,
},
],
};
dio = Dio()
..interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) {
requests.add(options);
handler.resolve(
Response(
requestOptions: options,
statusCode: 200,
data: options.method == 'GET'
? {
'code': 200,
'rows': [
{'deviceName': 'SN1', 'deviceId': 'robot-1'},
],
}
: response,
),
);
},
),
);
sl.registerSingleton<SiteCubit>(sites);
sl.registerSingleton<AppUserCubit>(users);
sl.registerSingleton<DevicesCubit>(devices);
sl.registerSingleton<ILoggerService>(_Logger());
sl.registerFactory<RobotListBloc>(() => RobotListBloc(dio));
sl.registerFactory<SchedulePlanCreateCubit>(
() => SchedulePlanCreateCubit(
SaveDeviceSchedulePlanUseCase(
DeviceTaskRepositoryImpl(datasource: DeviceTaskDatasourceImpl(dio)),
),
),
);
sl.registerFactory<WorkHomeTaskCubit>(
() => WorkHomeTaskCubit(
GetDeviceTaskPoolUseCase(
DeviceTaskRepositoryImpl(datasource: DeviceTaskDatasourceImpl(dio)),
),
),
);
});
tearDown(() async {
await sites.close();
await users.close();
await devices.close();
dio.close();
await sl.reset();
});
Future<void> mount(WidgetTester tester, {bool active = true}) async {
final l10n = await tester.runAsync(() async {
final l10n = AppLocalizations(const Locale('zh', 'CN'));
await l10n.load();
return l10n;
});
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: [_LocaleDelegate(l10n!)],
home: WorkHomePageV2(isActive: active),
),
);
addTearDown(() async {
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump();
});
await tester.pumpAndSettle();
}
String stat(WidgetTester tester, String label) =>
tester.widget<Text>(find.byKey(ValueKey('work_stat_$label'))).data!;
testWidgets('20秒轮询无变化不重建页面,有任务变化才更新统计', (tester) async {
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await mount(tester);
final original = tester.widget(
find.byKey(const ValueKey('work_task_SN1_1')),
);
await tester.pump(const Duration(seconds: 20));
await tester.pumpAndSettle();
expect(requests, hasLength(2));
expect(
identical(
tester.widget(find.byKey(const ValueKey('work_task_SN1_1'))),
original,
),
isTrue,
);
expect(find.byType(CircularProgressIndicator), findsNothing);
response = {'code': 200, 'rows': []};
await tester.pump(const Duration(seconds: 20));
await tester.pumpAndSettle();
expect(requests, hasLength(3));
expect(stat(tester, '执行中'), '0');
expect(find.text('暂无作业'), findsOneWidget);
});
testWidgets('轮询失败保留任务与统计,用图标提示错误;恢复后移除提示', (tester) async {
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await mount(tester);
response = {'code': 500, 'msg': '任务池暂时不可用'};
await tester.pump(const Duration(seconds: 20));
await tester.pumpAndSettle();
expect(stat(tester, '执行中'), '1');
expect(find.text('任务 #1'), findsOneWidget);
expect(find.byKey(const ValueKey('work_refresh_error')), findsOneWidget);
expect(find.text('任务加载失败'), findsNothing);
response = {'code': 200, 'rows': []};
await tester.pump(const Duration(seconds: 20));
await tester.pumpAndSettle();
expect(stat(tester, '执行中'), '0');
expect(find.byKey(const ValueKey('work_refresh_error')), findsNothing);
});
testWidgets('下发作业计划直接进入新建页,保留原列表页但不经过它', (tester) async {
await mount(tester);
await tester.ensureVisible(find.text('下发作业计划'));
await tester.tap(find.text('下发作业计划'));
await tester.pumpAndSettle();
expect(find.byType(WorkPlanCreatePageV2), findsOneWidget);
expect(find.byType(WorkPlanPageV2), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('按当前用户和场站组织查询,统计和所有任务来自同一返回列表', (tester) async {
await mount(tester);
expect(requests, hasLength(1));
expect(requests.single.method, 'POST');
expect(requests.single.uri.path, '/iot/deviceTask/deviceTaskPool');
expect(requests.single.data, {
'userId': 'u1',
'siteId': 18,
'orgId': 6,
'pageNum': 1,
'pageSize': 99999999,
});
expect(stat(tester, '执行中'), '1');
expect(stat(tester, '待执行'), '1');
expect(stat(tester, '今日已完成'), '—');
expect(find.text('任务 #1'), findsOneWidget);
expect(find.text('任务 #3'), findsOneWidget);
expect(find.text('任务 #5'), findsNothing);
expect(find.text('68%'), findsNothing);
await tester.ensureVisible(find.text('全部任务'));
await tester.tap(find.text('全部任务'));
await tester.pumpAndSettle();
expect(find.text('执行设备:SN2'), findsOneWidget);
expect(find.text('任务 #5'), findsNothing);
expect(requests, hasLength(1));
expect(tester.takeException(), isNull);
});
testWidgets('点待执行统计只显示 NEW 任务,失败/暂停不混入', (tester) async {
await mount(tester);
await tester.tap(find.text('待执行'));
await tester.pumpAndSettle();
final sheet = find.byType(BottomSheet);
expect(
find.descendant(of: sheet, matching: find.text('任务 #2')),
findsOneWidget,
);
expect(
find.descendant(of: sheet, matching: find.text('任务 #3')),
findsNothing,
);
expect(tester.takeException(), isNull);
});
testWidgets('任务设备不在场站列表时提示,不误用列表中的第一台设备', (tester) async {
await mount(tester);
await tester.tap(find.text('待执行'));
await tester.pumpAndSettle();
final card = find.byKey(const ValueKey('work_task_SN2_2'));
final button = find.descendant(of: card, matching: find.text('查看任务'));
await tester.ensureVisible(button);
await tester.tap(button);
await tester.pumpAndSettle();
expect(requests.last.method, 'GET');
expect(requests.last.queryParameters['siteId'], 18);
expect(find.text('当前场站设备列表中未找到对应设备'), findsOneWidget);
expect(find.byType(RobotAutonomousWorkPageV2), findsNothing);
expect(tester.takeException(), isNull);
await tester.pump(const Duration(seconds: 3));
});
testWidgets('请求失败显示重试,成功空列表显示零和空态', (tester) async {
response = {'code': 500, 'msg': '任务池不可用'};
await mount(tester);
expect(stat(tester, '执行中'), '—');
expect(find.text('任务加载失败'), findsOneWidget);
response = {'code': 200, 'rows': []};
await tester.ensureVisible(find.text('重新加载'));
await tester.tap(find.text('重新加载'));
await tester.pumpAndSettle();
expect(stat(tester, '执行中'), '0');
expect(stat(tester, '待执行'), '0');
expect(find.text('暂无作业'), findsOneWidget);
expect(requests, hasLength(2));
});
testWidgets('切换场站触发新查询,返回列表替换旧任务', (tester) async {
await mount(tester);
response = {
'code': 200,
'rows': [
{
'id': 10,
'deviceId': 'other',
'taskStaus': 'NEW',
'siteId': 19,
'orgId': 7,
},
],
};
sites.updateSites(const [
SiteEntity(id: 19, siteName: '另一场站', orgId: 7, status: 0),
]);
await tester.pumpAndSettle();
expect(requests, hasLength(2));
expect(requests.last.data['siteId'], 19);
expect(requests.last.data['orgId'], 7);
expect(stat(tester, '执行中'), '0');
expect(find.text('任务 #1'), findsNothing);
expect(find.text('任务 #10'), findsOneWidget);
});
testWidgets('非当前 tab 不查询,激活后加载;退出登录清空任务', (tester) async {
await mount(tester, active: false);
expect(requests, isEmpty);
final app = tester.widget<MaterialApp>(find.byType(MaterialApp));
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: app.localizationsDelegates,
home: const WorkHomePageV2(isActive: true),
),
);
await tester.pumpAndSettle();
expect(requests, hasLength(1));
users.clearAuth();
await tester.pumpAndSettle();
expect(find.text('任务 #1'), findsNothing);
expect(find.text('请先登录后查看作业任务'), findsOneWidget);
expect(stat(tester, '执行中'), '—');
expect(requests, hasLength(1));
});
});
}