// Proves the Phase-0 vault root recording: the path persists through // SharedPreferences, vaultRootValid() is true ONLY when a path is set AND its // directory exists, and clearVaultRoot() forgets it. Uses a real temp dir for // existence checks and a mock SharedPreferences for persistence. import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:badnote/services/vault_service.dart'; import 'package:badnote/storage/sidecar_store.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); late Directory tempDir; setUp(() async { SharedPreferences.setMockInitialValues({}); tempDir = await Directory.systemTemp.createTemp('vault_service_test'); }); tearDown(() async { if (await tempDir.exists()) { await tempDir.delete(recursive: true); } }); Future makeService() async { final prefs = await SharedPreferences.getInstance(); return VaultService.forTest(prefs); } test('starts with no vault root and is invalid', () async { final vault = await makeService(); expect(vault.vaultRoot, isNull); expect(await vault.vaultRootValid(), isFalse); }); test('setVaultRoot persists the path', () async { final vault = await makeService(); await vault.setVaultRoot(tempDir.path); expect(vault.vaultRoot, tempDir.path); // Re-read through a fresh service over the SAME prefs store: persisted. final prefs = await SharedPreferences.getInstance(); expect(prefs.getString(VaultService.vaultRootKey), tempDir.path); final reopened = VaultService.forTest(prefs); expect(reopened.vaultRoot, tempDir.path); }); test('vaultRootValid is true only when set AND the dir exists', () async { final vault = await makeService(); // Set to an existing dir → valid. await vault.setVaultRoot(tempDir.path); expect(await vault.vaultRootValid(), isTrue); // Point at a path that does not exist → invalid (folder went missing). final missing = '${tempDir.path}${Platform.pathSeparator}gone'; await vault.setVaultRoot(missing); expect(vault.vaultRoot, missing); expect(await vault.vaultRootValid(), isFalse); }); test('clearVaultRoot forgets the path and becomes invalid', () async { final vault = await makeService(); await vault.setVaultRoot(tempDir.path); expect(await vault.vaultRootValid(), isTrue); await vault.clearVaultRoot(); expect(vault.vaultRoot, isNull); expect(await vault.vaultRootValid(), isFalse); }); test('empty stored path is treated as invalid', () async { final vault = await makeService(); await vault.setVaultRoot(''); expect(await vault.vaultRootValid(), isFalse); }); group('createNotebook', () { late Directory srcDir; setUp(() async { srcDir = await Directory.systemTemp.createTemp('vault_src'); }); tearDown(() async { if (await srcDir.exists()) await srcDir.delete(recursive: true); }); Future makeSource(String name, [String content = 'pdf-bytes']) async { final f = File('${srcDir.path}${Platform.pathSeparator}$name'); await f.writeAsString(content); return f.path; } test('creates folder, copies file, returns the in-vault path', () async { final vault = await makeService(); await vault.setVaultRoot(tempDir.path); final src = await makeSource('Calculus Lecture 3.pdf', 'hello'); final vaultPath = await vault.createNotebook(src); // Returned path is inside the vault, in a folder named from the file. final expectedFolder = '${tempDir.path}${Platform.pathSeparator}Calculus Lecture 3'; expect(Directory(expectedFolder).existsSync(), isTrue); expect( vaultPath, '$expectedFolder${Platform.pathSeparator}Calculus Lecture 3.pdf', ); // The file was copied with its contents intact. expect(File(vaultPath).existsSync(), isTrue); expect(File(vaultPath).readAsStringSync(), 'hello'); // The original is untouched (copy, not move). expect(File(src).existsSync(), isTrue); }); test('de-duplicates the folder name on collision', () async { final vault = await makeService(); await vault.setVaultRoot(tempDir.path); final a = await makeSource('Notes.pdf', 'A'); final firstPath = await vault.createNotebook(a); expect( firstPath, '${tempDir.path}${Platform.pathSeparator}Notes${Platform.pathSeparator}Notes.pdf', ); // Re-import a file with the same basename → suffixed folder. final b = await makeSource('Notes.pdf', 'B'); final secondPath = await vault.createNotebook(b); expect( secondPath, '${tempDir.path}${Platform.pathSeparator}Notes 2${Platform.pathSeparator}Notes.pdf', ); expect(File(secondPath).readAsStringSync(), 'B'); // First copy still intact. expect(File(firstPath).readAsStringSync(), 'A'); }); test('throws when no vault root is set', () async { final vault = await makeService(); final src = await makeSource('x.pdf'); expect(() => vault.createNotebook(src), throwsStateError); }); }); group('scanNotebooks', () { Future writeFile(String path, [String content = 'x']) async { final f = File(path); await f.create(recursive: true); await f.writeAsString(content); return f.path; } test('empty / missing vault yields an empty list', () async { final vault = await makeService(); // No root set. expect(await vault.scanNotebooks(), isEmpty); // Root set but empty. await vault.setVaultRoot(tempDir.path); expect(await vault.scanNotebooks(), isEmpty); }); test('lists notebook folders that contain a source file', () async { final vault = await makeService(); await vault.setVaultRoot(tempDir.path); final sep = Platform.pathSeparator; // A PDF notebook with a sidecar. await writeFile('${tempDir.path}${sep}Lecture${sep}Lecture.pdf'); await writeFile( '${tempDir.path}${sep}Lecture${sep}Lecture.pdf.badnote.json', '{}', ); // A PPTX notebook without a sidecar. await writeFile('${tempDir.path}${sep}Deck${sep}Deck.pptx'); // A hidden vault-metadata folder is skipped. await writeFile('${tempDir.path}$sep.badnote${sep}vault.json', '{}'); // A folder with no importable file is skipped. await writeFile('${tempDir.path}${sep}Empty${sep}readme.txt'); final notebooks = await vault.scanNotebooks(); final byName = {for (final n in notebooks) n.filename: n}; expect(byName.keys, containsAll(['Lecture.pdf', 'Deck.pptx'])); expect(notebooks.length, 2); expect(byName['Lecture.pdf']!.docType, 'pdf'); expect(byName['Lecture.pdf']!.hasSidecar, isTrue); expect(byName['Deck.pptx']!.docType, 'pptx'); expect(byName['Deck.pptx']!.hasSidecar, isFalse); }); test('a created notebook is discoverable by the scan', () async { final vault = await makeService(); await vault.setVaultRoot(tempDir.path); final srcDir = await Directory.systemTemp.createTemp('vault_scan_src'); try { final src = '${srcDir.path}${Platform.pathSeparator}Doc.pdf'; await File(src).writeAsString('bytes'); await vault.createNotebook(src); final notebooks = await vault.scanNotebooks(); expect(notebooks.length, 1); expect(notebooks.first.filename, 'Doc.pdf'); expect(notebooks.first.docType, 'pdf'); } finally { await srcDir.delete(recursive: true); } }); }); group('createEmptyNotebook + scanNotes (Phase 4 standalone notebooks)', () { test('writes notebook.badnote.json with the title and docType notebook', () async { final vault = await makeService(); await vault.setVaultRoot(tempDir.path); final sep = Platform.pathSeparator; final notePath = await vault.createEmptyNotebook('My Algebra Notes'); // Synthetic note path is /notebook, folder named from the title. final expectedFolder = '${tempDir.path}${sep}My Algebra Notes'; expect(notePath, '$expectedFolder${sep}notebook'); // The sidecar exists at /notebook.badnote.json and carries the // title + docType, but NO fake source file was created. final sidecarFile = File('$notePath.badnote.json'); expect(sidecarFile.existsSync(), isTrue); final loaded = await SidecarStore.read(sidecarFile); expect(loaded, isNotNull); expect(loaded!.title, 'My Algebra Notes'); expect(loaded.docType, 'notebook'); // The folder holds only the sidecar (and possibly its .bak/.tmp), never // an importable source file. final files = Directory(expectedFolder) .listSync() .whereType() .map((f) => f.uri.pathSegments.last) .toList(); expect(files, contains('notebook.badnote.json')); expect( files.any((n) => n.endsWith('.pdf') || n.endsWith('.pptx') || n.endsWith('.docx') || n.endsWith('.ppt')), isFalse, ); }); test('de-duplicates the notebook folder name on title collision', () async { final vault = await makeService(); await vault.setVaultRoot(tempDir.path); final sep = Platform.pathSeparator; final a = await vault.createEmptyNotebook('Journal'); final b = await vault.createEmptyNotebook('Journal'); expect(a, '${tempDir.path}${sep}Journal${sep}notebook'); expect(b, '${tempDir.path}${sep}Journal 2${sep}notebook'); }); test('blank title falls back to an Untitled folder, null sidecar title', () async { final vault = await makeService(); await vault.setVaultRoot(tempDir.path); final sep = Platform.pathSeparator; final notePath = await vault.createEmptyNotebook(' '); expect(notePath, '${tempDir.path}${sep}Untitled${sep}notebook'); final loaded = await SidecarStore.read(File('$notePath.badnote.json')); expect(loaded!.title, isNull); }); test('scanNotes lists note folders; scanNotebooks excludes them', () async { final vault = await makeService(); await vault.setVaultRoot(tempDir.path); final sep = Platform.pathSeparator; // A standalone note. final notePath = await vault.createEmptyNotebook('Ideas'); // A file-backed document notebook. final docFolder = '${tempDir.path}${sep}Lecture'; final doc = File('$docFolder${sep}Lecture.pdf'); await doc.create(recursive: true); await doc.writeAsString('pdf'); final notes = await vault.scanNotes(); expect(notes.length, 1, reason: 'only the standalone note is a note'); expect(notes.first.title, 'Ideas'); expect(notes.first.notePath, notePath); expect(notes.first.folderPath, '${tempDir.path}${sep}Ideas'); // The document notebook is NOT a note... expect(notes.any((n) => n.folderPath == docFolder), isFalse); // ...and the standalone note is NOT a document. final notebooks = await vault.scanNotebooks(); expect(notebooks.length, 1); expect(notebooks.first.filename, 'Lecture.pdf'); expect( notebooks.any((nb) => nb.folderPath == '${tempDir.path}${sep}Ideas'), isFalse, ); }); test('note title round-trips through the sidecar (title persists)', () async { final vault = await makeService(); await vault.setVaultRoot(tempDir.path); final notePath = await vault.createEmptyNotebook('Round Trip'); final notes = await vault.scanNotes(); expect(notes.single.title, 'Round Trip'); // Re-open the sidecar directly: the title is still there. final loaded = await SidecarStore.read(File('$notePath.badnote.json')); expect(loaded!.title, 'Round Trip'); }); test('a note folder whose sidecar title is missing falls back to folder', () async { final vault = await makeService(); await vault.setVaultRoot(tempDir.path); final sep = Platform.pathSeparator; // Hand-write a note sidecar with no title field. final folder = '${tempDir.path}${sep}Loose Note'; final sidecar = File('$folder${sep}notebook.badnote.json'); await sidecar.create(recursive: true); await sidecar.writeAsString('{"docType":"notebook"}'); final notes = await vault.scanNotes(); expect(notes.single.title, 'Loose Note'); }); }); group('notebook container (OneNote-style)', () { test('createNotebookContainer writes manifest + blank page', () async { final vault = await makeService(); await vault.setVaultRoot(tempDir.path); final c = await vault.createNotebookContainer('Linear Algebra'); expect(c.title, 'Linear Algebra'); expect(c.memberCount, 1); final containers = await vault.scanContainers(); expect(containers.map((x) => x.folderPath), contains(c.folderPath)); // Containers are excluded from the single-doc / free-note scans. expect(await vault.scanNotes(), isEmpty); expect(await vault.scanNotebooks(), isEmpty); final page = await vault.addBlankPageToContainer( c.folderPath, title: 'Lecture 2', ); expect(page.title, 'Lecture 2'); final again = await vault.scanContainers(); expect(again.single.memberCount, 2); }); }); }