package com.peachpasswords.ui.vault import androidx.lifecycle.SavedStateHandle import com.peachpasswords.ui.PeachNav import com.peachpasswords.vault.StoredVault import com.peachpasswords.vault.VaultEntry import com.peachpasswords.vault.VaultRepository import com.peachpasswords.vault.VaultSettings import com.peachpasswords.vault.VaultStorage import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) class NotesModeTest { private val dispatcher = StandardTestDispatcher() @Before fun setUp() = Dispatchers.setMain(dispatcher) @After fun tearDown() = Dispatchers.resetMain() private class FakeStorage : VaultStorage { var slotA: StoredVault? = null var slotB: String? = null private var ver: Long = 0 private var settings = VaultSettings() override fun loadSlotA() = slotA override fun hasVault() = slotA != null override fun writeSlotA(slot: StoredVault): Boolean { slotA = slot; return true } override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true } var decoy: StoredVault? = null override fun loadDecoySlot() = decoy override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true } override fun hasDecoySlot() = decoy != null override fun clearDecoySlot() { decoy = null } override fun version() = ver override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver } override fun loadSettings() = settings override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true } override fun clearVault(): Boolean { slotA = null; slotB = null; ver = 0; settings = VaultSettings(); return true } } private fun note(title: String = "n", notes: String? = null) = VaultEntry(id = "", title = title, type = "note", notes = notes, createdAt = 0, modifiedAt = 0) private fun login(title: String = "l") = VaultEntry(id = "", title = title, type = "login", password = "pw", createdAt = 0, modifiedAt = 0) // ── Navigation decision (the VaultListScreen row-tap mapping) ────────────── @Test fun note_entry_navigates_to_notes_mode() { val e = VaultEntry(id = "abc", title = "Diary", type = "note", createdAt = 0, modifiedAt = 0) assertEquals(PeachNav.NotesMode("abc"), entryTapDestination(e)) } @Test fun non_note_entry_navigates_to_entry_detail() { val e = VaultEntry(id = "xyz", title = "GitHub", type = "login", createdAt = 0, modifiedAt = 0) assertEquals(PeachNav.EntryDetail("xyz"), entryTapDestination(e)) } // ── NOTES filter pill ───────────────────────────────────────────────────── @Test fun notes_filter_shows_only_note_entries() { val list = listOf(login("a"), note("b"), login("c"), note("d")) assertEquals(listOf("b", "d"), filterNotesOnly(list, noteOnly = true).map { it.title }) // Off: everything passes through, order preserved. assertEquals(listOf("a", "b", "c", "d"), filterNotesOnly(list, noteOnly = false).map { it.title }) } @Test fun notes_toc_search_matches_title_and_body() { val list = listOf( note("Trip", "hotel ideas"), note("Garden", "seed inventory"), note("Work", "standup"), ) assertEquals(listOf("Trip"), filterNotesForToc(list, "hotel").map { it.title }) assertEquals(listOf("Garden"), filterNotesForToc(list, "gard").map { it.title }) assertEquals(listOf("Trip", "Garden", "Work"), filterNotesForToc(list, " ").map { it.title }) } @Test fun note_toolbar_block_prefix_toggles_selected_lines() { val value = TextFieldValue("alpha\nbeta", selection = TextRange(0, 10)) val bulleted = applyLinePrefix(value, "- ") assertEquals("- alpha\n- beta", bulleted.text) val unbulleted = applyLinePrefix(bulleted.copy(selection = TextRange(0, bulleted.text.length)), "- ") assertEquals("alpha\nbeta", unbulleted.text) } @Test fun note_toolbar_quote_and_rule_markers_round_trip() { val quote = applyLinePrefix(TextFieldValue("alpha"), "> ") assertEquals("> alpha", quote.text) assertEquals("
alpha
", noteStorageText(quote.text, isHtml = true)) val rule = applyLinePrefix(TextFieldValue(""), "---") assertEquals("---", rule.text) assertEquals("
", noteStorageText(rule.text, isHtml = true)) } // ── Auto-save debounce (NotesModeViewModel) ─────────────────────────────── @Test fun notes_auto_save_debounces_800ms() = runTest(dispatcher) { val repo = VaultRepository(FakeStorage()) repo.createVault("correct horse battery") val saved = repo.addEntry(note("My note")) val vm = NotesModeViewModel(repo, SavedStateHandle(mapOf("id" to saved.id))) vm.onContentChanged("hello") advanceTimeBy(799) runCurrent() assertNull("must not save before 800ms", repo.getEntry(saved.id)?.notes) advanceTimeBy(2) // cross the 800ms threshold advanceUntilIdle() assertEquals("hello", repo.getEntry(saved.id)?.notes) } @Test fun notes_auto_save_coalesces_rapid_keystrokes() = runTest(dispatcher) { val repo = VaultRepository(FakeStorage()) repo.createVault("correct horse battery") val saved = repo.addEntry(note("My note")) val vm = NotesModeViewModel(repo, SavedStateHandle(mapOf("id" to saved.id))) vm.onContentChanged("h") advanceTimeBy(400) vm.onContentChanged("he") // resets the timer advanceTimeBy(400) runCurrent() assertNull("timer reset by second keystroke", repo.getEntry(saved.id)?.notes) advanceUntilIdle() assertEquals("he", repo.getEntry(saved.id)?.notes) } @Test fun notes_title_edit_saves_on_blur() = runTest(dispatcher) { val repo = VaultRepository(FakeStorage()) repo.createVault("correct horse battery") val saved = repo.addEntry(note("untitled")) val vm = NotesModeViewModel(repo, SavedStateHandle(mapOf("id" to saved.id))) // saveTitleNow bypasses the 800ms debounce (fired on field blur / back press). vm.saveTitleNow("Trip plans") advanceUntilIdle() assertEquals("Trip plans", repo.getEntry(saved.id)?.title) } @Test fun notes_back_save_commits_title_and_body_immediately() = runTest(dispatcher) { val repo = VaultRepository(FakeStorage()) repo.createVault("correct horse battery") val saved = repo.addEntry(note("untitled")) val vm = NotesModeViewModel(repo, SavedStateHandle(mapOf("id" to saved.id))) vm.onContentChanged("draft") advanceTimeBy(200) vm.saveNow("Trip plans", "final body") advanceUntilIdle() assertEquals("Trip plans", repo.getEntry(saved.id)?.title) assertEquals("final body", repo.getEntry(saved.id)?.notes) } @Test fun notes_editor_back_switches_to_toc_without_leaving_route() = runTest(dispatcher) { val repo = VaultRepository(FakeStorage()) repo.createVault("correct horse battery") val saved = repo.addEntry(note("open me")) val vm = NotesModeViewModel(repo, SavedStateHandle(mapOf("id" to saved.id))) assertEquals(saved.id, vm.activeNoteId.value) vm.showToc() advanceUntilIdle() assertNull(vm.activeNoteId.value) vm.openNote(saved.id) advanceUntilIdle() assertEquals(saved.id, vm.activeNoteId.value) } @Test fun notes_toc_new_note_creates_and_opens_note() = runTest(dispatcher) { val repo = VaultRepository(FakeStorage()) repo.createVault("correct horse battery") val vm = NotesModeViewModel(repo, SavedStateHandle(mapOf("id" to ""))) vm.createNote() advanceUntilIdle() val opened = vm.activeNoteId.value assertEquals("", repo.getEntry(opened!!)?.title) assertEquals("note", repo.getEntry(opened)?.type) } @Test fun leaving_an_untouched_new_note_discards_it() = runTest(dispatcher) { val repo = VaultRepository(FakeStorage()) repo.createVault("correct horse battery") val vm = NotesModeViewModel(repo, SavedStateHandle(mapOf("id" to ""))) vm.createNote() advanceUntilIdle() val opened = vm.activeNoteId.value!! vm.finishNote("", "") { vm.showToc() } advanceUntilIdle() assertNull(repo.getEntry(opened)) assertNull(vm.activeNoteId.value) } @Test fun notes_delete_removes_note_from_active_lookup() = runTest(dispatcher) { val repo = VaultRepository(FakeStorage()) repo.createVault("correct horse battery") val saved = repo.addEntry(note("delete me")) val vm = NotesModeViewModel(repo, SavedStateHandle(mapOf("id" to saved.id))) var done = false vm.delete { done = true } advanceUntilIdle() assertEquals(true, done) assertNull(repo.getEntry(saved.id)) } @Test fun notes_long_press_selection_starts_with_the_held_note_selected() = runTest(dispatcher) { val repo = VaultRepository(FakeStorage()) repo.createVault("correct horse battery") val saved = repo.addEntry(note("select me")) val vm = NotesModeViewModel(repo, SavedStateHandle(mapOf("id" to ""))) vm.enterMultiSelect(saved.id) assertTrue(vm.isMultiSelectMode.value) assertEquals(setOf(saved.id), vm.selectedIds.value) vm.toggleSelected(saved.id) assertEquals(emptySet(), vm.selectedIds.value) vm.exitMultiSelect() assertEquals(false, vm.isMultiSelectMode.value) } @Test fun notes_multi_select_moves_every_selected_note_to_trash() = runTest(dispatcher) { val repo = VaultRepository(FakeStorage()) repo.createVault("correct horse battery") val first = repo.addEntry(note("first")) val second = repo.addEntry(note("second")) val vm = NotesModeViewModel(repo, SavedStateHandle(mapOf("id" to ""))) vm.enterMultiSelect(first.id) vm.toggleSelected(second.id) vm.batchDelete() advanceUntilIdle() assertNull(repo.getEntry(first.id)) assertNull(repo.getEntry(second.id)) assertEquals(false, vm.isMultiSelectMode.value) assertEquals(emptySet(), vm.selectedIds.value) } }