Files
photo-live/apps/mobile/test/features/uploads/engine_scope_test.dart
T
浅念 955f9b13c1 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>
2026-09-19 04:47:31 +08:00

155 lines
5.3 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.
/// 引擎生命周期绑定测试(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);
});
}