Files
photo-live/apps/mobile/test/pages/capture_page_test.dart
T
浅念 0fa23e015e feat(app): server-side duplicate pre-check at enqueue moment
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>
2026-09-28 19:17:35 +08:00

646 lines
24 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.
/// 连拍页 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));
});
}