diff --git a/apps/mobile/lib/features/uploads/enqueue.dart b/apps/mobile/lib/features/uploads/enqueue.dart index fb1048d..33db23d 100644 --- a/apps/mobile/lib/features/uploads/enqueue.dart +++ b/apps/mobile/lib/features/uploads/enqueue.dart @@ -23,12 +23,14 @@ /// 落库后由引擎在 initUpload 携带 force 跳过服务端查重(仅 owner/admin /// 生效,服务端裁决——协作者强传仍会被 409 拒绝并可见失败文案)。 /// -/// 方案 A(选图点选拦截):[multiImagePickerProvider] 经 +/// 方案 A v2(选图点选拦截·硬封锁):[multiImagePickerProvider] 经 /// AssetPickerConfig.selectPredicate 挂 [PickerDuplicateGate]——点选 -/// 「已上传到目标相册」的照片当场拦下(SnackBar 提示),再次点击同一张 -/// 强制选择;判定口径同 [checkDuplicatesUnified](本机 ∪ 服务器,当前 -/// 账号+相册,bytes 为主),查询失败一律放行(enqueue 弹窗 + init 409 -/// 兜底不变)。 +/// 「已上传到目标相册」的照片当场拦下且**再次点击也不放行**(SnackBar +/// 提示)。判定**仅查本机 TaskStore 历史**(毫秒级——v1 的服务器单项 +/// 预检在弱网下把每次点选的勾选卡到最多 2s,是「选图不灵敏」的根因); +/// 服务器查重退至入队时刻统一弹窗(checkDuplicatesUnified)+ init 409 +/// 兜底,跨设备/重装场景的重复仍在点上传时拦。已接受限制(用户拍板): +/// 后台已删除的照片重传同一文件会被本机历史永远拦下(清数据/换设备)。 library; import 'dart:io'; @@ -42,8 +44,7 @@ import 'package:path/path.dart' as p; import 'package:wechat_assets_picker/wechat_assets_picker.dart'; import '../../core/api_client.dart' - show ApiClient, UploadDuplicateCheckItem, UploadDuplicateHit, - apiClientProvider; + show UploadDuplicateHit, apiClientProvider; import '../../core/notification_permission.dart'; import '../../l10n/app_localizations.dart'; import 'providers.dart'; @@ -85,14 +86,13 @@ final singleImagePickerProvider = Provider( /// 张数; /// - 权限由 picker 内部请求;永久拒绝时 picker 自带 denied/limited 视图 /// (非「返回空」);返回 null 仅发生在用户取消; -/// - 方案 A(点选拦截):selectPredicate 挂 [PickerDuplicateGate]——已 -/// 上传到目标相册的照片点选当场拦下(再点一次强制选择)。 +/// - 方案 A v2(点选拦截·硬封锁):selectPredicate 挂 [PickerDuplicateGate] +/// ——已上传到目标相册的照片点选当场拦下(再次点击不放行;仅本机判定)。 final multiImagePickerProvider = Provider( (ref) => (context, {required accountId, required albumId}) async { - // 拦截器按次构造:每次选图会话独立的判定缓存/放行集(不跨会话残留)。 + // 拦截器按次构造:每次选图会话独立的判定缓存(不跨会话残留)。 final gate = PickerDuplicateGate( store: ref.read(taskStoreProvider), - client: ref.read(apiClientProvider), accountId: accountId, albumId: albumId, ); @@ -143,57 +143,49 @@ typedef PickerAssetFact = ({ Future Function() fileSize, }); -/// 选图点选拦截器(方案 A):挂 wechat_assets_picker 的 +/// 选图点选拦截器(方案 A v2·硬封锁):挂 wechat_assets_picker 的 /// `AssetPickerConfig.selectPredicate` 钩子(10.1.3 源码 /// asset_picker_builder_delegate.dart:982——每次点选/取消时 await,返回 -/// false 静默不选)。 +/// false 静默不选;谓词返回前勾选不出现,故判定必须快)。 /// -/// 判定口径与 [checkDuplicatesUnified] 一致(本机 TaskStore 历史 ∪ 服务器 -/// 单项预检;同账号+相册,filename+bytes / bytes);**任一半场查询异常按 -/// 该半场无命中处理,fileSize 失败等整体异常直接放行**——预检是体验优化 -/// 而非闸门,绝不因自身故障挡住选图(enqueue 弹窗 + init 409 兜底不变)。 -/// 服务器半场有 [serverCheckTimeout](默认 2s)时间帽——弱网下不让点选 -/// 静默挂到 dio 连接超时(10s)。本机命中短路服务器预检(点选瞬间延迟 -/// 考量;与批量 union 不短路不同)。 +/// v2 要点(2026-09-29 用户反馈驱动): +/// - **仅本机判定**(同账号+相册+completed,filename+bytes)——毫秒级, +/// 不走网络。v1 的服务器单项预检(2s 时间帽)在弱网下让每次点选卡 +/// 0.5~2s,配合「二次点击强制」活口,重复照片被连点强制选中,拦截 +/// 形同虚设。服务器查重移回入队时刻统一弹窗 + init 409 兜底。 +/// - **硬封锁**:本机命中的重复每次点击都拒选(无强制活口);提示文案 +/// 不再引导再点。 +/// - 判定缓存共享 in-flight Future(putIfAbsent)——同一照片连点只判定 +/// 一次;[_judge] 全捕获不抛错,缓存 Future 无失败态。 +/// - 查询异常(fileSize 失败/本机查询失败)一律放行——预检是体验优化 +/// 而非闸门;title 为空(iOS 常见)本机无从匹配 → 放行(跨设备判定 +/// 在入队弹窗)。 /// -/// 重复 → 首次点击 false + SnackBar 提示(l10n `pickerDuplicateBlocked`, -/// 经谓词 ctx 向上解析到根 ScaffoldMessenger——picker 页自带的 Scaffold -/// 之上无 messenger;呈现前 clearSnackBars——快速拦多张不排队 N 条), -/// 该 asset.id 记入放行集 → 二次点击 true(强制选择活口)。取消选择 -/// (selected=true)恒放行且不预热放行集。per-asset 判定缓存避免同一张 -/// 重复查询。 +/// 拦下时 SnackBar 提示(l10n `pickerDuplicateBlocked`,经谓词 ctx 向上 +/// 解析到根 ScaffoldMessenger——picker 页自带的 Scaffold 之上无 +/// messenger;呈现前 clearSnackBars——快速拦多张不排队 N 条)。取消选择 +/// (selected=true)恒放行。 /// /// 公开(计划原定私有类)仅为让 enqueue_test 直接构造注入替身。 class PickerDuplicateGate { PickerDuplicateGate({ required this.store, - required this.client, required this.accountId, required this.albumId, - this.serverCheckTimeout = const Duration(seconds: 2), }); /// 本机历史半场数据源(生产=taskStoreProvider)。 final TaskStore store; - /// 服务器单项预检半场客户端(生产=apiClientProvider)。 - final ApiClient client; - /// 判定范围:账号(TaskStore 账号隔离硬守护的入参)。 final String accountId; /// 判定范围:目标相册(与入队目标一致)。 final String albumId; - /// 服务器半场预检时间帽(默认 2s;可注入短值做测试)。超时=无命中, - /// 同各半场失败降级放行;底层 dio 请求不取消(其自身超时照常兜底)。 - final Duration serverCheckTimeout; - - /// per-asset 判定缓存(assetId → 是否重复):二次点击瞬回。 - final Map _verdicts = {}; - - /// 二次点击放行集(首次拦截时记入——escape hatch)。 - final Set _forced = {}; + /// per-asset 判定缓存(assetId → 判定 Future):in-flight 共享—— + /// 同一照片连点(判定未完成时再点)复用同一次判定;完成后秒回。 + final Map> _verdicts = >{}; /// 核心判定(测试直接驱动——绕开 AssetEntity 的平台通道 fileSize)。 /// @@ -204,57 +196,34 @@ class PickerDuplicateGate { bool selected, ) async { if (selected) return true; - if (_forced.contains(asset.id)) return true; // 二次点击:强制选择。 - final isDuplicate = _verdicts[asset.id] ?? await _judge(asset); - _verdicts[asset.id] = isDuplicate; + final isDuplicate = await _verdicts.putIfAbsent( + asset.id, + () => _judge(asset), + ); if (!isDuplicate) return true; - _forced.add(asset.id); - // 判定是异步的:picker 页可能在等待期间已被关闭(mounted=false, - // 也就不会再有第二次点击)——跳过提示,不跨 async 间隙用 ctx。 + // 判定是异步的:picker 页可能在等待期间已被关闭(mounted=false) + // ——跳过提示,不跨 async 间隙用 ctx。 if (ctx.mounted) _notifyBlocked(ctx); return false; } - /// 判定一张是否重复(true=重复):本机 ∪ 服务器,异常降级放行。 + /// 判定一张是否重复(true=重复):仅本机历史,异常降级放行。 Future _judge(PickerAssetFact asset) async { try { - // 文件大小:两半场共用,取一次。 final bytes = await asset.fileSize(); - - // 层 1:本机历史(同账号+相册+completed,filename+bytes)。 final title = asset.title; - if (title != null) { - try { - final completed = await store.listByAccount( - accountId, - albumId: albumId, - states: const {UploadTaskState.completed}, - ); - final hit = completed.any( - (t) => t.filename == title && t.bytes == bytes, - ); - if (hit) return true; // 本机命中短路服务器(点选延迟考量)。 - } on Object { - // 本机查询失败:该半场按无命中,服务器半场仍判。 - } - } - - // 层 2:服务器单项预检(filename 仅元数据——服务端谓词只看 bytes; - // title null 时传空串占位,判定不受影响)。时间帽内未回 = 无命中 - // ——弱网下点选不静默挂到 dio 连接超时(10s)。 + if (title == null) return false; // iOS 常见:本机无从匹配 → 放行。 try { - final hits = await client - .checkUploadDuplicates( - albumId, - [(filename: title ?? '', bytes: bytes)], - ) - .timeout( - serverCheckTimeout, - onTimeout: () => const [], - ); - return hits.isNotEmpty; + final completed = await store.listByAccount( + accountId, + albumId: albumId, + states: const {UploadTaskState.completed}, + ); + return completed.any( + (t) => t.filename == title && t.bytes == bytes, + ); } on Object { - // 服务器预检失败/超时:按无命中(放行——预检故障不挡选图)。 + // 本机查询失败:放行(入队弹窗 + 409 兜底)。 return false; } } on Object { diff --git a/apps/mobile/lib/l10n/app_en.arb b/apps/mobile/lib/l10n/app_en.arb index 909939b..4f804d8 100644 --- a/apps/mobile/lib/l10n/app_en.arb +++ b/apps/mobile/lib/l10n/app_en.arb @@ -351,7 +351,7 @@ } }, "uploadPickNone": "No photos selected", - "pickerDuplicateBlocked": "Already uploaded to this album — tap again to select anyway", + "pickerDuplicateBlocked": "This photo has already been uploaded to this album", "albumManagePhotos": "Manage photos", "albumTakeFirstPhoto": "Take the first photo", "albumUploadDisabledHint": "Uploads are off for this album. New photos cannot be added right now", diff --git a/apps/mobile/lib/l10n/app_localizations.dart b/apps/mobile/lib/l10n/app_localizations.dart index 3901577..3bdef48 100644 --- a/apps/mobile/lib/l10n/app_localizations.dart +++ b/apps/mobile/lib/l10n/app_localizations.dart @@ -1703,7 +1703,7 @@ abstract class AppLocalizations { /// No description provided for @pickerDuplicateBlocked. /// /// In zh, this message translates to: - /// **'这张已上传到该相册——再点一次可强制选择'** + /// **'这张已上传到该相册,无法重复选择'** String get pickerDuplicateBlocked; /// No description provided for @albumManagePhotos. diff --git a/apps/mobile/lib/l10n/app_localizations_en.dart b/apps/mobile/lib/l10n/app_localizations_en.dart index f61e04f..aa51a3d 100644 --- a/apps/mobile/lib/l10n/app_localizations_en.dart +++ b/apps/mobile/lib/l10n/app_localizations_en.dart @@ -865,7 +865,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get pickerDuplicateBlocked => - 'Already uploaded to this album — tap again to select anyway'; + 'This photo has already been uploaded to this album'; @override String get albumManagePhotos => 'Manage photos'; diff --git a/apps/mobile/lib/l10n/app_localizations_zh.dart b/apps/mobile/lib/l10n/app_localizations_zh.dart index ca3a121..c55f00f 100644 --- a/apps/mobile/lib/l10n/app_localizations_zh.dart +++ b/apps/mobile/lib/l10n/app_localizations_zh.dart @@ -835,7 +835,7 @@ class AppLocalizationsZh extends AppLocalizations { String get uploadPickNone => '未选择照片'; @override - String get pickerDuplicateBlocked => '这张已上传到该相册——再点一次可强制选择'; + String get pickerDuplicateBlocked => '这张已上传到该相册,无法重复选择'; @override String get albumManagePhotos => '管理照片'; diff --git a/apps/mobile/lib/l10n/app_zh.arb b/apps/mobile/lib/l10n/app_zh.arb index 21d616e..92a4990 100644 --- a/apps/mobile/lib/l10n/app_zh.arb +++ b/apps/mobile/lib/l10n/app_zh.arb @@ -351,7 +351,7 @@ } }, "uploadPickNone": "未选择照片", - "pickerDuplicateBlocked": "这张已上传到该相册——再点一次可强制选择", + "pickerDuplicateBlocked": "这张已上传到该相册,无法重复选择", "albumManagePhotos": "管理照片", "albumTakeFirstPhoto": "拍摄第一张照片", "albumUploadDisabledHint": "相册已关闭上传,暂不能新增照片", diff --git a/apps/mobile/test/features/uploads/enqueue_test.dart b/apps/mobile/test/features/uploads/enqueue_test.dart index 1c82b81..775a54f 100644 --- a/apps/mobile/test/features/uploads/enqueue_test.dart +++ b/apps/mobile/test/features/uploads/enqueue_test.dart @@ -12,7 +12,6 @@ /// [PickerAssetFact] 记录绕开 AssetEntity.fileSize 的平台通道。 library; -import 'dart:async'; import 'dart:io'; import 'package:dio/dio.dart'; @@ -163,7 +162,7 @@ void main() { ); }); - group('选图点选拦截器 PickerDuplicateGate(方案 A)', () { + group('选图点选拦截器 PickerDuplicateGate(v2 硬封锁:仅本机判定)', () { /// 宿主树:MaterialApp(含根 ScaffoldMessenger + l10n)——SnackBar 提示 /// 与文案断言的载体;测试环境默认 en locale(同页级测试)。 Future pumpGateHost( @@ -198,18 +197,20 @@ void main() { fileSize: () async => bytes, ); - /// 种子:同账号+相册的已完成任务(本机半场命中源;runAsync 区内调用)。 + /// 种子:同账号+相册的已完成任务(本机命中源;runAsync 区内调用)。 Future seedCompletedTask( TaskStore store, String filename, - int bytes, - ) async { + int bytes, { + String accountId = 'alice', + String albumId = 'alb-1', + }) async { final seed = await store.create( UploadTaskDraft( - id: 'task-seed', - accountId: 'alice', - albumId: 'alb-1', - clientTaskId: 'ct-seed', + id: 'task-seed-$accountId-$albumId', + accountId: accountId, + albumId: albumId, + clientTaskId: 'ct-seed-$accountId-$albumId', sourceFilePath: '/tmp/seed.jpg', filename: filename, bytes: bytes, @@ -234,56 +235,41 @@ void main() { await tester.pumpAndSettle(); } - testWidgets('① 本机命中 → 首次拒选+提示;二次点击强制放行;本机命中短路服务器预检', + testWidgets('① 本机命中 → 每次点击均拒选(无强制活口)+ 提示不堆积', (tester) async { - final session = FakeSessionStore() - ..session = Session( - token: 'tok-1', - expiresAt: DateTime.utc(2099, 1, 1), - account: 'alice', - ); final store = TaskStore.memory(); addTearDown(() => store.close()); - // 服务器半场打桩为空命中(若被调用则请求被记录——本机命中应短路)。 - final adapter = FakeAdapter( - (o) => (200, {'duplicates': >[]}), - ); - final client = ApiClient( - sessionStore: session, - dio: Dio(BaseOptions(baseUrl: 'http://test.local')) - ..httpClientAdapter = adapter, - ); late BuildContext ctx; await pumpGateHost(tester, (c) => ctx = c); final gate = PickerDuplicateGate( store: store, - client: client, accountId: 'alice', albumId: 'alb-1', ); final asset = gateAsset(bytes: kJpgBytes.length); - late bool first; - late bool second; + late List verdicts; await tester.runAsync(() async { await seedCompletedTask(store, 'pick1.jpg', kJpgBytes.length); - first = await gate.decideFact(ctx, asset, false); - second = await gate.decideFact(ctx, asset, false); + verdicts = [ + await gate.decideFact(ctx, asset, false), + await gate.decideFact(ctx, asset, false), + await gate.decideFact(ctx, asset, false), + ]; }); - expect(first, isFalse, reason: '本机历史命中 → 首次点选拦下'); - expect(second, isTrue, reason: '二次点击同一张 → 强制选择(escape hatch)'); - expect(adapter.requests, isEmpty, - reason: '本机命中短路服务器单项预检(点选延迟考量)'); + expect( + verdicts, + everyElement(isFalse), + reason: 'v2 硬封锁:连点三次均拒选(二次点击强制已移除)', + ); - // 拦截提示:谓词 ctx 经根 messenger 呈现 SnackBar(en 文案)。 + // 拦截提示:谓词 ctx 经根 messenger 呈现 SnackBar(en 新文案)。 await tester.pump(); expect(find.byType(SnackBar), findsOneWidget); expect( - find.text( - 'Already uploaded to this album — tap again to select anyway', - ), + find.text('This photo has already been uploaded to this album'), findsOneWidget, ); @@ -299,286 +285,157 @@ void main() { expect(otherBlocked, isFalse, reason: '另一重复资产同样拦下'); await tester.pumpAndSettle(); expect( - find.text( - 'Already uploaded to this album — tap again to select anyway', - ), + find.text('This photo has already been uploaded to this album'), findsOneWidget, reason: '呈现前清旧条——当前仅一条提示', ); ScaffoldMessenger.of(ctx).hideCurrentSnackBar(); await tester.pumpAndSettle(); expect( - find.text( - 'Already uploaded to this album — tap again to select anyway', - ), + find.text('This photo has already been uploaded to this album'), findsNothing, reason: '隐藏后无第二条在队列等待(不堆积)', ); }); - testWidgets('② 服务器单项命中 → 拒选(单项请求携带 filename+bytes)', - (tester) async { - final session = FakeSessionStore() - ..session = Session( - token: 'tok-1', - expiresAt: DateTime.utc(2099, 1, 1), - account: 'alice', - ); + testWidgets('② 非重复(bytes/文件名/相册/账号 任一不同)→ 放行', ( + tester, + ) async { final store = TaskStore.memory(); addTearDown(() => store.close()); - // 本机无历史;服务器预检回 index=0 命中(本批仅此一项)。 - final adapter = FakeAdapter( - (o) => ( - 200, - { - 'duplicates': [ - { - 'index': 0, - 'bytes': kJpgBytes.length, - 'uploadedAt': '2026-01-01T00:00:00Z', - }, - ], - }, - ), - ); - final client = ApiClient( - sessionStore: session, - dio: Dio(BaseOptions(baseUrl: 'http://test.local')) - ..httpClientAdapter = adapter, - ); late BuildContext ctx; await pumpGateHost(tester, (c) => ctx = c); final gate = PickerDuplicateGate( store: store, - client: client, accountId: 'alice', albumId: 'alb-1', ); - - late bool first; await tester.runAsync(() async { - first = await gate.decideFact( + await seedCompletedTask(store, 'pick1.jpg', kJpgBytes.length); + }); + + late bool diffBytes; + late bool diffName; + late bool diffAlbum; + late bool diffAccount; + await tester.runAsync(() async { + diffBytes = await gate.decideFact( ctx, - gateAsset(bytes: kJpgBytes.length), + gateAsset(id: 'a-bytes', bytes: 12345), + false, + ); + diffName = await gate.decideFact( + ctx, + gateAsset(id: 'a-name', title: 'other.jpg', bytes: kJpgBytes.length), + false, + ); + // 判定范围(相册/账号)不同 → 各自构造 gate 验证隔离。 + diffAlbum = await PickerDuplicateGate( + store: store, + accountId: 'alice', + albumId: 'alb-2', + ).decideFact( + ctx, + gateAsset(id: 'a-album', bytes: kJpgBytes.length), + false, + ); + diffAccount = await PickerDuplicateGate( + store: store, + accountId: 'bob', + albumId: 'alb-1', + ).decideFact( + ctx, + gateAsset(id: 'a-account', bytes: kJpgBytes.length), false, ); }); - expect(first, isFalse, reason: '服务器历史命中 → 拒选'); - expect(adapter.requests, hasLength(1), reason: '单项预检恰好一次'); - final body = adapter.requests.single.body as Map; - expect(body['items'], hasLength(1)); + expect(diffBytes, isTrue, reason: '字节数不同 → 非重复'); + expect(diffName, isTrue, reason: '文件名不同 → 非重复'); + expect(diffAlbum, isTrue, reason: '其它相册的历史不算本相册重复'); + expect(diffAccount, isTrue, reason: '其它账号的历史不算本人重复'); + }); + + testWidgets('③ in-flight 共享:同一资产并发连点只判定一次', (tester) async { + final store = TaskStore.memory(); + addTearDown(() => store.close()); + + late BuildContext ctx; + await pumpGateHost(tester, (c) => ctx = c); + final gate = PickerDuplicateGate( + store: store, + accountId: 'alice', + albumId: 'alb-1', + ); + var sizeCalls = 0; + final asset = ( + id: 'asset-1', + title: 'pick1.jpg' as String?, + fileSize: () async { + sizeCalls++; + return kJpgBytes.length; + }, + ); + + late List verdicts; + await tester.runAsync(() async { + await seedCompletedTask(store, 'pick1.jpg', kJpgBytes.length); + verdicts = await Future.wait([ + gate.decideFact(ctx, asset, false), + gate.decideFact(ctx, asset, false), + ]); + }); + + expect(verdicts, everyElement(isFalse), reason: '并发两次均按重复拦下'); expect( - (body['items'] as List).single, - {'filename': 'pick1.jpg', 'bytes': kJpgBytes.length}, - reason: '请求条目 = asset.title + 文件字节数', + sizeCalls, + 1, + reason: '判定缓存共享 in-flight Future——连点只查一次(清偿登记遗留)', ); await drainBlockedSnackBar(tester, ctx); }); - testWidgets('③ 两路皆空 → 放行(判定缓存:二次调用不再发预检)', - (tester) async { - final session = FakeSessionStore() - ..session = Session( - token: 'tok-1', - expiresAt: DateTime.utc(2099, 1, 1), - account: 'alice', - ); + testWidgets('④ title 为空(iOS 场景)→ 放行(本机无从匹配)', ( + tester, + ) async { final store = TaskStore.memory(); addTearDown(() => store.close()); - final adapter = FakeAdapter( - (o) => (200, {'duplicates': >[]}), - ); - final client = ApiClient( - sessionStore: session, - dio: Dio(BaseOptions(baseUrl: 'http://test.local')) - ..httpClientAdapter = adapter, - ); late BuildContext ctx; await pumpGateHost(tester, (c) => ctx = c); final gate = PickerDuplicateGate( store: store, - client: client, accountId: 'alice', albumId: 'alb-1', ); - - late bool first; - late bool second; await tester.runAsync(() async { - first = await gate.decideFact( - ctx, - gateAsset(bytes: kJpgBytes.length), - false, - ); - second = await gate.decideFact( - ctx, - gateAsset(bytes: kJpgBytes.length), - false, - ); + await seedCompletedTask(store, 'pick1.jpg', kJpgBytes.length); }); - expect(first, isTrue, reason: '本机/服务器均无历史 → 放行'); - expect(second, isTrue, reason: '缓存判定直接放行'); - expect(adapter.requests, hasLength(1), - reason: '同一资产二次点选用缓存,不重复预检'); - }); - - testWidgets('④ 服务器预检异常 → 放行(预检故障不挡选图)', (tester) async { - final session = FakeSessionStore() - ..session = Session( - token: 'tok-1', - expiresAt: DateTime.utc(2099, 1, 1), - account: 'alice', - ); - final store = TaskStore.memory(); - addTearDown(() => store.close()); - // 本机无历史 + 服务器半场抛错(网络/权限等任意异常)。 - final adapter = FakeAdapter( - (_) => throw StateError('预检失败'), - ); - final client = ApiClient( - sessionStore: session, - dio: Dio(BaseOptions(baseUrl: 'http://test.local')) - ..httpClientAdapter = adapter, - ); - - late BuildContext ctx; - await pumpGateHost(tester, (c) => ctx = c); - final gate = PickerDuplicateGate( - store: store, - client: client, - accountId: 'alice', - albumId: 'alb-1', - ); - late bool verdict; await tester.runAsync(() async { verdict = await gate.decideFact( ctx, - gateAsset(bytes: kJpgBytes.length), + gateAsset(id: 'a-null', title: null, bytes: kJpgBytes.length), false, ); }); expect(verdict, isTrue, - reason: '预检异常放行——enqueue 弹窗 + init 409 仍是兜底'); - expect(adapter.requests, hasLength(1)); + reason: 'title 空 → 本机半场无从匹配 → 放行(服务器判定在入队弹窗)'); }); - testWidgets('⑤ 取消选择恒放行(不预热放行集,不发起查询)', (tester) async { - final session = FakeSessionStore() - ..session = Session( - token: 'tok-1', - expiresAt: DateTime.utc(2099, 1, 1), - account: 'alice', - ); + testWidgets('⑤ fileSize 获取失败 → 放行(判定故障不挡选图)', ( + tester, + ) async { final store = TaskStore.memory(); addTearDown(() => store.close()); - final adapter = FakeAdapter( - (o) => (200, {'duplicates': >[]}), - ); - final client = ApiClient( - sessionStore: session, - dio: Dio(BaseOptions(baseUrl: 'http://test.local')) - ..httpClientAdapter = adapter, - ); late BuildContext ctx; await pumpGateHost(tester, (c) => ctx = c); final gate = PickerDuplicateGate( store: store, - client: client, - accountId: 'alice', - albumId: 'alb-1', - ); - final asset = gateAsset(bytes: kJpgBytes.length); - - late bool deselect; - late bool reselect; - await tester.runAsync(() async { - await seedCompletedTask(store, 'pick1.jpg', kJpgBytes.length); - deselect = await gate.decideFact(ctx, asset, true); - reselect = await gate.decideFact(ctx, asset, false); - }); - - expect(deselect, isTrue, reason: '取消选择(selected=true)恒放行'); - expect(reselect, isFalse, - reason: '取消选择不预热放行集——再点选仍按重复拦截'); - expect(adapter.requests, isEmpty, - reason: '取消选择路径不发起任何查询(本机命中也短路服务器)'); - await drainBlockedSnackBar(tester, ctx); - }); - - testWidgets('⑥ 服务器预检超时(注入 50ms 帽)→ 放行(时间帽=无命中)', - (tester) async { - final session = FakeSessionStore() - ..session = Session( - token: 'tok-1', - expiresAt: DateTime.utc(2099, 1, 1), - account: 'alice', - ); - final store = TaskStore.memory(); - addTearDown(() => store.close()); - // 永不回包的适配器:请求受理但响应挂起(模拟弱网下预检吊住—— - // 不等 dio 默认超时,靠注入的短时间帽放行)。 - final neverResponds = Completer<(int, Object?)>(); - final adapter = FakeAdapter((o) => neverResponds.future); - final client = ApiClient( - sessionStore: session, - dio: Dio(BaseOptions(baseUrl: 'http://test.local')) - ..httpClientAdapter = adapter, - ); - - late BuildContext ctx; - await pumpGateHost(tester, (c) => ctx = c); - final gate = PickerDuplicateGate( - store: store, - client: client, - accountId: 'alice', - albumId: 'alb-1', - serverCheckTimeout: const Duration(milliseconds: 50), - ); - - late bool verdict; - await tester.runAsync(() async { - verdict = await gate.decideFact( - ctx, - gateAsset(bytes: kJpgBytes.length), - false, - ); - }); - - expect(verdict, isTrue, reason: '超时=无命中 → 放行(绝不吊起点选)'); - expect(adapter.requests, hasLength(1), reason: '预检确已发出(只是没回)'); - }); - - testWidgets('⑦ fileSize 获取失败 → 放行(预检故障不挡选图)', - (tester) async { - final session = FakeSessionStore() - ..session = Session( - token: 'tok-1', - expiresAt: DateTime.utc(2099, 1, 1), - account: 'alice', - ); - final store = TaskStore.memory(); - addTearDown(() => store.close()); - final adapter = FakeAdapter( - (o) => (200, {'duplicates': >[]}), - ); - final client = ApiClient( - sessionStore: session, - dio: Dio(BaseOptions(baseUrl: 'http://test.local')) - ..httpClientAdapter = adapter, - ); - - late BuildContext ctx; - await pumpGateHost(tester, (c) => ctx = c); - final gate = PickerDuplicateGate( - store: store, - client: client, accountId: 'alice', albumId: 'alb-1', ); @@ -597,8 +454,41 @@ void main() { }); expect(verdict, isTrue, reason: 'fileSize 失败 → 放行'); - expect(adapter.requests, isEmpty, - reason: 'fileSize 失败短路两半场——不发起任何查询'); + }); + + testWidgets('⑥ 取消选择恒放行且零查询;再点选仍按重复拦下', (tester) async { + final store = TaskStore.memory(); + addTearDown(() => store.close()); + + late BuildContext ctx; + await pumpGateHost(tester, (c) => ctx = c); + final gate = PickerDuplicateGate( + store: store, + accountId: 'alice', + albumId: 'alb-1', + ); + var sizeCalls = 0; + final asset = ( + id: 'asset-1', + title: 'pick1.jpg' as String?, + fileSize: () async { + sizeCalls++; + return kJpgBytes.length; + }, + ); + + late bool deselect; + late bool reselect; + await tester.runAsync(() async { + await seedCompletedTask(store, 'pick1.jpg', kJpgBytes.length); + deselect = await gate.decideFact(ctx, asset, true); + reselect = await gate.decideFact(ctx, asset, false); + }); + + expect(deselect, isTrue, reason: '取消选择(selected=true)恒放行'); + expect(sizeCalls, 1, reason: '仅再点选触发一次判定;取消路径零查询'); + expect(reselect, isFalse, reason: '再点选仍按重复拦下(硬封锁)'); + await drainBlockedSnackBar(tester, ctx); }); }); }