0fa23e015e
Task 2 of docs/plans/2026-09-28-上传查重前置到入队前-实施计划.md:
surface server-side duplicate history at the "click upload" moment,
merged with the local check into the EXISTING dialog.
- ApiClient.checkUploadDuplicates: POST /v1/uploads/duplicates/check
(commit 805f77b endpoint); 500/chunk sequential requests; chunk-local
index translated to global; empty list short-circuits (no request);
errors normalized to AppException via _send.
- enqueue.checkDuplicatesUnified: local TaskStore history ∪ server
history; server hits map back to filename via global index into the
stat'd picks; merged, de-duplicated (filename+bytes) list; server
failure (network/permission/any) silently degrades to local-only —
init 409 remains the backstop.
- capture_page + album_detail_page _pickFromGallery now call the
unified entry; dialog/skip/force logic untouched.
Tests: api_client chunking (1200 → 500/500/200) + index translation +
merge + 404 normalization + empty no-request; page-level three states
(hit/no-hit/network failure) incl. fresh-install server-only-hit dialog
on both pages; pre-existing Task 8 dialog tests keep passing via the
degrade path.
Co-Authored-By: Claude Code <noreply@anthropic.com>
646 lines
24 KiB
Dart
646 lines
24 KiB
Dart
/// 连拍页 widget 测试(UI-03 页面 13 深色摄影界面;R17 连拍入队 / O01)。
|
||
///
|
||
/// 打桩面:FakeCaptureSource(capture 返回真实 JPEG 临时文件,无相机
|
||
/// 平台通道;可注入闪光灯能力/记录翻转释放)+ 内存 TaskStore + 临时目录
|
||
/// 准备器 + 非 Android 权限门替身。覆盖:快门即拍即入队、连拍串行、分类
|
||
/// 切换仅影响之后拍摄、多选补充入队、待传/失败徽章实时刷新、有待传任务
|
||
/// 退出确认、权限拒绝/初始化失败视图与重试、深色界面(#10161E)、快门
|
||
/// ≥72dp、闪光灯按能力显示、前后摄切换重建、最近一张缩略图、捏合变焦
|
||
/// 与双击复位。
|
||
library;
|
||
|
||
import 'dart:io';
|
||
|
||
import 'package:camera/camera.dart' show CameraException, XFile;
|
||
import 'package:dio/dio.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:photo_live/core/api_client.dart';
|
||
import 'package:photo_live/core/notification_permission.dart';
|
||
import 'package:photo_live/core/session_store.dart';
|
||
import 'package:photo_live/features/albums/models.dart';
|
||
import 'package:photo_live/features/uploads/enqueue.dart';
|
||
import 'package:photo_live/features/uploads/preparer.dart';
|
||
import 'package:photo_live/features/uploads/providers.dart';
|
||
import 'package:photo_live/features/uploads/queue_controller.dart';
|
||
import 'package:photo_live/features/uploads/task_state.dart';
|
||
import 'package:photo_live/features/uploads/task_store.dart';
|
||
import 'package:photo_live/l10n/app_localizations.dart';
|
||
import 'package:photo_live/pages/capture_page.dart';
|
||
import 'package:photo_live/ui/tokens.dart';
|
||
|
||
import '../helpers/fakes.dart';
|
||
|
||
const List<int> kJpgBytes = <int>[
|
||
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00,
|
||
];
|
||
|
||
/// 拍摄源替身:capture 依次弹出预置文件;initialize 可注入失败次数;
|
||
/// 闪光灯能力可注入(记录开关调用);释放计数供前后切换断言。
|
||
class FakeCaptureSource extends CaptureSource {
|
||
FakeCaptureSource(this._files);
|
||
|
||
final List<File> _files;
|
||
int initializeCalls = 0;
|
||
int failNextInits = 0;
|
||
Object? initError;
|
||
|
||
/// 闪光灯能力(默认不支持——UI 不显示开关)。
|
||
bool flashSupported = false;
|
||
final List<bool> flashCalls = <bool>[];
|
||
|
||
/// dispose 计数(前后摄切换释放断言)。
|
||
int disposeCalls = 0;
|
||
|
||
@override
|
||
Future<void> initialize() async {
|
||
initializeCalls++;
|
||
if (failNextInits > 0) {
|
||
failNextInits--;
|
||
throw initError ?? StateError('init failed');
|
||
}
|
||
}
|
||
|
||
@override
|
||
Future<XFile> capture() async => XFile(_files.removeAt(0).path);
|
||
|
||
@override
|
||
Widget buildPreview() => const ColoredBox(
|
||
color: Color(0xFF101010),
|
||
child: Center(child: Text('PREVIEW_MARK')),
|
||
);
|
||
|
||
@override
|
||
bool get supportsFlash => flashSupported;
|
||
|
||
@override
|
||
Future<void> setFlashEnabled(bool enabled) async =>
|
||
flashCalls.add(enabled);
|
||
|
||
@override
|
||
Future<void> dispose() async => disposeCalls++;
|
||
}
|
||
|
||
/// 变焦能力替身(Task 7 捏合变焦):能力 1-8×,记录 setZoom 调用序列。
|
||
/// 继承 [FakeCaptureSource](预览标记/初始化/释放行为一致)。
|
||
class ZoomFakeCaptureSource extends FakeCaptureSource {
|
||
ZoomFakeCaptureSource(super.files);
|
||
|
||
final List<double> zoomCalls = <double>[];
|
||
|
||
@override
|
||
double get minZoom => 1;
|
||
|
||
@override
|
||
double get maxZoom => 8;
|
||
|
||
@override
|
||
Future<void> setZoom(double level) async => zoomCalls.add(level);
|
||
}
|
||
|
||
void main() {
|
||
late FakeSessionStore session;
|
||
late TaskStore store;
|
||
late Directory tmp;
|
||
late FakeCaptureSource source;
|
||
late List<String> galleryPaths;
|
||
|
||
/// 工厂调用记录(前后摄切换断言)。
|
||
late List<bool> factoryPreferFront;
|
||
|
||
final categories = <Category>[
|
||
const Category(
|
||
id: 'cat-1',
|
||
albumId: 'alb-1',
|
||
sortOrder: 0,
|
||
translations: [
|
||
CategoryTranslation(locale: 'zh-CN', name: '仪式'),
|
||
CategoryTranslation(locale: 'en', name: 'Ceremony'),
|
||
],
|
||
),
|
||
const Category(
|
||
id: 'cat-2',
|
||
albumId: 'alb-1',
|
||
sortOrder: 1,
|
||
translations: [
|
||
CategoryTranslation(locale: 'zh-CN', name: '晚宴'),
|
||
CategoryTranslation(locale: 'en', name: 'Gala'),
|
||
],
|
||
),
|
||
];
|
||
|
||
setUp(() async {
|
||
session = FakeSessionStore()
|
||
..session = Session(
|
||
token: 'tok-1',
|
||
expiresAt: DateTime.utc(2099, 1, 1),
|
||
account: 'alice',
|
||
);
|
||
store = TaskStore.memory();
|
||
tmp = await Directory.systemTemp.createTemp('capture-test-');
|
||
final shot1 = File('${tmp.path}/IMG_0001.jpg')..writeAsBytesSync(kJpgBytes);
|
||
final shot2 = File('${tmp.path}/IMG_0002.jpg')..writeAsBytesSync(kJpgBytes);
|
||
final shot3 = File('${tmp.path}/IMG_0003.jpg')..writeAsBytesSync(kJpgBytes);
|
||
source = FakeCaptureSource([shot1, shot2, shot3]);
|
||
galleryPaths = <String>[];
|
||
factoryPreferFront = <bool>[];
|
||
});
|
||
|
||
tearDown(() async {
|
||
await store.close();
|
||
try {
|
||
await tmp.delete(recursive: true);
|
||
} on Object {
|
||
// Windows:底部缩略图 FileImage 的异步读取可能仍持有临时文件句柄
|
||
//(fake-async 区内真实 IO 完成回调不被冲刷)——删除失败不判定
|
||
// 业务断言失败,残留目录交由系统临时目录清理。
|
||
}
|
||
});
|
||
|
||
CaptureTarget target() => CaptureTarget(
|
||
accountId: 'alice',
|
||
albumId: 'alb-1',
|
||
albumTitle: 'Opening Gala',
|
||
categories: categories,
|
||
);
|
||
|
||
Future<void> pumpCapture(
|
||
WidgetTester tester, {
|
||
CaptureSource? cameraSource,
|
||
FakeAdapter? apiAdapter,
|
||
}) async {
|
||
final effectiveSource = cameraSource ?? source;
|
||
await tester.pumpWidget(
|
||
ProviderScope(
|
||
overrides: [
|
||
...pageTestOverrides(
|
||
session: session,
|
||
taskStore: store,
|
||
client: ApiClient(
|
||
sessionStore: session,
|
||
dio: Dio(BaseOptions(baseUrl: 'http://test.local'))
|
||
..httpClientAdapter = apiAdapter ??
|
||
FakeAdapter(
|
||
// 多选入队的查重预检会发 API 请求:默认打桩为失败,
|
||
// 驱动「服务器预检失败 → 静默降级本机-only」路径。
|
||
(_) => throw StateError('连拍页测试 API 请求一律失败'),
|
||
),
|
||
),
|
||
),
|
||
captureSourceFactoryProvider.overrideWith(
|
||
(ref) => ({bool preferFront = false}) async {
|
||
factoryPreferFront.add(preferFront);
|
||
return effectiveSource;
|
||
},
|
||
),
|
||
uploadPreparerProvider.overrideWith(
|
||
(ref) => UploadPreparer(
|
||
store,
|
||
baseDirProvider: () async => tmp,
|
||
),
|
||
),
|
||
notificationPermissionProvider.overrideWith(
|
||
(ref) => NotificationPermission(isAndroid: false),
|
||
),
|
||
multiImagePickerProvider
|
||
.overrideWith((ref) => (context) async => galleryPaths),
|
||
],
|
||
child: MaterialApp(
|
||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||
supportedLocales: AppLocalizations.supportedLocales,
|
||
home: CapturePage(target: target()),
|
||
),
|
||
),
|
||
);
|
||
// 相机初始化完成:帧级 pump 直至预览出现(首个测试字体/主题冷启动
|
||
// 较慢,固定双 pump 不够稳)。
|
||
await tester.pump();
|
||
var guard = 0;
|
||
while (find.text('PREVIEW_MARK').evaluate().isEmpty && guard++ < 20) {
|
||
await tester.pump(const Duration(milliseconds: 50));
|
||
}
|
||
}
|
||
|
||
/// 触发一个会走真实文件 IO 的按钮:在 runAsync(真实事件循环)内直接
|
||
/// 调用其回调——widget 测试的 fake-async 区里 dart:io 完成回调不会被
|
||
/// pump 冲刷,入队链路必须整体跑在真实区;返回后 pump 刷新徽章防抖。
|
||
Future<void> pressRealIo(WidgetTester tester, IconData icon) async {
|
||
final button = find
|
||
.ancestor(of: find.byIcon(icon), matching: find.byType(IconButton))
|
||
.evaluate()
|
||
.single
|
||
.widget as IconButton;
|
||
await tester.runAsync(() async {
|
||
button.onPressed!();
|
||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||
});
|
||
await tester.pump(kQueueRefreshDebounce);
|
||
await tester.pump(const Duration(milliseconds: 60));
|
||
}
|
||
|
||
/// 快门(即拍即入队链路经 [pressRealIo])。
|
||
Future<void> shoot(WidgetTester tester, {int times = 1}) async {
|
||
for (var i = 0; i < times; i++) {
|
||
await pressRealIo(tester, Icons.camera);
|
||
}
|
||
}
|
||
|
||
testWidgets('深色摄影界面:页面背景 #10161E(不随主题)', (tester) async {
|
||
await pumpCapture(tester);
|
||
expect(
|
||
tester.widget<Scaffold>(find.byType(Scaffold)).backgroundColor,
|
||
kColorPhotoBackdrop,
|
||
reason: '§3 相机界面固定深色背景',
|
||
);
|
||
});
|
||
|
||
testWidgets('快门 ≥72dp(§4 页面 13)+ 相机控制齐备', (tester) async {
|
||
await pumpCapture(tester);
|
||
|
||
final shutterSize = tester.getSize(find.byKey(const ValueKey('capture-shutter')));
|
||
expect(shutterSize.width, greaterThanOrEqualTo(72));
|
||
expect(shutterSize.height, greaterThanOrEqualTo(72));
|
||
|
||
// 前后摄切换 + 相册入口(缩略图占位)常驻;闪光灯未注入能力不出现。
|
||
expect(find.byIcon(Icons.flip_camera_ios_outlined), findsOneWidget);
|
||
expect(find.byIcon(Icons.photo_library_outlined), findsOneWidget);
|
||
expect(find.byIcon(Icons.flash_off), findsNothing,
|
||
reason: '源不支持闪光灯时不显示开关');
|
||
});
|
||
|
||
testWidgets('快门即拍即入队:任务落库 queued + 顶栏待传徽章 1', (tester) async {
|
||
await pumpCapture(tester);
|
||
expect(find.text('PREVIEW_MARK'), findsOneWidget, reason: '相机预览就绪');
|
||
expect(find.text('0 queued'), findsOneWidget);
|
||
|
||
await shoot(tester);
|
||
|
||
final tasks = await store.listByAccount('alice');
|
||
expect(tasks, hasLength(1));
|
||
expect(tasks.single.state, UploadTaskState.queued);
|
||
expect(tasks.single.accountId, 'alice');
|
||
expect(tasks.single.albumId, 'alb-1');
|
||
expect(tasks.single.categoryId, isNull, reason: '默认分类');
|
||
expect(tasks.single.filename, 'IMG_0001.jpg');
|
||
expect(find.text('1 queued'), findsOneWidget, reason: '徽章实时刷新');
|
||
});
|
||
|
||
testWidgets('最近一张缩略图:拍摄前为相册占位,拍摄后显示照片',
|
||
(tester) async {
|
||
await pumpCapture(tester);
|
||
expect(find.byType(Image), findsNothing, reason: '拍摄前无缩略图');
|
||
|
||
await shoot(tester);
|
||
|
||
expect(
|
||
find.byType(Image),
|
||
findsOneWidget,
|
||
reason: '拍摄后底部显示最近一张(缩略图位)',
|
||
);
|
||
});
|
||
|
||
testWidgets('入队成功反馈:短暂浮层出现(不逐张弹确认,§4.5)',
|
||
(tester) async {
|
||
await pumpCapture(tester);
|
||
|
||
// 与 pressRealIo 同法但缩短真实等待(80ms < 200ms 反馈窗):入队链路
|
||
// 完成时浮层已翻转、计时器未到期,可在窗内断言不透明度 > 0。
|
||
final button = find
|
||
.ancestor(of: find.byIcon(Icons.camera), matching: find.byType(IconButton))
|
||
.evaluate()
|
||
.single
|
||
.widget as IconButton;
|
||
await tester.runAsync(() async {
|
||
button.onPressed!();
|
||
await Future<void>.delayed(const Duration(milliseconds: 80));
|
||
});
|
||
await tester.pump(const Duration(milliseconds: 180));
|
||
|
||
// 反馈浮层淡入完成(AnimatedOpacity → 1),文案为「已加入队列」。
|
||
expect(
|
||
tester.widget<AnimatedOpacity>(find.byType(AnimatedOpacity).first).opacity,
|
||
greaterThan(0),
|
||
reason: '200ms 反馈窗内浮层可见',
|
||
);
|
||
expect(find.text('Added to queue'), findsOneWidget);
|
||
// 等待 200ms 反馈计时器在真实事件循环中到期,避免悬挂计时器;随后
|
||
// fake pump 冲刷淡出动画帧。
|
||
await tester.runAsync(
|
||
() async => Future<void>.delayed(const Duration(milliseconds: 250)),
|
||
);
|
||
await tester.pump(const Duration(milliseconds: 400));
|
||
});
|
||
|
||
testWidgets('连拍两张串行入队(不逐张确认)', (tester) async {
|
||
await pumpCapture(tester);
|
||
|
||
await shoot(tester, times: 2);
|
||
|
||
final tasks = await store.listByAccount('alice');
|
||
expect(tasks.map((t) => t.filename), unorderedEquals(<String>[
|
||
'IMG_0001.jpg',
|
||
'IMG_0002.jpg',
|
||
]));
|
||
expect(find.text('2 queued'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('闪光灯:源支持才显示;点击开→关记录调用', (tester) async {
|
||
source.flashSupported = true;
|
||
await pumpCapture(tester);
|
||
|
||
expect(find.byIcon(Icons.flash_off), findsOneWidget, reason: '默认关');
|
||
await tester.tap(find.byIcon(Icons.flash_off));
|
||
await tester.pump();
|
||
|
||
expect(source.flashCalls, [true]);
|
||
expect(find.byIcon(Icons.flash_on), findsOneWidget, reason: '开启后图标翻转');
|
||
});
|
||
|
||
testWidgets('前后摄切换:释放当前源 → 反向重建(工厂收 preferFront)',
|
||
(tester) async {
|
||
await pumpCapture(tester);
|
||
expect(factoryPreferFront, [false]);
|
||
|
||
await tester.tap(find.byIcon(Icons.flip_camera_ios_outlined));
|
||
await tester.pump();
|
||
var guard = 0;
|
||
while (find.text('PREVIEW_MARK').evaluate().isEmpty && guard++ < 20) {
|
||
await tester.pump(const Duration(milliseconds: 50));
|
||
}
|
||
|
||
expect(source.disposeCalls, 1, reason: '旧源已释放(相机生命周期)');
|
||
expect(factoryPreferFront, [false, true], reason: '反向重建');
|
||
expect(find.text('PREVIEW_MARK'), findsOneWidget, reason: '预览恢复');
|
||
});
|
||
|
||
testWidgets('分类切换:hint 提示 + 仅影响之后拍摄(A15 归属不变)',
|
||
(tester) async {
|
||
await pumpCapture(tester);
|
||
await shoot(tester); // 第一张:默认分类。
|
||
|
||
await tester.tap(find.text('Gala'));
|
||
await tester.pump();
|
||
expect(
|
||
find.text('Switching only affects photos captured afterwards'),
|
||
findsOneWidget,
|
||
reason: '切换归属不变提示',
|
||
);
|
||
await tester.pumpAndSettle(); // snackbar 退场。
|
||
|
||
await shoot(tester); // 第二张:新分类。
|
||
|
||
final tasks = await store.listByAccount('alice');
|
||
final first =
|
||
tasks.firstWhere((t) => t.filename == 'IMG_0001.jpg');
|
||
final second =
|
||
tasks.firstWhere((t) => t.filename == 'IMG_0002.jpg');
|
||
expect(first.categoryId, isNull, reason: '既有任务归属不变');
|
||
expect(second.categoryId, 'cat-2', reason: '切换后拍摄走新分类');
|
||
});
|
||
|
||
testWidgets('相册多选补充入队 → 入队数提示', (tester) async {
|
||
final pick1 = File('${tmp.path}/pick1.jpg')..writeAsBytesSync(kJpgBytes);
|
||
final pick2 = File('${tmp.path}/pick2.jpg')..writeAsBytesSync(kJpgBytes);
|
||
galleryPaths.addAll([pick1.path, pick2.path]);
|
||
await pumpCapture(tester);
|
||
|
||
// 多选入队走真实文件 IO(同快门路径)。
|
||
await pressRealIo(tester, Icons.photo_library_outlined);
|
||
await tester.pump(const Duration(milliseconds: 100));
|
||
|
||
expect(find.text('Added 2 photo(s) to the upload queue'), findsOneWidget);
|
||
expect(await store.listByAccount('alice'), hasLength(2));
|
||
});
|
||
|
||
testWidgets('查重弹窗「全部上传」→ 重复项 force 入队、非重复项正常(Task 8)',
|
||
(tester) async {
|
||
// 既有已完成任务:与 pick1 同名同字节(本地查重命中一张)。
|
||
final seed = await store.create(UploadTaskDraft(
|
||
id: 'task-dup-seed',
|
||
accountId: 'alice',
|
||
albumId: 'alb-1',
|
||
clientTaskId: 'ct-dup-seed',
|
||
sourceFilePath: '${tmp.path}/seed-dup.jpg',
|
||
filename: 'pick1.jpg',
|
||
bytes: kJpgBytes.length,
|
||
contentType: 'image/jpeg',
|
||
));
|
||
for (final state in [
|
||
UploadTaskState.queued,
|
||
UploadTaskState.uploading,
|
||
UploadTaskState.uploadedConfirming,
|
||
UploadTaskState.completed,
|
||
]) {
|
||
await store.updateState(seed.id, state);
|
||
}
|
||
|
||
final pick1 = File('${tmp.path}/pick1.jpg')..writeAsBytesSync(kJpgBytes);
|
||
final pick2 = File('${tmp.path}/pick2.jpg')..writeAsBytesSync(kJpgBytes);
|
||
galleryPaths.addAll([pick1.path, pick2.path]);
|
||
await pumpCapture(tester);
|
||
|
||
// 相册入口:真实 IO 区完成查重,随后弹窗挂起等待选择。
|
||
await pressRealIo(tester, Icons.photo_library_outlined);
|
||
await tester.pump(const Duration(milliseconds: 300));
|
||
expect(find.text('可能已上传过'), findsOneWidget, reason: '查重弹窗出现');
|
||
|
||
// 「全部上传」= 用户确认强传:重复项 force=true、非重复项不受影响。
|
||
await tester.tap(find.text('全部上传 2 张'));
|
||
await tester.pump(const Duration(milliseconds: 100));
|
||
// 入队复制走真实文件 IO:真实事件循环窗口内完成。
|
||
await tester.runAsync(() async {
|
||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||
});
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('Added 2 photo(s) to the upload queue'), findsOneWidget);
|
||
final tasks = await store.listByAccount('alice');
|
||
final fresh = tasks.where((t) => t.id != seed.id).toList();
|
||
expect(fresh, hasLength(2));
|
||
expect(
|
||
fresh.firstWhere((t) => t.filename == 'pick1.jpg').force,
|
||
isTrue,
|
||
reason: '重复项经「全部上传」确认强传',
|
||
);
|
||
expect(
|
||
fresh.firstWhere((t) => t.filename == 'pick2.jpg').force,
|
||
isFalse,
|
||
reason: '非重复项正常查重语义',
|
||
);
|
||
});
|
||
|
||
testWidgets('服务器历史查重命中(本机无记录)→ 统一查重弹窗,取消不上传(Task 2)',
|
||
(tester) async {
|
||
// 新设备场景:本机 TaskStore 空,服务器预检命中 pick1(bytes 一致);
|
||
// pick2 字节数不同不命中——弹窗应精确提示 1 张重复。
|
||
final adapter = FakeAdapter((o) {
|
||
if (o.path == '/v1/uploads/duplicates/check' && o.method == 'POST') {
|
||
return (
|
||
200,
|
||
{
|
||
'duplicates': [
|
||
{
|
||
'index': 0,
|
||
'bytes': kJpgBytes.length,
|
||
'uploadedAt': '2026-09-25T08:00:00Z',
|
||
},
|
||
],
|
||
},
|
||
);
|
||
}
|
||
throw StateError('unexpected ${o.method} ${o.path}');
|
||
});
|
||
final pick1 = File('${tmp.path}/pick1.jpg')..writeAsBytesSync(kJpgBytes);
|
||
final pick2 = File('${tmp.path}/pick2.jpg')
|
||
..writeAsBytesSync([...kJpgBytes, 0x00]);
|
||
galleryPaths.addAll([pick1.path, pick2.path]);
|
||
await pumpCapture(tester, apiAdapter: adapter);
|
||
|
||
await pressRealIo(tester, Icons.photo_library_outlined);
|
||
await tester.pump(const Duration(milliseconds: 300));
|
||
|
||
expect(
|
||
find.text('可能已上传过'),
|
||
findsOneWidget,
|
||
reason: '服务器历史命中(新设备场景)也弹查重确认',
|
||
);
|
||
expect(find.textContaining('1 张照片可能已上传到该相册'), findsOneWidget);
|
||
expect(find.text('跳过重复,上传 1 张'), findsOneWidget);
|
||
|
||
// 取消:整批不上传(服务器命中同样尊重用户取消)。
|
||
await tester.tap(find.text('取消'));
|
||
await tester.pumpAndSettle();
|
||
expect(await store.listByAccount('alice'), isEmpty);
|
||
});
|
||
|
||
testWidgets('退出确认:有待传任务 → 弹确认;保留继续拍 / 确认才退出',
|
||
(tester) async {
|
||
await pumpCapture(tester);
|
||
expect(find.byType(CapturePage), findsOneWidget);
|
||
|
||
await shoot(tester);
|
||
|
||
// home 路由无返回按钮——经 Navigator.maybePop 驱动系统返回路径
|
||
//(PopScope.canPop=false → onPopInvokedWithResult 弹确认)。
|
||
Future<void> requestBack() async {
|
||
final navigator = tester.state<NavigatorState>(
|
||
find.byType(Navigator).first,
|
||
);
|
||
await navigator.maybePop();
|
||
await tester.pumpAndSettle();
|
||
}
|
||
|
||
// 有待传 → 弹确认。
|
||
await requestBack();
|
||
expect(find.text('Leave shooting?'), findsOneWidget);
|
||
expect(
|
||
find.textContaining('still uploading'),
|
||
findsOneWidget,
|
||
reason: '正文含待传数与后台继续说明',
|
||
);
|
||
|
||
// 保留继续拍 → 不退出。
|
||
await tester.tap(find.text('Keep shooting'));
|
||
await tester.pumpAndSettle();
|
||
expect(find.byType(CapturePage), findsOneWidget);
|
||
|
||
// 再次退出 → 确认离开 → 出栈。
|
||
await requestBack();
|
||
await tester.tap(find.text('Leave'));
|
||
await tester.pumpAndSettle();
|
||
expect(find.byType(CapturePage), findsNothing);
|
||
});
|
||
|
||
testWidgets('权限拒绝视图:文案 + 重试入口', (tester) async {
|
||
source.initError = CameraException('CameraAccessDenied', 'denied');
|
||
source.failNextInits = 1;
|
||
await pumpCapture(tester);
|
||
|
||
expect(
|
||
find.text(
|
||
'Camera unavailable. Allow camera access in system settings'),
|
||
findsOneWidget,
|
||
);
|
||
// 重试成功 → 预览就绪。
|
||
await tester.tap(find.text('Retry'));
|
||
await tester.pump();
|
||
await tester.pump(const Duration(milliseconds: 50));
|
||
expect(find.text('PREVIEW_MARK'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('初始化失败视图:非权限异常文案 + 重试恢复', (tester) async {
|
||
source.failNextInits = 1;
|
||
source.initError = StateError('camera broken');
|
||
await pumpCapture(tester);
|
||
|
||
expect(find.text('Camera failed to initialize'), findsOneWidget);
|
||
|
||
await tester.tap(find.text('Retry'));
|
||
await tester.pump();
|
||
await tester.pump(const Duration(milliseconds: 50));
|
||
expect(find.text('PREVIEW_MARK'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('常驻信息层:相册名 + 分类候选', (tester) async {
|
||
await pumpCapture(tester);
|
||
|
||
expect(find.text('Opening Gala'), findsOneWidget);
|
||
expect(find.text('Default category'), findsOneWidget);
|
||
expect(find.text('Ceremony'), findsOneWidget);
|
||
expect(find.text('Gala'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('捏合变焦:双指张开驱动 setZoom 并显示倍数徽标', (tester) async {
|
||
final zoomSource = ZoomFakeCaptureSource(<File>[]);
|
||
await pumpCapture(tester, cameraSource: zoomSource);
|
||
expect(find.text('PREVIEW_MARK'), findsOneWidget, reason: '相机预览就绪');
|
||
|
||
final center = tester.getCenter(find.text('PREVIEW_MARK'));
|
||
// 必须两根指针:ScaleGestureRecognizer 的 scale = 当前指距/初始指距,
|
||
// 单指针 span=0 → scale 恒为 1.0,永远测不到变焦。
|
||
final p1 = await tester.startGesture(center.translate(-40, 0));
|
||
final p2 = await tester.startGesture(center.translate(40, 0));
|
||
await p1.moveBy(const Offset(-40, 0));
|
||
await p2.moveBy(const Offset(40, 0));
|
||
await tester.pump();
|
||
await p1.up();
|
||
await p2.up();
|
||
await tester.pump();
|
||
|
||
expect(zoomSource.zoomCalls.last, greaterThan(1.0));
|
||
expect(find.textContaining('×'), findsOneWidget, reason: '捏合期间显示倍数徽标');
|
||
|
||
// 停止 1s 后徽标隐藏;同时冲刷计时器避免悬挂。
|
||
await tester.pump(const Duration(seconds: 1));
|
||
expect(find.textContaining('×'), findsNothing, reason: '1s 后徽标隐藏');
|
||
});
|
||
|
||
testWidgets('双击复位 1.0×', (tester) async {
|
||
final zoomSource = ZoomFakeCaptureSource(<File>[]);
|
||
await pumpCapture(tester, cameraSource: zoomSource);
|
||
|
||
// 先捏合放大到 >1×,再双击复位。
|
||
final center = tester.getCenter(find.text('PREVIEW_MARK'));
|
||
final p1 = await tester.startGesture(center.translate(-40, 0));
|
||
final p2 = await tester.startGesture(center.translate(40, 0));
|
||
await p1.moveBy(const Offset(-40, 0));
|
||
await p2.moveBy(const Offset(40, 0));
|
||
await p1.up();
|
||
await p2.up();
|
||
await tester.pump();
|
||
expect(zoomSource.zoomCalls.last, greaterThan(1.0), reason: '前置:已放大');
|
||
|
||
await tester.tap(find.text('PREVIEW_MARK'));
|
||
await tester.pump(const Duration(milliseconds: 100));
|
||
await tester.tap(find.text('PREVIEW_MARK'));
|
||
await tester.pump();
|
||
|
||
expect(zoomSource.zoomCalls.last, 1.0);
|
||
expect(find.text('1.0×'), findsOneWidget, reason: '复位后徽标显示 1.0×');
|
||
|
||
// 冲刷徽标隐藏计时器避免悬挂。
|
||
await tester.pump(const Duration(seconds: 1));
|
||
});
|
||
}
|