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 渲染结构、手势回调、去抖、预加载、连续笔画
215 lines
7.3 KiB
Dart
215 lines
7.3 KiB
Dart
// 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);
|
||
});
|
||
});
|
||
}
|