NotesModeTest.kt

11.1 KB · raw

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    // ── Navigation decision (the VaultListScreen row-tap mapping) ──────────────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    // ── NOTES filter pill ─────────────────────────────────────────────────────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        // Off: everything passes through, order preserved.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    // ── Auto-save debounce (NotesModeViewModel) ───────────────────────────────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) // cross the 800ms threshold130        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") // resets the timer143        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        // saveTitleNow bypasses the 800ms debounce (fired on field blur / back press).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}