Files
nj/app/test/features/home/bloc/home_bloc_test.dart
iven 749ef55b89
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
feat: Week 4 收尾 + 架构治理 — 搜索/家长中心/Feature Flag/Docker/环境配置
架构治理:
- Feature Flag 落地: Cargo.toml [features] default=["diary"] + main.rs cfg 条件编译
- 环境配置统一: AppConfig 类 + --dart-define 注入 + SSE 端口 8080→3000 修复

搜索替代方案 (无 FTS):
- SearchBloc + 标签/心情筛选接入后端 API
- JournalRepository 扩展 mood/tag 筛选参数
- 搜索页 UI 接入实际数据(替换占位文本)

家长中心最小集 (PIPL 合规):
- 后端: parent_service (绑定/查看/导出/删除/解绑) + parent_handler (6 个 API 端点)
- 前端: ParentBloc + ParentPage 功能完整实现
- 绑定孩子、只读查看日记、导出数据、删除数据、解绑

Docker 部署:
- verify.sh 健康检查脚本 (Axum/PG/Redis/OpenAPI 四项检查)

测试修复:
- home_bloc_test / calendar_bloc_test 适配 JournalRepository 新参数

验证: flutter test 84/84 pass, cargo test 76/76 pass, cargo check pass
2026-06-01 23:53:34 +08:00

276 lines
8.0 KiB
Dart
Raw Blame History

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