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,111 @@
import 'package:flutter_test/flutter_test.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/link_item.dart';
import 'package:frontend/features/home/domain/models/link_section.dart';
import 'package:frontend/features/home/domain/models/nav_node.dart';
void main() {
const MockHomeRepository repository = MockHomeRepository();
final HomeData data = repository.fetchHomeData();
group('导航树(对照 index3.html 的 nav-group)', () {
test('三个一级条目:一个直接跳转,两个可展开分组', () {
expect(
data.navNodes.map((NavNode node) => node.title),
<String>['常用推荐', '素材资源', '开发工具'],
);
expect(data.navNodes[0].isGroup, isFalse);
expect(data.navNodes[1].children.length, 3);
expect(data.navNodes[2].children.length, 2);
});
test('叶子 id 与原型里的锚点一致', () {
expect(
data.navLeaves.map((NavNode node) => node.id),
<String>['common', 'design', 'icon', 'stock', 'dev', 'api'],
);
});
test('active 固定取第一个条目', () {
expect(data.activeSectionId, 'common');
});
});
group('内容区(对照 index3.html 的两段内容)', () {
test('只有两个分区', () {
expect(
data.sections.map((LinkSection s) => s.title),
<String>['常用推荐', '素材资源 - UI 设计'],
);
});
test('两个分区共五张卡片,与原型的卡片数一致', () {
expect(data.sectionById('common')!.items, hasLength(3));
expect(data.sectionById('design')!.items, hasLength(2));
expect(data.itemCount, 5);
expect(data.allItems, hasLength(5));
});
test('原型里没有对应元素的锚点取不到分区', () {
for (final String id in <String>['icon', 'stock', 'dev', 'api']) {
expect(data.sectionById(id), isNull, reason: '$id 不应该有内容');
}
});
test('分区 id 不重复', () {
final Set<String> ids = data.sections
.map((LinkSection s) => s.id)
.toSet();
expect(ids, hasLength(data.sections.length));
});
test('条目字段完整且地址合法', () {
for (final LinkItem item in data.allItems) {
expect(item.id, isNotEmpty);
expect(item.title, isNotEmpty);
expect(item.description, isNotEmpty);
expect(item.url, startsWith('https://'));
expect(item.host, isNotEmpty);
}
});
test('条目 id 全局唯一', () {
final List<String> ids = data.allItems
.map((LinkItem item) => item.id)
.toList();
expect(ids.toSet(), hasLength(ids.length));
});
});
group('LinkItem.initial', () {
test('英文取首字母并大写', () {
expect(
const LinkItem(id: 'a', title: 'github', url: 'https://a.com').initial,
'G',
);
});
test('中文取第一个字,不会被截成半个字符', () {
expect(
const LinkItem(id: 'a', title: '知乎', url: 'https://a.com').initial,
'知',
);
});
test('空标题有兜底字符', () {
expect(
const LinkItem(id: 'a', title: '', url: 'https://a.com').initial,
'·',
);
});
});
group('NavNode.shortLabel', () {
test('取首字', () {
expect(const NavNode(id: 'x', title: '素材资源').shortLabel, '素');
expect(const NavNode(id: 'x', title: 'GitHub').shortLabel, 'G');
expect(const NavNode(id: 'x', title: '').shortLabel, '·');
});
});
}
@@ -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);
});
});
}
@@ -0,0 +1,102 @@
import 'package:flutter/material.dart';
import 'package:flutter/gestures.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/domain/models/link_item.dart';
import 'package:frontend/features/home/presentation/widgets/link_card.dart';
const LinkItem _item = LinkItem(
id: 'github',
title: 'GitHub',
url: 'https://github.com',
description: '代码托管与开源社区',
);
void main() {
late int openCount;
setUp(() {
openCount = 0;
});
Future<void> pumpCard(
WidgetTester tester, {
LinkItem item = _item,
Size size = const Size(280, 200),
}) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
backgroundColor: AppColors.background,
body: Center(
child: SizedBox(
width: 260,
child: LinkCard(item: item, onOpen: () => openCount++),
),
),
),
),
);
await tester.pump();
}
testWidgets('渲染首字母图标、名称与描述', (WidgetTester tester) async {
await pumpCard(tester);
expect(find.text('G'), findsOneWidget);
expect(find.text('GitHub'), findsOneWidget);
expect(find.text('代码托管与开源社区'), findsOneWidget);
});
testWidgets('没有描述时不报错', (WidgetTester tester) async {
await pumpCard(
tester,
item: const LinkItem(id: 'x', title: '示例', url: 'https://example.com'),
);
expect(find.text('示'), findsOneWidget);
expect(find.text('示例'), findsOneWidget);
});
testWidgets('点击触发打开回调', (WidgetTester tester) async {
await pumpCard(tester);
await tester.tap(find.text('GitHub'));
await tester.pump();
expect(openCount, 1);
});
testWidgets('悬停时上浮', (WidgetTester tester) async {
await pumpCard(tester);
// AnimatedContainer 目标 transform 的 y 分量就是上浮距离:
// 直接断言它,不受 Tooltip 等外层 RenderObject 的影响。
double lift() {
final AnimatedContainer container = tester.widget<AnimatedContainer>(
find.descendant(
of: find.byType(LinkCard),
matching: find.byType(AnimatedContainer),
),
);
return container.transform?.getTranslation().y ?? 0;
}
expect(lift(), 0);
final TestGesture gesture = await tester.createGesture(
kind: PointerDeviceKind.mouse,
);
await gesture.addPointer(location: tester.getCenter(find.byType(LinkCard)));
addTearDown(gesture.removePointer);
await gesture.moveTo(tester.getCenter(find.byType(LinkCard)));
await tester.pumpAndSettle();
expect(lift(), -AppSizes.cardHoverLift);
});
}