176 lines
5.6 KiB
Dart
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);
|
||
|
|
});
|
||
|
|
}
|