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