feat: 进行功能分区方便后期维护
- 添加导航条目和分类数据模型 - 实现导航卡片网格展示 - 支持点击卡片打开链接 - 添加主题样式(渐变色、卡片阴影) - 区分系统默认和自定义条目 - 添加分类统计信息"
This commit is contained in:
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user