Files
nj/app/test/features/calendar/bloc/calendar_bloc_test.dart
iven 9fce34f4ef
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
fix(app): 修复 4 个 Flutter 交互问题
1. 首页数据不刷新 — JournalRepository 添加 onJournalChanged
   Stream 变更通知,HomeBloc 订阅后自动刷新
2. 画笔再次点击不弹出面板 — 添加 ToolReactivated 事件,
   工具栏检测已激活工具时发出重新激活信号
3. 钢笔铅笔效果一样 — 调整 perfect_freehand 参数
   (pen: size 10/smooth 0.65, pencil: size 3/smooth 0.35)
4. 橡皮擦不生效 — ActiveStrokePainter 橡皮擦模式绘制
   半透明灰色反馈,笔画完成后 setState 触发 Layer 1 重绘
5. 贴纸文字无法缩放 — DraggableElement 用 Scale 手势
   替换 Pan 手势,支持双指缩放和旋转
2026-06-04 00:05:22 +08:00

240 lines
8.3 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,
String? classId,
}) async {
throw Exception('模拟网络错误');
}
@override
Future<int> getJournalCount() async => 0;
@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 {}
@override
Stream<void> get onJournalChanged => const Stream<void>.empty();
}