Files
photo-live/apps/mobile/test/features/uploads/enqueue_test.dart
T

495 lines
16 KiB
Dart
Raw Normal View History

/// 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);
});
});
}