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