feat(app): 选图拦截 v2——硬封锁+仅本机判定,治点选卡顿与连点强穿

问题(用户反馈):v1 服务器单项预检(2s 帽)弱网下把每次点选的勾选卡
0.5~2s(selectPredicate await 期间 selectAsset 不执行);配合「二次点击
强制选择」活口,重复照片被连点强制选中,拦截形同虚设。

- 判定仅查本机 TaskStore(毫秒级)——服务器查重退至入队统一弹窗+409 兜底
- 硬封锁:本机命中每次点击均拒选(_forced 放行集移除),文案不再引导再点
- 判定缓存升级 in-flight Future 共享(putIfAbsent):同一照片连点只判一次
  (清偿 9-29 评审登记 Minor 遗留)
- 构造去 client/serverCheckTimeout;iOS title 空放行(跨设备判定在入队弹窗)
- 已接受限制(用户拍板 A):后台已删照片重传同文件被本机历史永拦

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-29 14:24:53 +08:00
parent d0755aa466
commit f88c1c170a
7 changed files with 201 additions and 342 deletions
+49 -80
View File
@@ -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<SingleImagePicker>(
/// 张数;
/// - 权限由 picker 内部请求;永久拒绝时 picker 自带 denied/limited 视图
/// (非「返回空」);返回 null 仅发生在用户取消;
/// - 方案 A(点选拦截):selectPredicate 挂 [PickerDuplicateGate]——已
/// 上传到目标相册的照片点选当场拦下(再点一次强制选择)。
/// - 方案 A v2(点选拦截·硬封锁):selectPredicate 挂 [PickerDuplicateGate]
/// ——已上传到目标相册的照片点选当场拦下(再次点击不放行;仅本机判定)。
final multiImagePickerProvider = Provider<MultiImagePicker>(
(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<int> 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<String, bool> _verdicts = <String, bool>{};
/// 二次点击放行集(首次拦截时记入——escape hatch)。
final Set<String> _forced = <String>{};
/// per-asset 判定缓存(assetId → 判定 Future):in-flight 共享——
/// 同一照片连点(判定未完成时再点)复用同一次判定;完成后秒回。
final Map<String, Future<bool>> _verdicts = <String, Future<bool>>{};
/// 核心判定(测试直接驱动——绕开 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<bool> _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>{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,
<UploadDuplicateCheckItem>[(filename: title ?? '', bytes: bytes)],
)
.timeout(
serverCheckTimeout,
onTimeout: () => const <UploadDuplicateHit>[],
);
return hits.isNotEmpty;
final completed = await store.listByAccount(
accountId,
albumId: albumId,
states: const <UploadTaskState>{UploadTaskState.completed},
);
return completed.any(
(t) => t.filename == title && t.bytes == bytes,
);
} on Object {
// 服务器预检失败/超时:按无命中(放行——预检故障不挡选图)。
// 本机查询失败:放行(入队弹窗 + 409 兜底)。
return false;
}
} on Object {
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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.
@@ -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';
@@ -835,7 +835,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get uploadPickNone => '未选择照片';
@override
String get pickerDuplicateBlocked => '这张已上传到该相册——再点一次可强制选择';
String get pickerDuplicateBlocked => '这张已上传到该相册,无法重复选择';
@override
String get albumManagePhotos => '管理照片';
+1 -1
View File
@@ -351,7 +351,7 @@
}
},
"uploadPickNone": "未选择照片",
"pickerDuplicateBlocked": "这张已上传到该相册——再点一次可强制选择",
"pickerDuplicateBlocked": "这张已上传到该相册,无法重复选择",
"albumManagePhotos": "管理照片",
"albumTakeFirstPhoto": "拍摄第一张照片",
"albumUploadDisabledHint": "相册已关闭上传,暂不能新增照片",
@@ -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<void> pumpGateHost(
@@ -198,18 +197,20 @@ void main() {
fileSize: () async => bytes,
);
/// 种子:同账号+相册的已完成任务(本机半场命中源;runAsync 区内调用)。
/// 种子:同账号+相册的已完成任务(本机命中源;runAsync 区内调用)。
Future<void> 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': <Map<String, dynamic>>[]}),
);
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<bool> 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<String, dynamic>;
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<bool> 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<Object?>).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': <Map<String, dynamic>>[]}),
);
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': <Map<String, dynamic>>[]}),
);
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': <Map<String, dynamic>>[]}),
);
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);
});
});
}