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,197 @@
// MoodBloc 单元测试
//
// 覆盖心情统计加载、周期切换、API 解析、错误处理
// 使用 mocktail mock ApiClient
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:nuanji_app/data/models/journal_entry.dart';
import 'package:nuanji_app/data/remote/api_client.dart';
import 'package:nuanji_app/features/mood/bloc/mood_bloc.dart';
// ===== Mock =====
class MockApiClient extends Mock implements ApiClient {}
// ===== 测试数据 =====
/// 模拟 API 返回的心情统计数据
Map<String, dynamic> _mockResponseBody({
List<Map<String, dynamic>>? moodCounts,
int streakDays = 5,
int totalJournals = 12,
String? dominantMood = 'happy',
}) {
return {
'data': {
'mood_counts': moodCounts ??
[
{'mood': 'happy', 'count': 8, 'percentage': 66.7},
{'mood': 'calm', 'count': 3, 'percentage': 25.0},
{'mood': 'sad', 'count': 1, 'percentage': 8.3},
],
'streak_days': streakDays,
'total_journals': totalJournals,
'dominant_mood': dominantMood,
},
};
}
void main() {
late MockApiClient mockApi;
late MoodBloc bloc;
setUpAll(() {
registerFallbackValue(Response(requestOptions: RequestOptions()));
});
setUp(() {
mockApi = MockApiClient();
bloc = MoodBloc(api: mockApi);
});
tearDown(() {
bloc.dispose();
});
// ===== 辅助 =====
/// 配置 mock API.get 返回指定响应体
void _stubGet(Map<String, dynamic> body) {
when(() => mockApi.get(any(), queryParams: any(named: 'queryParams')))
.thenAnswer((_) async => Response(
requestOptions: RequestOptions(path: ''),
data: body,
));
}
/// 配置 mock API.get 抛出异常
void _stubGetError(Object error) {
when(() => mockApi.get(any(), queryParams: any(named: 'queryParams')))
.thenThrow(error);
}
/// 等待异步 _loadStats 完成
Future<void> _waitForAsync() =>
Future<void>.delayed(const Duration(milliseconds: 50));
// ===== 1. load() 设置 isLoading = true =====
test('load() 立即设置 isLoading = true', () {
// 让 API 永不返回,这样 isLoading 会一直保持 true
when(() => mockApi.get(any(), queryParams: any(named: 'queryParams')))
.thenAnswer((_) async {
await Future<void>.delayed(const Duration(seconds: 10));
return Response(requestOptions: RequestOptions(path: ''), data: {});
});
bloc.load();
expect(bloc.state.isLoading, isTrue);
});
// ===== 2. load() 成功 → isLoading = false, stats 有数据 =====
test('load() 成功后 isLoading = false 且 stats 包含数据', () async {
_stubGet(_mockResponseBody());
bloc.load();
await _waitForAsync();
expect(bloc.state.isLoading, isFalse);
expect(bloc.state.errorMessage, isNull);
expect(bloc.state.stats.streakDays, 5);
expect(bloc.state.stats.totalJournals, 12);
});
// ===== 3. load() 成功 → moodCounts 解析正确 =====
test('load() 成功后 moodCounts 解析正确', () async {
_stubGet(_mockResponseBody());
bloc.load();
await _waitForAsync();
final counts = bloc.state.stats.moodCounts;
expect(counts.length, 3);
expect(counts[0].mood, Mood.happy);
expect(counts[0].count, 8);
expect(counts[0].percentage, 66.7);
expect(counts[1].mood, Mood.calm);
expect(counts[1].count, 3);
expect(counts[1].percentage, 25.0);
expect(counts[2].mood, Mood.sad);
expect(counts[2].count, 1);
expect(counts[2].percentage, 8.3);
});
// ===== 4. load() 成功 → dominantMood 解析正确 =====
test('load() 成功后 dominantMood 解析正确', () async {
_stubGet(_mockResponseBody(dominantMood: 'calm'));
bloc.load();
await _waitForAsync();
expect(bloc.state.stats.dominantMood, Mood.calm);
});
test('load() dominantMood 为 null 时不崩溃', () async {
_stubGet(_mockResponseBody(dominantMood: null));
bloc.load();
await _waitForAsync();
expect(bloc.state.stats.dominantMood, isNull);
expect(bloc.state.errorMessage, isNull);
});
// ===== 5. load() 失败 → errorMessage = '加载统计数据失败' =====
test('load() API 失败时设置 errorMessage', () async {
_stubGetError(Exception('网络错误'));
bloc.load();
await _waitForAsync();
expect(bloc.state.isLoading, isFalse);
expect(bloc.state.errorMessage, '加载统计数据失败');
});
// ===== 6. changePeriod 更新周期并触发加载 =====
test('changePeriod 更新 selectedPeriod 并触发重新加载', () async {
_stubGet(_mockResponseBody(streakDays: 10));
bloc.changePeriod(StatsPeriod.month);
await _waitForAsync();
expect(bloc.state.selectedPeriod, StatsPeriod.month);
expect(bloc.state.isLoading, isFalse);
expect(bloc.state.stats.streakDays, 10);
});
// ===== 7. changePeriod 相同周期 → 不改变状态no-op=====
test('changePeriod 相同周期时不改变状态', () async {
// 先完成一次正常加载
_stubGet(_mockResponseBody(streakDays: 3));
bloc.load();
await _waitForAsync();
final stateBefore = bloc.state;
expect(stateBefore.selectedPeriod, StatsPeriod.week);
// 再次调用 changePeriod(week) — 应该是 no-op
bloc.changePeriod(StatsPeriod.week);
// 不等待异步,因为不应有新请求发出
expect(bloc.state.selectedPeriod, StatsPeriod.week);
// state 应该完全不变(同一个对象引用)
expect(identical(bloc.state, stateBefore), isTrue);
});
}