Files
nj/app/test/features/home/bloc/home_bloc_test.dart
iven ffde0c9e77
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
feat(test): Week 3 质量保障体系 — 55 新增测试 + CI/CD 流水线
前端第二批测试 (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 通过 (全部通过)
2026-06-01 23:20:18 +08:00

274 lines
8.0 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.
// 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;
}