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,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<Stroke> strokes = const [],
ValueChanged<Stroke>? 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<void> simulateDragStroke(
WidgetTester tester,
List<Offset> 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));
// 应找到 ListenerHandwritingCanvas 的 Listener + Gesture 识别器可能有额外 Listener
expect(find.byType(Listener), findsAtLeast(1));
// 应找到 RepaintBoundaryMaterialApp/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();
// 仅 tapdown + 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 = <Stroke>[];
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)));
});
});
}

View 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);
// 未调用 ensureSizecanvasSize == 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);
});
});
}

View File

@@ -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<StrokePoint> 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<UnmodifiableListView<StrokePoint>>());
});
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);
}
});
});
}

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