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

495 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(v2 硬封锁:仅本机判定)', () {
/// 宿主树: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, {
String accountId = 'alice',
String albumId = 'alb-1',
}) async {
final seed = await store.create(
UploadTaskDraft(
id: 'task-seed-$accountId-$albumId',
accountId: accountId,
albumId: albumId,
clientTaskId: 'ct-seed-$accountId-$albumId',
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 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',
);
final asset = gateAsset(bytes: kJpgBytes.length);
late List<bool> verdicts;
await tester.runAsync(() async {
await seedCompletedTask(store, 'pick1.jpg', kJpgBytes.length);
verdicts = [
await gate.decideFact(ctx, asset, false),
await gate.decideFact(ctx, asset, false),
await gate.decideFact(ctx, asset, false),
];
});
expect(
verdicts,
everyElement(isFalse),
reason: 'v2 硬封锁:连点三次均拒选(二次点击强制已移除)',
);
// 拦截提示:谓词 ctx 经根 messenger 呈现 SnackBar(en 新文案)。
await tester.pump();
expect(find.byType(SnackBar), findsOneWidget);
expect(
find.text('This photo has already been uploaded to this album'),
findsOneWidget,
);
// 不堆积:快速点另一个重复资产 → 旧条清除后只留一条(无 N×4s 队列)。
late bool otherBlocked;
await tester.runAsync(() async {
otherBlocked = await gate.decideFact(
ctx,
gateAsset(id: 'asset-2', bytes: kJpgBytes.length),
false,
);
});
expect(otherBlocked, isFalse, reason: '另一重复资产同样拦下');
await tester.pumpAndSettle();
expect(
find.text('This photo has already been uploaded to this album'),
findsOneWidget,
reason: '呈现前清旧条——当前仅一条提示',
);
ScaffoldMessenger.of(ctx).hideCurrentSnackBar();
await tester.pumpAndSettle();
expect(
find.text('This photo has already been uploaded to this album'),
findsNothing,
reason: '隐藏后无第二条在队列等待(不堆积)',
);
});
testWidgets('② 非重复(bytes/文件名/相册/账号 任一不同)→ 放行', (
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',
);
await tester.runAsync(() async {
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(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(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(
sizeCalls,
1,
reason: '判定缓存共享 in-flight Future——连点只查一次(清偿登记遗留)',
);
await drainBlockedSnackBar(tester, ctx);
});
testWidgets('④ title 为空(iOS 场景)→ 放行(本机无从匹配)', (
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',
);
await tester.runAsync(() async {
await seedCompletedTask(store, 'pick1.jpg', kJpgBytes.length);
});
late bool verdict;
await tester.runAsync(() async {
verdict = await gate.decideFact(
ctx,
gateAsset(id: 'a-null', title: null, bytes: kJpgBytes.length),
false,
);
});
expect(verdict, isTrue,
reason: 'title 空 → 本机半场无从匹配 → 放行(服务器判定在入队弹窗)');
});
testWidgets('⑤ fileSize 获取失败 → 放行(判定故障不挡选图)', (
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',
);
late bool verdict;
await tester.runAsync(() async {
verdict = await gate.decideFact(
ctx,
(
id: 'asset-1',
title: 'pick1.jpg',
fileSize: () async => throw StateError('size failed'),
),
false,
);
});
expect(verdict, isTrue, 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);
});
});
}