Files
photo-live/apps/mobile/test/pages/queue_tab_test.dart
T
2026-09-22 02:06:23 +08:00

777 lines
27 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.
/// 上传中心 widget 测试(UI-03 页面 14/15)。
///
/// 打桩面:RecordingEngine(记录 retryNow/cancel,不出队)+ 内存 TaskStore
/// 种子 + FakeAdapter 相册列表 + 临时目录准备器(准备失败重试换源)。
/// 覆盖:页头(Uploads 标题 + History 入口)、摘要卡(范围标注 + 四组
/// 任务数,突出已完成)、主段筛选(All/Active/Needs attention)、次级
/// 筛选菜单(相册/时间/已完成)、任务行渲染(状态徽章/状态专属文案/
/// 错误文案/相册名)、按状态动作(重试/重新选择/取消/移除记录 + 确认
/// 弹窗)、异常视图(仅失败行 + 说明行 + 摘要全量口径 + 专用空态)、
/// 批量重试条口径(仅可重试失败)、写通知防抖实时刷新、行级进度条
/// (分数 + 百分比文案 + 未订阅行不重建)。
library;
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
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/device_id.dart';
import 'package:photo_live/core/session_store.dart';
import 'package:photo_live/features/uploads/enqueue.dart';
import 'package:photo_live/features/uploads/preparer.dart';
import 'package:photo_live/features/uploads/providers.dart';
import 'package:photo_live/features/uploads/queue_controller.dart';
import 'package:photo_live/features/uploads/task_state.dart';
import 'package:photo_live/features/uploads/task_store.dart';
import 'package:photo_live/features/uploads/upload_engine.dart';
import 'package:photo_live/l10n/app_localizations.dart';
import 'package:photo_live/pages/queue_tab.dart';
import '../helpers/fakes.dart';
/// JPEG 魔数(准备器换源重试用真实临时文件)。
const List<int> kJpgBytes = <int>[
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00,
];
class _FixedDeviceIdStore implements DeviceIdStore {
@override
Future<String?> read() async => '0123456789abcdef0123456789abcdef';
@override
Future<void> write(String value) async {}
}
/// 引擎替身:记录 retryNow/cancel,不出队(动作流断言点;真实出队语义
/// 由 upload_engine_test 覆盖)。
class RecordingEngine extends UploadEngine {
RecordingEngine(
super.store,
super.api,
super.deviceId, {
required Dio objectDio,
}) : super(objectDio: objectDio);
final List<(String, String)> retryCalls = <(String, String)>[];
final List<(String, String)> cancelCalls = <(String, String)>[];
@override
Future<void> retryNow(String accountId, String taskId) async =>
retryCalls.add((accountId, taskId));
@override
Future<bool> cancel(String accountId, String taskId) async {
cancelCalls.add((accountId, taskId));
return true;
}
@override
void notifyWorkAvailable() {}
}
void main() {
late FakeSessionStore session;
late TaskStore store;
late RecordingEngine engine;
late ApiClient api;
late Directory tmp;
late List<String> pickedPaths;
setUp(() async {
session = FakeSessionStore()
..session = Session(
token: 'tok-1',
expiresAt: DateTime.utc(2099, 1, 1),
account: 'alice',
);
store = TaskStore.memory();
api = ApiClient(
sessionStore: session,
dio: Dio(BaseOptions(baseUrl: 'http://test.local'))
..httpClientAdapter = FakeAdapter((o) {
if (o.method == 'GET' && o.path == '/v1/albums') {
return (
200,
cursorPageBody(<Map<String, dynamic>>[
albumJson(id: 'alb-1'),
albumJson(
id: 'alb-2',
translations: <Map<String, dynamic>>[
<String, dynamic>{
'locale': 'en',
'title': 'Wedding Day',
'description': null,
'venue': null,
'address': null,
},
],
),
]),
);
}
throw StateError('unexpected ${o.method} ${o.path}');
}),
);
engine = RecordingEngine(
store,
api,
DeviceId(store: _FixedDeviceIdStore()),
objectDio: Dio()
..httpClientAdapter =
FakeAdapter((_) => throw StateError('替身引擎不应发起传输')),
);
tmp = await Directory.systemTemp.createTemp('queue-tab-test-');
pickedPaths = <String>[];
});
tearDown(() async {
await store.close();
await tmp.delete(recursive: true);
});
Future<void> pumpQueue(WidgetTester tester) async {
// 加高视口:摘要卡 + 筛选栏 + 批量条压缩列表区,默认 800x600 下
// 第二行落在懒构建区外(ListView.builder 不构建不可见项)。
tester.view.physicalSize = const Size(800, 1600);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(
ProviderScope(
overrides: [
...pageTestOverrides(session: session, taskStore: store, client: api),
uploadEngineProvider.overrideWith((ref) => engine),
uploadPreparerProvider.overrideWith(
(ref) => UploadPreparer(
store,
baseDirProvider: () async => tmp,
),
),
singleImagePickerProvider
.overrideWith((ref) => () async => pickedPaths.removeAt(0)),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
// History 入口跳转断言用占位路由。
routes: <String, WidgetBuilder>{
'/server-history': (context) =>
const Scaffold(body: Center(child: Text('SERVER_HISTORY_MARK'))),
},
home: const QueueTab(),
),
),
);
// 不用 pumpAndSettle:uploading 行的不确定态进度条永远动画(帧不断)。
await tester.pump(const Duration(milliseconds: 300));
await tester.pump(const Duration(milliseconds: 300));
}
/// 种子任务(真实迁移链到目标态;attempt/lastError 供失败文案断言)。
Future<UploadTask> seed(
String filename, {
String albumId = 'alb-1',
UploadTaskState state = UploadTaskState.queued,
DateTime? createdAt,
String? lastError,
int? attempt,
}) async {
final id = 'task-$filename';
var task = await store.create(
UploadTaskDraft(
id: id,
accountId: 'alice',
albumId: albumId,
clientTaskId: 'ct-$filename',
sourceFilePath: '${tmp.path}/$filename',
filename: filename,
bytes: 16,
contentType: 'image/jpeg',
),
now: createdAt,
);
Future<UploadTask> to(UploadTaskState s,
{String? error, int? tries}) =>
store.updateState(
task.id,
s,
patch: UploadTaskPatch(lastError: error, attempt: tries),
now: createdAt,
);
switch (state) {
case UploadTaskState.preparing:
break;
case UploadTaskState.queued:
case UploadTaskState.preparationFailed:
case UploadTaskState.cancelled:
task = await to(state, error: lastError);
case UploadTaskState.waitingNetwork:
task = await to(UploadTaskState.queued);
task = await to(state, error: lastError);
case UploadTaskState.uploading:
case UploadTaskState.retryableFailed:
task = await to(UploadTaskState.queued);
task = await to(UploadTaskState.uploading);
if (state == UploadTaskState.retryableFailed) {
task = await to(state, error: lastError, tries: attempt);
}
case UploadTaskState.uploadedConfirming:
case UploadTaskState.completed:
task = await to(UploadTaskState.queued);
task = await to(UploadTaskState.uploading);
task = await to(UploadTaskState.uploadedConfirming);
if (state == UploadTaskState.completed) {
task = await to(state);
}
}
return task;
}
/// 行级锚定:任务行 Padding 带 ValueKey('queue-task-{task.id}')。
Finder rowOf(String filename) =>
find.byKey(ValueKey('queue-task-task-$filename'));
/// 等防抖重查落定(写通知 → 200ms Timer → 异步查询;不 pumpAndSettle——
/// uploading 行的不确定态进度条动画不会停)。
Future<void> settleDebounce(WidgetTester tester) async {
await tester.pump(kQueueRefreshDebounce);
await tester.pump(const Duration(milliseconds: 120));
}
/// 打开次级筛选菜单(相册 × 时间 × 已完成)。
Future<void> openFilterSheet(WidgetTester tester) async {
await tester.tap(find.byIcon(Icons.tune));
await tester.pumpAndSettle();
}
/// 触发一个会走真实文件 IO 的按钮:在 runAsync(真实事件循环)内直接
/// 调用其回调——widget 测试的 fake-async 区里 dart:io 完成回调不会被
/// pump 冲刷,入队链路必须整体跑在真实区;返回后 pump 刷新徽章防抖。
Future<void> pressButtonRealIo(WidgetTester tester, Finder button) async {
final widget = button.evaluate().single.widget;
final VoidCallback onPressed;
if (widget is IconButton) {
onPressed = widget.onPressed!;
} else if (widget is TextButton) {
onPressed = widget.onPressed!;
} else {
throw StateError('unsupported button type: $widget');
}
await tester.runAsync(() async {
onPressed();
await Future<void>.delayed(const Duration(milliseconds: 400));
});
await tester.pump(kQueueRefreshDebounce);
await tester.pump(const Duration(milliseconds: 60));
}
testWidgets('页头:Uploads 标题 + History 入口跳服务端历史页',
(tester) async {
await pumpQueue(tester);
expect(find.text('Uploads'), findsOneWidget);
expect(find.text('History'), findsOneWidget);
await tester.tap(find.text('History'));
await tester.pumpAndSettle();
expect(find.text('SERVER_HISTORY_MARK'), findsOneWidget,
reason: 'History 入口 push /server-history(与本机队列数据源区分)');
});
testWidgets('摘要卡:范围标注(时间·本设备)+ 四组任务数(突出已完成)',
(tester) async {
await seed('done1.jpg', state: UploadTaskState.completed);
await seed('done2.jpg', state: UploadTaskState.completed);
await seed('up.jpg', state: UploadTaskState.uploading);
await seed('q1.jpg');
await seed('q2.jpg');
await seed(
'bad.jpg',
state: UploadTaskState.retryableFailed,
lastError: 'NETWORK_ERROR',
);
await pumpQueue(tester);
// 范围标注:未选相册 → 时间范围 · 本设备(跨设备记录在服务端历史页)。
expect(find.text('Any time · This device'), findsOneWidget);
// 四组计数:摘要标签小写、主段大写——find.text 区分大小写不冲突。
void expectSummary(String label, String value) {
final row = find.ancestor(
of: find.text(label),
matching: find.byType(Row),
);
expect(
find.descendant(of: row, matching: find.text(value)),
findsOneWidget,
reason: '摘要「$label」= $value',
);
}
expectSummary('completed', '2');
expectSummary('uploading', '1');
expectSummary('waiting', '2');
expectSummary('need attention', '1');
});
testWidgets('各状态任务行:徽章文案 + 相册名(缓存)+ 缩略图占位',
(tester) async {
await seed('prep.jpg', state: UploadTaskState.preparing);
await seed('ok.jpg', state: UploadTaskState.completed);
await seed('net.jpg', state: UploadTaskState.waitingNetwork);
await seed('alb2.jpg',
albumId: 'alb-2', state: UploadTaskState.uploading);
await pumpQueue(tester);
// 状态徽章(en locale;断言锚定在行内——主段文案与部分徽章同名)。
for (final (filename, badge) in [
('prep.jpg', 'Preparing photo'),
('ok.jpg', 'Completed'),
('net.jpg', 'Waiting for network'),
('alb2.jpg', 'Uploading'),
]) {
expect(
find.descendant(of: rowOf(filename), matching: find.text(badge)),
findsOneWidget,
reason: '$filename 行徽章/状态文案 $badge',
);
}
// 文件名。
expect(find.text('ok.jpg'), findsOneWidget);
// 相册名经 albums 缓存展示(en 主标题;3 条 alb-1 + 1 条 alb-2)。
expect(find.text('Opening Gala'), findsNWidgets(3));
expect(find.text('Wedding Day'), findsOneWidget);
// 缩略图:每行一个 Image.file(源缺失走 errorBuilder 占位;fake-async
// 下真实 IO 不完成,仅断言 Image 挂载)。
expect(find.byType(Image), findsNWidgets(4));
});
testWidgets('失败任务:本地化错误文案 + 尝试次数', (tester) async {
await seed(
'big.jpg',
state: UploadTaskState.preparationFailed,
lastError: 'PREP_TOO_LARGE',
);
await seed(
'flaky.jpg',
state: UploadTaskState.retryableFailed,
lastError: 'NETWORK_ERROR',
attempt: 2,
);
await pumpQueue(tester);
// §4.4:失败行显示本地化原因(独立错误行),状态行附尝试次数。
expect(find.text('Photo exceeds the 50MB limit'), findsOneWidget);
expect(find.text('Network error. Check your connection and retry'),
findsOneWidget);
expect(
find.descendant(
of: rowOf('flaky.jpg'),
matching: find.text('Upload failed'),
),
findsOneWidget,
);
expect(
find.descendant(
of: rowOf('flaky.jpg'),
matching: find.text('2 attempt(s)'),
),
findsOneWidget,
);
});
testWidgets('等待网络行:徽章 + 自动恢复提示 + 保留手动重试', (tester) async {
await seed(
'net.jpg',
state: UploadTaskState.waitingNetwork,
lastError: 'NETWORK_ERROR',
);
await pumpQueue(tester);
expect(
find.descendant(
of: rowOf('net.jpg'),
matching: find.text('Waiting for network'),
),
findsOneWidget,
);
// §4.4:等待网络附自动恢复说明(用户无需手动干预)。
expect(
find.descendant(
of: rowOf('net.jpg'),
matching: find.text('Resumes automatically when the network recovers'),
),
findsOneWidget,
);
// §4.4「保留已有重试」:等待网络行仍有刷新入口。
expect(
find.descendant(of: rowOf('net.jpg'), matching: find.byIcon(Icons.refresh)),
findsOneWidget,
);
});
testWidgets('主段筛选 Needs attention → 仅失败行 + 说明行 + 摘要仍全量(页面 15)',
(tester) async {
await seed('bad.jpg', state: UploadTaskState.preparationFailed);
await seed('good.jpg', state: UploadTaskState.completed);
await pumpQueue(tester);
await tester.tap(find.text('Needs attention'));
await settleDebounce(tester);
// 页面 15:异常视图只列失败任务,附「正在显示需关注任务」说明行。
expect(find.text('bad.jpg'), findsOneWidget);
expect(find.text('good.jpg'), findsNothing);
expect(
find.text('Showing tasks that need your attention'),
findsOneWidget,
);
// 摘要不随状态筛选变口径:completed 1 / need attention 1 仍可见。
expect(find.text('Any time · This device'), findsOneWidget);
void expectSummary(String label, String value) {
final row = find.ancestor(
of: find.text(label),
matching: find.byType(Row),
);
expect(find.descendant(of: row, matching: find.text(value)),
findsOneWidget);
}
expectSummary('completed', '1');
expectSummary('need attention', '1');
});
testWidgets('异常视图空态:无失败任务 → 专用空文案(页面 15)',
(tester) async {
await seed('good.jpg', state: UploadTaskState.completed);
await pumpQueue(tester);
await tester.tap(find.text('Needs attention'));
await settleDebounce(tester);
expect(find.text('No tasks need attention'), findsOneWidget);
expect(find.text('good.jpg'), findsNothing, reason: '非失败任务不显示');
});
testWidgets('主段筛选 Active → 活任务(含等待网络)', (tester) async {
await seed('live.jpg');
await seed('net.jpg', state: UploadTaskState.waitingNetwork);
await seed('done.jpg', state: UploadTaskState.completed);
await seed('bad.jpg', state: UploadTaskState.preparationFailed);
await pumpQueue(tester);
await tester.tap(find.text('Active'));
await settleDebounce(tester);
// 排队 + 等待网络属「进行中」(自动恢复范畴);终态与需关注排除。
expect(find.text('live.jpg'), findsOneWidget);
expect(find.text('net.jpg'), findsOneWidget);
expect(find.text('done.jpg'), findsNothing);
expect(find.text('bad.jpg'), findsNothing);
});
testWidgets('次级筛选菜单:Completed → 仅已完成行(主三段不亮)',
(tester) async {
await seed('done.jpg', state: UploadTaskState.completed);
await seed('gone.jpg', state: UploadTaskState.cancelled);
await seed('q.jpg');
await pumpQueue(tester);
await openFilterSheet(tester);
// 行内徽章同名「Completed」——锚定筛选面板的 ListTile。
await tester.tap(find.widgetWithText(ListTile, 'Completed'));
await tester.pumpAndSettle();
await settleDebounce(tester);
// §4.4:已完成组不含 cancelled(取消仅在「全部」可见)。
expect(find.text('done.jpg'), findsOneWidget);
expect(find.text('gone.jpg'), findsNothing);
expect(find.text('q.jpg'), findsNothing);
});
testWidgets('次级筛选菜单:Today → 昨日任务排除', (tester) async {
final now = DateTime.now();
await seed('fresh.jpg', createdAt: now);
await seed(
'old.jpg',
createdAt: now.subtract(const Duration(days: 40)),
);
await pumpQueue(tester);
expect(find.text('old.jpg'), findsOneWidget, reason: '默认全部时间');
await openFilterSheet(tester);
await tester.tap(find.widgetWithText(RadioListTile<QueueTimeFilter>, 'Today'));
await tester.pumpAndSettle();
await settleDebounce(tester);
expect(find.text('fresh.jpg'), findsOneWidget);
expect(find.text('old.jpg'), findsNothing);
});
testWidgets('次级筛选菜单:相册 alb-2 → 仅该相册行 + 摘要范围带相册名',
(tester) async {
await seed('one.jpg', albumId: 'alb-1');
await seed('two.jpg', albumId: 'alb-2');
await pumpQueue(tester);
await openFilterSheet(tester);
// 任务行副标题同名相册名——锚定筛选面板的单选行。
await tester.tap(
find.widgetWithText(RadioListTile<String>, 'Wedding Day'));
await tester.pumpAndSettle();
await settleDebounce(tester);
expect(find.text('two.jpg'), findsOneWidget);
expect(find.text('one.jpg'), findsNothing);
// 摘要范围标注随筛选更新:相册名 · 时间 · 本设备。
expect(find.text('Wedding Day · Any time · This device'), findsOneWidget);
});
testWidgets('按状态动作可见性:queued 取消 / completed 移除记录 / uploading 无',
(tester) async {
await seed('q.jpg', state: UploadTaskState.queued);
await seed('done.jpg', state: UploadTaskState.completed);
await seed('up.jpg', state: UploadTaskState.uploading);
await pumpQueue(tester);
// queued 行 → 取消图标;completed 行 → 移除记录(弱化文字按钮);
// uploading 行无动作(传输中不打断)。
expect(
find.descendant(of: rowOf('q.jpg'), matching: find.byIcon(Icons.close)),
findsOneWidget,
reason: 'queued 行有取消按钮',
);
expect(
find.descendant(
of: rowOf('done.jpg'),
matching: find.text('Remove record'),
),
findsOneWidget,
reason: 'completed 行有移除记录按钮',
);
expect(
find.descendant(of: rowOf('up.jpg'), matching: find.byType(IconButton))
.evaluate(),
isEmpty,
reason: 'uploading 行无图标动作(传输中不打断)',
);
expect(
find.descendant(of: rowOf('up.jpg'), matching: find.byType(TextButton))
.evaluate(),
isEmpty,
reason: 'uploading 行无文字动作',
);
});
testWidgets('取消流:queued 行点取消 → engine.cancel(账号, 任务)',
(tester) async {
final task = await seed('q.jpg');
await pumpQueue(tester);
await tester.tap(find.descendant(
of: rowOf('q.jpg'),
matching: find.byIcon(Icons.close),
));
await tester.pumpAndSettle();
expect(engine.cancelCalls, [('alice', task.id)]);
});
testWidgets('重试流:retryableFailed 行 → engine.retryNow',
(tester) async {
final task = await seed(
'flaky.jpg',
state: UploadTaskState.retryableFailed,
lastError: 'INTERNAL_ERROR',
);
await pumpQueue(tester);
await tester.tap(find.descendant(
of: rowOf('flaky.jpg'),
matching: find.byIcon(Icons.refresh),
));
await tester.pumpAndSettle();
expect(engine.retryCalls, [('alice', task.id)]);
});
testWidgets('准备失败:重新选图(非普通重试)→ 换源重新入队为 queued',
(tester) async {
final task = await seed(
'broken.jpg',
state: UploadTaskState.preparationFailed,
lastError: 'PREP_SOURCE_UNREADABLE',
);
// 替身选图器返回真实 JPEG 临时文件。
final picked = File('${tmp.path}/repicked.jpg')
..writeAsBytesSync(kJpgBytes);
pickedPaths.add(picked.path);
await pumpQueue(tester);
// §4.4:准备失败行是「重新选择」(image_search 图标),不是刷新重试。
expect(
find.descendant(
of: rowOf('broken.jpg'),
matching: find.text('File unavailable'),
),
findsOneWidget,
);
final selectAgain = find.descendant(
of: rowOf('broken.jpg'),
matching: find.widgetWithText(TextButton, 'Select again'),
);
// 换源复制是真实文件 IO——runAsync 放行真实事件循环完成。
await pressButtonRealIo(tester, selectAgain);
await settleDebounce(tester);
final updated = await store.get('alice', task.id);
expect(updated?.state, UploadTaskState.queued);
expect(updated?.lastError, isNull);
// 重试换源 = 新源复制进同一受管路径(路径不变、内容替换)。
expect(updated?.sourceFilePath, task.sourceFilePath);
expect(File(task.sourceFilePath).readAsBytesSync(), kJpgBytes,
reason: '受管副本内容已替换为重选文件');
});
testWidgets('移除记录:确认弹窗明确不删云端照片;确认后行消失 + 库内清除',
(tester) async {
final task = await seed('done.jpg', state: UploadTaskState.completed);
await pumpQueue(tester);
await tester.tap(find.descendant(
of: rowOf('done.jpg'),
matching: find.text('Remove record'),
));
await tester.pumpAndSettle();
// §4.4:确认弹窗明确「仅清本机记录,不删云端照片」。
expect(find.text('Remove this record?'), findsOneWidget);
expect(
find.textContaining('Photos already uploaded are not deleted'),
findsOneWidget,
);
// 取消 → 记录保留。
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(find.text('done.jpg'), findsOneWidget);
// 再删 → 确认 → 行消失。
await tester.tap(find.descendant(
of: rowOf('done.jpg'),
matching: find.text('Remove record'),
));
await tester.pumpAndSettle();
await tester.tap(find.text('Remove'));
await settleDebounce(tester);
expect(find.text('done.jpg'), findsNothing);
expect(await store.get('alice', task.id), isNull);
});
testWidgets('批量重试条:口径 = 可重试失败才重试(等待网络/准备失败不混入)',
(tester) async {
final auto1 = await seed(
'f1.jpg',
state: UploadTaskState.retryableFailed,
lastError: 'INTERNAL_ERROR',
);
await seed(
'f2.jpg',
state: UploadTaskState.waitingNetwork,
lastError: 'NETWORK_ERROR',
);
await seed(
'f3.jpg',
state: UploadTaskState.preparationFailed,
lastError: 'PREP_COPY_FAILED',
);
await pumpQueue(tester);
// 批量条出现:标题计数与摘要「需关注」同口径(准备失败 + 上传失败),
// 按钮文案「重试可恢复项」+ 范围说明(页面 15)。
expect(find.text('Retry eligible'), findsOneWidget);
expect(find.text('Needs attention · 2'), findsOneWidget);
expect(find.textContaining('recover on their own'), findsOneWidget);
await tester.tap(find.text('Retry eligible'));
await tester.pumpAndSettle();
// §4.4:一键重试仅作用于 retryableFailed——等待网络自动恢复、准备
// 失败需换源重选,均不进入批量重试。
expect(engine.retryCalls, [('alice', auto1.id)]);
expect(
engine.retryCalls.map((c) => c.$2),
isNot(contains('task-f3.jpg')),
reason: '准备失败需换源重选,不进批量重试',
);
expect(
engine.retryCalls.map((c) => c.$2),
isNot(contains('task-f2.jpg')),
reason: '等待网络自动恢复,不进批量重试',
);
});
testWidgets('无失败任务 → 无批量条', (tester) async {
await seed('q.jpg');
await pumpQueue(tester);
expect(find.text('Retry eligible'), findsNothing);
});
testWidgets('实时刷新:库内新增任务 → 防抖后列表出现(写通知驱动)',
(tester) async {
await seed('first.jpg');
await pumpQueue(tester);
expect(find.text('second.jpg'), findsNothing);
// 模拟连拍页入队(不经 UI 直接写库)。
await seed('second.jpg');
await settleDebounce(tester);
expect(find.text('second.jpg'), findsOneWidget);
});
testWidgets('行级进度:进度事件只刷新订阅行,未订阅行不整行重建',
(tester) async {
final task1 = await seed('up1.jpg', state: UploadTaskState.uploading);
await seed('up2.jpg', state: UploadTaskState.uploading);
await pumpQueue(tester);
final context = tester.element(find.byType(QueueTab));
final hub = ProviderScope.containerOf(context)
.read(uploadProgressHubProvider);
LinearProgressIndicator indicatorOf(String filename) => find
.descendant(
of: rowOf(filename),
matching: find.byType(LinearProgressIndicator),
)
.evaluate()
.single
.widget as LinearProgressIndicator;
expect(indicatorOf('up1.jpg').value, isNull, reason: '无事件时不确定态');
// 快照另一行的 widget 实例——行级订阅下它不应随 up1 的进度重建。
final otherBefore = indicatorOf('up2.jpg');
hub.emit(task1.id, 50, 100);
await tester.pump();
expect(indicatorOf('up1.jpg').value, 0.5, reason: '进度事件行级刷新为 50%');
expect(
find.descendant(
of: rowOf('up1.jpg'),
matching: find.text('Uploading 50%'),
),
findsOneWidget,
);
expect(
identical(indicatorOf('up2.jpg'), otherBefore),
isTrue,
reason: '未收到进度事件的行不重建(行级进度,不整页刷新)',
);
});
}