test(app): 前端第一批测试 — EditorBloc 19用例 + JournalElement 11用例 + InMemoryRepo 12用例

添加 mocktail 测试依赖 + 字体文件占位
总计 42 测试通过,覆盖工具/笔画/元素/撤销/重做/序列化/乐观锁/CRUD
This commit is contained in:
iven
2026-06-01 23:02:14 +08:00
parent 3eaf83c79a
commit 4c743e150e
11 changed files with 723 additions and 0 deletions

View File

View File

View File

View File

View File

View File

View File

@@ -768,6 +768,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.0.0"
mocktail:
dependency: "direct dev"
description:
name: mocktail
sha256: "5e1bf53cc7baa8062a33b84424deb61513858ea05c601b8509e683815b5914aa"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.5"
nested:
dependency: transitive
description:
@@ -992,6 +1000,62 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "5.0.2"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.5.5"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.23"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.5.6"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.2"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.1"
shelf:
dependency: transitive
description:

View File

@@ -68,6 +68,9 @@ dev_dependencies:
# 代码规范
flutter_lints: ^6.0.0
# 测试工具
mocktail: ^1.0.4
flutter:
uses-material-design: true

View File

@@ -0,0 +1,202 @@
// JournalElement 模型单元测试
//
// 覆盖工厂方法、便利工厂、copyWith 不可变性、序列化往返、默认值
import 'dart:math';
import 'package:flutter_test/flutter_test.dart';
import 'package:nuanji_app/data/models/journal_element.dart';
void main() {
group('JournalElement', () {
// ===== create 工厂 =====
test('create 工厂生成正确的默认值', () {
final element = JournalElement.create(
journalId: 'journal-1',
elementType: ElementType.text,
positionX: 10,
positionY: 20,
content: {'text': 'Hello'},
);
expect(element.journalId, 'journal-1');
expect(element.elementType, ElementType.text);
expect(element.positionX, 10.0);
expect(element.positionY, 20.0);
expect(element.width, 100.0);
expect(element.height, 100.0);
expect(element.rotation, 0.0);
expect(element.zIndex, 0);
expect(element.version, 1);
expect(element.id, isNotEmpty);
expect(element.createdAt, isNotNull);
expect(element.updatedAt, isNotNull);
});
// ===== createText =====
test('createText 便利工厂', () {
final element = JournalElement.createText(
journalId: 'j-1',
text: '你好',
position: const Offset(50, 100),
fontSize: 24.0,
fontColor: '#E07A5F',
);
expect(element.elementType, ElementType.text);
expect(element.content['text'], '你好');
expect(element.content['fontSize'], 24.0);
expect(element.content['fontColor'], '#E07A5F');
expect(element.positionX, 50.0);
expect(element.positionY, 100.0);
});
test('createText 使用默认字号和颜色', () {
final element = JournalElement.createText(
journalId: 'j-1',
text: '默认',
position: Offset.zero,
);
expect(element.content['fontSize'], 18.0);
expect(element.content['fontColor'], '#2D2420');
});
// ===== createImage =====
test('createImage 便利工厂', () {
final element = JournalElement.createImage(
journalId: 'j-1',
filePath: '/path/to/image.jpg',
position: Offset.zero,
thumbnailPath: '/path/to/thumb.jpg',
);
expect(element.elementType, ElementType.image);
expect(element.content['filePath'], '/path/to/image.jpg');
expect(element.content['thumbnailPath'], '/path/to/thumb.jpg');
});
test('createImage 无 thumbnailPath 时不包含该字段', () {
final element = JournalElement.createImage(
journalId: 'j-1',
filePath: '/path/to/img.jpg',
position: Offset.zero,
);
expect(element.content.containsKey('thumbnailPath'), isFalse);
expect(element.content['filePath'], '/path/to/img.jpg');
});
// ===== createSticker =====
test('createSticker 便利工厂', () {
final element = JournalElement.createSticker(
journalId: 'j-1',
emoji: '🐱',
position: const Offset(50, 50),
stickerPackId: 'pack-1',
stickerId: 's-1',
);
expect(element.elementType, ElementType.sticker);
expect(element.content['emoji'], '🐱');
expect(element.content['stickerPackId'], 'pack-1');
expect(element.content['stickerId'], 's-1');
});
test('createSticker 无可选字段时不包含', () {
final element = JournalElement.createSticker(
journalId: 'j-1',
emoji: '🌸',
position: Offset.zero,
);
expect(element.content.containsKey('stickerPackId'), isFalse);
expect(element.content.containsKey('stickerId'), isFalse);
expect(element.content['emoji'], '🌸');
});
// ===== copyWith 不可变性 =====
test('copyWith 返回新实例且不修改原始', () {
final original = JournalElement.createText(
journalId: 'j-1',
text: '原始',
position: Offset.zero,
);
final modified = original.copyWith(
positionX: 100.0,
positionY: 200.0,
);
expect(identical(original, modified), isFalse);
expect(original.positionX, 0.0);
expect(original.positionY, 0.0);
expect(modified.positionX, 100.0);
expect(modified.positionY, 200.0);
expect(modified.journalId, original.journalId);
expect(modified.id, original.id);
});
// ===== 序列化 =====
test('toJson / fromJson 往返一致', () {
final original = JournalElement.createText(
journalId: 'j-1',
text: '序列化测试',
position: const Offset(42, 84),
fontSize: 20.0,
);
final json = original.toJson();
final restored = JournalElement.fromJson(json);
expect(restored.id, original.id);
expect(restored.journalId, original.journalId);
expect(restored.elementType, original.elementType);
expect(restored.positionX, original.positionX);
expect(restored.positionY, original.positionY);
expect(restored.content['text'], original.content['text']);
expect(restored.version, original.version);
});
test('fromJson 处理空 content', () {
final json = {
'id': 'test-id',
'journal_id': 'j-1',
'element_type': 'text',
'position_x': 0.0,
'position_y': 0.0,
'width': 100.0,
'height': 100.0,
'rotation': 0.0,
'z_index': 0,
'content': <String, dynamic>{},
'version': 1,
'created_at': DateTime.now().toIso8601String(),
'updated_at': DateTime.now().toIso8601String(),
};
final element = JournalElement.fromJson(json);
expect(element.content, isA<Map>());
expect(element.content, isEmpty);
});
// ===== ElementType 枚举 =====
test('ElementType 枚举值完整', () {
expect(ElementType.values.length, 5);
expect(ElementType.values, containsAll([
ElementType.text,
ElementType.image,
ElementType.sticker,
ElementType.handwritingRef,
ElementType.tape,
]));
});
});
}

View File

@@ -0,0 +1,188 @@
// InMemoryJournalRepository 单元测试
//
// 覆盖CRUD 操作、乐观锁冲突、软删除、元素管理、clearAll
import 'dart:math';
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';
void main() {
group('InMemoryJournalRepository', () {
late InMemoryJournalRepository repo;
setUp(() {
repo = InMemoryJournalRepository();
});
// ===== 辅助方法 =====
JournalEntry _createEntry({String id = 'j-1', String title = '测试日记'}) {
return JournalEntry(
id: id,
authorId: 'user-1',
title: title,
date: DateTime(2026, 6, 1),
mood: Mood.happy,
weather: Weather.sunny,
tags: const [],
isPrivate: false,
sharedToClass: false,
version: 1,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
}
// ===== 创建 =====
test('创建日记', () async {
final entry = _createEntry();
final created = await repo.createJournal(entry);
expect(created.id, entry.id);
expect(created.title, '测试日记');
expect(repo.journalCount, 1);
});
// ===== 读取 =====
test('获取日记', () async {
final entry = _createEntry();
await repo.createJournal(entry);
final fetched = await repo.getJournal('j-1');
expect(fetched, isNotNull);
expect(fetched!.title, '测试日记');
});
test('获取不存在的日记返回 null', () async {
final fetched = await repo.getJournal('non-existent');
expect(fetched, isNull);
});
test('获取日记列表', () async {
await repo.createJournal(_createEntry(id: 'j-1'));
await repo.createJournal(_createEntry(id: 'j-2'));
final list = await repo.getJournals();
expect(list.length, 2);
});
test('空仓库返回空列表', () async {
final list = await repo.getJournals();
expect(list, isEmpty);
});
// ===== 更新 =====
test('更新日记标题', () async {
await repo.createJournal(_createEntry());
final existing = (await repo.getJournal('j-1'))!;
final updated = await repo.updateJournal(
existing.copyWith(title: '更新后的标题'),
);
expect(updated.title, '更新后的标题');
expect(updated.version, 2);
final refetched = (await repo.getJournal('j-1'))!;
expect(refetched.title, '更新后的标题');
expect(refetched.version, 2);
});
test('乐观锁版本冲突抛出异常', () async {
await repo.createJournal(_createEntry());
final v1 = (await repo.getJournal('j-1'))!;
// 先更新一次 → v2
await repo.updateJournal(v1.copyWith(title: 'v2'));
// 再用 v1 更新 → 应抛 StateError
expect(
() => repo.updateJournal(v1.copyWith(title: 'conflict')),
throwsA(isA<StateError>()),
);
});
// ===== 删除 =====
test('软删除日记', () async {
await repo.createJournal(_createEntry());
await repo.deleteJournal('j-1');
final fetched = await repo.getJournal('j-1');
expect(fetched, isNull);
});
// ===== 元素 =====
test('添加元素', () async {
await repo.createJournal(_createEntry());
final element = JournalElement.createText(
journalId: 'j-1',
text: '测试文字',
position: Offset.zero,
);
final added = await repo.addElement(element);
expect(added.journalId, 'j-1');
expect(repo.elementCount, 1);
});
test('获取元素列表(按 journalId 过滤)', () async {
await repo.createJournal(_createEntry(id: 'j-1'));
await repo.createJournal(_createEntry(id: 'j-2'));
await repo.addElement(JournalElement.createText(
journalId: 'j-1', text: '文字1', position: Offset.zero,
));
await repo.addElement(JournalElement.createSticker(
journalId: 'j-1', emoji: '🌸', position: const Offset(50, 50),
));
await repo.addElement(JournalElement.createText(
journalId: 'j-2', text: '文字2', position: Offset.zero,
));
final elementsJ1 = await repo.getElements('j-1');
final elementsJ2 = await repo.getElements('j-2');
expect(elementsJ1.length, 2);
expect(elementsJ2.length, 1);
});
test('删除元素', () async {
await repo.createJournal(_createEntry());
final element = JournalElement.createText(
journalId: 'j-1', text: '待删除', position: Offset.zero,
);
await repo.addElement(element);
await repo.removeElement(element.id);
expect(repo.elementCount, 0);
final elements = await repo.getElements('j-1');
expect(elements, isEmpty);
});
// ===== clearAll =====
test('clearAll 清空所有数据', () async {
await repo.createJournal(_createEntry(id: 'j-1'));
await repo.createJournal(_createEntry(id: 'j-2'));
await repo.addElement(JournalElement.createText(
journalId: 'j-1', text: '文字', position: Offset.zero,
));
repo.clearAll();
expect(repo.journalCount, 0);
expect(repo.elementCount, 0);
expect(await repo.getJournals(), isEmpty);
});
});
}

View File

@@ -0,0 +1,266 @@
// EditorBloc 单元测试
//
// 覆盖:工具切换、笔画管理(添加/撤销/重做/清空)、元素管理(添加/删除/移动/选中)、加载已有数据
// 不使用 bloc_test手动收集状态变化
import 'dart:math';
import 'package:flutter_test/flutter_test.dart';
import 'package:nuanji_app/data/models/journal_element.dart';
import 'package:nuanji_app/features/editor/bloc/editor_bloc.dart';
import 'package:nuanji_app/features/editor/widgets/stroke_model.dart';
void main() {
group('EditorBloc', () {
late EditorBloc bloc;
setUp(() {
bloc = EditorBloc();
});
tearDown(() {
bloc.close();
});
// ===== 辅助 =====
/// 收集事件触发后的最终状态
Future<EditorState> dispatch(EditorEvent event) async {
bloc.add(event);
// 等待 BLoC 处理完毕
await Future<void>.delayed(const Duration(milliseconds: 50));
return bloc.state;
}
Stroke _testStroke({String id = 's1'}) {
return Stroke(
id: id,
points: [StrokePoint(x: 10, y: 20)],
brushType: BrushType.pen,
color: '#000000',
width: 3.0,
);
}
// ===== 初始状态 =====
test('初始状态pen 工具、空笔画、空元素、非 dirty', () {
expect(bloc.state.activeTool, EditorTool.pen);
expect(bloc.state.strokes, isEmpty);
expect(bloc.state.elements, isEmpty);
expect(bloc.state.isDirty, isFalse);
expect(bloc.state.selectedElementId, isNull);
});
// ===== 工具切换 =====
test('切换到文字工具', () async {
final state = await dispatch(ToolChanged(EditorTool.text));
expect(state.activeTool, EditorTool.text);
});
test('切换到贴纸工具', () async {
final state = await dispatch(ToolChanged(EditorTool.sticker));
expect(state.activeTool, EditorTool.sticker);
});
test('切换工具时清除元素选中', () async {
// 先添加元素并选中
bloc.add(ElementAdded(JournalElement.createSticker(
journalId: 'j-1', emoji: '🐱', position: Offset.zero,
)));
await Future<void>.delayed(const Duration(milliseconds: 50));
bloc.add(ElementSelected(bloc.state.elements.first.id));
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(bloc.state.selectedElementId, isNotNull);
// 切换工具 → 清除选中
final state = await dispatch(ToolChanged(EditorTool.text));
expect(state.selectedElementId, isNull);
});
// ===== 画笔设置 =====
test('切换画笔类型和颜色', () async {
final state = await dispatch(
BrushChanged(type: BrushType.marker, color: '#FF0000', width: 8.0),
);
expect(state.brushType, BrushType.marker);
expect(state.brushColor, '#FF0000');
expect(state.brushWidth, 8.0);
});
// ===== 笔画管理 =====
test('添加笔画标记 dirty', () async {
final state = await dispatch(StrokeCompleted(_testStroke()));
expect(state.strokes.length, 1);
expect(state.strokes.first.id, 's1');
expect(state.isDirty, isTrue);
});
test('撤销笔画移到 redoStack', () async {
bloc.add(StrokeCompleted(_testStroke(id: 's1')));
await Future<void>.delayed(const Duration(milliseconds: 50));
final state = await dispatch(Undo());
expect(state.strokes, isEmpty);
expect(state.redoStack.length, 1);
expect(state.redoStack.first.id, 's1');
});
test('重做笔画从 redoStack 恢复', () async {
bloc.add(StrokeCompleted(_testStroke(id: 's1')));
await Future<void>.delayed(const Duration(milliseconds: 50));
bloc.add(Undo());
await Future<void>.delayed(const Duration(milliseconds: 50));
final state = await dispatch(Redo());
expect(state.strokes.length, 1);
expect(state.strokes.first.id, 's1');
expect(state.redoStack, isEmpty);
});
test('清空画布清除所有笔画和重做栈', () async {
bloc.add(StrokeCompleted(_testStroke(id: 's1')));
bloc.add(StrokeCompleted(_testStroke(id: 's2')));
await Future<void>.delayed(const Duration(milliseconds: 50));
final state = await dispatch(ClearCanvas());
expect(state.strokes, isEmpty);
expect(state.redoStack, isEmpty);
});
test('添加新笔画时清除重做栈', () async {
bloc.add(StrokeCompleted(_testStroke(id: 's1')));
await Future<void>.delayed(const Duration(milliseconds: 50));
bloc.add(Undo());
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(bloc.state.redoStack.length, 1);
// 添加新笔画 → 重做栈应清空
final state = await dispatch(StrokeCompleted(_testStroke(id: 's2')));
expect(state.strokes.length, 1);
expect(state.strokes.first.id, 's2');
expect(state.redoStack, isEmpty);
});
// ===== 元素管理 =====
test('添加文字元素', () async {
final state = await dispatch(ElementAdded(
JournalElement.createText(
journalId: 'j-1',
text: '你好',
position: Offset.zero,
),
));
expect(state.elements.length, 1);
expect(state.elements.first.elementType, ElementType.text);
expect(state.elements.first.content['text'], '你好');
expect(state.isDirty, isTrue);
});
test('添加贴纸元素', () async {
final state = await dispatch(ElementAdded(
JournalElement.createSticker(
journalId: 'j-1',
emoji: '🌸',
position: const Offset(50, 50),
),
));
expect(state.elements.length, 1);
expect(state.elements.first.content['emoji'], '🌸');
});
test('删除元素', () async {
final elem = JournalElement.createText(
journalId: 'j-1', text: '删除测试', position: Offset.zero,
);
bloc.add(ElementAdded(elem));
await Future<void>.delayed(const Duration(milliseconds: 50));
final state = await dispatch(ElementRemoved(elem.id));
expect(state.elements, isEmpty);
});
test('移动元素更新位置', () async {
final elem = JournalElement.createText(
journalId: 'j-1', text: '移动测试', position: Offset.zero,
);
bloc.add(ElementAdded(elem));
await Future<void>.delayed(const Duration(milliseconds: 50));
final state = await dispatch(ElementMoved(
elementId: elem.id,
positionX: 100.0,
positionY: 200.0,
));
final moved = state.elements.first;
expect(moved.positionX, 100.0);
expect(moved.positionY, 200.0);
});
test('选中元素', () async {
final elem = JournalElement.createSticker(
journalId: 'j-1', emoji: '🐱', position: Offset.zero,
);
bloc.add(ElementAdded(elem));
await Future<void>.delayed(const Duration(milliseconds: 50));
final state = await dispatch(ElementSelected(elem.id));
expect(state.selectedElementId, elem.id);
});
test('取消选中元素', () async {
final elem = JournalElement.createSticker(
journalId: 'j-1', emoji: '🐱', position: Offset.zero,
);
bloc.add(ElementAdded(elem));
await Future<void>.delayed(const Duration(milliseconds: 50));
bloc.add(ElementSelected(elem.id));
await Future<void>.delayed(const Duration(milliseconds: 50));
final state = await dispatch(ElementSelected(null));
expect(state.selectedElementId, isNull);
});
// ===== 加载已有数据 =====
test('加载已有笔画', () async {
final state = await dispatch(StrokesLoaded([
_testStroke(id: 's1'),
_testStroke(id: 's2'),
]));
expect(state.strokes.length, 2);
});
test('加载已有元素', () async {
final state = await dispatch(ElementsLoaded([
JournalElement.createText(journalId: 'j-1', text: '已有文字', position: Offset.zero),
JournalElement.createSticker(journalId: 'j-1', emoji: '🌸', position: const Offset(50, 50)),
]));
expect(state.elements.length, 2);
});
// ===== 自动保存 =====
test('onSave 回调在状态变更后记录', () async {
final savedStates = <EditorState>[];
final bloc = EditorBloc(
onSave: (state) => savedStates.add(state),
);
bloc.add(StrokeCompleted(_testStroke()));
await Future<void>.delayed(const Duration(milliseconds: 100));
// onSave 通过 debounce 触发2秒短时间等待可能未触发
// 但我们验证 bloc 的 isDirty 已被标记
expect(bloc.state.isDirty, isTrue);
bloc.close();
});
});
}