feat(storage): sidecar model + atomic store (lib only)
Some checks failed
CI / Windows build (push) Has been cancelled

Phase 1 of the file-based storage plan. Pure library, no runtime
behavior change yet (editors still use SQLite).

- BadnoteSidecar: per-file annotation document (schema-versioned)
  holding per-page ink (EditorStroke JSON), text highlights,
  scratch-link anchors (ScratchLink JSON) each with its own
  scratchpad (InkStroke world-coord JSON), and bookmarks. Reuses the
  existing toJson formats — no parallel stroke format.
- SidecarStore.writeAtomic: temp-file + rename atomic write keeping a
  .bak; read() falls back to .bak on a missing/corrupt primary.

Round-trip + atomic-write + .bak-recovery tests. analyze clean,
322 tests green.
This commit is contained in:
2026-06-24 20:53:01 +08:00
parent 9fcac47ef2
commit 953c7b700f
4 changed files with 794 additions and 0 deletions

View File

@@ -0,0 +1,212 @@
// 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/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),
],
},
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]);
// 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('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.bookmarks, isEmpty);
expect(reparsed.scratchLinks, isEmpty);
expect(reparsed.version, kBadnoteSidecarVersion);
});
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('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);
});
}

View File

@@ -0,0 +1,175 @@
// test/sidecar_store_test.dart
//
// Verifies SidecarStore (§F.1 atomic write + .bak fallback):
// * round-trip through disk preserves the model;
// * a failed (interrupted) write leaves no partial sidecar at the target —
// the previous good content survives;
// * .bak recovery when the primary file is corrupt or missing.
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.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/scratch_link.dart';
import 'package:badnote/storage/badnote_sidecar.dart';
import 'package:badnote/storage/sidecar_store.dart';
BadnoteSidecar _sidecar({String sourceFile = 'doc.pdf'}) => BadnoteSidecar(
sourceFile: sourceFile,
docType: 'pdf',
pageCount: 3,
strokes: {
0: [
EditorStroke(
id: 's0',
points: const [EditorPoint(x: 0.1, y: 0.2, pressure: 0.5)],
color: 0xFF000000,
),
],
},
highlights: {
0: const [SidecarHighlight(l: 0.1, t: 0.1, r: 0.9, b: 0.2)],
},
bookmarks: [
Bookmark(
id: 'bm',
documentId: 'd',
pageNumber: 1,
createdAt: DateTime.utc(2026, 1, 1),
),
],
scratchLinks: [
SidecarScratchLink(
link: const ScratchLink(
id: 'a',
documentId: 'd',
pageIndex: 0,
nx: 0.5,
ny: 0.5,
),
scratchpad: SidecarScratchpad(
strokes: [
InkStroke(
id: 'ink',
points: const [InkPoint(x: 1, y: 2, timestamp: 0)],
tool: PenTool.pen,
createdAt: DateTime.utc(2026, 1, 1),
),
],
),
),
],
);
void main() {
late Directory tmpDir;
setUp(() async {
tmpDir = await Directory.systemTemp.createTemp('sidecar_store_test');
});
tearDown(() async {
if (await tmpDir.exists()) {
await tmpDir.delete(recursive: true);
}
});
File targetFile() => File('${tmpDir.path}/doc.pdf.badnote.json');
test('writeAtomic then read round-trips the model', () async {
final target = targetFile();
final original = _sidecar();
await SidecarStore.writeAtomic(target, original);
expect(await target.exists(), isTrue);
final loaded = await SidecarStore.read(target);
expect(loaded, isNotNull);
expect(loaded!.sourceFile, 'doc.pdf');
expect(loaded.strokes[0], original.strokes[0]);
expect(loaded.highlights[0], original.highlights[0]);
expect(loaded.bookmarks, original.bookmarks);
expect(loaded.scratchLinks, original.scratchLinks);
});
test('write leaves no leftover .tmp file', () async {
final target = targetFile();
await SidecarStore.writeAtomic(target, _sidecar());
final tmp = File('${target.path}${SidecarStore.tmpSuffix}');
expect(await tmp.exists(), isFalse);
});
test('writeAtomic creates parent directories', () async {
final nested =
File('${tmpDir.path}/nested/folder/doc.pdf.badnote.json');
await SidecarStore.writeAtomic(nested, _sidecar());
expect(await nested.exists(), isTrue);
});
test('second write keeps the previous content as .bak', () async {
final target = targetFile();
await SidecarStore.writeAtomic(target, _sidecar(sourceFile: 'v1.pdf'));
await SidecarStore.writeAtomic(target, _sidecar(sourceFile: 'v2.pdf'));
final bak = File('${target.path}${SidecarStore.bakSuffix}');
expect(await bak.exists(), isTrue);
final bakModel =
BadnoteSidecar.fromJson(jsonDecode(await bak.readAsString()));
expect(bakModel.sourceFile, 'v1.pdf'); // previous good copy
final current = await SidecarStore.read(target);
expect(current!.sourceFile, 'v2.pdf');
});
test('simulated interrupted write leaves previous good file intact', () async {
final target = targetFile();
await SidecarStore.writeAtomic(target, _sidecar(sourceFile: 'good.pdf'));
// Simulate a crash mid-write: a partial .tmp is created but the rename
// never happened.
final tmp = File('${target.path}${SidecarStore.tmpSuffix}');
await tmp.writeAsString('{ "badnoteSidecarVersion": 1, "sourceFil');
// The target still holds the last good content — no partial leakage.
final loaded = await SidecarStore.read(target);
expect(loaded, isNotNull);
expect(loaded!.sourceFile, 'good.pdf');
});
test('.bak recovery when primary is corrupt', () async {
final target = targetFile();
await SidecarStore.writeAtomic(target, _sidecar(sourceFile: 'v1.pdf'));
await SidecarStore.writeAtomic(target, _sidecar(sourceFile: 'v2.pdf'));
// Corrupt the primary file.
await target.writeAsString('}{ not json at all');
final recovered = await SidecarStore.read(target);
expect(recovered, isNotNull);
expect(recovered!.sourceFile, 'v1.pdf'); // fell back to .bak
});
test('.bak recovery when primary is missing', () async {
final target = targetFile();
await SidecarStore.writeAtomic(target, _sidecar(sourceFile: 'v1.pdf'));
await SidecarStore.writeAtomic(target, _sidecar(sourceFile: 'v2.pdf'));
await target.delete();
final recovered = await SidecarStore.read(target);
expect(recovered, isNotNull);
expect(recovered!.sourceFile, 'v1.pdf');
});
test('read returns null when nothing exists', () async {
final loaded = await SidecarStore.read(targetFile());
expect(loaded, isNull);
});
}