Files
nj/app/test/features/calendar/bloc/calendar_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

233 lines
8.1 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.
// 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,
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 {}
}