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)
}
}