// 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 dispatch(HomeEvent event) async { bloc.add(event); await Future.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 _seed(List entries) async { for (final e in entries) { await repo.createJournal(e); } } // ===== 1. HomeLoadData 成功 → HomeLoading → HomeLoaded with journals ===== test('HomeLoadData 成功时状态经过 HomeLoading 到达 HomeLoaded', () async { final states = []; final subscription = bloc.stream.listen(states.add); await repo.createJournal(_makeEntry( id: 'j-1', date: _today(), )); bloc.add(const HomeLoadData()); await Future.delayed(const Duration(milliseconds: 50)); subscription.cancel(); // 至少经历 HomeLoading → HomeLoaded expect(states, contains(isA())); expect(states.last, isA()); 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()); 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 = []; final subscription = bloc.stream.listen(states.add); await repo.createJournal(_makeEntry( id: 'j-refresh', date: _today(), )); bloc.add(const HomeRefresh()); await Future.delayed(const Duration(milliseconds: 50)); subscription.cancel(); // HomeRefresh 内部 add(HomeLoadData),所以应有 Loading → Loaded expect(states, contains(isA())); expect(states.last, isA()); 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> getJournals({ DateTime? dateFrom, DateTime? dateTo, int? page, int? pageSize, String? mood, String? tag, String? classId, }) async { throw Exception('网络不可用'); } @override Future getJournalCount() async { throw Exception('网络不可用'); } @override Future getJournal(String id) async { throw UnimplementedError(); } @override Future createJournal(JournalEntry entry) async { throw UnimplementedError(); } @override Future updateJournal(JournalEntry entry) async { throw UnimplementedError(); } @override Future deleteJournal(String id) async { throw UnimplementedError(); } @override Future> getElements(String journalId) async { throw UnimplementedError(); } @override Future addElement(JournalElement element) async { throw UnimplementedError(); } @override Future updateElement(JournalElement element) async { throw UnimplementedError(); } @override Future removeElement(String elementId) async { throw UnimplementedError(); } } /// 在指定 bloc 上触发事件并等待处理完毕 Future _dispatchOn(HomeBloc bloc, HomeEvent event) async { bloc.add(event); await Future.delayed(const Duration(milliseconds: 50)); return bloc.state; }