NotesModeTest.kt

10.7 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() { 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    // ── 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    // ── Auto-save debounce (NotesModeViewModel) ───────────────────────────────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) // cross the 800ms threshold120        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") // resets the timer133        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        // saveTitleNow bypasses the 800ms debounce (fired on field blur / back press).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("", repo.getEntry(opened!!)?.title)194        assertEquals("note", repo.getEntry(opened)?.type)195    }196197    @Test fun leaving_an_untouched_new_note_discards_it() = runTest(dispatcher) {198        val repo = VaultRepository(FakeStorage())199        repo.createVault("correct horse battery")200        val vm = NotesModeViewModel(repo, SavedStateHandle(mapOf("id" to "")))201202        vm.createNote()203        advanceUntilIdle()204        val opened = vm.activeNoteId.value!!205206        vm.finishNote("", "") { vm.showToc() }207        advanceUntilIdle()208209        assertNull(repo.getEntry(opened))210        assertNull(vm.activeNoteId.value)211    }212213    @Test fun notes_delete_removes_note_from_active_lookup() = runTest(dispatcher) {214        val repo = VaultRepository(FakeStorage())215        repo.createVault("correct horse battery")216        val saved = repo.addEntry(note("delete me"))217        val vm = NotesModeViewModel(repo, SavedStateHandle(mapOf("id" to saved.id)))218        var done = false219220        vm.delete { done = true }221        advanceUntilIdle()222223        assertEquals(true, done)224        assertNull(repo.getEntry(saved.id))225    }226227    @Test fun notes_long_press_selection_starts_with_the_held_note_selected() = runTest(dispatcher) {228        val repo = VaultRepository(FakeStorage())229        repo.createVault("correct horse battery")230        val saved = repo.addEntry(note("select me"))231        val vm = NotesModeViewModel(repo, SavedStateHandle(mapOf("id" to "")))232233        vm.enterMultiSelect(saved.id)234235        assertTrue(vm.isMultiSelectMode.value)236        assertEquals(setOf(saved.id), vm.selectedIds.value)237        vm.toggleSelected(saved.id)238        assertEquals(emptySet<String>(), vm.selectedIds.value)239        vm.exitMultiSelect()240        assertEquals(false, vm.isMultiSelectMode.value)241    }242243    @Test fun notes_multi_select_moves_every_selected_note_to_trash() = runTest(dispatcher) {244        val repo = VaultRepository(FakeStorage())245        repo.createVault("correct horse battery")246        val first = repo.addEntry(note("first"))247        val second = repo.addEntry(note("second"))248        val vm = NotesModeViewModel(repo, SavedStateHandle(mapOf("id" to "")))249250        vm.enterMultiSelect(first.id)251        vm.toggleSelected(second.id)252        vm.batchDelete()253        advanceUntilIdle()254255        assertNull(repo.getEntry(first.id))256        assertNull(repo.getEntry(second.id))257        assertEquals(false, vm.isMultiSelectMode.value)258        assertEquals(emptySet<String>(), vm.selectedIds.value)259    }260}