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 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(navText(label)).style?.color; } /// 一级条目当前的底色(透明表示没有高亮)。 List highlightedRows(WidgetTester tester) { final List rows = []; for (final String label in ['常用推荐', '素材资源', '开发工具']) { final AnimatedContainer container = tester.widget( 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(mainScroll).controller!; } double scrollOffset(WidgetTester tester) => mainController(tester).offset; Future 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), ['常用推荐']); }); 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), ['常用推荐']); }); 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(find.byType(TextField)).controller?.text, 'GitHub', ); }); }); group('滚动', () { testWidgets('滚动主内容不会重建侧栏,也不会改动高亮', (WidgetTester tester) async { await pumpPage(tester); final ScrollController controller = mainController(tester); AnimatedContainer firstRow() { return tester.widget( 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), ['常用推荐']); }); 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, ['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, ['https://mattermost.example.com']); }); }); } /// 数据一直不回来,用来测加载中的那一帧。 class _PendingRepository implements HomeRepository { final Completer completer = Completer(); @override Future fetchHomeData() => completer.future; @override Future submitSite(SubmitSiteDraft draft) async {} @override Future updateSite(String id, SubmitSiteDraft draft) async {} @override Future deleteSite(String id) async {} } /// 永远失败。 class _FailingRepository implements HomeRepository { const _FailingRepository(this.message); final String message; @override Future fetchHomeData() async => throw HomeFailure(message); @override Future submitSite(SubmitSiteDraft draft) async {} @override Future updateSite(String id, SubmitSiteDraft draft) async {} @override Future deleteSite(String id) async {} } /// 第一次失败、第二次成功,用来验证重试按钮真的重新取数。 class _FlakyRepository implements HomeRepository { int calls = 0; @override Future fetchHomeData() async { calls++; if (calls == 1) { throw const HomeFailure('后端没起来'); } return mockHomeData; } @override Future submitSite(SubmitSiteDraft draft) async {} @override Future updateSite(String id, SubmitSiteDraft draft) async {} @override Future deleteSite(String id) async {} }