test(app): 前端第一批测试 — EditorBloc 19用例 + JournalElement 11用例 + InMemoryRepo 12用例
添加 mocktail 测试依赖 + 字体文件占位 总计 42 测试通过,覆盖工具/笔画/元素/撤销/重做/序列化/乐观锁/CRUD
This commit is contained in:
0
app/assets/fonts/Nunito-Bold.ttf
Normal file
0
app/assets/fonts/Nunito-Bold.ttf
Normal file
0
app/assets/fonts/Nunito-Regular.ttf
Normal file
0
app/assets/fonts/Nunito-Regular.ttf
Normal file
0
app/assets/fonts/Nunito-SemiBold.ttf
Normal file
0
app/assets/fonts/Nunito-SemiBold.ttf
Normal file
0
app/assets/fonts/Quicksand-Bold.ttf
Normal file
0
app/assets/fonts/Quicksand-Bold.ttf
Normal file
0
app/assets/fonts/Quicksand-Regular.ttf
Normal file
0
app/assets/fonts/Quicksand-Regular.ttf
Normal file
0
app/assets/fonts/Quicksand-SemiBold.ttf
Normal file
0
app/assets/fonts/Quicksand-SemiBold.ttf
Normal file
@@ -768,6 +768,14 @@ packages:
|
|||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
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:
|
nested:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -992,6 +1000,62 @@ packages:
|
|||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.0.2"
|
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:
|
shelf:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ dev_dependencies:
|
|||||||
# 代码规范
|
# 代码规范
|
||||||
flutter_lints: ^6.0.0
|
flutter_lints: ^6.0.0
|
||||||
|
|
||||||
|
# 测试工具
|
||||||
|
mocktail: ^1.0.4
|
||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|
||||||
|
|||||||
202
app/test/data/models/journal_element_test.dart
Normal file
202
app/test/data/models/journal_element_test.dart
Normal 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,
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
266
app/test/features/editor/bloc/editor_bloc_test.dart
Normal file
266
app/test/features/editor/bloc/editor_bloc_test.dart
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user