// test/sidecar_repository_test.dart // // Phase 2: SidecarRepository is the pen editor's persistence sink (replacing the // SQLite EditorRepository / DatabaseService scratch storage). These tests drive // the repository directly with a tiny debounce + flush() so writes are // deterministic, then RE-OPEN the same file and assert everything restored: // * per-page strokes // * per-page highlights (the previously in-memory-only data) // * scratch-link anchors + their embedded scratchpad ink (absolute world px) // * removing a highlight persists (the "un-highlight" action) import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:badnote/editor/engine/stroke_model.dart'; import 'package:badnote/editor/persistence/sidecar_repository.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/scratch_link.dart'; import 'package:badnote/storage/badnote_sidecar.dart'; const _fast = Duration(milliseconds: 1); EditorStroke _stroke(String id, {EditorTool tool = EditorTool.pen}) => EditorStroke( id: id, points: const [ EditorPoint(x: 0.1, y: 0.2, pressure: 0.5), EditorPoint(x: 0.3, y: 0.4, pressure: 0.7), ], tool: tool, color: 0xFF112233, width: 0.005, ); InkStroke _ink(String id, double x) => InkStroke( id: id, points: [InkPoint(x: x, y: x + 1, timestamp: 0)], tool: PenTool.pen, createdAt: DateTime.utc(2026, 1, 1), ); void main() { late Directory tmpDir; late String src; setUp(() async { tmpDir = await Directory.systemTemp.createTemp('sidecar_repo_test'); src = '${tmpDir.path}/Lecture.pdf'; // The source file doesn't have to exist for the sidecar to work, but create // it so the layout matches reality (sidecar lives alongside the file). await File(src).writeAsString('%PDF-1.7 fake'); }); tearDown(() async { if (await tmpDir.exists()) await tmpDir.delete(recursive: true); }); test('sidecar path is .badnote.json alongside the file', () async { final repo = await SidecarRepository.open(src, debounce: _fast); expect(repo.sidecarFile.path, '$src.badnote.json'); repo.dispose(); }); test('strokes + highlight + scratch-link + scratchpad restore on reopen', () async { final repo = await SidecarRepository.open(src, debounce: _fast); // Page 0 strokes. repo.scheduleStrokeSave(0, [_stroke('a'), _stroke('b')]); // Page 0 highlight (normalized rect). repo.scheduleHighlightSave( 0, const [SidecarHighlight(l: 0.1, t: 0.15, r: 0.8, b: 0.2, color: 0xFFFFFF00)], ); // A scratch-link anchor on page 3, then its private scratchpad ink. const link = ScratchLink( id: 'anchor-1', documentId: 'ignored-uses-path', pageIndex: 3, nx: 0.5, ny: 0.5, ); repo.scheduleScratchLinkUpsert(link); repo.scheduleScratchpadSave( 'anchor-1', SidecarScratchpad( canvasWidth: 5000, canvasHeight: 6000, strokes: [_ink('w0', 100), _ink('w1', 200)], ), ); await repo.flush(); repo.dispose(); // Re-open the SAME file: everything must come back. final reopened = await SidecarRepository.open(src, debounce: _fast); expect(reopened.loadedStrokes[0]?.map((s) => s.id), ['a', 'b']); expect(reopened.loadedStrokes[0]!.first.color, 0xFF112233); final hl = reopened.loadedHighlights[0]!.single; expect(hl.l, 0.1); expect(hl.r, 0.8); expect(hl.color, 0xFFFFFF00); final sl = reopened.loadedScratchLinks.single; expect(sl.link.id, 'anchor-1'); expect(sl.link.pageIndex, 3); expect(sl.scratchpad.canvasWidth, 5000); expect(sl.scratchpad.canvasHeight, 6000); expect(sl.scratchpad.strokes.map((s) => s.id), ['w0', 'w1']); expect(sl.scratchpad.strokes.first.points.single.x, 100); // The scratchpad is addressable by anchor id. expect(reopened.scratchpadFor('anchor-1')!.strokes.length, 2); expect(reopened.scratchpadFor('missing'), isNull); reopened.dispose(); }); test('removing a highlight persists (un-highlight)', () async { final repo = await SidecarRepository.open(src, debounce: _fast); repo.scheduleHighlightSave(0, const [ SidecarHighlight(l: 0.1, t: 0.1, r: 0.4, b: 0.2), SidecarHighlight(l: 0.5, t: 0.5, r: 0.9, b: 0.6), ]); await repo.flush(); repo.dispose(); // Re-open, drop one highlight (mirrors _removeHighlightAt → save), reopen. final mid = await SidecarRepository.open(src, debounce: _fast); expect(mid.loadedHighlights[0]!.length, 2); final remaining = mid.loadedHighlights[0]! .where((h) => h.l != 0.1) // remove the first .toList(); mid.scheduleHighlightSave(0, remaining); await mid.flush(); mid.dispose(); final after = await SidecarRepository.open(src, debounce: _fast); expect(after.loadedHighlights[0]!.length, 1); expect(after.loadedHighlights[0]!.single.l, 0.5); after.dispose(); }); test('removing the last highlight on a page clears the page entry', () async { final repo = await SidecarRepository.open(src, debounce: _fast); repo.scheduleHighlightSave( 0, const [SidecarHighlight(l: 0.1, t: 0.1, r: 0.4, b: 0.2)]); await repo.flush(); repo.scheduleHighlightSave(0, const []); await repo.flush(); repo.dispose(); final after = await SidecarRepository.open(src, debounce: _fast); expect(after.loadedHighlights.containsKey(0), isFalse); after.dispose(); }); test('typed-text annotations persist + restore on reopen', () async { final repo = await SidecarRepository.open(src, debounce: _fast); repo.scheduleTextsSave(2, const [ SidecarText( id: 'tx1', nx: 0.2, ny: 0.3, text: 'glued note', fontSize: 0.04, color: 0xFF112233, ), ]); await repo.flush(); repo.dispose(); final after = await SidecarRepository.open(src, debounce: _fast); final t = after.loadedTexts[2]!.single; expect(t.id, 'tx1'); expect(t.nx, 0.2); expect(t.ny, 0.3); expect(t.text, 'glued note'); expect(t.fontSize, 0.04); expect(t.color, 0xFF112233); after.dispose(); }); test('clearing all text on a page removes the page entry (empty-on-blur)', () async { final repo = await SidecarRepository.open(src, debounce: _fast); repo.scheduleTextsSave( 0, const [SidecarText(id: 'a', nx: 0.1, ny: 0.1, text: 'x')]); await repo.flush(); // The box is emptied + deleted (empty-on-blur), leaving no texts on page 0. repo.scheduleTextsSave(0, const []); await repo.flush(); repo.dispose(); final after = await SidecarRepository.open(src, debounce: _fast); expect(after.loadedTexts.containsKey(0), isFalse); after.dispose(); }); test('deleting a scratch link removes it and its scratchpad', () async { final repo = await SidecarRepository.open(src, debounce: _fast); repo.scheduleScratchLinkUpsert(const ScratchLink( id: 'x', documentId: 'd', pageIndex: 0, nx: 0.2, ny: 0.2, )); repo.scheduleScratchpadSave( 'x', SidecarScratchpad(strokes: [_ink('s', 1)]), ); await repo.flush(); repo.scheduleScratchLinkDelete('x'); await repo.flush(); repo.dispose(); final after = await SidecarRepository.open(src, debounce: _fast); expect(after.loadedScratchLinks, isEmpty); after.dispose(); }); test('paragraph-anchored bookmark restores on reopen', () async { final repo = await SidecarRepository.open(src, debounce: _fast); repo.scheduleBookmarkUpsert(Bookmark( id: 'bm-1', documentId: src, pageNumber: 4, label: 'A precise paragraph', createdAt: DateTime.utc(2026, 6, 24, 12), anchorLeft: 0.12, anchorTop: 0.34, anchorRight: 0.88, anchorBottom: 0.37, charIndex: 512, )); await repo.flush(); repo.dispose(); final reopened = await SidecarRepository.open(src, debounce: _fast); final bm = reopened.loadedBookmarks.single; expect(bm.id, 'bm-1'); expect(bm.pageNumber, 4); expect(bm.label, 'A precise paragraph'); expect(bm.anchorLeft, 0.12); expect(bm.anchorTop, 0.34); expect(bm.anchorRight, 0.88); expect(bm.anchorBottom, 0.37); expect(bm.charIndex, 512); reopened.dispose(); }); test('deleting a bookmark persists', () async { final repo = await SidecarRepository.open(src, debounce: _fast); repo.scheduleBookmarkUpsert(Bookmark( id: 'bm-a', documentId: src, pageNumber: 1, createdAt: DateTime.utc(2026, 6, 24), )); repo.scheduleBookmarkUpsert(Bookmark( id: 'bm-b', documentId: src, pageNumber: 2, createdAt: DateTime.utc(2026, 6, 24), )); await repo.flush(); repo.scheduleBookmarkDelete('bm-a'); await repo.flush(); repo.dispose(); final after = await SidecarRepository.open(src, debounce: _fast); expect(after.loadedBookmarks.map((b) => b.id), ['bm-b']); after.dispose(); }); test('upserting a bookmark by id updates it in place', () async { final repo = await SidecarRepository.open(src, debounce: _fast); final base = Bookmark( id: 'bm-x', documentId: src, pageNumber: 1, label: 'old', createdAt: DateTime.utc(2026, 6, 24), ); repo.scheduleBookmarkUpsert(base); repo.scheduleBookmarkUpsert(base.copyWith(label: 'new', pageNumber: 9)); await repo.flush(); repo.dispose(); final after = await SidecarRepository.open(src, debounce: _fast); expect(after.loadedBookmarks.length, 1); expect(after.loadedBookmarks.single.label, 'new'); expect(after.loadedBookmarks.single.pageNumber, 9); after.dispose(); }); test('upserting a scratch link preserves its existing scratchpad', () async { final repo = await SidecarRepository.open(src, debounce: _fast); const link = ScratchLink(id: 'k', documentId: 'd', pageIndex: 1, nx: 0.1, ny: 0.1); repo.scheduleScratchLinkUpsert(link); repo.scheduleScratchpadSave('k', SidecarScratchpad(strokes: [_ink('s', 7)])); // Re-upsert the same anchor (e.g. moved) — scratchpad must survive. repo.scheduleScratchLinkUpsert(link.copyWith(nx: 0.9)); await repo.flush(); repo.dispose(); final after = await SidecarRepository.open(src, debounce: _fast); final sl = after.loadedScratchLinks.single; expect(sl.link.nx, 0.9); expect(sl.scratchpad.strokes.single.id, 's'); after.dispose(); }); group('Phase 4 standalone notebook (notebook.badnote.json)', () { late String notePath; setUp(() { // The synthetic note "source" is /notebook → sidecar is // /notebook.badnote.json (no real source file on disk). notePath = '${tmpDir.path}/notebook'; }); test('a note\'s strokes (page 0) + title round-trip through the sidecar', () async { final repo = await SidecarRepository.open(notePath, debounce: _fast, docType: 'notebook'); expect(repo.sidecarFile.path, '$notePath.badnote.json'); repo.scheduleTitleSave('My Note'); repo.scheduleStrokeSave(0, [ _stroke('p', tool: EditorTool.pen), _stroke('h', tool: EditorTool.highlighter), ]); await repo.flush(); repo.dispose(); // Re-open the same standalone notebook sidecar. final reopened = await SidecarRepository.open(notePath, debounce: _fast, docType: 'notebook'); expect(reopened.loadedTitle, 'My Note'); expect(reopened.sidecar.docType, 'notebook'); final page0 = reopened.loadedStrokes[0]!; expect(page0.map((s) => s.id), ['p', 'h']); expect(page0.first.tool, EditorTool.pen); expect(page0.last.tool, EditorTool.highlighter); expect(page0.first.color, 0xFF112233); reopened.dispose(); }); test('editing the title persists; unchanged title is a no-op', () async { final repo = await SidecarRepository.open(notePath, debounce: _fast, docType: 'notebook'); repo.scheduleTitleSave('First'); await repo.flush(); // Same title again: no write needed, but flush stays safe. repo.scheduleTitleSave('First'); repo.scheduleTitleSave('Second'); await repo.flush(); repo.dispose(); final reopened = await SidecarRepository.open(notePath, debounce: _fast, docType: 'notebook'); expect(reopened.loadedTitle, 'Second'); reopened.dispose(); }); }); }