feat: 进行功能分区方便后期维护

- 添加导航条目和分类数据模型
- 实现导航卡片网格展示
- 支持点击卡片打开链接
- 添加主题样式(渐变色、卡片阴影)
- 区分系统默认和自定义条目
- 添加分类统计信息"
This commit is contained in:
2026-09-13 22:49:02 +08:00
parent 4075519075
commit e2cc3ac30f
40 changed files with 3379 additions and 515 deletions
@@ -0,0 +1,322 @@
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/mock_home_repository.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),
}) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
home: HomePage(
repository: const MockHomeRepository(),
urlOpener: urlOpener,
),
),
);
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);
});
});
}