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>
This commit is contained in:
@@ -25,6 +25,17 @@ import 'error_codes.dart';
|
||||
import 'models/cursor_page.dart';
|
||||
import 'session_store.dart';
|
||||
|
||||
/// 查重预检请求条目(契约 UploadDuplicateCheckItem;filename 仅元数据——
|
||||
/// 服务端查重谓词只看 bytes,与 initUpload ③.5 逐字一致)。
|
||||
typedef UploadDuplicateCheckItem = ({String filename, int bytes});
|
||||
|
||||
/// 查重预检命中(契约 UploadDuplicateCheckDuplicate;index 已从分块局部
|
||||
/// 下标换算为对入参列表的全局下标)。
|
||||
typedef UploadDuplicateHit = ({int index, int bytes, DateTime uploadedAt});
|
||||
|
||||
/// 单批查重条目上限(契约 items maxItems=500;客户端按此分块顺序调用)。
|
||||
const int kUploadDuplicateCheckChunkSize = 500;
|
||||
|
||||
class ApiClient {
|
||||
/// [dio] 可注入测试替身(自定义 HttpClientAdapter);[baseUrl] 缺省
|
||||
/// 取 [ApiConfig.baseUrl](--dart-define API_BASE 覆盖)。
|
||||
@@ -568,6 +579,53 @@ class ApiClient {
|
||||
limit: limit,
|
||||
);
|
||||
|
||||
/// operationId `checkUploadDuplicates`
|
||||
/// (POST /uploads/duplicates/check,批量只读预检)。
|
||||
///
|
||||
/// 按 [kUploadDuplicateCheckChunkSize] 顺序分块调用(契约单批上限 500;
|
||||
/// 块间串行防瞬时并发放大);各块命中的分块局部下标换算为对 [items] 的
|
||||
/// 全局下标后合并返回。空列表直接返回空(契约 items minItems=1,不发
|
||||
/// 请求)。错误经 [_send] 归一为 [AppException](404/403/400/401 见契约)。
|
||||
Future<List<UploadDuplicateHit>> checkUploadDuplicates(
|
||||
String albumId,
|
||||
List<UploadDuplicateCheckItem> items,
|
||||
) async {
|
||||
if (items.isEmpty) return const <UploadDuplicateHit>[];
|
||||
final hits = <UploadDuplicateHit>[];
|
||||
for (
|
||||
var start = 0;
|
||||
start < items.length;
|
||||
start += kUploadDuplicateCheckChunkSize
|
||||
) {
|
||||
final chunk = items
|
||||
.skip(start)
|
||||
.take(kUploadDuplicateCheckChunkSize)
|
||||
.toList();
|
||||
final resp = await _send(
|
||||
() => _dio.post<Map<String, dynamic>>(
|
||||
'/v1/uploads/duplicates/check',
|
||||
data: {
|
||||
'albumId': albumId,
|
||||
'items': [
|
||||
for (final item in chunk)
|
||||
{'filename': item.filename, 'bytes': item.bytes},
|
||||
],
|
||||
},
|
||||
),
|
||||
);
|
||||
final duplicates = (resp.data!['duplicates'] as List<dynamic>)
|
||||
.cast<Map<String, dynamic>>();
|
||||
for (final d in duplicates) {
|
||||
hits.add((
|
||||
index: start + (d['index'] as int),
|
||||
bytes: d['bytes'] as int,
|
||||
uploadedAt: DateTime.parse(d['uploadedAt'] as String),
|
||||
));
|
||||
}
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
// ── photos(契约 tag: photos)──
|
||||
|
||||
/// operationId `listAlbumPhotos`
|
||||
|
||||
@@ -11,6 +11,11 @@
|
||||
/// state=uploaded + filename+bytes 匹配 → 返回 [DuplicateCheck],由
|
||||
/// 调用方决定弹窗确认或跳过。批量场景汇总后单次弹窗(非逐张)。
|
||||
///
|
||||
/// Task 2(查重前置到入队前·统一入口):入队前查重升级为
|
||||
/// [checkDuplicatesUnified]——本机 TaskStore 历史 ∪ 服务器历史
|
||||
/// (duplicates/check 只读预检,bytes 命中);服务器失败静默降级
|
||||
/// 本机-only,不阻塞入队。
|
||||
///
|
||||
/// Task 8(服务端查重 App 侧集成):弹窗「全部上传」= 用户确认强传——
|
||||
/// 重复项以 force=true 入队([enqueueForUpload] 的 forcePaths),任务行
|
||||
/// 落库后由引擎在 initUpload 携带 force 跳过服务端查重(仅 owner/admin
|
||||
@@ -25,6 +30,8 @@ import 'package:image_picker/image_picker.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:wechat_assets_picker/wechat_assets_picker.dart';
|
||||
|
||||
import '../../core/api_client.dart'
|
||||
show UploadDuplicateHit, apiClientProvider;
|
||||
import '../../core/notification_permission.dart';
|
||||
import 'providers.dart';
|
||||
import 'task_state.dart';
|
||||
@@ -172,6 +179,77 @@ Future<List<DuplicateMatch>?> checkDuplicates(
|
||||
return matches.isEmpty ? null : matches;
|
||||
}
|
||||
|
||||
/// 统一查重(P0-1 层 1+层 2 前置,Task 2):本机 TaskStore 历史 ∪
|
||||
/// 服务器历史(POST /uploads/duplicates/check;服务端谓词仅 bytes——
|
||||
/// filename 刻意不参与,与 initUpload 查重一致)。
|
||||
///
|
||||
/// 服务器预检失败(网络/权限/任意异常)→ 静默降级本机-only(不阻塞
|
||||
/// 入队;服务端 init 409 仍是兜底)。返回 null = 无重复。
|
||||
///
|
||||
/// 服务器命中按全局下标映射回本批文件(stat 本批取 filename,与
|
||||
/// [checkDuplicates] 同模式),弹窗/强传标记的 filename 派生由此生效。
|
||||
Future<List<DuplicateMatch>?> checkDuplicatesUnified(
|
||||
WidgetRef ref, {
|
||||
required String accountId,
|
||||
required String albumId,
|
||||
required List<String> sourcePaths,
|
||||
}) async {
|
||||
if (sourcePaths.isEmpty) return null;
|
||||
|
||||
// 层 1:本机 TaskStore 历史(filename+bytes 匹配)。
|
||||
final local =
|
||||
await checkDuplicates(
|
||||
ref,
|
||||
accountId: accountId,
|
||||
albumId: albumId,
|
||||
sourcePaths: sourcePaths,
|
||||
) ??
|
||||
const <DuplicateMatch>[];
|
||||
|
||||
// 层 2:服务器历史(bytes 命中,逐请求条目一条)。
|
||||
final picks = <({String filename, int bytes})>[];
|
||||
for (final path in sourcePaths) {
|
||||
try {
|
||||
final file = File(path);
|
||||
picks.add((filename: p.basename(path), bytes: await file.length()));
|
||||
} on Object {
|
||||
// 读不到的文件不参与查重(入队时准备器会报错)。
|
||||
}
|
||||
}
|
||||
final serverHits = <UploadDuplicateHit>[];
|
||||
if (picks.isNotEmpty) {
|
||||
try {
|
||||
serverHits.addAll(
|
||||
await ref
|
||||
.read(apiClientProvider)
|
||||
.checkUploadDuplicates(albumId, picks),
|
||||
);
|
||||
} on Object {
|
||||
// 静默降级本机-only:预检是体验优化而非闸门,失败不阻塞入队
|
||||
//(服务端 init 409 仍是兜底)。
|
||||
}
|
||||
}
|
||||
|
||||
// 合并去重(filename+bytes;本机命中在前——同一重复保留先见者)。
|
||||
final seen = <String>{};
|
||||
final merged = <DuplicateMatch>[];
|
||||
for (final match in [
|
||||
...local,
|
||||
for (final hit in serverHits)
|
||||
if (hit.index >= 0 && hit.index < picks.length)
|
||||
DuplicateMatch(
|
||||
filename: picks[hit.index].filename,
|
||||
bytes: hit.bytes,
|
||||
uploadedAt: hit.uploadedAt,
|
||||
),
|
||||
]) {
|
||||
if (seen.add('${match.filename}\u0000${match.bytes}')) {
|
||||
merged.add(match);
|
||||
}
|
||||
}
|
||||
return merged.isEmpty ? null : merged;
|
||||
}
|
||||
|
||||
/// 批量入队:通知权限单次请求 + 逐张准备入队。
|
||||
///
|
||||
/// [skipPaths]:用户选择跳过的重复项;[forcePaths]:查重弹窗「全部上传」
|
||||
|
||||
@@ -342,8 +342,9 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
||||
if (paths.isEmpty || !mounted) return;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
|
||||
// P0-1 层 1:入队前查重。
|
||||
final duplicates = await checkDuplicates(
|
||||
// P0-1 层 1+2(Task 2 统一入口):入队前查重——本机 TaskStore 历史
|
||||
// ∪ 服务器历史(bytes 命中;服务器失败静默降级本机-only)。
|
||||
final duplicates = await checkDuplicatesUnified(
|
||||
ref,
|
||||
accountId: account,
|
||||
albumId: album.id,
|
||||
|
||||
@@ -453,8 +453,9 @@ class _CapturePageState extends ConsumerState<CapturePage>
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
|
||||
// P0-1 层 1:入队前查重(同相册已完成的 filename+bytes 匹配)。
|
||||
final duplicates = await checkDuplicates(
|
||||
// P0-1 层 1+2(Task 2 统一入口):入队前查重——本机 TaskStore 历史
|
||||
// ∪ 服务器历史(bytes 命中;服务器失败静默降级本机-only)。
|
||||
final duplicates = await checkDuplicatesUnified(
|
||||
ref,
|
||||
accountId: widget.target.accountId,
|
||||
albumId: widget.target.albumId,
|
||||
|
||||
@@ -557,4 +557,136 @@ void main() {
|
||||
expect(store.session?.token, 'tok-live-1', reason: '旧密码错误不清会话');
|
||||
expect(unauthorizedCalls, 0, reason: 'password-change 401 不驱逐');
|
||||
});
|
||||
|
||||
group('checkUploadDuplicates:上传查重预检(Task 2,分块)', () {
|
||||
test('≤500 项单请求:albumId + items 透传,命中含共享 bytes 逐条返回',
|
||||
() async {
|
||||
seedSession();
|
||||
final adapter = FakeAdapter(
|
||||
(o) => (
|
||||
200,
|
||||
{
|
||||
'duplicates': [
|
||||
{'index': 1, 'bytes': 2048, 'uploadedAt': '2026-09-25T08:00:00Z'},
|
||||
{'index': 0, 'bytes': 1024, 'uploadedAt': '2026-09-24T08:00:00Z'},
|
||||
// 两个请求条目共享同一命中 bytes:契约保证各返回一条。
|
||||
{'index': 2, 'bytes': 1024, 'uploadedAt': '2026-09-24T08:00:00Z'},
|
||||
],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final hits = await wired(adapter).checkUploadDuplicates('alb-1', const [
|
||||
(filename: 'a.jpg', bytes: 1024),
|
||||
(filename: 'b.jpg', bytes: 2048),
|
||||
(filename: 'c.jpg', bytes: 1024),
|
||||
]);
|
||||
|
||||
expect(adapter.requests, hasLength(1), reason: '≤500 项不分块');
|
||||
final req = adapter.requests.single;
|
||||
expect(req.method, 'POST');
|
||||
expect(req.path, '/v1/uploads/duplicates/check');
|
||||
expect(req.headers['Authorization'], 'Bearer tok-live-1');
|
||||
expect(req.body, {
|
||||
'albumId': 'alb-1',
|
||||
'items': [
|
||||
{'filename': 'a.jpg', 'bytes': 1024},
|
||||
{'filename': 'b.jpg', 'bytes': 2048},
|
||||
{'filename': 'c.jpg', 'bytes': 1024},
|
||||
],
|
||||
}, reason: '请求体对齐契约 UploadDuplicateCheckRequest');
|
||||
expect(hits, [
|
||||
(
|
||||
index: 1,
|
||||
bytes: 2048,
|
||||
uploadedAt: DateTime.utc(2026, 9, 25, 8),
|
||||
),
|
||||
(
|
||||
index: 0,
|
||||
bytes: 1024,
|
||||
uploadedAt: DateTime.utc(2026, 9, 24, 8),
|
||||
),
|
||||
(
|
||||
index: 2,
|
||||
bytes: 1024,
|
||||
uploadedAt: DateTime.utc(2026, 9, 24, 8),
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
test('>500 项分块:1200 项 → 3 个请求(500/500/200)+ 局部下标换算全局',
|
||||
() async {
|
||||
seedSession();
|
||||
final adapter = FakeAdapter((o) {
|
||||
// 每块只命中块内第 0 项:全局下标 = 分块起点 + 0。
|
||||
final items = (o.data as Map<String, dynamic>)['items'] as List<dynamic>;
|
||||
return (
|
||||
200,
|
||||
{
|
||||
'duplicates': [
|
||||
{
|
||||
'index': 0,
|
||||
'bytes': items[0]['bytes'],
|
||||
'uploadedAt': '2026-09-25T08:00:00Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
final items = List.generate(
|
||||
1200,
|
||||
(i) => (filename: 'f$i.jpg', bytes: 1000 + i),
|
||||
);
|
||||
final hits = await wired(adapter).checkUploadDuplicates('alb-1', items);
|
||||
|
||||
expect(adapter.requests, hasLength(3), reason: '契约单批上限 500');
|
||||
expect(
|
||||
adapter.requests.map(
|
||||
(r) => ((r.body as Map<String, dynamic>)['items'] as List).length,
|
||||
),
|
||||
[500, 500, 200],
|
||||
reason: '顺序分块,末块为余量',
|
||||
);
|
||||
expect(hits.map((h) => h.index), [0, 500, 1000],
|
||||
reason: '分块局部下标换算为对原始列表的全局下标');
|
||||
expect(hits.map((h) => h.bytes), [1000, 1500, 2000]);
|
||||
});
|
||||
|
||||
test('空列表不发起请求(契约 items minItems=1)', () async {
|
||||
final adapter = FakeAdapter((_) => throw StateError('不应发起请求'));
|
||||
|
||||
final hits = await wired(adapter).checkUploadDuplicates('alb-1', const []);
|
||||
|
||||
expect(hits, isEmpty);
|
||||
expect(adapter.requests, isEmpty);
|
||||
});
|
||||
|
||||
test('错误响应经 _send 归一为 AppException(404 ALBUM_NOT_FOUND)',
|
||||
() async {
|
||||
seedSession();
|
||||
final adapter = FakeAdapter(
|
||||
(o) => (
|
||||
404,
|
||||
{
|
||||
'code': 'ALBUM_NOT_FOUND',
|
||||
'message': 'no such album',
|
||||
'requestId': 'r1',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final err = await captureError(
|
||||
() => wired(adapter).checkUploadDuplicates('alb-x', const [
|
||||
(filename: 'a.jpg', bytes: 1024),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(
|
||||
err,
|
||||
isA<AppException>()
|
||||
.having((e) => e.code, 'code', ErrorCodes.albumNotFound),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -47,11 +47,13 @@ void main() {
|
||||
});
|
||||
|
||||
/// 路由:GET 详情(反映可变分类表)/ GET 照片预览(scope 自适应;默认
|
||||
/// 管理者空列表)/ 分类 CRUD;其余路径直接抛错。
|
||||
/// 管理者空列表)/ 分类 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') {
|
||||
@@ -75,6 +77,10 @@ void main() {
|
||||
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}');
|
||||
});
|
||||
|
||||
@@ -325,16 +331,19 @@ void main() {
|
||||
});
|
||||
|
||||
/// 详情页 + 上传链路替身(内存任务库/临时目录准备器/多选替身/
|
||||
/// 非平台相机源)。
|
||||
Future<void> pumpDetailForUpload(WidgetTester tester) async {
|
||||
final adapter = detailAdapter();
|
||||
/// 非平台相机源;[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(adapter),
|
||||
client: clientOf(effective),
|
||||
),
|
||||
multiImagePickerProvider
|
||||
.overrideWith((ref) => (context) async => galleryPaths),
|
||||
@@ -464,6 +473,132 @@ void main() {
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -165,7 +165,11 @@ void main() {
|
||||
categories: categories,
|
||||
);
|
||||
|
||||
Future<void> pumpCapture(WidgetTester tester, {CaptureSource? cameraSource}) async {
|
||||
Future<void> pumpCapture(
|
||||
WidgetTester tester, {
|
||||
CaptureSource? cameraSource,
|
||||
FakeAdapter? apiAdapter,
|
||||
}) async {
|
||||
final effectiveSource = cameraSource ?? source;
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
@@ -176,8 +180,12 @@ void main() {
|
||||
client: ApiClient(
|
||||
sessionStore: session,
|
||||
dio: Dio(BaseOptions(baseUrl: 'http://test.local'))
|
||||
..httpClientAdapter = FakeAdapter(
|
||||
(_) => throw StateError('连拍页不应发起 API 请求')),
|
||||
..httpClientAdapter = apiAdapter ??
|
||||
FakeAdapter(
|
||||
// 多选入队的查重预检会发 API 请求:默认打桩为失败,
|
||||
// 驱动「服务器预检失败 → 静默降级本机-only」路径。
|
||||
(_) => throw StateError('连拍页测试 API 请求一律失败'),
|
||||
),
|
||||
),
|
||||
),
|
||||
captureSourceFactoryProvider.overrideWith(
|
||||
@@ -462,6 +470,50 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user