2026-06-24 20:45:16 +08:00
|
|
|
// 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';
|
2026-06-24 22:48:18 +08:00
|
|
|
import 'package:badnote/storage/sidecar_store.dart';
|
2026-06-24 20:45:16 +08:00
|
|
|
|
|
|
|
|
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<VaultService> 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);
|
|
|
|
|
});
|
2026-06-24 21:12:38 +08:00
|
|
|
|
|
|
|
|
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<String> 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<String> 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);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-06-24 22:48:18 +08:00
|
|
|
|
|
|
|
|
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 <folder>/notebook, folder named from the title.
|
|
|
|
|
final expectedFolder = '${tempDir.path}${sep}My Algebra Notes';
|
|
|
|
|
expect(notePath, '$expectedFolder${sep}notebook');
|
|
|
|
|
|
|
|
|
|
// The sidecar exists at <folder>/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<File>()
|
|
|
|
|
.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');
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-06-24 20:45:16 +08:00
|
|
|
}
|