前端第二批测试 (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 通过 (全部通过)
231 lines
8.1 KiB
Dart
231 lines
8.1 KiB
Dart
// 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<CalendarState> dispatch(CalendarEvent event) async {
|
||
bloc.add(event);
|
||
await Future<void>.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<CalendarInitial>());
|
||
});
|
||
|
||
// ===== 月份切换 =====
|
||
|
||
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<CalendarLoaded>());
|
||
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<void>.delayed(const Duration(milliseconds: 50));
|
||
|
||
// 此时状态应为 CalendarLoaded(isLoading: false,因为加载失败)
|
||
expect(failingBloc.state, isA<CalendarLoaded>());
|
||
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<CalendarLoaded>());
|
||
|
||
// 切换视图模式为 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<CalendarInitial>());
|
||
|
||
final state = await dispatch(CalendarDaySelected(DateTime(2026, 6, 15)));
|
||
// 状态应保持为 CalendarInitial
|
||
expect(state, isA<CalendarInitial>());
|
||
});
|
||
|
||
// ===== 视图模式 =====
|
||
|
||
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<CalendarInitial>());
|
||
|
||
final state = await dispatch(CalendarViewModeChanged(CalendarViewMode.week));
|
||
// 状态应保持为 CalendarInitial
|
||
expect(state, isA<CalendarInitial>());
|
||
});
|
||
|
||
// ===== 日期键归一化 =====
|
||
|
||
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<List<JournalEntry>> getJournals({
|
||
DateTime? dateFrom,
|
||
DateTime? dateTo,
|
||
int? page,
|
||
int? pageSize,
|
||
}) async {
|
||
throw Exception('模拟网络错误');
|
||
}
|
||
|
||
@override
|
||
Future<JournalEntry?> getJournal(String id) async => null;
|
||
|
||
@override
|
||
Future<JournalEntry> createJournal(JournalEntry entry) async => entry;
|
||
|
||
@override
|
||
Future<JournalEntry> updateJournal(JournalEntry entry) async => entry;
|
||
|
||
@override
|
||
Future<void> deleteJournal(String id) async {}
|
||
|
||
@override
|
||
Future<List<JournalElement>> getElements(String journalId) async => [];
|
||
|
||
@override
|
||
Future<JournalElement> addElement(JournalElement element) async => element;
|
||
|
||
@override
|
||
Future<JournalElement> updateElement(JournalElement element) async => element;
|
||
|
||
@override
|
||
Future<void> removeElement(String elementId) async {}
|
||
}
|