diff --git a/.github/workflows/main-merge.yml b/.github/workflows/main-merge.yml new file mode 100644 index 0000000..bf1fbf5 --- /dev/null +++ b/.github/workflows/main-merge.yml @@ -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 diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml new file mode 100644 index 0000000..6fe940f --- /dev/null +++ b/.github/workflows/pr-check.yml @@ -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 diff --git a/app/test/features/auth/bloc/auth_bloc_test.dart b/app/test/features/auth/bloc/auth_bloc_test.dart new file mode 100644 index 0000000..9044d98 --- /dev/null +++ b/app/test/features/auth/bloc/auth_bloc_test.dart @@ -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 dispatch(AuthEvent event) async { + bloc.add(event); + await Future.delayed(const Duration(milliseconds: 50)); + return bloc.state; + } + + /// 先将 BLoC 推入 Authenticated 状态,方便测试角色/班级码等后续流程 + Future seedAuthenticated({ + User user = _testUser, + bool needsRoleSelection = false, + bool needsClassCode = false, + }) async { + bloc.emit(Authenticated( + user: user, + needsRoleSelection: needsRoleSelection, + needsClassCode: needsClassCode, + )); + await Future.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()); + 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()); + 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()); + 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()); + 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()); + 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()); + 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()); + 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()); + 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()); + final authenticated = state as Authenticated; + expect(authenticated.needsRoleSelection, isFalse); + expect(authenticated.needsClassCode, isFalse); + }); + + test('当前非 Authenticated → 状态不变', () async { + // arrange — bloc 初始状态为 AuthInitial + expect(bloc.state, isA()); + + // act + final state = await dispatch(const RoleSelected(UserRoleType.student)); + + // assert — 状态未改变 + expect(state, isA()); + }); + }); + + // ===== 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()); + 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()); + 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()); + 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()); + }); + }); + + // ===== AuthExpired ===== + + group('AuthExpired', () { + test('认证过期 → Unauthenticated', () async { + // arrange — 从已认证状态开始 + await seedAuthenticated(); + + // act + final state = await dispatch(const AuthExpired()); + + // assert + expect(state, isA()); + }); + }); + + // ===== TokenRefreshed ===== + + group('TokenRefreshed', () { + test('令牌刷新 → 状态不变', () async { + // arrange — 从已认证状态开始 + await seedAuthenticated(); + final stateBefore = bloc.state; + + // act + final state = await dispatch(TokenRefreshed(_testToken)); + + // assert — 状态不变 + expect(state, equals(stateBefore)); + }); + }); +} diff --git a/app/test/features/calendar/bloc/calendar_bloc_test.dart b/app/test/features/calendar/bloc/calendar_bloc_test.dart new file mode 100644 index 0000000..f82938c --- /dev/null +++ b/app/test/features/calendar/bloc/calendar_bloc_test.dart @@ -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 dispatch(CalendarEvent event) async { + bloc.add(event); + await Future.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()); + }); + + // ===== 月份切换 ===== + + 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()); + 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.delayed(const Duration(milliseconds: 50)); + + // 此时状态应为 CalendarLoaded(isLoading: false,因为加载失败) + expect(failingBloc.state, isA()); + 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()); + + // 切换视图模式为 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()); + + final state = await dispatch(CalendarDaySelected(DateTime(2026, 6, 15))); + // 状态应保持为 CalendarInitial + expect(state, isA()); + }); + + // ===== 视图模式 ===== + + 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()); + + final state = await dispatch(CalendarViewModeChanged(CalendarViewMode.week)); + // 状态应保持为 CalendarInitial + expect(state, isA()); + }); + + // ===== 日期键归一化 ===== + + 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> getJournals({ + DateTime? dateFrom, + DateTime? dateTo, + int? page, + int? pageSize, + }) async { + throw Exception('模拟网络错误'); + } + + @override + Future getJournal(String id) async => null; + + @override + Future createJournal(JournalEntry entry) async => entry; + + @override + Future updateJournal(JournalEntry entry) async => entry; + + @override + Future deleteJournal(String id) async {} + + @override + Future> getElements(String journalId) async => []; + + @override + Future addElement(JournalElement element) async => element; + + @override + Future updateElement(JournalElement element) async => element; + + @override + Future removeElement(String elementId) async {} +} diff --git a/app/test/features/home/bloc/home_bloc_test.dart b/app/test/features/home/bloc/home_bloc_test.dart new file mode 100644 index 0000000..02b928b --- /dev/null +++ b/app/test/features/home/bloc/home_bloc_test.dart @@ -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 dispatch(HomeEvent event) async { + bloc.add(event); + await Future.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 _seed(List entries) async { + for (final e in entries) { + await repo.createJournal(e); + } + } + + // ===== 1. HomeLoadData 成功 → HomeLoading → HomeLoaded with journals ===== + + test('HomeLoadData 成功时状态经过 HomeLoading 到达 HomeLoaded', () async { + final states = []; + final subscription = bloc.stream.listen(states.add); + + await repo.createJournal(_makeEntry( + id: 'j-1', + date: _today(), + )); + + bloc.add(const HomeLoadData()); + await Future.delayed(const Duration(milliseconds: 50)); + + subscription.cancel(); + + // 至少经历 HomeLoading → HomeLoaded + expect(states, contains(isA())); + expect(states.last, isA()); + + 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()); + 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 = []; + final subscription = bloc.stream.listen(states.add); + + await repo.createJournal(_makeEntry( + id: 'j-refresh', + date: _today(), + )); + + bloc.add(const HomeRefresh()); + await Future.delayed(const Duration(milliseconds: 50)); + + subscription.cancel(); + + // HomeRefresh 内部 add(HomeLoadData),所以应有 Loading → Loaded + expect(states, contains(isA())); + expect(states.last, isA()); + + 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> getJournals({ + DateTime? dateFrom, + DateTime? dateTo, + int? page, + int? pageSize, + }) async { + throw Exception('网络不可用'); + } + + @override + Future getJournal(String id) async { + throw UnimplementedError(); + } + + @override + Future createJournal(JournalEntry entry) async { + throw UnimplementedError(); + } + + @override + Future updateJournal(JournalEntry entry) async { + throw UnimplementedError(); + } + + @override + Future deleteJournal(String id) async { + throw UnimplementedError(); + } + + @override + Future> getElements(String journalId) async { + throw UnimplementedError(); + } + + @override + Future addElement(JournalElement element) async { + throw UnimplementedError(); + } + + @override + Future updateElement(JournalElement element) async { + throw UnimplementedError(); + } + + @override + Future removeElement(String elementId) async { + throw UnimplementedError(); + } +} + +/// 在指定 bloc 上触发事件并等待处理完毕 +Future _dispatchOn(HomeBloc bloc, HomeEvent event) async { + bloc.add(event); + await Future.delayed(const Duration(milliseconds: 50)); + return bloc.state; +} diff --git a/app/test/features/mood/bloc/mood_bloc_test.dart b/app/test/features/mood/bloc/mood_bloc_test.dart new file mode 100644 index 0000000..738ba85 --- /dev/null +++ b/app/test/features/mood/bloc/mood_bloc_test.dart @@ -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 _mockResponseBody({ + List>? 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 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 _waitForAsync() => + Future.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.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); + }); +} diff --git a/crates/erp-diary/src/service/journal_service.rs b/crates/erp-diary/src/service/journal_service.rs index c1b2c79..16f95f6 100644 --- a/crates/erp-diary/src/service/journal_service.rs +++ b/crates/erp-diary/src/service/journal_service.rs @@ -304,3 +304,89 @@ fn model_to_resp(model: journal_entry::Model) -> JournalResp { 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::> 失败 + model.tags = Some(serde_json::json!("not_an_array")); + let resp = model_to_resp(model); + + assert!(resp.tags.is_empty()); + } +} diff --git a/crates/erp-diary/src/service/sync_service.rs b/crates/erp-diary/src/service/sync_service.rs index 8719395..356e1aa 100644 --- a/crates/erp-diary/src/service/sync_service.rs +++ b/crates/erp-diary/src/service/sync_service.rs @@ -234,3 +234,169 @@ impl SyncService { 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 = Vec::new(); + + let errors: Vec = 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 = 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()); + } +}