feat(app): wire server dedup - force passthrough + PHOTO_DUPLICATE classification & l10n
Task 8: close the gap between server-side duplicate detection (90dcc58)
and the app.
- ApiClient.mapDioException: normalize 409 wire body reusing
VALIDATION_FAILED with message=PHOTO_DUPLICATE to
ErrorCodes.photoDuplicate (message-based; auto-compat if the contract
later adds a dedicated code)
- l10n zh/en errorPhotoDuplicate; error_l10n maps the code (queue page
inherits via fallback)
- UploadEngine._classifyFailure: PHOTO_DUPLICATE joins the no-auto-retry
bucket (retrying the same init body would 409 again); force to skip
dedup travels only through the enqueue path
- UploadTasks drift table +force column (schema v2, addColumn migration;
legacy rows default false); UploadTaskDraft/UploadTask/prepare carry it
- enqueueForUpload +forcePaths; duplicate dialog "全部上传" (capture page
+ album detail page) now returns DuplicateDialogChoice so confirmed
duplicates enqueue with force=true (server adjudicates owner/admin)
- engine init body carries force only when true (absent otherwise)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -405,6 +405,63 @@ void main() {
|
||||
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('退出确认:有待传任务 → 弹确认;保留继续拍 / 确认才退出',
|
||||
(tester) async {
|
||||
await pumpCapture(tester);
|
||||
|
||||
Reference in New Issue
Block a user