Files
photo-live/apps/mobile/test/helpers/fakes.dart
T
2026-09-21 15:55:44 +08:00

314 lines
9.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.
/// Widget/单元测试共用替身(P3 计划 Task 3)。
///
/// 与 test/core/api_client_test.dart 中的手写打桩同款:FakeAdapter 为
/// dio HttpClientAdapter,按路由分发预设响应并记录全部请求;未引入
/// http_mock_adapter(零额外 dev 依赖,打桩面完全可控)。
library;
import 'dart:async';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:drift/drift.dart' show driftRuntimeOptions;
import 'package:photo_live/app/locale_controller.dart';
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 {
Session? session;
@override
Future<Session?> read() async => session;
@override
Future<void> save(Session value) async => session = value;
@override
Future<void> clear() async => session = null;
}
/// 内存语言偏好存储(测试替身,P3 Task 10)。
class FakeLocaleStore implements LocalePrefsStore {
/// 当前持久值(read 返回);null = 未设置(跟随系统)。
String? value;
/// 按序记录全部写入(断言持久化调用用)。
final List<String> writes = <String>[];
@override
Future<String?> read() async => value;
@override
Future<void> write(String value) async {
writes.add(value);
this.value = value;
}
}
/// 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({
required this.method,
required this.path,
required this.uri,
required this.headers,
required this.body,
});
final String method;
final String path;
/// 完整 URI(含 query,游标/过滤参数断言用)。
final Uri uri;
final Map<String, dynamic> headers;
final Object? body;
}
/// 手写 dio 打桩适配器:按路由分发预设响应并记录全部请求。
///
/// [router] 返回 `(status, body)`(可为 async 以模拟慢响应/加载态);
/// body 为 null 表示无响应体(204)。
class FakeAdapter implements HttpClientAdapter {
FakeAdapter(this.router);
final FutureOr<(int, Object?)> Function(RequestOptions options) router;
final List<RecordedRequest> requests = <RecordedRequest>[];
@override
Future<ResponseBody> fetch(
RequestOptions options,
Stream? requestStream,
Future? cancelFuture,
) async {
requests.add(
RecordedRequest(
method: options.method,
path: options.path,
uri: options.uri,
headers: Map<String, dynamic>.from(options.headers),
body: options.data,
),
);
if (requestStream != null) {
// 消费请求流(真实服务端会读 body;上传引擎的进度计数流由此驱动)。
await requestStream.drain<Object?>();
}
final (status, body) = await router(options);
if (body == null) {
// 204 等:空体且不带 JSON content-type(transformer 不解码)。
return ResponseBody.fromString('', status);
}
return ResponseBody.fromString(
jsonEncode(body),
status,
headers: {
Headers.contentTypeHeader: [Headers.jsonContentType],
},
);
}
@override
void close({bool force = false}) {}
}
/// 常用响应体:登录成功(openapi LoginResponse 形状)。
Map<String, dynamic> loginOkBody({
String token = 'tok-new',
String expiresAt = '2026-09-20T12:00:00Z',
}) => <String, dynamic>{'sessionToken': token, 'expiresAt': expiresAt};
/// 常用响应体:契约 Error 形状。
Map<String, dynamic> errorBody(String code) => <String, dynamic>{
'code': code,
'message': 'stub message',
'requestId': 'req-stub',
};
// ── 相册域 fixtures(P3 计划 Task 4;形状对齐 openapi.yaml schemas)──
/// 契约 `Album` 响应体(GET/POST /albums、PATCH /albums/{id})。
Map<String, dynamic> albumJson({
String id = 'alb-1',
String publicToken = 'sharetoken000000000001',
String ownerId = 'u-owner',
String status = 'active',
String defaultLocale = 'zh-CN',
String timezone = 'Asia/Shanghai',
String startAt = '2026-09-18T06:30:00Z', // 上海墙钟 14:30
String? endAt,
bool uploadEnabled = true,
bool publicEnabled = true,
bool downloadEnabled = true,
bool originalDownloadEnabled = false,
List<Map<String, dynamic>>? translations,
List<Map<String, dynamic>> categories = const <Map<String, dynamic>>[],
Map<String, dynamic>? cover,
}) => <String, dynamic>{
'id': id,
'publicToken': publicToken,
'ownerId': ownerId,
'status': status,
'defaultLocale': defaultLocale,
'timezone': timezone,
'startAt': startAt,
'endAt': endAt,
'uploadEnabled': uploadEnabled,
'publicEnabled': publicEnabled,
'downloadEnabled': downloadEnabled,
'originalDownloadEnabled': originalDownloadEnabled,
'watermarkVersion': 0,
'translations':
translations ??
<Map<String, dynamic>>[
<String, dynamic>{
'locale': 'zh-CN',
'title': '开幕晚宴',
'description': null,
'venue': '大宴会厅',
'address': null,
},
<String, dynamic>{
'locale': 'en',
'title': 'Opening Gala',
'description': null,
'venue': null,
'address': null,
},
],
'categories': categories,
'createdAt': '2026-09-01T00:00:00Z',
'updatedAt': '2026-09-01T00:00:00Z',
'statsSyncedAt': null,
'cover': cover,
};
/// 契约 `Category` 响应体。
Map<String, dynamic> categoryJson({
String id = 'cat-1',
String albumId = 'alb-1',
int sortOrder = 0,
List<Map<String, dynamic>>? translations,
}) => <String, dynamic>{
'id': id,
'albumId': albumId,
'sortOrder': sortOrder,
'translations':
translations ??
<Map<String, dynamic>>[
<String, dynamic>{'locale': 'zh-CN', 'name': '仪式'},
<String, dynamic>{'locale': 'en', 'name': 'Ceremony'},
],
};
/// 契约 `Member` 响应体。
Map<String, dynamic> memberJson({
String userId = 'u-2',
String account = 'staff02',
String displayName = 'Staff Two',
String role = 'collaborator',
}) => <String, dynamic>{
'userId': userId,
'account': account,
'displayName': displayName,
'role': role,
'status': 'active',
};
/// 契约 `AdminUserLookupItem` 响应体。
Map<String, dynamic> lookupUserJson({
String id = 'u-9',
String account = 'staff09',
String displayName = 'Staff Nine',
}) => <String, dynamic>{
'id': id,
'account': account,
'displayName': displayName,
};
/// 契约 `AdminPhoto` 响应体。
Map<String, dynamic> adminPhotoJson({
String photoId = 'ph-00000001',
String albumId = 'alb-1',
String visibility = 'published',
String processingState = 'ready',
int bytes = 123456,
String uploaderAccount = 'staff02',
String uploaderName = 'Staff Two',
Map<String, dynamic>? preview,
}) => <String, dynamic>{
'photoId': photoId,
'albumId': albumId,
'categoryId': null,
'uploaderId': 'u-2',
'visibility': visibility,
'processingState': processingState,
'bytes': bytes,
'width': 4000,
'height': 3000,
'shotAt': null,
'uploadedAt': '2026-09-18T07:00:00Z',
'publishedAt': '2026-09-18T07:05:00Z',
'likeCount': 0,
'uploader': <String, dynamic>{
'userId': 'u-2',
'account': uploaderAccount,
'displayName': uploaderName,
},
'preview': preview,
};
/// 契约 `PhotoPreview` 响应体(thumb/browse 签名 URL)。
Map<String, dynamic> photoPreviewJson({
String thumbUrl = 'http://test.local/thumb.jpg',
String browseUrl = 'http://test.local/browse.jpg',
}) => <String, dynamic>{
'thumb': {
'url': thumbUrl,
'expiresAt': '2026-09-22T00:00:00Z',
},
'browse': {
'url': browseUrl,
'expiresAt': '2026-09-22T00:00:00Z',
},
};
/// 契约游标分页响应体(hasMore 与 nextCursor 成对)。
Map<String, dynamic> cursorPageBody(
List<Map<String, dynamic>> items, {
String? nextCursor,
}) => <String, dynamic>{
'items': items,
'nextCursor': nextCursor,
'hasMore': nextCursor != null,
};