feat: 实现导航页基础功能
- 添加导航条目和分类数据模型 - 实现导航卡片网格展示 - 支持点击卡片打开链接 - 添加主题样式(渐变色、卡片阴影) - 区分系统默认和自定义条目 - 添加分类统计信息"
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:frontend/features/home/data/home_repository.dart';
|
||||
import 'package:frontend/features/home/data/http_home_repository.dart';
|
||||
import 'package:frontend/features/home/domain/models/home_data.dart';
|
||||
import 'package:frontend/features/home/domain/models/link_section.dart';
|
||||
import 'package:frontend/features/home/domain/models/nav_node.dart';
|
||||
import 'package:frontend/features/home/domain/models/submit_site_draft.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
|
||||
/// 和后端真实响应同构:两个一级分组,其中一个带子分组,还留一个空分组。
|
||||
const String _navJson = '''
|
||||
{
|
||||
"groups": [
|
||||
{
|
||||
"id": "common",
|
||||
"title": "常用推荐",
|
||||
"source": "system",
|
||||
"editable": false,
|
||||
"sites": [
|
||||
{ "id": "github", "title": "GitHub", "url": "https://github.com",
|
||||
"description": "代码托管与开源社区", "categoryId": "common",
|
||||
"source": "system", "order": 10, "editable": false,
|
||||
"createdAt": "2026-09-13T10:29:48Z", "updatedAt": "2026-09-13T10:29:48Z",
|
||||
"submittedBy": "" },
|
||||
{ "id": "zhihu", "title": "知乎", "url": "https://www.zhihu.com",
|
||||
"description": "深度内容问答社区", "categoryId": "common",
|
||||
"source": "system", "order": 20, "editable": false,
|
||||
"createdAt": "2026-09-13T10:29:48Z", "updatedAt": "2026-09-13T10:29:48Z",
|
||||
"submittedBy": "" }
|
||||
],
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"id": "materials",
|
||||
"title": "素材资源",
|
||||
"source": "system",
|
||||
"editable": false,
|
||||
"sites": [],
|
||||
"children": [
|
||||
{
|
||||
"id": "design",
|
||||
"title": "UI 设计",
|
||||
"source": "system",
|
||||
"editable": false,
|
||||
"sites": [
|
||||
{ "id": "figma", "title": "Figma", "url": "https://www.figma.com",
|
||||
"description": "协同界面设计工具", "categoryId": "design",
|
||||
"source": "system", "order": 10, "editable": false,
|
||||
"createdAt": "2026-09-13T10:29:48Z", "updatedAt": "2026-09-13T10:29:48Z",
|
||||
"submittedBy": "" }
|
||||
],
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"id": "icon",
|
||||
"title": "图标库",
|
||||
"source": "system",
|
||||
"editable": false,
|
||||
"sites": [],
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
''';
|
||||
|
||||
HttpHomeRepository repositoryReturning(
|
||||
String body, {
|
||||
int status = 200,
|
||||
void Function(http.Request request)? onRequest,
|
||||
}) {
|
||||
final MockClient client = MockClient((http.Request request) async {
|
||||
onRequest?.call(request);
|
||||
return http.Response.bytes(utf8.encode(body), status);
|
||||
});
|
||||
return HttpHomeRepository(baseUrl: 'http://api.test', client: client);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('映射后端响应', () {
|
||||
late HomeData data;
|
||||
|
||||
setUp(() async {
|
||||
data = await repositoryReturning(_navJson).fetchHomeData();
|
||||
});
|
||||
|
||||
test('请求打到 /api/nav', () async {
|
||||
Uri? requested;
|
||||
await repositoryReturning(
|
||||
_navJson,
|
||||
onRequest: (http.Request request) => requested = request.url,
|
||||
).fetchHomeData();
|
||||
|
||||
expect(requested.toString(), 'http://api.test/api/nav');
|
||||
});
|
||||
|
||||
test('分组变成侧栏导航树', () {
|
||||
expect(
|
||||
data.navNodes.map((NavNode node) => node.title),
|
||||
<String>['常用推荐', '素材资源'],
|
||||
);
|
||||
expect(data.navNodes[0].isGroup, isFalse, reason: '没有子分组就是直接跳转的条目');
|
||||
expect(data.navNodes[1].isGroup, isTrue);
|
||||
expect(
|
||||
data.navNodes[1].children.map((NavNode node) => node.title),
|
||||
<String>['UI 设计', '图标库'],
|
||||
);
|
||||
});
|
||||
|
||||
test('只有挂着网站的分组才进内容区', () {
|
||||
expect(
|
||||
data.sections.map((LinkSection section) => section.id),
|
||||
<String>['common', 'design'],
|
||||
);
|
||||
// 空分组仍然留在侧栏里,只是点了没有对应内容。
|
||||
expect(data.sectionById('icon'), isNull);
|
||||
expect(
|
||||
data.navLeaves.map((NavNode node) => node.id),
|
||||
contains('icon'),
|
||||
);
|
||||
});
|
||||
|
||||
test('子分组的内容标题带上父级', () {
|
||||
expect(data.sectionById('design')!.title, '素材资源 - UI 设计');
|
||||
expect(data.sectionById('common')!.title, '常用推荐');
|
||||
});
|
||||
|
||||
test('网站按后端给的顺序展开', () {
|
||||
expect(
|
||||
data.sectionById('common')!.items.map((item) => item.title),
|
||||
<String>['GitHub', '知乎'],
|
||||
);
|
||||
expect(data.sectionById('design')!.items.single.url, 'https://www.figma.com');
|
||||
expect(data.sectionById('design')!.items.single.description, '协同界面设计工具');
|
||||
});
|
||||
});
|
||||
|
||||
group('错误处理', () {
|
||||
test('非 200 时带出后端的说明', () async {
|
||||
final HttpHomeRepository repository = repositoryReturning(
|
||||
'{"error":"找不到数据"}',
|
||||
status: 500,
|
||||
);
|
||||
await expectLater(
|
||||
repository.fetchHomeData(),
|
||||
throwsA(
|
||||
isA<HomeFailure>().having(
|
||||
(HomeFailure failure) => failure.message,
|
||||
'message',
|
||||
contains('找不到数据'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('返回的不是 JSON 时给出明确提示', () async {
|
||||
final HttpHomeRepository repository = repositoryReturning(
|
||||
'<html>502 Bad Gateway</html>',
|
||||
);
|
||||
await expectLater(
|
||||
repository.fetchHomeData(),
|
||||
throwsA(
|
||||
isA<HomeFailure>().having(
|
||||
(HomeFailure failure) => failure.message,
|
||||
'message',
|
||||
contains('不是合法 JSON'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('缺少 groups 时给出明确提示', () async {
|
||||
final HttpHomeRepository repository = repositoryReturning('{"ok":true}');
|
||||
await expectLater(
|
||||
repository.fetchHomeData(),
|
||||
throwsA(
|
||||
isA<HomeFailure>().having(
|
||||
(HomeFailure failure) => failure.message,
|
||||
'message',
|
||||
contains('groups'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('连不上后端时给出地址', () async {
|
||||
final MockClient client = MockClient(
|
||||
(http.Request request) async => throw const SocketExceptionStub(),
|
||||
);
|
||||
final HttpHomeRepository repository = HttpHomeRepository(
|
||||
baseUrl: 'http://127.0.0.1:8080',
|
||||
client: client,
|
||||
);
|
||||
await expectLater(
|
||||
repository.fetchHomeData(),
|
||||
throwsA(
|
||||
isA<HomeFailure>().having(
|
||||
(HomeFailure failure) => failure.message,
|
||||
'message',
|
||||
contains('连不上后端'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('缺字段的条目被跳过,不会让整页失败', () async {
|
||||
final HomeData data = await repositoryReturning('''
|
||||
{"groups":[{"id":"g","title":"G","sites":[
|
||||
{"id":"","title":"没有 id","url":"https://a.com"},
|
||||
{"id":"ok","title":"正常","url":"https://b.com"}
|
||||
],"children":[]}]}
|
||||
''').fetchHomeData();
|
||||
|
||||
expect(data.sectionById('g')!.items, hasLength(1));
|
||||
expect(data.sectionById('g')!.items.single.title, '正常');
|
||||
});
|
||||
});
|
||||
|
||||
group('提交网站', () {
|
||||
const SubmitSiteDraft draft = SubmitSiteDraft(
|
||||
title: 'Hugging Face',
|
||||
url: 'https://huggingface.co',
|
||||
description: '开源模型与数据集社区',
|
||||
group: 'AI 工具',
|
||||
subGroup: '模型社区',
|
||||
submittedBy: '[email protected]',
|
||||
);
|
||||
|
||||
test('POST 到 /api/sites,字段用 camelCase', () async {
|
||||
http.Request? captured;
|
||||
final HttpHomeRepository repository = HttpHomeRepository(
|
||||
baseUrl: 'http://api.test',
|
||||
client: MockClient((http.Request request) async {
|
||||
captured = request;
|
||||
return http.Response.bytes(utf8.encode('{}'), 201);
|
||||
}),
|
||||
);
|
||||
|
||||
await repository.submitSite(draft);
|
||||
|
||||
expect(captured!.method, 'POST');
|
||||
expect(captured!.url.toString(), 'http://api.test/api/sites');
|
||||
final Map<String, dynamic> body =
|
||||
jsonDecode(captured!.body) as Map<String, dynamic>;
|
||||
expect(body['title'], 'Hugging Face');
|
||||
expect(body['url'], 'https://huggingface.co');
|
||||
expect(body['group'], 'AI 工具');
|
||||
expect(body['subGroup'], '模型社区');
|
||||
expect(body['submittedBy'], '[email protected]');
|
||||
expect(body.containsKey('categoryId'), isFalse, reason: '不能自己指定分组 id');
|
||||
});
|
||||
|
||||
test('后端返回 400 时抛出后端的中文说明', () async {
|
||||
final HttpHomeRepository repository = repositoryReturning(
|
||||
'{"error":"url 不能是本机或内网地址:localhost"}',
|
||||
status: 400,
|
||||
);
|
||||
await expectLater(
|
||||
repository.submitSite(draft),
|
||||
throwsA(
|
||||
isA<HomeFailure>().having(
|
||||
(HomeFailure failure) => failure.message,
|
||||
'message',
|
||||
allOf(contains('400'), contains('不能是本机或内网地址')),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('连不上后端时给出地址', () async {
|
||||
final HttpHomeRepository repository = HttpHomeRepository(
|
||||
baseUrl: 'http://127.0.0.1:8080',
|
||||
client: MockClient(
|
||||
(http.Request request) async => throw const SocketExceptionStub(),
|
||||
),
|
||||
);
|
||||
await expectLater(
|
||||
repository.submitSite(draft),
|
||||
throwsA(
|
||||
isA<HomeFailure>().having(
|
||||
(HomeFailure failure) => failure.message,
|
||||
'message',
|
||||
contains('连不上后端'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// 用来模拟「连不上」的异常。
|
||||
class SocketExceptionStub implements Exception {
|
||||
const SocketExceptionStub();
|
||||
|
||||
@override
|
||||
String toString() => 'Connection refused';
|
||||
}
|
||||
@@ -7,7 +7,12 @@ import 'package:frontend/features/home/domain/models/nav_node.dart';
|
||||
|
||||
void main() {
|
||||
const MockHomeRepository repository = MockHomeRepository();
|
||||
final HomeData data = repository.fetchHomeData();
|
||||
late HomeData data;
|
||||
|
||||
// 取数改成异步之后,每个用例开始前先拿一次。
|
||||
setUp(() async {
|
||||
data = await repository.fetchHomeData();
|
||||
});
|
||||
|
||||
group('导航树(对照 index3.html 的 nav-group)', () {
|
||||
test('三个一级条目:一个直接跳转,两个可展开分组', () {
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
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/link_item.dart';
|
||||
import 'package:frontend/features/home/domain/models/link_section.dart';
|
||||
import 'package:frontend/features/home/domain/models/nav_node.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 'package:frontend/features/home/presentation/widgets/site_form_dialog.dart';
|
||||
|
||||
import '../../../helpers/fake_url_opener.dart';
|
||||
|
||||
@@ -22,6 +32,9 @@ void main() {
|
||||
// 高度取 420:原型只有两段内容,视口再高就滚不动了,
|
||||
// 「回到顶部」这类用例也就没有意义。
|
||||
Size size = const Size(900, 420),
|
||||
HomeRepository repository = const MockHomeRepository(),
|
||||
/// 加载中的用例要停在第一帧,不能等它 settle。
|
||||
bool settle = true,
|
||||
}) async {
|
||||
tester.view.physicalSize = size;
|
||||
tester.view.devicePixelRatio = 1;
|
||||
@@ -30,12 +43,17 @@ void main() {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: HomePage(
|
||||
repository: const MockHomeRepository(),
|
||||
repository: repository,
|
||||
urlOpener: urlOpener,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
// 数据现在来自 Future,等它落地后再断言。
|
||||
if (settle) {
|
||||
await tester.pumpAndSettle();
|
||||
} else {
|
||||
await tester.pump();
|
||||
}
|
||||
}
|
||||
|
||||
/// 侧栏里的导航文字(避免和内容区的分区标题同名而混淆)。
|
||||
@@ -319,4 +337,297 @@ void main() {
|
||||
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 {
|
||||
final _RecordingRepository repository = _RecordingRepository();
|
||||
// 表单比默认视口高,用大一点的窗口,免得字段在屏幕外点不到。
|
||||
await pumpPage(
|
||||
tester,
|
||||
size: const Size(1000, 1200),
|
||||
repository: repository,
|
||||
);
|
||||
expect(repository.fetches, 1);
|
||||
|
||||
await tester.tap(navText('提交网站'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('网站名称'), findsOneWidget);
|
||||
expect(find.text('提交的网站会放在你自己的分组里,不会进内置分类。'), findsOneWidget);
|
||||
|
||||
// 页面里除了表单还有顶部搜索框,这里限定在对话框内部找输入框。
|
||||
Finder field(int index) => find
|
||||
.descendant(
|
||||
of: find.byType(SiteFormDialog),
|
||||
matching: find.byType(TextField),
|
||||
)
|
||||
.at(index);
|
||||
|
||||
await tester.enterText(field(0), 'Hugging Face');
|
||||
await tester.enterText(field(1), 'https://huggingface.co');
|
||||
await tester.enterText(field(3), 'AI 工具');
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('提交'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(repository.submitted, hasLength(1));
|
||||
expect(repository.submitted.single.group, 'AI 工具');
|
||||
expect(repository.fetches, 2, reason: '提交成功后要重新拉一次数据');
|
||||
expect(find.text('已提交,感谢'), findsOneWidget);
|
||||
expect(find.text('网站名称'), findsNothing, reason: '对话框已经关掉');
|
||||
});
|
||||
});
|
||||
|
||||
group('编辑与删除', () {
|
||||
testWidgets('可修改的条目能编辑,保存后刷新', (WidgetTester tester) async {
|
||||
final _MutableRepository repository = _MutableRepository();
|
||||
await pumpPage(
|
||||
tester,
|
||||
size: const Size(1000, 1200),
|
||||
repository: repository,
|
||||
);
|
||||
expect(repository.fetches, 1);
|
||||
|
||||
await tester.tap(find.byTooltip('编辑'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 表单预填了现有内容,提交者字段不出现。
|
||||
expect(find.text('编辑网站'), findsOneWidget);
|
||||
expect(
|
||||
tester
|
||||
.widget<TextField>(
|
||||
find
|
||||
.descendant(
|
||||
of: find.byType(SiteFormDialog),
|
||||
matching: find.byType(TextField),
|
||||
)
|
||||
.at(0),
|
||||
)
|
||||
.controller!
|
||||
.text,
|
||||
'Hugging Face',
|
||||
);
|
||||
|
||||
await tester.tap(find.text('提交'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(repository.updated, <String>['s-1']);
|
||||
expect(repository.fetches, 2, reason: '保存成功后要重新拉一次数据');
|
||||
expect(find.text('已保存'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('删除会先确认,确认后才真的删并刷新', (WidgetTester tester) async {
|
||||
final _MutableRepository repository = _MutableRepository();
|
||||
await pumpPage(
|
||||
tester,
|
||||
size: const Size(1000, 1200),
|
||||
repository: repository,
|
||||
);
|
||||
|
||||
await tester.tap(find.byTooltip('删除'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.textContaining('确定要删除「Hugging Face」'), findsOneWidget);
|
||||
|
||||
// 先点取消:什么都不该发生。
|
||||
await tester.tap(find.text('取消'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(repository.deleted, isEmpty);
|
||||
|
||||
await tester.tap(find.byTooltip('删除'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('删除'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(repository.deleted, <String>['s-1']);
|
||||
expect(repository.fetches, 2);
|
||||
expect(find.text('已删除'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('内置条目没有编辑和删除入口', (WidgetTester tester) async {
|
||||
await pumpPage(tester);
|
||||
|
||||
expect(find.byType(LinkCard), findsWidgets);
|
||||
expect(find.byTooltip('编辑'), findsNothing);
|
||||
expect(find.byTooltip('删除'), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// 返回一条「用户提交」的站点(editable),并记录改删调用。
|
||||
class _MutableRepository implements HomeRepository {
|
||||
int fetches = 0;
|
||||
final List<String> updated = <String>[];
|
||||
final List<String> deleted = <String>[];
|
||||
|
||||
static const HomeData _data = HomeData(
|
||||
siteName: 'Suxing Nav',
|
||||
searchHint: '输入关键字搜索全网资源...',
|
||||
navNodes: <NavNode>[NavNode(id: 'g-1', title: 'AI 工具', editable: true)],
|
||||
sections: <LinkSection>[
|
||||
LinkSection(
|
||||
id: 'g-1',
|
||||
title: 'AI 工具',
|
||||
items: <LinkItem>[
|
||||
LinkItem(
|
||||
id: 's-1',
|
||||
title: 'Hugging Face',
|
||||
url: 'https://huggingface.co',
|
||||
description: '开源模型与数据集社区',
|
||||
categoryId: 'g-1',
|
||||
editable: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@override
|
||||
Future<HomeData> fetchHomeData() async {
|
||||
fetches++;
|
||||
return _data;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> submitSite(SubmitSiteDraft draft) async {}
|
||||
|
||||
@override
|
||||
Future<void> updateSite(String id, SubmitSiteDraft draft) async {
|
||||
updated.add(id);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteSite(String id) async {
|
||||
deleted.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// 记录取数次数与提交内容,用来验证页面的联调行为。
|
||||
class _RecordingRepository implements HomeRepository {
|
||||
int fetches = 0;
|
||||
final List<SubmitSiteDraft> submitted = <SubmitSiteDraft>[];
|
||||
final List<String> updated = <String>[];
|
||||
final List<String> deleted = <String>[];
|
||||
|
||||
@override
|
||||
Future<HomeData> fetchHomeData() async {
|
||||
fetches++;
|
||||
return mockHomeData;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> submitSite(SubmitSiteDraft draft) async {
|
||||
submitted.add(draft);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateSite(String id, SubmitSiteDraft draft) async {
|
||||
updated.add(id);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteSite(String id) async {
|
||||
deleted.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// 数据一直不回来,用来测加载中的那一帧。
|
||||
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 {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.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/domain/models/home_data.dart';
|
||||
import 'package:frontend/features/home/domain/models/nav_node.dart';
|
||||
import 'package:frontend/features/home/domain/models/submit_site_draft.dart';
|
||||
import 'package:frontend/features/home/presentation/widgets/site_form_dialog.dart';
|
||||
|
||||
/// 记录提交内容的假仓库。[failWith] 有值时模拟后端报错,[gate] 用来卡住请求。
|
||||
class _RecordingRepository implements HomeRepository {
|
||||
final List<SubmitSiteDraft> submitted = <SubmitSiteDraft>[];
|
||||
String? failWith;
|
||||
Completer<void>? gate;
|
||||
|
||||
@override
|
||||
Future<HomeData> fetchHomeData() async => mockHomeData;
|
||||
|
||||
@override
|
||||
Future<void> submitSite(SubmitSiteDraft draft) async {
|
||||
if (gate != null) {
|
||||
await gate!.future;
|
||||
}
|
||||
final String? failure = failWith;
|
||||
if (failure != null) {
|
||||
throw HomeFailure(failure);
|
||||
}
|
||||
submitted.add(draft);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateSite(String id, SubmitSiteDraft draft) async {}
|
||||
|
||||
@override
|
||||
Future<void> deleteSite(String id) async {}
|
||||
}
|
||||
|
||||
/// 侧栏那棵树:内置分组不可选,用户分组可选。
|
||||
const List<NavNode> _navNodes = <NavNode>[
|
||||
NavNode(id: 'common', title: '常用推荐'),
|
||||
NavNode(
|
||||
id: 'g-1',
|
||||
title: 'AI 工具',
|
||||
editable: true,
|
||||
children: <NavNode>[
|
||||
NavNode(id: 'g-2', title: '模型社区', editable: true),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
void main() {
|
||||
late _RecordingRepository repository;
|
||||
late bool? dialogResult;
|
||||
|
||||
setUp(() {
|
||||
repository = _RecordingRepository();
|
||||
dialogResult = null;
|
||||
});
|
||||
|
||||
Future<void> openDialog(
|
||||
WidgetTester tester, {
|
||||
String heading = '提交网站',
|
||||
SubmitSiteDraft? initial,
|
||||
}) async {
|
||||
// 表单比默认测试视口高,放大一点免得字段跑到屏幕外。
|
||||
tester.view.physicalSize = const Size(1000, 1200);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Builder(
|
||||
builder: (BuildContext context) => TextButton(
|
||||
onPressed: () async {
|
||||
dialogResult = await showSiteFormDialog(
|
||||
context: context,
|
||||
navNodes: _navNodes,
|
||||
heading: heading,
|
||||
initial: initial,
|
||||
onSubmit: (SubmitSiteDraft draft) =>
|
||||
repository.submitSite(draft),
|
||||
);
|
||||
},
|
||||
child: const Text('打开'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('打开'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// 按顺序填字段:0 名称、1 地址、2 描述、3 分组、4 子分组、5 称呼。
|
||||
Future<void> fill(WidgetTester tester, int index, String value) async {
|
||||
await tester.enterText(find.byType(TextField).at(index), value);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
Future<void> fillValidForm(WidgetTester tester) async {
|
||||
await fill(tester, 0, 'Hugging Face');
|
||||
await fill(tester, 1, 'https://huggingface.co');
|
||||
await fill(tester, 2, '开源模型与数据集社区');
|
||||
await fill(tester, 3, 'AI 工具');
|
||||
await fill(tester, 4, '模型社区');
|
||||
await fill(tester, 5, '[email protected]');
|
||||
}
|
||||
|
||||
testWidgets('空表单提交会逐项提示,且不发请求', (WidgetTester tester) async {
|
||||
await openDialog(tester);
|
||||
|
||||
await tester.tap(find.text('提交'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('请填写网站名称'), findsOneWidget);
|
||||
expect(find.text('请填写网站地址'), findsOneWidget);
|
||||
expect(find.text('请填写分组名称'), findsOneWidget);
|
||||
expect(repository.submitted, isEmpty);
|
||||
expect(dialogResult, isNull, reason: '校验没过时对话框不关');
|
||||
});
|
||||
|
||||
testWidgets('内网地址会被拦下来', (WidgetTester tester) async {
|
||||
await openDialog(tester);
|
||||
await fill(tester, 0, '内网');
|
||||
await fill(tester, 1, 'http://192.168.1.10');
|
||||
await fill(tester, 3, 'AI 工具');
|
||||
|
||||
await tester.tap(find.text('提交'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('不能填本机或内网地址'), findsOneWidget);
|
||||
expect(repository.submitted, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('合法内容提交给仓库,成功后关闭并返回 true', (WidgetTester tester) async {
|
||||
await openDialog(tester);
|
||||
await fillValidForm(tester);
|
||||
|
||||
await tester.tap(find.text('提交'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(repository.submitted, hasLength(1));
|
||||
final SubmitSiteDraft draft = repository.submitted.single;
|
||||
expect(draft.title, 'Hugging Face');
|
||||
expect(draft.url, 'https://huggingface.co');
|
||||
expect(draft.group, 'AI 工具');
|
||||
expect(draft.subGroup, '模型社区');
|
||||
expect(draft.submittedBy, '[email protected]');
|
||||
expect(dialogResult, isTrue);
|
||||
expect(find.text('取消'), findsNothing, reason: '对话框已经关掉');
|
||||
});
|
||||
|
||||
testWidgets('后端报错时留在表单里显示原因', (WidgetTester tester) async {
|
||||
repository.failWith = 'url 不能是本机或内网地址:localhost';
|
||||
await openDialog(tester);
|
||||
await fillValidForm(tester);
|
||||
|
||||
await tester.tap(find.text('提交'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('不能是本机或内网地址'), findsOneWidget);
|
||||
expect(repository.submitted, isEmpty);
|
||||
expect(dialogResult, isNull);
|
||||
expect(find.text('取消'), findsOneWidget, reason: '还能改了再提交');
|
||||
});
|
||||
|
||||
testWidgets('提交中按钮变成「提交中…」且不可再点', (WidgetTester tester) async {
|
||||
repository.gate = Completer<void>();
|
||||
await openDialog(tester);
|
||||
await fillValidForm(tester);
|
||||
|
||||
await tester.tap(find.text('提交'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('提交中…'), findsOneWidget);
|
||||
|
||||
repository.gate!.complete();
|
||||
await tester.pumpAndSettle();
|
||||
expect(dialogResult, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('只给用户分组建议,点一下填进输入框', (WidgetTester tester) async {
|
||||
await openDialog(tester);
|
||||
|
||||
// 内置分组不出现在建议里。
|
||||
expect(find.text('AI 工具'), findsOneWidget, reason: '用户分组建议');
|
||||
expect(find.text('常用推荐'), findsNothing, reason: '内置分组不可选');
|
||||
|
||||
await tester.tap(find.text('AI 工具'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final TextField groupField = tester.widget<TextField>(
|
||||
find.byType(TextField).at(3),
|
||||
);
|
||||
expect(groupField.controller!.text, 'AI 工具');
|
||||
// 分组定了之后,它下面已有的子分组才出现。
|
||||
expect(find.text('模型社区'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('编辑模式预填现有内容,且不显示提交者字段', (WidgetTester tester) async {
|
||||
await openDialog(
|
||||
tester,
|
||||
heading: '编辑网站',
|
||||
initial: const SubmitSiteDraft(
|
||||
title: 'Hugging Face',
|
||||
url: 'https://huggingface.co',
|
||||
description: '开源模型与数据集社区',
|
||||
group: 'AI 工具',
|
||||
subGroup: '模型社区',
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('编辑网站'), findsOneWidget);
|
||||
expect(
|
||||
tester.widget<TextField>(find.byType(TextField).at(0)).controller!.text,
|
||||
'Hugging Face',
|
||||
);
|
||||
expect(
|
||||
tester.widget<TextField>(find.byType(TextField).at(1)).controller!.text,
|
||||
'https://huggingface.co',
|
||||
);
|
||||
expect(
|
||||
tester.widget<TextField>(find.byType(TextField).at(3)).controller!.text,
|
||||
'AI 工具',
|
||||
);
|
||||
// 后端 PATCH 不接受 submittedBy,编辑时就不显示这个字段。
|
||||
expect(find.text('你的称呼或邮箱'), findsNothing);
|
||||
// 分组已填好,它下面的子分组建议直接出来。
|
||||
// (子分组输入框里也预填了同样的文字,所以这里用 findsWidgets。)
|
||||
expect(find.text('模型社区'), findsWidgets);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user