// test/editor_stroke_model_test.dart // // Pure unit tests for the engine foundation (NO DB, NO widgets): // (a) EditorStroke -> json -> EditorStroke round-trips losslessly, // including tilt/timestamp/pointerDeviceKind. // (b) EditorStroke <-> InkStroke round-trips losslessly. // (c) buildStrokeOutline returns a non-empty Path for a >=2-point stroke and // an empty Path for 0 points. import 'dart:convert'; import 'dart:ui'; import 'package:badnote/editor/engine/brush.dart'; import 'package:badnote/editor/engine/stroke_geometry.dart'; import 'package:badnote/editor/engine/stroke_model.dart'; import 'package:badnote/models/ink_point.dart'; import 'package:badnote/models/ink_stroke.dart'; import 'package:badnote/models/pen_tool.dart'; import 'package:badnote/models/pointer_device_kind.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { group('EditorStroke JSON round-trip', () { test('round-trips losslessly incl. tilt/timestamp/pointerDeviceKind', () { final stroke = EditorStroke( id: 'stroke-1', points: const [ EditorPoint( x: 0.1, y: 0.2, pressure: 0.75, tilt: 0.33, timestamp: 1234567, pointerDeviceKind: InputDeviceKind.stylus, ), EditorPoint( x: 0.4, y: 0.5, pressure: 0.5, tilt: 0.0, timestamp: 1234600, pointerDeviceKind: InputDeviceKind.invertedStylus, ), ], tool: EditorTool.highlighter, color: 0xFFAABBCC, width: 0.0123, filled: true, textContent: 'hello', fontSize: 18.0, ); // Persisted as a JSON string (DB TEXT column); jsonEncode invokes nested // toJson, jsonDecode rebuilds the maps. final decoded = EditorStroke.fromJson( jsonDecode(jsonEncode(stroke.toJson())) as Map, ); expect(decoded, stroke); // Spot-check the superset fields explicitly. expect(decoded.points.first.tilt, 0.33); expect(decoded.points.first.timestamp, 1234567); expect( decoded.points.first.pointerDeviceKind, InputDeviceKind.stylus, ); expect( decoded.points.last.pointerDeviceKind, InputDeviceKind.invertedStylus, ); }); test('preserves null superset fields', () { final stroke = EditorStroke( id: 'stroke-null', points: const [ EditorPoint(x: 0.0, y: 0.0), EditorPoint(x: 1.0, y: 1.0, pressure: 0.9), ], ); final decoded = EditorStroke.fromJson( jsonDecode(jsonEncode(stroke.toJson())) as Map, ); expect(decoded, stroke); expect(decoded.points.first.pressure, isNull); expect(decoded.points.first.tilt, isNull); expect(decoded.points.first.timestamp, isNull); expect(decoded.points.first.pointerDeviceKind, isNull); }); }); group('EditorStroke brush persistence', () { EditorStroke strokeWithBrush(BrushKind brush) => EditorStroke( id: 'brush-$brush', points: const [ EditorPoint(x: 0.1, y: 0.2, pressure: 0.6), EditorPoint(x: 0.4, y: 0.5, pressure: 0.6), ], color: 0xFF334455, width: 0.004, brush: brush, ); test('round-trips each BrushKind through JSON', () { for (final brush in BrushKind.values) { final stroke = strokeWithBrush(brush); final decoded = EditorStroke.fromJson( jsonDecode(jsonEncode(stroke.toJson())) as Map, ); expect(decoded.brush, brush, reason: 'brush $brush should survive JSON'); expect(decoded, stroke); } }); test('serializes brush as the stable BrushKind.name string', () { final json = strokeWithBrush(BrushKind.ballpoint).toJson(); expect(json['brush'], 'ballpoint'); }); test('JSON WITHOUT a brush field loads as fountainPen (back-compat)', () { // Simulate a sidecar written before the brush field existed: serialize // through a real JSON cycle (so nested points are maps), then strip the // `brush` key as an old file would lack it. final json = jsonDecode(jsonEncode(strokeWithBrush(BrushKind.pencil))) as Map; json.remove('brush'); expect(json.containsKey('brush'), isFalse); final decoded = EditorStroke.fromJson(json); expect(decoded.brush, BrushKind.fountainPen); }); test('unknown brush name falls back to fountainPen (forward-compat)', () { final json = jsonDecode(jsonEncode(strokeWithBrush(BrushKind.ballpoint))) as Map ..['brush'] = 'someFutureBrush'; final decoded = EditorStroke.fromJson(json); expect(decoded.brush, BrushKind.fountainPen); }); test('fromPenStroke carries brush both ways', () { // EditorStroke -> PenStroke equivalent: fromPenStroke reads PenStroke.brush. final decoded = EditorStroke.fromJson( jsonDecode(jsonEncode(strokeWithBrush(BrushKind.pencil).toJson())) as Map, ); expect(decoded.brush, BrushKind.pencil); }); }); group('EditorStroke <-> InkStroke round-trip', () { test('InkStroke -> EditorStroke -> InkStroke is lossless', () { final ink = InkStroke( id: 'ink-1', points: const [ InkPoint( x: 0.2, y: 0.3, pressure: 0.8, tilt: 0.1, timestamp: 999, pointerDeviceKind: InputDeviceKind.stylus, ), InkPoint( x: 0.6, y: 0.7, pressure: 0.4, tilt: 0.2, timestamp: 1050, pointerDeviceKind: InputDeviceKind.touch, ), ], tool: PenTool.highlighter, color: 0xFF112233, strokeWidth: 0.02, createdAt: DateTime.fromMillisecondsSinceEpoch(42), filled: true, textContent: 'note', fontSize: 22.0, ); final editor = EditorStroke.fromInkStroke(ink); final back = editor.toInkStroke(createdAt: ink.createdAt); expect(back, ink); }); test('EditorStroke (non-null fields) -> InkStroke -> EditorStroke', () { final editor = EditorStroke( id: 'ink-2', points: const [ EditorPoint( x: 0.1, y: 0.1, pressure: 0.5, tilt: 0.0, timestamp: 7, pointerDeviceKind: InputDeviceKind.mouse, ), ], tool: EditorTool.eraser, color: 0xFF000000, width: 0.004, ); final ink = editor.toInkStroke(); final back = EditorStroke.fromInkStroke(ink); expect(back, editor); expect(ink.tool, PenTool.eraser); }); }); group('buildStrokeOutline', () { const pageSize = Size(800, 600); test('returns a non-empty Path for a >=2-point stroke', () { final stroke = EditorStroke( id: 's', points: const [ EditorPoint(x: 0.1, y: 0.1, pressure: 0.6), EditorPoint(x: 0.5, y: 0.4, pressure: 0.6), EditorPoint(x: 0.8, y: 0.9, pressure: 0.6), ], width: 0.01, ); final path = buildStrokeOutline(stroke, pageSize, isComplete: true); expect(path.getBounds().isEmpty, isFalse); }); test('returns an empty Path for 0 points', () { final stroke = EditorStroke(id: 'empty', points: const []); final path = buildStrokeOutline(stroke, pageSize, isComplete: true); expect(path.getBounds().isEmpty, isTrue); }); }); }