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:
175
test/sidecar_store_test.dart
Normal file
175
test/sidecar_store_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user