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:
54
.github/workflows/main-merge.yml
vendored
Normal file
54
.github/workflows/main-merge.yml
vendored
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
name: Main Merge
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
backend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
components: rustfmt, clippy
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: crates → target
|
||||||
|
|
||||||
|
- name: cargo fmt
|
||||||
|
run: cargo fmt --all -- --check
|
||||||
|
|
||||||
|
- name: cargo check
|
||||||
|
run: cargo check --all-targets
|
||||||
|
|
||||||
|
- name: cargo clippy
|
||||||
|
run: cargo clippy --all-targets -- -D warnings
|
||||||
|
|
||||||
|
- name: cargo test
|
||||||
|
run: cargo test --all
|
||||||
|
|
||||||
|
- name: cargo audit # 安全审计(可选,允许失败)
|
||||||
|
run: |
|
||||||
|
cargo install cargo-audit 2>/dev/null || true
|
||||||
|
cargo audit || true
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: '3.x'
|
||||||
|
channel: 'stable'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: flutter pub get
|
||||||
|
working-directory: app
|
||||||
|
|
||||||
|
- name: flutter analyze
|
||||||
|
run: flutter analyze --no-fatal-infos
|
||||||
|
working-directory: app
|
||||||
|
|
||||||
|
- name: flutter test
|
||||||
|
run: flutter test
|
||||||
|
working-directory: app
|
||||||
56
.github/workflows/pr-check.yml
vendored
Normal file
56
.github/workflows/pr-check.yml
vendored
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
name: PR Check
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
types: [opened, synchronize, reopened]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# 后端检查
|
||||||
|
backend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
components: rustfmt, clippy
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: crates → target
|
||||||
|
|
||||||
|
- name: cargo fmt
|
||||||
|
run: cargo fmt --all -- --check
|
||||||
|
working-directory: .
|
||||||
|
|
||||||
|
- name: cargo check
|
||||||
|
run: cargo check --all-targets
|
||||||
|
working-directory: .
|
||||||
|
|
||||||
|
- name: cargo clippy
|
||||||
|
run: cargo clippy --all-targets -- -D warnings
|
||||||
|
working-directory: .
|
||||||
|
|
||||||
|
- name: cargo test
|
||||||
|
run: cargo test --all
|
||||||
|
working-directory: .
|
||||||
|
|
||||||
|
# 前端检查
|
||||||
|
frontend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: '3.x'
|
||||||
|
channel: 'stable'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: flutter pub get
|
||||||
|
working-directory: app
|
||||||
|
|
||||||
|
- name: flutter analyze
|
||||||
|
run: flutter analyze --no-fatal-infos
|
||||||
|
working-directory: app
|
||||||
|
|
||||||
|
- name: flutter test
|
||||||
|
run: flutter test
|
||||||
|
working-directory: app
|
||||||
398
app/test/features/auth/bloc/auth_bloc_test.dart
Normal file
398
app/test/features/auth/bloc/auth_bloc_test.dart
Normal file
@@ -0,0 +1,398 @@
|
|||||||
|
// AuthBloc 单元测试
|
||||||
|
//
|
||||||
|
// 覆盖:App启动恢复、登录/注册、角色选择、班级码加入、登出、认证过期
|
||||||
|
// 使用 mocktail 手动 mock AuthRepository 和 ClassRepository
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
import 'package:nuanji_app/data/models/auth_token.dart';
|
||||||
|
import 'package:nuanji_app/data/models/school_class.dart';
|
||||||
|
import 'package:nuanji_app/data/models/user.dart';
|
||||||
|
import 'package:nuanji_app/data/remote/api_client.dart';
|
||||||
|
import 'package:nuanji_app/data/repositories/auth_repository.dart';
|
||||||
|
import 'package:nuanji_app/data/repositories/class_repository.dart';
|
||||||
|
import 'package:nuanji_app/features/auth/bloc/auth_bloc.dart';
|
||||||
|
|
||||||
|
// ===== Mock 类 =====
|
||||||
|
|
||||||
|
class MockAuthRepository extends Mock implements AuthRepository {}
|
||||||
|
|
||||||
|
class MockClassRepository extends Mock implements ClassRepository {}
|
||||||
|
|
||||||
|
// ===== 测试数据 =====
|
||||||
|
|
||||||
|
const _testUser = User(
|
||||||
|
id: 'u-001',
|
||||||
|
username: 'testuser',
|
||||||
|
displayName: '测试用户',
|
||||||
|
);
|
||||||
|
|
||||||
|
const _testUserWithRole = User(
|
||||||
|
id: 'u-002',
|
||||||
|
username: 'roleduser',
|
||||||
|
displayName: '有角色用户',
|
||||||
|
roles: [
|
||||||
|
UserRole(
|
||||||
|
id: 'r-001',
|
||||||
|
name: '学生',
|
||||||
|
code: 'student',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
final _testSchoolClass = SchoolClass(
|
||||||
|
id: 'c-001',
|
||||||
|
name: '三年级一班',
|
||||||
|
schoolName: '阳光小学',
|
||||||
|
teacherId: 't-001',
|
||||||
|
classCode: 'ABC123',
|
||||||
|
createdAt: DateTime(2026, 1, 1),
|
||||||
|
updatedAt: DateTime(2026, 1, 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
// TokenRefreshed 测试用 — DateTime 不是编译期常量,因此用 final
|
||||||
|
final _testToken = AuthToken(
|
||||||
|
accessToken: 'access-token',
|
||||||
|
refreshToken: 'refresh-token',
|
||||||
|
expiresIn: 3600,
|
||||||
|
expiresAt: DateTime.parse('2099-12-31T23:59:59Z'),
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late MockAuthRepository mockAuthRepo;
|
||||||
|
late MockClassRepository mockClassRepo;
|
||||||
|
late AuthBloc bloc;
|
||||||
|
|
||||||
|
setUpAll(() {
|
||||||
|
// 为 mocktail 的 any() 匹配器注册 String fallback 值
|
||||||
|
registerFallbackValue('');
|
||||||
|
});
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
mockAuthRepo = MockAuthRepository();
|
||||||
|
mockClassRepo = MockClassRepository();
|
||||||
|
bloc = AuthBloc(
|
||||||
|
authRepository: mockAuthRepo,
|
||||||
|
classRepository: mockClassRepo,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
bloc.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== 辅助 =====
|
||||||
|
|
||||||
|
/// 派发事件并等待 BLoC 处理完毕,返回最终状态
|
||||||
|
Future<AuthState> dispatch(AuthEvent event) async {
|
||||||
|
bloc.add(event);
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||||
|
return bloc.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 先将 BLoC 推入 Authenticated 状态,方便测试角色/班级码等后续流程
|
||||||
|
Future<void> seedAuthenticated({
|
||||||
|
User user = _testUser,
|
||||||
|
bool needsRoleSelection = false,
|
||||||
|
bool needsClassCode = false,
|
||||||
|
}) async {
|
||||||
|
bloc.emit(Authenticated(
|
||||||
|
user: user,
|
||||||
|
needsRoleSelection: needsRoleSelection,
|
||||||
|
needsClassCode: needsClassCode,
|
||||||
|
));
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== AppStarted =====
|
||||||
|
|
||||||
|
group('AppStarted', () {
|
||||||
|
test('有本地用户 → Authenticated', () async {
|
||||||
|
// arrange
|
||||||
|
when(() => mockAuthRepo.restoreAuth()).thenAnswer((_) async => _testUser);
|
||||||
|
|
||||||
|
// act
|
||||||
|
final state = await dispatch(const AppStarted());
|
||||||
|
|
||||||
|
// assert
|
||||||
|
expect(state, isA<Authenticated>());
|
||||||
|
final authenticated = state as Authenticated;
|
||||||
|
expect(authenticated.user.id, equals('u-001'));
|
||||||
|
expect(authenticated.needsRoleSelection, isTrue); // _testUser 没有 roles
|
||||||
|
verify(() => mockAuthRepo.restoreAuth()).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('无本地用户 → Unauthenticated', () async {
|
||||||
|
// arrange
|
||||||
|
when(() => mockAuthRepo.restoreAuth()).thenAnswer((_) async => null);
|
||||||
|
|
||||||
|
// act
|
||||||
|
final state = await dispatch(const AppStarted());
|
||||||
|
|
||||||
|
// assert
|
||||||
|
expect(state, isA<Unauthenticated>());
|
||||||
|
verify(() => mockAuthRepo.restoreAuth()).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restoreAuth 异常 → Unauthenticated', () async {
|
||||||
|
// arrange
|
||||||
|
when(() => mockAuthRepo.restoreAuth()).thenThrow(Exception('存储损坏'));
|
||||||
|
|
||||||
|
// act
|
||||||
|
final state = await dispatch(const AppStarted());
|
||||||
|
|
||||||
|
// assert — 异常不冒泡,而是降级为未认证
|
||||||
|
expect(state, isA<Unauthenticated>());
|
||||||
|
verify(() => mockAuthRepo.restoreAuth()).called(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== LoginRequested =====
|
||||||
|
|
||||||
|
group('LoginRequested', () {
|
||||||
|
test('成功 → Authenticating → Authenticated', () async {
|
||||||
|
// arrange
|
||||||
|
when(() => mockAuthRepo.login(
|
||||||
|
username: any(named: 'username'),
|
||||||
|
password: any(named: 'password'),
|
||||||
|
)).thenAnswer((_) async => _testUserWithRole);
|
||||||
|
|
||||||
|
// act
|
||||||
|
final state = await dispatch(
|
||||||
|
const LoginRequested(username: 'testuser', password: 'pass123'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// assert — 有角色的用户,needsRoleSelection 为 false
|
||||||
|
expect(state, isA<Authenticated>());
|
||||||
|
final authenticated = state as Authenticated;
|
||||||
|
expect(authenticated.user.id, equals('u-002'));
|
||||||
|
expect(authenticated.needsRoleSelection, isFalse);
|
||||||
|
verify(() => mockAuthRepo.login(
|
||||||
|
username: 'testuser',
|
||||||
|
password: 'pass123',
|
||||||
|
)).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AuthException → AuthError', () async {
|
||||||
|
// arrange
|
||||||
|
when(() => mockAuthRepo.login(
|
||||||
|
username: any(named: 'username'),
|
||||||
|
password: any(named: 'password'),
|
||||||
|
)).thenThrow(const AuthException('用户名或密码错误'));
|
||||||
|
|
||||||
|
// act
|
||||||
|
final state = await dispatch(
|
||||||
|
const LoginRequested(username: 'bad', password: 'wrong'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// assert
|
||||||
|
expect(state, isA<AuthError>());
|
||||||
|
final error = state as AuthError;
|
||||||
|
expect(error.message, equals('用户名或密码错误'));
|
||||||
|
expect(error.retryable, isTrue); // 默认可重试
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OfflineException → AuthError(retryable: true)', () async {
|
||||||
|
// arrange
|
||||||
|
when(() => mockAuthRepo.login(
|
||||||
|
username: any(named: 'username'),
|
||||||
|
password: any(named: 'password'),
|
||||||
|
)).thenThrow(const OfflineException());
|
||||||
|
|
||||||
|
// act
|
||||||
|
final state = await dispatch(
|
||||||
|
const LoginRequested(username: 'user', password: 'pass'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// assert
|
||||||
|
expect(state, isA<AuthError>());
|
||||||
|
final error = state as AuthError;
|
||||||
|
expect(error.message, contains('网络'));
|
||||||
|
expect(error.retryable, isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== RegisterRequested =====
|
||||||
|
|
||||||
|
group('RegisterRequested', () {
|
||||||
|
test('成功 → Authenticating(isRegister:true) → Authenticated(needsRoleSelection:true)', () async {
|
||||||
|
// arrange
|
||||||
|
when(() => mockAuthRepo.register(
|
||||||
|
username: any(named: 'username'),
|
||||||
|
password: any(named: 'password'),
|
||||||
|
displayName: any(named: 'displayName'),
|
||||||
|
)).thenAnswer((_) async => _testUser);
|
||||||
|
|
||||||
|
// act
|
||||||
|
final state = await dispatch(
|
||||||
|
const RegisterRequested(
|
||||||
|
username: 'newuser',
|
||||||
|
password: 'pass123',
|
||||||
|
displayName: '小明',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// assert — 注册后必须选角色
|
||||||
|
expect(state, isA<Authenticated>());
|
||||||
|
final authenticated = state as Authenticated;
|
||||||
|
expect(authenticated.needsRoleSelection, isTrue);
|
||||||
|
verify(() => mockAuthRepo.register(
|
||||||
|
username: 'newuser',
|
||||||
|
password: 'pass123',
|
||||||
|
displayName: '小明',
|
||||||
|
)).called(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== RoleSelected =====
|
||||||
|
|
||||||
|
group('RoleSelected', () {
|
||||||
|
test('选择 student → needsClassCode:true', () async {
|
||||||
|
// arrange — 从 Authenticated 状态开始
|
||||||
|
await seedAuthenticated();
|
||||||
|
|
||||||
|
// act
|
||||||
|
final state = await dispatch(const RoleSelected(UserRoleType.student));
|
||||||
|
|
||||||
|
// assert
|
||||||
|
expect(state, isA<Authenticated>());
|
||||||
|
final authenticated = state as Authenticated;
|
||||||
|
expect(authenticated.needsRoleSelection, isFalse);
|
||||||
|
expect(authenticated.needsClassCode, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('选择 teacher → needsClassCode:false', () async {
|
||||||
|
// arrange
|
||||||
|
await seedAuthenticated();
|
||||||
|
|
||||||
|
// act
|
||||||
|
final state = await dispatch(const RoleSelected(UserRoleType.teacher));
|
||||||
|
|
||||||
|
// assert
|
||||||
|
expect(state, isA<Authenticated>());
|
||||||
|
final authenticated = state as Authenticated;
|
||||||
|
expect(authenticated.needsRoleSelection, isFalse);
|
||||||
|
expect(authenticated.needsClassCode, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('当前非 Authenticated → 状态不变', () async {
|
||||||
|
// arrange — bloc 初始状态为 AuthInitial
|
||||||
|
expect(bloc.state, isA<AuthInitial>());
|
||||||
|
|
||||||
|
// act
|
||||||
|
final state = await dispatch(const RoleSelected(UserRoleType.student));
|
||||||
|
|
||||||
|
// assert — 状态未改变
|
||||||
|
expect(state, isA<AuthInitial>());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== ClassCodeSubmitted =====
|
||||||
|
|
||||||
|
group('ClassCodeSubmitted', () {
|
||||||
|
test('成功 → needsClassCode:false', () async {
|
||||||
|
// arrange
|
||||||
|
await seedAuthenticated(needsClassCode: true);
|
||||||
|
when(() => mockClassRepo.joinClass(any(), nickname: any(named: 'nickname')))
|
||||||
|
.thenAnswer((_) async => _testSchoolClass);
|
||||||
|
|
||||||
|
// act
|
||||||
|
final state = await dispatch(const ClassCodeSubmitted('ABC123'));
|
||||||
|
|
||||||
|
// assert
|
||||||
|
expect(state, isA<Authenticated>());
|
||||||
|
final authenticated = state as Authenticated;
|
||||||
|
expect(authenticated.needsClassCode, isFalse);
|
||||||
|
expect(authenticated.isLoading, isFalse);
|
||||||
|
expect(authenticated.classCodeError, isNull);
|
||||||
|
verify(() => mockClassRepo.joinClass('ABC123', nickname: '测试用户')).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('429 → classCodeError 包含 "30分钟"', () async {
|
||||||
|
// arrange
|
||||||
|
await seedAuthenticated(needsClassCode: true);
|
||||||
|
|
||||||
|
// 构造 429 DioException
|
||||||
|
final dioError = DioException(
|
||||||
|
requestOptions: RequestOptions(path: '/diary/classes/join'),
|
||||||
|
response: Response(
|
||||||
|
requestOptions: RequestOptions(path: '/diary/classes/join'),
|
||||||
|
statusCode: 429,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
when(() => mockClassRepo.joinClass(any(), nickname: any(named: 'nickname')))
|
||||||
|
.thenThrow(dioError);
|
||||||
|
|
||||||
|
// act
|
||||||
|
final state = await dispatch(const ClassCodeSubmitted('BADCODE'));
|
||||||
|
|
||||||
|
// assert
|
||||||
|
expect(state, isA<Authenticated>());
|
||||||
|
final authenticated = state as Authenticated;
|
||||||
|
expect(authenticated.isLoading, isFalse);
|
||||||
|
expect(authenticated.classCodeError, contains('30 分钟'));
|
||||||
|
expect(authenticated.needsClassCode, isTrue); // 仍然需要班级码
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== LogoutRequested =====
|
||||||
|
|
||||||
|
group('LogoutRequested', () {
|
||||||
|
test('登出 → Unauthenticated', () async {
|
||||||
|
// arrange
|
||||||
|
await seedAuthenticated();
|
||||||
|
when(() => mockAuthRepo.logout()).thenAnswer((_) async {});
|
||||||
|
|
||||||
|
// act
|
||||||
|
final state = await dispatch(const LogoutRequested());
|
||||||
|
|
||||||
|
// assert
|
||||||
|
expect(state, isA<Unauthenticated>());
|
||||||
|
verify(() => mockAuthRepo.logout()).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('logout 抛异常 → 仍为 Unauthenticated(错误忽略)', () async {
|
||||||
|
// arrange
|
||||||
|
await seedAuthenticated();
|
||||||
|
when(() => mockAuthRepo.logout()).thenThrow(Exception('网络错误'));
|
||||||
|
|
||||||
|
// act
|
||||||
|
final state = await dispatch(const LogoutRequested());
|
||||||
|
|
||||||
|
// assert — 即使 logout 失败,状态也变为 Unauthenticated
|
||||||
|
expect(state, isA<Unauthenticated>());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== AuthExpired =====
|
||||||
|
|
||||||
|
group('AuthExpired', () {
|
||||||
|
test('认证过期 → Unauthenticated', () async {
|
||||||
|
// arrange — 从已认证状态开始
|
||||||
|
await seedAuthenticated();
|
||||||
|
|
||||||
|
// act
|
||||||
|
final state = await dispatch(const AuthExpired());
|
||||||
|
|
||||||
|
// assert
|
||||||
|
expect(state, isA<Unauthenticated>());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== TokenRefreshed =====
|
||||||
|
|
||||||
|
group('TokenRefreshed', () {
|
||||||
|
test('令牌刷新 → 状态不变', () async {
|
||||||
|
// arrange — 从已认证状态开始
|
||||||
|
await seedAuthenticated();
|
||||||
|
final stateBefore = bloc.state;
|
||||||
|
|
||||||
|
// act
|
||||||
|
final state = await dispatch(TokenRefreshed(_testToken));
|
||||||
|
|
||||||
|
// assert — 状态不变
|
||||||
|
expect(state, equals(stateBefore));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
230
app/test/features/calendar/bloc/calendar_bloc_test.dart
Normal file
230
app/test/features/calendar/bloc/calendar_bloc_test.dart
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
// CalendarBloc 单元测试
|
||||||
|
//
|
||||||
|
// 覆盖:月份切换加载、日期选择、视图模式切换、状态保持
|
||||||
|
// 使用 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/calendar/bloc/calendar_bloc.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('CalendarBloc', () {
|
||||||
|
late CalendarBloc bloc;
|
||||||
|
late InMemoryJournalRepository repo;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
repo = InMemoryJournalRepository();
|
||||||
|
bloc = CalendarBloc(journalRepository: repo);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
bloc.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== 辅助 =====
|
||||||
|
|
||||||
|
/// 收集事件触发后的最终状态
|
||||||
|
Future<CalendarState> dispatch(CalendarEvent event) async {
|
||||||
|
bloc.add(event);
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||||
|
return bloc.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建测试日记条目
|
||||||
|
///
|
||||||
|
/// 日期放在月份中间(15 号),避免 InMemoryJournalRepository
|
||||||
|
/// 使用 isAfter/isBefore(严格大于/小于)时的边界问题。
|
||||||
|
JournalEntry _makeEntry({required String id, required DateTime date}) {
|
||||||
|
return JournalEntry(
|
||||||
|
id: id,
|
||||||
|
authorId: 'user-1',
|
||||||
|
title: '日记',
|
||||||
|
date: date,
|
||||||
|
createdAt: date,
|
||||||
|
updatedAt: date,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 初始状态 =====
|
||||||
|
|
||||||
|
test('初始状态为 CalendarInitial', () {
|
||||||
|
expect(bloc.state, isA<CalendarInitial>());
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== 月份切换 =====
|
||||||
|
|
||||||
|
test('CalendarMonthChanged 成功加载日记 → journalsByDate 包含数据', () async {
|
||||||
|
// 在 6 月 15 日创建日记
|
||||||
|
final june15 = DateTime(2026, 6, 15);
|
||||||
|
await repo.createJournal(_makeEntry(id: 'j-1', date: june15));
|
||||||
|
|
||||||
|
final state = await dispatch(CalendarMonthChanged(DateTime(2026, 6, 1)));
|
||||||
|
|
||||||
|
expect(state, isA<CalendarLoaded>());
|
||||||
|
final loaded = state as CalendarLoaded;
|
||||||
|
expect(loaded.focusedMonth, DateTime(2026, 6, 1));
|
||||||
|
expect(loaded.isLoading, isFalse);
|
||||||
|
expect(loaded.journalsByDate, isNotEmpty);
|
||||||
|
|
||||||
|
final key = DateTime(2026, 6, 15);
|
||||||
|
expect(loaded.journalsByDate[key], isNotNull);
|
||||||
|
expect(loaded.journalsByDate[key]!.length, 1);
|
||||||
|
expect(loaded.journalsByDate[key]!.first.id, 'j-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CalendarMonthChanged 失败 → isLoading: false,保留旧数据', () async {
|
||||||
|
// 使用一个会抛异常的仓库替身
|
||||||
|
final failingRepo = _FailingJournalRepository();
|
||||||
|
final failingBloc = CalendarBloc(journalRepository: failingRepo);
|
||||||
|
|
||||||
|
// 先加载一次成功数据(这里直接 emit 一个 loaded 状态)
|
||||||
|
failingBloc.add(CalendarMonthChanged(DateTime(2026, 6, 1)));
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||||
|
|
||||||
|
// 此时状态应为 CalendarLoaded(isLoading: false,因为加载失败)
|
||||||
|
expect(failingBloc.state, isA<CalendarLoaded>());
|
||||||
|
final loaded = failingBloc.state as CalendarLoaded;
|
||||||
|
expect(loaded.isLoading, isFalse);
|
||||||
|
expect(loaded.journalsByDate, isEmpty);
|
||||||
|
|
||||||
|
await failingBloc.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CalendarMonthChanged 保留上一个 viewMode', () async {
|
||||||
|
// 先加载月份,进入 CalendarLoaded 状态
|
||||||
|
await dispatch(CalendarMonthChanged(DateTime(2026, 6, 1)));
|
||||||
|
expect(bloc.state, isA<CalendarLoaded>());
|
||||||
|
|
||||||
|
// 切换视图模式为 week
|
||||||
|
await dispatch(CalendarViewModeChanged(CalendarViewMode.week));
|
||||||
|
expect((bloc.state as CalendarLoaded).viewMode, CalendarViewMode.week);
|
||||||
|
|
||||||
|
// 再切换月份,viewMode 应该保留为 week
|
||||||
|
final state = await dispatch(CalendarMonthChanged(DateTime(2026, 7, 1)));
|
||||||
|
final loaded = state as CalendarLoaded;
|
||||||
|
expect(loaded.viewMode, CalendarViewMode.week);
|
||||||
|
expect(loaded.focusedMonth, DateTime(2026, 7, 1));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== 日期选择 =====
|
||||||
|
|
||||||
|
test('CalendarDaySelected 有日记 → selectedDayJournals 不为空', () async {
|
||||||
|
final june15 = DateTime(2026, 6, 15);
|
||||||
|
await repo.createJournal(_makeEntry(id: 'j-1', date: june15));
|
||||||
|
|
||||||
|
// 先加载月份
|
||||||
|
await dispatch(CalendarMonthChanged(DateTime(2026, 6, 1)));
|
||||||
|
|
||||||
|
// 选择 6 月 15 日
|
||||||
|
final state = await dispatch(CalendarDaySelected(june15));
|
||||||
|
final loaded = state as CalendarLoaded;
|
||||||
|
expect(loaded.selectedDay, june15);
|
||||||
|
expect(loaded.selectedDayJournals, isNotEmpty);
|
||||||
|
expect(loaded.selectedDayJournals.length, 1);
|
||||||
|
expect(loaded.selectedDayJournals.first.id, 'j-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CalendarDaySelected 无日记 → selectedDayJournals 为空', () async {
|
||||||
|
// 先加载月份(空数据)
|
||||||
|
await dispatch(CalendarMonthChanged(DateTime(2026, 6, 1)));
|
||||||
|
|
||||||
|
// 选择 6 月 10 日(无日记)
|
||||||
|
final june10 = DateTime(2026, 6, 10);
|
||||||
|
final state = await dispatch(CalendarDaySelected(june10));
|
||||||
|
final loaded = state as CalendarLoaded;
|
||||||
|
expect(loaded.selectedDay, june10);
|
||||||
|
expect(loaded.selectedDayJournals, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CalendarDaySelected 在 CalendarInitial 状态不改变状态', () async {
|
||||||
|
// 不先加载月份,初始状态为 CalendarInitial
|
||||||
|
expect(bloc.state, isA<CalendarInitial>());
|
||||||
|
|
||||||
|
final state = await dispatch(CalendarDaySelected(DateTime(2026, 6, 15)));
|
||||||
|
// 状态应保持为 CalendarInitial
|
||||||
|
expect(state, isA<CalendarInitial>());
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== 视图模式 =====
|
||||||
|
|
||||||
|
test('CalendarViewModeChanged 更新 viewMode', () async {
|
||||||
|
// 先进入 CalendarLoaded 状态
|
||||||
|
await dispatch(CalendarMonthChanged(DateTime(2026, 6, 1)));
|
||||||
|
|
||||||
|
final state = await dispatch(CalendarViewModeChanged(CalendarViewMode.timeline));
|
||||||
|
final loaded = state as CalendarLoaded;
|
||||||
|
expect(loaded.viewMode, CalendarViewMode.timeline);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CalendarViewModeChanged 在 CalendarInitial 状态不改变状态', () async {
|
||||||
|
expect(bloc.state, isA<CalendarInitial>());
|
||||||
|
|
||||||
|
final state = await dispatch(CalendarViewModeChanged(CalendarViewMode.week));
|
||||||
|
// 状态应保持为 CalendarInitial
|
||||||
|
expect(state, isA<CalendarInitial>());
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== 日期键归一化 =====
|
||||||
|
|
||||||
|
test('CalendarMonthChanged 日期键归一化 — 同一天不同时间映射到同一个 key', () async {
|
||||||
|
// 创建两篇日记,日期都是 6 月 15 日但时间不同
|
||||||
|
final june15Morning = DateTime(2026, 6, 15, 8, 30);
|
||||||
|
final june15Afternoon = DateTime(2026, 6, 15, 14, 45);
|
||||||
|
await repo.createJournal(_makeEntry(id: 'j-1', date: june15Morning));
|
||||||
|
await repo.createJournal(_makeEntry(id: 'j-2', date: june15Afternoon));
|
||||||
|
|
||||||
|
final state = await dispatch(CalendarMonthChanged(DateTime(2026, 6, 1)));
|
||||||
|
final loaded = state as CalendarLoaded;
|
||||||
|
|
||||||
|
// 归一化后的 key 应为 DateTime(2026, 6, 15)
|
||||||
|
final normalizedKey = DateTime(2026, 6, 15);
|
||||||
|
expect(loaded.journalsByDate.containsKey(normalizedKey), isTrue);
|
||||||
|
// 两篇日记都映射到同一个 key
|
||||||
|
expect(loaded.journalsByDate[normalizedKey]!.length, 2);
|
||||||
|
expect(
|
||||||
|
loaded.journalsByDate[normalizedKey]!.map((j) => j.id).toList()
|
||||||
|
..sort(),
|
||||||
|
['j-1', 'j-2'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 加载日记时总是抛异常的仓库 — 用于测试错误路径
|
||||||
|
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 => null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<JournalEntry> createJournal(JournalEntry entry) async => entry;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<JournalEntry> updateJournal(JournalEntry entry) async => entry;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deleteJournal(String id) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<JournalElement>> getElements(String journalId) async => [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<JournalElement> addElement(JournalElement element) async => element;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<JournalElement> updateElement(JournalElement element) async => element;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> removeElement(String elementId) async {}
|
||||||
|
}
|
||||||
273
app/test/features/home/bloc/home_bloc_test.dart
Normal file
273
app/test/features/home/bloc/home_bloc_test.dart
Normal 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;
|
||||||
|
}
|
||||||
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -304,3 +304,89 @@ fn model_to_resp(model: journal_entry::Model) -> JournalResp {
|
|||||||
updated_at: model.updated_at,
|
updated_at: model.updated_at,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::dto::{Mood, Weather};
|
||||||
|
use chrono::{NaiveDate, Utc};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// 构造一个带默认值的测试用 journal_entry::Model
|
||||||
|
fn make_test_model() -> journal_entry::Model {
|
||||||
|
journal_entry::Model {
|
||||||
|
id: Uuid::now_v7(),
|
||||||
|
tenant_id: Uuid::now_v7(),
|
||||||
|
author_id: Uuid::now_v7(),
|
||||||
|
class_id: None,
|
||||||
|
title: "测试日记".to_string(),
|
||||||
|
date: NaiveDate::from_ymd_opt(2026, 6, 1).unwrap(),
|
||||||
|
mood: "\"happy\"".to_string(),
|
||||||
|
weather: "\"sunny\"".to_string(),
|
||||||
|
tags: Some(serde_json::json!(["tag1", "tag2"])),
|
||||||
|
is_private: true,
|
||||||
|
shared_to_class: false,
|
||||||
|
assigned_topic_id: None,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
updated_at: Utc::now(),
|
||||||
|
created_by: Uuid::now_v7(),
|
||||||
|
updated_by: Uuid::now_v7(),
|
||||||
|
deleted_at: None,
|
||||||
|
version: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn model_to_resp_normal_conversion() {
|
||||||
|
let model = make_test_model();
|
||||||
|
let resp = model_to_resp(model);
|
||||||
|
|
||||||
|
assert_eq!(resp.title, "测试日记");
|
||||||
|
assert!(matches!(resp.mood, Mood::Happy));
|
||||||
|
assert!(matches!(resp.weather, Weather::Sunny));
|
||||||
|
assert_eq!(resp.tags, vec!["tag1", "tag2"]);
|
||||||
|
assert!(resp.is_private);
|
||||||
|
assert!(!resp.shared_to_class);
|
||||||
|
assert_eq!(resp.version, 1);
|
||||||
|
assert!(resp.class_id.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn model_to_resp_invalid_mood_falls_back_to_happy() {
|
||||||
|
let mut model = make_test_model();
|
||||||
|
// 不是合法 JSON 字符串,serde_json::from_str 会失败
|
||||||
|
model.mood = "invalid_json".to_string();
|
||||||
|
let resp = model_to_resp(model);
|
||||||
|
|
||||||
|
assert!(matches!(resp.mood, Mood::Happy));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn model_to_resp_invalid_weather_falls_back_to_sunny() {
|
||||||
|
let mut model = make_test_model();
|
||||||
|
// 不是合法 JSON 字符串
|
||||||
|
model.weather = "xxx".to_string();
|
||||||
|
let resp = model_to_resp(model);
|
||||||
|
|
||||||
|
assert!(matches!(resp.weather, Weather::Sunny));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn model_to_resp_tags_none_yields_empty_list() {
|
||||||
|
let mut model = make_test_model();
|
||||||
|
model.tags = None;
|
||||||
|
let resp = model_to_resp(model);
|
||||||
|
|
||||||
|
assert!(resp.tags.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn model_to_resp_tags_not_array_yields_empty_list() {
|
||||||
|
let mut model = make_test_model();
|
||||||
|
// 有效 JSON 但不是数组 → serde_json::from_value::<Vec<String>> 失败
|
||||||
|
model.tags = Some(serde_json::json!("not_an_array"));
|
||||||
|
let resp = model_to_resp(model);
|
||||||
|
|
||||||
|
assert!(resp.tags.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -234,3 +234,169 @@ impl SyncService {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::dto::SyncChange;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn conflict_info_construction() {
|
||||||
|
let journal_id = uuid::Uuid::now_v7();
|
||||||
|
let info = ConflictInfo {
|
||||||
|
journal_id,
|
||||||
|
local_version: 2,
|
||||||
|
server_version: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(info.journal_id, journal_id);
|
||||||
|
assert_eq!(info.local_version, 2);
|
||||||
|
assert_eq!(info.server_version, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn conflict_info_serializes_with_correct_fields() {
|
||||||
|
let info = ConflictInfo {
|
||||||
|
journal_id: uuid::Uuid::nil(),
|
||||||
|
local_version: 1,
|
||||||
|
server_version: 3,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&info).unwrap();
|
||||||
|
|
||||||
|
assert!(json.contains("\"local_version\":1"));
|
||||||
|
assert!(json.contains("\"server_version\":3"));
|
||||||
|
assert!(json.contains("\"journal_id\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_change_create_journal_carries_data() {
|
||||||
|
let data = serde_json::json!({
|
||||||
|
"title": "我的日记",
|
||||||
|
"mood": "happy"
|
||||||
|
});
|
||||||
|
let change = SyncChange::CreateJournal { data: data.clone() };
|
||||||
|
|
||||||
|
// 验证 match 可以正确提取 data
|
||||||
|
match &change {
|
||||||
|
SyncChange::CreateJournal { data } => {
|
||||||
|
assert_eq!(data.get("title").unwrap().as_str().unwrap(), "我的日记");
|
||||||
|
assert_eq!(data.get("mood").unwrap().as_str().unwrap(), "happy");
|
||||||
|
}
|
||||||
|
_ => panic!("Expected CreateJournal variant"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_change_update_version_extraction() {
|
||||||
|
let id = uuid::Uuid::now_v7();
|
||||||
|
let change = SyncChange::UpdateJournal {
|
||||||
|
id,
|
||||||
|
version: 7,
|
||||||
|
data: serde_json::json!({"title": "更新标题"}),
|
||||||
|
};
|
||||||
|
|
||||||
|
match &change {
|
||||||
|
SyncChange::UpdateJournal {
|
||||||
|
id: cid,
|
||||||
|
version,
|
||||||
|
data,
|
||||||
|
} => {
|
||||||
|
assert_eq!(*cid, id);
|
||||||
|
assert_eq!(*version, 7);
|
||||||
|
assert_eq!(data.get("title").unwrap().as_str().unwrap(), "更新标题");
|
||||||
|
}
|
||||||
|
_ => panic!("Expected UpdateJournal variant"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_change_delete_version_extraction() {
|
||||||
|
let id = uuid::Uuid::now_v7();
|
||||||
|
let change = SyncChange::DeleteJournal { id, version: 3 };
|
||||||
|
|
||||||
|
match &change {
|
||||||
|
SyncChange::DeleteJournal {
|
||||||
|
id: cid,
|
||||||
|
version,
|
||||||
|
} => {
|
||||||
|
assert_eq!(*cid, id);
|
||||||
|
assert_eq!(*version, 3);
|
||||||
|
}
|
||||||
|
_ => panic!("Expected DeleteJournal variant"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_change_roundtrip_serialization() {
|
||||||
|
let change = SyncChange::UpdateJournal {
|
||||||
|
id: uuid::Uuid::nil(),
|
||||||
|
version: 2,
|
||||||
|
data: serde_json::json!({"title": "test"}),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&change).unwrap();
|
||||||
|
let back: SyncChange = serde_json::from_str(&json).unwrap();
|
||||||
|
|
||||||
|
match back {
|
||||||
|
SyncChange::UpdateJournal { version, .. } => assert_eq!(version, 2),
|
||||||
|
_ => panic!("Expected UpdateJournal after roundtrip"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn conflict_collection_pattern_mimics_sync_logic() {
|
||||||
|
// 模拟 sync 方法中 VersionConflict 收集到 conflicts 列表的行为
|
||||||
|
let mut conflicts: Vec<ConflictInfo> = Vec::new();
|
||||||
|
|
||||||
|
let errors: Vec<DiaryError> = vec![
|
||||||
|
DiaryError::VersionConflict {
|
||||||
|
local: 1,
|
||||||
|
server: 3,
|
||||||
|
},
|
||||||
|
DiaryError::VersionConflict {
|
||||||
|
local: 2,
|
||||||
|
server: 5,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for e in errors {
|
||||||
|
match e {
|
||||||
|
DiaryError::VersionConflict { local, server } => {
|
||||||
|
conflicts.push(ConflictInfo {
|
||||||
|
journal_id: uuid::Uuid::nil(),
|
||||||
|
local_version: local,
|
||||||
|
server_version: server,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(conflicts.len(), 2);
|
||||||
|
assert_eq!(conflicts[0].local_version, 1);
|
||||||
|
assert_eq!(conflicts[0].server_version, 3);
|
||||||
|
assert_eq!(conflicts[1].local_version, 2);
|
||||||
|
assert_eq!(conflicts[1].server_version, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_conflict_error_does_not_collect() {
|
||||||
|
// 验证非 VersionConflict 错误不会被收集到 conflicts 列表
|
||||||
|
let mut conflicts: Vec<ConflictInfo> = Vec::new();
|
||||||
|
let error = DiaryError::NotFound("日记不存在".to_string());
|
||||||
|
|
||||||
|
match error {
|
||||||
|
DiaryError::VersionConflict { local, server } => {
|
||||||
|
conflicts.push(ConflictInfo {
|
||||||
|
journal_id: uuid::Uuid::nil(),
|
||||||
|
local_version: local,
|
||||||
|
server_version: server,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// 其他错误不应收集
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(conflicts.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user