- 添加导航条目和分类数据模型 - 实现导航卡片网格展示 - 支持点击卡片打开链接 - 添加主题样式(渐变色、卡片阴影) - 区分系统默认和自定义条目 - 添加分类统计信息"
451 lines
15 KiB
Dart
451 lines
15 KiB
Dart
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 {}
|
||
}
|