397 lines
14 KiB
Dart
397 lines
14 KiB
Dart
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);
|
||
});
|
||
}
|