// CalendarBloc 单元测试 // // 覆盖:月份切换加载、日期选择、视图模式切换、状态保持 // 使用 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/calendar/bloc/calendar_bloc.dart'; void main() { group('CalendarBloc', () { late CalendarBloc bloc; late InMemoryJournalRepository repo; setUp(() { repo = InMemoryJournalRepository(); bloc = CalendarBloc(journalRepository: repo); }); tearDown(() { bloc.close(); }); // ===== 辅助 ===== /// 收集事件触发后的最终状态 Future dispatch(CalendarEvent event) async { bloc.add(event); await Future.delayed(const Duration(milliseconds: 50)); return bloc.state; } /// 创建测试日记条目 /// /// 日期放在月份中间(15 号),避免 InMemoryJournalRepository /// 使用 isAfter/isBefore(严格大于/小于)时的边界问题。 JournalEntry _makeEntry({required String id, required DateTime date}) { return JournalEntry( id: id, authorId: 'user-1', title: '日记', date: date, createdAt: date, updatedAt: date, ); } // ===== 初始状态 ===== test('初始状态为 CalendarInitial', () { expect(bloc.state, isA()); }); // ===== 月份切换 ===== test('CalendarMonthChanged 成功加载日记 → journalsByDate 包含数据', () async { // 在 6 月 15 日创建日记 final june15 = DateTime(2026, 6, 15); await repo.createJournal(_makeEntry(id: 'j-1', date: june15)); final state = await dispatch(CalendarMonthChanged(DateTime(2026, 6, 1))); expect(state, isA()); final loaded = state as CalendarLoaded; expect(loaded.focusedMonth, DateTime(2026, 6, 1)); expect(loaded.isLoading, isFalse); expect(loaded.journalsByDate, isNotEmpty); final key = DateTime(2026, 6, 15); expect(loaded.journalsByDate[key], isNotNull); expect(loaded.journalsByDate[key]!.length, 1); expect(loaded.journalsByDate[key]!.first.id, 'j-1'); }); test('CalendarMonthChanged 失败 → isLoading: false,保留旧数据', () async { // 使用一个会抛异常的仓库替身 final failingRepo = _FailingJournalRepository(); final failingBloc = CalendarBloc(journalRepository: failingRepo); // 先加载一次成功数据(这里直接 emit 一个 loaded 状态) failingBloc.add(CalendarMonthChanged(DateTime(2026, 6, 1))); await Future.delayed(const Duration(milliseconds: 50)); // 此时状态应为 CalendarLoaded(isLoading: false,因为加载失败) expect(failingBloc.state, isA()); final loaded = failingBloc.state as CalendarLoaded; expect(loaded.isLoading, isFalse); expect(loaded.journalsByDate, isEmpty); await failingBloc.close(); }); test('CalendarMonthChanged 保留上一个 viewMode', () async { // 先加载月份,进入 CalendarLoaded 状态 await dispatch(CalendarMonthChanged(DateTime(2026, 6, 1))); expect(bloc.state, isA()); // 切换视图模式为 week await dispatch(CalendarViewModeChanged(CalendarViewMode.week)); expect((bloc.state as CalendarLoaded).viewMode, CalendarViewMode.week); // 再切换月份,viewMode 应该保留为 week final state = await dispatch(CalendarMonthChanged(DateTime(2026, 7, 1))); final loaded = state as CalendarLoaded; expect(loaded.viewMode, CalendarViewMode.week); expect(loaded.focusedMonth, DateTime(2026, 7, 1)); }); // ===== 日期选择 ===== test('CalendarDaySelected 有日记 → selectedDayJournals 不为空', () async { final june15 = DateTime(2026, 6, 15); await repo.createJournal(_makeEntry(id: 'j-1', date: june15)); // 先加载月份 await dispatch(CalendarMonthChanged(DateTime(2026, 6, 1))); // 选择 6 月 15 日 final state = await dispatch(CalendarDaySelected(june15)); final loaded = state as CalendarLoaded; expect(loaded.selectedDay, june15); expect(loaded.selectedDayJournals, isNotEmpty); expect(loaded.selectedDayJournals.length, 1); expect(loaded.selectedDayJournals.first.id, 'j-1'); }); test('CalendarDaySelected 无日记 → selectedDayJournals 为空', () async { // 先加载月份(空数据) await dispatch(CalendarMonthChanged(DateTime(2026, 6, 1))); // 选择 6 月 10 日(无日记) final june10 = DateTime(2026, 6, 10); final state = await dispatch(CalendarDaySelected(june10)); final loaded = state as CalendarLoaded; expect(loaded.selectedDay, june10); expect(loaded.selectedDayJournals, isEmpty); }); test('CalendarDaySelected 在 CalendarInitial 状态不改变状态', () async { // 不先加载月份,初始状态为 CalendarInitial expect(bloc.state, isA()); final state = await dispatch(CalendarDaySelected(DateTime(2026, 6, 15))); // 状态应保持为 CalendarInitial expect(state, isA()); }); // ===== 视图模式 ===== test('CalendarViewModeChanged 更新 viewMode', () async { // 先进入 CalendarLoaded 状态 await dispatch(CalendarMonthChanged(DateTime(2026, 6, 1))); final state = await dispatch(CalendarViewModeChanged(CalendarViewMode.timeline)); final loaded = state as CalendarLoaded; expect(loaded.viewMode, CalendarViewMode.timeline); }); test('CalendarViewModeChanged 在 CalendarInitial 状态不改变状态', () async { expect(bloc.state, isA()); final state = await dispatch(CalendarViewModeChanged(CalendarViewMode.week)); // 状态应保持为 CalendarInitial expect(state, isA()); }); // ===== 日期键归一化 ===== test('CalendarMonthChanged 日期键归一化 — 同一天不同时间映射到同一个 key', () async { // 创建两篇日记,日期都是 6 月 15 日但时间不同 final june15Morning = DateTime(2026, 6, 15, 8, 30); final june15Afternoon = DateTime(2026, 6, 15, 14, 45); await repo.createJournal(_makeEntry(id: 'j-1', date: june15Morning)); await repo.createJournal(_makeEntry(id: 'j-2', date: june15Afternoon)); final state = await dispatch(CalendarMonthChanged(DateTime(2026, 6, 1))); final loaded = state as CalendarLoaded; // 归一化后的 key 应为 DateTime(2026, 6, 15) final normalizedKey = DateTime(2026, 6, 15); expect(loaded.journalsByDate.containsKey(normalizedKey), isTrue); // 两篇日记都映射到同一个 key expect(loaded.journalsByDate[normalizedKey]!.length, 2); expect( loaded.journalsByDate[normalizedKey]!.map((j) => j.id).toList() ..sort(), ['j-1', 'j-2'], ); }); }); } /// 加载日记时总是抛异常的仓库 — 用于测试错误路径 class _FailingJournalRepository implements JournalRepository { @override Future> getJournals({ DateTime? dateFrom, DateTime? dateTo, int? page, int? pageSize, String? mood, String? tag, }) async { throw Exception('模拟网络错误'); } @override Future getJournal(String id) async => null; @override Future createJournal(JournalEntry entry) async => entry; @override Future updateJournal(JournalEntry entry) async => entry; @override Future deleteJournal(String id) async {} @override Future> getElements(String journalId) async => []; @override Future addElement(JournalElement element) async => element; @override Future updateElement(JournalElement element) async => element; @override Future removeElement(String elementId) async {} }