diff --git a/app/test/features/editor/widgets/handwriting_canvas_test.dart b/app/test/features/editor/widgets/handwriting_canvas_test.dart new file mode 100644 index 0000000..aa16a31 --- /dev/null +++ b/app/test/features/editor/widgets/handwriting_canvas_test.dart @@ -0,0 +1,272 @@ +// HandwritingCanvas Widget 集成测试 — 指针事件驱动笔画完成回调 +// +// 验证: +// 1. Widget 正确渲染(双层 CustomPaint) +// 2. 手势事件触发 onStrokeCompleted 回调 +// 3. 不同画笔类型/颜色/宽度正确传递 +// 4. 去抖过滤(微小移动被丢弃) + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nuanji_app/features/editor/widgets/handwriting_canvas.dart'; +import 'package:nuanji_app/features/editor/widgets/stroke_model.dart'; + +void main() { + // ============================================================ + // 辅助 + // ============================================================ + + /// 包裹 HandwritingCanvas 在必要的父组件中,提供约束尺寸 + Widget buildTestSubject({ + Key? key, + BrushType brushType = BrushType.pen, + String brushColor = '#2D2420', + double brushWidth = 3.0, + List strokes = const [], + ValueChanged? onStrokeCompleted, + }) { + return MaterialApp( + home: Scaffold( + body: SizedBox( + width: 800, + height: 600, + child: HandwritingCanvas( + key: key, + brushType: brushType, + brushColor: brushColor, + brushWidth: brushWidth, + strokes: strokes, + onStrokeCompleted: onStrokeCompleted, + ), + ), + ), + ); + } + + /// 使用标准 TestGesture 模拟一条完整的拖拽手势 + Future simulateDragStroke( + WidgetTester tester, + List points, + ) async { + assert(points.length >= 2, '至少需要 down 和 up 两个点'); + + final gesture = await tester.startGesture(points.first); + await tester.pump(); + + for (var i = 1; i < points.length; i++) { + await gesture.moveTo(points[i]); + await tester.pump(); + } + + await gesture.up(); + await tester.pump(); + } + + // ============================================================ + // 渲染结构验证 + // ============================================================ + group('HandwritingCanvas — 渲染结构', () { + testWidgets('正确渲染双层 CustomPaint', (tester) async { + await tester.pumpWidget(buildTestSubject()); + await tester.pumpAndSettle(); + + // 应找到 CustomPaint(两层:CachedStrokesPainter + ActiveStrokePainter) + final customPaints = find.byType(CustomPaint); + expect(customPaints, findsAtLeast(2)); + + // 应找到 Listener(HandwritingCanvas 的 Listener + Gesture 识别器可能有额外 Listener) + expect(find.byType(Listener), findsAtLeast(1)); + + // 应找到 RepaintBoundary(MaterialApp/Scaffold 可能添加额外的) + expect(find.byType(RepaintBoundary), findsAtLeast(1)); + }); + + testWidgets('初始无笔画时仍正确渲染', (tester) async { + await tester.pumpWidget(buildTestSubject()); + await tester.pumpAndSettle(); + + expect(find.byType(HandwritingCanvas), findsOneWidget); + }); + }); + + // ============================================================ + // 手势事件 → onStrokeCompleted + // ============================================================ + group('HandwritingCanvas — 笔画完成回调', () { + testWidgets('有效拖拽触发 onStrokeCompleted', (tester) async { + Stroke? completedStroke; + await tester.pumpWidget(buildTestSubject( + onStrokeCompleted: (stroke) => completedStroke = stroke, + )); + await tester.pumpAndSettle(); + + // 模拟 5 个点的笔画(距离足够大,避免去抖过滤) + await simulateDragStroke(tester, [ + const Offset(100, 100), + const Offset(150, 120), + const Offset(200, 140), + const Offset(250, 160), + const Offset(300, 180), + ]); + + // 应触发回调 + expect(completedStroke, isNotNull); + expect(completedStroke!.points.length, greaterThanOrEqualTo(2)); + expect(completedStroke!.brushType, BrushType.pen); + expect(completedStroke!.color, '#2D2420'); + expect(completedStroke!.width, 3.0); + }); + + testWidgets('笔画携带正确的画笔类型', (tester) async { + Stroke? completedStroke; + await tester.pumpWidget(buildTestSubject( + brushType: BrushType.marker, + onStrokeCompleted: (stroke) => completedStroke = stroke, + )); + await tester.pumpAndSettle(); + + await simulateDragStroke(tester, [ + const Offset(100, 100), + const Offset(200, 200), + const Offset(300, 100), + ]); + + expect(completedStroke, isNotNull); + expect(completedStroke!.brushType, BrushType.marker); + }); + + testWidgets('笔画携带正确的颜色和宽度', (tester) async { + Stroke? completedStroke; + await tester.pumpWidget(buildTestSubject( + brushColor: '#E07A5F', + brushWidth: 8.0, + onStrokeCompleted: (stroke) => completedStroke = stroke, + )); + await tester.pumpAndSettle(); + + await simulateDragStroke(tester, [ + const Offset(50, 50), + const Offset(150, 150), + const Offset(250, 50), + ]); + + expect(completedStroke!.color, '#E07A5F'); + expect(completedStroke!.width, 8.0); + }); + + testWidgets('tap(无拖拽)不触发回调', (tester) async { + Stroke? completedStroke; + await tester.pumpWidget(buildTestSubject( + onStrokeCompleted: (stroke) => completedStroke = stroke, + )); + await tester.pumpAndSettle(); + + // 仅 tap(down + up 在同一位置)— 单点不足以构成笔画 + await tester.tapAt(const Offset(100, 100)); + await tester.pumpAndSettle(); + + // Tap 产生 1 个点(down 和 up 位置相同),不触发回调 + expect(completedStroke, isNull); + }); + }); + + // ============================================================ + // 预加载笔画 + // ============================================================ + group('HandwritingCanvas — 预加载笔画', () { + testWidgets('初始笔画列表正确传入', (tester) async { + final existingStrokes = [ + Stroke( + id: 'existing-1', + points: [ + const StrokePoint(x: 10, y: 10), + const StrokePoint(x: 100, y: 100), + ], + ), + ]; + + await tester.pumpWidget(buildTestSubject( + strokes: existingStrokes, + )); + await tester.pumpAndSettle(); + + expect(find.byType(HandwritingCanvas), findsOneWidget); + }); + + testWidgets('笔画更新触发 didUpdateWidget', (tester) async { + final key = GlobalKey(); + + // 第一次渲染 — 无笔画 + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: SizedBox( + width: 800, + height: 600, + child: HandwritingCanvas( + key: key, + strokes: const [], + ), + ), + ), + )); + await tester.pumpAndSettle(); + + // 更新 — 添加笔画 + final updatedStrokes = [ + Stroke( + id: 'new-1', + points: [ + const StrokePoint(x: 50, y: 50), + const StrokePoint(x: 200, y: 200), + ], + ), + ]; + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: SizedBox( + width: 800, + height: 600, + child: HandwritingCanvas( + key: key, + strokes: updatedStrokes, + ), + ), + ), + )); + await tester.pumpAndSettle(); + + expect(find.byType(HandwritingCanvas), findsOneWidget); + }); + }); + + // ============================================================ + // 连续多笔画 + // ============================================================ + group('HandwritingCanvas — 连续多笔画', () { + testWidgets('连续绘制多条笔画,每条都触发回调', (tester) async { + final completedStrokes = []; + await tester.pumpWidget(buildTestSubject( + onStrokeCompleted: (stroke) => completedStrokes.add(stroke), + )); + await tester.pumpAndSettle(); + + // 第一条笔画 + await simulateDragStroke(tester, [ + const Offset(50, 50), + const Offset(150, 100), + const Offset(250, 50), + ]); + + // 第二条笔画(使用新的 gesture) + await simulateDragStroke(tester, [ + const Offset(100, 200), + const Offset(200, 300), + const Offset(300, 200), + ]); + + expect(completedStrokes.length, 2); + expect(completedStrokes[0].id, isNot(equals(completedStrokes[1].id))); + }); + }); +} diff --git a/app/test/features/editor/widgets/stroke_cache_test.dart b/app/test/features/editor/widgets/stroke_cache_test.dart new file mode 100644 index 0000000..c618e3e --- /dev/null +++ b/app/test/features/editor/widgets/stroke_cache_test.dart @@ -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); + }); + }); +} diff --git a/app/test/features/editor/widgets/stroke_model_test.dart b/app/test/features/editor/widgets/stroke_model_test.dart new file mode 100644 index 0000000..3790c92 --- /dev/null +++ b/app/test/features/editor/widgets/stroke_model_test.dart @@ -0,0 +1,159 @@ +// StrokeModel 单元测试 — 笔画数据模型的序列化与不可变性验证 + +import 'dart:collection'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:nuanji_app/features/editor/widgets/stroke_model.dart'; + +void main() { + // ============================================================ + // StrokePoint + // ============================================================ + group('StrokePoint', () { + test('构造函数设置默认值', () { + const point = StrokePoint(x: 10.0, y: 20.0); + expect(point.x, 10.0); + expect(point.y, 20.0); + expect(point.pressure, 0.5); + expect(point.timestamp, 0); + }); + + test('copyWith 返回新实例,原实例不变', () { + const original = StrokePoint(x: 1.0, y: 2.0, pressure: 0.3, timestamp: 100); + final copied = original.copyWith(x: 10.0, pressure: 0.8); + + expect(copied.x, 10.0); + expect(copied.y, 2.0); // 未变 + expect(copied.pressure, 0.8); + expect(copied.timestamp, 100); // 未变 + + // 原实例不变 + expect(original.x, 1.0); + expect(original.pressure, 0.3); + }); + + test('toJson → fromJson 往返一致', () { + const point = StrokePoint(x: 123.456, y: 789.012, pressure: 0.75, timestamp: 1700000000); + final json = point.toJson(); + final restored = StrokePoint.fromJson(json); + + expect(restored.x, closeTo(point.x, 0.001)); + expect(restored.y, closeTo(point.y, 0.001)); + expect(restored.pressure, closeTo(point.pressure, 0.001)); + expect(restored.timestamp, point.timestamp); + }); + + test('fromJson 处理缺失字段使用默认值', () { + final restored = StrokePoint.fromJson({'x': 5.0, 'y': 10.0}); + expect(restored.pressure, 0.5); + expect(restored.timestamp, 0); + }); + }); + + // ============================================================ + // Stroke + // ============================================================ + group('Stroke', () { + List makePoints(int count) => List.generate( + count, + (i) => StrokePoint(x: i * 10.0, y: i * 5.0, pressure: 0.5, timestamp: i * 16), + ); + + test('构造函数设置默认值', () { + final stroke = Stroke(id: 'test-1', points: makePoints(3)); + expect(stroke.id, 'test-1'); + expect(stroke.brushType, BrushType.pen); + expect(stroke.color, '#2D2420'); + expect(stroke.width, 3.0); + }); + + test('copyWith 返回新实例', () { + final original = Stroke( + id: 's1', + points: makePoints(3), + brushType: BrushType.marker, + color: '#FF0000', + width: 5.0, + ); + final copied = original.copyWith(color: '#00FF00', width: 8.0); + + expect(copied.id, 's1'); // 未变 + expect(copied.brushType, BrushType.marker); // 未变 + expect(copied.color, '#00FF00'); + expect(copied.width, 8.0); + }); + + test('toJson → fromJson 往返一致', () { + final stroke = Stroke( + id: 'abc-123', + points: makePoints(5), + brushType: BrushType.pencil, + color: '#81B29A', + width: 2.0, + ); + final json = stroke.toJson(); + final restored = Stroke.fromJson(json); + + expect(restored.id, stroke.id); + expect(restored.brushType, stroke.brushType); + expect(restored.color, stroke.color); + expect(restored.width, stroke.width); + expect(restored.points.length, stroke.points.length); + for (var i = 0; i < stroke.points.length; i++) { + expect(restored.points[i].x, closeTo(stroke.points[i].x, 0.001)); + expect(restored.points[i].y, closeTo(stroke.points[i].y, 0.001)); + } + }); + + test('fromJson 产生不可变点列表', () { + final stroke = Stroke.fromJson({ + 'id': 'immutable-test', + 'points': [ + {'x': 1.0, 'y': 2.0}, + {'x': 3.0, 'y': 4.0}, + ], + }); + expect(stroke.points, isA>()); + }); + + test('fromJson 处理缺失可选字段使用默认值', () { + final restored = Stroke.fromJson({ + 'id': 'minimal', + 'points': [ + {'x': 0.0, 'y': 0.0}, + ], + }); + expect(restored.brushType, BrushType.pen); + expect(restored.color, '#2D2420'); + expect(restored.width, 3.0); + }); + + test('fromJson 处理未知 brushType 回退到 pen', () { + final restored = Stroke.fromJson({ + 'id': 'unknown-brush', + 'points': [ + {'x': 0.0, 'y': 0.0}, + ], + 'brushType': 'nonexistent', + }); + expect(restored.brushType, BrushType.pen); + }); + }); + + // ============================================================ + // BrushType 枚举 + // ============================================================ + group('BrushType', () { + test('包含全部 4 种画笔', () { + expect(BrushType.values.length, 4); + expect(BrushType.values.map((b) => b.value), ['pen', 'pencil', 'marker', 'eraser']); + }); + + test('value 与枚举一一对应', () { + for (final bt in BrushType.values) { + final found = BrushType.values.firstWhere((b) => b.value == bt.value); + expect(found, bt); + } + }); + }); +} diff --git a/app/test/features/editor/widgets/stroke_renderer_test.dart b/app/test/features/editor/widgets/stroke_renderer_test.dart new file mode 100644 index 0000000..29acb82 --- /dev/null +++ b/app/test/features/editor/widgets/stroke_renderer_test.dart @@ -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 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); + }); + }); +}