feat(test): Week 3 质量保障体系 — 55 新增测试 + CI/CD 流水线
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled

前端第二批测试 (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 通过 (全部通过)
This commit is contained in:
iven
2026-06-01 23:20:18 +08:00
parent f0921d554c
commit ffde0c9e77
8 changed files with 1460 additions and 0 deletions

View File

@@ -0,0 +1,230 @@
// 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));
// 此时状态应为 CalendarLoadedisLoading: 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 {}
}