feat(test): Week 3 质量保障体系 — 55 新增测试 + CI/CD 流水线
前端第二批测试 (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:
197
app/test/features/mood/bloc/mood_bloc_test.dart
Normal file
197
app/test/features/mood/bloc/mood_bloc_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user