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:
iven
2026-06-03 18:57:41 +08:00
parent 4cd08535d3
commit f6d394afb6
4 changed files with 883 additions and 0 deletions

View File

@@ -0,0 +1,214 @@
// StrokeRenderer 单元测试 — 纯函数验证
//
// 覆盖pointsToOutline、buildStrokePath、parseHexColor、createPaintForStroke
// 不依赖 Flutter 绑定dart:ui 的 Canvas/Image仅测试纯逻辑。
import 'dart:ui';
import 'package:flutter_test/flutter_test.dart';
import 'package:nuanji_app/features/editor/widgets/stroke_model.dart';
import 'package:nuanji_app/features/editor/widgets/stroke_renderer.dart';
void main() {
// ============================================================
// parseHexColor
// ============================================================
group('parseHexColor', () {
test('解析标准 #RRGGBB 格式', () {
final color = parseHexColor('#E07A5F');
expect(color.value, const Color(0xFFE07A5F).value);
});
test('解析不带 # 的 6 位十六进制', () {
// parseHexColor 会先 replaceFirst('#', ''),所以直接传 6 位也应该工作
final color = parseHexColor('#2D2420');
expect(color, const Color(0xFF2D2420));
});
test('全黑 #000000', () {
expect(parseHexColor('#000000').value, const Color(0xFF000000).value);
});
test('全白 #FFFFFF', () {
expect(parseHexColor('#FFFFFF').value, const Color(0xFFFFFFFF).value);
});
test('无效长度回退到默认色', () {
const fallback = Color(0xFF2D2420);
expect(parseHexColor('#FFF').value, fallback.value);
expect(parseHexColor('#12345').value, fallback.value);
expect(parseHexColor('').value, fallback.value);
});
test('无效字符回退到默认色', () {
expect(parseHexColor('#GGGGGG').value, const Color(0xFF2D2420).value);
});
});
// ============================================================
// pointsToOutline
// ============================================================
group('pointsToOutline', () {
/// 构造 N 个均匀分布的点
List<StrokePoint> makeLinearPoints(int count) => List.generate(
count,
(i) => StrokePoint(
x: i * 10.0,
y: i * 10.0,
pressure: 0.5,
timestamp: i * 16,
),
);
test('少于 2 个点返回空列表', () {
final empty = pointsToOutline([], BrushType.pen, 3.0);
expect(empty, isEmpty);
final onePoint = pointsToOutline(
[const StrokePoint(x: 0, y: 0)],
BrushType.pen,
3.0,
);
expect(onePoint, isEmpty);
});
test('2 个点生成非空轮廓', () {
final points = makeLinearPoints(2);
final outline = pointsToOutline(points, BrushType.pen, 3.0);
expect(outline, isNotEmpty);
// perfect_freehand 生成的是封闭轮廓,点数远多于输入点
expect(outline.length, greaterThan(points.length));
});
test('4 种画笔类型均能生成轮廓', () {
final points = makeLinearPoints(10);
for (final bt in BrushType.values) {
final outline = pointsToOutline(points, bt, 3.0);
expect(outline, isNotEmpty, reason: '$bt 应生成非空轮廓');
}
});
test('宽度影响轮廓大小 — 更大的 width 产生更大的轮廓', () {
final points = makeLinearPoints(10);
final outlineThin = pointsToOutline(points, BrushType.pen, 1.0);
final outlineThick = pointsToOutline(points, BrushType.pen, 8.0);
// 计算轮廓的包围盒面积作为粗略大小指标
double bboxArea(List<Offset> pts) {
if (pts.isEmpty) return 0;
double minX = double.infinity, maxX = double.negativeInfinity;
double minY = double.infinity, maxY = double.negativeInfinity;
for (final p in pts) {
if (p.dx < minX) minX = p.dx;
if (p.dx > maxX) maxX = p.dx;
if (p.dy < minY) minY = p.dy;
if (p.dy > maxY) maxY = p.dy;
}
return (maxX - minX) * (maxY - minY);
}
expect(bboxArea(outlineThick), greaterThan(bboxArea(outlineThin)));
});
test('isComplete 参数影响输出(端点处理)', () {
final points = makeLinearPoints(5);
final complete = pointsToOutline(points, BrushType.pen, 3.0, isComplete: true);
final active = pointsToOutline(points, BrushType.pen, 3.0, isComplete: false);
// 两者都应该生成轮廓,但端点处理不同
expect(complete, isNotEmpty);
expect(active, isNotEmpty);
});
});
// ============================================================
// buildStrokePath
// ============================================================
group('buildStrokePath', () {
test('空列表返回空 Path', () {
final path = buildStrokePath([]);
// 空 path 的 bounds 是零矩形
expect(path.getBounds().isEmpty, isTrue);
});
test('非空点列表返回有效 Path', () {
final points = [
const Offset(0, 0),
const Offset(10, 10),
const Offset(20, 5),
const Offset(30, 15),
];
final path = buildStrokePath(points);
expect(path.getBounds().isEmpty, isFalse);
expect(path.getBounds().width, greaterThan(0));
expect(path.getBounds().height, greaterThan(0));
});
test('路径包围盒包含所有输入点', () {
final points = [
const Offset(5, 5),
const Offset(100, 50),
const Offset(200, 100),
];
final path = buildStrokePath(points);
final bounds = path.getBounds();
for (final p in points) {
expect(bounds.left, lessThanOrEqualTo(p.dx));
expect(bounds.top, lessThanOrEqualTo(p.dy));
expect(bounds.right, greaterThanOrEqualTo(p.dx));
expect(bounds.bottom, greaterThanOrEqualTo(p.dy));
}
});
});
// ============================================================
// createPaintForStroke
// ============================================================
group('createPaintForStroke', () {
Stroke makeStroke(BrushType type, {String color = '#2D2420', double width = 3.0}) {
return Stroke(
id: 'test',
points: [
const StrokePoint(x: 0, y: 0),
const StrokePoint(x: 100, y: 100),
],
brushType: type,
color: color,
width: width,
);
}
test('钢笔 — 不透明实心填充', () {
final paint = createPaintForStroke(makeStroke(BrushType.pen));
expect(paint.color.value, parseHexColor('#2D2420').value);
expect(paint.style, PaintingStyle.fill);
expect(paint.isAntiAlias, isTrue);
});
test('铅笔 — 不透明实心填充', () {
final paint = createPaintForStroke(makeStroke(BrushType.pencil));
expect(paint.style, PaintingStyle.fill);
expect(paint.isAntiAlias, isTrue);
});
test('马克笔 — 半透明', () {
final paint = createPaintForStroke(makeStroke(BrushType.marker, color: '#E07A5F'));
// alpha = 0.4
expect(paint.color.alpha, closeTo(102, 1)); // 0.4 * 255 ≈ 102
expect(paint.style, PaintingStyle.fill);
});
test('橡皮擦 — dstOut 混合模式', () {
final paint = createPaintForStroke(makeStroke(BrushType.eraser));
expect(paint.blendMode, BlendMode.dstOut);
expect(paint.style, PaintingStyle.fill);
expect(paint.isAntiAlias, isTrue);
});
test('颜色正确传递', () {
final paint = createPaintForStroke(makeStroke(BrushType.pen, color: '#81B29A'));
expect(paint.color.value, parseHexColor('#81B29A').value);
});
});
}