test(app): ClassBloc + SearchBloc 单元测试 — 33 个测试全覆盖
ClassBloc (13 tests): - 班级列表加载(成功/失败/loading 状态) - 选中班级详情 + 子事件触发 - 成员/日记墙/主题/评语加载 - 创建班级 + 错误处理 - 加入班级 + 列表刷新 - 布置主题 - sharedToClass 过滤逻辑 SearchBloc (20 tests): - 关键词搜索(标题/摘要/标签匹配、大小写不敏感) - 心情筛选(null/有值/失败) - 标签搜索 - 搜索历史(记录/去重) - 清除搜索 + Tab 切换 - hasActiveFilter 属性
This commit is contained in:
293
app/test/features/search/bloc/search_bloc_test.dart
Normal file
293
app/test/features/search/bloc/search_bloc_test.dart
Normal file
@@ -0,0 +1,293 @@
|
||||
// SearchBloc 单元测试
|
||||
//
|
||||
// 覆盖:关键词搜索、标签搜索、心情筛选、搜索历史、清除搜索、tab 切换
|
||||
// 使用 mocktail mock JournalRepository
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:nuanji_app/data/models/journal_entry.dart';
|
||||
import 'package:nuanji_app/data/repositories/journal_repository.dart';
|
||||
import 'package:nuanji_app/data/models/journal_entry.dart';
|
||||
import 'package:nuanji_app/features/search/bloc/search_bloc.dart';
|
||||
|
||||
// ===== Mock =====
|
||||
|
||||
class MockJournalRepository extends Mock implements JournalRepository {}
|
||||
|
||||
// ===== 测试数据工厂 =====
|
||||
|
||||
JournalEntry _makeJournal({
|
||||
String id = 'j-1',
|
||||
String title = '今天的心情日记',
|
||||
Mood mood = Mood.happy,
|
||||
String? contentExcerpt,
|
||||
List<String> tags = const [],
|
||||
}) {
|
||||
return JournalEntry(
|
||||
id: id,
|
||||
authorId: 'user-1',
|
||||
title: title,
|
||||
date: DateTime(2026, 6, 1),
|
||||
mood: mood,
|
||||
contentExcerpt: contentExcerpt,
|
||||
tags: tags,
|
||||
createdAt: DateTime(2026, 6, 1),
|
||||
updatedAt: DateTime(2026, 6, 1),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
late MockJournalRepository mockJournalRepo;
|
||||
late SearchBloc bloc;
|
||||
|
||||
setUp(() {
|
||||
mockJournalRepo = MockJournalRepository();
|
||||
bloc = SearchBloc(journalRepository: mockJournalRepo);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
bloc.close();
|
||||
});
|
||||
|
||||
/// 辅助:dispatch 并等待处理完成
|
||||
Future<SearchState> dispatch(SearchEvent event) async {
|
||||
bloc.add(event);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
return bloc.state;
|
||||
}
|
||||
|
||||
// ===== 关键词搜索 =====
|
||||
|
||||
group('SearchByKeyword', () {
|
||||
test('空关键词不触发搜索,返回空结果', () async {
|
||||
final state = await dispatch(const SearchByKeyword(''));
|
||||
expect(state, isA<SearchLoaded>());
|
||||
expect((state as SearchLoaded).results, isEmpty);
|
||||
});
|
||||
|
||||
test('纯空格关键词视为空', () async {
|
||||
final state = await dispatch(const SearchByKeyword(' '));
|
||||
expect(state, isA<SearchLoaded>());
|
||||
expect((state as SearchLoaded).results, isEmpty);
|
||||
});
|
||||
|
||||
test('匹配标题中的关键词', () async {
|
||||
final journals = [
|
||||
_makeJournal(id: 'j1', title: '今天的心情日记'),
|
||||
_makeJournal(id: 'j2', title: '周末旅行记'),
|
||||
_makeJournal(id: 'j3', title: '读后感'),
|
||||
];
|
||||
when(() => mockJournalRepo.getJournals(page: 1, pageSize: 200))
|
||||
.thenAnswer((_) async => journals);
|
||||
|
||||
final state = await dispatch(const SearchByKeyword('心情'));
|
||||
|
||||
expect(state, isA<SearchLoaded>());
|
||||
final loaded = state as SearchLoaded;
|
||||
expect(loaded.results.length, 1);
|
||||
expect(loaded.results.first.id, 'j1');
|
||||
expect(loaded.activeKeyword, '心情');
|
||||
});
|
||||
|
||||
test('匹配内容摘要中的关键词', () async {
|
||||
final journals = [
|
||||
_makeJournal(id: 'j1', title: '日记', contentExcerpt: '今天心情很好,阳光明媚'),
|
||||
_makeJournal(id: 'j2', title: '随笔', contentExcerpt: '天气阴沉'),
|
||||
];
|
||||
when(() => mockJournalRepo.getJournals(page: 1, pageSize: 200))
|
||||
.thenAnswer((_) async => journals);
|
||||
|
||||
final state = await dispatch(const SearchByKeyword('阳光'));
|
||||
|
||||
expect(state, isA<SearchLoaded>());
|
||||
expect((state as SearchLoaded).results.length, 1);
|
||||
expect((state as SearchLoaded).results.first.id, 'j1');
|
||||
});
|
||||
|
||||
test('匹配标签中的关键词', () async {
|
||||
final journals = [
|
||||
_makeJournal(id: 'j1', tags: ['旅行', '周末']),
|
||||
_makeJournal(id: 'j2', tags: ['学习']),
|
||||
];
|
||||
when(() => mockJournalRepo.getJournals(page: 1, pageSize: 200))
|
||||
.thenAnswer((_) async => journals);
|
||||
|
||||
final state = await dispatch(const SearchByKeyword('旅行'));
|
||||
|
||||
expect(state, isA<SearchLoaded>());
|
||||
expect((state as SearchLoaded).results.length, 1);
|
||||
});
|
||||
|
||||
test('大小写不敏感搜索', () async {
|
||||
final journals = [
|
||||
_makeJournal(id: 'j1', title: 'Happy Day'),
|
||||
_makeJournal(id: 'j2', title: 'happy mood'),
|
||||
];
|
||||
when(() => mockJournalRepo.getJournals(page: 1, pageSize: 200))
|
||||
.thenAnswer((_) async => journals);
|
||||
|
||||
final state = await dispatch(const SearchByKeyword('HAPPY'));
|
||||
|
||||
expect(state, isA<SearchLoaded>());
|
||||
expect((state as SearchLoaded).results.length, 2);
|
||||
});
|
||||
|
||||
test('搜索失败返回 SearchError', () async {
|
||||
when(() => mockJournalRepo.getJournals(page: 1, pageSize: 200))
|
||||
.thenThrow(Exception('网络错误'));
|
||||
|
||||
final state = await dispatch(const SearchByKeyword('测试'));
|
||||
|
||||
expect(state, isA<SearchError>());
|
||||
});
|
||||
|
||||
test('搜索历史记录', () async {
|
||||
when(() => mockJournalRepo.getJournals(page: 1, pageSize: 200))
|
||||
.thenAnswer((_) async => []);
|
||||
|
||||
await dispatch(const SearchByKeyword('关键词A'));
|
||||
await dispatch(const SearchByKeyword('关键词B'));
|
||||
|
||||
final state = await dispatch(const SearchByKeyword(''));
|
||||
final loaded = state as SearchLoaded;
|
||||
expect(loaded.searchHistory, ['关键词B', '关键词A']);
|
||||
});
|
||||
|
||||
test('搜索历史去重', () async {
|
||||
when(() => mockJournalRepo.getJournals(page: 1, pageSize: 200))
|
||||
.thenAnswer((_) async => []);
|
||||
|
||||
await dispatch(const SearchByKeyword('重复'));
|
||||
await dispatch(const SearchByKeyword('其他'));
|
||||
await dispatch(const SearchByKeyword('重复'));
|
||||
|
||||
final state = await dispatch(const SearchByKeyword(''));
|
||||
final loaded = state as SearchLoaded;
|
||||
expect(loaded.searchHistory, ['重复', '其他']);
|
||||
});
|
||||
});
|
||||
|
||||
// ===== 心情筛选 =====
|
||||
|
||||
group('SearchByMood', () {
|
||||
test('null mood 返回空结果', () async {
|
||||
final state = await dispatch(const SearchByMood(null));
|
||||
expect(state, isA<SearchLoaded>());
|
||||
expect((state as SearchLoaded).results, isEmpty);
|
||||
expect((state as SearchLoaded).activeMood, isNull);
|
||||
});
|
||||
|
||||
test('按心情筛选日记', () async {
|
||||
final journals = [
|
||||
_makeJournal(id: 'j1', mood: Mood.happy),
|
||||
_makeJournal(id: 'j2', mood: Mood.happy),
|
||||
_makeJournal(id: 'j3', mood: Mood.sad),
|
||||
];
|
||||
when(() => mockJournalRepo.getJournals(mood: 'happy', page: 1, pageSize: 50))
|
||||
.thenAnswer((_) async => journals.where((j) => j.mood == Mood.happy).toList());
|
||||
|
||||
final state = await dispatch(const SearchByMood(Mood.happy));
|
||||
|
||||
expect(state, isA<SearchLoaded>());
|
||||
final loaded = state as SearchLoaded;
|
||||
expect(loaded.results.length, 2);
|
||||
expect(loaded.activeMood, 'happy');
|
||||
});
|
||||
|
||||
test('心情筛选失败返回 SearchError', () async {
|
||||
when(() => mockJournalRepo.getJournals(mood: 'happy', page: 1, pageSize: 50))
|
||||
.thenThrow(Exception('网络错误'));
|
||||
|
||||
final state = await dispatch(const SearchByMood(Mood.happy));
|
||||
|
||||
expect(state, isA<SearchError>());
|
||||
});
|
||||
});
|
||||
|
||||
// ===== 标签搜索 =====
|
||||
|
||||
group('SearchByTag', () {
|
||||
test('按标签筛选日记', () async {
|
||||
final journals = [
|
||||
_makeJournal(id: 'j1', tags: ['旅行', '周末']),
|
||||
];
|
||||
when(() => mockJournalRepo.getJournals(tag: '旅行', page: 1, pageSize: 50))
|
||||
.thenAnswer((_) async => journals);
|
||||
|
||||
final state = await dispatch(const SearchByTag('旅行'));
|
||||
|
||||
expect(state, isA<SearchLoaded>());
|
||||
final loaded = state as SearchLoaded;
|
||||
expect(loaded.results.length, 1);
|
||||
expect(loaded.activeTag, '旅行');
|
||||
expect(loaded.searchHistory, contains('旅行'));
|
||||
});
|
||||
|
||||
test('标签搜索失败返回 SearchError', () async {
|
||||
when(() => mockJournalRepo.getJournals(tag: '不存在', page: 1, pageSize: 50))
|
||||
.thenThrow(Exception('网络错误'));
|
||||
|
||||
final state = await dispatch(const SearchByTag('不存在'));
|
||||
|
||||
expect(state, isA<SearchError>());
|
||||
});
|
||||
});
|
||||
|
||||
// ===== 清除搜索 =====
|
||||
|
||||
group('SearchClear', () {
|
||||
test('清除搜索返回空结果', () async {
|
||||
// 先执行一次搜索
|
||||
when(() => mockJournalRepo.getJournals(page: 1, pageSize: 200))
|
||||
.thenAnswer((_) async => [_makeJournal()]);
|
||||
await dispatch(const SearchByKeyword('测试'));
|
||||
|
||||
// 清除
|
||||
final state = await dispatch(const SearchClear());
|
||||
|
||||
expect(state, isA<SearchLoaded>());
|
||||
final loaded = state as SearchLoaded;
|
||||
expect(loaded.results, isEmpty);
|
||||
expect(loaded.activeKeyword, isNull);
|
||||
expect(loaded.activeMood, isNull);
|
||||
expect(loaded.activeTag, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
// ===== Tab 切换 =====
|
||||
|
||||
group('SearchTabChanged', () {
|
||||
test('切换 tab 更新 activeTab', () async {
|
||||
when(() => mockJournalRepo.getJournals(page: 1, pageSize: 200))
|
||||
.thenAnswer((_) async => []);
|
||||
await dispatch(const SearchByKeyword('测试'));
|
||||
|
||||
final state = await dispatch(const SearchTabChanged(SearchResultTab.journal));
|
||||
|
||||
expect(state, isA<SearchLoaded>());
|
||||
expect((state as SearchLoaded).activeTab, SearchResultTab.journal);
|
||||
});
|
||||
|
||||
test('非 SearchLoaded 状态下切换 tab 无效', () async {
|
||||
// 初始状态 SearchInitial,不响应 tab 切换
|
||||
final state = await dispatch(const SearchTabChanged(SearchResultTab.tag));
|
||||
expect(state, isA<SearchInitial>());
|
||||
});
|
||||
});
|
||||
|
||||
// ===== hasActiveFilter =====
|
||||
|
||||
group('SearchLoaded.hasActiveFilter', () {
|
||||
test('无筛选条件时 hasActiveFilter 为 false', () async {
|
||||
final state = await dispatch(const SearchClear());
|
||||
expect((state as SearchLoaded).hasActiveFilter, isFalse);
|
||||
});
|
||||
|
||||
test('有关键词时 hasActiveFilter 为 true', () async {
|
||||
when(() => mockJournalRepo.getJournals(page: 1, pageSize: 200))
|
||||
.thenAnswer((_) async => []);
|
||||
final state = await dispatch(const SearchByKeyword('测试'));
|
||||
expect((state as SearchLoaded).hasActiveFilter, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user