Files
photo-live/apps/mobile/test/features/uploads/enqueue_test.dart
T
浅念 7e5c550fb3 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>
2026-09-29 10:10:49 +08:00

488 lines
16 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 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';
import 'package:photo_live/core/api_client.dart';
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';
/// JPEG 魔数最小字节(同页级测试)。
const List<int> kJpgBytes = <int>[
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00,
];
void main() {
testWidgets('本机∪服务器:同文件双命中 → 单条结果 + sourcePath + 本地时间优先',
(tester) async {
final session = FakeSessionStore()
..session = Session(
token: 'tok-1',
expiresAt: DateTime.utc(2099, 1, 1),
account: 'alice',
);
final store = TaskStore.memory();
final tmp = Directory.systemTemp.createTempSync('enqueue-test-');
addTearDown(() async {
await store.close();
try {
tmp.deleteSync(recursive: true);
} on Object {
// Windows 句柄残留容忍(同页级测试)。
}
});
// 层 2 命中源:服务器预检命中 pick1(index 0;uploadedAt 早于本地)。
final adapter = FakeAdapter(
(o) => (
200,
{
'duplicates': [
{
'index': 0,
'bytes': kJpgBytes.length,
'uploadedAt': '2020-01-01T00:00:00Z',
},
],
},
),
);
final client = ApiClient(
sessionStore: session,
dio: Dio(BaseOptions(baseUrl: 'http://test.local'))
..httpClientAdapter = adapter,
);
late WidgetRef capturedRef;
await tester.pumpWidget(
ProviderScope(
overrides: [
...pageTestOverrides(
session: session,
taskStore: store,
client: client,
),
],
child: Consumer(
builder: (context, ref, _) {
capturedRef = ref;
return const SizedBox();
},
),
),
);
// 本批照片(同步写盘;pick2 字节不同 = 两层的非命中)。
final pick1 = File('${tmp.path}/pick1.jpg')..writeAsBytesSync(kJpgBytes);
final pick2 = File('${tmp.path}/pick2.jpg')
..writeAsBytesSync([...kJpgBytes, 0x00]);
// 层 1 命中源 + 统一查重:真实异步链整体在真实事件循环内完成。
final seededAt = DateTime.utc(2026, 9, 20, 12);
late final DateTime seedCreatedAt;
late List<DuplicateMatch>? result;
await tester.runAsync(() async {
final seed = await store.create(
UploadTaskDraft(
id: 'task-seed',
accountId: 'alice',
albumId: 'alb-1',
clientTaskId: 'ct-seed',
sourceFilePath: '${tmp.path}/seed.jpg',
filename: 'pick1.jpg',
bytes: kJpgBytes.length,
contentType: 'image/jpeg',
),
now: seededAt,
);
for (final state in [
UploadTaskState.queued,
UploadTaskState.uploading,
UploadTaskState.uploadedConfirming,
UploadTaskState.completed,
]) {
await store.updateState(seed.id, state);
}
seedCreatedAt = seed.createdAt;
result = await checkDuplicatesUnified(
capturedRef,
accountId: 'alice',
albumId: 'alb-1',
sourcePaths: [pick1.path, pick2.path],
);
});
expect(result, isNotNull, reason: '双命中至少一条');
final matches = result!;
expect(
matches, hasLength(1), reason: '同一文件本地+服务器双命中合并为单条(不重复呈现)');
expect(
matches.single.sourcePath,
pick1.path,
reason: '重复项携带完整源路径(路径精确派生 dupPaths 的依据)',
);
expect(
matches.single.uploadedAt,
seedCreatedAt,
reason: '本地历史时间优先(服务器回显 2020 不覆盖本地 2026-09-20)',
);
expect(
adapter.requests,
hasLength(1),
reason: '服务器预检发起(≤500 项单请求)',
);
expect(
(adapter.requests.single.body as Map<String, dynamic>)['items'],
hasLength(2),
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);
});
});
}