feat(mobile): UI-01 theme system, shared components and navigation
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
/// UI-01 主题一致性测试(Task 3)。
|
||||
///
|
||||
/// 覆盖:浅色 ColorScheme 方案 §3.1 精确值、深色语义对应值、字阶
|
||||
/// (22/17/16-14/13-12)、组件主题(按钮高 48/圆角 12、输入圆角 12、
|
||||
/// Dialog 圆角 16、BottomSheet 顶部圆角 16、AppBar titleLarge)、动效
|
||||
/// 令牌(含「减少动态效果」归零)。
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:photo_live/app/theme.dart';
|
||||
import 'package:photo_live/ui/tokens.dart';
|
||||
|
||||
void main() {
|
||||
group('浅色 ColorScheme(方案 §3.1 精确值)', () {
|
||||
final scheme = AppTheme.light.colorScheme;
|
||||
|
||||
test('基础色', () {
|
||||
expect(scheme.primary, const Color(0xFF1A73E8));
|
||||
expect(scheme.onPrimary, const Color(0xFFFFFFFF));
|
||||
expect(scheme.surface, const Color(0xFFFFFFFF));
|
||||
expect(scheme.onSurface, const Color(0xFF172033));
|
||||
expect(scheme.secondary, const Color(0xFF996300), reason: '等待色');
|
||||
expect(scheme.error, const Color(0xFFB42318));
|
||||
// 成功色不进 ColorScheme(独立令牌)。
|
||||
expect(kColorSuccess, const Color(0xFF16794B));
|
||||
});
|
||||
|
||||
test('页面背景与分隔线/次文字', () {
|
||||
expect(AppTheme.light.scaffoldBackgroundColor, const Color(0xFFF6F8FB));
|
||||
expect(AppTheme.light.dividerTheme.color, kColorDivider);
|
||||
expect(kColorDivider, const Color(0xFFE5EAF1));
|
||||
expect(scheme.onSurfaceVariant, kColorTextSecondary);
|
||||
expect(kColorTextSecondary, const Color(0xFF5E6878));
|
||||
});
|
||||
});
|
||||
|
||||
group('深色 ColorScheme(语义对应,不反转图片)', () {
|
||||
final theme = AppTheme.dark;
|
||||
final scheme = theme.colorScheme;
|
||||
|
||||
test('深表面 + 浅文字 + 提亮主色', () {
|
||||
expect(scheme.brightness, Brightness.dark);
|
||||
expect(scheme.surface, const Color(0xFF1F2530));
|
||||
expect(scheme.onSurface, const Color(0xFFE6EAF2));
|
||||
expect(scheme.primary, kColorInfoDark);
|
||||
expect(scheme.onPrimary, const Color(0xFF0B2E6F));
|
||||
expect(theme.scaffoldBackgroundColor, const Color(0xFF12161D));
|
||||
});
|
||||
|
||||
test('等待/错误为深色提亮变体', () {
|
||||
expect(scheme.secondary, kColorWaitingDark);
|
||||
expect(scheme.error, kColorErrorDark);
|
||||
});
|
||||
});
|
||||
|
||||
group('字阶(随系统缩放:fontSize 走 TextScaler)', () {
|
||||
final textTheme = AppTheme.light.textTheme;
|
||||
|
||||
test('22 页面标题 / 17 分组 / 16-14 正文 / 13-12 辅助', () {
|
||||
expect(textTheme.titleLarge!.fontSize, 22);
|
||||
expect(textTheme.titleMedium!.fontSize, 17);
|
||||
expect(textTheme.bodyLarge!.fontSize, 16);
|
||||
expect(textTheme.bodyMedium!.fontSize, 14);
|
||||
expect(textTheme.bodySmall!.fontSize, 13);
|
||||
expect(textTheme.labelSmall!.fontSize, 12);
|
||||
});
|
||||
|
||||
test('AppBar 标题用 titleLarge', () {
|
||||
expect(AppTheme.light.appBarTheme.titleTextStyle!.fontSize, 22);
|
||||
});
|
||||
});
|
||||
|
||||
group('组件主题', () {
|
||||
final theme = AppTheme.light;
|
||||
|
||||
test('按钮高 48、圆角 12', () {
|
||||
final filled = theme.filledButtonTheme.style;
|
||||
expect(
|
||||
filled!.minimumSize!.resolve(const <WidgetState>{}),
|
||||
const Size(64, 48),
|
||||
);
|
||||
expect(
|
||||
theme.elevatedButtonTheme.style!.minimumSize!.resolve(
|
||||
const <WidgetState>{},
|
||||
),
|
||||
const Size(64, 48),
|
||||
);
|
||||
final shape =
|
||||
filled.shape!.resolve(const <WidgetState>{})
|
||||
as RoundedRectangleBorder;
|
||||
expect(shape.borderRadius, BorderRadius.circular(kRadiusInput));
|
||||
expect(
|
||||
theme.outlinedButtonTheme.style!.minimumSize!.resolve(
|
||||
const <WidgetState>{},
|
||||
),
|
||||
const Size(48, 48),
|
||||
);
|
||||
expect(
|
||||
theme.textButtonTheme.style!.minimumSize!.resolve(
|
||||
const <WidgetState>{},
|
||||
),
|
||||
const Size(48, 48),
|
||||
);
|
||||
});
|
||||
|
||||
test('输入框圆角 12', () {
|
||||
final border = theme.inputDecorationTheme.border as OutlineInputBorder;
|
||||
expect(border.borderRadius, BorderRadius.circular(kRadiusInput));
|
||||
expect(kRadiusInput, 12);
|
||||
});
|
||||
|
||||
test('Dialog 圆角 16、BottomSheet 顶部圆角 16', () {
|
||||
final dialog = theme.dialogTheme.shape as RoundedRectangleBorder;
|
||||
expect(dialog.borderRadius, BorderRadius.circular(kRadiusCard));
|
||||
final sheet = theme.bottomSheetTheme.shape as RoundedRectangleBorder;
|
||||
expect(
|
||||
(sheet.borderRadius as BorderRadius).topLeft,
|
||||
const Radius.circular(kRadiusCard),
|
||||
);
|
||||
expect(
|
||||
(sheet.borderRadius as BorderRadius).topRight,
|
||||
const Radius.circular(kRadiusCard),
|
||||
);
|
||||
expect(kRadiusCard, 16);
|
||||
});
|
||||
|
||||
test('底部导航指示器色已配置', () {
|
||||
expect(
|
||||
theme.navigationBarTheme.indicatorColor,
|
||||
theme.colorScheme.secondaryContainer,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('动效令牌', () {
|
||||
testWidgets('默认轻量 180ms / 轮播 300ms / 减少动态效果归零', (tester) async {
|
||||
expect(kMotionFast, const Duration(milliseconds: 180));
|
||||
expect(kMotionCarousel, const Duration(milliseconds: 300));
|
||||
expect(kMotionReduce, Duration.zero);
|
||||
|
||||
late BuildContext captured;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Builder(
|
||||
builder: (context) {
|
||||
captured = context;
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(motion(captured), kMotionFast);
|
||||
expect(motion(captured, carousel: true), kMotionCarousel);
|
||||
|
||||
// 「减少动态效果」开启 → 一律归零。
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: MediaQuery(
|
||||
data: const MediaQueryData(disableAnimations: true),
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
captured = context;
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(motion(captured), kMotionReduce);
|
||||
expect(motion(captured, carousel: true), kMotionReduce);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -145,17 +145,14 @@ class FakeAdapter implements HttpClientAdapter {
|
||||
Map<String, dynamic> loginOkBody({
|
||||
String token = 'tok-new',
|
||||
String expiresAt = '2026-09-20T12:00:00Z',
|
||||
}) => <String, dynamic>{
|
||||
'sessionToken': token,
|
||||
'expiresAt': expiresAt,
|
||||
};
|
||||
}) => <String, dynamic>{'sessionToken': token, 'expiresAt': expiresAt};
|
||||
|
||||
/// 常用响应体:契约 Error 形状。
|
||||
Map<String, dynamic> errorBody(String code) => <String, dynamic>{
|
||||
'code': code,
|
||||
'message': 'stub message',
|
||||
'requestId': 'req-stub',
|
||||
};
|
||||
'code': code,
|
||||
'message': 'stub message',
|
||||
'requestId': 'req-stub',
|
||||
};
|
||||
|
||||
// ── 相册域 fixtures(P3 计划 Task 4;形状对齐 openapi.yaml schemas)──
|
||||
|
||||
@@ -175,44 +172,45 @@ Map<String, dynamic> albumJson({
|
||||
bool originalDownloadEnabled = false,
|
||||
List<Map<String, dynamic>>? translations,
|
||||
List<Map<String, dynamic>> categories = const <Map<String, dynamic>>[],
|
||||
}) =>
|
||||
<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': null,
|
||||
};
|
||||
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({
|
||||
@@ -220,17 +218,17 @@ Map<String, dynamic> categoryJson({
|
||||
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'},
|
||||
],
|
||||
};
|
||||
}) => <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({
|
||||
@@ -238,26 +236,24 @@ Map<String, dynamic> memberJson({
|
||||
String account = 'staff02',
|
||||
String displayName = 'Staff Two',
|
||||
String role = 'collaborator',
|
||||
}) =>
|
||||
<String, dynamic>{
|
||||
'userId': userId,
|
||||
'account': account,
|
||||
'displayName': displayName,
|
||||
'role': role,
|
||||
'status': 'active',
|
||||
};
|
||||
}) => <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,
|
||||
};
|
||||
}) => <String, dynamic>{
|
||||
'id': id,
|
||||
'account': account,
|
||||
'displayName': displayName,
|
||||
};
|
||||
|
||||
/// 契约 `AdminPhoto` 响应体。
|
||||
Map<String, dynamic> adminPhotoJson({
|
||||
@@ -266,36 +262,34 @@ Map<String, dynamic> adminPhotoJson({
|
||||
String visibility = 'published',
|
||||
String processingState = 'ready',
|
||||
int bytes = 123456,
|
||||
}) =>
|
||||
<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': 'staff02',
|
||||
'displayName': 'Staff Two',
|
||||
},
|
||||
'preview': null,
|
||||
};
|
||||
}) => <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': 'staff02',
|
||||
'displayName': 'Staff Two',
|
||||
},
|
||||
'preview': null,
|
||||
};
|
||||
|
||||
/// 契约游标分页响应体(hasMore 与 nextCursor 成对)。
|
||||
Map<String, dynamic> cursorPageBody(
|
||||
List<Map<String, dynamic>> items, {
|
||||
String? nextCursor,
|
||||
}) =>
|
||||
<String, dynamic>{
|
||||
'items': items,
|
||||
'nextCursor': nextCursor,
|
||||
'hasMore': nextCursor != null,
|
||||
};
|
||||
}) => <String, dynamic>{
|
||||
'items': items,
|
||||
'nextCursor': nextCursor,
|
||||
'hasMore': nextCursor != null,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
/// 底部导航与「上传」角标测试(UI-01 Task 3,§4.1)。
|
||||
///
|
||||
/// 覆盖:三页签改名(Albums/Uploads/Settings)与 IndexedStack 状态保持、
|
||||
/// 角标 = 当前账号未结束任务数(countsByState 聚合,写通知防抖刷新)、
|
||||
/// 他账号任务不计入(账号隔离)、任务全部结束后角标隐藏、
|
||||
/// [unfinishedCountOf] 纯函数口径(含 retryableFailed)。
|
||||
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/app/theme.dart';
|
||||
import 'package:photo_live/core/api_client.dart';
|
||||
import 'package:photo_live/core/session_store.dart';
|
||||
import 'package:photo_live/features/uploads/queue_controller.dart';
|
||||
import 'package:photo_live/features/uploads/task_state.dart';
|
||||
import 'package:photo_live/features/uploads/task_store.dart';
|
||||
import 'package:photo_live/l10n/app_localizations.dart';
|
||||
import 'package:photo_live/pages/albums_tab.dart';
|
||||
import 'package:photo_live/pages/home_page.dart';
|
||||
import 'package:photo_live/pages/queue_tab.dart';
|
||||
|
||||
import '../helpers/fakes.dart';
|
||||
|
||||
UploadTaskDraft _draft(String id, {String account = 'staff01'}) =>
|
||||
UploadTaskDraft(
|
||||
id: id,
|
||||
accountId: account,
|
||||
albumId: 'alb-1',
|
||||
clientTaskId: 'ct-$id',
|
||||
sourceFilePath: '/tmp/$id.jpg',
|
||||
filename: '$id.jpg',
|
||||
bytes: 10,
|
||||
contentType: 'image/jpeg',
|
||||
);
|
||||
|
||||
/// 沿合法状态机单步迁移(自迁移非法,须按序调用)。
|
||||
Future<void> _transition(TaskStore store, String id, UploadTaskState to) =>
|
||||
store.updateState(id, to);
|
||||
|
||||
void main() {
|
||||
late FakeSessionStore session;
|
||||
late TaskStore taskStore;
|
||||
|
||||
setUp(() {
|
||||
session = FakeSessionStore()
|
||||
..session = Session(
|
||||
token: 'tok-live-1',
|
||||
expiresAt: DateTime.utc(2099, 1, 1),
|
||||
account: 'staff01',
|
||||
);
|
||||
taskStore = TaskStore.memory();
|
||||
});
|
||||
|
||||
Future<void> pumpHome(WidgetTester tester) async {
|
||||
// 相册列表页签进入即拉列表;空页兜底即可。
|
||||
final adapter = FakeAdapter(
|
||||
(o) => o.path == '/v1/albums'
|
||||
? (200, cursorPageBody(<Map<String, dynamic>>[]))
|
||||
: throw StateError('unexpected ${o.path}'),
|
||||
);
|
||||
final client = ApiClient(
|
||||
sessionStore: session,
|
||||
dio: Dio(BaseOptions(baseUrl: 'http://test.local'))
|
||||
..httpClientAdapter = adapter,
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
...pageTestOverrides(
|
||||
session: session,
|
||||
taskStore: taskStore,
|
||||
client: client,
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.light,
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const HomePage(),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// 任务库写通知 → 200ms 防抖重查(kQueueRefreshDebounce)。
|
||||
///
|
||||
/// 不用 pumpAndSettle:uploading 任务的行级进度条为不确定态(常驻动画)。
|
||||
Future<void> pumpCounts(WidgetTester tester) async {
|
||||
await tester.pump(kQueueRefreshDebounce * 2);
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
}
|
||||
|
||||
testWidgets('三页签改名 + IndexedStack 保持页面状态', (tester) async {
|
||||
await pumpHome(tester);
|
||||
|
||||
// IndexedStack 常驻三页签(非当前页签 offstage,find 默认跳过,故显式
|
||||
// 关闭 skipOffstage)。Albums/Settings 各两次(页签 AppBar 标题 + 导航
|
||||
// 标签);Uploads 仅导航标签(队列页标题为 Upload queue)。
|
||||
expect(find.text('Albums', skipOffstage: false), findsNWidgets(2));
|
||||
expect(find.text('Uploads', skipOffstage: false), findsOneWidget);
|
||||
expect(find.text('Settings', skipOffstage: false), findsNWidgets(2));
|
||||
expect(
|
||||
find.text('Queue', skipOffstage: false),
|
||||
findsNothing,
|
||||
reason: '旧页签名已移除',
|
||||
);
|
||||
|
||||
expect(find.byType(IndexedStack), findsOneWidget);
|
||||
expect(find.byType(AlbumsTab, skipOffstage: false), findsOneWidget);
|
||||
expect(find.byType(QueueTab, skipOffstage: false), findsOneWidget);
|
||||
expect(find.byType(Badge), findsNothing, reason: '初始无任务无角标');
|
||||
});
|
||||
|
||||
testWidgets('角标 = 当前账号未完成任务数;他账号任务不计入', (tester) async {
|
||||
await pumpHome(tester);
|
||||
|
||||
// 其他账号任务:账号隔离,不显示角标。
|
||||
final other = await taskStore.create(_draft('t-o1', account: 'staff02'));
|
||||
expect(other.accountId, 'staff02');
|
||||
await pumpCounts(tester);
|
||||
expect(find.byType(Badge), findsNothing, reason: '他账号任务不计入');
|
||||
|
||||
// 本账号 3 个未完成(2 queued + 1 uploading)。
|
||||
final t1 = await taskStore.create(_draft('t-1'));
|
||||
final t2 = await taskStore.create(_draft('t-2'));
|
||||
final t3 = await taskStore.create(_draft('t-3'));
|
||||
await _transition(taskStore, t1.id, UploadTaskState.queued);
|
||||
await _transition(taskStore, t2.id, UploadTaskState.queued);
|
||||
await _transition(taskStore, t3.id, UploadTaskState.queued);
|
||||
await _transition(taskStore, t3.id, UploadTaskState.uploading);
|
||||
await pumpCounts(tester);
|
||||
|
||||
expect(find.byType(Badge), findsOneWidget);
|
||||
expect(find.text('3'), findsOneWidget, reason: '角标计数 3');
|
||||
});
|
||||
|
||||
testWidgets('任务全部结束 → 角标隐藏', (tester) async {
|
||||
await pumpHome(tester);
|
||||
|
||||
final tasks = <String>[
|
||||
for (var i = 1; i <= 2; i++) (await taskStore.create(_draft('t-$i'))).id,
|
||||
];
|
||||
for (final id in tasks) {
|
||||
await _transition(taskStore, id, UploadTaskState.queued);
|
||||
}
|
||||
await pumpCounts(tester);
|
||||
expect(find.text('2'), findsOneWidget);
|
||||
|
||||
for (final id in tasks) {
|
||||
await _transition(taskStore, id, UploadTaskState.uploading);
|
||||
await _transition(taskStore, id, UploadTaskState.uploadedConfirming);
|
||||
await _transition(taskStore, id, UploadTaskState.completed);
|
||||
}
|
||||
await pumpCounts(tester);
|
||||
|
||||
expect(find.byType(Badge), findsNothing, reason: '0 隐藏角标');
|
||||
});
|
||||
|
||||
test('unfinishedCountOf 口径:待传 + 等待网络 + 可重试失败', () {
|
||||
Map<UploadTaskState, int> counts(Map<UploadTaskState, int> patch) =>
|
||||
<UploadTaskState, int>{
|
||||
for (final state in UploadTaskState.values) state: patch[state] ?? 0,
|
||||
};
|
||||
// 待传口径(pendingCountOf)之外,可重试失败计入;准备失败/取消/完成不计。
|
||||
expect(
|
||||
unfinishedCountOf(
|
||||
counts({UploadTaskState.queued: 2, UploadTaskState.uploading: 1}),
|
||||
),
|
||||
3,
|
||||
);
|
||||
expect(
|
||||
unfinishedCountOf(
|
||||
counts({
|
||||
UploadTaskState.retryableFailed: 1,
|
||||
UploadTaskState.waitingNetwork: 2,
|
||||
}),
|
||||
),
|
||||
3,
|
||||
);
|
||||
expect(
|
||||
unfinishedCountOf(
|
||||
counts({
|
||||
UploadTaskState.completed: 5,
|
||||
UploadTaskState.cancelled: 1,
|
||||
UploadTaskState.preparationFailed: 1,
|
||||
}),
|
||||
),
|
||||
0,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/// EmptyState widget 测试(UI-01 Task 3)。
|
||||
///
|
||||
/// 覆盖:图标 + 标题 + 说明 + 可选操作按钮全量渲染;操作回调触发;
|
||||
/// 无说明/无按钮时不占位。
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:photo_live/app/theme.dart';
|
||||
import 'package:photo_live/ui/components/empty_state.dart';
|
||||
|
||||
void main() {
|
||||
Future<void> pumpState(
|
||||
WidgetTester tester, {
|
||||
String? subtitle,
|
||||
String? actionLabel,
|
||||
VoidCallback? onAction,
|
||||
}) {
|
||||
return tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light,
|
||||
home: Scaffold(
|
||||
body: EmptyState(
|
||||
icon: Icons.photo_library_outlined,
|
||||
title: 'No albums yet',
|
||||
subtitle: subtitle,
|
||||
actionLabel: actionLabel,
|
||||
onAction: onAction,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('渲染图标 + 标题 + 说明 + 操作按钮', (tester) async {
|
||||
var taps = 0;
|
||||
await pumpState(
|
||||
tester,
|
||||
subtitle: 'Create one to get started',
|
||||
actionLabel: 'Create album',
|
||||
onAction: () => taps++,
|
||||
);
|
||||
|
||||
expect(find.byIcon(Icons.photo_library_outlined), findsOneWidget);
|
||||
expect(find.text('No albums yet'), findsOneWidget);
|
||||
expect(find.text('Create one to get started'), findsOneWidget);
|
||||
expect(find.text('Create album'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Create album'));
|
||||
expect(taps, 1, reason: '操作回调触发');
|
||||
});
|
||||
|
||||
testWidgets('无说明/无按钮 → 仅图标与标题', (tester) async {
|
||||
await pumpState(tester);
|
||||
|
||||
expect(find.byIcon(Icons.photo_library_outlined), findsOneWidget);
|
||||
expect(find.text('No albums yet'), findsOneWidget);
|
||||
expect(find.byType(FilledButton), findsNothing);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/// ErrorState widget 测试(UI-01 Task 3)。
|
||||
///
|
||||
/// 覆盖:错误图标 + 说明 + 重试按钮(主强调,与 EmptyState 区分);
|
||||
/// 重试回调触发。
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:photo_live/app/theme.dart';
|
||||
import 'package:photo_live/l10n/app_localizations.dart';
|
||||
import 'package:photo_live/ui/components/error_state.dart';
|
||||
import 'package:photo_live/ui/tokens.dart';
|
||||
|
||||
void main() {
|
||||
Future<void> pumpState(WidgetTester tester, VoidCallback onRetry) {
|
||||
return tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light,
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: ErrorState(
|
||||
message: 'Network error. Please retry',
|
||||
onRetry: onRetry,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('渲染错误图标 + 说明 + 重试按钮,回调触发', (tester) async {
|
||||
var retries = 0;
|
||||
await pumpState(tester, () => retries++);
|
||||
|
||||
expect(find.byIcon(Icons.cloud_off), findsOneWidget, reason: '错误语义图标');
|
||||
expect(find.text('Network error. Please retry'), findsOneWidget);
|
||||
final retryButton = find.widgetWithText(FilledButton, 'Retry');
|
||||
expect(retryButton, findsOneWidget, reason: '重试为主强调按钮');
|
||||
expect(find.byIcon(Icons.refresh), findsOneWidget);
|
||||
|
||||
await tester.tap(retryButton);
|
||||
expect(retries, 1, reason: '重试回调触发');
|
||||
});
|
||||
|
||||
testWidgets('深色主题错误图标用提亮变体', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.dark,
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const Scaffold(
|
||||
body: ErrorState(message: 'boom', onRetry: _noop),
|
||||
),
|
||||
),
|
||||
);
|
||||
final icon = tester.widget<Icon>(find.byIcon(Icons.cloud_off));
|
||||
expect(icon.color, kColorErrorDark);
|
||||
});
|
||||
}
|
||||
|
||||
void _noop() {}
|
||||
@@ -0,0 +1,196 @@
|
||||
/// PhotoThumbnail widget 测试(UI-01 Task 3)。
|
||||
///
|
||||
/// 覆盖:无 URL 常驻中性占位(固定宽高比)、加载中立即显示占位(非
|
||||
/// 转圈,网格布局稳定)、失败替代图(中性块 + 小图标,不显示破图)、
|
||||
/// 加载成功显示图片(HttpOverrides 注入 1x1 PNG)。
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:photo_live/app/theme.dart';
|
||||
import 'package:photo_live/ui/components/photo_thumbnail.dart';
|
||||
|
||||
/// 1x1 透明 PNG(真实可解码字节)。
|
||||
final Uint8List kPngBytes = base64Decode(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ'
|
||||
'AAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
|
||||
);
|
||||
|
||||
void main() {
|
||||
Future<void> pumpThumb(
|
||||
WidgetTester tester, {
|
||||
String? url,
|
||||
double aspectRatio = 16 / 9,
|
||||
}) {
|
||||
return tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light,
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: PhotoThumbnail(url: url, aspectRatio: aspectRatio),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('url 为 null → 常驻中性占位(固定宽高比)', (tester) async {
|
||||
await pumpThumb(tester);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byIcon(Icons.image_outlined), findsOneWidget);
|
||||
expect(find.byIcon(Icons.broken_image_outlined), findsNothing);
|
||||
|
||||
// 固定宽高比:视口 800x600 → 800 x 450。
|
||||
final size = tester.getSize(find.byType(PhotoThumbnail));
|
||||
expect(size.width, 800);
|
||||
expect(size.height, 450);
|
||||
});
|
||||
|
||||
testWidgets('加载中 → 立即占位;失败 → 中性块 + 小图标(不显示破图)', (tester) async {
|
||||
// 测试环境默认 HttpClient 一律 400 → NetworkImage 失败路径。
|
||||
await pumpThumb(tester, url: 'http://test.local/cover.jpg');
|
||||
|
||||
// 首帧立即显示占位(非转圈;布局稳定)。
|
||||
expect(find.byIcon(Icons.image_outlined), findsOneWidget);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byIcon(Icons.broken_image_outlined), findsOneWidget);
|
||||
expect(find.byIcon(Icons.image_outlined), findsNothing);
|
||||
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('加载成功 → 图片显示、占位消失', (tester) async {
|
||||
// debug 钩子注入假 HttpClient:以真实 1x1 PNG 字节应答全部 GET
|
||||
// (覆盖 flutter_test 默认的网络 400 mock)。测试体内复位——painting
|
||||
// 调试变量须在框架不变量校验前归位(addTearDown 时机过晚)。
|
||||
debugNetworkImageHttpClientProvider = _FakeHttpClient.new;
|
||||
try {
|
||||
await pumpThumb(tester, url: 'http://test.local/cover.png');
|
||||
|
||||
// 立即占位(帧未 ready)。
|
||||
expect(find.byIcon(Icons.image_outlined), findsOneWidget);
|
||||
|
||||
// 真实网络字节 + dart:ui 解码在真实事件循环中完成(fake-async 需要
|
||||
// runAsync 放行)。
|
||||
await tester.runAsync(
|
||||
() => Future<void>.delayed(const Duration(milliseconds: 200)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byIcon(Icons.image_outlined),
|
||||
findsNothing,
|
||||
reason: '加载完成后占位消失',
|
||||
);
|
||||
expect(find.byIcon(Icons.broken_image_outlined), findsNothing);
|
||||
final image = tester.widget<Image>(find.byType(Image));
|
||||
expect(image.image, isA<NetworkImage>());
|
||||
} finally {
|
||||
debugNetworkImageHttpClientProvider = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 以真实 1x1 PNG 字节应答全部 GET 的 HttpClient 替身。
|
||||
class _FakeHttpClient implements HttpClient {
|
||||
@override
|
||||
bool autoUncompress = true;
|
||||
|
||||
@override
|
||||
Future<HttpClientRequest> getUrl(Uri url) async => _FakeHttpClientRequest();
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) =>
|
||||
throw UnimplementedError('${invocation.memberName}');
|
||||
}
|
||||
|
||||
class _FakeHttpClientRequest implements HttpClientRequest {
|
||||
final _FakeHttpHeaders _headers = _FakeHttpHeaders();
|
||||
|
||||
@override
|
||||
HttpHeaders get headers => _headers;
|
||||
|
||||
@override
|
||||
String get method => 'GET';
|
||||
|
||||
@override
|
||||
Future<HttpClientResponse> close() async => _FakeHttpClientResponse();
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) =>
|
||||
throw UnimplementedError('${invocation.memberName}');
|
||||
}
|
||||
|
||||
class _FakeHttpClientResponse extends Stream<List<int>>
|
||||
implements HttpClientResponse {
|
||||
late final Stream<List<int>> _stream = Stream<List<int>>.value(kPngBytes);
|
||||
|
||||
@override
|
||||
StreamSubscription<List<int>> listen(
|
||||
void Function(List<int> event)? onData, {
|
||||
Function? onError,
|
||||
void Function()? onDone,
|
||||
bool? cancelOnError,
|
||||
}) => _stream.listen(
|
||||
onData,
|
||||
onError: onError,
|
||||
onDone: onDone,
|
||||
cancelOnError: cancelOnError,
|
||||
);
|
||||
|
||||
@override
|
||||
int get statusCode => HttpStatus.ok;
|
||||
|
||||
@override
|
||||
int get contentLength => kPngBytes.length;
|
||||
|
||||
@override
|
||||
bool get isRedirect => false;
|
||||
|
||||
@override
|
||||
bool get persistentConnection => true;
|
||||
|
||||
@override
|
||||
String get reasonPhrase => 'OK';
|
||||
|
||||
@override
|
||||
HttpHeaders get headers => _FakeHttpHeaders();
|
||||
|
||||
@override
|
||||
HttpClientResponseCompressionState get compressionState =>
|
||||
HttpClientResponseCompressionState.notCompressed;
|
||||
|
||||
@override
|
||||
List<Cookie> get cookies => <Cookie>[];
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) =>
|
||||
throw UnimplementedError('${invocation.memberName}');
|
||||
}
|
||||
|
||||
class _FakeHttpHeaders implements HttpHeaders {
|
||||
final Map<String, String> _values = <String, String>{};
|
||||
|
||||
@override
|
||||
String? value(String field) => _values[field.toLowerCase()];
|
||||
|
||||
@override
|
||||
void add(String name, Object value, {bool preserveHeaderCase = false}) {
|
||||
_values[name.toLowerCase()] = value.toString();
|
||||
}
|
||||
|
||||
@override
|
||||
void set(String name, Object value, {bool preserveHeaderCase = false}) {
|
||||
_values[name.toLowerCase()] = value.toString();
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) =>
|
||||
throw UnimplementedError('${invocation.memberName}');
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/// SectionHeader widget 测试(UI-01 Task 3)。
|
||||
///
|
||||
/// 覆盖:分组标题渲染(titleMedium 字阶)、可选行尾动作渲染/缺省。
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:photo_live/app/theme.dart';
|
||||
import 'package:photo_live/ui/components/section_header.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('渲染标题与可选行尾动作', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light,
|
||||
home: const Scaffold(
|
||||
body: SectionHeader(title: '活动信息', trailing: Text('查看全部')),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final title = tester.widget<Text>(find.text('活动信息'));
|
||||
expect(title.style!.fontSize, 17, reason: '分组标题字阶 17(titleMedium)');
|
||||
expect(find.text('查看全部'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('无行尾动作 → 仅标题', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Scaffold(body: SectionHeader(title: '活动信息')),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('活动信息'), findsOneWidget);
|
||||
expect(find.byType(TextButton), findsNothing);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/// StateBadge widget 测试(UI-01 Task 3)。
|
||||
///
|
||||
/// 覆盖:五个语义变体均渲染图标 + 文案 + 语义色(不只靠颜色)、
|
||||
/// compact/normal 两档高度、深色主题取提亮变体。
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:photo_live/app/theme.dart';
|
||||
import 'package:photo_live/ui/components/state_badge.dart';
|
||||
import 'package:photo_live/ui/tokens.dart';
|
||||
|
||||
void main() {
|
||||
Future<void> pumpBadge(
|
||||
WidgetTester tester, {
|
||||
required BadgeVariant variant,
|
||||
bool compact = false,
|
||||
bool dark = false,
|
||||
}) {
|
||||
return tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: dark ? AppTheme.dark : AppTheme.light,
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: StateBadge(
|
||||
variant: variant,
|
||||
label: 'badge-label',
|
||||
compact: compact,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Finder containerOf() => find
|
||||
.descendant(of: find.byType(StateBadge), matching: find.byType(Container))
|
||||
.first;
|
||||
|
||||
for (final entry in <(BadgeVariant, IconData, Color)>[
|
||||
(BadgeVariant.success, Icons.check_circle, kColorSuccess),
|
||||
(BadgeVariant.warning, Icons.hourglass_top, kColorWaiting),
|
||||
(BadgeVariant.error, Icons.error_outline, kColorError),
|
||||
(BadgeVariant.neutral, Icons.remove_circle_outline, kColorTextSecondary),
|
||||
(BadgeVariant.info, Icons.info_outline, kColorInfo),
|
||||
]) {
|
||||
final (variant, icon, color) = entry;
|
||||
testWidgets('${variant.name}:图标 + 文案 + 语义色', (tester) async {
|
||||
await pumpBadge(tester, variant: variant);
|
||||
|
||||
expect(find.byIcon(icon), findsOneWidget, reason: '内置语义图标');
|
||||
expect(find.text('badge-label'), findsOneWidget, reason: '状态文案');
|
||||
expect(find.byType(StateBadge), findsOneWidget);
|
||||
|
||||
final container = tester.widget<Container>(containerOf());
|
||||
final decoration = container.decoration as BoxDecoration;
|
||||
expect(
|
||||
decoration.color,
|
||||
color.withValues(alpha: 0.12),
|
||||
reason: '浅色底:语义色 12% 透明度',
|
||||
);
|
||||
|
||||
final text = tester.widget<Text>(find.text('badge-label'));
|
||||
expect(text.style!.color, color, reason: '文字用语义色');
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('compact 高 24 / normal 高 32', (tester) async {
|
||||
await pumpBadge(tester, variant: BadgeVariant.success, compact: true);
|
||||
expect(tester.getSize(find.byType(StateBadge)).height, 24);
|
||||
|
||||
await pumpBadge(tester, variant: BadgeVariant.success);
|
||||
expect(tester.getSize(find.byType(StateBadge)).height, 32);
|
||||
});
|
||||
|
||||
testWidgets('深色主题取提亮变体(success 例)', (tester) async {
|
||||
await pumpBadge(tester, variant: BadgeVariant.success, dark: true);
|
||||
|
||||
expect(find.byIcon(Icons.check_circle), findsOneWidget);
|
||||
final text = tester.widget<Text>(find.text('badge-label'));
|
||||
expect(text.style!.color, kColorSuccessDark);
|
||||
expect(kColorSuccessDark, isNot(kColorSuccess), reason: '深浅变体不同值');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user