前端第二批测试 (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 通过 (全部通过)
198 lines
5.5 KiB
Dart
198 lines
5.5 KiB
Dart
// 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);
|
||
});
|
||
}
|