2026-09-19 05:35:16 +08:00
|
|
|
|
/// 集成测试 App 装配(P3 Task 11)。
|
|
|
|
|
|
///
|
|
|
|
|
|
/// 与生产 `lib/main.dart` 的差异【全部为有意注入点,登记为计划偏差】:
|
|
|
|
|
|
/// - **选图替身**:`multiImagePickerProvider`/`singleImagePickerProvider`
|
|
|
|
|
|
/// 返回 ADB 推送到设备的小图路径——image_picker 的系统相册 UI 无法在
|
|
|
|
|
|
/// instrumentation 下自动驱动(连拍经相机 UI 同理,统一走多选路径,
|
|
|
|
|
|
/// 入队后的 准备→队列→引擎→原生桥接→真实后端 链路完全真实);
|
|
|
|
|
|
/// - **通知权限 no-op**:避免首个入队动作弹出系统权限对话框卡住自动流
|
|
|
|
|
|
/// (脚本端另做 `pm grant` 双保险);
|
|
|
|
|
|
/// - **语言固定 zh**:MuMu 系统语言不确定,固定后文案查找器才稳定
|
|
|
|
|
|
/// (App 内切换的语义已由 Task 10 单测覆盖,本套件另做一次切换冒烟);
|
|
|
|
|
|
/// - **故障闸门**:ApiClient 的 dio adapter 包 [FaultGate](断网场景)。
|
|
|
|
|
|
library;
|
|
|
|
|
|
|
|
|
|
|
|
import 'dart:io';
|
|
|
|
|
|
|
|
|
|
|
|
import 'package:dio/dio.dart';
|
|
|
|
|
|
import 'package:flutter/widgets.dart';
|
|
|
|
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
|
|
import 'package:path/path.dart' as p;
|
|
|
|
|
|
import 'package:photo_live/app/app.dart';
|
|
|
|
|
|
import 'package:photo_live/app/locale_controller.dart';
|
|
|
|
|
|
import 'package:photo_live/core/api_client.dart';
|
|
|
|
|
|
import 'package:photo_live/core/config.dart';
|
|
|
|
|
|
import 'package:photo_live/core/notification_permission.dart';
|
|
|
|
|
|
import 'package:photo_live/features/uploads/enqueue.dart';
|
|
|
|
|
|
|
|
|
|
|
|
import 'fault_gate.dart';
|
|
|
|
|
|
import 'it_config.dart';
|
|
|
|
|
|
|
|
|
|
|
|
/// 选图替身:持有「下一次相册动作返回的路径」,消费后清空(未暂存时
|
|
|
|
|
|
/// 返回空=模拟用户取消,让调用方走取消分支而不是挂起)。
|
|
|
|
|
|
class ItImagePicker {
|
|
|
|
|
|
List<String> _staged = const <String>[];
|
|
|
|
|
|
|
2026-09-20 15:03:17 +08:00
|
|
|
|
/// 最近一次 consume 是否已发生(诊断:区分「处理器没跑」vs「入队失败」)。
|
|
|
|
|
|
bool consumed = false;
|
|
|
|
|
|
|
2026-09-19 05:35:16 +08:00
|
|
|
|
/// 暂存下一批返回路径。
|
|
|
|
|
|
void stage(List<String> paths) => _staged = paths;
|
|
|
|
|
|
|
|
|
|
|
|
List<String> consume() {
|
|
|
|
|
|
final paths = _staged;
|
|
|
|
|
|
_staged = const <String>[];
|
2026-09-20 15:03:17 +08:00
|
|
|
|
consumed = true;
|
2026-09-19 05:35:16 +08:00
|
|
|
|
return paths;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 测试装配产物。
|
|
|
|
|
|
class ItAppHarness {
|
|
|
|
|
|
ItAppHarness._(this.container, this.gate, this.picker);
|
|
|
|
|
|
|
|
|
|
|
|
/// App 根容器(持有全部覆写;测试经它读 taskStore/apiClient 等)。
|
|
|
|
|
|
final ProviderContainer container;
|
|
|
|
|
|
|
|
|
|
|
|
/// 断网故障闸门。
|
|
|
|
|
|
final FaultGate gate;
|
|
|
|
|
|
|
|
|
|
|
|
/// 选图替身。
|
|
|
|
|
|
final ItImagePicker picker;
|
|
|
|
|
|
|
|
|
|
|
|
/// 装配 App(不 runApp——由 testWidgets 内 pumpWidget 注入
|
|
|
|
|
|
/// UncontrolledProviderScope)。
|
|
|
|
|
|
static ItAppHarness create() {
|
|
|
|
|
|
ItConfig.assertValid();
|
|
|
|
|
|
final gate = FaultGate();
|
|
|
|
|
|
final picker = ItImagePicker();
|
|
|
|
|
|
|
|
|
|
|
|
final dio = Dio(
|
|
|
|
|
|
BaseOptions(
|
|
|
|
|
|
baseUrl: ApiConfig.baseUrl,
|
|
|
|
|
|
connectTimeout: const Duration(seconds: 10),
|
|
|
|
|
|
sendTimeout: const Duration(seconds: 60),
|
|
|
|
|
|
receiveTimeout: const Duration(seconds: 60),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
dio.httpClientAdapter = FaultInjectingHttpClientAdapter(
|
|
|
|
|
|
gate: gate,
|
|
|
|
|
|
inner: dio.httpClientAdapter,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
final container = ProviderContainer(
|
|
|
|
|
|
overrides: [
|
|
|
|
|
|
localeSettingsProvider.overrideWith(
|
|
|
|
|
|
() => LocaleSettings(initial: AppLocaleChoice.zh),
|
|
|
|
|
|
),
|
|
|
|
|
|
apiClientProvider.overrideWith((ref) {
|
|
|
|
|
|
final client = ApiClient(
|
|
|
|
|
|
sessionStore: ref.watch(sessionStoreProvider),
|
|
|
|
|
|
dio: dio,
|
|
|
|
|
|
);
|
|
|
|
|
|
ref.onDispose(() => client.close());
|
|
|
|
|
|
return client;
|
|
|
|
|
|
}),
|
|
|
|
|
|
multiImagePickerProvider.overrideWithValue(() async => picker.consume()),
|
|
|
|
|
|
singleImagePickerProvider.overrideWithValue(() async {
|
|
|
|
|
|
final paths = picker.consume();
|
|
|
|
|
|
return paths.isEmpty ? null : paths.first;
|
|
|
|
|
|
}),
|
|
|
|
|
|
notificationPermissionProvider.overrideWithValue(
|
|
|
|
|
|
NotificationPermission(isAndroid: false),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
);
|
|
|
|
|
|
return ItAppHarness._(container, gate, picker);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 注入根组件(每个 testWidgets 开头调用)。
|
|
|
|
|
|
Widget appWidget() =>
|
|
|
|
|
|
UncontrolledProviderScope(container: container, child: const PhotoLiveApp());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 解析设备端可读的测试图片目录。
|
|
|
|
|
|
///
|
|
|
|
|
|
/// 优先 App 自有 files 目录(脚本经 `run-as` 拷入,无 SELinux/作用域存储
|
|
|
|
|
|
/// 风险),回退 `/data/local/tmp`(adb push 的直读路径,多数模拟器可用)。
|
|
|
|
|
|
/// 返回含全部 `it_*.jpg` 的目录路径;两个候选都为空时抛错(提示先跑脚本)。
|
2026-09-20 15:03:17 +08:00
|
|
|
|
/// 注意:flutter test 换代码后走「卸载重装」,应用数据目录会被清空——
|
|
|
|
|
|
/// 候选 1 常常不存在,listSync 对缺失目录直接抛 PathNotFoundException,
|
|
|
|
|
|
/// 必须逐候选容错后回退到 /data/local/tmp(脚本推送、全员可读、卸装存活)。
|
2026-09-19 05:35:16 +08:00
|
|
|
|
Future<String> resolveImageDir(String supportDirPath) async {
|
|
|
|
|
|
final candidates = <String>[
|
|
|
|
|
|
p.join(supportDirPath, 'photolive-it'),
|
|
|
|
|
|
ItConfig.sourceDir,
|
|
|
|
|
|
];
|
|
|
|
|
|
for (final dir in candidates) {
|
2026-09-20 15:03:17 +08:00
|
|
|
|
final list = Directory(dir).existsSync()
|
|
|
|
|
|
? Directory(dir)
|
|
|
|
|
|
.listSync()
|
|
|
|
|
|
.whereType<File>()
|
|
|
|
|
|
.map((f) => p.basename(f.path))
|
|
|
|
|
|
.where((name) => name.startsWith('it_') && name.endsWith('.jpg'))
|
|
|
|
|
|
.toList()
|
|
|
|
|
|
: const <String>[];
|
2026-09-19 05:35:16 +08:00
|
|
|
|
if (list.isNotEmpty) return dir;
|
|
|
|
|
|
}
|
|
|
|
|
|
throw StateError(
|
|
|
|
|
|
'设备上找不到测试图片(已尝试 ${candidates.join('、')})——'
|
|
|
|
|
|
'请先运行 scripts/run-mobile-it.sh(负责 adb push + run-as 拷贝)',
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|