Files
flutterApp/test/region_editor_test.dart
2026-11-06 08:44:05 +08:00

397 lines
14 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/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:latlong2/latlong.dart';
import 'package:maibu_satabot_v2/core/logging/i_logger_service.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/regions/data/datasources/region_remote_datasource.dart';
import 'package:maibu_satabot_v2/features/regions/data/repositories/region_repository_impl.dart';
import 'package:maibu_satabot_v2/features/regions/domain/entities/region_draft.dart';
import 'package:maibu_satabot_v2/features/regions/domain/usecases/region_usecases.dart';
import 'package:maibu_satabot_v2/features/regions/presentation/cubit/region_editor_cubit.dart';
import 'package:maibu_satabot_v2/features/regions/presentation/widgets/region_boundary_layer.dart';
import 'package:maibu_satabot_v2/features/regions/presentation/widgets/region_form_dialog.dart';
const points = [
RegionPoint(lat: 31.08333536934687, lng: 120.84125702784672),
RegionPoint(lat: 31.083297377312423, lng: 120.84137894536802),
RegionPoint(lat: 31.082865000940348, lng: 120.84104682313267),
RegionPoint(lat: 31.08283424555773, lng: 120.84114772002897),
];
RegionDraft sample({
int? id,
String name = '测试分区',
List<RegionPoint> scope = points,
}) => RegionDraft(
id: id,
siteId: 18,
name: name,
code: '0090',
remark: '只是测试',
scope: scope,
defaultRouteId: 266,
boundRouteIds: [266],
);
class _Logger implements ILoggerService {
final messages = <String>[];
@override
void logWithLevel(
String message, {
String level = 'INFO',
Map<String, dynamic>? data,
bool shouldLog = false,
}) {
if (shouldLog) messages.add(message);
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _Routes implements AutonomousWorkGateway {
final sites = <int>[];
Future<WorkResult<List<WorkRouteOption>>> Function() response = () async =>
const WorkResult.ok([
WorkRouteOption(id: '266', name: 'APP测试 自测自定义01'),
WorkRouteOption(id: '267', name: '备用路线'),
WorkRouteOption(id: 'demo-1', name: '不支持数字主键的路线'),
]);
@override
Future<WorkResult<List<WorkRouteOption>>> loadRoutes({required int siteId}) {
sites.add(siteId);
return response();
}
// 除查询路线列表外,任何自主作业/路线保存调用都会立即让测试失败。
@override
dynamic noSuchMethod(Invocation invocation) =>
throw StateError('不应调用路线业务:${invocation.memberName}');
}
class _Harness {
_Harness(RegionDraft initial) {
dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) async {
requests.add(options);
handler.resolve(
Response(
requestOptions: options,
statusCode: 200,
data: await response(),
),
);
},
),
);
final repository = RegionRepositoryImpl(
remote: RegionRemoteDatasource(dio, logger),
routes: routes,
);
cubit = RegionEditorCubit(
SaveRegionUseCase(repository),
LoadRegionRoutesUseCase(repository),
)..initialize(initial);
}
final dio = Dio();
final logger = _Logger();
final routes = _Routes();
final requests = <RequestOptions>[];
late final RegionEditorCubit cubit;
Future<Map<String, dynamic>> Function() response = () async => {
'code': 200,
'msg': '操作成功',
'data': 1,
};
Future<void> close() async {
await cubit.close();
dio.close();
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('新建完整链路按用户示例发送 JSON,编码保持 0090,scope 只传四点', () async {
final h = _Harness(sample());
addTearDown(h.close);
expect(await h.cubit.save(sample()), isTrue);
expect(h.requests.single.method, 'POST');
expect(h.requests.single.uri.path, '/system/region/save');
expect(h.requests.single.data, {
'regionName': '测试分区',
'regionCode': '0090',
'siteId': 18,
'type': 'region',
'remark': '只是测试',
'scope': [
for (final p in points) {'lat': p.lat, 'lng': p.lng},
],
'status': 1,
'defaultRoute': 266,
'bindRoutes': [266],
});
expect(h.cubit.state.draft.id, isNull); // data: 1 不是分区 ID
expect(h.logger.messages.any((m) => m.contains('请求: POST')), isTrue);
expect(h.logger.messages.any((m) => m.contains('参数:')), isTrue);
expect(h.logger.messages.any((m) => m.contains('响应: HTTP 200')), isTrue);
expect(await h.cubit.save(sample()), isFalse); // 防止无主键时重复创建同一份草稿
expect(h.requests, hasLength(1));
});
test('修改保留原 ID、编码、绑定路线;数字保存数量不能覆盖 ID', () async {
final h = _Harness(sample(id: 14));
addTearDown(h.close);
expect(await h.cubit.save(sample(id: 14, name: '测试分区之修改')), isTrue);
expect(h.requests.single.data['id'], 14);
expect(h.requests.single.data['regionName'], '测试分区之修改');
expect(h.requests.single.data['regionCode'], '0090');
expect(h.requests.single.data['bindRoutes'], [266]);
expect(h.cubit.state.draft.id, 14);
});
test('只有明确返回的对象主键用于创建后的再次修改', () async {
final h = _Harness(sample());
addTearDown(h.close);
h.response = () async => {
'code': 200,
'data': {'id': 22},
};
expect(await h.cubit.save(sample()), isTrue);
expect(h.cubit.state.draft.id, 22);
h.response = () async => {'code': 200, 'data': true};
expect(await h.cubit.save(sample(id: 22, name: '改名')), isTrue);
expect(h.requests.last.data['id'], 22);
expect(h.cubit.state.draft.id, 22);
});
test('必须四个有效且不同的点,不足/超出/重复/无效点均不发接口', () async {
final h = _Harness(sample());
addTearDown(h.close);
for (final scope in [
points.take(3).toList(),
[...points, points.first],
[points.first, points.first, points[2], points[3]],
[const RegionPoint(lat: double.nan, lng: 120), ...points.skip(1)],
]) {
h.cubit.initialize(sample(scope: scope));
expect(await h.cubit.save(sample(scope: scope)), isFalse);
expect(h.cubit.state.error, isNotEmpty);
}
expect(h.requests, isEmpty);
});
test('分区打点上限、撤回和重新打点独立于路线查询及保存', () async {
final h = _Harness(RegionDraft(siteId: 18));
addTearDown(h.close);
for (final point in points) {
expect(h.cubit.addPoint(point), isNull);
}
expect(
h.cubit.addPoint(const RegionPoint(lat: 31.1, lng: 120.8)),
contains('4'),
);
expect(h.cubit.state.draft.scope, hasLength(4));
h.cubit.removePoint(3);
expect(h.cubit.addPoint(points.last), isNull);
expect(h.cubit.state.draft.scope, hasLength(4));
expect(h.requests, isEmpty);
expect(h.routes.sites, isEmpty);
});
test('保存期间不能重复提交或覆盖草稿,失败后可重试且不会丢四点', () async {
final h = _Harness(sample(id: 14));
addTearDown(h.close);
final response = Completer<Map<String, dynamic>>();
h.response = () => response.future;
final first = h.cubit.save(sample(id: 14));
expect(h.cubit.state.isSaving, isTrue);
expect(await h.cubit.save(sample(id: 14)), isFalse);
h.cubit.initialize(RegionDraft(siteId: 19));
expect(h.cubit.state.draft.siteId, 18);
response.complete({'code': 500, 'msg': '后台拒绝保存'});
expect(await first, isFalse);
expect(h.cubit.state.error, contains('后台拒绝保存'));
expect(h.cubit.state.draft.scope, hasLength(4));
expect(h.requests, hasLength(1));
h.response = () async => {'code': 200, 'data': 1};
expect(await h.cubit.save(sample(id: 14)), isTrue);
expect(h.requests, hasLength(2));
});
test('延迟返回的路线列表不能清掉保存失败原因;数字数量 0 不能报成功', () async {
final h = _Harness(sample());
addTearDown(h.close);
final routes = Completer<WorkResult<List<WorkRouteOption>>>();
h.routes.response = () => routes.future;
final loading = h.cubit.loadRoutes();
h.response = () async => {'code': 200, 'data': 0};
expect(await h.cubit.save(sample()), isFalse);
routes.complete(const WorkResult.ok([]));
await loading;
expect(h.cubit.state.routesStatus, RegionRoutesStatus.ready);
expect(h.cubit.state.error, contains('未保存'));
expect(h.cubit.state.status, RegionEditorStatus.failure);
});
Future<void> open(
WidgetTester tester,
_Harness h, {
bool Function()? isCurrent,
}) async {
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) => Scaffold(
body: TextButton(
onPressed: () => showRegionFormDialog(
context,
cubit: h.cubit,
isScopeCurrent: isCurrent ?? () => true,
),
child: const Text('打开分区'),
),
),
),
),
);
await tester.tap(find.text('打开分区'));
await tester.pumpAndSettle();
addTearDown(() async {
await tester.pumpWidget(const SizedBox.shrink());
});
}
testWidgets('表单回填截图字段,使用真实路线选项、多选绑定并携带 ID 修改', (tester) async {
final h = _Harness(sample(id: 14));
addTearDown(h.close);
await open(tester, h);
expect(find.text('编辑分区'), findsOneWidget);
expect(
tester
.widget<TextFormField>(find.byKey(const ValueKey('region_code')))
.controller!
.text,
'0090',
);
expect(h.routes.sites, [18]);
expect(h.cubit.state.routes.map((r) => r.id), [266, 267]);
await tester.ensureVisible(find.byKey(const ValueKey('region_name')));
await tester.enterText(
find.byKey(const ValueKey('region_name')),
'测试分区之修改',
);
final bindings = find.byKey(const ValueKey('region_bound_routes'));
await tester.ensureVisible(bindings);
await tester.tap(bindings);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('region_bind_267')));
await tester.tap(find.text('确定'));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('region_save')));
await tester.pumpAndSettle();
expect(h.requests.single.data['id'], 14);
expect(h.requests.single.data['defaultRoute'], 266);
expect(h.requests.single.data['bindRoutes'], [266, 267]);
expect(h.requests.single.data['regionName'], '测试分区之修改');
expect(find.byType(RegionFormDialog), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('区域保存失败停留表单,保留编码和备注并允许重试', (tester) async {
final h = _Harness(sample());
addTearDown(h.close);
h.response = () async => {'code': 500, 'msg': '名称重复'};
await open(tester, h);
await tester.tap(find.byKey(const ValueKey('region_save')));
await tester.pumpAndSettle();
expect(find.byType(RegionFormDialog), findsOneWidget);
expect(find.textContaining('名称重复'), findsOneWidget);
expect(
tester
.widget<TextFormField>(find.byKey(const ValueKey('region_code')))
.controller!
.text,
'0090',
);
expect(
tester
.widget<TextFormField>(find.byKey(const ValueKey('region_remark')))
.controller!
.text,
'只是测试',
);
h.response = () async => {'code': 200, 'data': 1};
await tester.tap(find.byKey(const ValueKey('region_save')));
await tester.pumpAndSettle();
expect(find.byType(RegionFormDialog), findsNothing);
expect(h.requests, hasLength(2));
});
testWidgets('场站或账号已切换时阻止提交;不足四点保存按钮不可点', (tester) async {
final h = _Harness(sample());
addTearDown(h.close);
await open(tester, h, isCurrent: () => false);
await tester.tap(find.byKey(const ValueKey('region_save')));
await tester.pumpAndSettle();
expect(find.textContaining('场站或账号已切换'), findsOneWidget);
expect(h.requests, isEmpty);
h.cubit.initialize(sample(scope: points.take(3).toList()));
await tester.pumpAndSettle();
expect(
tester
.widget<FilledButton>(find.byKey(const ValueKey('region_save')))
.onPressed,
isNull,
);
expect(h.requests, isEmpty);
});
testWidgets('横屏小高度表单可滚动填写,保存操作保持可见', (tester) async {
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(844, 390);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.view.resetPhysicalSize);
final h = _Harness(sample());
addTearDown(h.close);
await open(tester, h);
await tester.ensureVisible(find.byKey(const ValueKey('region_remark')));
await tester.enterText(
find.byKey(const ValueKey('region_remark')),
'横屏保存测试',
);
await tester.tap(find.byKey(const ValueKey('region_save')));
await tester.pumpAndSettle();
expect(h.requests.single.data['remark'], '横屏保存测试');
expect(tester.takeException(), isNull);
});
testWidgets('独立分区图层绘制四点且不追加第五个闭合点', (tester) async {
final geometry = [for (final p in points) LatLng(p.lat, p.lng)];
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: FlutterMap(
options: MapOptions(initialCenter: geometry.first, initialZoom: 19),
children: [RegionBoundaryLayer(points: geometry)],
),
),
),
);
await tester.pumpAndSettle();
expect(find.byType(PolygonLayer), findsOneWidget);
expect(
tester.widget<MarkerLayer>(find.byType(MarkerLayer)).markers,
hasLength(4),
);
expect(geometry, hasLength(4));
expect(tester.takeException(), isNull);
});
}