ClassBloc (13 tests): - 班级列表加载(成功/失败/loading 状态) - 选中班级详情 + 子事件触发 - 成员/日记墙/主题/评语加载 - 创建班级 + 错误处理 - 加入班级 + 列表刷新 - 布置主题 - sharedToClass 过滤逻辑 SearchBloc (20 tests): - 关键词搜索(标题/摘要/标签匹配、大小写不敏感) - 心情筛选(null/有值/失败) - 标签搜索 - 搜索历史(记录/去重) - 清除搜索 + Tab 切换 - hasActiveFilter 属性
373 lines
13 KiB
Dart
373 lines
13 KiB
Dart
// 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');
|
||
});
|
||
});
|
||
}
|