Fix tests

This commit is contained in:
William Brawner 2024-02-15 16:09:31 -07:00
parent a6616550dd
commit e0d43d5154
13 changed files with 948 additions and 295 deletions

View file

@ -153,6 +153,7 @@ dependencies {
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
val coroutinesVersion = "1.7.1"
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion")
val lifecycleVersion = "2.2.0"
implementation("androidx.lifecycle:lifecycle-extensions:$lifecycleVersion")
kapt("androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion")

View file

@ -0,0 +1,269 @@
package com.wbrawner.simplemarkdown
import android.app.Activity.RESULT_OK
import android.app.Instrumentation
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.webkit.WebView
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.input.key.NativeKeyEvent
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.getOrNull
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotDisplayed
import androidx.compose.ui.test.hasAnyDescendant
import androidx.compose.ui.test.hasAnySibling
import androidx.compose.ui.test.hasContentDescription
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.compose.ui.test.junit4.createEmptyComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.performKeyPress
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.performTextInputSelection
import androidx.compose.ui.test.performTextReplacement
import androidx.compose.ui.test.printToLog
import androidx.compose.ui.text.TextRange
import androidx.core.content.FileProvider
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider.getApplicationContext
import androidx.test.espresso.action.ViewActions.*
import androidx.test.espresso.intent.Intents.intending
import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction
import androidx.test.espresso.intent.rule.IntentsRule
import androidx.test.espresso.matcher.ViewMatchers.*
import androidx.test.espresso.web.assertion.WebViewAssertions.webMatches
import androidx.test.espresso.web.sugar.Web.onWebView
import androidx.test.espresso.web.webdriver.DriverAtoms.findElement
import androidx.test.espresso.web.webdriver.DriverAtoms.getText
import androidx.test.espresso.web.webdriver.Locator
import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
import kotlinx.coroutines.test.runTest
import org.hamcrest.CoreMatchers.containsString
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.io.File
import java.io.Reader
class MarkdownTests {
@get:Rule
val composeRule = createEmptyComposeRule()
@get:Rule
val intentsRule = IntentsRule()
private lateinit var file: File
@Before
fun setup() {
file = File(getApplicationContext<Context>().filesDir.absolutePath + "/tmp", "temp.md")
assertTrue(requireNotNull(file.parentFile).mkdirs())
file.delete()
}
@Test
@Throws(Exception::class)
fun openAppTest() {
val context = getInstrumentation().targetContext
context.packageManager
.getLaunchIntentForPackage(context.packageName)
.apply { context.startActivity(this) }
}
@Test
fun editAndPreviewMarkdownTest() {
ActivityScenario.launch(MainActivity::class.java)
composeRule.typeMarkdown("# Header test")
composeRule.checkMarkdownEquals("# Header test")
composeRule.openPreview()
onWebView(isAssignableFrom(WebView::class.java))
.forceJavascriptEnabled()
.withElement(findElement(Locator.TAG_NAME, "h1"))
.check(webMatches(getText(), containsString("Header test")))
}
@Test
fun openThenNewMarkdownTest() {
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
file.outputStream().writer().use { it.write(markdownText) }
val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply {
data = Uri.fromFile(file)
addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
})
intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWith(activityResult)
ActivityScenario.launch(MainActivity::class.java)
composeRule.openMenu()
composeRule.clickOpenMenuItem()
composeRule.checkMarkdownEquals(markdownText)
composeRule.openMenu()
composeRule.clickNewMenuItem()
composeRule.verifyDialogIsNotShown()
composeRule.checkMarkdownEquals("")
}
@Test
fun editThenNewMarkdownTest() {
ActivityScenario.launch(MainActivity::class.java)
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
composeRule.typeMarkdown(markdownText)
composeRule.openMenu()
composeRule.clickNewMenuItem()
composeRule.onNode(isDialog()).printToLog("TestDebugging")
composeRule.verifyDialogIsShown("Would you like to save your changes?")
composeRule.discardChanges()
composeRule.checkMarkdownEquals("")
}
@Test
fun saveMarkdownWithFileUriTest() = runTest {
ActivityScenario.launch(MainActivity::class.java)
composeRule.checkTitleEquals("Untitled.md")
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
composeRule.typeMarkdown(markdownText)
val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply {
data = Uri.fromFile(file)
})
intending(hasAction(Intent.ACTION_CREATE_DOCUMENT)).respondWith(activityResult)
composeRule.openMenu()
composeRule.clickSaveMenuItem()
composeRule.awaitIdle()
assertEquals(markdownText, file.inputStream().reader().use(Reader::readText))
composeRule.checkTitleEquals("temp.md")
}
@Test
fun saveMarkdownWithContentUriTest() = runTest {
ActivityScenario.launch(MainActivity::class.java)
composeRule.checkTitleEquals("Untitled.md")
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
composeRule.typeMarkdown(markdownText)
val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply {
data = FileProvider.getUriForFile(
getApplicationContext(),
"${BuildConfig.APPLICATION_ID}.fileprovider",
file
)
})
intending(hasAction(Intent.ACTION_CREATE_DOCUMENT)).respondWith(activityResult)
composeRule.openMenu()
composeRule.clickSaveMenuItem()
composeRule.awaitIdle()
assertEquals(markdownText, file.inputStream().reader().use(Reader::readText))
composeRule.checkTitleEquals("temp.md")
}
@Test
fun loadMarkdownWithFileUriTest() = runTest {
ActivityScenario.launch(MainActivity::class.java)
composeRule.checkTitleEquals("Untitled.md")
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
file.outputStream().writer().use { it.write(markdownText) }
val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply {
data = Uri.fromFile(file)
})
intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWith(activityResult)
composeRule.openMenu()
composeRule.clickOpenMenuItem()
composeRule.awaitIdle()
composeRule.checkMarkdownEquals(markdownText)
composeRule.checkTitleEquals("temp.md")
}
@Test
fun loadMarkdownWithContentUriTest() = runTest {
ActivityScenario.launch(MainActivity::class.java)
composeRule.checkTitleEquals("Untitled.md")
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
file.outputStream().writer().use { it.write(markdownText) }
val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply {
data = FileProvider.getUriForFile(
getApplicationContext(),
"${BuildConfig.APPLICATION_ID}.fileprovider",
file
)
})
intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWith(activityResult)
composeRule.openMenu()
composeRule.clickOpenMenuItem()
composeRule.awaitIdle()
composeRule.checkMarkdownEquals(markdownText)
composeRule.checkTitleEquals("temp.md")
}
@Test
fun openEditAndSaveMarkdownTest() = runTest {
val markdownText = "# UI Testing\n\nThe quick brown fox jumped over the lazy dog."
file.outputStream().writer().use { it.write(markdownText) }
val activityResult = Instrumentation.ActivityResult(RESULT_OK, Intent().apply {
data = Uri.fromFile(file)
})
intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)).respondWith(activityResult)
ActivityScenario.launch(MainActivity::class.java)
composeRule.checkTitleEquals("Untitled.md")
composeRule.openMenu()
composeRule.clickOpenMenuItem()
composeRule.awaitIdle()
composeRule.verifyTextIsShown("Successfully loaded temp.md")
composeRule.checkMarkdownEquals(markdownText)
composeRule.checkTitleEquals("temp.md")
val additionalText = "# More info\n\nThis is some additional text"
composeRule.typeMarkdown(additionalText)
composeRule.openMenu()
composeRule.clickSaveMenuItem()
composeRule.awaitIdle()
composeRule.verifyTextIsShown("Successfully saved temp.md")
assertEquals(additionalText, file.inputStream().reader().use(Reader::readText))
composeRule.checkTitleEquals("temp.md")
}
private fun ComposeTestRule.checkTitleEquals(title: String) =
onNode(hasAnySibling(hasContentDescription("Main Menu")).and(hasText(title)))
.assertIsDisplayed()
private fun ComposeTestRule.typeMarkdown(markdown: String) =
onNode(hasSetTextAction()).performTextReplacement(markdown)
private fun ComposeTestRule.checkMarkdownEquals(markdown: String) {
val markdownMatcher = SemanticsMatcher("Markdown = [$markdown]") {
it.config.getOrNull(SemanticsProperties.EditableText)?.text == markdown
}
onNode(hasSetTextAction()).assert(markdownMatcher)
}
private fun ComposeTestRule.openPreview() = onNodeWithText("Preview").performClick()
private fun ComposeTestRule.openMenu() =
onNodeWithContentDescription("Editor Actions").performClick()
private fun ComposeTestRule.clickOpenMenuItem() = onNodeWithText("Open").performClick()
private fun ComposeTestRule.clickNewMenuItem() = onNodeWithText("New").performClick()
private fun ComposeTestRule.clickSaveMenuItem() = onNodeWithText("Save").performClick()
private fun ComposeTestRule.verifyDialogIsShown(text: String) =
onNode(isDialog().and(hasAnyDescendant(hasText(text)))).assertIsDisplayed()
private fun ComposeTestRule.verifyDialogIsNotShown() = onNode(isDialog()).assertIsNotDisplayed()
private fun ComposeTestRule.discardChanges() = onNodeWithText("No").performClick()
private fun ComposeTestRule.verifyTextIsShown(text: String) =
onNodeWithText(text).assertIsDisplayed()
}

View file

@ -14,9 +14,9 @@ import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Help
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Help
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.PrivacyTip
import androidx.compose.material.icons.filled.Settings
@ -128,8 +128,7 @@ class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsRes
viewModel = viewModel,
enableWideLayout = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Expanded,
enableAutosave = autosaveEnabled,
enableReadability = readabilityEnabled,
darkMode = darkModePreference
enableReadability = readabilityEnabled
)
}
composable(Route.SETTINGS.path) {
@ -173,7 +172,7 @@ enum class Route(
EDITOR("/", "Editor", Icons.Default.Edit),
SETTINGS("/settings", "Settings", Icons.Default.Settings),
SUPPORT("/support", "Support SimpleMarkdown", Icons.Default.Favorite),
HELP("/help", "Help", Icons.Default.Help),
HELP("/help", "Help", Icons.AutoMirrored.Filled.Help),
ABOUT("/about", "About", Icons.Default.Info),
PRIVACY("/privacy", "Privacy", Icons.Default.PrivacyTip),
}

View file

@ -7,30 +7,34 @@ import androidx.lifecycle.viewmodel.CreationExtras
import com.wbrawner.simplemarkdown.utility.FileHelper
import com.wbrawner.simplemarkdown.utility.Preference
import com.wbrawner.simplemarkdown.utility.PreferenceHelper
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File
import java.net.URI
import java.util.concurrent.atomic.AtomicBoolean
data class EditorState(
val fileName: String = "Untitled.md",
val markdown: String = "",
val path: URI? = null,
val dirty: Boolean = false,
val toast: String? = null,
val alert: AlertDialogModel? = null,
val saveCallback: (() -> Unit)? = null
)
class MarkdownViewModel(
private val fileHelper: FileHelper,
private val preferenceHelper: PreferenceHelper
) : ViewModel() {
private val _fileName = MutableStateFlow("Untitled.md")
val fileName = _fileName.asStateFlow()
private val _markdown = MutableStateFlow("")
val markdown = _markdown.asStateFlow()
private val path = MutableStateFlow<URI?>(null)
private val isDirty = AtomicBoolean(false)
private val _effects = MutableSharedFlow<Effect>()
val effects = _effects.asSharedFlow()
private val _state = MutableStateFlow(EditorState())
val state = _state.asStateFlow()
private val saveMutex = Mutex()
init {
@ -39,71 +43,114 @@ class MarkdownViewModel(
}
}
suspend fun updateMarkdown(markdown: String?) {
this@MarkdownViewModel._markdown.emit(markdown ?: "")
isDirty.set(true)
fun updateMarkdown(markdown: String?) {
_state.value = _state.value.copy(
markdown = markdown ?: "",
dirty = true
)
}
fun dismissToast() {
_state.value = _state.value.copy(toast = null)
}
fun dismissAlert() {
_state.value = _state.value.copy(alert = null)
}
private fun unsetSaveCallback() {
_state.value = _state.value.copy(saveCallback = null)
}
suspend fun load(loadPath: String?) {
if (loadPath.isNullOrBlank()) {
Timber.i("No URI provided to load, attempting to load last autosaved file")
preferenceHelper[Preference.AUTOSAVE_URI]
?.let {
val autosaveUri = it as? String
if (autosaveUri.isNullOrBlank()) {
preferenceHelper[Preference.AUTOSAVE_URI] = null
} else {
Timber.d("Using uri from shared preferences: $it")
load(autosaveUri)
}
}
return
}
try {
val uri = URI.create(loadPath)
fileHelper.open(uri)
?.let { (name, content) ->
path.emit(uri)
_effects.emit(Effect.ClearText)
_fileName.emit(name)
_markdown.emit(content)
isDirty.set(false)
preferenceHelper[Preference.AUTOSAVE_URI] = loadPath
} ?: _effects.emit(Effect.Error("Failed to open file at path: $loadPath"))
} catch (e: Exception) {
Timber.e(e, "Failed to open file at path: $loadPath")
saveMutex.withLock {
val actualLoadPath = loadPath
?.ifBlank { null }
?: preferenceHelper[Preference.AUTOSAVE_URI]
?.let {
val autosaveUri = it as? String
if (autosaveUri.isNullOrBlank()) {
preferenceHelper[Preference.AUTOSAVE_URI] = null
null
} else {
Timber.d("Using uri from shared preferences: $it")
autosaveUri
}
} ?: return
Timber.d("Loading file at $actualLoadPath")
try {
val uri = URI.create(actualLoadPath)
fileHelper.open(uri)
?.let { (name, content) ->
_state.value = _state.value.copy(
path = uri,
fileName = name,
markdown = content,
dirty = false,
toast = "Successfully loaded $name"
)
preferenceHelper[Preference.AUTOSAVE_URI] = actualLoadPath
} ?: throw IllegalStateException("Opened file was null")
} catch (e: Exception) {
Timber.e(e, "Failed to open file at path: $actualLoadPath")
_state.value = _state.value.copy(
alert = AlertDialogModel(
text = "Failed to open file at path: $actualLoadPath",
confirmButton = AlertDialogModel.ButtonModel("OK", onClick = ::dismissAlert)
)
)
}
}
}
suspend fun save(savePath: URI? = path.value, promptSavePath: Boolean = true): Boolean {
return saveMutex.withLock {
if (savePath == null) {
Timber.w("Attempted to save file with empty path")
if (promptSavePath) {
_effects.emit(Effect.OpenSaveDialog {})
suspend fun save(savePath: URI? = null, interactive: Boolean = true): Boolean =
saveMutex.withLock {
val actualSavePath = savePath
?: _state.value.path
?: run {
Timber.w("Attempted to save file with empty path")
if (interactive) {
_state.value = _state.value.copy(saveCallback = ::unsetSaveCallback)
}
return@withLock false
}
return false
}
try {
val name = fileHelper.save(savePath, markdown.value)
_fileName.emit(name)
path.emit(savePath)
isDirty.set(false)
Timber.i("Saved file ${fileName.value} to uri $savePath")
Timber.i("Persisting autosave uri in shared prefs: $savePath")
preferenceHelper[Preference.AUTOSAVE_URI] = savePath
Timber.i("Saving file to $actualSavePath...")
val currentState = _state.value
val name = fileHelper.save(actualSavePath, currentState.markdown)
_state.value = currentState.copy(
fileName = name,
path = actualSavePath,
dirty = false,
toast = if (interactive) "Successfully saved $name" else null
)
Timber.i("Saved file $name to uri $actualSavePath")
Timber.i("Persisting autosave uri in shared prefs: $actualSavePath")
preferenceHelper[Preference.AUTOSAVE_URI] = actualSavePath
true
} catch (e: Exception) {
val message = "Failed to save file to $savePath"
val message = "Failed to save file to $actualSavePath"
Timber.e(e, message)
_effects.emit(Effect.Error(message))
_state.value = _state.value.copy(
alert = AlertDialogModel(
text = message,
confirmButton = AlertDialogModel.ButtonModel(
text = "OK",
onClick = ::dismissAlert
)
)
)
false
}
}
}
suspend fun autosave() {
if (!isDirty.get()) {
val isAutoSaveEnabled = preferenceHelper[Preference.AUTOSAVE_ENABLED] as Boolean
if (!isAutoSaveEnabled) {
Timber.i("Ignoring autosave as autosave not enabled")
return
}
if (!_state.value.dirty) {
Timber.d("Ignoring autosave as contents haven't changed")
return
}
@ -111,52 +158,59 @@ class MarkdownViewModel(
Timber.i("Ignoring autosave since manual save is already in progress")
return
}
val isAutoSaveEnabled = preferenceHelper[Preference.AUTOSAVE_ENABLED] as Boolean
if (!isAutoSaveEnabled) {
Timber.i("Ignoring autosave as autosave not enabled")
return
}
if (!save(promptSavePath = false)) {
// The user has left the app, with autosave enabled, and we don't already have a
// Uri for them or for some reason we were unable to save to the original Uri. In
// this case, we need to just save to internal file storage so that we can recover
val file = File(fileHelper.defaultDirectory, fileName.value).toURI()
Timber.i("No cached uri for autosave, saving to $file instead")
save(file)
Timber.d("Performing autosave")
if (!save(interactive = false)) {
withContext(Dispatchers.IO) {
// The user has left the app, with autosave enabled, and we don't already have a
// Uri for them or for some reason we were unable to save to the original Uri. In
// this case, we need to just save to internal file storage so that we can recover
val file = File(fileHelper.defaultDirectory, _state.value.fileName).toURI()
Timber.i("No cached uri for autosave, saving to $file instead")
// Here we call the fileHelper directly so that the file is still registered as dirty.
// This prevents the user from ending up in a scenario where they've autosaved the file
// to an internal storage location, thus marking it as not dirty, but no longer able to
// access the file if the accidentally go to create a new file without properly saving
// the current one
fileHelper.save(file, _state.value.markdown)
preferenceHelper[Preference.AUTOSAVE_URI] = file
}
}
}
suspend fun reset(untitledFileName: String, force: Boolean = false) {
fun reset(untitledFileName: String, force: Boolean = false) {
Timber.i("Resetting view model to default state")
if (!force && isDirty.get()) {
_effects.emit(Effect.Prompt(
"Would you like to save your changes?",
confirm = {
viewModelScope.launch {
_effects.emit(Effect.OpenSaveDialog {
reset(untitledFileName, false)
})
if (!force && _state.value.dirty) {
_state.value = _state.value.copy(alert = AlertDialogModel(
text = "Would you like to save your changes?",
confirmButton = AlertDialogModel.ButtonModel(
text = "Yes",
onClick = {
_state.value = _state.value.copy(
saveCallback = {
reset(untitledFileName, false)
}
)
}
},
cancel = {
viewModelScope.launch {
),
dismissButton = AlertDialogModel.ButtonModel(
text = "No",
onClick = {
reset(untitledFileName, true)
}
}
)
))
return
}
_fileName.emit(untitledFileName)
_markdown.emit("")
path.emit(null)
_effects.emit(Effect.ClearText)
isDirty.set(false)
_state.value = EditorState(fileName = untitledFileName)
Timber.i("Removing autosave uri from shared prefs")
preferenceHelper[Preference.AUTOSAVE_URI] = null
}
companion object {
fun factory(fileHelper: FileHelper, preferenceHelper: PreferenceHelper): ViewModelProvider.Factory = object : ViewModelProvider.Factory {
fun factory(
fileHelper: FileHelper,
preferenceHelper: PreferenceHelper
): ViewModelProvider.Factory = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(
modelClass: Class<T>,
@ -166,13 +220,12 @@ class MarkdownViewModel(
}
}
}
sealed interface Effect {
data class OpenSaveDialog(val postSaveBlock: suspend () -> Unit) : Effect
data class Prompt(val text: String, val confirm: () -> Unit, val cancel: () -> Unit) :
Effect
data object ClearText : Effect
data class Error(val text: String) : Effect
}
}
data class AlertDialogModel(
val text: String,
val confirmButton: ButtonModel,
val dismissButton: ButtonModel? = null
) {
data class ButtonModel(val text: String, val onClick: () -> Unit)
}

View file

@ -1,6 +1,7 @@
package com.wbrawner.simplemarkdown.ui
import android.content.Intent
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.ExperimentalFoundationApi
@ -21,7 +22,7 @@ import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Share
@ -39,6 +40,9 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Snackbar
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
@ -47,11 +51,9 @@ import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberDrawerState
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -59,7 +61,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
@ -67,6 +68,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.KeyboardCapitalization
@ -75,15 +77,18 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat.startActivity
import androidx.navigation.NavController
import com.wbrawner.simplemarkdown.AlertDialogModel
import com.wbrawner.simplemarkdown.EditorState
import com.wbrawner.simplemarkdown.MarkdownViewModel
import com.wbrawner.simplemarkdown.R
import com.wbrawner.simplemarkdown.Route
import com.wbrawner.simplemarkdown.model.Readability
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.net.URI
import kotlin.reflect.KProperty1
@Composable
fun MainScreen(
@ -91,164 +96,216 @@ fun MainScreen(
viewModel: MarkdownViewModel,
enableWideLayout: Boolean,
enableAutosave: Boolean,
enableReadability: Boolean,
darkMode: String,
enableReadability: Boolean
) {
var lockSwiping by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
val fileName by viewModel.fileName.collectAsState()
val openFileLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) {
coroutineScope.launch {
viewModel.load(it.toString())
}
}
val saveFileLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/*")) {
it?.let {
coroutineScope.launch {
viewModel.save(URI.create(it.toString()))
}
}
}
var errorMessage by remember { mutableStateOf<String?>(null) }
var promptEffect by remember { mutableStateOf<MarkdownViewModel.Effect.Prompt?>(null) }
var clearText by remember { mutableStateOf(0) }
LaunchedEffect(viewModel) {
viewModel.effects.collect { effect ->
when (effect) {
is MarkdownViewModel.Effect.OpenSaveDialog -> saveFileLauncher.launch(fileName)
is MarkdownViewModel.Effect.Error -> errorMessage = effect.text
is MarkdownViewModel.Effect.Prompt -> promptEffect = effect
is MarkdownViewModel.Effect.ClearText -> clearText++
}
}
}
val fileName by viewModel.collectAsState(EditorState::fileName, "")
val markdown by viewModel.collectAsState(EditorState::markdown, "")
val alert by viewModel.collectAsState(EditorState::alert, null)
val saveCallback by viewModel.collectAsState(EditorState::saveCallback, null)
LaunchedEffect(enableAutosave) {
if (!enableAutosave) return@LaunchedEffect
while (isActive) {
delay(30_000)
delay(500)
viewModel.autosave()
}
}
errorMessage?.let { message ->
AlertDialog(
onDismissRequest = { errorMessage = null },
confirmButton = {
TextButton(onClick = { errorMessage = null }) {
Text("OK")
}
},
text = { Text(message) }
)
val toast by viewModel.collectAsState(EditorState::toast, null)
MainScreen(
fileName = fileName,
markdown = markdown,
setMarkdown = viewModel::updateMarkdown,
message = toast,
dismissMessage = viewModel::dismissToast,
alert = alert,
dismissAlert = viewModel::dismissAlert,
navigate = {
navController.navigate(it.path)
},
navigateBack = { navController.popBackStack() },
loadFile = {
coroutineScope.launch {
viewModel.load(it.toString())
}
},
saveFile = {
coroutineScope.launch {
viewModel.save(it)
}
},
saveCallback = saveCallback,
reset = {
viewModel.reset("Untitled.md")
},
enableWideLayout = enableWideLayout,
enableReadability = enableReadability,
)
}
@Composable
private fun MainScreen(
fileName: String = "Untitled.md",
markdown: String = "",
setMarkdown: (String) -> Unit = {},
message: String? = null,
dismissMessage: () -> Unit = {},
alert: AlertDialogModel? = null,
dismissAlert: () -> Unit = {},
navigate: (Route) -> Unit = {},
navigateBack: () -> Unit = {},
loadFile: (Uri?) -> Unit = {},
saveFile: (URI?) -> Unit = {},
saveCallback: (() -> Unit)? = null,
reset: () -> Unit = {},
enableWideLayout: Boolean = false,
enableReadability: Boolean = false
) {
var lockSwiping by remember { mutableStateOf(false) }
val openFileLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) {
loadFile(it)
}
val saveFileLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/*")) {
it?.let { uri -> saveFile(URI.create(uri.toString())) }
}
saveCallback?.let { callback ->
val launcher =
rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/*")) {
it?.let { uri -> saveFile(URI.create(uri.toString())) }
callback()
}
LaunchedEffect(callback) {
launcher.launch(fileName)
}
}
promptEffect?.let { prompt ->
val snackBarState = remember { SnackbarHostState() }
LaunchedEffect(message) {
message?.let {
snackBarState.showSnackbar(it)
dismissMessage()
}
}
alert?.let {
AlertDialog(
onDismissRequest = { errorMessage = null },
onDismissRequest = dismissAlert,
confirmButton = {
TextButton(onClick = {
prompt.confirm()
promptEffect = null
}) {
Text("Yes")
TextButton(onClick = it.confirmButton.onClick) {
Text(it.confirmButton.text)
}
},
dismissButton = {
TextButton(onClick = {
prompt.cancel()
promptEffect = null
}) {
Text("No")
it.dismissButton?.let { dismissButton ->
TextButton(onClick = dismissButton.onClick) {
Text(dismissButton.text)
}
}
},
text = { Text(prompt.text) }
text = { Text(it.text) }
)
}
MarkdownNavigationDrawer(navigate = { navController.navigate(it.path) }) { drawerState ->
Scaffold(topBar = {
val context = LocalContext.current
MarkdownTopAppBar(title = fileName,
backAsUp = false,
navController = navController,
drawerState = drawerState,
actions = {
IconButton(onClick = {
val shareIntent = Intent(Intent.ACTION_SEND)
shareIntent.putExtra(Intent.EXTRA_TEXT, viewModel.markdown.value)
shareIntent.type = "text/plain"
startActivity(
context, Intent.createChooser(
shareIntent, context.getString(R.string.share_file)
), null
)
}) {
Icon(imageVector = Icons.Default.Share, contentDescription = "Share")
}
Box {
var menuExpanded by remember { mutableStateOf(false) }
IconButton(onClick = { menuExpanded = true }) {
Icon(imageVector = Icons.Default.MoreVert, "Editor Actions")
MarkdownNavigationDrawer(navigate) { drawerState ->
Scaffold(
topBar = {
val context = LocalContext.current
MarkdownTopAppBar(title = fileName,
backAsUp = false,
goBack = navigateBack,
drawerState = drawerState,
actions = {
IconButton(onClick = {
val shareIntent = Intent(Intent.ACTION_SEND)
shareIntent.putExtra(Intent.EXTRA_TEXT, markdown)
shareIntent.type = "text/plain"
startActivity(
context, Intent.createChooser(
shareIntent, context.getString(R.string.share_file)
), null
)
}) {
Icon(imageVector = Icons.Default.Share, contentDescription = "Share")
}
DropdownMenu(expanded = menuExpanded,
onDismissRequest = { menuExpanded = false }) {
DropdownMenuItem(text = { Text("New") }, onClick = {
menuExpanded = false
coroutineScope.launch {
viewModel.reset("Untitled.md")
}
})
DropdownMenuItem(text = { Text("Open") }, onClick = {
menuExpanded = false
openFileLauncher.launch(arrayOf("text/*"))
})
DropdownMenuItem(text = { Text("Save") }, onClick = {
menuExpanded = false
coroutineScope.launch {
viewModel.save()
}
})
DropdownMenuItem(text = { Text("Save as…") },
onClick = {
Box {
var menuExpanded by remember { mutableStateOf(false) }
IconButton(onClick = { menuExpanded = true }) {
Icon(imageVector = Icons.Default.MoreVert, "Editor Actions")
}
DropdownMenu(expanded = menuExpanded,
onDismissRequest = { menuExpanded = false }) {
DropdownMenuItem(text = { Text("New") }, onClick = {
menuExpanded = false
saveFileLauncher.launch(fileName)
reset()
})
if (!enableWideLayout) {
DropdownMenuItem(text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Lock Swiping")
Checkbox(
checked = lockSwiping,
onCheckedChange = { lockSwiping = !lockSwiping })
}
}, onClick = {
lockSwiping = !lockSwiping
DropdownMenuItem(text = { Text("Open") }, onClick = {
menuExpanded = false
openFileLauncher.launch(arrayOf("text/*"))
})
DropdownMenuItem(text = { Text("Save") }, onClick = {
menuExpanded = false
saveFile(null)
})
DropdownMenuItem(text = { Text("Save as…") },
onClick = {
menuExpanded = false
saveFileLauncher.launch(fileName)
})
if (!enableWideLayout) {
DropdownMenuItem(text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Lock Swiping")
Checkbox(
checked = lockSwiping,
onCheckedChange = { lockSwiping = !lockSwiping })
}
}, onClick = {
lockSwiping = !lockSwiping
menuExpanded = false
})
}
}
}
}
})
}) { paddingValues ->
val markdown by viewModel.markdown.collectAsState()
val (textFieldValue, setTextFieldValue) = remember(clearText) {
val annotatedMarkdown = if (enableReadability) {
markdown.annotateReadability()
} else {
AnnotatedString(markdown)
}
mutableStateOf(TextFieldValue(annotatedMarkdown))
}
val setTextFieldAndViewModelValues: (TextFieldValue) -> Unit = {
setTextFieldValue(it)
coroutineScope.launch {
viewModel.updateMarkdown(it.text)
)
},
snackbarHost = {
SnackbarHost(
modifier = Modifier.imePadding(),
hostState = snackBarState
) {
Snackbar(it)
}
}
) { paddingValues ->
if (enableWideLayout) {
Row(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
MarkdownTextField(modifier = Modifier.fillMaxHeight().weight(1f), textFieldValue, setTextFieldAndViewModelValues)
Spacer(modifier = Modifier.fillMaxHeight().width(1.dp).background(color = MaterialTheme.colorScheme.primary))
MarkdownPreview(modifier = Modifier.fillMaxHeight().weight(1f), markdown, darkMode)
Row(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
MarkdownTextField(
modifier = Modifier
.fillMaxHeight()
.weight(1f),
markdown = markdown,
setMarkdown = setMarkdown,
enableReadability = enableReadability
)
Spacer(
modifier = Modifier
.fillMaxHeight()
.width(1.dp)
.background(color = MaterialTheme.colorScheme.primary)
)
MarkdownPreview(
modifier = Modifier
.fillMaxHeight()
.weight(1f),
markdown = markdown
)
}
} else {
Column(
@ -257,12 +314,10 @@ fun MainScreen(
.padding(paddingValues)
) {
TabbedMarkdownEditor(
coroutineScope,
lockSwiping,
textFieldValue,
setTextFieldAndViewModelValues,
markdown,
darkMode
markdown = markdown,
setMarkdown = setMarkdown,
lockSwiping = lockSwiping,
enableReadability = enableReadability
)
}
}
@ -273,13 +328,12 @@ fun MainScreen(
@Composable
@OptIn(ExperimentalFoundationApi::class)
private fun TabbedMarkdownEditor(
coroutineScope: CoroutineScope,
lockSwiping: Boolean,
textFieldValue: TextFieldValue,
setTextFieldAndViewModelValues: (TextFieldValue) -> Unit,
markdown: String,
darkMode: String
setMarkdown: (String) -> Unit,
lockSwiping: Boolean,
enableReadability: Boolean
) {
val coroutineScope = rememberCoroutineScope()
val pagerState = rememberPagerState { 2 }
TabRow(selectedTabIndex = pagerState.currentPage) {
Tab(text = { Text("Edit") },
@ -301,9 +355,14 @@ private fun TabbedMarkdownEditor(
}
}
if (page == 0) {
MarkdownTextField(modifier = Modifier.fillMaxSize(), textFieldValue, setTextFieldAndViewModelValues)
MarkdownTextField(
modifier = Modifier.fillMaxSize(),
markdown = markdown,
setMarkdown = setMarkdown,
enableReadability = enableReadability
)
} else {
MarkdownPreview(modifier = Modifier.fillMaxSize(), markdown, darkMode)
MarkdownPreview(modifier = Modifier.fillMaxSize(), markdown)
}
}
}
@ -371,7 +430,7 @@ fun MarkdownNavigationDrawer(
@Composable
fun MarkdownTopAppBar(
title: String,
navController: NavController,
goBack: () -> Unit,
backAsUp: Boolean = true,
drawerState: DrawerState? = null,
actions: (@Composable RowScope.() -> Unit)? = null
@ -382,7 +441,7 @@ fun MarkdownTopAppBar(
}, navigationIcon = {
val (icon, contentDescription, onClick) = remember {
if (backAsUp) {
Triple(Icons.Default.ArrowBack, "Go Back") { navController.popBackStack() }
Triple(Icons.AutoMirrored.Filled.ArrowBack, "Go Back", goBack)
} else {
Triple(
Icons.Default.Menu, "Main Menu"
@ -406,10 +465,26 @@ fun MarkdownTopAppBar(
@Composable
fun MarkdownTextField(
modifier: Modifier,
textFieldValue: TextFieldValue,
setTextFieldValue: (TextFieldValue) -> Unit,
markdown: String,
setMarkdown: (String) -> Unit,
enableReadability: Boolean = false
) {
val (selection, setSelection) = remember { mutableStateOf(TextRange.Zero) }
val (composition, setComposition) = remember { mutableStateOf<TextRange?>(null) }
val textFieldValue = remember(markdown) {
val annotatedMarkdown = if (enableReadability) {
markdown.annotateReadability()
} else {
AnnotatedString(markdown)
}
TextFieldValue(annotatedMarkdown, selection, composition)
}
val setTextFieldAndViewModelValues: (TextFieldValue) -> Unit = {
setSelection(it.selection)
setComposition(it.composition)
setMarkdown(it.text)
}
TextField(
modifier = modifier.imePadding(),
colors = TextFieldDefaults.colors(
@ -421,15 +496,7 @@ fun MarkdownTextField(
unfocusedIndicatorColor = Color.Transparent
),
value = textFieldValue,
onValueChange = {
setTextFieldValue(
if (enableReadability) {
it.copy(annotatedString = it.text.annotateReadability())
} else {
it
}
)
},
onValueChange = setTextFieldAndViewModelValues,
placeholder = {
Text("Markdown here…")
},
@ -440,3 +507,8 @@ fun MarkdownTextField(
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences)
)
}
@Composable
fun <P> MarkdownViewModel.collectAsState(prop: KProperty1<EditorState, P>, initial: P): State<P> =
state.map { prop.get(it) }
.collectAsState(initial)

View file

@ -22,7 +22,7 @@ fun MarkdownInfoScreen(
topBar = {
MarkdownTopAppBar(
title = title,
navController = navController,
goBack = { navController.popBackStack() },
)
}
) { paddingValues ->
@ -35,8 +35,7 @@ fun MarkdownInfoScreen(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
markdown = markdown,
"Auto"
markdown = markdown
)
}
}

View file

@ -3,6 +3,7 @@ package com.wbrawner.simplemarkdown.ui
import android.content.Context
import android.view.ViewGroup
import android.webkit.WebView
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@ -24,9 +25,9 @@ private const val container = "<main id=\"content\"></main>"
@OptIn(ExperimentalStdlibApi::class)
@Composable
fun MarkdownPreview(modifier: Modifier = Modifier, markdown: String, darkMode: String) {
fun MarkdownPreview(modifier: Modifier = Modifier, markdown: String) {
val materialColors = MaterialTheme.colorScheme
val style = remember(darkMode) {
val style = remember(isSystemInDarkTheme()) {
"""body {
| background: #${materialColors.surface.toArgb().toHexString().substring(2)};
| color: #${materialColors.onSurface.toArgb().toHexString().substring(2)};

View file

@ -35,7 +35,7 @@ import com.wbrawner.simplemarkdown.utility.PreferenceHelper
@Composable
fun SettingsScreen(navController: NavController, preferenceHelper: PreferenceHelper) {
Scaffold(topBar = {
MarkdownTopAppBar(title = "Settings", navController = navController)
MarkdownTopAppBar(title = "Settings", goBack = { navController.popBackStack() })
}) { paddingValues ->
Column(
modifier = Modifier

View file

@ -37,7 +37,7 @@ import com.wbrawner.simplemarkdown.utility.SupportLinks
@Composable
fun SupportScreen(navController: NavController) {
Scaffold(topBar = {
MarkdownTopAppBar(title = "Support SimpleMarkdown", navController = navController)
MarkdownTopAppBar(title = "Support SimpleMarkdown", goBack = { navController.popBackStack() })
}) { paddingValues ->
val context = LocalContext.current
Column(

View file

@ -2,6 +2,8 @@ package com.wbrawner.simplemarkdown.utility
import android.content.Context
import android.content.Intent
import android.os.Environment
import android.provider.MediaStore
import androidx.core.net.toUri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@ -32,14 +34,21 @@ interface FileHelper {
}
class AndroidFileHelper(private val context: Context) : FileHelper {
override val defaultDirectory: File = context.filesDir
override val defaultDirectory: File = context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS)
?: context.filesDir
override suspend fun open(source: URI): Pair<String, String>? = withContext(Dispatchers.IO) {
val uri = source.toString().toUri()
context.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
try {
context.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
} catch (e: SecurityException) {
// We weren't granted the persistent read/write permission for this file.
// TODO: Return whether or not we got the persistent permission in order to determine
// whether or not we should show this file in the recent files section
}
context.contentResolver.openFileDescriptor(uri, "r")
?.use {
uri.getName(context) to FileInputStream(it.fileDescriptor).reader()

View file

@ -1,29 +1,39 @@
package com.wbrawner.simplemarkdown
import com.wbrawner.simplemarkdown.utility.FileHelper
import kotlinx.coroutines.delay
import java.io.File
import java.net.URI
class FakeFileHelper : FileHelper {
override val defaultDirectory: File
get() = File.createTempFile("sm", null)
override val defaultDirectory: File by lazy {
File.createTempFile("simplemarkdown", null)
.apply {
delete()
mkdir()
}
}
var file: Pair<String, String> = "Untitled.md" to "This is a test file"
var openedUris = ArrayDeque<URI>()
var savedData = ArrayDeque<SavedData>()
@Volatile
var errorOnOpen: Boolean = false
@Volatile
var errorOnSave: Boolean = false
override suspend fun open(source: URI): Pair<String, String> {
delay(1000)
if (errorOnOpen) error("errorOnOpen set to true")
openedUris.addLast(source)
return file
}
override suspend fun save(destination: URI, content: String): String {
delay(1000)
if (errorOnSave) error("errorOnSave set to true")
savedData.addLast(SavedData(destination, content))
return file.first
return destination.path.substringAfterLast("/")
}
}

View file

@ -6,7 +6,7 @@ import kotlinx.coroutines.flow.StateFlow
class FakePreferenceHelper: PreferenceHelper {
val preferences = mutableMapOf<Preference, Any?>()
override fun get(preference: Preference): Any? = preferences[preference]
override fun get(preference: Preference): Any? = preferences[preference]?: preference.default
override fun set(preference: Preference, value: Any?) {
preferences[preference] = value

View file

@ -1,56 +1,296 @@
package com.wbrawner.simplemarkdown
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.CreationExtras
import com.wbrawner.simplemarkdown.utility.Preference
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestResult
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import timber.log.Timber
import java.io.File
import java.net.URI
import java.util.Deque
import java.util.concurrent.ConcurrentLinkedDeque
class MarkdownViewModelTest {
private lateinit var fileHelper: FakeFileHelper
private lateinit var preferenceHelper: FakePreferenceHelper
private lateinit var viewModelFactory: ViewModelProvider.Factory
private lateinit var viewModel: MarkdownViewModel
private lateinit var viewModelScope: TestScope
@OptIn(ExperimentalCoroutinesApi::class)
@Before
fun setup() {
Timber.plant(object: Timber.Tree() {
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
println("$tag/$priority: $message")
t?.printStackTrace()
}
})
val scheduler = StandardTestDispatcher()
Dispatchers.setMain(scheduler)
viewModelScope = TestScope(scheduler)
fileHelper = FakeFileHelper()
preferenceHelper = FakePreferenceHelper()
viewModel = MarkdownViewModel(fileHelper, preferenceHelper)
viewModelFactory = MarkdownViewModel.factory(fileHelper, preferenceHelper)
viewModel = viewModelFactory.create(MarkdownViewModel::class.java, CreationExtras.Empty)
viewModelScope.advanceUntilIdle()
}
@Test
fun testMarkdownUpdate() = runBlocking {
assertEquals("", viewModel.markdown.value)
fun testMarkdownUpdate() = runTest {
assertEquals("", viewModel.state.value.markdown)
viewModel.updateMarkdown("Updated content")
assertEquals("Updated content", viewModel.markdown.value)
assertEquals("Updated content", viewModel.state.value.markdown)
}
@Test
fun testLoadWithNoPathAndNoAutosaveUri() = runBlocking {
fun testLoadWithNoPathAndNoAutosaveUri() = runTest {
viewModel.load(null)
assertTrue(fileHelper.openedUris.isEmpty())
}
@Test
fun testLoadWithNoPathAndAutosaveUri() = runBlocking {
fun testAutoLoad() = runTest {
val uri = URI.create("file:///home/user/Untitled.md")
preferenceHelper[Preference.AUTOSAVE_URI] = uri.toString()
viewModel.load(null)
viewModel = viewModelFactory.create(MarkdownViewModel::class.java, CreationExtras.Empty)
viewModelScope.advanceUntilIdle()
assertEquals(uri, fileHelper.openedUris.firstOrNull())
val (fileName, contents) = fileHelper.file
assertEquals(fileName, viewModel.fileName.value)
assertEquals(contents, viewModel.markdown.value)
assertEquals(fileName, viewModel.state.value.fileName)
assertEquals(contents, viewModel.state.value.markdown)
}
@Test
fun testLoadWithPath() = runBlocking {
fun testLoadWithPath() = runTest {
val uri = URI.create("file:///home/user/Untitled.md")
viewModel.load(uri.toString())
assertEquals(uri, fileHelper.openedUris.firstOrNull())
val (fileName, contents) = fileHelper.file
assertEquals(fileName, viewModel.fileName.value)
assertEquals(contents, viewModel.markdown.value)
assertEquals(fileName, viewModel.state.value.fileName)
assertEquals(contents, viewModel.state.value.markdown)
}
@Test
fun testLoadWithEmptyPath() = runTest {
preferenceHelper[Preference.AUTOSAVE_URI] = ""
viewModel.load("")
assertEquals(null, preferenceHelper[Preference.AUTOSAVE_URI])
assertTrue(fileHelper.openedUris.isEmpty())
}
@Test
fun testLoadWithInvalidUri() = runTest {
viewModel.load(":/:/")
}
@Test
fun testLoadWithError() = runTest {
fileHelper.errorOnOpen = true
val uri = URI.create("file:///home/user/Untitled.md")
viewModel.load(uri.toString())
assertNotNull(viewModel.state.value.alert)
}
@Test
fun testSaveWithNullPath() = runTest {
assertFalse(viewModel.save(null, false))
assertNull(viewModel.state.value.alert)
assertNull(viewModel.state.value.saveCallback)
}
@Test
fun testSaveWithNullPathAndPrompt() = runTest {
assertFalse(viewModel.save(null, true))
assertNotNull(viewModel.state.value.saveCallback)
viewModel.state.value.saveCallback!!.invoke()
assertNull(viewModel.state.value.saveCallback)
}
@Test
fun testSaveWithValidPath() = runTest {
val uri = URI.create("file:///home/user/Saved.md")
val testMarkdown = "# Test"
viewModel.updateMarkdown(testMarkdown)
assertEquals(testMarkdown, viewModel.state.value.markdown)
assertTrue(viewModel.save(uri))
assertEquals("Saved.md", viewModel.state.value.fileName)
assertEquals(uri, fileHelper.savedData.last().uri)
assertEquals(testMarkdown, fileHelper.savedData.last().content)
assertEquals(uri, preferenceHelper[Preference.AUTOSAVE_URI])
}
@Test
fun testSaveWithException() = runTest {
val uri = URI.create("file:///home/user/Untitled.md")
val testMarkdown = "# Test"
viewModel.updateMarkdown(testMarkdown)
assertEquals(testMarkdown, viewModel.state.value.markdown)
fileHelper.errorOnSave = true
assertNull(viewModel.state.value.alert)
assertFalse(viewModel.save(uri))
assertNotNull(viewModel.state.value.alert)
requireNotNull(viewModel.state.value.alert?.confirmButton?.onClick).invoke()
assertNull(viewModel.state.value.alert)
}
@Test
fun testResetWithSavedChanges() = runTest {
viewModel.updateMarkdown("# Test")
val uri = URI.create("file:///home/user/Saved.md")
assertTrue(viewModel.save(uri))
assertFalse(viewModel.state.value.dirty)
assertNull(viewModel.state.value.alert)
viewModel.reset("New.md")
assertNull(viewModel.state.value.alert)
with(viewModel.state.value) {
assertEquals("New.md", fileName)
assertEquals("", markdown)
assertNull(path)
assertNull(saveCallback)
assertNull(alert)
assertFalse(dirty)
}
}
@Test
fun testResetWithUnsavedChanges() = runTest {
viewModel.updateMarkdown("# Test")
assertTrue(viewModel.state.value.dirty)
assertNull(viewModel.state.value.alert)
viewModel.reset("Untitled.md")
with(viewModel.state.value.alert) {
assertNotNull(this)
requireNotNull(this)
val onClick = dismissButton?.onClick
assertNotNull(onClick)
requireNotNull(onClick)
onClick.invoke()
}
assertEquals(viewModel.state.value, EditorState())
}
@Test
fun testResetWithUnsavedChangesAndPrompt() = runTest {
viewModel.updateMarkdown("# Test")
assertTrue(viewModel.state.value.dirty)
assertNull(viewModel.state.value.alert)
viewModel.reset("Untitled.md")
assertNull(viewModel.state.value.saveCallback)
with(viewModel.state.value.alert) {
assertNotNull(this)
requireNotNull(this)
confirmButton.onClick.invoke()
}
val uri = URI.create("file:///home/user/Saved.md")
viewModel.save(uri)
assertNotNull(viewModel.state.value.saveCallback)
requireNotNull(viewModel.state.value.saveCallback).invoke()
assertEquals(viewModel.state.value, EditorState())
}
@Test
fun testForceResetWithUnsavedChanges() = runTest {
viewModel.updateMarkdown("# Test")
assertTrue(viewModel.state.value.dirty)
val uri = URI.create("file:///home/user/Saved.md")
assertTrue(viewModel.save(uri))
assertFalse(viewModel.state.value.dirty)
viewModel.updateMarkdown("# Test\n\nDirty changes")
assertTrue(viewModel.state.value.dirty)
assertNull(viewModel.state.value.alert)
viewModel.reset("Unsaved.md", true)
assertNull(viewModel.state.value.alert)
with(viewModel.state.value) {
assertEquals("Unsaved.md", fileName)
assertEquals("", markdown)
assertNull(path)
assertNull(saveCallback)
assertNull(alert)
assertFalse(dirty)
}
assertNull(preferenceHelper[Preference.AUTOSAVE_URI])
}
@Test
fun testAutosaveWithPreferenceDisabled() = runTest {
preferenceHelper[Preference.AUTOSAVE_ENABLED] = false
viewModel.updateMarkdown("# Test")
assertTrue(viewModel.state.value.dirty)
viewModel.autosave()
assertEquals(0, fileHelper.savedData.count())
}
@Test
fun testAutosaveWithNoNewData() = runTest {
viewModel.updateMarkdown("# Test")
val uri = URI.create("file:///home/user/Saved.md")
assertTrue(viewModel.save(uri))
assertEquals(1, fileHelper.savedData.count())
assertFalse(viewModel.state.value.dirty)
viewModel.autosave()
assertEquals(1, fileHelper.savedData.count())
}
@Test
fun testAutosaveWithSaveInProgress() = runTest {
viewModel.updateMarkdown("# Test")
val uri = URI.create("file:///home/user/Saved.md")
val coroutineScope = TestScope(StandardTestDispatcher())
coroutineScope.launch {
assertTrue(viewModel.save(uri))
}
coroutineScope.advanceTimeBy(500)
assertEquals(0, fileHelper.savedData.count())
assertTrue(viewModel.state.value.dirty)
viewModel.autosave()
assertEquals(0, fileHelper.savedData.count())
coroutineScope.advanceTimeBy(1000)
assertEquals(1, fileHelper.savedData.count())
}
@Test
fun testAutosaveWithUnknownUri() = runTest {
viewModel.updateMarkdown("# Test")
assertTrue(viewModel.state.value.dirty)
viewModel.autosave()
assertTrue(viewModel.state.value.dirty)
assertEquals(1, fileHelper.savedData.count())
assertEquals(
File(fileHelper.defaultDirectory, "Untitled.md").toURI(),
fileHelper.savedData.first().uri
)
}
@Test
fun testAutosaveWithKnownUri() = runTest {
viewModel.updateMarkdown("# Test")
assertTrue(viewModel.state.value.dirty)
val uri = URI.create("file:///home/user/Saved.md")
assertTrue(viewModel.save(uri))
assertEquals(1, fileHelper.savedData.count())
assertFalse(viewModel.state.value.dirty)
viewModel.updateMarkdown("# Test\n\nDirty changes")
assertTrue(viewModel.state.value.dirty)
viewModel.autosave()
assertEquals(2, fileHelper.savedData.count())
}
}