240 lines
8.1 KiB
Dart
240 lines
8.1 KiB
Dart
|
|
import 'dart:async';
|
||
|
|
|
||
|
|
import 'package:flutter/material.dart';
|
||
|
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||
|
|
import 'package:flutter_test/flutter_test.dart';
|
||
|
|
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
|
||
|
|
import 'package:maibu_satabot_v2/features/autonomous_work/domain/entities/work_session_models.dart';
|
||
|
|
import 'package:maibu_satabot_v2/features/autonomous_work/domain/repositories/autonomous_work_gateway.dart';
|
||
|
|
import 'package:maibu_satabot_v2/features/v2/device_list/presentation/widgets/device_task_control_tile.dart';
|
||
|
|
|
||
|
|
import 'support/region_test_localizations.dart';
|
||
|
|
|
||
|
|
WorkTaskSnapshot task({
|
||
|
|
String id = '42',
|
||
|
|
String device = 'SN-1',
|
||
|
|
WorkRunState state = WorkRunState.running,
|
||
|
|
String rawStatus = '',
|
||
|
|
}) => WorkTaskSnapshot(
|
||
|
|
taskId: id,
|
||
|
|
deviceId: device,
|
||
|
|
state: state,
|
||
|
|
rawStatus: rawStatus,
|
||
|
|
routeName: 'Route $id',
|
||
|
|
);
|
||
|
|
|
||
|
|
class FakeGateway implements AutonomousWorkGateway, WorkTaskQueueSource {
|
||
|
|
List<WorkTaskSnapshot> tasks = [task()];
|
||
|
|
final changes = StreamController<WorkTaskSnapshot?>.broadcast(sync: true);
|
||
|
|
final calls = <String>[];
|
||
|
|
bool loadFails = false;
|
||
|
|
bool commandFails = false;
|
||
|
|
int loads = 0;
|
||
|
|
Completer<WorkResult<WorkTaskSnapshot>>? pending;
|
||
|
|
|
||
|
|
@override
|
||
|
|
WorkSceneCapabilities get capabilities => const WorkSceneCapabilities();
|
||
|
|
|
||
|
|
@override
|
||
|
|
Stream<WorkTaskSnapshot?> watchTaskChanges({required String deviceId}) =>
|
||
|
|
changes.stream;
|
||
|
|
|
||
|
|
@override
|
||
|
|
Future<WorkResult<List<WorkTaskSnapshot>>> loadTasks({
|
||
|
|
required String deviceId,
|
||
|
|
}) async {
|
||
|
|
loads++;
|
||
|
|
return loadFails
|
||
|
|
? const WorkResult.fail('offline')
|
||
|
|
: WorkResult.ok([...tasks]);
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<WorkResult<WorkTaskSnapshot>> command(
|
||
|
|
String deviceId,
|
||
|
|
WorkTaskSnapshot selected,
|
||
|
|
bool resume,
|
||
|
|
) async {
|
||
|
|
calls.add('${resume ? 'resume' : 'pause'}/$deviceId/${selected.taskId}');
|
||
|
|
if (pending != null) return pending!.future;
|
||
|
|
if (commandFails) return const WorkResult.fail('command failed');
|
||
|
|
final updated = selected.copyWith(
|
||
|
|
state: resume ? WorkRunState.running : WorkRunState.paused,
|
||
|
|
);
|
||
|
|
tasks = [
|
||
|
|
for (final t in tasks)
|
||
|
|
if (t.taskId == updated.taskId) updated else t,
|
||
|
|
];
|
||
|
|
changes.add(updated);
|
||
|
|
return WorkResult.ok(updated);
|
||
|
|
}
|
||
|
|
|
||
|
|
@override
|
||
|
|
Future<WorkResult<WorkTaskSnapshot>> pauseWork({
|
||
|
|
required String deviceId,
|
||
|
|
required WorkTaskSnapshot task,
|
||
|
|
}) => command(deviceId, task, false);
|
||
|
|
|
||
|
|
@override
|
||
|
|
Future<WorkResult<WorkTaskSnapshot>> resumeWork({
|
||
|
|
required String deviceId,
|
||
|
|
required WorkTaskSnapshot task,
|
||
|
|
}) => command(deviceId, task, true);
|
||
|
|
|
||
|
|
@override
|
||
|
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||
|
|
}
|
||
|
|
|
||
|
|
void main() {
|
||
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||
|
|
setUpAll(RegionTestLocalizations.loadAssets);
|
||
|
|
|
||
|
|
Future<void> mount(WidgetTester tester, FakeGateway gateway) async {
|
||
|
|
addTearDown(gateway.changes.close);
|
||
|
|
await tester.pumpWidget(
|
||
|
|
MaterialApp(
|
||
|
|
locale: AppLocalizations.chinese,
|
||
|
|
supportedLocales: AppLocalizations.supportedLocales,
|
||
|
|
localizationsDelegates: const [
|
||
|
|
RegionTestLocalizations(),
|
||
|
|
GlobalMaterialLocalizations.delegate,
|
||
|
|
GlobalWidgetsLocalizations.delegate,
|
||
|
|
GlobalCupertinoLocalizations.delegate,
|
||
|
|
],
|
||
|
|
home: Scaffold(
|
||
|
|
body: SizedBox(
|
||
|
|
width: 160,
|
||
|
|
child: DeviceTaskControlTile(deviceId: 'SN-1', gateway: gateway),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
await tester.pumpAndSettle();
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<void> open(WidgetTester tester) async {
|
||
|
|
await tester.tap(find.byType(DeviceTaskControlTile));
|
||
|
|
await tester.pumpAndSettle();
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<void> confirm(WidgetTester tester, String label) async {
|
||
|
|
await tester.tap(find.text(label));
|
||
|
|
await tester.pump();
|
||
|
|
await tester.pump(const Duration(milliseconds: 300));
|
||
|
|
}
|
||
|
|
|
||
|
|
Color? background(WidgetTester tester) =>
|
||
|
|
(tester
|
||
|
|
.widget<Container>(
|
||
|
|
find.byKey(
|
||
|
|
const ValueKey('device-task-control-background'),
|
||
|
|
),
|
||
|
|
)
|
||
|
|
.decoration
|
||
|
|
as BoxDecoration)
|
||
|
|
.color;
|
||
|
|
|
||
|
|
testWidgets('pause turns yellow and next action resumes the same task', (
|
||
|
|
tester,
|
||
|
|
) async {
|
||
|
|
final gateway = FakeGateway();
|
||
|
|
await mount(tester, gateway);
|
||
|
|
await open(tester);
|
||
|
|
expect(gateway.calls, isEmpty);
|
||
|
|
await confirm(tester, '确认暂停');
|
||
|
|
expect(gateway.calls, ['pause/SN-1/42']);
|
||
|
|
expect(background(tester), const Color(0xFFFFF3CD));
|
||
|
|
expect(find.text('恢复任务'), findsOneWidget);
|
||
|
|
await open(tester);
|
||
|
|
await confirm(tester, '确认继续作业');
|
||
|
|
expect(gateway.calls, ['pause/SN-1/42', 'resume/SN-1/42']);
|
||
|
|
expect(background(tester), Colors.white);
|
||
|
|
expect(gateway.loads, 5);
|
||
|
|
});
|
||
|
|
|
||
|
|
testWidgets('other devices and ended tasks cannot be operated', (
|
||
|
|
tester,
|
||
|
|
) async {
|
||
|
|
final gateway = FakeGateway()
|
||
|
|
..tasks = [task(device: 'SN-other'), task(state: WorkRunState.finished)];
|
||
|
|
await mount(tester, gateway);
|
||
|
|
await open(tester);
|
||
|
|
expect(find.text('当前没有任务'), findsOneWidget);
|
||
|
|
expect(gateway.calls, isEmpty);
|
||
|
|
});
|
||
|
|
|
||
|
|
testWidgets('task ends while confirming: no pause is sent', (tester) async {
|
||
|
|
final gateway = FakeGateway();
|
||
|
|
await mount(tester, gateway);
|
||
|
|
await open(tester);
|
||
|
|
gateway.tasks = [];
|
||
|
|
gateway.changes.add(task(state: WorkRunState.finished));
|
||
|
|
await confirm(tester, '确认暂停');
|
||
|
|
expect(find.text('当前没有任务'), findsOneWidget);
|
||
|
|
expect(gateway.calls, isEmpty);
|
||
|
|
expect(background(tester), Colors.white);
|
||
|
|
});
|
||
|
|
|
||
|
|
testWidgets('pool error is distinct from no task and cannot send commands', (
|
||
|
|
tester,
|
||
|
|
) async {
|
||
|
|
final gateway = FakeGateway()..loadFails = true;
|
||
|
|
await mount(tester, gateway);
|
||
|
|
await open(tester);
|
||
|
|
expect(find.text('任务池查询失败,请稍后重试。'), findsOneWidget);
|
||
|
|
expect(gateway.calls, isEmpty);
|
||
|
|
});
|
||
|
|
|
||
|
|
testWidgets('failed resume preserves yellow paused state', (tester) async {
|
||
|
|
final gateway = FakeGateway()
|
||
|
|
..tasks = [task(state: WorkRunState.paused)]
|
||
|
|
..commandFails = true;
|
||
|
|
await mount(tester, gateway);
|
||
|
|
expect(background(tester), const Color(0xFFFFF3CD));
|
||
|
|
await open(tester);
|
||
|
|
await confirm(tester, '确认继续作业');
|
||
|
|
expect(find.text('恢复失败,请重试。'), findsOneWidget);
|
||
|
|
expect(background(tester), const Color(0xFFFFF3CD));
|
||
|
|
});
|
||
|
|
|
||
|
|
testWidgets('multiple tasks require an explicit selection', (tester) async {
|
||
|
|
final gateway = FakeGateway()..tasks = [task(), task(id: '43')];
|
||
|
|
await mount(tester, gateway);
|
||
|
|
await open(tester);
|
||
|
|
expect(find.text('选择要暂停或恢复的任务'), findsOneWidget);
|
||
|
|
await tester.tap(find.text('Route 43'));
|
||
|
|
await tester.pumpAndSettle();
|
||
|
|
await confirm(tester, '确认暂停');
|
||
|
|
expect(gateway.calls, ['pause/SN-1/43']);
|
||
|
|
expect(background(tester), const Color(0xFFFFF3CD));
|
||
|
|
await open(tester);
|
||
|
|
expect(find.text('选择要暂停或恢复的任务'), findsNothing);
|
||
|
|
await confirm(tester, '确认继续作业');
|
||
|
|
expect(gateway.calls, ['pause/SN-1/43', 'resume/SN-1/43']);
|
||
|
|
});
|
||
|
|
|
||
|
|
testWidgets('pending command blocks duplicate taps and terminal event wins', (
|
||
|
|
tester,
|
||
|
|
) async {
|
||
|
|
final gateway = FakeGateway()..pending = Completer();
|
||
|
|
await mount(tester, gateway);
|
||
|
|
await open(tester);
|
||
|
|
await confirm(tester, '确认暂停');
|
||
|
|
await tester.tap(find.byType(DeviceTaskControlTile));
|
||
|
|
await tester.pump();
|
||
|
|
expect(gateway.calls, ['pause/SN-1/42']);
|
||
|
|
gateway.changes.add(task(state: WorkRunState.finished));
|
||
|
|
gateway.pending!.complete(WorkResult.ok(task(state: WorkRunState.paused)));
|
||
|
|
await tester.pumpAndSettle();
|
||
|
|
expect(background(tester), Colors.white);
|
||
|
|
expect(find.text('任务暂停'), findsOneWidget);
|
||
|
|
});
|
||
|
|
|
||
|
|
testWidgets('queued task cannot be paused', (tester) async {
|
||
|
|
final gateway = FakeGateway()..tasks = [task(state: WorkRunState.queued)];
|
||
|
|
await mount(tester, gateway);
|
||
|
|
await open(tester);
|
||
|
|
expect(find.text('当前任务尚未执行或正在结束,暂不能暂停或恢复。'), findsOneWidget);
|
||
|
|
expect(gateway.calls, isEmpty);
|
||
|
|
});
|
||
|
|
}
|