// 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); 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; 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; 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; 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 = { 'badnoteSidecarVersion': 1, 'docType': 'pdf', 'sourceFile': 'old.pdf', 'strokes': {}, 'highlights': {}, 'bookmarks': [], 'scratchLinks': [], }; 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); }); }