// 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 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 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); }); }); }