/// 服务端上传历史页 widget 测试(UI-03 页面 16;A19 语义)。 /// /// 覆盖:标题/副标题(跨设备口径)与 A19 数据源区分横幅、行内容 /// (clientTaskId 短码/KB/状态徽章/时间 · 设备来源安全回退)、相册与 /// 日期客户端筛选、下拉刷新、游标加载更多、加载更多失败页脚重试、 /// 首屏错误重试。 library; import 'package:dio/dio.dart'; import 'package:flutter/material.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/l10n/app_localizations.dart'; import 'package:photo_live/pages/server_history_page.dart'; import '../helpers/fakes.dart'; const String _kOwnDevice = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; const String _kOtherDevice = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; class _FixedDeviceIdStore implements DeviceIdStore { _FixedDeviceIdStore(this._value); final String _value; @override Future read() async => _value; @override Future write(String value) async {} } /// 契约 `UploadSessionRecord` 响应体(GET /uploads 条目)。 Map sessionJson({ String uploadId = 'upl-0001', String albumId = 'alb-1', String clientTaskId = 'clienttask000000000000000000000001', String state = 'confirmed', int expectedBytes = 2048, String createdAt = '2026-09-19T08:00:00Z', String photoId = 'pho-0001', String processingState = 'ready', String? sourceDeviceId = _kOwnDevice, }) => { 'uploadId': uploadId, 'albumId': albumId, 'clientTaskId': clientTaskId, 'state': state, 'expectedBytes': expectedBytes, 'contentType': 'image/jpeg', 'createdAt': createdAt, 'sourceDeviceId': sourceDeviceId, 'photo': { 'photoId': photoId, 'processingState': processingState, 'visibility': 'published', }, }; void main() { late FakeSessionStore sessionStore; setUp(() { sessionStore = FakeSessionStore() ..session = Session(token: 'tok-1', account: 'alice'); }); /// 页面 watch albumListProvider(相册筛选候选)——适配器统一供给 /// GET /v1/albums;/v1/uploads 路由由各用例注入。 FakeAdapter adapterOf( (int, Object?) Function(RequestOptions options) uploads, { List> albums = const >[], }) => FakeAdapter((o) { if (o.method == 'GET' && o.path == '/v1/albums') { return (200, cursorPageBody(albums)); } return uploads(o); }); Future pumpPage(WidgetTester tester, ApiClient client) async { await tester.pumpWidget( ProviderScope( overrides: [ apiClientProvider.overrideWith((ref) => client), deviceIdProvider.overrideWith( (ref) => DeviceId(store: _FixedDeviceIdStore(_kOwnDevice)), ), ], child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: const ServerHistoryPage(), ), ), ); await tester.pumpAndSettle(); } ApiClient clientOf(FakeAdapter adapter) => ApiClient( sessionStore: sessionStore, dio: Dio(BaseOptions(baseUrl: 'http://test.local')) ..httpClientAdapter = adapter, ); /// 仅 /v1/uploads 请求(剥离相册列表请求)。 Iterable uploadsRequests(FakeAdapter adapter) => adapter.requests.where((r) => r.path == '/v1/uploads'); testWidgets('标题/副标题(跨设备口径)+ A19 横幅 + 行内容与设备来源回退', (tester) async { final adapter = adapterOf((o) { if (o.method == 'GET' && o.path == '/v1/uploads') { return ( 200, cursorPageBody([ sessionJson(clientTaskId: 'owndevice0000000000000000000001'), sessionJson( uploadId: 'upl-0002', clientTaskId: 'otherdev000000000000000000000002', state: 'pending', processingState: 'processing', expectedBytes: 5 * 1024 * 1024, sourceDeviceId: _kOtherDevice, ), sessionJson( uploadId: 'upl-0003', clientTaskId: 'nodevice0000000000000000000000003', state: 'expired', processingState: 'processing_failed', expectedBytes: 4096, sourceDeviceId: null, ), ]), ); } throw StateError('unexpected ${o.method} ${o.path}'); }); await pumpPage(tester, clientOf(adapter)); // 页面 16:标题 + 跨设备副标题(与本地队列口径区分)。 expect(find.text('Server records'), findsOneWidget); expect(find.text('All devices · Your account'), findsOneWidget); // A19 数据源区分横幅(本机待传队列入口指向「上传」页)。 expect(find.textContaining('all of your devices'), findsOneWidget); expect(find.textContaining('Uploads tab'), findsOneWidget); // 行:clientTaskId 前 8 位 + KB(2048B → 2 KB;5MB → 5120 KB)。 expect(find.textContaining('owndevic ·'), findsOneWidget); expect(find.textContaining('2 KB'), findsOneWidget); expect(find.textContaining('otherdev ·'), findsOneWidget); expect(find.textContaining('5120 KB'), findsOneWidget); expect(find.textContaining('nodevice · 4 KB'), findsOneWidget); // 状态徽章(会话状态 + 处理状态;页面 16 专用文案)。 expect(find.text('Confirmed'), findsOneWidget); expect(find.text('Pending'), findsOneWidget); expect(find.text('Ready'), findsOneWidget); expect(find.text('Processing'), findsOneWidget); expect(find.text('Expired'), findsOneWidget); expect(find.text('Processing failed'), findsOneWidget); // 设备来源安全回退:本机 → This device;他机/缺失 → Unknown device //(不展示裸设备 ID)。 expect(find.textContaining('This device'), findsOneWidget); expect(find.textContaining('Unknown device'), findsNWidgets(2)); expect( find.textContaining(_kOtherDevice.substring(0, 8)), findsNothing, reason: '契约无设备名——不展示裸设备 ID', ); // listUploads 请求带分页 limit=20。 expect( uploadsRequests(adapter).single.uri.queryParameters['limit'], '20', ); }); testWidgets('相册筛选(候选来自相册列表;客户端过滤已加载记录)', (tester) async { final adapter = adapterOf( (o) { if (o.method == 'GET' && o.path == '/v1/uploads') { return ( 200, cursorPageBody([ sessionJson( albumId: 'alb-1', clientTaskId: 'galarow00000000000000000000000001', ), sessionJson( albumId: 'alb-2', clientTaskId: 'wedrow000000000000000000000000002', ), ]), ); } throw StateError('unexpected ${o.method} ${o.path}'); }, albums: [ albumJson(), albumJson( id: 'alb-2', publicToken: 'sharetoken000000000002', translations: [ { 'locale': 'zh-CN', 'title': '婚礼当天', 'description': null, 'venue': null, 'address': null, }, { 'locale': 'en', 'title': 'Wedding Day', 'description': null, 'venue': null, 'address': null, }, ], ), ], ); await pumpPage(tester, clientOf(adapter)); expect(find.textContaining('galarow'), findsOneWidget); expect(find.textContaining('wedrow'), findsOneWidget); // 打开相册筛选菜单(默认 All albums)→ 选择 Wedding Day。 await tester.tap(find.text('All albums')); await tester.pumpAndSettle(); await tester.tap(find.text('Wedding Day')); await tester.pumpAndSettle(); // 仅保留 alb-2 记录;筛选入口标签随选择更新。 expect(find.textContaining('wedrow'), findsOneWidget); expect(find.textContaining('galarow'), findsNothing); expect(find.text('Wedding Day'), findsOneWidget, reason: '药丸标签'); }); testWidgets('日期筛选(Today:仅保留今天创建的记录)', (tester) async { final now = DateTime.now().toUtc().toIso8601String(); final adapter = adapterOf((o) { if (o.method == 'GET' && o.path == '/v1/uploads') { return ( 200, cursorPageBody([ sessionJson( clientTaskId: 'todayrow0000000000000000000000001', createdAt: now, ), sessionJson( uploadId: 'upl-0002', clientTaskId: 'oldrow00000000000000000000000002', createdAt: '2026-09-01T00:00:00Z', ), ]), ); } throw StateError('unexpected ${o.method} ${o.path}'); }); await pumpPage(tester, clientOf(adapter)); expect(find.textContaining('todayrow'), findsOneWidget); expect(find.textContaining('oldrow'), findsOneWidget); // 打开日期筛选菜单(默认 Any time)→ 选择 Today。 await tester.tap(find.text('Any time')); await tester.pumpAndSettle(); await tester.tap(find.text('Today')); await tester.pumpAndSettle(); expect(find.textContaining('todayrow'), findsOneWidget); expect(find.textContaining('oldrow'), findsNothing); }); testWidgets('下拉刷新重取第一页', (tester) async { var first = true; final adapter = adapterOf((o) { if (o.method == 'GET' && o.path == '/v1/uploads') { return first ? ( 200, cursorPageBody([ sessionJson( clientTaskId: 'firstrow000000000000000000000001', ), ]), ) : ( 200, cursorPageBody([ sessionJson( clientTaskId: 'refreshr000000000000000000000002', ), ]), ); } throw StateError('unexpected ${o.method} ${o.path}'); }); await pumpPage(tester, clientOf(adapter)); expect(find.textContaining('firstrow'), findsOneWidget); first = false; await tester.fling( find.byType(ListView), const Offset(0, 300), 600, ); await tester.pump(); await tester.pumpAndSettle(); expect(find.textContaining('refreshr'), findsOneWidget); expect(find.textContaining('firstrow'), findsNothing); }); testWidgets('游标加载更多(滚动触底 → 第二页带 cursor)', (tester) async { final page1 = List>.generate( 20, (i) => sessionJson( uploadId: 'upl-p1-$i', clientTaskId: 'page1row$i.padLeft-000000000000000000001', ), ); final adapter = adapterOf((o) { final cursor = o.uri.queryParameters['cursor']; if (cursor == null) { return (200, cursorPageBody(page1, nextCursor: 'hc1')); } if (cursor == 'hc1') { return ( 200, cursorPageBody( [sessionJson(clientTaskId: 'page2item000000000000000000000001')], ), ); } throw StateError('unexpected cursor $cursor'); }); await pumpPage(tester, clientOf(adapter)); expect(uploadsRequests(adapter), hasLength(1), reason: '首页无 cursor'); await tester.drag(find.byType(ListView), const Offset(0, -6000)); await tester.pumpAndSettle(); expect( uploadsRequests(adapter).last.uri.queryParameters['cursor'], 'hc1', ); expect(find.textContaining('page2ite'), findsOneWidget); }); testWidgets('加载更多失败 → 页脚重试行;重试恢复追加第二页', (tester) async { final page1 = List>.generate( 20, (i) => sessionJson( uploadId: 'upl-p1-$i', clientTaskId: 'page1row$i.padLeft-000000000000000000001', ), ); var failMore = true; final adapter = adapterOf((o) { final cursor = o.uri.queryParameters['cursor']; if (cursor == null) { return (200, cursorPageBody(page1, nextCursor: 'hc1')); } if (failMore) { failMore = false; throw DioException( requestOptions: RequestOptions(path: o.path), type: DioExceptionType.connectionError, ); } return ( 200, cursorPageBody( [sessionJson(clientTaskId: 'page2item000000000000000000000001')], ), ); }); await pumpPage(tester, clientOf(adapter)); await tester.drag(find.byType(ListView), const Offset(0, -6000)); await tester.pumpAndSettle(); // 首屏成功不整体报错:仅页脚错误说明 + 重试(页面 16)。 expect(find.textContaining('Network error'), findsOneWidget); expect(find.byIcon(Icons.cloud_off), findsOneWidget); expect(find.text('Retry'), findsOneWidget); await tester.tap(find.text('Retry')); await tester.pumpAndSettle(); expect(find.textContaining('Network error'), findsNothing); expect(find.textContaining('page2ite'), findsOneWidget); }); testWidgets('首屏加载失败展示错误与重试;恢复后重试成功', (tester) async { var failFirst = true; final adapter = adapterOf((o) { if (o.method == 'GET' && o.path == '/v1/uploads') { if (failFirst) { throw DioException( requestOptions: RequestOptions(path: o.path), type: DioExceptionType.connectionError, ); } return (200, cursorPageBody([sessionJson()])); } throw StateError('unexpected ${o.method} ${o.path}'); }); await pumpPage(tester, clientOf(adapter)); expect(find.textContaining('Network error'), findsOneWidget); expect(find.byIcon(Icons.cloud_off), findsOneWidget); failFirst = false; await tester.tap(find.text('Retry')); await tester.pumpAndSettle(); expect(find.textContaining('Network error'), findsNothing); expect(find.textContaining('clientta'), findsOneWidget, reason: '重试后行渲染'); }); }