feat(test): Week 3 质量保障体系 — 55 新增测试 + CI/CD 流水线
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled

前端第二批测试 (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:
iven
2026-06-01 23:20:18 +08:00
parent f0921d554c
commit ffde0c9e77
8 changed files with 1460 additions and 0 deletions

54
.github/workflows/main-merge.yml vendored Normal file
View 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
View 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

View 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));
});
});
}

View 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));
// 此时状态应为 CalendarLoadedisLoading: 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 {}
}

View 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;
}

View 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);
});
}

View File

@@ -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::<Vec<String>> 失败
model.tags = Some(serde_json::json!("not_an_array"));
let resp = model_to_resp(model);
assert!(resp.tags.is_empty());
}
}

View File

@@ -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<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());
}
}