前端第二批测试 (42 用例): - AuthBloc: 16 用例 (启动恢复/登录/注册/角色选择/班级码/登出) - HomeBloc: 8 用例 (数据加载/今日检测/心情统计/连续天数/离线容错) - CalendarBloc: 10 用例 (月份切换/日期选择/视图模式/状态保持) - MoodBloc: 8 用例 (统计加载/周期切换/API解析/错误处理) 后端 P0 单元测试 (13 用例): - journal_service: 5 用例 (model_to_resp 转换/mood回退/weather回退/tags解析) - sync_service: 8 用例 (冲突收集/DTO构造/序列化roundtrip/非冲突排除) CI/CD: - pr-check.yml: PR 触发 cargo fmt+check+clippy+test + flutter analyze+test - main-merge.yml: main push 触发完整检查 + cargo audit 安全审计 测试统计: 前端 84 通过, 后端 73 通过 (全部通过)
274 lines
8.0 KiB
Dart
274 lines
8.0 KiB
Dart
// HomeBloc 单元测试
|
||
//
|
||
// 覆盖:首页数据加载、今日日记检查、心情统计、连续天数计算、离线容错
|
||
// 使用 InMemoryJournalRepository 作为测试替身
|
||
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:nuanji_app/data/models/journal_element.dart';
|
||
import 'package:nuanji_app/data/models/journal_entry.dart';
|
||
import 'package:nuanji_app/data/repositories/journal_repository.dart';
|
||
import 'package:nuanji_app/features/home/bloc/home_bloc.dart';
|
||
|
||
void main() {
|
||
group('HomeBloc', () {
|
||
late HomeBloc bloc;
|
||
late InMemoryJournalRepository repo;
|
||
|
||
setUp(() {
|
||
repo = InMemoryJournalRepository();
|
||
bloc = HomeBloc(journalRepository: repo);
|
||
});
|
||
|
||
tearDown(() {
|
||
bloc.close();
|
||
});
|
||
|
||
// ===== 辅助 =====
|
||
|
||
/// 收集事件触发后的最终状态
|
||
Future<HomeState> dispatch(HomeEvent event) async {
|
||
bloc.add(event);
|
||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||
return bloc.state;
|
||
}
|
||
|
||
/// 创建测试日记条目
|
||
JournalEntry _makeEntry({
|
||
required String id,
|
||
required DateTime date,
|
||
Mood mood = Mood.happy,
|
||
}) {
|
||
return JournalEntry(
|
||
id: id,
|
||
authorId: 'user-1',
|
||
title: '测试日记',
|
||
date: date,
|
||
mood: mood,
|
||
createdAt: date,
|
||
updatedAt: date,
|
||
);
|
||
}
|
||
|
||
/// 获取当天零时的 DateTime
|
||
DateTime _today() {
|
||
final now = DateTime.now();
|
||
return DateTime(now.year, now.month, now.day);
|
||
}
|
||
|
||
/// 往前推 n 天的日期
|
||
DateTime _daysAgo(int n) {
|
||
return _today().subtract(Duration(days: n));
|
||
}
|
||
|
||
/// 向仓库批量添加日记
|
||
Future<void> _seed(List<JournalEntry> entries) async {
|
||
for (final e in entries) {
|
||
await repo.createJournal(e);
|
||
}
|
||
}
|
||
|
||
// ===== 1. HomeLoadData 成功 → HomeLoading → HomeLoaded with journals =====
|
||
|
||
test('HomeLoadData 成功时状态经过 HomeLoading 到达 HomeLoaded', () async {
|
||
final states = <HomeState>[];
|
||
final subscription = bloc.stream.listen(states.add);
|
||
|
||
await repo.createJournal(_makeEntry(
|
||
id: 'j-1',
|
||
date: _today(),
|
||
));
|
||
|
||
bloc.add(const HomeLoadData());
|
||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||
|
||
subscription.cancel();
|
||
|
||
// 至少经历 HomeLoading → HomeLoaded
|
||
expect(states, contains(isA<HomeLoading>()));
|
||
expect(states.last, isA<HomeLoaded>());
|
||
|
||
final loaded = states.last as HomeLoaded;
|
||
expect(loaded.recentJournals.length, 1);
|
||
expect(loaded.recentJournals.first.id, 'j-1');
|
||
});
|
||
|
||
// ===== 2. HomeLoadData 有今日日记 → hasTodayEntry = true =====
|
||
|
||
test('HomeLoadData 有今日日记 → hasTodayEntry = true', () async {
|
||
await _seed([
|
||
_makeEntry(id: 'j-today', date: _today()),
|
||
]);
|
||
|
||
final state = await dispatch(const HomeLoadData());
|
||
final loaded = state as HomeLoaded;
|
||
|
||
expect(loaded.hasTodayEntry, isTrue);
|
||
});
|
||
|
||
// ===== 3. HomeLoadData 无今日日记 → hasTodayEntry = false =====
|
||
|
||
test('HomeLoadData 无今日日记 → hasTodayEntry = false', () async {
|
||
await _seed([
|
||
_makeEntry(id: 'j-yesterday', date: _daysAgo(1)),
|
||
]);
|
||
|
||
final state = await dispatch(const HomeLoadData());
|
||
final loaded = state as HomeLoaded;
|
||
|
||
expect(loaded.hasTodayEntry, isFalse);
|
||
});
|
||
|
||
// ===== 4. HomeLoadData 计算最常用心情 topMood → happy(3次) vs calm(1次) =====
|
||
|
||
test('HomeLoadData topMood 为出现次数最多的心情', () async {
|
||
await _seed([
|
||
_makeEntry(id: 'j-1', date: _daysAgo(0), mood: Mood.happy),
|
||
_makeEntry(id: 'j-2', date: _daysAgo(1), mood: Mood.happy),
|
||
_makeEntry(id: 'j-3', date: _daysAgo(2), mood: Mood.happy),
|
||
_makeEntry(id: 'j-4', date: _daysAgo(3), mood: Mood.calm),
|
||
]);
|
||
|
||
final state = await dispatch(const HomeLoadData());
|
||
final loaded = state as HomeLoaded;
|
||
|
||
expect(loaded.topMood, Mood.happy);
|
||
});
|
||
|
||
// ===== 5. HomeLoadData 计算连续天数 streakDays(连续 3 天)=====
|
||
|
||
test('HomeLoadData streakDays 计算从今天开始的连续天数', () async {
|
||
// 今天、昨天、前天 — 连续 3 天
|
||
await _seed([
|
||
_makeEntry(id: 'j-today', date: _daysAgo(0)),
|
||
_makeEntry(id: 'j-yesterday', date: _daysAgo(1)),
|
||
_makeEntry(id: 'j-day-before', date: _daysAgo(2)),
|
||
// 4 天前(中断连续)
|
||
_makeEntry(id: 'j-old', date: _daysAgo(4)),
|
||
]);
|
||
|
||
final state = await dispatch(const HomeLoadData());
|
||
final loaded = state as HomeLoaded;
|
||
|
||
expect(loaded.streakDays, 3);
|
||
});
|
||
|
||
// ===== 6. HomeLoadData 失败 → HomeLoaded (空数据,离线友好) =====
|
||
|
||
test('HomeLoadData 失败时返回空 HomeLoaded(离线友好)', () async {
|
||
// 使用一个会在 getJournals 时抛异常的仓库
|
||
final failingRepo = _FailingJournalRepository();
|
||
final failingBloc = HomeBloc(journalRepository: failingRepo);
|
||
|
||
final state = await _dispatchOn(failingBloc, const HomeLoadData());
|
||
|
||
expect(state, isA<HomeLoaded>());
|
||
final loaded = state as HomeLoaded;
|
||
expect(loaded.recentJournals, isEmpty);
|
||
expect(loaded.hasTodayEntry, isFalse);
|
||
expect(loaded.topMood, isNull);
|
||
expect(loaded.streakDays, 0);
|
||
|
||
failingBloc.close();
|
||
});
|
||
|
||
// ===== 7. HomeRefresh → 触发 HomeLoadData =====
|
||
|
||
test('HomeRefresh 触发重新加载(经过 Loading → Loaded)', () async {
|
||
final states = <HomeState>[];
|
||
final subscription = bloc.stream.listen(states.add);
|
||
|
||
await repo.createJournal(_makeEntry(
|
||
id: 'j-refresh',
|
||
date: _today(),
|
||
));
|
||
|
||
bloc.add(const HomeRefresh());
|
||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||
|
||
subscription.cancel();
|
||
|
||
// HomeRefresh 内部 add(HomeLoadData),所以应有 Loading → Loaded
|
||
expect(states, contains(isA<HomeLoading>()));
|
||
expect(states.last, isA<HomeLoaded>());
|
||
|
||
final loaded = states.last as HomeLoaded;
|
||
expect(loaded.recentJournals.length, 1);
|
||
});
|
||
|
||
// ===== 8. HomeLoadData 空数据 → HomeLoaded with defaults =====
|
||
|
||
test('HomeLoadData 空数据时返回默认 HomeLoaded', () async {
|
||
// 仓库中不添加任何日记
|
||
final state = await dispatch(const HomeLoadData());
|
||
final loaded = state as HomeLoaded;
|
||
|
||
expect(loaded.recentJournals, isEmpty);
|
||
expect(loaded.hasTodayEntry, isFalse);
|
||
expect(loaded.topMood, isNull);
|
||
expect(loaded.streakDays, 0);
|
||
});
|
||
});
|
||
}
|
||
|
||
// ===== 测试辅助:总是抛出异常的仓库 =====
|
||
|
||
/// 模拟仓库失败场景,用于测试离线容错
|
||
class _FailingJournalRepository implements JournalRepository {
|
||
@override
|
||
Future<List<JournalEntry>> getJournals({
|
||
DateTime? dateFrom,
|
||
DateTime? dateTo,
|
||
int? page,
|
||
int? pageSize,
|
||
}) async {
|
||
throw Exception('网络不可用');
|
||
}
|
||
|
||
@override
|
||
Future<JournalEntry?> getJournal(String id) async {
|
||
throw UnimplementedError();
|
||
}
|
||
|
||
@override
|
||
Future<JournalEntry> createJournal(JournalEntry entry) async {
|
||
throw UnimplementedError();
|
||
}
|
||
|
||
@override
|
||
Future<JournalEntry> updateJournal(JournalEntry entry) async {
|
||
throw UnimplementedError();
|
||
}
|
||
|
||
@override
|
||
Future<void> deleteJournal(String id) async {
|
||
throw UnimplementedError();
|
||
}
|
||
|
||
@override
|
||
Future<List<JournalElement>> getElements(String journalId) async {
|
||
throw UnimplementedError();
|
||
}
|
||
|
||
@override
|
||
Future<JournalElement> addElement(JournalElement element) async {
|
||
throw UnimplementedError();
|
||
}
|
||
|
||
@override
|
||
Future<JournalElement> updateElement(JournalElement element) async {
|
||
throw UnimplementedError();
|
||
}
|
||
|
||
@override
|
||
Future<void> removeElement(String elementId) async {
|
||
throw UnimplementedError();
|
||
}
|
||
}
|
||
|
||
/// 在指定 bloc 上触发事件并等待处理完毕
|
||
Future<HomeState> _dispatchOn(HomeBloc bloc, HomeEvent event) async {
|
||
bloc.add(event);
|
||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||
return bloc.state;
|
||
}
|