Files
nj/app/test/features/mood/bloc/mood_bloc_test.dart
iven ffde0c9e77
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
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 通过 (全部通过)
2026-06-01 23:20:18 +08:00

198 lines
5.5 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);
});
}