Files
nj/app/test/features/class_/bloc/class_bloc_test.dart
iven 9c92cba87f test(app): ClassBloc + SearchBloc 单元测试 — 33 个测试全覆盖
ClassBloc (13 tests):
- 班级列表加载(成功/失败/loading 状态)
- 选中班级详情 + 子事件触发
- 成员/日记墙/主题/评语加载
- 创建班级 + 错误处理
- 加入班级 + 列表刷新
- 布置主题
- sharedToClass 过滤逻辑

SearchBloc (20 tests):
- 关键词搜索(标题/摘要/标签匹配、大小写不敏感)
- 心情筛选(null/有值/失败)
- 标签搜索
- 搜索历史(记录/去重)
- 清除搜索 + Tab 切换
- hasActiveFilter 属性
2026-06-03 19:03:29 +08:00

373 lines
13 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<ClassState> dispatch(ClassEvent event) async {
bloc.add(event);
// 等待所有异步事件处理完毕
await Future<void>.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<ClassListLoaded>());
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<ClassListLoaded>());
final loaded = state as ClassListLoaded;
expect(loaded.classes, isEmpty);
expect(loaded.isLoading, false);
});
test('加载中状态先触发', () async {
when(() => mockClassRepo.getMyClasses()).thenAnswer((_) async {
await Future<void>.delayed(const Duration(milliseconds: 100));
return [_makeClass()];
});
bloc.add(const ClassLoadMyClasses());
// 立即检查,应该在 loading 状态
await Future<void>.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<ClassDetailLoaded>());
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<ClassError>());
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<ClassDetailLoaded>());
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<ClassInitial>());
});
});
// ===== 日记墙 =====
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<void>.delayed(const Duration(milliseconds: 150));
// 此时应该已经在 ClassDetailLoaded 状态
expect(bloc.state, isA<ClassDetailLoaded>());
// 现在单独测试 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<ClassDetailLoaded>());
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<ClassListLoaded>());
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<ClassListLoaded>());
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<ClassListLoaded>());
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<ClassError>());
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<ClassDetailLoaded>());
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<ClassDetailLoaded>());
final detail = state as ClassDetailLoaded;
expect(detail.comments.length, 2);
expect(detail.selectedJournalId, 'j-1');
});
});
}