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:
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user