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.Assert.assertTrue25import org.junit.Before26import org.junit.Test2728@OptIn(ExperimentalCoroutinesApi::class)29class NotesModeTest {3031 private val dispatcher = StandardTestDispatcher()3233 @Before fun setUp() = Dispatchers.setMain(dispatcher)34 @After fun tearDown() = Dispatchers.resetMain()3536 private class FakeStorage : VaultStorage {37 var slotA: StoredVault? = null38 var slotB: String? = null39 private var ver: Long = 040 private var settings = VaultSettings()41 override fun loadSlotA() = slotA42 override fun hasVault() = slotA != null43 override fun writeSlotA(slot: StoredVault): Boolean { slotA = slot; return true }44 override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }45 var decoy: StoredVault? = null46 override fun loadDecoySlot() = decoy47 override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true }48 override fun hasDecoySlot() = decoy != null49 override fun clearDecoySlot() { decoy = null }50 override fun version() = ver51 override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }52 override fun loadSettings() = settings53 override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true }54 override fun clearVault(): Boolean { slotA = null; slotB = null; ver = 0; settings = VaultSettings(); return true }55 }5657 private fun note(title: String = "n", notes: String? = null) =58 VaultEntry(id = "", title = title, type = "note", notes = notes, createdAt = 0, modifiedAt = 0)5960 private fun login(title: String = "l") =61 VaultEntry(id = "", title = title, type = "login", password = "pw", createdAt = 0, modifiedAt = 0)6263 6465 @Test fun note_entry_navigates_to_notes_mode() {66 val e = VaultEntry(id = "abc", title = "Diary", type = "note", createdAt = 0, modifiedAt = 0)67 assertEquals(PeachNav.NotesMode("abc"), entryTapDestination(e))68 }6970 @Test fun non_note_entry_navigates_to_entry_detail() {71 val e = VaultEntry(id = "xyz", title = "GitHub", type = "login", createdAt = 0, modifiedAt = 0)72 assertEquals(PeachNav.EntryDetail("xyz"), entryTapDestination(e))73 }7475 7677 @Test fun notes_filter_shows_only_note_entries() {78 val list = listOf(login("a"), note("b"), login("c"), note("d"))79 assertEquals(listOf("b", "d"), filterNotesOnly(list, noteOnly = true).map { it.title })80 81 assertEquals(listOf("a", "b", "c", "d"), filterNotesOnly(list, noteOnly = false).map { it.title })82 }8384 @Test fun notes_toc_search_matches_title_and_body() {85 val list = listOf(86 note("Trip", "hotel ideas"),87 note("Garden", "seed inventory"),88 note("Work", "standup"),89 )9091 assertEquals(listOf("Trip"), filterNotesForToc(list, "hotel").map { it.title })92 assertEquals(listOf("Garden"), filterNotesForToc(list, "gard").map { it.title })93 assertEquals(listOf("Trip", "Garden", "Work"), filterNotesForToc(list, " ").map { it.title })94 }9596 @Test fun note_toolbar_block_prefix_toggles_selected_lines() {97 val value = TextFieldValue("alpha\nbeta", selection = TextRange(0, 10))9899 val bulleted = applyLinePrefix(value, "- ")100 assertEquals("- alpha\n- beta", bulleted.text)101102 val unbulleted = applyLinePrefix(bulleted.copy(selection = TextRange(0, bulleted.text.length)), "- ")103 assertEquals("alpha\nbeta", unbulleted.text)104 }105106 @Test fun note_toolbar_quote_and_rule_markers_round_trip() {107 val quote = applyLinePrefix(TextFieldValue("alpha"), "> ")108 assertEquals("> alpha", quote.text)109 assertEquals("<blockquote>alpha</blockquote>", noteStorageText(quote.text, isHtml = true))110111 val rule = applyLinePrefix(TextFieldValue(""), "---")112 assertEquals("---", rule.text)113 assertEquals("<hr>", noteStorageText(rule.text, isHtml = true))114 }115116 117118 @Test fun notes_auto_save_debounces_800ms() = runTest(dispatcher) {119 val repo = VaultRepository(FakeStorage())120 repo.createVault("correct horse battery")121 val saved = repo.addEntry(note("My note"))122 val vm = NotesModeViewModel(repo, SavedStateHandle(mapOf("id" to saved.id)))123124 vm.onContentChanged("hello")125 advanceTimeBy(799)126 runCurrent()127 assertNull("must not save before 800ms", repo.getEntry(saved.id)?.notes)128129 advanceTimeBy(2) 130 advanceUntilIdle()131 assertEquals("hello", repo.getEntry(saved.id)?.notes)132 }133134 @Test fun notes_auto_save_coalesces_rapid_keystrokes() = runTest(dispatcher) {135 val repo = VaultRepository(FakeStorage())136 repo.createVault("correct horse battery")137 val saved = repo.addEntry(note("My note"))138 val vm = NotesModeViewModel(repo, SavedStateHandle(mapOf("id" to saved.id)))139140 vm.onContentChanged("h")141 advanceTimeBy(400)142 vm.onContentChanged("he") 143 advanceTimeBy(400)144 runCurrent()145 assertNull("timer reset by second keystroke", repo.getEntry(saved.id)?.notes)146 advanceUntilIdle()147 assertEquals("he", repo.getEntry(saved.id)?.notes)148 }149150 @Test fun notes_title_edit_saves_on_blur() = runTest(dispatcher) {151 val repo = VaultRepository(FakeStorage())152 repo.createVault("correct horse battery")153 val saved = repo.addEntry(note("untitled"))154 val vm = NotesModeViewModel(repo, SavedStateHandle(mapOf("id" to saved.id)))155156 157 vm.saveTitleNow("Trip plans")158 advanceUntilIdle()159 assertEquals("Trip plans", repo.getEntry(saved.id)?.title)160 }161162 @Test fun notes_back_save_commits_title_and_body_immediately() = runTest(dispatcher) {163 val repo = VaultRepository(FakeStorage())164 repo.createVault("correct horse battery")165 val saved = repo.addEntry(note("untitled"))166 val vm = NotesModeViewModel(repo, SavedStateHandle(mapOf("id" to saved.id)))167168 vm.onContentChanged("draft")169 advanceTimeBy(200)170 vm.saveNow("Trip plans", "final body")171 advanceUntilIdle()172173 assertEquals("Trip plans", repo.getEntry(saved.id)?.title)174 assertEquals("final body", repo.getEntry(saved.id)?.notes)175 }176177 @Test fun notes_editor_back_switches_to_toc_without_leaving_route() = runTest(dispatcher) {178 val repo = VaultRepository(FakeStorage())179 repo.createVault("correct horse battery")180 val saved = repo.addEntry(note("open me"))181 val vm = NotesModeViewModel(repo, SavedStateHandle(mapOf("id" to saved.id)))182183 assertEquals(saved.id, vm.activeNoteId.value)184185 vm.showToc()186 advanceUntilIdle()187 assertNull(vm.activeNoteId.value)188189 vm.openNote(saved.id)190 advanceUntilIdle()191 assertEquals(saved.id, vm.activeNoteId.value)192 }193194 @Test fun notes_toc_new_note_creates_and_opens_note() = runTest(dispatcher) {195 val repo = VaultRepository(FakeStorage())196 repo.createVault("correct horse battery")197 val vm = NotesModeViewModel(repo, SavedStateHandle(mapOf("id" to "")))198199 vm.createNote()200 advanceUntilIdle()201202 val opened = vm.activeNoteId.value203 assertEquals("", repo.getEntry(opened!!)?.title)204 assertEquals("note", repo.getEntry(opened)?.type)205 }206207 @Test fun leaving_an_untouched_new_note_discards_it() = runTest(dispatcher) {208 val repo = VaultRepository(FakeStorage())209 repo.createVault("correct horse battery")210 val vm = NotesModeViewModel(repo, SavedStateHandle(mapOf("id" to "")))211212 vm.createNote()213 advanceUntilIdle()214 val opened = vm.activeNoteId.value!!215216 vm.finishNote("", "") { vm.showToc() }217 advanceUntilIdle()218219 assertNull(repo.getEntry(opened))220 assertNull(vm.activeNoteId.value)221 }222223 @Test fun notes_delete_removes_note_from_active_lookup() = runTest(dispatcher) {224 val repo = VaultRepository(FakeStorage())225 repo.createVault("correct horse battery")226 val saved = repo.addEntry(note("delete me"))227 val vm = NotesModeViewModel(repo, SavedStateHandle(mapOf("id" to saved.id)))228 var done = false229230 vm.delete { done = true }231 advanceUntilIdle()232233 assertEquals(true, done)234 assertNull(repo.getEntry(saved.id))235 }236237 @Test fun notes_long_press_selection_starts_with_the_held_note_selected() = runTest(dispatcher) {238 val repo = VaultRepository(FakeStorage())239 repo.createVault("correct horse battery")240 val saved = repo.addEntry(note("select me"))241 val vm = NotesModeViewModel(repo, SavedStateHandle(mapOf("id" to "")))242243 vm.enterMultiSelect(saved.id)244245 assertTrue(vm.isMultiSelectMode.value)246 assertEquals(setOf(saved.id), vm.selectedIds.value)247 vm.toggleSelected(saved.id)248 assertEquals(emptySet<String>(), vm.selectedIds.value)249 vm.exitMultiSelect()250 assertEquals(false, vm.isMultiSelectMode.value)251 }252253 @Test fun notes_multi_select_moves_every_selected_note_to_trash() = runTest(dispatcher) {254 val repo = VaultRepository(FakeStorage())255 repo.createVault("correct horse battery")256 val first = repo.addEntry(note("first"))257 val second = repo.addEntry(note("second"))258 val vm = NotesModeViewModel(repo, SavedStateHandle(mapOf("id" to "")))259260 vm.enterMultiSelect(first.id)261 vm.toggleSelected(second.id)262 vm.batchDelete()263 advanceUntilIdle()264265 assertNull(repo.getEntry(first.id))266 assertNull(repo.getEntry(second.id))267 assertEquals(false, vm.isMultiSelectMode.value)268 assertEquals(emptySet<String>(), vm.selectedIds.value)269 }270}