/// 集成测试 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 _staged = const []; /// 最近一次 consume 是否已发生(诊断:区分「处理器没跑」vs「入队失败」)。 bool consumed = false; /// 暂存下一批返回路径。 void stage(List paths) => _staged = paths; List consume() { final paths = _staged; _staged = const []; consumed = true; 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` 的目录路径;两个候选都为空时抛错(提示先跑脚本)。 /// 注意:flutter test 换代码后走「卸载重装」,应用数据目录会被清空—— /// 候选 1 常常不存在,listSync 对缺失目录直接抛 PathNotFoundException, /// 必须逐候选容错后回退到 /data/local/tmp(脚本推送、全员可读、卸装存活)。 Future resolveImageDir(String supportDirPath) async { final candidates = [ p.join(supportDirPath, 'photolive-it'), ItConfig.sourceDir, ]; for (final dir in candidates) { final list = Directory(dir).existsSync() ? Directory(dir) .listSync() .whereType() .map((f) => p.basename(f.path)) .where((name) => name.startsWith('it_') && name.endsWith('.jpg')) .toList() : const []; if (list.isNotEmpty) return dir; } throw StateError( '设备上找不到测试图片(已尝试 ${candidates.join('、')})——' '请先运行 scripts/run-mobile-it.sh(负责 adb push + run-as 拷贝)', ); }