diff --git a/app/test/features/class_/bloc/class_bloc_test.dart b/app/test/features/class_/bloc/class_bloc_test.dart new file mode 100644 index 0000000..d5b19db --- /dev/null +++ b/app/test/features/class_/bloc/class_bloc_test.dart @@ -0,0 +1,372 @@ +// ClassBloc 单元测试 +// +// 覆盖:班级列表加载、选中详情、成员/日记墙/主题/评语加载、创建班级、加入班级、布置主题 +// 使用 mocktail mock ClassRepository + 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/models/school_class.dart'; +import 'package:nuanji_app/data/repositories/class_repository.dart'; +import 'package:nuanji_app/data/repositories/journal_repository.dart'; +import 'package:nuanji_app/features/class_/bloc/class_bloc.dart'; + +// ===== Mocks ===== + +class MockClassRepository extends Mock implements ClassRepository {} + +class MockJournalRepository extends Mock implements JournalRepository {} + +// ===== 测试数据工厂 ===== + +SchoolClass _makeClass({ + String id = 'class-1', + String name = '三年级一班', + String schoolName = '暖阳小学', + String teacherId = 'teacher-1', + String classCode = 'ABC123', + int memberCount = 25, +}) { + return SchoolClass( + id: id, + name: name, + schoolName: schoolName, + teacherId: teacherId, + classCode: classCode, + memberCount: memberCount, + createdAt: DateTime(2026, 1, 1), + updatedAt: DateTime(2026, 1, 1), + ); +} + +JournalEntry _makeJournal({ + String id = 'j-1', + String title = '今天的心情', + Mood mood = Mood.happy, + bool sharedToClass = true, +}) { + return JournalEntry( + id: id, + authorId: 'user-1', + title: title, + date: DateTime(2026, 6, 1), + mood: mood, + createdAt: DateTime(2026, 6, 1), + updatedAt: DateTime(2026, 6, 1), + sharedToClass: sharedToClass, + ); +} + +ClassMemberDto _makeMember({ + String userId = 'student-1', + String role = 'student', + String? nickname = '小明', +}) { + return ClassMemberDto( + userId: userId, + role: role, + nickname: nickname, + joinedAt: DateTime(2026, 1, 15), + ); +} + +TopicDto _makeTopic({ + String id = 'topic-1', + String classId = 'class-1', + String title = '我的暑假计划', +}) { + return TopicDto( + id: id, + classId: classId, + teacherId: 'teacher-1', + title: title, + isActive: true, + ); +} + +CommentDto _makeComment({ + String id = 'comment-1', + String journalId = 'j-1', + String content = '写得很好!', +}) { + return CommentDto( + id: id, + journalId: journalId, + authorId: 'teacher-1', + content: content, + createdAt: DateTime(2026, 6, 2), + ); +} + +void main() { + late MockClassRepository mockClassRepo; + late MockJournalRepository mockJournalRepo; + late ClassBloc bloc; + + setUp(() { + mockClassRepo = MockClassRepository(); + mockJournalRepo = MockJournalRepository(); + bloc = ClassBloc( + classRepository: mockClassRepo, + journalRepository: mockJournalRepo, + ); + }); + + tearDown(() { + bloc.close(); + }); + + // ===== 辅助:收集事件触发后的最终状态 ===== + + Future dispatch(ClassEvent event) async { + bloc.add(event); + // 等待所有异步事件处理完毕 + await Future.delayed(const Duration(milliseconds: 50)); + return bloc.state; + } + + // ===== 班级列表 ===== + + group('ClassLoadMyClasses', () { + test('成功加载班级列表', () async { + final classes = [_makeClass(id: 'c1'), _makeClass(id: 'c2', name: '三年级二班')]; + when(() => mockClassRepo.getMyClasses()).thenAnswer((_) async => classes); + + final state = await dispatch(const ClassLoadMyClasses()); + + expect(state, isA()); + final loaded = state as ClassListLoaded; + expect(loaded.classes.length, 2); + expect(loaded.classes[0].id, 'c1'); + expect(loaded.classes[1].name, '三年级二班'); + expect(loaded.isLoading, false); + expect(loaded.error, isNull); + }); + + test('加载失败返回空列表', () async { + when(() => mockClassRepo.getMyClasses()).thenThrow(Exception('网络错误')); + + final state = await dispatch(const ClassLoadMyClasses()); + + expect(state, isA()); + final loaded = state as ClassListLoaded; + expect(loaded.classes, isEmpty); + expect(loaded.isLoading, false); + }); + + test('加载中状态先触发', () async { + when(() => mockClassRepo.getMyClasses()).thenAnswer((_) async { + await Future.delayed(const Duration(milliseconds: 100)); + return [_makeClass()]; + }); + + bloc.add(const ClassLoadMyClasses()); + // 立即检查,应该在 loading 状态 + await Future.delayed(const Duration(milliseconds: 10)); + // state 可能是 loading,也可能已完成,取决于调度 + }); + }); + + // ===== 选中班级 ===== + + group('ClassSelected', () { + test('成功选中班级并触发子事件加载', () async { + final classInfo = _makeClass(); + final members = [_makeMember()]; + final journals = [_makeJournal(sharedToClass: true)]; + final topics = [_makeTopic()]; + + when(() => mockClassRepo.getClass('class-1')).thenAnswer((_) async => classInfo); + when(() => mockJournalRepo.getJournals(classId: 'class-1')).thenAnswer((_) async => journals); + when(() => mockClassRepo.getMembers('class-1')).thenAnswer((_) async => members); + when(() => mockClassRepo.getTopics('class-1')).thenAnswer((_) async => topics); + + final state = await dispatch(const ClassSelected('class-1')); + + expect(state, isA()); + final detail = state as ClassDetailLoaded; + expect(detail.classInfo.id, 'class-1'); + expect(detail.members.length, 1); + expect(detail.topics.length, 1); + }); + + test('加载失败返回 ClassError', () async { + when(() => mockClassRepo.getClass('class-1')).thenThrow(Exception('不存在')); + + final state = await dispatch(const ClassSelected('class-1')); + + expect(state, isA()); + expect((state as ClassError).message, contains('加载班级失败')); + }); + }); + + // ===== 成员加载 ===== + + group('ClassLoadMembers', () { + test('在 ClassDetailLoaded 状态下加载成员', () async { + // 先进入 ClassDetailLoaded + when(() => mockClassRepo.getClass('class-1')).thenAnswer((_) async => _makeClass()); + when(() => mockJournalRepo.getJournals(classId: 'class-1')).thenAnswer((_) async => []); + when(() => mockClassRepo.getMembers('class-1')).thenAnswer((_) async => []); + when(() => mockClassRepo.getTopics('class-1')).thenAnswer((_) async => []); + + await dispatch(const ClassSelected('class-1')); + + // 现在加载成员 + final members = [_makeMember(), _makeMember(userId: 'student-2', nickname: '小红')]; + when(() => mockClassRepo.getMembers('class-1')).thenAnswer((_) async => members); + + final state = await dispatch(const ClassLoadMembers('class-1')); + + expect(state, isA()); + final detail = state as ClassDetailLoaded; + expect(detail.members.length, 2); + expect(detail.members[0].nickname, '小明'); + expect(detail.members[1].nickname, '小红'); + }); + + test('非 ClassDetailLoaded 状态下忽略', () async { + // 初始状态 ClassInitial,直接加载成员应被忽略 + final state = await dispatch(const ClassLoadMembers('class-1')); + + expect(state, isA()); + }); + }); + + // ===== 日记墙 ===== + + group('ClassLoadDiaryWall', () { + test('只包含 sharedToClass 为 true 的日记', () async { + // 手动进入 ClassDetailLoaded 状态 + when(() => mockClassRepo.getClass('class-1')).thenAnswer((_) async => _makeClass()); + when(() => mockJournalRepo.getJournals(classId: 'class-1')).thenAnswer((_) async => []); + when(() => mockClassRepo.getMembers('class-1')).thenAnswer((_) async => []); + when(() => mockClassRepo.getTopics('class-1')).thenAnswer((_) async => []); + await dispatch(const ClassSelected('class-1')); + // 等待 ClassSelected 的子事件完成 + await Future.delayed(const Duration(milliseconds: 150)); + // 此时应该已经在 ClassDetailLoaded 状态 + expect(bloc.state, isA()); + + // 现在单独测试 ClassLoadDiaryWall 过滤逻辑 + final journals = [ + _makeJournal(id: 'j1', sharedToClass: true), + _makeJournal(id: 'j2', sharedToClass: false), + _makeJournal(id: 'j3', sharedToClass: true), + ]; + when(() => mockJournalRepo.getJournals(classId: 'class-1')).thenAnswer((_) async => journals); + + final state = await dispatch(const ClassLoadDiaryWall('class-1')); + + expect(state, isA()); + final detail = state as ClassDetailLoaded; + expect(detail.diaryWall.length, 2); + expect(detail.diaryWall.every((j) => j.sharedToClass), isTrue); + }); + }); + + // ===== 创建班级 ===== + + group('ClassCreate', () { + test('成功创建班级并添加到列表', () async { + // 先加载列表 + when(() => mockClassRepo.getMyClasses()).thenAnswer((_) async => [_makeClass(id: 'c1')]); + await dispatch(const ClassLoadMyClasses()); + + // 创建新班级 + final newClass = _makeClass(id: 'c2', name: '新班级'); + when(() => mockClassRepo.createClass(name: '新班级')).thenAnswer((_) async => newClass); + + final state = await dispatch(const ClassCreate(name: '新班级')); + + expect(state, isA()); + final loaded = state as ClassListLoaded; + expect(loaded.classes.length, 2); + expect(loaded.classes.last.id, 'c2'); + }); + + test('创建失败设置 error', () async { + when(() => mockClassRepo.getMyClasses()).thenAnswer((_) async => []); + await dispatch(const ClassLoadMyClasses()); + + when(() => mockClassRepo.createClass(name: '失败')).thenThrow(Exception('创建失败')); + + final state = await dispatch(const ClassCreate(name: '失败')); + + expect(state, isA()); + expect((state as ClassListLoaded).error, isNotNull); + }); + }); + + // ===== 加入班级 ===== + + group('ClassJoin', () { + test('加入成功后触发列表刷新', () async { + when(() => mockClassRepo.joinClass('ABC123')).thenAnswer((_) async => _makeClass()); + when(() => mockClassRepo.getMyClasses()).thenAnswer((_) async => [_makeClass()]); + + await dispatch(const ClassJoin(classCode: 'ABC123')); + + // 应该触发 ClassLoadMyClasses,最终状态为 ClassListLoaded + expect(bloc.state, isA()); + verify(() => mockClassRepo.joinClass('ABC123')).called(1); + verify(() => mockClassRepo.getMyClasses()).called(1); + }); + + test('加入失败返回 ClassError', () async { + when(() => mockClassRepo.joinClass('INVALID')).thenThrow(Exception('班级码错误')); + + final state = await dispatch(const ClassJoin(classCode: 'INVALID')); + + expect(state, isA()); + expect((state as ClassError).message, contains('加入班级失败')); + }); + }); + + // ===== 布置主题 ===== + + group('TopicAssign', () { + test('成功布置主题并添加到列表', () async { + // 进入 ClassDetailLoaded + when(() => mockClassRepo.getClass('class-1')).thenAnswer((_) async => _makeClass()); + when(() => mockJournalRepo.getJournals(classId: 'class-1')).thenAnswer((_) async => []); + when(() => mockClassRepo.getMembers('class-1')).thenAnswer((_) async => []); + when(() => mockClassRepo.getTopics('class-1')).thenAnswer((_) async => []); + await dispatch(const ClassSelected('class-1')); + + final newTopic = _makeTopic(id: 'topic-2', title: '新主题'); + when(() => mockClassRepo.assignTopic(classId: 'class-1', title: '新主题')) + .thenAnswer((_) async => newTopic); + + final state = await dispatch(const TopicAssign(classId: 'class-1', title: '新主题')); + + expect(state, isA()); + final detail = state as ClassDetailLoaded; + expect(detail.topics.length, 1); + expect(detail.topics.first.title, '新主题'); + }); + }); + + // ===== 评语 ===== + + group('ClassLoadComments', () { + test('加载评语并设置 selectedJournalId', () async { + // 进入 ClassDetailLoaded + when(() => mockClassRepo.getClass('class-1')).thenAnswer((_) async => _makeClass()); + when(() => mockJournalRepo.getJournals(classId: 'class-1')).thenAnswer((_) async => []); + when(() => mockClassRepo.getMembers('class-1')).thenAnswer((_) async => []); + when(() => mockClassRepo.getTopics('class-1')).thenAnswer((_) async => []); + await dispatch(const ClassSelected('class-1')); + + final comments = [_makeComment(), _makeComment(id: 'comment-2', content: '继续加油')]; + when(() => mockClassRepo.getComments('j-1')).thenAnswer((_) async => comments); + + final state = await dispatch(const ClassLoadComments('j-1')); + + expect(state, isA()); + final detail = state as ClassDetailLoaded; + expect(detail.comments.length, 2); + expect(detail.selectedJournalId, 'j-1'); + }); + }); +} diff --git a/app/test/features/search/bloc/search_bloc_test.dart b/app/test/features/search/bloc/search_bloc_test.dart new file mode 100644 index 0000000..6288ff9 --- /dev/null +++ b/app/test/features/search/bloc/search_bloc_test.dart @@ -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 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); + }); + }); +}