/// 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 read() async => session; @override Future save(Session value) async => session = value; @override Future clear() async => session = null; } /// 内存语言偏好存储(测试替身,P3 Task 10)。 class FakeLocaleStore implements LocalePrefsStore { /// 当前持久值(read 返回);null = 未设置(跟随系统)。 String? value; /// 按序记录全部写入(断言持久化调用用)。 final List writes = []; @override Future read() async => value; @override Future 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 pageTestOverrides({ FakeSessionStore? session, TaskStore? taskStore, ApiClient? client, }) { // 每个测试各自 new 一块内存库(独立 QueryExecutor,随容器销毁), // drift 的“多实例同执行器”告警在此是误报——测试输出降噪。 driftRuntimeOptions.dontWarnAboutMultipleDatabases = true; final store = taskStore ?? TaskStore.memory(); return [ 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 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 requests = []; @override Future fetch( RequestOptions options, Stream? requestStream, Future? cancelFuture, ) async { requests.add( RecordedRequest( method: options.method, path: options.path, uri: options.uri, headers: Map.from(options.headers), body: options.data, ), ); if (requestStream != null) { // 消费请求流(真实服务端会读 body;上传引擎的进度计数流由此驱动)。 await requestStream.drain(); } 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 loginOkBody({ String token = 'tok-new', String expiresAt = '2026-09-20T12:00:00Z', }) => {'sessionToken': token, 'expiresAt': expiresAt}; /// 常用响应体:契约 Error 形状。 Map errorBody(String code) => { 'code': code, 'message': 'stub message', 'requestId': 'req-stub', }; // ── 相册域 fixtures(P3 计划 Task 4;形状对齐 openapi.yaml schemas)── /// 契约 `Album` 响应体(GET/POST /albums、PATCH /albums/{id})。 Map 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>? translations, List> categories = const >[], Map? cover, }) => { '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 ?? >[ { 'locale': 'zh-CN', 'title': '开幕晚宴', 'description': null, 'venue': '大宴会厅', 'address': null, }, { '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 categoryJson({ String id = 'cat-1', String albumId = 'alb-1', int sortOrder = 0, List>? translations, }) => { 'id': id, 'albumId': albumId, 'sortOrder': sortOrder, 'translations': translations ?? >[ {'locale': 'zh-CN', 'name': '仪式'}, {'locale': 'en', 'name': 'Ceremony'}, ], }; /// 契约 `Member` 响应体。 Map memberJson({ String userId = 'u-2', String account = 'staff02', String displayName = 'Staff Two', String role = 'collaborator', }) => { 'userId': userId, 'account': account, 'displayName': displayName, 'role': role, 'status': 'active', }; /// 契约 `AdminUserLookupItem` 响应体。 Map lookupUserJson({ String id = 'u-9', String account = 'staff09', String displayName = 'Staff Nine', }) => { 'id': id, 'account': account, 'displayName': displayName, }; /// 契约 `AdminPhoto` 响应体。 Map 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? preview, }) => { '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': { 'userId': 'u-2', 'account': uploaderAccount, 'displayName': uploaderName, }, 'preview': preview, }; /// 契约 `PhotoPreview` 响应体(thumb/browse 签名 URL)。 Map photoPreviewJson({ String thumbUrl = 'http://test.local/thumb.jpg', String browseUrl = 'http://test.local/browse.jpg', }) => { 'thumb': { 'url': thumbUrl, 'expiresAt': '2026-09-22T00:00:00Z', }, 'browse': { 'url': browseUrl, 'expiresAt': '2026-09-22T00:00:00Z', }, }; /// 契约游标分页响应体(hasMore 与 nextCursor 成对)。 Map cursorPageBody( List> items, { String? nextCursor, }) => { 'items': items, 'nextCursor': nextCursor, 'hasMore': nextCursor != null, };