1package com.peachpasswords.ui.vault23import androidx.lifecycle.SavedStateHandle4import com.peachpasswords.ui.PeachNav5import com.peachpasswords.vault.StoredVault6import com.peachpasswords.vault.VaultEntry7import com.peachpasswords.vault.VaultRepository8import com.peachpasswords.vault.VaultSettings9import com.peachpasswords.vault.VaultStorage10import androidx.compose.ui.text.TextRange11import androidx.compose.ui.text.input.TextFieldValue12import kotlinx.coroutines.Dispatchers13import kotlinx.coroutines.ExperimentalCoroutinesApi14import kotlinx.coroutines.test.StandardTestDispatcher15import kotlinx.coroutines.test.advanceTimeBy16import kotlinx.coroutines.test.advanceUntilIdle17import kotlinx.coroutines.test.resetMain18import kotlinx.coroutines.test.runCurrent19import kotlinx.coroutines.test.runTest20import kotlinx.coroutines.test.setMain21import org.junit.After22import org.junit.Assert.assertEquals23import org.junit.Assert.assertNull24import org.junit.Before25import org.junit.Test2627@OptIn(ExperimentalCoroutinesApi::class)28class NotesModeTest {2930 private val dispatcher = StandardTestDispatcher()3132 @Before fun setUp() = Dispatchers.setMain(dispatcher)33 @After fun tearDown() = Dispatchers.resetMain()3435 private class FakeStorage : VaultStorage {36 var slotA: StoredVault? = null37 var slotB: String? = null38 private var ver: Long = 039 private var settings = VaultSettings()40 override fun loadSlotA() = slotA41 override fun hasVault() = slotA != null42 override fun writeSlotA(slot: StoredVault): Boolean { slotA = slot; return true }43 override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }44 var decoy: StoredVault? = null45 override fun loadDecoySlot() = decoy46 override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true }47 override fun hasDecoySlot() = decoy != null48 override fun clearDecoySlot() { decoy = null }49 override fun version() = ver50 override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }51 override fun loadSettings() = settings52 override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true }53 override fun clearVault() { slotA = null; slotB = null; ver = 0; settings = VaultSettings() }54 }5556 private fun note(title: String = "n", notes: String? = null) =57 VaultEntry(id = "", title = title, type = "note", notes = notes, createdAt = 0, modifiedAt = 0)5859 private fun login(title: String = "l") =60 VaultEntry(id = "", title = title, type = "login", password = "pw", createdAt = 0, modifiedAt = 0)6162 6364 @Test fun note_entry_navigates_to_notes_mode() {65 val e = VaultEntry(id = "abc", title = "Diary", type = "note", createdAt = 0, modifiedAt = 0)66 assertEquals(PeachNav.NotesMode("abc"), entryTapDestination(e))67 }6869 @Test fun non_note_entry_navigates_to_entry_detail() {70 val e = VaultEntry(id = "xyz", title = "GitHub", type = "login", createdAt = 0, modifiedAt = 0)71 assertEquals(PeachNav.EntryDetail("xyz"), entryTapDestination(e))72 }7374 7576 @Test fun notes_filter_shows_only_note_entries() {77 val list = listOf(login("a"), note("b"), login("c"), note("d"))78 assertEquals(listOf("b", "d"), filterNotesOnly(list, noteOnly = true).map { it.title })79 80 assertEquals(listOf("a", "b", "c", "d"), filterNotesOnly(list, noteOnly = false).map { it.title })81 }8283 @Test fun notes_toc_search_matches_title_and_body() {84 val list = listOf(85 note("Trip", "hotel ideas"),86 note("Garden", "seed inventory"),87 note("Work", "standup"),88 )8990 assertEquals(listOf("Trip"), filterNotesForToc(list, "hotel").map { it.title })91 assertEquals(listOf("Garden"), filterNotesForToc(list, "gard").map { it.title })92 assertEquals(listOf("Trip", "Garden", "Work"), filterNotesForToc(list, " ").map { it.title })93 }9495 @Test fun note_toolbar_block_prefix_toggles_selected_lines() {96 val value = TextFieldValue("alpha\nbeta", selection = TextRange(0, 10))9798 val bulleted = applyLinePrefix(value, "- ")99 assertEquals("- alpha\n- beta", bulleted.text)100101 val unbulleted = applyLinePrefix(bulleted.copy(selection = TextRange(0, bulleted.text.length)), "- ")102 assertEquals("alpha\nbeta", unbulleted.text)103 }104105 106107 @Test fun notes_auto_save_debounces_800ms() = runTest(dispatcher) {108 val repo = VaultRepository(FakeStorage())109 repo.createVault("correct horse battery")110 val saved = repo.addEntry(note("My note"))111 val vm = NotesModeViewModel(repo, SavedStateHandle(mapOf("id" to saved.id)))112113 vm.onContentChanged("hello")114 advanceTimeBy(799)115 runCurrent()116 assertNull("must not save before 800ms", repo.getEntry(saved.id)?.notes)117118 advanceTimeBy(2) 119 advanceUntilIdle()120 assertEquals("hello", repo.getEntry(saved.id)?.notes)121 }122123 @Test fun notes_auto_save_coalesces_rapid_keystrokes() = runTest(dispatcher) {124 val repo = VaultRepository(FakeStorage())125 repo.createVault("correct horse battery")126 val saved = repo.addEntry(note("My note"))127 val vm = NotesModeViewModel(repo, SavedStateHandle(mapOf("id" to saved.id)))128129 vm.onContentChanged("h")130 advanceTimeBy(400)131 vm.onContentChanged("he") 132 advanceTimeBy(400)133 runCurrent()134 assertNull("timer reset by second keystroke", repo.getEntry(saved.id)?.notes)135 advanceUntilIdle()136 assertEquals("he", repo.getEntry(saved.id)?.notes)137 }138139 @Test fun notes_title_edit_saves_on_blur() = runTest(dispatcher) {140 val repo = VaultRepository(FakeStorage())141 repo.createVault("correct horse battery")142 val saved = repo.addEntry(note("untitled"))143 val vm = NotesModeViewModel(repo, SavedStateHandle(mapOf("id" to saved.id)))144145 146 vm.saveTitleNow("Trip plans")147 advanceUntilIdle()148 assertEquals("Trip plans", repo.getEntry(saved.id)?.title)149 }150151 @Test fun notes_back_save_commits_title_and_body_immediately() = runTest(dispatcher) {152 val repo = VaultRepository(FakeStorage())153 repo.createVault("correct horse battery")154 val saved = repo.addEntry(note("untitled"))155 val vm = NotesModeViewModel(repo, SavedStateHandle(mapOf("id" to saved.id)))156157 vm.onContentChanged("draft")158 advanceTimeBy(200)159 vm.saveNow("Trip plans", "final body")160 advanceUntilIdle()161162 assertEquals("Trip plans", repo.getEntry(saved.id)?.title)163 assertEquals("final body", repo.getEntry(saved.id)?.notes)164 }165166 @Test fun notes_editor_back_switches_to_toc_without_leaving_route() = runTest(dispatcher) {167 val repo = VaultRepository(FakeStorage())168 repo.createVault("correct horse battery")169 val saved = repo.addEntry(note("open me"))170 val vm = NotesModeViewModel(repo, SavedStateHandle(mapOf("id" to saved.id)))171172 assertEquals(saved.id, vm.activeNoteId.value)173174 vm.showToc()175 advanceUntilIdle()176 assertNull(vm.activeNoteId.value)177178 vm.openNote(saved.id)179 advanceUntilIdle()180 assertEquals(saved.id, vm.activeNoteId.value)181 }182183 @Test fun notes_toc_new_note_creates_and_opens_note() = runTest(dispatcher) {184 val repo = VaultRepository(FakeStorage())185 repo.createVault("correct horse battery")186 val vm = NotesModeViewModel(repo, SavedStateHandle(mapOf("id" to "")))187188 vm.createNote()189 advanceUntilIdle()190191 val opened = vm.activeNoteId.value192 assertEquals("Untitled", repo.getEntry(opened!!)?.title)193 assertEquals("note", repo.getEntry(opened)?.type)194 }195196 @Test fun notes_delete_removes_note_from_active_lookup() = runTest(dispatcher) {197 val repo = VaultRepository(FakeStorage())198 repo.createVault("correct horse battery")199 val saved = repo.addEntry(note("delete me"))200 val vm = NotesModeViewModel(repo, SavedStateHandle(mapOf("id" to saved.id)))201 var done = false202203 vm.delete { done = true }204 advanceUntilIdle()205206 assertEquals(true, done)207 assertNull(repo.getEntry(saved.id))208 }209}