diff --git a/lib/storage/badnote_sidecar.dart b/lib/storage/badnote_sidecar.dart new file mode 100644 index 0000000..be1141d --- /dev/null +++ b/lib/storage/badnote_sidecar.dart @@ -0,0 +1,323 @@ +// lib/storage/badnote_sidecar.dart +// +// The on-disk sidecar model: all annotations for one source file, serialized as +// `.badnote.json` next to the file ("跟着文件走"). This is Phase 1 of the +// file-based storage plan (docs/plans/2026-06-24-file-based-storage.md §A) — a +// pure model with NO runtime wiring yet. +// +// Design rule: REUSE the existing JSON shapes verbatim; do not invent a parallel +// stroke format. Specifically: +// * per-page ink → List (lib/editor/engine/stroke_model.dart; +// byte-for-byte the `ink.stroke_json` column today) +// * scratchpad ink → List (lib/models/ink_stroke.dart; the exact +// format scratchpads already persist, absolute world px) +// * scratch anchors → ScratchLink (lib/models/scratch_link.dart) +// * bookmarks → Bookmark (lib/models/bookmark.dart) +// +// Only the *containers* and the (previously in-memory-only) highlight rect are +// new here. Unknown JSON fields are ignored on read so the schema is +// forward-compatible (e.g. a future `brush` field — see §A.5 brush TODO). + +import 'dart:ui' show Rect; + +import '../editor/engine/stroke_model.dart'; +import '../models/bookmark.dart'; +import '../models/ink_stroke.dart'; +import '../models/scratch_link.dart'; + +/// Current sidecar schema version. Persisted as `badnoteSidecarVersion` for +/// forward-compat; readers tolerate unknown extra fields. +const int kBadnoteSidecarVersion = 1; + +/// A single highlighted text rectangle on a page, normalized to the page rect +/// ([0,1] for l/t/r/b — exactly as `_highlightSelection` computes it in +/// pen_editor_screen.dart) plus an ARGB [color]. There is no existing highlight +/// MODEL in the codebase (highlights are in-memory `Rect`s today, see +/// `TODO(persist-highlights)`), so this small value class is the representation. +class SidecarHighlight { + const SidecarHighlight({ + required this.l, + required this.t, + required this.r, + required this.b, + this.color = 0xFFFFFF00, + }); + + /// Normalized left edge in [0,1]. + final double l; + + /// Normalized top edge in [0,1]. + final double t; + + /// Normalized right edge in [0,1]. + final double r; + + /// Normalized bottom edge in [0,1]. + final double b; + + /// ARGB color of the highlight. + final int color; + + /// Builds a highlight from a normalized [Rect] (as stored in + /// `_highlightsByPage`) and an ARGB color. + factory SidecarHighlight.fromRect(Rect rect, {int color = 0xFFFFFF00}) => + SidecarHighlight( + l: rect.left, + t: rect.top, + r: rect.right, + b: rect.bottom, + color: color, + ); + + /// The normalized rect (page-relative) for rendering. + Rect toRect() => Rect.fromLTRB(l, t, r, b); + + Map toJson() => { + 'l': l, + 't': t, + 'r': r, + 'b': b, + 'color': color, + }; + + factory SidecarHighlight.fromJson(Map json) => + SidecarHighlight( + l: (json['l'] as num).toDouble(), + t: (json['t'] as num).toDouble(), + r: (json['r'] as num).toDouble(), + b: (json['b'] as num).toDouble(), + color: (json['color'] as num?)?.toInt() ?? 0xFFFFFF00, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SidecarHighlight && + runtimeType == other.runtimeType && + l == other.l && + t == other.t && + r == other.r && + b == other.b && + color == other.color; + + @override + int get hashCode => Object.hash(l, t, r, b, color); + + @override + String toString() => + 'SidecarHighlight(l: $l, t: $t, r: $r, b: $b, color: $color)'; +} + +/// An anchor's private infinite scratchpad: a list of [InkStroke]s in ABSOLUTE +/// world pixels (unchanged format from `SplitViewScreen`), plus the world size +/// so it restores (today the canvas always resets to 4000×4000). +class SidecarScratchpad { + const SidecarScratchpad({ + this.canvasWidth = 4000.0, + this.canvasHeight = 4000.0, + this.strokes = const [], + }); + + final double canvasWidth; + final double canvasHeight; + + /// Absolute-world-pixel strokes, in `InkStroke.toJson()` format. + final List strokes; + + Map toJson() => { + 'canvasWidth': canvasWidth, + 'canvasHeight': canvasHeight, + 'strokes': strokes.map((s) => s.toJson()).toList(), + }; + + factory SidecarScratchpad.fromJson(Map json) => + SidecarScratchpad( + canvasWidth: (json['canvasWidth'] as num?)?.toDouble() ?? 4000.0, + canvasHeight: (json['canvasHeight'] as num?)?.toDouble() ?? 4000.0, + strokes: ((json['strokes'] as List?) ?? const []) + .map((e) => InkStroke.fromJson(e as Map)) + .toList(), + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SidecarScratchpad && + runtimeType == other.runtimeType && + canvasWidth == other.canvasWidth && + canvasHeight == other.canvasHeight && + _listEq(strokes, other.strokes); + + @override + int get hashCode => + Object.hash(canvasWidth, canvasHeight, Object.hashAll(strokes)); + + @override + String toString() => 'SidecarScratchpad(canvasWidth: $canvasWidth, ' + 'canvasHeight: $canvasHeight, strokes: ${strokes.length})'; +} + +/// A scratch link anchor that EMBEDS its private scratchpad (merges today's two +/// SQLite tables — `scratch_links` geometry + `scratchpads` ink — see §A.2). +class SidecarScratchLink { + const SidecarScratchLink({ + required this.link, + this.scratchpad = const SidecarScratchpad(), + }); + + /// Anchor geometry (reuses [ScratchLink] verbatim). + final ScratchLink link; + + /// The anchor's private scratchpad. + final SidecarScratchpad scratchpad; + + Map toJson() => { + ...link.toJson(), + 'scratchpad': scratchpad.toJson(), + }; + + factory SidecarScratchLink.fromJson(Map json) => + SidecarScratchLink( + link: ScratchLink.fromJson(json), + scratchpad: json['scratchpad'] == null + ? const SidecarScratchpad() + : SidecarScratchpad.fromJson( + json['scratchpad'] as Map), + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SidecarScratchLink && + runtimeType == other.runtimeType && + link == other.link && + scratchpad == other.scratchpad; + + @override + int get hashCode => Object.hash(link, scratchpad); + + @override + String toString() => + 'SidecarScratchLink(link: $link, scratchpad: $scratchpad)'; +} + +/// The whole sidecar: all annotations for one source file. +/// +/// Maps to the JSON in §A.2 of the plan. `strokes` and `highlights` are keyed by +/// page index. Strokes reuse [EditorStroke] JSON; bookmarks reuse [Bookmark] +/// JSON; scratch links reuse [ScratchLink] JSON (embedding [InkStroke] JSON for +/// the scratchpad). +class BadnoteSidecar { + BadnoteSidecar({ + this.version = kBadnoteSidecarVersion, + this.sourceFile, + this.docType, + this.pageCount, + this.rotation = 0, + this.createdAt, + this.updatedAt, + Map>? strokes, + Map>? highlights, + List? bookmarks, + List? scratchLinks, + }) : strokes = strokes ?? >{}, + highlights = highlights ?? >{}, + bookmarks = bookmarks ?? [], + scratchLinks = scratchLinks ?? []; + + /// Schema version (`badnoteSidecarVersion`). + final int version; + + /// Basename of the annotated source file, e.g. `Calculus Lecture 3.pdf`. + final String? sourceFile; + + /// `pdf` / `pptx` / `notebook` etc. + final String? docType; + + final int? pageCount; + final int rotation; + final DateTime? createdAt; + final DateTime? updatedAt; + + /// Page index → committed [EditorStroke]s (normalized page coords). + final Map> strokes; + + /// Page index → highlighted text rects (normalized). + final Map> highlights; + + final List bookmarks; + final List scratchLinks; + + Map toJson() => { + 'badnoteSidecarVersion': version, + if (sourceFile != null) 'sourceFile': sourceFile, + if (docType != null) 'docType': docType, + if (pageCount != null) 'pageCount': pageCount, + 'rotation': rotation, + if (createdAt != null) 'createdAt': createdAt!.toIso8601String(), + if (updatedAt != null) 'updatedAt': updatedAt!.toIso8601String(), + 'strokes': { + for (final entry in strokes.entries) + entry.key.toString(): + entry.value.map((s) => s.toJson()).toList(), + }, + 'highlights': { + for (final entry in highlights.entries) + entry.key.toString(): + entry.value.map((h) => h.toJson()).toList(), + }, + 'bookmarks': bookmarks.map((b) => b.toJson()).toList(), + 'scratchLinks': scratchLinks.map((s) => s.toJson()).toList(), + }; + + factory BadnoteSidecar.fromJson(Map json) { + Map> decodePageMap( + Object? raw, + T Function(Map) item, + ) { + final out = >{}; + if (raw is Map) { + raw.forEach((key, value) { + final pageIndex = int.tryParse(key.toString()); + if (pageIndex == null || value is! List) return; + out[pageIndex] = value + .map((e) => item(e as Map)) + .toList(); + }); + } + return out; + } + + return BadnoteSidecar( + version: (json['badnoteSidecarVersion'] as num?)?.toInt() ?? + kBadnoteSidecarVersion, + sourceFile: json['sourceFile'] as String?, + docType: json['docType'] as String?, + pageCount: (json['pageCount'] as num?)?.toInt(), + rotation: (json['rotation'] as num?)?.toInt() ?? 0, + createdAt: json['createdAt'] == null + ? null + : DateTime.tryParse(json['createdAt'] as String), + updatedAt: json['updatedAt'] == null + ? null + : DateTime.tryParse(json['updatedAt'] as String), + strokes: decodePageMap(json['strokes'], EditorStroke.fromJson), + highlights: decodePageMap(json['highlights'], SidecarHighlight.fromJson), + bookmarks: ((json['bookmarks'] as List?) ?? const []) + .map((e) => Bookmark.fromJson(e as Map)) + .toList(), + scratchLinks: ((json['scratchLinks'] as List?) ?? const []) + .map((e) => SidecarScratchLink.fromJson(e as Map)) + .toList(), + ); + } +} + +bool _listEq(List a, List b) { + if (identical(a, b)) return true; + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; +} diff --git a/lib/storage/sidecar_store.dart b/lib/storage/sidecar_store.dart new file mode 100644 index 0000000..8762c24 --- /dev/null +++ b/lib/storage/sidecar_store.dart @@ -0,0 +1,84 @@ +// lib/storage/sidecar_store.dart +// +// Atomic read/write for `.badnote.json` sidecars (Phase 1 / §F.1 of +// docs/plans/2026-06-24-file-based-storage.md). Pure dart:io, NO UI. +// +// Write protocol (§F.1): +// 1. Serialize to pretty JSON, write to `.tmp` with flush:true. +// 2. Before clobbering, copy the current good `` to `.bak` +// (one-deep backup — cheap insurance against a corrupt write). +// 3. `rename` tmp → target. rename is atomic on the same filesystem (NTFS / +// POSIX), so a reader never observes a half-written sidecar. +// +// Read protocol: parse ``; if it is missing OR fails to parse, fall back +// to `.bak`. If neither yields valid JSON, return null. + +import 'dart:convert'; +import 'dart:io'; + +import 'badnote_sidecar.dart'; + +/// Stateless helper namespace for sidecar persistence. +class SidecarStore { + const SidecarStore._(); + + static const JsonEncoder _encoder = JsonEncoder.withIndent(' '); + + /// Suffix for the in-progress temp file. + static const String tmpSuffix = '.tmp'; + + /// Suffix for the one-deep backup of the last good sidecar. + static const String bakSuffix = '.bak'; + + /// Atomically writes [sidecar] to [target] (temp + rename), keeping a `.bak` + /// of the previous good file. Never leaves a partial sidecar at [target]: + /// either the previous content (on failure before rename) or the new content. + static Future writeAtomic(File target, BadnoteSidecar sidecar) async { + final json = _encoder.convert(sidecar.toJson()); + await writeAtomicJson(target, json); + } + + /// Lower-level variant for callers that already hold the JSON string. + static Future writeAtomicJson(File target, String json) async { + await target.parent.create(recursive: true); + + final tmp = File('${target.path}$tmpSuffix'); + await tmp.writeAsString(json, flush: true); + + // Back up the previous good file before clobbering it. + if (await target.exists()) { + final bak = File('${target.path}$bakSuffix'); + try { + await target.copy(bak.path); + } catch (_) { + // A failed backup must not block the write; the atomic rename below + // still guarantees the new content lands intact. + } + } + + // Atomic on the same filesystem. + await tmp.rename(target.path); + } + + /// Reads and parses the sidecar at [target], falling back to `.bak` + /// if the primary is missing or corrupt. Returns null if neither is readable. + static Future read(File target) async { + final primary = await _tryRead(target); + if (primary != null) return primary; + + final bak = File('${target.path}$bakSuffix'); + return _tryRead(bak); + } + + static Future _tryRead(File file) async { + try { + if (!await file.exists()) return null; + final raw = await file.readAsString(); + final decoded = jsonDecode(raw); + if (decoded is! Map) return null; + return BadnoteSidecar.fromJson(decoded); + } catch (_) { + return null; + } + } +} diff --git a/test/badnote_sidecar_test.dart b/test/badnote_sidecar_test.dart new file mode 100644 index 0000000..91986ac --- /dev/null +++ b/test/badnote_sidecar_test.dart @@ -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); + +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; + 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); + }); +} diff --git a/test/sidecar_store_test.dart b/test/sidecar_store_test.dart new file mode 100644 index 0000000..323e332 --- /dev/null +++ b/test/sidecar_store_test.dart @@ -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); + }); +}