2026-06-24 21:03:28 +08:00
|
|
|
// 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';
|
2026-06-25 00:00:16 +08:00
|
|
|
import 'package:badnote/models/bookmark.dart';
|
2026-06-24 21:03:28 +08:00
|
|
|
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 <sourceFile>.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('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();
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-25 00:00:16 +08:00
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-24 21:03:28 +08:00
|
|
|
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();
|
|
|
|
|
});
|
2026-06-24 22:48:18 +08:00
|
|
|
|
|
|
|
|
group('Phase 4 standalone notebook (notebook.badnote.json)', () {
|
|
|
|
|
late String notePath;
|
|
|
|
|
|
|
|
|
|
setUp(() {
|
|
|
|
|
// The synthetic note "source" is <folder>/notebook → sidecar is
|
|
|
|
|
// <folder>/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();
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-06-24 21:03:28 +08:00
|
|
|
}
|