7e5c550fb3
方案 A(选图点选拦截):multiImagePickerProvider 经 wechat_assets_picker 10.1.3 官方钩子 AssetPickerConfig.selectPredicate 挂 PickerDuplicateGate—— 点选「已上传到目标相册」的照片当场拦下(SnackBar 提示),再次点击同一张 强制选择(escape hatch)。 - MultiImagePicker typedef 增参 accountId/albumId(拦截判定范围=当前 目标相册;相册详情/连拍页两处调用点传入); - 判定口径同 checkDuplicatesUnified:本机 TaskStore(同账号+相册+ completed,filename+bytes)∪ 服务器单项预检(bytes;本机命中短路 服务器,点选延迟考量);任一半场异常按该半场无命中,fileSize 失败等 整体异常直接放行——enqueue 弹窗 + init 409 兜底不变; - per-asset 判定缓存(二次点击瞬回)+ 二次点击放行集;取消选择恒放行 且不预热放行集; - AssetEntity 事实适配(PickerAssetFact 测试接缝):fileSize 为 photo_manager 3.12 的 Future<int> 平台通道 getter(entity.dart:778), 惰性求值;title 为 String?(Android 恒有值;iOS 需 needTitle——null 时本机半场跳过,服务器按 bytes 仍可判); - l10n pickerDuplicateBlocked(zh/en);测试替身签名同步 (album_detail/capture_page 页测 + integration_test app_boot), 页测补 (accountId, albumId) 接线断言;gate 五态用例入 enqueue_test。 Co-Authored-By: Claude Code <noreply@anthropic.com>
689 lines
27 KiB
Dart
689 lines
27 KiB
Dart
/// 相册详情页 widget 测试(UI-03 §4 页面 06-08 重设计后)。
|
||
///
|
||
/// 覆盖:封面 → 标题/时区时间场地 → 状态徽章 → 次级四入口(Photos/
|
||
/// Members/Categories/Share)→ Recent photos 三列网格 + See all → 底部
|
||
/// 固定上传栏(拍摄上传主 / 从相册选择次,上传关闭时禁用并说明);空相册
|
||
/// 安静占位;协作者(scope all 403 → own)隐藏管理入口;分类入口跳转
|
||
/// 独立分类页(页面 12,旧内嵌区块已移除);底部栏直达连拍页与多选入队。
|
||
library;
|
||
|
||
import 'dart:io';
|
||
|
||
import 'package:camera/camera.dart' show 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/error_codes.dart';
|
||
import 'package:photo_live/core/notification_permission.dart';
|
||
import 'package:photo_live/core/session_store.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/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/album_categories_page.dart';
|
||
import 'package:photo_live/pages/album_detail_page.dart';
|
||
import 'package:photo_live/pages/capture_page.dart';
|
||
|
||
import '../helpers/fakes.dart';
|
||
|
||
/// JPEG 魔数最小字节(入队准备器真实文件驱动)。
|
||
const List<int> kJpgBytes = <int>[
|
||
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00,
|
||
];
|
||
|
||
void main() {
|
||
late FakeSessionStore store;
|
||
late List<Map<String, dynamic>> categories;
|
||
|
||
setUp(() {
|
||
store = FakeSessionStore();
|
||
categories = <Map<String, dynamic>>[
|
||
categoryJson(id: 'cat-1', sortOrder: 0),
|
||
];
|
||
});
|
||
|
||
/// 路由:GET 详情(反映可变分类表)/ GET 照片预览(scope 自适应;默认
|
||
/// 管理者空列表)/ 分类 CRUD / 查重预检([duplicateCheck],Task 2);
|
||
/// 其余路径直接抛错(含未配置的查重预检——驱动「服务器失败静默降级」)。
|
||
FakeAdapter detailAdapter({
|
||
List<Map<String, dynamic>>? photos,
|
||
bool collaborator = false,
|
||
(int, Object?)? Function(String method, String path)? onCategoryMutation,
|
||
(int, Object?)? duplicateCheck,
|
||
}) =>
|
||
FakeAdapter((o) {
|
||
if (o.path == '/v1/albums/alb-1' && o.method == 'GET') {
|
||
return (200, albumJson(categories: categories));
|
||
}
|
||
if (o.path == '/v1/albums/alb-1/photos' && o.method == 'GET') {
|
||
final scope = o.uri.queryParameters['scope'];
|
||
if (collaborator && scope == 'all') {
|
||
return (403, errorBody(ErrorCodes.authForbidden));
|
||
}
|
||
return (200, cursorPageBody(photos ?? <Map<String, dynamic>>[]));
|
||
}
|
||
if (o.path == '/v1/albums/alb-1/categories' && o.method == 'POST') {
|
||
return onCategoryMutation?.call('POST', o.path) ??
|
||
(201, categoryJson(id: 'cat-new'));
|
||
}
|
||
if (o.path.startsWith('/v1/categories/') && o.method == 'PATCH') {
|
||
return onCategoryMutation?.call('PATCH', o.path) ??
|
||
(200, categoryJson(id: 'cat-1'));
|
||
}
|
||
if (o.path.startsWith('/v1/categories/') && o.method == 'DELETE') {
|
||
return onCategoryMutation?.call('DELETE', o.path) ?? (204, null);
|
||
}
|
||
if (o.path == '/v1/uploads/duplicates/check' && o.method == 'POST') {
|
||
if (duplicateCheck != null) return duplicateCheck;
|
||
throw StateError('unexpected ${o.method} ${o.path}');
|
||
}
|
||
throw StateError('unexpected ${o.method} ${o.path}');
|
||
});
|
||
|
||
ApiClient clientOf(FakeAdapter adapter) => ApiClient(
|
||
sessionStore: store,
|
||
dio: Dio(BaseOptions(baseUrl: 'http://test.local'))
|
||
..httpClientAdapter = adapter,
|
||
);
|
||
|
||
Future<void> pumpDetail(WidgetTester tester, ApiClient client) async {
|
||
await tester.pumpWidget(
|
||
ProviderScope(
|
||
// 详情页触达 sessionStoreProvider(上传入口账号),真实平台通道
|
||
// 在测试环境挂起,必须注入替身。
|
||
overrides: [...pageTestOverrides(session: store, client: client)],
|
||
child: MaterialApp(
|
||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||
supportedLocales: AppLocalizations.supportedLocales,
|
||
home: const AlbumDetailPage(albumId: 'alb-1'),
|
||
),
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
}
|
||
|
||
/// 新布局下分类区在折叠线以下:先滚动到位再交互。
|
||
Future<void> reveal(WidgetTester tester, Finder finder) async {
|
||
await tester.ensureVisible(finder);
|
||
await tester.pumpAndSettle();
|
||
}
|
||
|
||
testWidgets('布局结构:封面 → 信息 → 四入口 → Recent photos → 底部上传栏',
|
||
(tester) async {
|
||
final adapter = detailAdapter(
|
||
photos: List<Map<String, dynamic>>.generate(
|
||
3,
|
||
(i) => adminPhotoJson(
|
||
photoId: 'ph-${(i + 1).toString().padLeft(2, '0')}',
|
||
preview: photoPreviewJson(),
|
||
),
|
||
),
|
||
);
|
||
await pumpDetail(tester, clientOf(adapter));
|
||
|
||
// 封面占位(无 cover → 中性占位,非转圈)+ 标题 + 单行时间/场地
|
||
// (相册时区墙钟 + 场地语言回退)。
|
||
final body = find.byType(SingleChildScrollView);
|
||
expect(
|
||
find.descendant(of: body, matching: find.text('Opening Gala')),
|
||
findsOneWidget,
|
||
);
|
||
expect(
|
||
find.text('2026/9/18 ~ Not set (Asia/Shanghai) · 大宴会厅'),
|
||
findsOneWidget,
|
||
);
|
||
expect(find.byIcon(Icons.image_outlined), findsOneWidget);
|
||
// Recent photos 网格缩略图(签名 URL 测试环境失败 → 替代图,不破图)。
|
||
expect(find.byIcon(Icons.broken_image_outlined), findsNWidgets(3));
|
||
|
||
// 状态区:公开/上传徽章(关闭不等于不公开,分别呈现)。
|
||
expect(find.text('Public'), findsOneWidget);
|
||
expect(find.text('Uploads on'), findsOneWidget);
|
||
expect(find.text('Download'), findsNothing);
|
||
expect(find.text('Original'), findsNothing);
|
||
expect(find.text('sharetoken000000000001'), findsNothing);
|
||
|
||
// Recent photos:分区标题 + 「See all」入口 + 三列网格。
|
||
expect(find.text('Recent photos'), findsOneWidget);
|
||
expect(find.text('See all'), findsOneWidget);
|
||
|
||
// 次级四入口(管理者全量)。入口文字限定在四入口卡内(页面下方
|
||
// 「分类管理」分区标题同为 Categories 文案,按 Card 作用域区分)。
|
||
final tilesCard = find.byType(Card).first;
|
||
expect(find.descendant(of: tilesCard, matching: find.text('Photos')),
|
||
findsOneWidget);
|
||
expect(find.descendant(of: tilesCard, matching: find.text('Members')),
|
||
findsOneWidget);
|
||
expect(find.descendant(of: tilesCard, matching: find.text('Categories')),
|
||
findsOneWidget);
|
||
expect(find.descendant(of: tilesCard, matching: find.text('Share')),
|
||
findsOneWidget);
|
||
// 顶栏「⋯」操作菜单入口。
|
||
expect(find.byIcon(Icons.more_vert), findsOneWidget);
|
||
|
||
// 底部固定上传栏:拍摄上传(主)+ 从相册选择(次)。
|
||
final capture = find.widgetWithText(FilledButton, 'Capture and upload');
|
||
final pick = find.widgetWithText(OutlinedButton, 'Pick from gallery');
|
||
expect(capture, findsOneWidget);
|
||
expect(pick, findsOneWidget);
|
||
|
||
// 垂直顺序:标题 → 徽章 → 四入口 → Recent photos → 底部栏。全宽
|
||
// 16:9 封面较高,先滚动让 Recent photos 入屏再比较几何位置。
|
||
await reveal(tester, find.text('Recent photos'));
|
||
final titleTop = tester
|
||
.getRect(find.descendant(of: body, matching: find.text('Opening Gala')))
|
||
.top;
|
||
final badgeTop = tester.getRect(find.text('Public')).top;
|
||
final tilesTop = tester
|
||
.getRect(find.descendant(of: tilesCard, matching: find.text('Photos')))
|
||
.top;
|
||
final recentTop = tester.getRect(find.text('Recent photos')).top;
|
||
final captureTop = tester.getRect(capture).top;
|
||
expect(titleTop, lessThan(badgeTop));
|
||
expect(badgeTop, lessThan(tilesTop));
|
||
expect(tilesTop, lessThan(recentTop));
|
||
expect(recentTop, lessThan(captureTop));
|
||
});
|
||
|
||
testWidgets('无照片:空相册安静占位「Ready for your first photo」',
|
||
(tester) async {
|
||
await pumpDetail(tester, clientOf(detailAdapter()));
|
||
|
||
expect(find.text('Ready for your first photo'), findsOneWidget);
|
||
expect(
|
||
find.text('Photos you take or pick will appear here'),
|
||
findsOneWidget,
|
||
);
|
||
// 空态无伪造计数/示例照片,上传入口收敛到底部栏(上传开启时可用)。
|
||
expect(find.text('No photos yet'), findsNothing);
|
||
final capture = find.widgetWithText(FilledButton, 'Capture and upload');
|
||
expect(
|
||
tester.widget<FilledButton>(capture).onPressed,
|
||
isNotNull,
|
||
reason: '上传开启时底部栏可用',
|
||
);
|
||
});
|
||
|
||
testWidgets('上传关闭:底部栏说明原因并禁用双入口', (tester) async {
|
||
final adapter = FakeAdapter((o) {
|
||
if (o.path == '/v1/albums/alb-1' && o.method == 'GET') {
|
||
return (200, albumJson(categories: categories, uploadEnabled: false));
|
||
}
|
||
if (o.path == '/v1/albums/alb-1/photos' && o.method == 'GET') {
|
||
return (200, cursorPageBody(<Map<String, dynamic>>[]));
|
||
}
|
||
throw StateError('unexpected ${o.method} ${o.path}');
|
||
});
|
||
await pumpDetail(tester, clientOf(adapter));
|
||
|
||
expect(
|
||
find.text(
|
||
'Uploads are off for this album. New photos cannot be added right now',
|
||
),
|
||
findsOneWidget,
|
||
);
|
||
expect(
|
||
tester
|
||
.widget<FilledButton>(
|
||
find.widgetWithText(FilledButton, 'Capture and upload'),
|
||
)
|
||
.onPressed,
|
||
isNull,
|
||
);
|
||
expect(
|
||
tester
|
||
.widget<OutlinedButton>(
|
||
find.widgetWithText(OutlinedButton, 'Pick from gallery'),
|
||
)
|
||
.onPressed,
|
||
isNull,
|
||
);
|
||
});
|
||
|
||
testWidgets('协作者(all 403 → own):隐藏管理入口,仅见自己上传预览',
|
||
(tester) async {
|
||
final adapter = detailAdapter(
|
||
photos: <Map<String, dynamic>>[
|
||
adminPhotoJson(
|
||
photoId: 'ph-own-1',
|
||
uploaderAccount: 'staff02',
|
||
preview: photoPreviewJson(),
|
||
),
|
||
],
|
||
collaborator: true,
|
||
);
|
||
await pumpDetail(tester, clientOf(adapter));
|
||
|
||
// scope 自适应:all(403)→ own(200)。
|
||
final photoRequests = adapter.requests
|
||
.where((r) => r.path == '/v1/albums/alb-1/photos')
|
||
.toList();
|
||
expect(photoRequests, hasLength(2));
|
||
expect(photoRequests.first.uri.queryParameters['scope'], 'all');
|
||
expect(photoRequests.last.uri.queryParameters['scope'], 'own');
|
||
|
||
// 管理入口仅创建者/管理员可见;Photos/Share 对全体成员开放。
|
||
expect(find.text('Members'), findsNothing);
|
||
expect(find.text('Categories'), findsNothing, reason: '含分区一并隐藏');
|
||
expect(find.text('Photos'), findsOneWidget);
|
||
expect(find.text('Share'), findsOneWidget);
|
||
expect(find.byIcon(Icons.add_circle_outline), findsNothing);
|
||
|
||
// 预览网格退化为自己的上传(1 张;封面占位另 1 张)。
|
||
expect(find.byIcon(Icons.broken_image_outlined), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('分类入口:跳转独立分类页(旧内嵌区块已移除)', (tester) async {
|
||
final adapter = detailAdapter();
|
||
await pumpDetail(tester, clientOf(adapter));
|
||
|
||
// 旧内嵌分类管理区块不再存在于详情页。
|
||
expect(find.byIcon(Icons.add_circle_outline), findsNothing);
|
||
expect(find.text('仪式 · Ceremony'), findsNothing);
|
||
|
||
// Categories 入口 → 独立分类页(页面 12)。入口在折叠线下,先滚动
|
||
// 可见再点击。
|
||
final tilesCard = find.byType(Card).first;
|
||
final categoriesTile =
|
||
find.descendant(of: tilesCard, matching: find.text('Categories'));
|
||
await reveal(tester, categoriesTile);
|
||
await tester.tap(categoriesTile);
|
||
await tester.pumpAndSettle();
|
||
expect(find.byType(AlbumCategoriesPage), findsOneWidget);
|
||
expect(find.text('仪式'), findsOneWidget, reason: '分类列表(默认语言名)');
|
||
|
||
// 返回后详情重载(分类随详情返回)。
|
||
await tester.pageBack();
|
||
await tester.pumpAndSettle();
|
||
expect(find.byType(AlbumCategoriesPage), findsNothing);
|
||
expect(
|
||
adapter.requests
|
||
.where((r) => r.method == 'GET' && r.path == '/v1/albums/alb-1')
|
||
.length,
|
||
greaterThanOrEqualTo(2),
|
||
);
|
||
});
|
||
|
||
|
||
group('底部固定上传栏(原 Task 9 FAB 流迁移)', () {
|
||
late Directory tmp;
|
||
late List<String> galleryPaths;
|
||
late List<(String, String)> pickerScopes;
|
||
late TaskStore taskStore;
|
||
|
||
setUp(() async {
|
||
store.session = Session(
|
||
token: 'tok-1',
|
||
expiresAt: DateTime.utc(2099, 1, 1),
|
||
account: 'alice',
|
||
);
|
||
tmp = await Directory.systemTemp.createTemp('detail-upload-');
|
||
galleryPaths = <String>[];
|
||
// 方案 A 接线断言:选图器收到的 (accountId, albumId)。
|
||
pickerScopes = <(String, String)>[];
|
||
taskStore = TaskStore.memory();
|
||
});
|
||
|
||
tearDown(() async {
|
||
await taskStore.close();
|
||
await tmp.delete(recursive: true);
|
||
});
|
||
|
||
/// 详情页 + 上传链路替身(内存任务库/临时目录准备器/多选替身/
|
||
/// 非平台相机源;[adapter] 可注入查重预检路由,Task 2)。
|
||
Future<void> pumpDetailForUpload(
|
||
WidgetTester tester, {
|
||
FakeAdapter? adapter,
|
||
}) async {
|
||
final effective = adapter ?? detailAdapter();
|
||
await tester.pumpWidget(
|
||
ProviderScope(
|
||
overrides: [
|
||
...pageTestOverrides(
|
||
session: store,
|
||
taskStore: taskStore,
|
||
client: clientOf(effective),
|
||
),
|
||
multiImagePickerProvider.overrideWith(
|
||
(ref) => (context, {required accountId, required albumId}) async {
|
||
pickerScopes.add((accountId, albumId));
|
||
return galleryPaths;
|
||
},
|
||
),
|
||
uploadPreparerProvider.overrideWith(
|
||
(ref) => UploadPreparer(
|
||
taskStore,
|
||
baseDirProvider: () async => tmp,
|
||
),
|
||
),
|
||
notificationPermissionProvider.overrideWith(
|
||
(ref) => NotificationPermission(isAndroid: false),
|
||
),
|
||
captureSourceFactoryProvider.overrideWith(
|
||
(ref) => ({bool preferFront = false}) async =>
|
||
_StaticCaptureSource(),
|
||
),
|
||
],
|
||
child: MaterialApp(
|
||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||
supportedLocales: AppLocalizations.supportedLocales,
|
||
home: const AlbumDetailPage(albumId: 'alb-1'),
|
||
),
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
}
|
||
|
||
testWidgets('「拍摄上传」→ 直达连拍页(目标相册带出)', (tester) async {
|
||
await pumpDetailForUpload(tester);
|
||
|
||
await tester.tap(find.text('Capture and upload'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.byType(CapturePage), findsOneWidget);
|
||
// 连拍页常驻信息层显示目标相册名(与详情标题同源:界面语言回退)。
|
||
expect(
|
||
find.descendant(
|
||
of: find.byType(CapturePage),
|
||
matching: find.text('Opening Gala'),
|
||
),
|
||
findsOneWidget,
|
||
);
|
||
});
|
||
|
||
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 pumpDetailForUpload(tester);
|
||
|
||
// 入队链路含真实文件 IO:真实事件循环内触发行内动作。
|
||
final button = find.widgetWithText(OutlinedButton, 'Pick from gallery');
|
||
await tester.runAsync(() async {
|
||
(button.evaluate().single.widget as OutlinedButton).onPressed!();
|
||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||
});
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(
|
||
find.text('Added 2 photo(s) to the upload queue'),
|
||
findsOneWidget,
|
||
);
|
||
// 方案 A 接线:拦截判定范围随调用点传入(当前账号+当前相册)。
|
||
expect(pickerScopes, [('alice', 'alb-1')]);
|
||
final tasks = await taskStore.listByAccount('alice');
|
||
expect(tasks, hasLength(2));
|
||
expect(tasks.every((t) => t.albumId == 'alb-1'), isTrue);
|
||
});
|
||
|
||
testWidgets('查重弹窗「全部上传」→ 重复项 force 入队(Task 8)',
|
||
(tester) async {
|
||
// 既有已完成任务:与 pick1 同名同字节(本地查重命中一张)。
|
||
final seed = await taskStore.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 taskStore.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 pumpDetailForUpload(tester);
|
||
|
||
// 触发选图:真实 IO 区完成查重,随后弹窗挂起等待选择。
|
||
final button = find.widgetWithText(OutlinedButton, 'Pick from gallery');
|
||
await tester.runAsync(() async {
|
||
(button.evaluate().single.widget as OutlinedButton).onPressed!();
|
||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||
});
|
||
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));
|
||
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 taskStore.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('服务器历史命中(本机无记录)→ 统一查重弹窗 + force 入队(Task 2)',
|
||
(tester) async {
|
||
// THE 用户场景:新设备/重装——本机 TaskStore 空,服务器有同 bytes
|
||
// 历史(预检命中也应在点击上传时刻弹确认)。
|
||
final adapter = detailAdapter(
|
||
duplicateCheck: (
|
||
200,
|
||
{
|
||
'duplicates': [
|
||
{
|
||
'index': 0,
|
||
'bytes': kJpgBytes.length,
|
||
'uploadedAt': '2026-09-25T08:00:00Z',
|
||
},
|
||
],
|
||
},
|
||
),
|
||
);
|
||
// pick1 与服务器历史同 bytes(命中);pick2 字节数不同(不命中)。
|
||
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 pumpDetailForUpload(tester, adapter: adapter);
|
||
|
||
final button = find.widgetWithText(OutlinedButton, 'Pick from gallery');
|
||
await tester.runAsync(() async {
|
||
(button.evaluate().single.widget as OutlinedButton).onPressed!();
|
||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||
});
|
||
await tester.pump(const Duration(milliseconds: 300));
|
||
expect(
|
||
find.text('可能已上传过'),
|
||
findsOneWidget,
|
||
reason: '服务器历史命中(本机空)也弹查重确认',
|
||
);
|
||
expect(find.textContaining('1 张照片可能已上传到该相册'), findsOneWidget);
|
||
expect(
|
||
find.text('跳过重复,上传 1 张'),
|
||
findsOneWidget,
|
||
reason: '另 1 张不重复 → 提供跳过重复入口',
|
||
);
|
||
// 请求形状:albumId + 逐项 filename/bytes(index 指向该 items 数组)。
|
||
final req = adapter.requests.firstWhere(
|
||
(r) => r.path == '/v1/uploads/duplicates/check',
|
||
);
|
||
expect(req.method, 'POST');
|
||
expect(req.body, {
|
||
'albumId': 'alb-1',
|
||
'items': [
|
||
{'filename': 'pick1.jpg', 'bytes': kJpgBytes.length},
|
||
{'filename': 'pick2.jpg', 'bytes': kJpgBytes.length + 1},
|
||
],
|
||
});
|
||
|
||
// 「全部上传」= 确认强传:仅服务器命中的 pick1 force=true。
|
||
await tester.tap(find.text('全部上传 2 张'));
|
||
await tester.pump(const Duration(milliseconds: 100));
|
||
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 taskStore.listByAccount('alice');
|
||
expect(tasks, hasLength(2));
|
||
expect(
|
||
tasks.firstWhere((t) => t.filename == 'pick1.jpg').force,
|
||
isTrue,
|
||
reason: '服务器命中的重复项经「全部上传」确认强传',
|
||
);
|
||
expect(
|
||
tasks.firstWhere((t) => t.filename == 'pick2.jpg').force,
|
||
isFalse,
|
||
reason: '非重复项正常查重语义',
|
||
);
|
||
});
|
||
|
||
testWidgets('服务器预检失败 → 静默降级本机-only:无重复不弹窗直接入队(Task 2)',
|
||
(tester) async {
|
||
// 本机 TaskStore 空 + 服务器 500:降级后无重复 → 不阻塞入队。
|
||
final adapter = detailAdapter(
|
||
duplicateCheck: (500, errorBody(ErrorCodes.internalError)),
|
||
);
|
||
final pick1 = File('${tmp.path}/pick1.jpg')..writeAsBytesSync(kJpgBytes);
|
||
galleryPaths.add(pick1.path);
|
||
await pumpDetailForUpload(tester, adapter: adapter);
|
||
|
||
final button = find.widgetWithText(OutlinedButton, 'Pick from gallery');
|
||
await tester.runAsync(() async {
|
||
(button.evaluate().single.widget as OutlinedButton).onPressed!();
|
||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||
});
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('可能已上传过'), findsNothing, reason: '静默降级不弹窗');
|
||
expect(
|
||
find.text('Added 1 photo(s) to the upload queue'),
|
||
findsOneWidget,
|
||
reason: '降级不阻塞入队(服务端 init 409 仍是兜底)',
|
||
);
|
||
});
|
||
|
||
testWidgets('双方均无重复(服务器空命中)→ 不弹窗直接入队(Task 2)',
|
||
(tester) async {
|
||
final adapter = detailAdapter(
|
||
duplicateCheck: (200, {'duplicates': <Map<String, dynamic>>[]}),
|
||
);
|
||
final pick1 = File('${tmp.path}/pick1.jpg')..writeAsBytesSync(kJpgBytes);
|
||
galleryPaths.add(pick1.path);
|
||
await pumpDetailForUpload(tester, adapter: adapter);
|
||
|
||
final button = find.widgetWithText(OutlinedButton, 'Pick from gallery');
|
||
await tester.runAsync(() async {
|
||
(button.evaluate().single.widget as OutlinedButton).onPressed!();
|
||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||
});
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('可能已上传过'), findsNothing);
|
||
expect(find.text('Added 1 photo(s) to the upload queue'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('同 basename 兄弟文件:仅命中那张被标记/跳过(评审 #2 路径精确)',
|
||
(tester) async {
|
||
// 批内两个同名文件(不同目录、不同字节):仅 dirA 那张服务器命中。
|
||
// basename 派生会把两张都误标为重复(「跳过重复」丢非重复项);
|
||
// 路径精确派生只标记命中那张。
|
||
final adapter = detailAdapter(
|
||
duplicateCheck: (
|
||
200,
|
||
{
|
||
'duplicates': [
|
||
{
|
||
'index': 0,
|
||
'bytes': kJpgBytes.length,
|
||
'uploadedAt': '2026-09-25T08:00:00Z',
|
||
},
|
||
],
|
||
},
|
||
),
|
||
);
|
||
// 同步建目录:fake-async 区内不做真实异步 IO(同 enqueue_test 教训)。
|
||
final dirA = Directory('${tmp.path}/a')..createSync();
|
||
final dirB = Directory('${tmp.path}/b')..createSync();
|
||
final hit = File('${dirA.path}/IMG_SAME.jpg')
|
||
..writeAsBytesSync(kJpgBytes);
|
||
final other = File('${dirB.path}/IMG_SAME.jpg')
|
||
..writeAsBytesSync([...kJpgBytes, 0x00]);
|
||
galleryPaths.addAll([hit.path, other.path]);
|
||
await pumpDetailForUpload(tester, adapter: adapter);
|
||
|
||
final button = find.widgetWithText(OutlinedButton, 'Pick from gallery');
|
||
await tester.runAsync(() async {
|
||
(button.evaluate().single.widget as OutlinedButton).onPressed!();
|
||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||
});
|
||
await tester.pump(const Duration(milliseconds: 300));
|
||
expect(find.text('可能已上传过'), findsOneWidget);
|
||
expect(find.textContaining('1 张照片可能已上传到该相册'), findsOneWidget,
|
||
reason: '仅命中那张计为重复(同名兄弟不误伤)');
|
||
expect(find.text('跳过重复,上传 1 张'), findsOneWidget);
|
||
|
||
// 「跳过重复」:仅未命中的同名兄弟入队,命中那张被跳过。
|
||
await tester.tap(find.text('跳过重复,上传 1 张'));
|
||
await tester.pump(const Duration(milliseconds: 100));
|
||
await tester.runAsync(() async {
|
||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||
});
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.textContaining('1 张已入队,1 张重复已跳过'), findsOneWidget);
|
||
final tasks = await taskStore.listByAccount('alice');
|
||
expect(tasks, hasLength(1), reason: '命中那张被跳过,仅同名兄弟入队');
|
||
expect(
|
||
tasks.single.bytes,
|
||
kJpgBytes.length + 1,
|
||
reason: '入队的是未命中那张(字节不同可区分;sourceFilePath 已是'
|
||
'准备器复制路径,原始选图路径不再保留);命中那张(同 basename)'
|
||
'被精确跳过',
|
||
);
|
||
});
|
||
});
|
||
}
|
||
|
||
/// 相机源替身(详情页跳连拍页测试:仅就绪,不拍摄)。extends 继承
|
||
/// 接口默认实现(闪光灯/变焦能力默认不支持——UI 不显示开关不启用手势)。
|
||
class _StaticCaptureSource extends CaptureSource {
|
||
@override
|
||
Future<void> initialize() async {}
|
||
|
||
@override
|
||
Future<XFile> capture() => throw UnimplementedError();
|
||
|
||
@override
|
||
Widget buildPreview() => const SizedBox();
|
||
|
||
@override
|
||
Future<void> dispose() async {}
|
||
}
|