fix(app): picker dup gate — 2s server-check timeout, single snackbar, fileSize-throw allow

评审跟进三项(拦截面加固,判定口径不变):

- 服务器半场时间帽:checkUploadDuplicates 竞速 serverCheckTimeout
  (构造参数,默认 2s)——弱网下点选不再静默挂到 dio 连接超时
  (10s);超时=无命中,同各半场失败降级放行(底层请求不取消,
  dio 自身超时照常兜底)。测试 ⑥:永不回包适配器 + 注入 50ms 帽
  → 放行且请求确已发出;
- SnackBar 不堆积:呈现前 clearSnackBars——快速点多个重复资产
  只留当前一条(原会排队 N 条 4s 提示)。测试 ① 扩展:第二条
  隐藏后无排队等待的提示条;
- fileSize 失败路径钉住(测试 ⑦):资产事实 fileSize 抛错 → 放行
  且两半场零查询。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-29 10:35:04 +08:00
parent 7f8cda1cd3
commit 0673f394d5
2 changed files with 151 additions and 17 deletions
@@ -12,6 +12,7 @@
/// [PickerAssetFact] 记录绕开 AssetEntity.fileSize 的平台通道。
library;
import 'dart:async';
import 'dart:io';
import 'package:dio/dio.dart';
@@ -285,7 +286,34 @@ void main() {
),
findsOneWidget,
);
await drainBlockedSnackBar(tester, ctx);
// 不堆积:快速点另一个重复资产 → 旧条清除后只留一条(无 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)',
@@ -483,5 +511,94 @@ void main() {
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': <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 失败短路两半场——不发起任何查询');
});
});
}