feat(app): block selecting already-uploaded photos at picker tap (double-tap to force)

方案 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>
This commit is contained in:
浅念
2026-09-29 10:10:49 +08:00
parent 2261b8aebc
commit 7e5c550fb3
13 changed files with 563 additions and 17 deletions
@@ -93,7 +93,10 @@ class ItAppHarness {
ref.onDispose(() => client.close());
return client;
}),
multiImagePickerProvider.overrideWithValue((context) async => picker.consume()),
multiImagePickerProvider.overrideWithValue(
(context, {required accountId, required albumId}) async =>
picker.consume(),
),
singleImagePickerProvider.overrideWithValue(() async {
final paths = picker.consume();
return paths.isEmpty ? null : paths.first;
+173 -5
View File
@@ -22,27 +22,46 @@
/// 重复项以 force=true 入队([enqueueForUpload] 的 forcePaths),任务行
/// 落库后由引擎在 initUpload 携带 force 跳过服务端查重(仅 owner/admin
/// 生效,服务端裁决——协作者强传仍会被 409 拒绝并可见失败文案)。
///
/// 方案 A(选图点选拦截):[multiImagePickerProvider] 经
/// AssetPickerConfig.selectPredicate 挂 [PickerDuplicateGate]——点选
/// 「已上传到目标相册」的照片当场拦下(SnackBar 提示),再次点击同一张
/// 强制选择;判定口径同 [checkDuplicatesUnified](本机 ∪ 服务器,当前
/// 账号+相册,bytes 为主),查询失败一律放行(enqueue 弹窗 + init 409
/// 兜底不变)。
library;
import 'dart:io';
import 'package:flutter/widgets.dart' show BuildContext, Localizations;
import 'package:flutter/material.dart' show ScaffoldMessenger, SnackBar;
import 'package:flutter/widgets.dart'
show BuildContext, Localizations, Text;
import 'package:flutter_riverpod/flutter_riverpod.dart';
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;
show ApiClient, UploadDuplicateCheckItem, UploadDuplicateHit,
apiClientProvider;
import '../../core/notification_permission.dart';
import '../../l10n/app_localizations.dart';
import 'providers.dart';
import 'task_state.dart';
import 'task_store.dart';
/// 单图选择(准备失败重试的重新选图路径);返回源文件路径或 null(取消)。
typedef SingleImagePicker = Future<String?> Function();
/// 多图选择(相册多选入队);返回源文件路径列表(空=取消)。
typedef MultiImagePicker = Future<List<String>> Function(BuildContext context);
///
/// [accountId]/[albumId] 为点选拦截的判定范围(方案 A:当前目标相册;
/// 与入队目标同源)——两处调用点(相册详情/连拍页)均持有,必传。
typedef MultiImagePicker = Future<List<String>> Function(
BuildContext context, {
required String accountId,
required String albumId,
});
/// 单图选择 Provider(生产=image_picker;测试 override)。
final singleImagePickerProvider = Provider<SingleImagePicker>(
@@ -65,9 +84,18 @@ final singleImagePickerProvider = Provider<SingleImagePicker>(
/// 该张(null 过滤),不丢弃整批;入队数由调用方 SnackBar 反映实际
/// 张数;
/// - 权限由 picker 内部请求;永久拒绝时 picker 自带 denied/limited 视图
/// (非「返回空」);返回 null 仅发生在用户取消。
/// (非「返回空」);返回 null 仅发生在用户取消;
/// - 方案 A(点选拦截):selectPredicate 挂 [PickerDuplicateGate]——已
/// 上传到目标相册的照片点选当场拦下(再点一次强制选择)。
final multiImagePickerProvider = Provider<MultiImagePicker>(
(ref) => (context) async {
(ref) => (context, {required accountId, required albumId}) async {
// 拦截器按次构造:每次选图会话独立的判定缓存/放行集(不跨会话残留)。
final gate = PickerDuplicateGate(
store: ref.read(taskStoreProvider),
client: ref.read(apiClientProvider),
accountId: accountId,
albumId: albumId,
);
final locale = Localizations.maybeLocaleOf(context);
final assets = await AssetPicker.pickAssets(
context,
@@ -75,6 +103,14 @@ final multiImagePickerProvider = Provider<MultiImagePicker>(
requestType: RequestType.image,
maxAssets: kPickerSoftMaxAssets,
textDelegate: assetPickerTextDelegateFromLocale(locale),
// 点选拦截(selectPredicate 签名 FutureOr<bool> Function(ctx,
// asset, selected)——false 静默不选);AssetEntity →
// [PickerAssetFact] 适配(fileSize 惰性求值)在此完成。
selectPredicate: (ctx, asset, selected) => gate.decideFact(
ctx,
(id: asset.id, title: asset.title, fileSize: () => asset.fileSize),
selected,
),
),
);
if (assets == null) return const <String>[];
@@ -91,6 +127,138 @@ final multiImagePickerProvider = Provider<MultiImagePicker>(
/// 选图软上限(用户要求不设硬限制;9999 为防误触极端值)。
const int kPickerSoftMaxAssets = 9999;
/// 拦截器所需的最小资产事实([PickerDuplicateGate] 的测试接缝)。
///
/// AssetEntity → 此记录的适配在 [multiImagePickerProvider] 内完成:
/// - `fileSize`:photo_manager 3.12 为 `Future<int> get fileSize`
/// (entity.dart:778——平台通道异步,无同步 length 字段);以惰性闭包
/// 传入,缓存命中/取消选择/二次点击路径均不发起查询;
/// - `title`:`String?`——iOS 需 FilterOption.needTitle 才非空
/// (wechat_assets_picker 默认 filterOptions 仅对 audio 开),Android
/// 侧无条件携带(ConvertUtils.kt 无条件 `title to entity.displayName`);
/// null → 本机半场跳过(服务器按 bytes 仍可判)。
typedef PickerAssetFact = ({
String id,
String? title,
Future<int> Function() fileSize,
});
/// 选图点选拦截器(方案 A):挂 wechat_assets_picker 的
/// `AssetPickerConfig.selectPredicate` 钩子(10.1.3 源码
/// asset_picker_builder_delegate.dart:982——每次点选/取消时 await,返回
/// false 静默不选)。
///
/// 判定口径与 [checkDuplicatesUnified] 一致(本机 TaskStore 历史 ∪ 服务器
/// 单项预检;同账号+相册,filename+bytes / bytes);**任一半场查询异常按
/// 该半场无命中处理,fileSize 失败等整体异常直接放行**——预检是体验优化
/// 而非闸门,绝不因自身故障挡住选图(enqueue 弹窗 + init 409 兜底不变)。
/// 本机命中短路服务器预检(点选瞬间延迟考量;与批量 union 不短路不同)。
///
/// 重复 → 首次点击 false + SnackBar 提示(l10n `pickerDuplicateBlocked`,
/// 经谓词 ctx 向上解析到根 ScaffoldMessenger——picker 页自带的 Scaffold
/// 之上无 messenger),该 asset.id 记入放行集 → 二次点击 true(强制选择
/// 活口)。取消选择(selected=true)恒放行且不预热放行集。per-asset
/// 判定缓存避免同一张重复查询。
///
/// 公开(计划原定私有类)仅为让 enqueue_test 直接构造注入替身。
class PickerDuplicateGate {
PickerDuplicateGate({
required this.store,
required this.client,
required this.accountId,
required this.albumId,
});
/// 本机历史半场数据源(生产=taskStoreProvider)。
final TaskStore store;
/// 服务器单项预检半场客户端(生产=apiClientProvider)。
final ApiClient client;
/// 判定范围:账号(TaskStore 账号隔离硬守护的入参)。
final String accountId;
/// 判定范围:目标相册(与入队目标一致)。
final String albumId;
/// per-asset 判定缓存(assetId → 是否重复):二次点击瞬回。
final Map<String, bool> _verdicts = <String, bool>{};
/// 二次点击放行集(首次拦截时记入——escape hatch)。
final Set<String> _forced = <String>{};
/// 核心判定(测试直接驱动——绕开 AssetEntity 的平台通道 fileSize)。
///
/// [selected]=true 表示当前已选中、本次点击为**取消选择** → 恒放行。
Future<bool> decideFact(
BuildContext ctx,
PickerAssetFact asset,
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;
if (!isDuplicate) return true;
_forced.add(asset.id);
// 判定是异步的:picker 页可能在等待期间已被关闭(mounted=false,
// 也就不会再有第二次点击)——跳过提示,不跨 async 间隙用 ctx。
if (ctx.mounted) _notifyBlocked(ctx);
return false;
}
/// 判定一张是否重复(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 时传空串占位,判定不受影响)。
try {
final hits = await client.checkUploadDuplicates(
albumId,
<UploadDuplicateCheckItem>[(filename: title ?? '', bytes: bytes)],
);
return hits.isNotEmpty;
} on Object {
// 服务器预检失败:按无命中(放行——预检故障不挡选图)。
return false;
}
} on Object {
// fileSize 失败等任意异常:放行。
return false;
}
}
/// 拦截提示(谓词 ctx → 根 ScaffoldMessenger)。
void _notifyBlocked(BuildContext ctx) {
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(ctx).pickerDuplicateBlocked),
),
);
}
}
/// 查重弹窗选择结果(Task 8):null=取消(不上传)。
///
/// 「跳过重复」→ skipPaths=重复项(仅非重复入队);「全部上传」→
+1
View File
@@ -349,6 +349,7 @@
}
},
"uploadPickNone": "No photos selected",
"pickerDuplicateBlocked": "Already uploaded to this album — tap again to select anyway",
"albumManagePhotos": "Manage photos",
"albumTakeFirstPhoto": "Take the first photo",
"albumUploadDisabledHint": "Uploads are off for this album. New photos cannot be added right now",
@@ -1688,6 +1688,12 @@ abstract class AppLocalizations {
/// **'未选择照片'**
String get uploadPickNone;
/// No description provided for @pickerDuplicateBlocked.
///
/// In zh, this message translates to:
/// **'这张已上传到该相册——再点一次可强制选择'**
String get pickerDuplicateBlocked;
/// No description provided for @albumManagePhotos.
///
/// In zh, this message translates to:
@@ -856,6 +856,10 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get uploadPickNone => 'No photos selected';
@override
String get pickerDuplicateBlocked =>
'Already uploaded to this album — tap again to select anyway';
@override
String get albumManagePhotos => 'Manage photos';
@@ -828,6 +828,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get uploadPickNone => '未选择照片';
@override
String get pickerDuplicateBlocked => '这张已上传到该相册——再点一次可强制选择';
@override
String get albumManagePhotos => '管理照片';
+1
View File
@@ -349,6 +349,7 @@
}
},
"uploadPickNone": "未选择照片",
"pickerDuplicateBlocked": "这张已上传到该相册——再点一次可强制选择",
"albumManagePhotos": "管理照片",
"albumTakeFirstPhoto": "拍摄第一张照片",
"albumUploadDisabledHint": "相册已关闭上传,暂不能新增照片",
+6 -1
View File
@@ -338,7 +338,12 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
final l10n = AppLocalizations.of(context);
final account = await _currentAccountId();
if (account == null || !mounted) return;
final paths = await ref.read(multiImagePickerProvider)(context);
// 方案 A:accountId/albumId 传入选图器(点选拦截的判定范围)。
final paths = await ref.read(multiImagePickerProvider)(
context,
accountId: account,
albumId: album.id,
);
if (paths.isEmpty || !mounted) return;
final messenger = ScaffoldMessenger.of(context);
+6 -1
View File
@@ -448,7 +448,12 @@ class _CapturePageState extends ConsumerState<CapturePage>
/// 相册多选入队(目标 = 当前相册与分类;入口=底部缩略图位)。
Future<void> _pickFromGallery() async {
final paths = await ref.read(multiImagePickerProvider)(context);
// 方案 A:目标账号/相册传入选图器(点选拦截的判定范围)。
final paths = await ref.read(multiImagePickerProvider)(
context,
accountId: widget.target.accountId,
albumId: widget.target.albumId,
);
if (paths.isEmpty || !mounted) return;
final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
@@ -1,15 +1,23 @@
/// checkDuplicatesUnified 单元测试(Task 2 评审 #1/#3)。
/// checkDuplicatesUnified 单元测试(Task 2 评审 #1/#3)+ 选图点选拦截器
/// [PickerDuplicateGate] 五态用例(方案 A)。
///
/// 页级测试覆盖弹窗三态;本文件钉住合并语义:同一文件被本机 TaskStore
/// 与服务器历史同时命中 → 单条结果(不重复呈现)且本地 uploadedAt 优先。
/// 经 Consumer 捕获 WidgetRef 驱动真实函数——drift/文件 stat 均真实异步,
/// 整体跑在 runAsync(真实事件循环)窗口内(同页级测试的 real-io 模式;
/// fake-async 区内不做真实异步 IO)。
///
/// 拦截器用例直接构造 [PickerDuplicateGate](构造注入真实内存 TaskStore +
/// dio 打桩 ApiClient,同 checkDuplicatesUnified 的替身面);资产事实用
/// [PickerAssetFact] 记录绕开 AssetEntity.fileSize 的平台通道。
library;
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart'
show BuildContext, Builder, MaterialApp, Scaffold, ScaffoldMessenger,
SnackBar;
import 'package:flutter/widgets.dart' show SizedBox;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -18,6 +26,7 @@ import 'package:photo_live/core/session_store.dart';
import 'package:photo_live/features/uploads/enqueue.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 '../../helpers/fakes.dart';
@@ -152,4 +161,327 @@ void main() {
reason: '两张均送预检(含本地已命中的那张——union 语义不做短路)',
);
});
group('选图点选拦截器 PickerDuplicateGate(方案 A)', () {
/// 宿主树:MaterialApp(含根 ScaffoldMessenger + l10n)——SnackBar 提示
/// 与文案断言的载体;测试环境默认 en locale(同页级测试)。
Future<void> pumpGateHost(
WidgetTester tester,
void Function(BuildContext) capture,
) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: Builder(
builder: (context) {
capture(context);
return const SizedBox();
},
),
),
),
);
}
/// 测试资产事实(fileSize 惰性求值——gate 缓存未命中才调用)。
PickerAssetFact gateAsset({
String id = 'asset-1',
String? title = 'pick1.jpg',
required int bytes,
}) =>
(
id: id,
title: title,
fileSize: () async => bytes,
);
/// 种子:同账号+相册的已完成任务(本机半场命中源;runAsync 区内调用)。
Future<void> seedCompletedTask(
TaskStore store,
String filename,
int bytes,
) async {
final seed = await store.create(
UploadTaskDraft(
id: 'task-seed',
accountId: 'alice',
albumId: 'alb-1',
clientTaskId: 'ct-seed',
sourceFilePath: '/tmp/seed.jpg',
filename: filename,
bytes: bytes,
contentType: 'image/jpeg',
),
);
for (final state in [
UploadTaskState.queued,
UploadTaskState.uploading,
UploadTaskState.uploadedConfirming,
UploadTaskState.completed,
]) {
await store.updateState(seed.id, state);
}
}
/// 拦截提示收尾:等 SnackBar 挂载 → 断言由调用方完成 → 隐藏并冲刷,
/// 避免 4s 自动关闭 timer 残留到 testWidgets 收尾。
Future<void> drainBlockedSnackBar(WidgetTester tester, BuildContext ctx) async {
await tester.pump();
ScaffoldMessenger.of(ctx).hideCurrentSnackBar();
await tester.pumpAndSettle();
}
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;
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);
});
expect(first, isFalse, reason: '本机历史命中 → 首次点选拦下');
expect(second, isTrue, reason: '二次点击同一张 → 强制选择(escape hatch)');
expect(adapter.requests, isEmpty,
reason: '本机命中短路服务器单项预检(点选延迟考量)');
// 拦截提示:谓词 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',
),
findsOneWidget,
);
await drainBlockedSnackBar(tester, ctx);
});
testWidgets('② 服务器单项命中 → 拒选(单项请求携带 filename+bytes)',
(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());
// 本机无历史;服务器预检回 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(
ctx,
gateAsset(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(
(body['items'] as List<Object?>).single,
{'filename': 'pick1.jpg', 'bytes': kJpgBytes.length},
reason: '请求条目 = asset.title + 文件字节数',
);
await drainBlockedSnackBar(tester, ctx);
});
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',
);
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,
);
});
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),
false,
);
});
expect(verdict, isTrue,
reason: '预检异常放行——enqueue 弹窗 + init 409 仍是兜底');
expect(adapter.requests, hasLength(1));
});
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 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);
});
});
}
@@ -312,6 +312,7 @@ void main() {
group('底部固定上传栏(原 Task 9 FAB 流迁移)', () {
late Directory tmp;
late List<String> galleryPaths;
late List<(String, String)> pickerScopes;
late TaskStore taskStore;
setUp(() async {
@@ -322,6 +323,8 @@ void main() {
);
tmp = await Directory.systemTemp.createTemp('detail-upload-');
galleryPaths = <String>[];
// 方案 A 接线断言:选图器收到的 (accountId, albumId)。
pickerScopes = <(String, String)>[];
taskStore = TaskStore.memory();
});
@@ -345,8 +348,12 @@ void main() {
taskStore: taskStore,
client: clientOf(effective),
),
multiImagePickerProvider
.overrideWith((ref) => (context) async => galleryPaths),
multiImagePickerProvider.overrideWith(
(ref) => (context, {required accountId, required albumId}) async {
pickerScopes.add((accountId, albumId));
return galleryPaths;
},
),
uploadPreparerProvider.overrideWith(
(ref) => UploadPreparer(
taskStore,
@@ -406,6 +413,8 @@ void main() {
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);
+11 -2
View File
@@ -105,6 +105,8 @@ void main() {
late Directory tmp;
late FakeCaptureSource source;
late List<String> galleryPaths;
// 方案 A 接线断言:选图器收到的 (accountId, albumId)。
late List<(String, String)> pickerScopes;
/// 工厂调用记录(前后摄切换断言)。
late List<bool> factoryPreferFront;
@@ -144,6 +146,7 @@ void main() {
final shot3 = File('${tmp.path}/IMG_0003.jpg')..writeAsBytesSync(kJpgBytes);
source = FakeCaptureSource([shot1, shot2, shot3]);
galleryPaths = <String>[];
pickerScopes = <(String, String)>[];
factoryPreferFront = <bool>[];
});
@@ -203,8 +206,12 @@ void main() {
notificationPermissionProvider.overrideWith(
(ref) => NotificationPermission(isAndroid: false),
),
multiImagePickerProvider
.overrideWith((ref) => (context) async => galleryPaths),
multiImagePickerProvider.overrideWith(
(ref) => (context, {required accountId, required albumId}) async {
pickerScopes.add((accountId, albumId));
return galleryPaths;
},
),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
@@ -410,6 +417,8 @@ void main() {
await tester.pump(const Duration(milliseconds: 100));
expect(find.text('Added 2 photo(s) to the upload queue'), findsOneWidget);
// 方案 A 接线:拦截判定范围随调用点传入(连拍目标账号+相册)。
expect(pickerScopes, [('alice', 'alb-1')]);
expect(await store.listByAccount('alice'), hasLength(2));
});
@@ -28,9 +28,9 @@
- Modify: `apps/mobile/lib/l10n/app_zh.arb`、`app_en.arb`(+pickerDuplicateBlocked)
- Test: `apps/mobile/test/features/uploads/enqueue_test.dart`(gate 用例)、`test/pages/{album_detail_page,capture_page}_test.dart`、`integration_test/helpers/app_boot.dart`(替身签名)
- [ ] **Step 1: 失败测试**(gate 五态:本机命中首次拒/二次放行;服务器单项命中拒;两路空放行;服务器异常放行;取消选择恒放行——用 ProviderScope override 注入 fake taskStore/apiClient)
- [ ] **Step 2: 确认失败 → 实现**(先查 photo_manager AssetEntity 文件大小 API 与 title 语义)
- [ ] **Step 3: 全绿**(flutter analyze + 全量 test)
- [ ] **Step 4: Commit** `feat(app): block selecting already-uploaded photos at picker tap (double-tap to force)`
- [x] **Step 1: 失败测试**(gate 五态:本机命中首次拒/二次放行;服务器单项命中拒;两路空放行;服务器异常放行;取消选择恒放行——用 ProviderScope override 注入 fake taskStore/apiClient)
- [x] **Step 2: 确认失败 → 实现**(先查 photo_manager AssetEntity 文件大小 API 与 title 语义)
- [x] **Step 3: 全绿**(flutter analyze + 全量 test)
- [x] **Step 4: Commit** `feat(app): block selecting already-uploaded photos at picker tap (double-tap to force)`
**风险与回退:** 谓词异步查询在点选瞬间有 ~100-300ms 延迟(有缓存后二次即瞬回);查询失败放行=最坏回到现状(弹窗+409 兜底);revert 单 commit 即回退。置灰视觉(方案 B)不在本次。