All checks were successful
CI / Windows build (push) Successful in 8m42s
Add notebook.json containers with multi-member pages, fix PDF text editing (size/bold/drag/double-tap), index SidecarText in search, and share keyboard page shortcuts plus a PDF scrubber. Co-authored-by: Cursor <cursoragent@cursor.com>
457 lines
15 KiB
Dart
457 lines
15 KiB
Dart
// test/badnote_sidecar_test.dart
|
|
//
|
|
// Round-trips a BadnoteSidecar containing per-page strokes + highlights +
|
|
// bookmarks + scratch-links (each with its own scratchpad of InkStrokes),
|
|
// asserting the re-parsed model equals the original. Verifies the sidecar reuses
|
|
// the existing EditorStroke / InkStroke / ScratchLink / Bookmark JSON shapes and
|
|
// that the schema `version` field survives.
|
|
|
|
import 'dart:convert';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
import 'package:badnote/editor/engine/brush.dart';
|
|
import 'package:badnote/editor/engine/stroke_model.dart';
|
|
import 'package:badnote/models/bookmark.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:badnote/models/scratch_link.dart';
|
|
import 'package:badnote/storage/badnote_sidecar.dart';
|
|
|
|
EditorStroke _editorStroke(String id, EditorTool tool) => EditorStroke(
|
|
id: id,
|
|
points: [
|
|
const EditorPoint(x: 0.1, y: 0.2, pressure: 0.5, tilt: 0.1),
|
|
EditorPoint(
|
|
x: 0.3,
|
|
y: 0.4,
|
|
pressure: 0.9,
|
|
tilt: 0.2,
|
|
timestamp: 1234,
|
|
pointerDeviceKind: InputDeviceKind.stylus,
|
|
),
|
|
],
|
|
tool: tool,
|
|
color: 0xFF112233,
|
|
width: 0.005,
|
|
);
|
|
|
|
InkStroke _inkStroke(String id) => InkStroke(
|
|
id: id,
|
|
points: [
|
|
const InkPoint(
|
|
x: 100.0,
|
|
y: 200.0,
|
|
pressure: 0.7,
|
|
tilt: 0.3,
|
|
timestamp: 99,
|
|
pointerDeviceKind: InputDeviceKind.stylus,
|
|
),
|
|
const InkPoint(x: 300.0, y: 400.0, timestamp: 100),
|
|
],
|
|
tool: PenTool.pen,
|
|
color: 0xFF445566,
|
|
strokeWidth: 3.0,
|
|
createdAt: DateTime.utc(2026, 6, 24, 10, 0, 0),
|
|
);
|
|
|
|
BadnoteSidecar _fullSidecar() => BadnoteSidecar(
|
|
sourceFile: 'Calculus Lecture 3.pdf',
|
|
docType: 'pdf',
|
|
pageCount: 42,
|
|
rotation: 90,
|
|
createdAt: DateTime.utc(2026, 6, 24, 10, 0, 0),
|
|
updatedAt: DateTime.utc(2026, 6, 24, 10, 32, 11),
|
|
strokes: {
|
|
0: [_editorStroke('s0', EditorTool.pen)],
|
|
3: [
|
|
_editorStroke('s1', EditorTool.highlighter),
|
|
_editorStroke('s2', EditorTool.pen),
|
|
],
|
|
},
|
|
highlights: {
|
|
0: const [
|
|
SidecarHighlight(l: 0.12, t: 0.20, r: 0.88, b: 0.235, color: 0xFFFFEB3B),
|
|
],
|
|
},
|
|
texts: {
|
|
0: const [
|
|
SidecarText(
|
|
id: 't0',
|
|
nx: 0.25,
|
|
ny: 0.33,
|
|
text: 'Hello 手写',
|
|
fontSize: 0.04,
|
|
color: 0xFF112233,
|
|
),
|
|
],
|
|
3: const [
|
|
SidecarText(id: 't1', nx: 0.5, ny: 0.6, text: 'second'),
|
|
],
|
|
},
|
|
bookmarks: [
|
|
Bookmark(
|
|
id: 'bm1',
|
|
documentId: 'doc1',
|
|
pageNumber: 5,
|
|
label: 'Proof',
|
|
color: 0xFF2196F3,
|
|
createdAt: DateTime.utc(2026, 6, 24, 9, 0, 0),
|
|
),
|
|
],
|
|
scratchLinks: [
|
|
SidecarScratchLink(
|
|
link: const ScratchLink(
|
|
id: 'anchor1',
|
|
documentId: 'doc1',
|
|
pageIndex: 7,
|
|
nx: 0.83,
|
|
ny: 0.41,
|
|
),
|
|
scratchpad: SidecarScratchpad(
|
|
canvasWidth: 5000,
|
|
canvasHeight: 6000,
|
|
strokes: [_inkStroke('ink1'), _inkStroke('ink2')],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
|
|
/// Serializes through `jsonEncode`/`jsonDecode` exactly as `SidecarStore`
|
|
/// persists/loads on disk (nested freezed objects only flatten via the encoder's
|
|
/// toEncodable hook, so an in-memory map round-trip would not).
|
|
BadnoteSidecar _roundTrip(BadnoteSidecar sidecar) => BadnoteSidecar.fromJson(
|
|
jsonDecode(jsonEncode(sidecar.toJson())) as Map<String, dynamic>);
|
|
|
|
void main() {
|
|
test('full round-trip preserves strokes/highlights/bookmarks/links', () {
|
|
final original = _fullSidecar();
|
|
final reparsed = _roundTrip(original);
|
|
|
|
expect(reparsed.version, kBadnoteSidecarVersion);
|
|
expect(reparsed.sourceFile, 'Calculus Lecture 3.pdf');
|
|
expect(reparsed.docType, 'pdf');
|
|
expect(reparsed.pageCount, 42);
|
|
expect(reparsed.rotation, 90);
|
|
expect(reparsed.createdAt, DateTime.utc(2026, 6, 24, 10, 0, 0));
|
|
expect(reparsed.updatedAt, DateTime.utc(2026, 6, 24, 10, 32, 11));
|
|
|
|
// Strokes (EditorStroke value equality via freezed).
|
|
expect(reparsed.strokes.keys.toSet(), {0, 3});
|
|
expect(reparsed.strokes[0], original.strokes[0]);
|
|
expect(reparsed.strokes[3], original.strokes[3]);
|
|
|
|
// Highlights.
|
|
expect(reparsed.highlights[0], original.highlights[0]);
|
|
|
|
// Typed-text annotations (SidecarText value equality).
|
|
expect(reparsed.texts.keys.toSet(), {0, 3});
|
|
expect(reparsed.texts[0], original.texts[0]);
|
|
expect(reparsed.texts[3], original.texts[3]);
|
|
|
|
// Bookmarks (Bookmark value equality via freezed).
|
|
expect(reparsed.bookmarks, original.bookmarks);
|
|
|
|
// Scratch links + embedded scratchpads.
|
|
expect(reparsed.scratchLinks, original.scratchLinks);
|
|
final sp = reparsed.scratchLinks.single.scratchpad;
|
|
expect(sp.canvasWidth, 5000);
|
|
expect(sp.canvasHeight, 6000);
|
|
expect(sp.strokes, original.scratchLinks.single.scratchpad.strokes);
|
|
});
|
|
|
|
test('sidecar with mixed-brush strokes round-trips each brush', () {
|
|
EditorStroke brushStroke(String id, BrushKind brush) => EditorStroke(
|
|
id: id,
|
|
points: const [
|
|
EditorPoint(x: 0.1, y: 0.2, pressure: 0.6),
|
|
EditorPoint(x: 0.4, y: 0.5, pressure: 0.6),
|
|
],
|
|
color: 0xFF223344,
|
|
width: 0.004,
|
|
brush: brush,
|
|
);
|
|
|
|
final original = BadnoteSidecar(strokes: {
|
|
0: [
|
|
brushStroke('b-fountain', BrushKind.fountainPen),
|
|
brushStroke('b-ballpoint', BrushKind.ballpoint),
|
|
],
|
|
1: [
|
|
brushStroke('b-pencil', BrushKind.pencil),
|
|
brushStroke('b-highlighter', BrushKind.highlighter),
|
|
],
|
|
});
|
|
final reparsed = _roundTrip(original);
|
|
|
|
expect(
|
|
reparsed.strokes[0]!.map((s) => s.brush).toList(),
|
|
[BrushKind.fountainPen, BrushKind.ballpoint],
|
|
);
|
|
expect(
|
|
reparsed.strokes[1]!.map((s) => s.brush).toList(),
|
|
[BrushKind.pencil, BrushKind.highlighter],
|
|
);
|
|
// Full value equality (brush is part of EditorStroke's freezed equality).
|
|
expect(reparsed.strokes[0], original.strokes[0]);
|
|
expect(reparsed.strokes[1], original.strokes[1]);
|
|
});
|
|
|
|
test('strokes JSON is byte-compatible with EditorStroke.toJson', () {
|
|
final stroke = _editorStroke('s0', EditorTool.pen);
|
|
final sidecar = BadnoteSidecar(strokes: {2: [stroke]});
|
|
final json = sidecar.toJson();
|
|
final pageList = (json['strokes'] as Map)['2'] as List;
|
|
expect(pageList.single, stroke.toJson());
|
|
});
|
|
|
|
test('scratchpad strokes JSON is byte-compatible with InkStroke.toJson', () {
|
|
final ink = _inkStroke('ink1');
|
|
final scratchpad = SidecarScratchpad(strokes: [ink]);
|
|
final json = scratchpad.toJson();
|
|
expect((json['strokes'] as List).single, ink.toJson());
|
|
});
|
|
|
|
test('scratch link JSON includes ScratchLink fields plus scratchpad', () {
|
|
const link = ScratchLink(
|
|
id: 'a',
|
|
documentId: 'd',
|
|
pageIndex: 3,
|
|
nx: 0.5,
|
|
ny: 0.5,
|
|
);
|
|
final json = SidecarScratchLink(link: link).toJson();
|
|
// Reuses ScratchLink.toJson keys verbatim.
|
|
for (final key in link.toJson().keys) {
|
|
expect(json.containsKey(key), isTrue, reason: 'missing key $key');
|
|
}
|
|
expect(json.containsKey('scratchpad'), isTrue);
|
|
});
|
|
|
|
test('empty sidecar round-trips to empty containers', () {
|
|
final reparsed = _roundTrip(BadnoteSidecar());
|
|
expect(reparsed.strokes, isEmpty);
|
|
expect(reparsed.highlights, isEmpty);
|
|
expect(reparsed.texts, isEmpty);
|
|
expect(reparsed.bookmarks, isEmpty);
|
|
expect(reparsed.scratchLinks, isEmpty);
|
|
expect(reparsed.version, kBadnoteSidecarVersion);
|
|
});
|
|
|
|
test('text annotation round-trips (page, nx/ny, text, fontSize, color)', () {
|
|
final original = BadnoteSidecar(texts: {
|
|
2: const [
|
|
SidecarText(
|
|
id: 'tx1',
|
|
nx: 0.18,
|
|
ny: 0.42,
|
|
text: 'A typed note 手写测试',
|
|
fontSize: 0.035,
|
|
color: 0xFFAB12CD,
|
|
),
|
|
],
|
|
});
|
|
final reparsed = _roundTrip(original);
|
|
expect(reparsed.texts.keys.toSet(), {2});
|
|
final t = reparsed.texts[2]!.single;
|
|
expect(t.id, 'tx1');
|
|
expect(t.nx, 0.18);
|
|
expect(t.ny, 0.42);
|
|
expect(t.text, 'A typed note 手写测试');
|
|
expect(t.fontSize, 0.035);
|
|
expect(t.color, 0xFFAB12CD);
|
|
// Full SidecarText value equality.
|
|
expect(reparsed.texts[2], original.texts[2]);
|
|
});
|
|
|
|
test('text JSON is byte-compatible with SidecarText.toJson', () {
|
|
const tx = SidecarText(id: 't0', nx: 0.25, ny: 0.33, text: 'hi');
|
|
final sidecar = BadnoteSidecar(texts: {1: const [tx]});
|
|
final json = sidecar.toJson();
|
|
final pageList = (json['texts'] as Map)['1'] as List;
|
|
expect(pageList.single, tx.toJson());
|
|
});
|
|
|
|
test('missing texts decodes to empty (back-compat)', () {
|
|
// A sidecar authored before the texts field existed (no `texts` key).
|
|
final json =
|
|
jsonDecode(jsonEncode(BadnoteSidecar().toJson())) as Map<String, dynamic>;
|
|
expect(json.containsKey('texts'), isFalse,
|
|
reason: 'empty texts omitted (no key bloat for legacy sidecars)');
|
|
final reparsed = BadnoteSidecar.fromJson(json);
|
|
expect(reparsed.texts, isEmpty);
|
|
});
|
|
|
|
test('SidecarText defaults fill in for a minimal JSON blob', () {
|
|
// Only the required fields present; fontSize/color/text fall to defaults.
|
|
final t = SidecarText.fromJson(const {'id': 'a', 'nx': 0.1, 'ny': 0.2});
|
|
expect(t.text, '');
|
|
expect(t.fontSize, 0.03);
|
|
expect(t.color, 0xFF000000);
|
|
expect(t.fontWeight, 400);
|
|
expect(t.fontFamily, isNull);
|
|
});
|
|
|
|
test('SidecarText fontWeight and fontFamily round-trip', () {
|
|
const tx = SidecarText(
|
|
id: 't-bold',
|
|
nx: 0.1,
|
|
ny: 0.2,
|
|
text: '粗体',
|
|
fontSize: 0.045,
|
|
fontWeight: 700,
|
|
fontFamily: 'IBM Plex Sans',
|
|
);
|
|
final decoded = SidecarText.fromJson(tx.toJson());
|
|
expect(decoded, tx);
|
|
expect(decoded.fontWeight, 700);
|
|
expect(decoded.fontFamily, 'IBM Plex Sans');
|
|
});
|
|
|
|
test('paragraph-anchored bookmark round-trips its anchor + char index', () {
|
|
final original = BadnoteSidecar(bookmarks: [
|
|
Bookmark(
|
|
id: 'bm-anchor',
|
|
documentId: 'doc1',
|
|
pageNumber: 3,
|
|
label: 'A bookmarked paragraph',
|
|
color: 0xFF2196F3,
|
|
createdAt: DateTime.utc(2026, 6, 24, 11, 0, 0),
|
|
anchorLeft: 0.12,
|
|
anchorTop: 0.20,
|
|
anchorRight: 0.88,
|
|
anchorBottom: 0.235,
|
|
charIndex: 1234,
|
|
),
|
|
]);
|
|
final reparsed = _roundTrip(original);
|
|
expect(reparsed.bookmarks, original.bookmarks);
|
|
final bm = reparsed.bookmarks.single;
|
|
expect(bm.pageNumber, 3);
|
|
expect(bm.anchorLeft, 0.12);
|
|
expect(bm.anchorTop, 0.20);
|
|
expect(bm.anchorRight, 0.88);
|
|
expect(bm.anchorBottom, 0.235);
|
|
expect(bm.charIndex, 1234);
|
|
});
|
|
|
|
test('legacy page-only bookmark JSON decodes (anchor fields absent)', () {
|
|
// A bookmark authored before the anchor fields existed: only page-level.
|
|
final legacyJson = {
|
|
'badnoteSidecarVersion': 1,
|
|
'bookmarks': [
|
|
{
|
|
'id': 'legacy1',
|
|
'documentId': 'doc1',
|
|
'pageNumber': 7,
|
|
'label': 'Old bookmark',
|
|
'color': 0xFF2196F3,
|
|
'createdAt': '2026-06-01T00:00:00.000Z',
|
|
}
|
|
],
|
|
};
|
|
final decoded = BadnoteSidecar.fromJson(legacyJson);
|
|
final bm = decoded.bookmarks.single;
|
|
expect(bm.pageNumber, 7);
|
|
expect(bm.anchorLeft, isNull);
|
|
expect(bm.anchorTop, isNull);
|
|
expect(bm.anchorRight, isNull);
|
|
expect(bm.anchorBottom, isNull);
|
|
expect(bm.charIndex, isNull);
|
|
// Re-encoding preserves the page-only data; any anchor keys are null.
|
|
final reJson = bm.toJson();
|
|
expect(reJson['anchorLeft'], isNull);
|
|
expect(reJson['charIndex'], isNull);
|
|
expect(reJson['pageNumber'], 7);
|
|
// And it decodes back to an equal page-only bookmark.
|
|
expect(Bookmark.fromJson(reJson), bm);
|
|
});
|
|
|
|
test('unknown fields are ignored (forward-compat)', () {
|
|
final encoded = jsonEncode(BadnoteSidecar(
|
|
strokes: {0: [_editorStroke('s0', EditorTool.pen)]},
|
|
).toJson());
|
|
final json = jsonDecode(encoded) as Map<String, dynamic>;
|
|
json['futureFieldWeDoNotKnow'] = {'anything': true};
|
|
((json['strokes'] as Map)['0'] as List)[0]['brush'] =
|
|
'marker'; // future per-stroke field
|
|
final reparsed = BadnoteSidecar.fromJson(json);
|
|
expect(reparsed.strokes[0]!.single.id, 's0');
|
|
});
|
|
|
|
test('background name round-trips through the sidecar', () {
|
|
final original = BadnoteSidecar(docType: 'notebook', background: 'cornell');
|
|
final reparsed = _roundTrip(original);
|
|
expect(reparsed.background, 'cornell');
|
|
// Omitted when null (no key bloat for blank/legacy sidecars).
|
|
expect(BadnoteSidecar().toJson().containsKey('background'), isFalse);
|
|
});
|
|
|
|
test('missing background decodes to null (back-compat → blank)', () {
|
|
final json =
|
|
jsonDecode(jsonEncode(BadnoteSidecar().toJson())) as Map<String, dynamic>;
|
|
expect(json.containsKey('background'), isFalse);
|
|
final reparsed = BadnoteSidecar.fromJson(json);
|
|
expect(reparsed.background, isNull);
|
|
});
|
|
|
|
test('pageText (PDF body / scanned-OCR text) round-trips through sidecar', () {
|
|
final original = BadnoteSidecar(
|
|
docType: 'pdf',
|
|
sourceFile: 'scan.pdf',
|
|
pageText: 'page one body\fpage two 第二页',
|
|
);
|
|
final reparsed = _roundTrip(original);
|
|
expect(reparsed.pageText, 'page one body\fpage two 第二页');
|
|
// ocrText (handwriting) and pageText (document body) are independent fields.
|
|
expect(reparsed.ocrText, isNull);
|
|
// Omitted when null/empty (no key bloat for legacy/annotation-only sidecars).
|
|
expect(BadnoteSidecar().toJson().containsKey('pageText'), isFalse);
|
|
});
|
|
|
|
test('missing pageText decodes to null (back-compat)', () {
|
|
// A sidecar authored before the import-OCR feature simply omits pageText.
|
|
final json = <String, dynamic>{
|
|
'badnoteSidecarVersion': 1,
|
|
'docType': 'pdf',
|
|
'sourceFile': 'old.pdf',
|
|
'strokes': <String, dynamic>{},
|
|
'highlights': <String, dynamic>{},
|
|
'bookmarks': <dynamic>[],
|
|
'scratchLinks': <dynamic>[],
|
|
};
|
|
final reparsed = BadnoteSidecar.fromJson(json);
|
|
expect(reparsed.pageText, isNull);
|
|
});
|
|
|
|
test('pageText and ocrText coexist independently', () {
|
|
final original = BadnoteSidecar(
|
|
docType: 'pdf',
|
|
ocrText: 'handwritten note',
|
|
pageText: 'printed document body',
|
|
);
|
|
final reparsed = _roundTrip(original);
|
|
expect(reparsed.ocrText, 'handwritten note');
|
|
expect(reparsed.pageText, 'printed document body');
|
|
});
|
|
|
|
test('missing scratchpad defaults to 4000x4000 empty pad', () {
|
|
final json = SidecarScratchLink(
|
|
link: const ScratchLink(
|
|
id: 'a',
|
|
documentId: 'd',
|
|
pageIndex: 0,
|
|
nx: 0,
|
|
ny: 0,
|
|
),
|
|
).toJson();
|
|
json.remove('scratchpad');
|
|
final reparsed = SidecarScratchLink.fromJson(json);
|
|
expect(reparsed.scratchpad.canvasWidth, 4000.0);
|
|
expect(reparsed.scratchpad.canvasHeight, 4000.0);
|
|
expect(reparsed.scratchpad.strokes, isEmpty);
|
|
});
|
|
}
|