test(app): ClassBloc + SearchBloc 单元测试 — 33 个测试全覆盖

ClassBloc (13 tests):
- 班级列表加载(成功/失败/loading 状态)
- 选中班级详情 + 子事件触发
- 成员/日记墙/主题/评语加载
- 创建班级 + 错误处理
- 加入班级 + 列表刷新
- 布置主题
- sharedToClass 过滤逻辑

SearchBloc (20 tests):
- 关键词搜索(标题/摘要/标签匹配、大小写不敏感)
- 心情筛选(null/有值/失败)
- 标签搜索
- 搜索历史(记录/去重)
- 清除搜索 + Tab 切换
- hasActiveFilter 属性
This commit is contained in:
iven
2026-06-03 19:03:29 +08:00
parent f6d394afb6
commit 9c92cba87f
2 changed files with 665 additions and 0 deletions

View 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);
});
});
}