Files
Navius/frontend/test/features/home/presentation/home_page_test.dart
T
CNWei 039ea70edf feat: 实现导航页基础功能
- 添加导航条目和分类数据模型
- 实现导航卡片网格展示
- 支持点击卡片打开链接
- 添加主题样式(渐变色、卡片阴影)
- 区分系统默认和自定义条目
- 添加分类统计信息"
2026-09-19 23:17:36 +08:00

451 lines
15 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.
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:frontend/core/constants/app_sizes.dart';
import 'package:frontend/core/theme/app_colors.dart';
import 'package:frontend/features/home/data/home_repository.dart';
import 'package:frontend/features/home/data/mock_home_data.dart';
import 'package:frontend/features/home/data/mock_home_repository.dart';
import 'package:frontend/features/home/domain/models/home_data.dart';
import 'package:frontend/features/home/domain/models/submit_site_draft.dart';
import 'package:frontend/features/home/presentation/pages/home_page.dart';
import 'package:frontend/features/home/presentation/widgets/link_card.dart';
import 'package:frontend/features/home/presentation/widgets/link_section_view.dart';
import 'package:frontend/features/home/presentation/widgets/sidebar_nav.dart';
import '../../../helpers/fake_url_opener.dart';
void main() {
late FakeUrlOpener urlOpener;
setUp(() {
urlOpener = FakeUrlOpener();
});
Future<void> pumpPage(
WidgetTester tester, {
// 高度取 420:原型只有两段内容,视口再高就滚不动了,
// 「回到顶部」这类用例也就没有意义。
Size size = const Size(900, 420),
HomeRepository repository = const MockHomeRepository(),
/// 加载中的用例要停在第一帧,不能等它 settle。
bool settle = true,
}) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
home: HomePage(
repository: repository,
urlOpener: urlOpener,
),
),
);
// 数据现在来自 Future,等它落地后再断言。
if (settle) {
await tester.pumpAndSettle();
} else {
await tester.pump();
}
}
/// 侧栏里的导航文字(避免和内容区的分区标题同名而混淆)。
Finder navText(String label) {
return find.descendant(
of: find.byType(SidebarNav),
matching: find.text(label),
);
}
/// 内容区的分区标题。
Finder sectionTitle(String label) {
return find.descendant(
of: find.byType(LinkSectionView),
matching: find.text(label),
);
}
double sidebarWidth(WidgetTester tester) {
return tester.getSize(find.byType(SidebarNav)).width;
}
Color? navColor(WidgetTester tester, String label) {
return tester.widget<Text>(navText(label)).style?.color;
}
/// 一级条目当前的底色(透明表示没有高亮)。
List<String> highlightedRows(WidgetTester tester) {
final List<String> rows = <String>[];
for (final String label in <String>['常用推荐', '素材资源', '开发工具']) {
final AnimatedContainer container = tester.widget<AnimatedContainer>(
find
.ancestor(
of: navText(label),
matching: find.byType(AnimatedContainer),
)
.first,
);
final Color? color = (container.decoration! as BoxDecoration).color;
if (color != null && color.toARGB32() != 0) {
rows.add(label);
}
}
return rows;
}
/// 主内容区的滚动控制器。
///
/// 侧栏的导航容器也是一个 SingleChildScrollView,所以这里用
/// 「搜索框的祖先」来精确定位主滚动视图,而不是靠 `.first` / `.last`。
ScrollController mainController(WidgetTester tester) {
final Finder mainScroll = find.ancestor(
of: find.byType(TextField),
matching: find.byType(SingleChildScrollView),
);
return tester.widget<SingleChildScrollView>(mainScroll).controller!;
}
double scrollOffset(WidgetTester tester) => mainController(tester).offset;
Future<void> expandMaterials(WidgetTester tester) async {
await tester.tap(navText('素材资源'));
await tester.pumpAndSettle();
}
/// 折叠态下导航项是空行(没有文字也没有图标),只能按位置定位:
/// 0 是侧栏本身,1~3 是三条一级导航。
Finder collapsedRow(int index) {
return find
.descendant(
of: find.byType(SidebarNav),
matching: find.byType(AnimatedContainer),
)
.at(index);
}
group('布局', () {
testWidgets('侧栏展开常驻,渲染 logo、三条导航与底部提交入口', (WidgetTester tester) async {
await pumpPage(tester);
expect(find.byType(SidebarNav), findsOneWidget);
expect(sidebarWidth(tester), AppSizes.sidebarWidth);
expect(find.text('Suxing Nav'), findsOneWidget);
expect(navText('常用推荐'), findsOneWidget);
expect(navText('素材资源'), findsOneWidget);
expect(navText('开发工具'), findsOneWidget);
expect(navText('提交网站'), findsOneWidget);
expect(find.byType(AppBar), findsNothing);
});
testWidgets('子菜单默认收起', (WidgetTester tester) async {
await pumpPage(tester);
expect(navText('UI 设计'), findsNothing);
expect(navText('图标库'), findsNothing);
expect(navText('前端框架'), findsNothing);
});
testWidgets('两个分区与五张卡片,与原型一致', (WidgetTester tester) async {
await pumpPage(tester);
expect(find.byType(LinkSectionView), findsNWidgets(2));
expect(find.byType(LinkCard), findsNWidgets(5));
expect(sectionTitle('常用推荐'), findsOneWidget);
expect(sectionTitle('素材资源 - UI 设计'), findsOneWidget);
});
});
group('侧栏折叠(对应 #toggleBtn)', () {
testWidgets('点折叠按钮后宽度变为 64,logo 变成首字', (WidgetTester tester) async {
await pumpPage(tester);
await tester.tap(find.byTooltip('展开/折叠侧边栏'));
await tester.pumpAndSettle();
expect(sidebarWidth(tester), AppSizes.sidebarCollapsedWidth);
expect(find.text('Suxing Nav'), findsNothing);
expect(find.text('S'), findsOneWidget);
// 折叠态下 .nav-text 与 .submit-text 都被隐藏,只剩空行。
expect(navText('常用推荐'), findsNothing);
expect(navText('提交网站'), findsNothing);
});
testWidgets('再点一次可以展开回来', (WidgetTester tester) async {
await pumpPage(tester);
await tester.tap(find.byTooltip('展开/折叠侧边栏'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('展开/折叠侧边栏'));
await tester.pumpAndSettle();
expect(sidebarWidth(tester), AppSizes.sidebarWidth);
expect(find.text('Suxing Nav'), findsOneWidget);
});
});
group('分组子菜单(对应 .has-sub 的脚本)', () {
testWidgets('点击分组展开子菜单,再点收起', (WidgetTester tester) async {
await pumpPage(tester);
await expandMaterials(tester);
expect(navText('UI 设计'), findsOneWidget);
expect(navText('图标库'), findsOneWidget);
expect(navText('高清壁纸'), findsOneWidget);
await tester.tap(navText('素材资源'));
await tester.pumpAndSettle();
expect(navText('UI 设计'), findsNothing);
});
testWidgets('折叠状态下点分组会先展开侧栏', (WidgetTester tester) async {
await pumpPage(tester);
await tester.tap(find.byTooltip('展开/折叠侧边栏'));
await tester.pumpAndSettle();
expect(sidebarWidth(tester), AppSizes.sidebarCollapsedWidth);
await tester.tap(collapsedRow(2));
await tester.pumpAndSettle();
expect(sidebarWidth(tester), AppSizes.sidebarWidth);
expect(navText('UI 设计'), findsOneWidget);
});
});
group('导航与内容', () {
testWidgets('高亮固定写在第一个条目上', (WidgetTester tester) async {
await pumpPage(tester);
expect(navColor(tester, '常用推荐'), AppColors.primary);
expect(navColor(tester, '素材资源'), AppColors.textSecondary);
expect(highlightedRows(tester), <String>['常用推荐']);
});
testWidgets('点有对应分区的导航会滚过去,高亮不变', (WidgetTester tester) async {
await pumpPage(tester);
final double before = scrollOffset(tester);
await expandMaterials(tester);
await tester.tap(navText('UI 设计'));
await tester.pumpAndSettle();
expect(scrollOffset(tester), greaterThan(before));
expect(navColor(tester, '常用推荐'), AppColors.primary);
expect(highlightedRows(tester), <String>['常用推荐']);
});
testWidgets('点没有对应分区的导航不会有任何反应', (WidgetTester tester) async {
await pumpPage(tester);
await expandMaterials(tester);
await tester.tap(navText('UI 设计'));
await tester.pumpAndSettle();
final double afterScroll = scrollOffset(tester);
// #icon 在原型里没有对应元素。
await tester.tap(navText('图标库'));
await tester.pumpAndSettle();
expect(scrollOffset(tester), afterScroll);
});
testWidgets('搜索框只接收输入,不过滤内容', (WidgetTester tester) async {
await pumpPage(tester);
await tester.enterText(find.byType(TextField), 'GitHub');
await tester.pumpAndSettle();
expect(find.byType(LinkSectionView), findsNWidgets(2));
expect(find.byType(LinkCard), findsNWidgets(5));
expect(
tester.widget<TextField>(find.byType(TextField)).controller?.text,
'GitHub',
);
});
});
group('滚动', () {
testWidgets('滚动主内容不会重建侧栏,也不会改动高亮', (WidgetTester tester) async {
await pumpPage(tester);
final ScrollController controller = mainController(tester);
AnimatedContainer firstRow() {
return tester.widget<AnimatedContainer>(
find
.ancestor(
of: navText('常用推荐'),
matching: find.byType(AnimatedContainer),
)
.first,
);
}
final AnimatedContainer beforeScroll = firstRow();
controller.jumpTo(controller.position.maxScrollExtent);
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
expect(
identical(beforeScroll, firstRow()),
isTrue,
reason: '滚动主内容时侧栏被重建了',
);
expect(highlightedRows(tester), <String>['常用推荐']);
});
testWidgets('回到顶部把偏移归零', (WidgetTester tester) async {
await pumpPage(tester);
final ScrollController controller = mainController(tester);
controller.jumpTo(controller.position.maxScrollExtent);
await tester.pump();
expect(scrollOffset(tester), greaterThan(0));
await tester.tap(find.byTooltip('回到顶部'));
await tester.pumpAndSettle();
expect(scrollOffset(tester), 0);
});
});
group('其它', () {
testWidgets('点击卡片交由 UrlOpener 打开对应地址', (WidgetTester tester) async {
await pumpPage(tester);
await tester.tap(find.text('GitHub'));
await tester.pump();
expect(urlOpener.openedUrls, <String>['https://github.com']);
});
testWidgets('点提交网站不会有任何反应', (WidgetTester tester) async {
await pumpPage(tester);
await tester.tap(navText('提交网站'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 400));
expect(find.byType(SnackBar), findsNothing);
});
});
group('加载状态', () {
testWidgets('数据没回来时显示加载提示', (WidgetTester tester) async {
final _PendingRepository repository = _PendingRepository();
await pumpPage(tester, repository: repository, settle: false);
expect(find.text('正在加载…'), findsOneWidget);
expect(find.byType(LinkSectionView), findsNothing);
repository.completer.complete(mockHomeData);
await tester.pumpAndSettle();
expect(find.text('正在加载…'), findsNothing);
expect(find.byType(LinkSectionView), findsNWidgets(2));
});
testWidgets('取数失败时显示原因和重试按钮', (WidgetTester tester) async {
await pumpPage(
tester,
repository: const _FailingRepository('连不上后端(http://127.0.0.1:8080)'),
);
expect(find.text('加载失败'), findsOneWidget);
expect(find.textContaining('连不上后端'), findsOneWidget);
expect(find.text('重试'), findsOneWidget);
expect(find.byType(LinkSectionView), findsNothing);
});
testWidgets('点重试会重新取数,成功后渲染内容', (WidgetTester tester) async {
final _FlakyRepository repository = _FlakyRepository();
await pumpPage(tester, repository: repository);
expect(find.text('加载失败'), findsOneWidget);
await tester.tap(find.text('重试'));
await tester.pumpAndSettle();
expect(repository.calls, 2);
expect(find.text('加载失败'), findsNothing);
expect(find.byType(LinkSectionView), findsNWidgets(2));
expect(find.byType(LinkCard), findsNWidgets(5));
});
});
group('提交网站', () {
testWidgets('点侧栏底部按钮跳到后端配的提交入口', (WidgetTester tester) async {
await pumpPage(tester);
await tester.tap(navText('提交网站'));
await tester.pump();
// 地址来自后端 `site.submitUrl`,页面不再自己弹表单。
expect(urlOpener.openedUrls, <String>['https://mattermost.example.com']);
});
});
}
/// 数据一直不回来,用来测加载中的那一帧。
class _PendingRepository implements HomeRepository {
final Completer<HomeData> completer = Completer<HomeData>();
@override
Future<HomeData> fetchHomeData() => completer.future;
@override
Future<void> submitSite(SubmitSiteDraft draft) async {}
@override
Future<void> updateSite(String id, SubmitSiteDraft draft) async {}
@override
Future<void> deleteSite(String id) async {}
}
/// 永远失败。
class _FailingRepository implements HomeRepository {
const _FailingRepository(this.message);
final String message;
@override
Future<HomeData> fetchHomeData() async => throw HomeFailure(message);
@override
Future<void> submitSite(SubmitSiteDraft draft) async {}
@override
Future<void> updateSite(String id, SubmitSiteDraft draft) async {}
@override
Future<void> deleteSite(String id) async {}
}
/// 第一次失败、第二次成功,用来验证重试按钮真的重新取数。
class _FlakyRepository implements HomeRepository {
int calls = 0;
@override
Future<HomeData> fetchHomeData() async {
calls++;
if (calls == 1) {
throw const HomeFailure('后端没起来');
}
return mockHomeData;
}
@override
Future<void> submitSite(SubmitSiteDraft draft) async {}
@override
Future<void> updateSite(String id, SubmitSiteDraft draft) async {}
@override
Future<void> deleteSite(String id) async {}
}