feat(storage): sidecar model + atomic store (lib only)
Some checks failed
CI / Windows build (push) Has been cancelled
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:
212
test/badnote_sidecar_test.dart
Normal file
212
test/badnote_sidecar_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user