Files
BadNote/test/editor_stroke_model_test.dart

241 lines
7.6 KiB
Dart
Raw Permalink Normal View History

// 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<String, dynamic>,
);
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<String, dynamic>,
);
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<String, dynamic>,
);
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<String, dynamic>;
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<String, dynamic>
..['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<String, dynamic>,
);
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);
});
});
}