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,273 @@
// 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;
}