feat(pen): persist brush kind so it survives reload
Some checks failed
CI / Windows build (push) Has been cancelled

Closes TODO(brush-persist). EditorStroke now serializes its brush as
the stable BrushKind name; sidecars written before this field, and any
unknown name, load as fountainPen (back-compat). PenStroke<->EditorStroke
carry brush both ways, so a ballpoint/highlighter/pencil stroke keeps
its opacity/blend after a document is closed and reopened.

Note: InkStroke (the note/scratchpad world-coord format) has no brush
field, so notes derive brush from the tool — highlighter is preserved,
ballpoint/pencil collapse to fountainPen on reload (TODO: extend
InkStroke). PDF documents persist brush fully. analyze clean,
379 tests green.
This commit is contained in:
2026-06-24 23:40:40 +08:00
parent 6c2dd71b82
commit 46589a4c87
8 changed files with 206 additions and 40 deletions

View File

@@ -10,6 +10,7 @@
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';
@@ -90,6 +91,65 @@ void main() {
});
});
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(