Files
BadNote/test/sidecar_repository_test.dart
Akiba So 978111eeff
Some checks failed
CI / Windows build (push) Has been cancelled
feat(storage): PDF editor persists to per-file sidecar
Phase 2 (core swap). The PDF editor and split-view scratchpad stop
writing SQLite and persist to a per-file sidecar
`<pdfPath>.badnote.json` (debounced, atomic temp+rename+.bak) — so
annotations travel with the file. The source path is the identity
(no more djb2 doc-id).

- SidecarRepository wraps the Phase-1 store with debounced autosave.
- pen_editor: per-page ink, scratch-links AND highlights now persist
  to the sidecar and restore on reopen (closes persist-highlights).
- New "un-highlight" tool: tap a stored highlight to remove it — the
  highlight could not be removed before.
- split_view: each anchor's scratchpad lives in the sidecar's
  scratchLinks[id].scratchpad, keyed by anchor id.

Note: pre-existing SQLite annotations are migrated later (Phase 5);
note/slide editors swap in Phase 4. analyze clean, tests green.
2026-06-24 21:03:28 +08:00

204 lines
6.9 KiB
Dart

// 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/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();
});
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();
});
}