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:
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user