test(app): 手写引擎 Canvas 集成测试 — 55 个测试全覆盖
4 个测试文件: - stroke_model_test.dart (12 tests) — StrokePoint/Stroke 序列化、不可变性、默认值 - stroke_renderer_test.dart (19 tests) — parseHexColor/pointsToOutline/buildStrokePath/createPaintForStroke - stroke_cache_test.dart (15 tests) — StrokeRasterCache 添加/同步/清除/尺寸变化/边界条件 - handwriting_canvas_test.dart (9 tests) — Widget 渲染结构、手势回调、去抖、预加载、连续笔画
This commit is contained in:
238
app/test/features/editor/widgets/stroke_cache_test.dart
Normal file
238
app/test/features/editor/widgets/stroke_cache_test.dart
Normal file
@@ -0,0 +1,238 @@
|
||||
// StrokeRasterCache 单元测试 — 光栅化缓存管理器
|
||||
//
|
||||
// 注意:ui.PictureRecorder().endRecording().toImage() 需要 Flutter Test 绑定,
|
||||
// 因此这些测试在 flutter test 环境中运行(自动提供 TestWidgetsFlutterBinding)。
|
||||
// 使用足够大的画布尺寸(>0)才能使光栅化生效。
|
||||
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nuanji_app/features/editor/widgets/stroke_cache.dart';
|
||||
import 'package:nuanji_app/features/editor/widgets/stroke_model.dart';
|
||||
|
||||
void main() {
|
||||
// ============================================================
|
||||
// 辅助
|
||||
// ============================================================
|
||||
|
||||
/// 构造一条从 (x0,y0) 到 (x1,y1) 的简单笔画
|
||||
Stroke makeStroke(
|
||||
String id, {
|
||||
double x0 = 10.0,
|
||||
double y0 = 10.0,
|
||||
double x1 = 200.0,
|
||||
double y1 = 200.0,
|
||||
BrushType brushType = BrushType.pen,
|
||||
String color = '#2D2420',
|
||||
double width = 3.0,
|
||||
int pointCount = 10,
|
||||
}) {
|
||||
return Stroke(
|
||||
id: id,
|
||||
points: List.generate(
|
||||
pointCount,
|
||||
(i) {
|
||||
final t = i / (pointCount - 1);
|
||||
return StrokePoint(
|
||||
x: x0 + (x1 - x0) * t,
|
||||
y: y0 + (y1 - y0) * t,
|
||||
pressure: 0.5,
|
||||
timestamp: i * 16,
|
||||
);
|
||||
},
|
||||
),
|
||||
brushType: brushType,
|
||||
color: color,
|
||||
width: width,
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 生命周期与基本属性
|
||||
// ============================================================
|
||||
group('StrokeRasterCache — 生命周期', () {
|
||||
test('初始状态为空', () {
|
||||
final cache = StrokeRasterCache();
|
||||
addTearDown(cache.dispose);
|
||||
|
||||
expect(cache.compositeImage, isNull);
|
||||
expect(cache.layerVersion, 0);
|
||||
expect(cache.length, 0);
|
||||
expect(cache.cachedStrokeIds, isEmpty);
|
||||
});
|
||||
|
||||
test('dispose 后可安全调用', () {
|
||||
final cache = StrokeRasterCache();
|
||||
cache.dispose();
|
||||
// 不应抛异常
|
||||
expect(cache.compositeImage, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 尺寸管理
|
||||
// ============================================================
|
||||
group('StrokeRasterCache — 尺寸管理', () {
|
||||
test('ensureSize 设置画布尺寸', () {
|
||||
final cache = StrokeRasterCache();
|
||||
addTearDown(cache.dispose);
|
||||
|
||||
cache.ensureSize(const Size(800, 600));
|
||||
expect(cache.canvasSize, const Size(800, 600));
|
||||
});
|
||||
|
||||
test('ensureSize 相同尺寸不触发失效', () {
|
||||
final cache = StrokeRasterCache();
|
||||
addTearDown(cache.dispose);
|
||||
|
||||
cache.ensureSize(const Size(800, 600));
|
||||
final v1 = cache.layerVersion;
|
||||
cache.ensureSize(const Size(800, 600)); // 相同尺寸
|
||||
expect(cache.layerVersion, v1); // 版本不变
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 笔画操作
|
||||
// ============================================================
|
||||
group('StrokeRasterCache — 笔画操作', () {
|
||||
late StrokeRasterCache cache;
|
||||
|
||||
setUp(() {
|
||||
cache = StrokeRasterCache();
|
||||
cache.ensureSize(const Size(800, 600));
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
cache.dispose();
|
||||
});
|
||||
|
||||
test('addStroke 缓存笔画并递增版本', () async {
|
||||
final stroke = makeStroke('s1');
|
||||
await cache.addStroke(stroke);
|
||||
|
||||
expect(cache.length, 1);
|
||||
expect(cache.cachedStrokeIds, contains('s1'));
|
||||
expect(cache.layerVersion, greaterThan(0));
|
||||
expect(cache.compositeImage, isNotNull);
|
||||
});
|
||||
|
||||
test('addStroke 在画布尺寸为零时跳过', () async {
|
||||
final emptyCache = StrokeRasterCache();
|
||||
addTearDown(emptyCache.dispose);
|
||||
|
||||
// 未调用 ensureSize,canvasSize == Size.zero
|
||||
await emptyCache.addStroke(makeStroke('s1'));
|
||||
expect(emptyCache.length, 0);
|
||||
});
|
||||
|
||||
test('多条笔画增量合成', () async {
|
||||
await cache.addStroke(makeStroke('s1'));
|
||||
final v1 = cache.layerVersion;
|
||||
|
||||
await cache.addStroke(makeStroke('s2'));
|
||||
final v2 = cache.layerVersion;
|
||||
|
||||
await cache.addStroke(makeStroke('s3'));
|
||||
|
||||
expect(cache.length, 3);
|
||||
expect(v2, greaterThan(v1));
|
||||
expect(cache.layerVersion, greaterThan(v2));
|
||||
});
|
||||
|
||||
test('不同画笔类型均可光栅化', () async {
|
||||
for (final bt in BrushType.values) {
|
||||
final id = 'stroke-${bt.value}';
|
||||
await cache.addStroke(makeStroke(
|
||||
id,
|
||||
brushType: bt,
|
||||
color: bt == BrushType.eraser ? '#FFFFFF' : '#E07A5F',
|
||||
));
|
||||
expect(cache.cachedStrokeIds, contains(id), reason: '$bt 应能光栅化');
|
||||
}
|
||||
expect(cache.length, BrushType.values.length);
|
||||
});
|
||||
|
||||
test('clear 清除所有缓存', () async {
|
||||
await cache.addStroke(makeStroke('s1'));
|
||||
await cache.addStroke(makeStroke('s2'));
|
||||
expect(cache.length, 2);
|
||||
|
||||
await cache.clear();
|
||||
expect(cache.length, 0);
|
||||
expect(cache.compositeImage, isNull);
|
||||
expect(cache.cachedStrokeIds, isEmpty);
|
||||
});
|
||||
|
||||
test('syncStrokes 添加缺失笔画', () async {
|
||||
await cache.addStroke(makeStroke('s1'));
|
||||
expect(cache.length, 1);
|
||||
|
||||
// syncStrokes 传入 s1 + s2,应只添加 s2
|
||||
await cache.syncStrokes([makeStroke('s1'), makeStroke('s2')]);
|
||||
expect(cache.length, 2);
|
||||
expect(cache.cachedStrokeIds, containsAll(['s1', 's2']));
|
||||
});
|
||||
|
||||
test('syncStrokes 移除多余笔画(模拟撤销)', () async {
|
||||
await cache.addStroke(makeStroke('s1'));
|
||||
await cache.addStroke(makeStroke('s2'));
|
||||
expect(cache.length, 2);
|
||||
|
||||
// syncStrokes 只保留 s1,移除 s2
|
||||
await cache.syncStrokes([makeStroke('s1')]);
|
||||
expect(cache.length, 1);
|
||||
expect(cache.cachedStrokeIds, {'s1'});
|
||||
});
|
||||
|
||||
test('syncStrokes 无变化时不增加版本', () async {
|
||||
await cache.addStroke(makeStroke('s1'));
|
||||
final v = cache.layerVersion;
|
||||
|
||||
// 传入完全相同的笔画列表
|
||||
await cache.syncStrokes([makeStroke('s1')]);
|
||||
expect(cache.layerVersion, v);
|
||||
});
|
||||
|
||||
test('invalidateAll 重建所有缓存', () async {
|
||||
await cache.addStroke(makeStroke('s1'));
|
||||
final v1 = cache.layerVersion;
|
||||
|
||||
// 尺寸变化触发 invalidateAll
|
||||
cache.ensureSize(const Size(1024, 768));
|
||||
await cache.addStroke(makeStroke('s1')); // 重建后重新添加
|
||||
|
||||
// 版本应高于之前(因为 invalidateAll + addStroke 都递增)
|
||||
expect(cache.layerVersion, greaterThan(v1));
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 空边界条件
|
||||
// ============================================================
|
||||
group('StrokeRasterCache — 边界条件', () {
|
||||
test('单点笔画不会生成缓存条目', () async {
|
||||
final cache = StrokeRasterCache();
|
||||
cache.ensureSize(const Size(800, 600));
|
||||
addTearDown(cache.dispose);
|
||||
|
||||
final singlePointStroke = Stroke(
|
||||
id: 'single',
|
||||
points: [const StrokePoint(x: 50, y: 50)],
|
||||
);
|
||||
await cache.addStroke(singlePointStroke);
|
||||
|
||||
// 单点 → pointsToOutline 返回空 → _rasterizeStroke 返回 null
|
||||
expect(cache.length, 0);
|
||||
});
|
||||
|
||||
test('空笔画列表的 syncStrokes 不报错', () async {
|
||||
final cache = StrokeRasterCache();
|
||||
cache.ensureSize(const Size(800, 600));
|
||||
addTearDown(cache.dispose);
|
||||
|
||||
await cache.syncStrokes([]); // 不应抛
|
||||
expect(cache.length, 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user