feat(mobile): upload queue ui and continuous capture page
Task 9 (P3 plan): queue tab replaces the stub, continuous capture page enqueues per shot, album detail gains an upload FAB, and the upload engine lifecycle is now bound to the login session. - Engine scope: session account drives UploadEngine start/stop; wired from app boot, login success, logout, and 401 eviction. Tasks stay in the store across logout (accountId hard isolation). - Queue tab: album x state x time filter bar, task rows with thumbnail, state chip, localized error copy, attempt count, per-row progress via a broadcast progress hub, state-dependent actions (re-pick retry, retryNow, cancel, delete), batch retry bar, 200ms-debounced live refresh from drift table updates. - Capture page: camera preview via injected CaptureSource, one enqueue per shutter press, persistent album/category/badges overlay with a switch-only-affects-new-shots hint, gallery multi-pick, exit confirm while uploads are pending, permission-denied and init-failure views. - POST_NOTIFICATIONS runtime permission via a small MethodChannel (photolive/permissions) + PermissionPlugin.kt: single runtime permission need, avoids permission_handler dependency; requested once on first enqueue, silent degradation when denied or unavailable. - Album detail FAB: capture-and-upload / pick-from-gallery sheet. - l10n: ~50 new keys in zh/en arbs; tests: +45 (filter matrix, engine scope lifecycle, permission gate, queue tab, capture page, FAB entry); existing page tests updated to inject session/task-store fakes. Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -33,9 +33,11 @@ void main() {
|
||||
);
|
||||
|
||||
// PhotoLiveApp.initState 读取被 override 的客户端并注册 onUnauthorized。
|
||||
// Task 9 起根组件 watch 引擎作用域(会话→引擎 start),真实
|
||||
// flutter_secure_storage/path_provider 平台通道在测试环境挂起,须替身。
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [apiClientProvider.overrideWith((ref) => client)],
|
||||
overrides: [...pageTestOverrides(session: store, client: client)],
|
||||
child: const PhotoLiveApp(),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/// POST_NOTIFICATIONS 权限门测试(P3 计划 Task 9)。
|
||||
///
|
||||
/// 覆盖:Android 首次请求一次(幂等,二次调用不触通道)、非 Android 跳过、
|
||||
/// 通道缺失/异常静默降级不阻断入队。
|
||||
library;
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:photo_live/core/notification_permission.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
const channel = MethodChannel(NotificationPermission.kChannelName);
|
||||
// 测试绑定的 defaultBinaryMessenger 即 TestDefaultBinaryMessenger
|
||||
// (setMockMethodCallHandler 所在)。
|
||||
final byteData = TestWidgetsFlutterBinding.instance.defaultBinaryMessenger;
|
||||
|
||||
tearDown(() => byteData.setMockMethodCallHandler(channel, null));
|
||||
|
||||
test('Android:首次请求触发通道一次;再次调用不再打扰(幂等)', () async {
|
||||
var calls = 0;
|
||||
byteData.setMockMethodCallHandler(channel, (call) async {
|
||||
expect(call.method, 'requestPostNotifications');
|
||||
calls++;
|
||||
return 'granted';
|
||||
});
|
||||
final permission =
|
||||
NotificationPermission(isAndroid: true, channel: channel);
|
||||
|
||||
await permission.ensureRequested();
|
||||
await permission.ensureRequested();
|
||||
await permission.ensureRequested();
|
||||
|
||||
expect(calls, 1, reason: '进程内只请求一次');
|
||||
});
|
||||
|
||||
test('非 Android:不触通道(iOS 后台传输不依赖通知权限)', () async {
|
||||
var calls = 0;
|
||||
byteData.setMockMethodCallHandler(channel, (call) async {
|
||||
calls++;
|
||||
return 'granted';
|
||||
});
|
||||
final permission =
|
||||
NotificationPermission(isAndroid: false, channel: channel);
|
||||
|
||||
await permission.ensureRequested();
|
||||
|
||||
expect(calls, 0);
|
||||
});
|
||||
|
||||
test('通道缺失(iOS/桌面无此方法)→ 静默降级不抛', () async {
|
||||
byteData.setMockMethodCallHandler(
|
||||
channel,
|
||||
(call) async => throw MissingPluginException('no handler'),
|
||||
);
|
||||
final permission =
|
||||
NotificationPermission(isAndroid: true, channel: channel);
|
||||
|
||||
await permission.ensureRequested(); // 不抛即通过。
|
||||
});
|
||||
|
||||
test('通道异常后仍标记已请求(失败也不二次打扰)', () async {
|
||||
var calls = 0;
|
||||
byteData.setMockMethodCallHandler(
|
||||
channel,
|
||||
(call) async {
|
||||
calls++;
|
||||
throw PlatformException(code: 'ACTIVITY_MISSING');
|
||||
},
|
||||
);
|
||||
final permission =
|
||||
NotificationPermission(isAndroid: true, channel: channel);
|
||||
|
||||
await permission.ensureRequested();
|
||||
await permission.ensureRequested();
|
||||
|
||||
expect(calls, 1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/// 引擎生命周期绑定测试(P3 计划 Task 9,Task 6 关注点 1 接线)。
|
||||
///
|
||||
/// ProviderContainer 级(非 widget):登录会话账号 → uploadEngineScopeProvider
|
||||
/// 驱动 UploadEngine.start/stop。覆盖:登录启动、登出停止且任务保留库内、
|
||||
/// 账号切换重启。真实平台通道(secure storage 等)全部以替身注入。
|
||||
library;
|
||||
|
||||
import 'package:dio/dio.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/engine_scope.dart';
|
||||
import 'package:photo_live/features/uploads/providers.dart';
|
||||
import 'package:photo_live/features/uploads/task_state.dart';
|
||||
import 'package:photo_live/features/uploads/task_store.dart';
|
||||
|
||||
import '../../helpers/fakes.dart';
|
||||
|
||||
/// 固定设备 ID(引擎出队才会读;空库场景实际不触网络)。
|
||||
class _FixedDeviceIdStore implements DeviceIdStore {
|
||||
@override
|
||||
Future<String?> read() async => '0123456789abcdef0123456789abcdef';
|
||||
@override
|
||||
Future<void> write(String value) async {}
|
||||
}
|
||||
|
||||
void main() {
|
||||
late FakeSessionStore session;
|
||||
late TaskStore store;
|
||||
|
||||
setUp(() {
|
||||
session = FakeSessionStore();
|
||||
store = TaskStore.memory();
|
||||
});
|
||||
|
||||
tearDown(() => store.close());
|
||||
|
||||
ProviderContainer pumpContainer() {
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
sessionStoreProvider.overrideWithValue(session),
|
||||
taskStoreProvider.overrideWithValue(store),
|
||||
deviceIdProvider
|
||||
.overrideWith((ref) => DeviceId(store: _FixedDeviceIdStore())),
|
||||
apiClientProvider.overrideWith(
|
||||
(ref) => ApiClient(
|
||||
sessionStore: session,
|
||||
dio: Dio(BaseOptions(baseUrl: 'http://test.local'))
|
||||
..httpClientAdapter = FakeAdapter(
|
||||
(_) => throw StateError('空库场景不应发起请求'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
return container;
|
||||
}
|
||||
|
||||
/// 读一次 scope Provider 使绑定生效,并等会话账号 future 落定。
|
||||
Future<String?> settleScope(ProviderContainer container) async {
|
||||
final account = await container.read(sessionAccountProvider.future);
|
||||
// 触发 scope Provider 重建(引擎 start/stop 发生在 watch 传播中)。
|
||||
container.read(uploadEngineScopeProvider);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
return account;
|
||||
}
|
||||
|
||||
test('登录(会话在场)→ 引擎 start(isRunning,scope=账号)', () async {
|
||||
session.session = Session(
|
||||
token: 'tok-1',
|
||||
expiresAt: DateTime.utc(2099, 1, 1),
|
||||
account: 'alice',
|
||||
);
|
||||
final container = pumpContainer();
|
||||
|
||||
final account = await settleScope(container);
|
||||
final engine = container.read(uploadEngineProvider);
|
||||
|
||||
expect(account, 'alice');
|
||||
expect(container.read(uploadEngineScopeProvider), 'alice');
|
||||
expect(engine.isRunning, isTrue, reason: '登录后引擎应周期出队');
|
||||
});
|
||||
|
||||
test('登出 → 引擎 stop,任务行保留库内', () async {
|
||||
session.session = Session(
|
||||
token: 'tok-1',
|
||||
expiresAt: DateTime.utc(2099, 1, 1),
|
||||
account: 'alice',
|
||||
);
|
||||
final container = pumpContainer();
|
||||
await settleScope(container);
|
||||
final engine = container.read(uploadEngineProvider);
|
||||
expect(engine.isRunning, isTrue);
|
||||
|
||||
// 种子一条任务(登出后必须保留——账号切换安全由 accountId 硬隔离保障)。
|
||||
final task = await store.create(UploadTaskDraft(
|
||||
id: 'task-keep',
|
||||
accountId: 'alice',
|
||||
albumId: 'alb-1',
|
||||
clientTaskId: 'ct-keep',
|
||||
sourceFilePath: '/tmp/keep.jpg',
|
||||
filename: 'keep.jpg',
|
||||
bytes: 16,
|
||||
contentType: 'image/jpeg',
|
||||
));
|
||||
expect(task.state, UploadTaskState.preparing);
|
||||
|
||||
session.session = null;
|
||||
await container.read(sessionAccountProvider.notifier).refresh();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(engine.isRunning, isFalse, reason: '登出后引擎必须停止出队');
|
||||
expect(container.read(uploadEngineScopeProvider), isNull);
|
||||
expect(
|
||||
await store.get('alice', 'task-keep'),
|
||||
isNotNull,
|
||||
reason: '登出不清任务库',
|
||||
);
|
||||
});
|
||||
|
||||
test('账号切换 alice → bob → 引擎随新账号重启', () async {
|
||||
session.session = Session(
|
||||
token: 'tok-1',
|
||||
expiresAt: DateTime.utc(2099, 1, 1),
|
||||
account: 'alice',
|
||||
);
|
||||
final container = pumpContainer();
|
||||
await settleScope(container);
|
||||
expect(container.read(uploadEngineScopeProvider), 'alice');
|
||||
|
||||
session.session = Session(
|
||||
token: 'tok-2',
|
||||
expiresAt: DateTime.utc(2099, 1, 1),
|
||||
account: 'bob',
|
||||
);
|
||||
await container.read(sessionAccountProvider.notifier).refresh();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
final engine = container.read(uploadEngineProvider);
|
||||
expect(container.read(uploadEngineScopeProvider), 'bob');
|
||||
expect(engine.isRunning, isTrue, reason: '切换账号 = 新 scope 继续出队');
|
||||
});
|
||||
|
||||
test('未登录启动 App → 引擎不启动', () async {
|
||||
final container = pumpContainer();
|
||||
await settleScope(container);
|
||||
|
||||
expect(container.read(uploadEngineScopeProvider), isNull);
|
||||
expect(container.read(uploadEngineProvider).isRunning, isFalse);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
/// 队列筛选纯逻辑测试(P3 计划 Task 9,需求 4.3 三维筛选)。
|
||||
///
|
||||
/// 覆盖:状态分组映射、时间快捷 cutoff、[QueueFilter.matches] 全矩阵
|
||||
/// (相册 × 状态 × 时间 + 时区归一边界)、计数汇总与 copyWith。
|
||||
/// 任务行经内存 TaskStore 种子(真实迁移链,无需 widget。
|
||||
library;
|
||||
|
||||
import 'package:flutter_test/flutter_test.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';
|
||||
|
||||
void main() {
|
||||
late TaskStore store;
|
||||
var seq = 0;
|
||||
|
||||
setUp(() {
|
||||
store = TaskStore.memory();
|
||||
seq = 0;
|
||||
});
|
||||
|
||||
tearDown(() => store.close());
|
||||
|
||||
/// 种子一条指定状态/相册/创建时间的任务(真实迁移链抵达目标状态)。
|
||||
Future<UploadTask> seedTask({
|
||||
String accountId = 'alice',
|
||||
String albumId = 'alb-1',
|
||||
UploadTaskState state = UploadTaskState.queued,
|
||||
DateTime? createdAt,
|
||||
}) async {
|
||||
final id = 'task-${seq++}';
|
||||
final draft = UploadTaskDraft(
|
||||
id: id,
|
||||
accountId: accountId,
|
||||
albumId: albumId,
|
||||
clientTaskId: 'ct-$id',
|
||||
sourceFilePath: '/tmp/$id.jpg',
|
||||
filename: '$id.jpg',
|
||||
bytes: 16,
|
||||
contentType: 'image/jpeg',
|
||||
);
|
||||
var task = await store.create(draft, now: createdAt);
|
||||
// 迁移链:preparing → 目标态(按状态机合法路径逐步推进)。
|
||||
switch (state) {
|
||||
case UploadTaskState.preparing:
|
||||
break;
|
||||
case UploadTaskState.preparationFailed:
|
||||
task = await store.updateState(task.id, state, now: createdAt);
|
||||
case UploadTaskState.queued:
|
||||
case UploadTaskState.cancelled:
|
||||
task = await store.updateState(task.id, state, now: createdAt);
|
||||
case UploadTaskState.waitingNetwork:
|
||||
task = await store.updateState(
|
||||
task.id,
|
||||
UploadTaskState.queued,
|
||||
now: createdAt,
|
||||
);
|
||||
task = await store.updateState(task.id, state, now: createdAt);
|
||||
case UploadTaskState.uploading:
|
||||
case UploadTaskState.retryableFailed:
|
||||
task = await store.updateState(
|
||||
task.id,
|
||||
UploadTaskState.queued,
|
||||
now: createdAt,
|
||||
);
|
||||
task = await store.updateState(
|
||||
task.id,
|
||||
UploadTaskState.uploading,
|
||||
now: createdAt,
|
||||
);
|
||||
if (state == UploadTaskState.retryableFailed) {
|
||||
task = await store.updateState(task.id, state, now: createdAt);
|
||||
}
|
||||
case UploadTaskState.uploadedConfirming:
|
||||
case UploadTaskState.completed:
|
||||
task = await store.updateState(
|
||||
task.id,
|
||||
UploadTaskState.queued,
|
||||
now: createdAt,
|
||||
);
|
||||
task = await store.updateState(
|
||||
task.id,
|
||||
UploadTaskState.uploading,
|
||||
now: createdAt,
|
||||
);
|
||||
task = await store.updateState(
|
||||
task.id,
|
||||
UploadTaskState.uploadedConfirming,
|
||||
now: createdAt,
|
||||
);
|
||||
if (state == UploadTaskState.completed) {
|
||||
task = await store.updateState(task.id, state, now: createdAt);
|
||||
}
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
group('QueueStateFilter.states 分组', () {
|
||||
test('all → 空集(不过滤)', () {
|
||||
expect(QueueStateFilter.all.states, isEmpty);
|
||||
});
|
||||
|
||||
test('failed 组 = 准备失败 + 上传失败(不含等待网络)', () {
|
||||
expect(QueueStateFilter.failed.states, {
|
||||
UploadTaskState.preparationFailed,
|
||||
UploadTaskState.retryableFailed,
|
||||
});
|
||||
});
|
||||
|
||||
test('uploading 组 = 上传中 + 确认中;completed 组不含 cancelled', () {
|
||||
expect(QueueStateFilter.uploading.states, {
|
||||
UploadTaskState.uploading,
|
||||
UploadTaskState.uploadedConfirming,
|
||||
});
|
||||
expect(QueueStateFilter.completed.states,
|
||||
{UploadTaskState.completed});
|
||||
});
|
||||
});
|
||||
|
||||
group('QueueTimeFilter.since', () {
|
||||
final now = DateTime(2026, 9, 19, 15, 30);
|
||||
|
||||
test('all → null(不过滤)', () {
|
||||
expect(QueueTimeFilter.all.since(now), isNull);
|
||||
});
|
||||
|
||||
test('today → 本地当日零点;7/30 天 → 对应回溯', () {
|
||||
expect(QueueTimeFilter.today.since(now), DateTime(2026, 9, 19));
|
||||
expect(
|
||||
QueueTimeFilter.last7Days.since(now),
|
||||
DateTime(2026, 9, 12, 15, 30),
|
||||
);
|
||||
expect(
|
||||
QueueTimeFilter.last30Days.since(now),
|
||||
DateTime(2026, 8, 20, 15, 30),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('QueueFilter.matches 矩阵', () {
|
||||
final now = DateTime(2026, 9, 19, 15, 30);
|
||||
|
||||
test('相册维度:命中与不命中', () async {
|
||||
final inAlbum =
|
||||
await seedTask(albumId: 'alb-1', createdAt: now);
|
||||
final other =
|
||||
await seedTask(albumId: 'alb-2', createdAt: now);
|
||||
final filter =
|
||||
const QueueFilter(albumId: 'alb-1');
|
||||
|
||||
expect(filter.matches(inAlbum, now: now), isTrue);
|
||||
expect(filter.matches(other, now: now), isFalse);
|
||||
});
|
||||
|
||||
test('状态维度:failed 组命中组内两态、排除组外', () async {
|
||||
final prep = await seedTask(
|
||||
state: UploadTaskState.preparationFailed,
|
||||
createdAt: now,
|
||||
);
|
||||
final upload = await seedTask(
|
||||
state: UploadTaskState.retryableFailed,
|
||||
createdAt: now,
|
||||
);
|
||||
final waiting = await seedTask(
|
||||
state: UploadTaskState.waitingNetwork,
|
||||
createdAt: now,
|
||||
);
|
||||
final filter = const QueueFilter(
|
||||
state: QueueStateFilter.failed,
|
||||
);
|
||||
|
||||
expect(filter.matches(prep, now: now), isTrue);
|
||||
expect(filter.matches(upload, now: now), isTrue);
|
||||
expect(filter.matches(waiting, now: now), isFalse);
|
||||
});
|
||||
|
||||
test('时间维度:cutoff 恰好含、早一毫秒不含(本地→UTC 归一)',
|
||||
() async {
|
||||
final edge = await seedTask(
|
||||
createdAt: DateTime(2026, 9, 19, 0, 0), // = today cutoff
|
||||
);
|
||||
final before = await seedTask(
|
||||
createdAt:
|
||||
DateTime(2026, 9, 18, 23, 59, 59, 999),
|
||||
);
|
||||
final filter = const QueueFilter(
|
||||
time: QueueTimeFilter.today,
|
||||
);
|
||||
|
||||
expect(filter.matches(edge, now: now), isTrue);
|
||||
expect(filter.matches(before, now: now), isFalse);
|
||||
});
|
||||
|
||||
test('today 匹配 UTC 落库 createdAt(跨时区比较归一)', () async {
|
||||
// UTC 落库时刻(本地 9/19 当日内)——create 落库即 UTC。
|
||||
final task = await seedTask(
|
||||
createdAt: DateTime.utc(2026, 9, 19, 2, 30),
|
||||
);
|
||||
const filter =
|
||||
QueueFilter(time: QueueTimeFilter.today);
|
||||
|
||||
expect(filter.matches(task, now: now), isTrue);
|
||||
});
|
||||
|
||||
test('三维叠加:全部命中才匹配', () async {
|
||||
final hit = await seedTask(
|
||||
albumId: 'alb-1',
|
||||
state: UploadTaskState.retryableFailed,
|
||||
createdAt: DateTime(2026, 9, 19, 10, 0),
|
||||
);
|
||||
final wrongAlbum = await seedTask(
|
||||
albumId: 'alb-2',
|
||||
state: UploadTaskState.retryableFailed,
|
||||
createdAt: DateTime(2026, 9, 19, 10, 0),
|
||||
);
|
||||
final wrongState = await seedTask(
|
||||
albumId: 'alb-1',
|
||||
state: UploadTaskState.queued,
|
||||
createdAt: DateTime(2026, 9, 19, 10, 0),
|
||||
);
|
||||
final tooOld = await seedTask(
|
||||
albumId: 'alb-1',
|
||||
state: UploadTaskState.retryableFailed,
|
||||
createdAt: DateTime(2026, 8, 1),
|
||||
);
|
||||
const filter = QueueFilter(
|
||||
albumId: 'alb-1',
|
||||
state: QueueStateFilter.failed,
|
||||
time: QueueTimeFilter.last30Days,
|
||||
);
|
||||
|
||||
expect(filter.matches(hit, now: now), isTrue);
|
||||
expect(filter.matches(wrongAlbum, now: now), isFalse);
|
||||
expect(filter.matches(wrongState, now: now), isFalse);
|
||||
expect(filter.matches(tooOld, now: now), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('QueueFilter.copyWith / 计数汇总', () {
|
||||
test('copyWith 覆盖与 clearAlbum 显式清空', () {
|
||||
const base = QueueFilter(albumId: 'alb-1');
|
||||
expect(base.copyWith(albumId: 'alb-2').albumId, 'alb-2');
|
||||
expect(base.copyWith(clearAlbum: true).albumId, isNull);
|
||||
// 不传时保持。
|
||||
expect(base.copyWith().albumId, 'alb-1');
|
||||
});
|
||||
|
||||
test('pendingCountOf/failedCountOf 汇总', () {
|
||||
final counts = <UploadTaskState, int>{
|
||||
for (final s in UploadTaskState.values) s: 0,
|
||||
}
|
||||
..[UploadTaskState.preparing] = 1
|
||||
..[UploadTaskState.queued] = 2
|
||||
..[UploadTaskState.uploading] = 3
|
||||
..[UploadTaskState.uploadedConfirming] = 4
|
||||
..[UploadTaskState.waitingNetwork] = 5
|
||||
..[UploadTaskState.preparationFailed] = 6
|
||||
..[UploadTaskState.retryableFailed] = 7
|
||||
..[UploadTaskState.completed] = 8
|
||||
..[UploadTaskState.cancelled] = 9;
|
||||
expect(pendingCountOf(counts), 15);
|
||||
expect(failedCountOf(counts), 13);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -9,7 +9,11 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:drift/drift.dart' show driftRuntimeOptions;
|
||||
import 'package:photo_live/core/api_client.dart';
|
||||
import 'package:photo_live/core/session_store.dart';
|
||||
import 'package:photo_live/features/uploads/providers.dart';
|
||||
import 'package:photo_live/features/uploads/task_store.dart';
|
||||
|
||||
/// 内存会话存储(测试替身)。
|
||||
class FakeSessionStore implements SessionStore {
|
||||
@@ -25,6 +29,32 @@ class FakeSessionStore implements SessionStore {
|
||||
Future<void> clear() async => session = null;
|
||||
}
|
||||
|
||||
/// Widget 测试通用 overrides(P3 Task 9 起,登录页/主壳会触达
|
||||
/// sessionAccountProvider 与任务库 Provider——真实 flutter_secure_storage
|
||||
/// 与 path_provider 的平台通道在测试环境挂起,凡 pump 到相关页面均需
|
||||
/// 注入替身)。
|
||||
///
|
||||
/// 返回 [FakeSessionStore] + 内存 [TaskStore] + 可选 FakeAdapter ApiClient
|
||||
/// 的 override 列表。Riverpod 3 已不公开 `Override` 类型(无法作签名注解),
|
||||
/// 故元素类型为 dynamic——调用点以 spread 展开进 ProviderScope.overrides
|
||||
/// (元素真实类型即 Override,展开本身类型安全):
|
||||
/// `ProviderScope(overrides: [...pageTestOverrides(...), 其他], ...)`。
|
||||
List<dynamic> pageTestOverrides({
|
||||
FakeSessionStore? session,
|
||||
TaskStore? taskStore,
|
||||
ApiClient? client,
|
||||
}) {
|
||||
// 每个测试各自 new 一块内存库(独立 QueryExecutor,随容器销毁),
|
||||
// drift 的“多实例同执行器”告警在此是误报——测试输出降噪。
|
||||
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
||||
final store = taskStore ?? TaskStore.memory();
|
||||
return <dynamic>[
|
||||
if (session != null) sessionStoreProvider.overrideWithValue(session),
|
||||
taskStoreProvider.overrideWithValue(store),
|
||||
if (client != null) apiClientProvider.overrideWith((ref) => client),
|
||||
];
|
||||
}
|
||||
|
||||
/// 记录到适配器的请求快照。
|
||||
class RecordedRequest {
|
||||
const RecordedRequest({
|
||||
|
||||
@@ -4,16 +4,31 @@
|
||||
/// 分类管理三流(新增对话框载荷、名称空校验拦截、编辑 PATCH、删除确认)。
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:camera/camera.dart' show XFile;
|
||||
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/notification_permission.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/task_store.dart';
|
||||
import 'package:photo_live/l10n/app_localizations.dart';
|
||||
import 'package:photo_live/pages/album_detail_page.dart';
|
||||
import 'package:photo_live/pages/capture_page.dart';
|
||||
|
||||
import '../helpers/fakes.dart';
|
||||
|
||||
/// JPEG 魔数最小字节(入队准备器真实文件驱动)。
|
||||
const List<int> kJpgBytes = <int>[
|
||||
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00,
|
||||
];
|
||||
|
||||
void main() {
|
||||
late FakeSessionStore store;
|
||||
late List<Map<String, dynamic>> categories;
|
||||
@@ -55,7 +70,11 @@ void main() {
|
||||
Future<void> pumpDetail(WidgetTester tester, ApiClient client) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [apiClientProvider.overrideWith((ref) => client)],
|
||||
// Task 9 起详情页有上传 FAB(触达 sessionAccountProvider),
|
||||
// 真实平台通道在测试环境挂起,必须注入替身。
|
||||
overrides: [
|
||||
...pageTestOverrides(session: store, client: client),
|
||||
],
|
||||
child: MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
@@ -79,7 +98,8 @@ void main() {
|
||||
);
|
||||
expect(find.text('sharetoken000000000001'), findsOneWidget);
|
||||
expect(find.text('大宴会厅'), findsOneWidget);
|
||||
expect(find.text('Upload'), findsOneWidget);
|
||||
// 「Upload」出现两次:开关标签 chip + Task 9 上传 FAB 文案。
|
||||
expect(find.text('Upload'), findsNWidgets(2));
|
||||
expect(find.text('Original'), findsOneWidget);
|
||||
// 动作行(5 项)。
|
||||
expect(find.text('Edit'), findsOneWidget);
|
||||
@@ -266,4 +286,131 @@ void main() {
|
||||
expect(find.text('仪式 · Ceremony'), findsNothing);
|
||||
expect(find.text('No categories yet. Tap + to add one'), findsOneWidget);
|
||||
});
|
||||
|
||||
group('上传入口 FAB(Task 9)', () {
|
||||
late Directory tmp;
|
||||
late List<String> galleryPaths;
|
||||
late TaskStore taskStore;
|
||||
|
||||
setUp(() async {
|
||||
store.session = Session(
|
||||
token: 'tok-1',
|
||||
expiresAt: DateTime.utc(2099, 1, 1),
|
||||
account: 'alice',
|
||||
);
|
||||
tmp = await Directory.systemTemp.createTemp('detail-upload-');
|
||||
galleryPaths = <String>[];
|
||||
taskStore = TaskStore.memory();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await taskStore.close();
|
||||
await tmp.delete(recursive: true);
|
||||
});
|
||||
|
||||
/// 详情页 + 上传链路替身(内存任务库/临时目录准备器/多选替身/
|
||||
/// 非平台相机源)。
|
||||
Future<void> pumpDetailForUpload(WidgetTester tester) async {
|
||||
final adapter = detailAdapter();
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
...pageTestOverrides(
|
||||
session: store,
|
||||
taskStore: taskStore,
|
||||
client: clientOf(adapter),
|
||||
),
|
||||
multiImagePickerProvider
|
||||
.overrideWith((ref) => () async => galleryPaths),
|
||||
uploadPreparerProvider.overrideWith(
|
||||
(ref) => UploadPreparer(
|
||||
taskStore,
|
||||
baseDirProvider: () async => tmp,
|
||||
),
|
||||
),
|
||||
notificationPermissionProvider.overrideWith(
|
||||
(ref) => NotificationPermission(isAndroid: false),
|
||||
),
|
||||
captureSourceFactoryProvider
|
||||
.overrideWith((ref) => () async => _StaticCaptureSource()),
|
||||
],
|
||||
child: MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const AlbumDetailPage(albumId: 'alb-1'),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('FAB → 底部弹层双入口(拍照上传 / 从相册选择)', (tester) async {
|
||||
await pumpDetailForUpload(tester);
|
||||
|
||||
await tester.tap(find.byType(FloatingActionButton));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Capture and upload'), findsOneWidget);
|
||||
expect(find.text('Pick from gallery'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('拍照上传 → 进入连拍页(目标相册带出)', (tester) async {
|
||||
await pumpDetailForUpload(tester);
|
||||
|
||||
await tester.tap(find.byType(FloatingActionButton));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Capture and upload'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(CapturePage), findsOneWidget);
|
||||
// 连拍页常驻信息层显示目标相册名(与详情标题同源:默认 zh 主标题)。
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(CapturePage),
|
||||
matching: find.text('开幕晚宴'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('从相册选择 → 多选入队 + 入队数提示', (tester) async {
|
||||
final pick1 = File('${tmp.path}/pick1.jpg')..writeAsBytesSync(kJpgBytes);
|
||||
final pick2 = File('${tmp.path}/pick2.jpg')..writeAsBytesSync(kJpgBytes);
|
||||
galleryPaths.addAll([pick1.path, pick2.path]);
|
||||
await pumpDetailForUpload(tester);
|
||||
|
||||
await tester.tap(find.byType(FloatingActionButton));
|
||||
await tester.pumpAndSettle();
|
||||
// 入队链路含真实文件 IO:真实事件循环内触发行内动作。
|
||||
final tile = find.widgetWithText(ListTile, 'Pick from gallery');
|
||||
await tester.runAsync(() async {
|
||||
(tile.evaluate().single.widget as ListTile).onTap!();
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
});
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.text('Added 2 photo(s) to the upload queue'),
|
||||
findsOneWidget,
|
||||
);
|
||||
final tasks = await taskStore.listByAccount('alice');
|
||||
expect(tasks, hasLength(2));
|
||||
expect(tasks.every((t) => t.albumId == 'alb-1'), isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// 相机源替身(详情页跳连拍页测试:仅就绪,不拍摄)。
|
||||
class _StaticCaptureSource implements CaptureSource {
|
||||
@override
|
||||
Future<void> initialize() async {}
|
||||
|
||||
@override
|
||||
Future<XFile> capture() => throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Widget buildPreview() => const SizedBox();
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
/// 连拍页 widget 测试(P3 计划 Task 9,R17 连拍入队 / O01 现场反馈)。
|
||||
///
|
||||
/// 打桩面:FakeCaptureSource(capture 返回真实 JPEG 临时文件,无相机
|
||||
/// 平台通道)+ 内存 TaskStore + 临时目录准备器 + 非 Android 权限门替身
|
||||
/// (静默跳过通道)。覆盖:快门即拍即入队、连拍串行、分类切换仅影响
|
||||
/// 之后拍摄、多选补充入队、待传/失败徽章实时刷新、有待传任务退出确认、
|
||||
/// 权限拒绝/初始化失败视图与重试。
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:camera/camera.dart' show CameraException, XFile;
|
||||
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/notification_permission.dart';
|
||||
import 'package:photo_live/core/session_store.dart';
|
||||
import 'package:photo_live/features/albums/models.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/l10n/app_localizations.dart';
|
||||
import 'package:photo_live/pages/capture_page.dart';
|
||||
|
||||
import '../helpers/fakes.dart';
|
||||
|
||||
const List<int> kJpgBytes = <int>[
|
||||
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00,
|
||||
];
|
||||
|
||||
/// 拍摄源替身:capture 依次弹出预置文件;initialize 可注入失败次数。
|
||||
class FakeCaptureSource implements CaptureSource {
|
||||
FakeCaptureSource(this._files);
|
||||
|
||||
final List<File> _files;
|
||||
int initializeCalls = 0;
|
||||
int failNextInits = 0;
|
||||
Object? initError;
|
||||
|
||||
@override
|
||||
Future<void> initialize() async {
|
||||
initializeCalls++;
|
||||
if (failNextInits > 0) {
|
||||
failNextInits--;
|
||||
throw initError ?? StateError('init failed');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<XFile> capture() async => XFile(_files.removeAt(0).path);
|
||||
|
||||
@override
|
||||
Widget buildPreview() => const ColoredBox(
|
||||
color: Color(0xFF101010),
|
||||
child: Center(child: Text('PREVIEW_MARK')),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {}
|
||||
}
|
||||
|
||||
void main() {
|
||||
late FakeSessionStore session;
|
||||
late TaskStore store;
|
||||
late Directory tmp;
|
||||
late FakeCaptureSource source;
|
||||
late List<String> galleryPaths;
|
||||
|
||||
final categories = <Category>[
|
||||
const Category(
|
||||
id: 'cat-1',
|
||||
albumId: 'alb-1',
|
||||
sortOrder: 0,
|
||||
translations: [
|
||||
CategoryTranslation(locale: 'zh-CN', name: '仪式'),
|
||||
CategoryTranslation(locale: 'en', name: 'Ceremony'),
|
||||
],
|
||||
),
|
||||
const Category(
|
||||
id: 'cat-2',
|
||||
albumId: 'alb-1',
|
||||
sortOrder: 1,
|
||||
translations: [
|
||||
CategoryTranslation(locale: 'zh-CN', name: '晚宴'),
|
||||
CategoryTranslation(locale: 'en', name: 'Gala'),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
setUp(() async {
|
||||
session = FakeSessionStore()
|
||||
..session = Session(
|
||||
token: 'tok-1',
|
||||
expiresAt: DateTime.utc(2099, 1, 1),
|
||||
account: 'alice',
|
||||
);
|
||||
store = TaskStore.memory();
|
||||
tmp = await Directory.systemTemp.createTemp('capture-test-');
|
||||
final shot1 = File('${tmp.path}/IMG_0001.jpg')..writeAsBytesSync(kJpgBytes);
|
||||
final shot2 = File('${tmp.path}/IMG_0002.jpg')..writeAsBytesSync(kJpgBytes);
|
||||
final shot3 = File('${tmp.path}/IMG_0003.jpg')..writeAsBytesSync(kJpgBytes);
|
||||
source = FakeCaptureSource([shot1, shot2, shot3]);
|
||||
galleryPaths = <String>[];
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await store.close();
|
||||
await tmp.delete(recursive: true);
|
||||
});
|
||||
|
||||
CaptureTarget target() => CaptureTarget(
|
||||
accountId: 'alice',
|
||||
albumId: 'alb-1',
|
||||
albumTitle: 'Opening Gala',
|
||||
categories: categories,
|
||||
);
|
||||
|
||||
Future<void> pumpCapture(WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
...pageTestOverrides(
|
||||
session: session,
|
||||
taskStore: store,
|
||||
client: ApiClient(
|
||||
sessionStore: session,
|
||||
dio: Dio(BaseOptions(baseUrl: 'http://test.local'))
|
||||
..httpClientAdapter = FakeAdapter(
|
||||
(_) => throw StateError('连拍页不应发起 API 请求')),
|
||||
),
|
||||
),
|
||||
captureSourceFactoryProvider
|
||||
.overrideWith((ref) => () async => source),
|
||||
uploadPreparerProvider.overrideWith(
|
||||
(ref) => UploadPreparer(
|
||||
store,
|
||||
baseDirProvider: () async => tmp,
|
||||
),
|
||||
),
|
||||
notificationPermissionProvider.overrideWith(
|
||||
(ref) => NotificationPermission(isAndroid: false),
|
||||
),
|
||||
multiImagePickerProvider
|
||||
.overrideWith((ref) => () async => galleryPaths),
|
||||
],
|
||||
child: MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: CapturePage(target: target()),
|
||||
),
|
||||
),
|
||||
);
|
||||
// 相机初始化完成:帧级 pump 直至预览出现(首个测试字体/主题冷启动
|
||||
// 较慢,固定双 pump 不够稳)。
|
||||
await tester.pump();
|
||||
var guard = 0;
|
||||
while (find.text('PREVIEW_MARK').evaluate().isEmpty && guard++ < 20) {
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
}
|
||||
}
|
||||
|
||||
/// 触发一个会走真实文件 IO 的按钮:在 runAsync(真实事件循环)内直接
|
||||
/// 调用其回调——widget 测试的 fake-async 区里 dart:io 完成回调不会被
|
||||
/// pump 冲刷,入队链路必须整体跑在真实区;返回后 pump 刷新徽章防抖。
|
||||
Future<void> pressRealIo(WidgetTester tester, IconData icon) async {
|
||||
final button = find
|
||||
.ancestor(of: find.byIcon(icon), matching: find.byType(IconButton))
|
||||
.evaluate()
|
||||
.single
|
||||
.widget as IconButton;
|
||||
await tester.runAsync(() async {
|
||||
button.onPressed!();
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
});
|
||||
await tester.pump(kQueueRefreshDebounce);
|
||||
await tester.pump(const Duration(milliseconds: 60));
|
||||
}
|
||||
|
||||
/// 快门(即拍即入队链路经 [pressRealIo])。
|
||||
Future<void> shoot(WidgetTester tester, {int times = 1}) async {
|
||||
for (var i = 0; i < times; i++) {
|
||||
await pressRealIo(tester, Icons.camera);
|
||||
}
|
||||
}
|
||||
|
||||
testWidgets('快门即拍即入队:任务落库 queued + 待传徽章 1', (tester) async {
|
||||
await pumpCapture(tester);
|
||||
expect(find.text('PREVIEW_MARK'), findsOneWidget, reason: '相机预览就绪');
|
||||
expect(find.text('Pending 0'), findsOneWidget);
|
||||
|
||||
await shoot(tester);
|
||||
|
||||
final tasks = await store.listByAccount('alice');
|
||||
expect(tasks, hasLength(1));
|
||||
expect(tasks.single.state, UploadTaskState.queued);
|
||||
expect(tasks.single.accountId, 'alice');
|
||||
expect(tasks.single.albumId, 'alb-1');
|
||||
expect(tasks.single.categoryId, isNull, reason: '默认分类');
|
||||
expect(tasks.single.filename, 'IMG_0001.jpg');
|
||||
expect(find.text('Pending 1'), findsOneWidget, reason: '徽章实时刷新');
|
||||
});
|
||||
|
||||
testWidgets('连拍两张串行入队(不逐张确认)', (tester) async {
|
||||
await pumpCapture(tester);
|
||||
|
||||
await shoot(tester, times: 2);
|
||||
|
||||
final tasks = await store.listByAccount('alice');
|
||||
expect(tasks.map((t) => t.filename), unorderedEquals(<String>[
|
||||
'IMG_0001.jpg',
|
||||
'IMG_0002.jpg',
|
||||
]));
|
||||
expect(find.text('Pending 2'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('分类切换:hint 提示 + 仅影响之后拍摄(A15 归属不变)',
|
||||
(tester) async {
|
||||
await pumpCapture(tester);
|
||||
await shoot(tester); // 第一张:默认分类。
|
||||
|
||||
await tester.tap(find.text('Gala'));
|
||||
await tester.pump();
|
||||
expect(
|
||||
find.text('Switching only affects photos captured afterwards'),
|
||||
findsOneWidget,
|
||||
reason: '切换归属不变提示',
|
||||
);
|
||||
await tester.pumpAndSettle(); // snackbar 退场。
|
||||
|
||||
await shoot(tester); // 第二张:新分类。
|
||||
|
||||
final tasks = await store.listByAccount('alice');
|
||||
final first =
|
||||
tasks.firstWhere((t) => t.filename == 'IMG_0001.jpg');
|
||||
final second =
|
||||
tasks.firstWhere((t) => t.filename == 'IMG_0002.jpg');
|
||||
expect(first.categoryId, isNull, reason: '既有任务归属不变');
|
||||
expect(second.categoryId, 'cat-2', reason: '切换后拍摄走新分类');
|
||||
});
|
||||
|
||||
testWidgets('相册多选补充入队 → 入队数提示', (tester) async {
|
||||
final pick1 = File('${tmp.path}/pick1.jpg')..writeAsBytesSync(kJpgBytes);
|
||||
final pick2 = File('${tmp.path}/pick2.jpg')..writeAsBytesSync(kJpgBytes);
|
||||
galleryPaths.addAll([pick1.path, pick2.path]);
|
||||
await pumpCapture(tester);
|
||||
|
||||
// 多选入队走真实文件 IO(同快门路径)。
|
||||
await pressRealIo(tester, Icons.photo_library_outlined);
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.text('Added 2 photo(s) to the upload queue'), findsOneWidget);
|
||||
expect(await store.listByAccount('alice'), hasLength(2));
|
||||
});
|
||||
|
||||
testWidgets('退出确认:有待传任务 → 弹确认;保留继续拍 / 确认才退出',
|
||||
(tester) async {
|
||||
await pumpCapture(tester);
|
||||
expect(find.byType(CapturePage), findsOneWidget);
|
||||
|
||||
await shoot(tester);
|
||||
|
||||
// home 路由无返回按钮——经 Navigator.maybePop 驱动系统返回路径
|
||||
//(PopScope.canPop=false → onPopInvokedWithResult 弹确认)。
|
||||
Future<void> requestBack() async {
|
||||
final navigator = tester.state<NavigatorState>(
|
||||
find.byType(Navigator).first,
|
||||
);
|
||||
await navigator.maybePop();
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
// 有待传 → 弹确认。
|
||||
await requestBack();
|
||||
expect(find.text('Leave shooting?'), findsOneWidget);
|
||||
expect(
|
||||
find.textContaining('still uploading'),
|
||||
findsOneWidget,
|
||||
reason: '正文含待传数与后台继续说明',
|
||||
);
|
||||
|
||||
// 保留继续拍 → 不退出。
|
||||
await tester.tap(find.text('Keep shooting'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(CapturePage), findsOneWidget);
|
||||
|
||||
// 再次退出 → 确认离开 → 出栈。
|
||||
await requestBack();
|
||||
await tester.tap(find.text('Leave'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(CapturePage), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('权限拒绝视图:文案 + 重试入口', (tester) async {
|
||||
source.initError = CameraException('CameraAccessDenied', 'denied');
|
||||
source.failNextInits = 1;
|
||||
await pumpCapture(tester);
|
||||
|
||||
expect(
|
||||
find.text(
|
||||
'Camera unavailable. Allow camera access in system settings'),
|
||||
findsOneWidget,
|
||||
);
|
||||
// 重试成功 → 预览就绪。
|
||||
await tester.tap(find.text('Retry'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
expect(find.text('PREVIEW_MARK'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('初始化失败视图:非权限异常文案 + 重试恢复', (tester) async {
|
||||
source.failNextInits = 1;
|
||||
source.initError = StateError('camera broken');
|
||||
await pumpCapture(tester);
|
||||
|
||||
expect(find.text('Camera failed to initialize'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Retry'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
expect(find.text('PREVIEW_MARK'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('常驻信息层:相册名 + 分类候选', (tester) async {
|
||||
await pumpCapture(tester);
|
||||
|
||||
expect(find.text('Opening Gala'), findsOneWidget);
|
||||
expect(find.text('Default category'), findsOneWidget);
|
||||
expect(find.text('Ceremony'), findsOneWidget);
|
||||
expect(find.text('Gala'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -37,7 +37,11 @@ void main() {
|
||||
Future<void> pumpLoginPage(WidgetTester tester, ApiClient client) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [apiClientProvider.overrideWith((ref) => client)],
|
||||
// Task 9 起登录成功会刷新 sessionAccountProvider——真实
|
||||
// flutter_secure_storage 平台通道在测试环境挂起,必须注入替身。
|
||||
overrides: [
|
||||
...pageTestOverrides(session: store, client: client),
|
||||
],
|
||||
child: MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
|
||||
@@ -0,0 +1,546 @@
|
||||
/// 上传队列页 widget 测试(P3 计划 Task 9,需求 4.3)。
|
||||
///
|
||||
/// 打桩面:RecordingEngine(记录 retryNow/cancel,不出队)+ 内存 TaskStore
|
||||
/// 种子 + FakeAdapter 相册列表 + 临时目录准备器(准备失败重试换源)。
|
||||
/// 覆盖:任务行渲染(状态徽章/错误文案/相册名)、三维筛选交互、按状态
|
||||
/// 动作(重试/取消/删除)、批量重试条、写通知防抖实时刷新、行级进度条。
|
||||
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 {
|
||||
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: const MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: 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;
|
||||
}
|
||||
|
||||
/// 等防抖重查落定(写通知 → 200ms Timer → 异步查询;不 pumpAndSettle——
|
||||
/// uploading 行的不确定态进度条动画不会停)。
|
||||
Future<void> settleDebounce(WidgetTester tester) async {
|
||||
await tester.pump(kQueueRefreshDebounce);
|
||||
await tester.pump(const Duration(milliseconds: 120));
|
||||
}
|
||||
|
||||
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;断言锚定在行内——chip 栏文案与部分徽章同名)。
|
||||
expect(
|
||||
find
|
||||
.descendant(
|
||||
of: find.widgetWithText(ListTile, 'prep.jpg'),
|
||||
matching: find.text('Preparing'),
|
||||
)
|
||||
.evaluate(),
|
||||
isNotEmpty,
|
||||
);
|
||||
expect(
|
||||
find
|
||||
.descendant(
|
||||
of: find.widgetWithText(ListTile, 'ok.jpg'),
|
||||
matching: find.text('Completed'),
|
||||
)
|
||||
.evaluate(),
|
||||
isNotEmpty,
|
||||
);
|
||||
expect(
|
||||
find
|
||||
.descendant(
|
||||
of: find.widgetWithText(ListTile, 'net.jpg'),
|
||||
matching: find.text('Waiting for network'),
|
||||
)
|
||||
.evaluate(),
|
||||
isNotEmpty,
|
||||
);
|
||||
expect(
|
||||
find
|
||||
.descendant(
|
||||
of: find.widgetWithText(ListTile, 'alb2.jpg'),
|
||||
matching: find.text('Uploading'),
|
||||
)
|
||||
.evaluate(),
|
||||
isNotEmpty,
|
||||
);
|
||||
// 文件名。
|
||||
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);
|
||||
|
||||
expect(find.text('Photo exceeds the 50MB limit'), findsOneWidget);
|
||||
expect(
|
||||
find.text('Network error. Check your connection and retry · '
|
||||
'2 attempt(s)'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('状态筛选:Failed chip → 仅失败行', (tester) async {
|
||||
await seed('bad.jpg', state: UploadTaskState.preparationFailed);
|
||||
await seed('good.jpg', state: UploadTaskState.completed);
|
||||
await pumpQueue(tester);
|
||||
|
||||
// 状态 chips 行横向滚动——Failed 在屏外,先滚入再点。
|
||||
await tester.ensureVisible(find.text('Failed'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Failed'));
|
||||
await settleDebounce(tester);
|
||||
|
||||
expect(find.text('bad.jpg'), findsOneWidget);
|
||||
expect(find.text('good.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 tester.tap(find.text('Today'));
|
||||
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 tester.tap(find.text('All albums'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Wedding Day').last);
|
||||
await settleDebounce(tester);
|
||||
|
||||
expect(find.text('two.jpg'), findsOneWidget);
|
||||
expect(find.text('one.jpg'), findsNothing);
|
||||
});
|
||||
|
||||
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 行无动作。
|
||||
final cancelTile = find.widgetWithText(ListTile, 'q.jpg');
|
||||
final doneTile = find.widgetWithText(ListTile, 'done.jpg');
|
||||
final upTile = find.widgetWithText(ListTile, 'up.jpg');
|
||||
expect(
|
||||
find
|
||||
.descendant(of: cancelTile, matching: find.byIcon(Icons.close))
|
||||
.evaluate()
|
||||
.length,
|
||||
1,
|
||||
reason: 'queued 行有取消按钮',
|
||||
);
|
||||
expect(
|
||||
find
|
||||
.descendant(
|
||||
of: doneTile,
|
||||
matching: find.byIcon(Icons.delete_outline),
|
||||
)
|
||||
.evaluate()
|
||||
.length,
|
||||
1,
|
||||
reason: 'completed 行有删除按钮',
|
||||
);
|
||||
expect(
|
||||
find.descendant(of: upTile, matching: find.byType(IconButton)).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: find.widgetWithText(ListTile, '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: find.widgetWithText(ListTile, '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);
|
||||
|
||||
// 换源复制是真实文件 IO——runAsync 放行真实事件循环完成。
|
||||
await tester.runAsync(() async {
|
||||
await tester.tap(find.descendant(
|
||||
of: find.widgetWithText(ListTile, 'broken.jpg'),
|
||||
matching: find.byIcon(Icons.refresh),
|
||||
));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 400));
|
||||
});
|
||||
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('删除流:completed 行删除 → 行消失 + 库内清除',
|
||||
(tester) async {
|
||||
final task = await seed('done.jpg', state: UploadTaskState.completed);
|
||||
await pumpQueue(tester);
|
||||
|
||||
await tester.tap(find.descendant(
|
||||
of: find.widgetWithText(ListTile, 'done.jpg'),
|
||||
matching: find.byIcon(Icons.delete_outline),
|
||||
));
|
||||
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',
|
||||
);
|
||||
final auto2 = await seed(
|
||||
'f2.jpg',
|
||||
state: UploadTaskState.waitingNetwork,
|
||||
lastError: 'NETWORK_ERROR',
|
||||
);
|
||||
await seed(
|
||||
'f3.jpg',
|
||||
state: UploadTaskState.preparationFailed,
|
||||
lastError: 'PREP_COPY_FAILED',
|
||||
);
|
||||
await pumpQueue(tester);
|
||||
|
||||
// 2 条可自动重试(retryableFailed + waitingNetwork)→ 批量条出现。
|
||||
expect(find.text('Retry all failed'), findsOneWidget);
|
||||
expect(find.text('Failed · 2'), findsOneWidget,
|
||||
reason: '失败计数 = 准备失败 + 上传失败(不含等待网络)');
|
||||
|
||||
await tester.tap(find.text('Retry all failed'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(engine.retryCalls, containsAll([
|
||||
('alice', auto1.id),
|
||||
('alice', auto2.id),
|
||||
]));
|
||||
expect(
|
||||
engine.retryCalls.map((c) => c.$2),
|
||||
isNot(contains('task-f3.jpg')),
|
||||
reason: '准备失败需换源重选,不进批量重试',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('无失败任务 → 无批量条', (tester) async {
|
||||
await seed('q.jpg');
|
||||
await pumpQueue(tester);
|
||||
|
||||
expect(find.text('Retry all failed'), 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('行级进度:uploading 行订阅进度总线,分数更新',
|
||||
(tester) async {
|
||||
final task = await seed('up.jpg', state: UploadTaskState.uploading);
|
||||
await pumpQueue(tester);
|
||||
|
||||
final context = tester.element(find.byType(QueueTab));
|
||||
final hub = ProviderScope.containerOf(context)
|
||||
.read(uploadProgressHubProvider);
|
||||
|
||||
LinearProgressIndicator indicatorOf() => find
|
||||
.descendant(
|
||||
of: find.widgetWithText(ListTile, 'up.jpg'),
|
||||
matching: find.byType(LinearProgressIndicator),
|
||||
)
|
||||
.evaluate()
|
||||
.single
|
||||
.widget as LinearProgressIndicator;
|
||||
|
||||
expect(indicatorOf().value, isNull, reason: '无事件时不确定态进度条');
|
||||
|
||||
hub.emit(task.id, 50, 100);
|
||||
await tester.pump();
|
||||
|
||||
expect(indicatorOf().value, 0.5, reason: '进度事件行级刷新为 50%');
|
||||
});
|
||||
}
|
||||
@@ -38,7 +38,11 @@ void main() {
|
||||
Future<void> pumpHome(WidgetTester tester, ApiClient client) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [apiClientProvider.overrideWith((ref) => client)],
|
||||
// Task 9 起主壳含队列页(触达 sessionAccountProvider/TaskStore),
|
||||
// 真实平台通道在测试环境挂起,必须注入替身。
|
||||
overrides: [
|
||||
...pageTestOverrides(session: store, client: client),
|
||||
],
|
||||
child: MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
|
||||
Reference in New Issue
Block a user