// 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 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 dispatch(SearchEvent event) async { bloc.add(event); await Future.delayed(const Duration(milliseconds: 50)); return bloc.state; } // ===== 关键词搜索 ===== group('SearchByKeyword', () { test('空关键词不触发搜索,返回空结果', () async { final state = await dispatch(const SearchByKeyword('')); expect(state, isA()); expect((state as SearchLoaded).results, isEmpty); }); test('纯空格关键词视为空', () async { final state = await dispatch(const SearchByKeyword(' ')); expect(state, isA()); 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()); 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()); 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()); 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()); 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()); }); 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()); 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()); 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()); }); }); // ===== 标签搜索 ===== 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()); 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()); }); }); // ===== 清除搜索 ===== 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()); 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()); expect((state as SearchLoaded).activeTab, SearchResultTab.journal); }); test('非 SearchLoaded 状态下切换 tab 无效', () async { // 初始状态 SearchInitial,不响应 tab 切换 final state = await dispatch(const SearchTabChanged(SearchResultTab.tag)); expect(state, isA()); }); }); // ===== 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); }); }); }