Files
BadNote/test/sidecar_store_test.dart
Akiba So 953c7b700f
Some checks failed
CI / Windows build (push) Has been cancelled
feat(storage): sidecar model + atomic store (lib only)
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.
2026-06-24 20:53:01 +08:00

176 lines
5.6 KiB
Dart

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