/// 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:async'; 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 kJpgBytes = [ 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? 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)['items'], hasLength(2), reason: '两张均送预检(含本地已命中的那张——union 语义不做短路)', ); }); group('选图点选拦截器 PickerDuplicateGate(方案 A)', () { /// 宿主树:MaterialApp(含根 ScaffoldMessenger + l10n)——SnackBar 提示 /// 与文案断言的载体;测试环境默认 en locale(同页级测试)。 Future 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 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 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': >[]}), ); 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, ); // 不堆积:快速点另一个重复资产 → 旧条清除后只留一条(无 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( 'Already uploaded to this album — tap again to select anyway', ), findsOneWidget, reason: '呈现前清旧条——当前仅一条提示', ); ScaffoldMessenger.of(ctx).hideCurrentSnackBar(); await tester.pumpAndSettle(); expect( find.text( 'Already uploaded to this album — tap again to select anyway', ), findsNothing, reason: '隐藏后无第二条在队列等待(不堆积)', ); }); 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; expect(body['items'], hasLength(1)); expect( (body['items'] as List).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': >[]}), ); 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': >[]}), ); 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); }); testWidgets('⑥ 服务器预检超时(注入 50ms 帽)→ 放行(时间帽=无命中)', (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()); // 永不回包的适配器:请求受理但响应挂起(模拟弱网下预检吊住—— // 不等 dio 默认超时,靠注入的短时间帽放行)。 final neverResponds = Completer<(int, Object?)>(); final adapter = FakeAdapter((o) => neverResponds.future); 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', serverCheckTimeout: const Duration(milliseconds: 50), ); late bool verdict; await tester.runAsync(() async { verdict = await gate.decideFact( ctx, gateAsset(bytes: kJpgBytes.length), false, ); }); expect(verdict, isTrue, reason: '超时=无命中 → 放行(绝不吊起点选)'); expect(adapter.requests, hasLength(1), reason: '预检确已发出(只是没回)'); }); testWidgets('⑦ fileSize 获取失败 → 放行(预检故障不挡选图)', (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': >[]}), ); 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, ( id: 'asset-1', title: 'pick1.jpg', fileSize: () async => throw StateError('size failed'), ), false, ); }); expect(verdict, isTrue, reason: 'fileSize 失败 → 放行'); expect(adapter.requests, isEmpty, reason: 'fileSize 失败短路两半场——不发起任何查询'); }); }); }