NotesModeTest.kt

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