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() { slotA = null; slotB = null; ver = 0; settings = VaultSettings() }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 107108 @Test fun notes_auto_save_debounces_800ms() = runTest(dispatcher) {109 val repo = VaultRepository(FakeStorage())110 repo.createVault("correct horse battery")111 val saved = repo.addEntry(note("My note"))112 val vm = NotesModeViewModel(repo, SavedStateHandle(mapOf("id" to saved.id)))113114 vm.onContentChanged("hello")115 advanceTimeBy(799)116 runCurrent()117 assertNull("must not save before 800ms", repo.getEntry(saved.id)?.notes)118119 advanceTimeBy(2) 120 advanceUntilIdle()121 assertEquals("hello", repo.getEntry(saved.id)?.notes)122 }123124 @Test fun notes_auto_save_coalesces_rapid_keystrokes() = runTest(dispatcher) {125 val repo = VaultRepository(FakeStorage())126 repo.createVault("correct horse battery")127 val saved = repo.addEntry(note("My note"))128 val vm = NotesModeViewModel(repo, SavedStateHandle(mapOf("id" to saved.id)))129130 vm.onContentChanged("h")131 advanceTimeBy(400)132 vm.onContentChanged("he") 133 advanceTimeBy(400)134 runCurrent()135 assertNull("timer reset by second keystroke", repo.getEntry(saved.id)?.notes)136 advanceUntilIdle()137 assertEquals("he", repo.getEntry(saved.id)?.notes)138 }139140 @Test fun notes_title_edit_saves_on_blur() = runTest(dispatcher) {141 val repo = VaultRepository(FakeStorage())142 repo.createVault("correct horse battery")143 val saved = repo.addEntry(note("untitled"))144 val vm = NotesModeViewModel(repo, SavedStateHandle(mapOf("id" to saved.id)))145146 147 vm.saveTitleNow("Trip plans")148 advanceUntilIdle()149 assertEquals("Trip plans", repo.getEntry(saved.id)?.title)150 }151152 @Test fun notes_back_save_commits_title_and_body_immediately() = runTest(dispatcher) {153 val repo = VaultRepository(FakeStorage())154 repo.createVault("correct horse battery")155 val saved = repo.addEntry(note("untitled"))156 val vm = NotesModeViewModel(repo, SavedStateHandle(mapOf("id" to saved.id)))157158 vm.onContentChanged("draft")159 advanceTimeBy(200)160 vm.saveNow("Trip plans", "final body")161 advanceUntilIdle()162163 assertEquals("Trip plans", repo.getEntry(saved.id)?.title)164 assertEquals("final body", repo.getEntry(saved.id)?.notes)165 }166167 @Test fun notes_editor_back_switches_to_toc_without_leaving_route() = runTest(dispatcher) {168 val repo = VaultRepository(FakeStorage())169 repo.createVault("correct horse battery")170 val saved = repo.addEntry(note("open me"))171 val vm = NotesModeViewModel(repo, SavedStateHandle(mapOf("id" to saved.id)))172173 assertEquals(saved.id, vm.activeNoteId.value)174175 vm.showToc()176 advanceUntilIdle()177 assertNull(vm.activeNoteId.value)178179 vm.openNote(saved.id)180 advanceUntilIdle()181 assertEquals(saved.id, vm.activeNoteId.value)182 }183184 @Test fun notes_toc_new_note_creates_and_opens_note() = runTest(dispatcher) {185 val repo = VaultRepository(FakeStorage())186 repo.createVault("correct horse battery")187 val vm = NotesModeViewModel(repo, SavedStateHandle(mapOf("id" to "")))188189 vm.createNote()190 advanceUntilIdle()191192 val opened = vm.activeNoteId.value193 assertEquals("Untitled", repo.getEntry(opened!!)?.title)194 assertEquals("note", repo.getEntry(opened)?.type)195 }196197 @Test fun notes_delete_removes_note_from_active_lookup() = runTest(dispatcher) {198 val repo = VaultRepository(FakeStorage())199 repo.createVault("correct horse battery")200 val saved = repo.addEntry(note("delete me"))201 val vm = NotesModeViewModel(repo, SavedStateHandle(mapOf("id" to saved.id)))202 var done = false203204 vm.delete { done = true }205 advanceUntilIdle()206207 assertEquals(true, done)208 assertNull(repo.getEntry(saved.id))209 }210211 @Test fun notes_long_press_selection_starts_with_the_held_note_selected() = runTest(dispatcher) {212 val repo = VaultRepository(FakeStorage())213 repo.createVault("correct horse battery")214 val saved = repo.addEntry(note("select me"))215 val vm = NotesModeViewModel(repo, SavedStateHandle(mapOf("id" to "")))216217 vm.enterMultiSelect(saved.id)218219 assertTrue(vm.isMultiSelectMode.value)220 assertEquals(setOf(saved.id), vm.selectedIds.value)221 vm.toggleSelected(saved.id)222 assertEquals(emptySet<String>(), vm.selectedIds.value)223 vm.exitMultiSelect()224 assertEquals(false, vm.isMultiSelectMode.value)225 }226227 @Test fun notes_multi_select_moves_every_selected_note_to_trash() = runTest(dispatcher) {228 val repo = VaultRepository(FakeStorage())229 repo.createVault("correct horse battery")230 val first = repo.addEntry(note("first"))231 val second = repo.addEntry(note("second"))232 val vm = NotesModeViewModel(repo, SavedStateHandle(mapOf("id" to "")))233234 vm.enterMultiSelect(first.id)235 vm.toggleSelected(second.id)236 vm.batchDelete()237 advanceUntilIdle()238239 assertNull(repo.getEntry(first.id))240 assertNull(repo.getEntry(second.id))241 assertEquals(false, vm.isMultiSelectMode.value)242 assertEquals(emptySet<String>(), vm.selectedIds.value)243 }244}