test(app): ClassBloc + SearchBloc 单元测试 — 33 个测试全覆盖

ClassBloc (13 tests):
- 班级列表加载(成功/失败/loading 状态)
- 选中班级详情 + 子事件触发
- 成员/日记墙/主题/评语加载
- 创建班级 + 错误处理
- 加入班级 + 列表刷新
- 布置主题
- sharedToClass 过滤逻辑

SearchBloc (20 tests):
- 关键词搜索(标题/摘要/标签匹配、大小写不敏感)
- 心情筛选(null/有值/失败)
- 标签搜索
- 搜索历史(记录/去重)
- 清除搜索 + Tab 切换
- hasActiveFilter 属性
This commit is contained in:
iven
2026-06-03 19:03:29 +08:00
parent f6d394afb6
commit 9c92cba87f
2 changed files with 665 additions and 0 deletions

View File

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

View File

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