Files
photo-live/apps/mobile/test/pages/server_history_page_test.dart
T
2026-09-22 02:06:23 +08:00

434 lines
14 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 测试(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<String?> read() async => _value;
@override
Future<void> write(String value) async {}
}
/// 契约 `UploadSessionRecord` 响应体(GET /uploads 条目)。
Map<String, dynamic> 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,
}) =>
<String, dynamic>{
'uploadId': uploadId,
'albumId': albumId,
'clientTaskId': clientTaskId,
'state': state,
'expectedBytes': expectedBytes,
'contentType': 'image/jpeg',
'createdAt': createdAt,
'sourceDeviceId': sourceDeviceId,
'photo': <String, dynamic>{
'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<Map<String, dynamic>> albums = const <Map<String, dynamic>>[],
}) =>
FakeAdapter((o) {
if (o.method == 'GET' && o.path == '/v1/albums') {
return (200, cursorPageBody(albums));
}
return uploads(o);
});
Future<void> 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<RecordedRequest> 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: [
<String, dynamic>{
'locale': 'zh-CN',
'title': '婚礼当天',
'description': null,
'venue': null,
'address': null,
},
<String, dynamic>{
'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<Map<String, dynamic>>.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<Map<String, dynamic>>.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: '重试后行渲染');
});
}