2026-09-29 10:10:49 +08:00
|
|
|
|
/// checkDuplicatesUnified 单元测试(Task 2 评审 #1/#3)+ 选图点选拦截器
|
|
|
|
|
|
/// [PickerDuplicateGate] 五态用例(方案 A)。
|
2026-09-28 20:22:37 +08:00
|
|
|
|
///
|
|
|
|
|
|
/// 页级测试覆盖弹窗三态;本文件钉住合并语义:同一文件被本机 TaskStore
|
|
|
|
|
|
/// 与服务器历史同时命中 → 单条结果(不重复呈现)且本地 uploadedAt 优先。
|
|
|
|
|
|
/// 经 Consumer 捕获 WidgetRef 驱动真实函数——drift/文件 stat 均真实异步,
|
|
|
|
|
|
/// 整体跑在 runAsync(真实事件循环)窗口内(同页级测试的 real-io 模式;
|
|
|
|
|
|
/// fake-async 区内不做真实异步 IO)。
|
2026-09-29 10:10:49 +08:00
|
|
|
|
///
|
|
|
|
|
|
/// 拦截器用例直接构造 [PickerDuplicateGate](构造注入真实内存 TaskStore +
|
|
|
|
|
|
/// dio 打桩 ApiClient,同 checkDuplicatesUnified 的替身面);资产事实用
|
|
|
|
|
|
/// [PickerAssetFact] 记录绕开 AssetEntity.fileSize 的平台通道。
|
2026-09-28 20:22:37 +08:00
|
|
|
|
library;
|
|
|
|
|
|
|
2026-09-29 10:35:04 +08:00
|
|
|
|
import 'dart:async';
|
2026-09-28 20:22:37 +08:00
|
|
|
|
import 'dart:io';
|
|
|
|
|
|
|
|
|
|
|
|
import 'package:dio/dio.dart';
|
2026-09-29 10:10:49 +08:00
|
|
|
|
import 'package:flutter/material.dart'
|
|
|
|
|
|
show BuildContext, Builder, MaterialApp, Scaffold, ScaffoldMessenger,
|
|
|
|
|
|
SnackBar;
|
2026-09-28 20:22:37 +08:00
|
|
|
|
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';
|
2026-09-29 10:10:49 +08:00
|
|
|
|
import 'package:photo_live/l10n/app_localizations.dart';
|
2026-09-28 20:22:37 +08:00
|
|
|
|
|
|
|
|
|
|
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 语义不做短路)',
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
2026-09-29 10:10:49 +08:00
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
);
|
2026-09-29 10:35:04 +08:00
|
|
|
|
|
|
|
|
|
|
// 不堆积:快速点另一个重复资产 → 旧条清除后只留一条(无 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: '隐藏后无第二条在队列等待(不堆积)',
|
|
|
|
|
|
);
|
2026-09-29 10:10:49 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
});
|
2026-09-29 10:35:04 +08:00
|
|
|
|
|
|
|
|
|
|
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': <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 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 失败短路两半场——不发起任何查询');
|
|
|
|
|
|
});
|
2026-09-29 10:10:49 +08:00
|
|
|
|
});
|
2026-09-28 20:22:37 +08:00
|
|
|
|
}
|