// test/scratch_link_test.dart // // Guards the PDF-anchored scratch-link feature: // 1. ScratchLink toJson/fromJson round-trip (pure model). // 2. saveScratchLink / loadScratchLinks / deleteScratchLink round-trip a set // of anchors through DatabaseService (anchors survive a reload). // 3. A per-anchor scratchpad keyed by the anchor id is private to that anchor // and is removed when the anchor is deleted (proves the reuse of the // existing scratchpad storage keyed by anchor id, not document id). // // DatabaseService.getInstance() needs getApplicationDocumentsDirectory(); we // mock PathProviderPlatform to a temp dir so the singleton opens a real (ffi) // sqlite DB on disk. import 'dart:convert'; import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; // ignore: depend_on_referenced_packages import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; // ignore: depend_on_referenced_packages import 'package:plugin_platform_interface/plugin_platform_interface.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.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/services/database_service.dart'; class _FakePathProvider extends PathProviderPlatform with MockPlatformInterfaceMixin { _FakePathProvider(this.dir); final String dir; @override Future getApplicationDocumentsPath() async => dir; } void main() { TestWidgetsFlutterBinding.ensureInitialized(); sqfliteFfiInit(); databaseFactory = databaseFactoryFfi; group('ScratchLink model', () { test('toJson/fromJson round-trips', () { const link = ScratchLink( id: 'anchor-1', documentId: 'doc-abc', pageIndex: 3, nx: 0.25, ny: 0.8, ); final restored = ScratchLink.fromJson(jsonDecode(jsonEncode(link.toJson())) as Map); expect(restored, link); expect(restored.pageIndex, 3); expect(restored.nx, 0.25); expect(restored.ny, 0.8); }); }); group('ScratchLink persistence', () { late Directory tmp; setUp(() async { tmp = await Directory.systemTemp.createTemp('scratch_link_test_'); final dbFile = File('${tmp.path}/badnote.db'); if (dbFile.existsSync()) dbFile.deleteSync(); PathProviderPlatform.instance = _FakePathProvider(tmp.path); await DatabaseService.resetForTest(); }); tearDown(() async { await DatabaseService.resetForTest(); try { tmp.deleteSync(recursive: true); } catch (_) {} }); test('save / load / delete anchors round-trip', () async { final db = await DatabaseService.getInstance(); const a = ScratchLink( id: 'a', documentId: 'doc-1', pageIndex: 0, nx: 0.1, ny: 0.2, ); const b = ScratchLink( id: 'b', documentId: 'doc-1', pageIndex: 4, nx: 0.7, ny: 0.9, ); // A different document's anchor must not leak into doc-1's list. const other = ScratchLink( id: 'c', documentId: 'doc-2', pageIndex: 1, nx: 0.5, ny: 0.5, ); await db.saveScratchLink(a); await db.saveScratchLink(b); await db.saveScratchLink(other); final loaded = await db.loadScratchLinks('doc-1'); expect(loaded.length, 2); expect(loaded.map((l) => l.id).toSet(), {'a', 'b'}); final reloadedA = loaded.firstWhere((l) => l.id == 'a'); expect(reloadedA.pageIndex, 0); expect(reloadedA.nx, 0.1); expect(reloadedA.ny, 0.2); // doc-2 keeps its own anchor. expect((await db.loadScratchLinks('doc-2')).single.id, 'c'); await db.deleteScratchLink('a'); final after = await db.loadScratchLinks('doc-1'); expect(after.map((l) => l.id), ['b']); }); test('each anchor has its own private scratchpad keyed by anchor id', () async { final db = await DatabaseService.getInstance(); InkStroke stroke(String id, double x) => InkStroke( id: id, points: [ InkPoint(x: x, y: x, pressure: 0.5, timestamp: 0), ], tool: PenTool.pen, createdAt: DateTime.fromMillisecondsSinceEpoch(0), ); // Two anchors, two different private scratchpads (keyed by anchor id). final inkA = [stroke('s1', 10)]; final inkB = [stroke('s2', 20)]; await db.saveScratchpad( 'anchor-A', jsonEncode(inkA.map((s) => s.toJson()).toList())); await db.saveScratchpad( 'anchor-B', jsonEncode(inkB.map((s) => s.toJson()).toList())); final loadedA = await db.loadScratchpad('anchor-A'); final loadedB = await db.loadScratchpad('anchor-B'); expect(loadedA.single.points.single.x, 10); expect(loadedB.single.points.single.x, 20); // Deleting the anchor removes its private scratchpad too. await db.saveScratchLink(const ScratchLink( id: 'anchor-A', documentId: 'doc-9', pageIndex: 0, nx: 0.0, ny: 0.0, )); await db.deleteScratchLink('anchor-A'); expect(await db.loadScratchpad('anchor-A'), isEmpty); // anchor-B is untouched. expect((await db.loadScratchpad('anchor-B')).single.points.single.x, 20); }); }); }