// 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'); }); }); }