架构治理: - 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
233 lines
8.1 KiB
Dart
233 lines
8.1 KiB
Dart
// 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,
|
||
String? mood,
|
||
String? tag,
|
||
}) 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 {}
|
||
}
|