feat(mobile): UI-01 theme system, shared components and navigation
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
/// 相册列表页签 widget 测试(P3 计划 Task 4)。
|
||||
/// 相册列表页签 widget 测试(P3 Task 4;UI-01 Task 3 起覆盖封面卡片样板)。
|
||||
///
|
||||
/// 覆盖:首屏加载与卡片标签行(状态/开关/时区时间/令牌短显)、游标
|
||||
/// 加载更多(第二页携带 cursor)、空态文案、首屏错误 + 重试恢复。
|
||||
/// 覆盖:封面卡片样板(封面占位/标题回退/时间场地行/公开与上传徽章/
|
||||
/// 令牌与四开关移除)、关闭状态徽章、封面加载失败替代图、游标加载更多
|
||||
/// (第二页携带 cursor)、空态文案、首屏错误 + 重试恢复。
|
||||
library;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
@@ -13,6 +14,7 @@ import 'package:photo_live/core/error_codes.dart';
|
||||
import 'package:photo_live/features/albums/providers.dart';
|
||||
import 'package:photo_live/l10n/app_localizations.dart';
|
||||
import 'package:photo_live/pages/albums_tab.dart';
|
||||
import 'package:photo_live/ui/components/photo_thumbnail.dart';
|
||||
|
||||
import '../helpers/fakes.dart';
|
||||
|
||||
@@ -24,10 +26,10 @@ void main() {
|
||||
});
|
||||
|
||||
ApiClient clientOf(FakeAdapter adapter) => ApiClient(
|
||||
sessionStore: store,
|
||||
dio: Dio(BaseOptions(baseUrl: 'http://test.local'))
|
||||
..httpClientAdapter = adapter,
|
||||
);
|
||||
sessionStore: store,
|
||||
dio: Dio(BaseOptions(baseUrl: 'http://test.local'))
|
||||
..httpClientAdapter = adapter,
|
||||
);
|
||||
|
||||
Future<void> pumpTab(WidgetTester tester, ApiClient client) async {
|
||||
await tester.pumpWidget(
|
||||
@@ -43,35 +45,62 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('首屏加载 → 卡片渲染标题/状态/开关标签/时区时间/令牌短显',
|
||||
(tester) async {
|
||||
testWidgets('封面卡片样板:封面占位 + 标题回退 + 时间场地 + 公开/上传徽章', (tester) async {
|
||||
final adapter = FakeAdapter(
|
||||
(o) => o.path == '/v1/albums'
|
||||
? (200, cursorPageBody([albumJson(
|
||||
status: 'offline',
|
||||
uploadEnabled: true,
|
||||
publicEnabled: false,
|
||||
downloadEnabled: true,
|
||||
originalDownloadEnabled: false,
|
||||
)]))
|
||||
? (200, cursorPageBody([albumJson()]))
|
||||
: throw StateError('unexpected ${o.path}'),
|
||||
);
|
||||
await pumpTab(tester, clientOf(adapter));
|
||||
|
||||
expect(find.text('开幕晚宴'), findsOneWidget);
|
||||
expect(find.text('Offline'), findsOneWidget);
|
||||
// 四个开关标签(色调区分开关,文案一致)。
|
||||
expect(find.text('Upload'), findsOneWidget);
|
||||
// 标题按界面语言回退(en → en 翻译),最多两行。
|
||||
expect(find.text('Opening Gala'), findsOneWidget);
|
||||
// 时间用相册时区(上海墙钟 14:30)+ 场地(en 未填场地回退 zh-CN)。
|
||||
expect(find.text('2026/9/18 14:30 ~ Not set · 大宴会厅'), findsOneWidget);
|
||||
// 封面未配置 → 固定 16:9 中性占位(非转圈)。
|
||||
expect(find.byType(PhotoThumbnail), findsOneWidget);
|
||||
expect(find.byIcon(Icons.image_outlined), findsOneWidget);
|
||||
// 公开/上传状态徽章(图标 + 文案,不只靠颜色)。
|
||||
expect(find.text('Public'), findsOneWidget);
|
||||
expect(find.text('Download'), findsOneWidget);
|
||||
expect(find.text('Original'), findsOneWidget);
|
||||
// Asia/Shanghai 墙钟 14:30;结束未定。
|
||||
expect(find.text('2026/9/18 14:30 ~ Not set'), findsOneWidget);
|
||||
// 令牌短显(前 10 字符 + 省略号)。
|
||||
expect(find.text('sharetoken…'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.info_outline), findsOneWidget);
|
||||
expect(find.text('Uploads on'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.check_circle), findsOneWidget);
|
||||
// publicToken 与四开关堆叠已从主卡片移除(完整配置在详情设置)。
|
||||
expect(find.text('sharetoken…'), findsNothing);
|
||||
expect(find.text('Download'), findsNothing);
|
||||
expect(find.text('Original'), findsNothing);
|
||||
expect(find.byIcon(Icons.add), findsOneWidget, reason: 'FAB 新建入口');
|
||||
});
|
||||
|
||||
testWidgets('关闭状态徽章与封面加载失败替代图', (tester) async {
|
||||
final adapter = FakeAdapter(
|
||||
(o) => o.path == '/v1/albums'
|
||||
? (
|
||||
200,
|
||||
cursorPageBody([
|
||||
albumJson(
|
||||
publicEnabled: false,
|
||||
uploadEnabled: false,
|
||||
cover: {
|
||||
'url': 'http://test.local/cover.jpg',
|
||||
'expiresAt': '2026-09-22T00:00:00Z',
|
||||
},
|
||||
),
|
||||
]),
|
||||
)
|
||||
: throw StateError('unexpected ${o.path}'),
|
||||
);
|
||||
await pumpTab(tester, clientOf(adapter));
|
||||
|
||||
// 公开关闭 / 上传关闭 → 中性徽章。
|
||||
expect(find.text('Not public'), findsOneWidget);
|
||||
expect(find.text('Uploads off'), findsOneWidget);
|
||||
// 封面 URL 加载失败(测试环境网络请求 400)→ 中性块 + 小图标,不显示破图。
|
||||
expect(find.byType(PhotoThumbnail), findsOneWidget);
|
||||
expect(find.byIcon(Icons.broken_image_outlined), findsOneWidget);
|
||||
expect(find.byIcon(Icons.image_outlined), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('滚动接近底部 → 游标加载第二页(携带 cursor)', (tester) async {
|
||||
final page1 = List<Map<String, dynamic>>.generate(
|
||||
20,
|
||||
@@ -81,15 +110,13 @@ void main() {
|
||||
10,
|
||||
(i) => albumJson(id: 'alb-${i + 21}'),
|
||||
);
|
||||
final adapter = FakeAdapter(
|
||||
(o) {
|
||||
if (o.path != '/v1/albums') throw StateError('unexpected ${o.path}');
|
||||
final cursor = o.uri.queryParameters['cursor'];
|
||||
if (cursor == null) return (200, cursorPageBody(page1, nextCursor: 'c1'));
|
||||
if (cursor == 'c1') return (200, cursorPageBody(page2));
|
||||
throw StateError('unexpected cursor $cursor');
|
||||
},
|
||||
);
|
||||
final adapter = FakeAdapter((o) {
|
||||
if (o.path != '/v1/albums') throw StateError('unexpected ${o.path}');
|
||||
final cursor = o.uri.queryParameters['cursor'];
|
||||
if (cursor == null) return (200, cursorPageBody(page1, nextCursor: 'c1'));
|
||||
if (cursor == 'c1') return (200, cursorPageBody(page2));
|
||||
throw StateError('unexpected cursor $cursor');
|
||||
});
|
||||
await pumpTab(tester, clientOf(adapter));
|
||||
|
||||
// 首屏只拉第一页(20 条入 state;ListView 懒构建,不逐条断言渲染)。
|
||||
@@ -99,8 +126,11 @@ void main() {
|
||||
);
|
||||
expect(container.read(albumListProvider).items, hasLength(20));
|
||||
|
||||
// 上滑触发加载更多。
|
||||
await tester.drag(find.byType(ListView), const Offset(0, -2400));
|
||||
// 上滑触发加载更多(封面卡片较高,分段滚动直至触发第二页请求)。
|
||||
for (var i = 0; i < 10 && adapter.requests.length < 2; i++) {
|
||||
await tester.drag(find.byType(ListView), const Offset(0, -1200));
|
||||
await tester.pump();
|
||||
}
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(adapter.requests, hasLength(2));
|
||||
@@ -132,16 +162,14 @@ void main() {
|
||||
|
||||
testWidgets('首屏错误 → 错误视图,重试后恢复', (tester) async {
|
||||
var failFirst = true;
|
||||
final adapter = FakeAdapter(
|
||||
(o) {
|
||||
if (o.path != '/v1/albums') throw StateError('unexpected ${o.path}');
|
||||
if (failFirst) {
|
||||
failFirst = false;
|
||||
return (500, errorBody(ErrorCodes.internalError));
|
||||
}
|
||||
return (200, cursorPageBody([albumJson()]));
|
||||
},
|
||||
);
|
||||
final adapter = FakeAdapter((o) {
|
||||
if (o.path != '/v1/albums') throw StateError('unexpected ${o.path}');
|
||||
if (failFirst) {
|
||||
failFirst = false;
|
||||
return (500, errorBody(ErrorCodes.internalError));
|
||||
}
|
||||
return (200, cursorPageBody([albumJson()]));
|
||||
});
|
||||
await pumpTab(tester, clientOf(adapter));
|
||||
|
||||
expect(
|
||||
@@ -152,6 +180,6 @@ void main() {
|
||||
await tester.tap(find.text('Retry'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('开幕晚宴'), findsOneWidget);
|
||||
expect(find.text('Opening Gala'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user