diff --git a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivityExt.kt b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivityExt.kt index e9bfe1a..2504966 100644 --- a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivityExt.kt +++ b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivityExt.kt @@ -45,7 +45,7 @@ internal fun ViewSiteActivity.maybeDisableChecks() { message( text = context.getString(R.string.disable_automatic_checks_prompt, model.name).toHtml() ) - positiveButton(R.string.disable) { viewModel.disable() } + positiveButton(R.string.disable) { viewModel.disableSite() } negativeButton(android.R.string.cancel) } } diff --git a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModel.kt b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModel.kt index 76258c5..a88013c 100644 --- a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModel.kt +++ b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModel.kt @@ -16,6 +16,8 @@ package com.afollestad.nocknock.ui.viewsite import androidx.annotation.CheckResult +import androidx.annotation.VisibleForTesting +import androidx.annotation.VisibleForTesting.PRIVATE import androidx.lifecycle.LifecycleObserver import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData @@ -90,10 +92,7 @@ class ViewSiteViewModel( @CheckResult fun onUrlWarningVisibility(): LiveData { return url.map { val parsed = HttpUrl.parse(it) - return@map it.isNotEmpty() && - parsed != null && - parsed.scheme() != "http" && - parsed.scheme() != "https" + return@map it.isNotEmpty() && parsed == null } } @@ -101,11 +100,10 @@ class ViewSiteViewModel( @CheckResult fun onValidationModeDescription(): LiveData { return validationMode.map { - when (it) { + when (it!!) { STATUS_CODE -> R.string.validation_mode_status_desc TERM_SEARCH -> R.string.validation_mode_term_desc JAVASCRIPT -> R.string.validation_mode_javascript_desc - else -> throw IllegalStateException("Unknown validation mode: $it") } } } @@ -204,7 +202,7 @@ class ViewSiteViewModel( } } - fun disable() { + fun disableSite() { validationManager.cancelCheck(site) notificationManager.cancelStatusNotification(site) @@ -224,13 +222,15 @@ class ViewSiteViewModel( } // Utilities - private fun getCheckIntervalMs(): Long { + @VisibleForTesting(otherwise = PRIVATE) + fun getCheckIntervalMs(): Long { val value = checkIntervalValue.value ?: return 0 val unit = checkIntervalUnit.value ?: return 0 return value * unit } - private fun getValidationArgs(): String? { + @VisibleForTesting(otherwise = PRIVATE) + fun getValidationArgs(): String? { return when (validationMode.value) { TERM_SEARCH -> validationSearchTerm.value JAVASCRIPT -> validationScript.value @@ -281,13 +281,13 @@ class ViewSiteViewModel( } // Validate arguments - if (validationMode == TERM_SEARCH && + if (validationMode.value == TERM_SEARCH && validationSearchTerm.value.isNullOrEmpty() ) { errorCount++ validationSearchTermError.value = R.string.please_enter_search_term validationScriptError.value = null - } else if (validationMode == JAVASCRIPT && + } else if (validationMode.value == JAVASCRIPT && validationScript.value.isNullOrEmpty() ) { errorCount++ diff --git a/app/src/test/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModelTest.kt b/app/src/test/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModelTest.kt index e06b747..c249e2c 100644 --- a/app/src/test/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModelTest.kt +++ b/app/src/test/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModelTest.kt @@ -15,6 +15,609 @@ */ package com.afollestad.nocknock.ui.viewsite -class ViewSiteViewModelTest +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.afollestad.nocknock.MOCK_MODEL_1 +import com.afollestad.nocknock.R +import com.afollestad.nocknock.data.model.Site +import com.afollestad.nocknock.data.model.SiteSettings +import com.afollestad.nocknock.data.model.Status.CHECKING +import com.afollestad.nocknock.data.model.Status.ERROR +import com.afollestad.nocknock.data.model.Status.OK +import com.afollestad.nocknock.data.model.Status.WAITING +import com.afollestad.nocknock.data.model.ValidationMode.JAVASCRIPT +import com.afollestad.nocknock.data.model.ValidationMode.STATUS_CODE +import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH +import com.afollestad.nocknock.data.model.ValidationResult +import com.afollestad.nocknock.engine.validation.ValidationManager +import com.afollestad.nocknock.mockDatabase +import com.afollestad.nocknock.notifications.NockNotificationManager +import com.afollestad.nocknock.test +import com.afollestad.nocknock.utilities.providers.StringProvider +import com.google.common.truth.Truth +import com.google.common.truth.Truth.assertThat +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.argumentCaptor +import com.nhaarman.mockitokotlin2.doAnswer +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.never +import com.nhaarman.mockitokotlin2.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Rule +import org.junit.Test +import java.util.Calendar -// TODO this will be mostly identical to the add site test +/** @author Aidan Follestad (@afollestad) */ +@ExperimentalCoroutinesApi +class ViewSiteViewModelTest { + + companion object { + private const val TEXT_NONE = "None" + private const val TEXT_EVERYTHING_CHECKS_OUT = "Everything checks out!" + private const val TEXT_WAITING = "Waiting..." + private const val TEXT_CHECKING = "Checking..." + private const val TEXT_CHECKS_DISABLED = "Automatic Checks Disabled" + } + + private val stringProvider = mock { + on { get(any()) } doAnswer { inv -> + val id = inv.getArgument(0) + when (id) { + R.string.none -> TEXT_NONE + R.string.everything_checks_out -> TEXT_EVERYTHING_CHECKS_OUT + R.string.waiting -> TEXT_WAITING + R.string.checking_status -> TEXT_CHECKING + R.string.auto_checks_disabled -> TEXT_CHECKS_DISABLED + else -> "" + } + } + } + private val database = mockDatabase() + private val validationManager = mock() + private val notificationManager = mock() + + @Rule @JvmField val rule = InstantTaskExecutorRule() + + private val viewModel = ViewSiteViewModel( + stringProvider, + database, + notificationManager, + validationManager, + Dispatchers.Unconfined, + Dispatchers.Unconfined + ) + + @After fun tearDown() = viewModel.destroy() + + @Test fun onUrlWarningVisibility() { + val urlWarningVisibility = viewModel.onUrlWarningVisibility() + .test() + + viewModel.url.value = "" + urlWarningVisibility.assertValues(false) + + viewModel.url.value = "helloworld" + urlWarningVisibility.assertValues(true) + + viewModel.url.value = "http://helloworld.com" + urlWarningVisibility.assertValues(false) + + viewModel.url.value = "ftp://helloworld.com" + urlWarningVisibility.assertValues(true) + } + + @Test fun onValidationModeDescription() { + val description = viewModel.onValidationModeDescription() + .test() + + viewModel.validationMode.value = STATUS_CODE + description.assertValues(R.string.validation_mode_status_desc) + + viewModel.validationMode.value = TERM_SEARCH + description.assertValues(R.string.validation_mode_term_desc) + + viewModel.validationMode.value = JAVASCRIPT + description.assertValues(R.string.validation_mode_javascript_desc) + } + + @Test fun onValidationSearchTermVisibility() { + val visibility = viewModel.onValidationSearchTermVisibility() + .test() + + viewModel.validationMode.value = STATUS_CODE + visibility.assertValues(false) + + viewModel.validationMode.value = TERM_SEARCH + visibility.assertValues(true) + + viewModel.validationMode.value = JAVASCRIPT + visibility.assertValues(false) + } + + @Test fun onValidationScriptVisibility() { + val visibility = viewModel.onValidationScriptVisibility() + .test() + + viewModel.validationMode.value = STATUS_CODE + visibility.assertValues(false) + + viewModel.validationMode.value = TERM_SEARCH + visibility.assertValues(false) + + viewModel.validationMode.value = JAVASCRIPT + visibility.assertValues(true) + } + + @Test fun onDisableChecksVisibility() { + val visibility = viewModel.onDisableChecksVisibility() + .test() + + viewModel.disabled.value = false + visibility.assertValues(true) + + viewModel.disabled.value = true + visibility.assertValues(false) + } + + @Test fun onDoneButtonText() { + val text = viewModel.onDoneButtonText() + .test() + + viewModel.disabled.value = false + text.assertValues(R.string.save_changes) + + viewModel.disabled.value = true + text.assertValues(R.string.renable_and_save_changes) + } + + @Test fun onLastCheckResultText() { + val text = viewModel.onLastCheckResultText() + .test() + val lastResult = ValidationResult( + siteId = 1, + timestampMs = 10, + status = OK, + reason = "Hello, world!" + ) + + viewModel.lastResult.value = null + text.assertValues(TEXT_NONE) + + viewModel.lastResult.value = lastResult + text.assertValues(TEXT_EVERYTHING_CHECKS_OUT) + + viewModel.lastResult.value = lastResult.copy(status = WAITING) + text.assertValues(TEXT_WAITING) + + viewModel.lastResult.value = lastResult.copy(status = CHECKING) + text.assertValues(TEXT_CHECKING) + + viewModel.lastResult.value = lastResult.copy( + status = ERROR, + reason = "Uh oh!" + ) + text.assertValues("Uh oh!") + } + + @Test fun onNextCheckText() { + viewModel.checkIntervalValue.value = 60 + viewModel.checkIntervalUnit.value = 5000 + + val text = viewModel.onNextCheckText() + .test() + val calendar = Calendar.getInstance() + .apply { + set(Calendar.YEAR, 2018) + set(Calendar.MONTH, Calendar.DECEMBER) + set(Calendar.DAY_OF_MONTH, 6) + set(Calendar.HOUR_OF_DAY, 8) + set(Calendar.MINUTE, 30) + set(Calendar.SECOND, 0) + } + val lastResult = ValidationResult( + siteId = 1, + timestampMs = calendar.timeInMillis, + status = OK, + reason = null + ) + + viewModel.disabled.value = true + viewModel.lastResult.value = lastResult + text.assertValues(TEXT_CHECKS_DISABLED) + + viewModel.disabled.value = false + text.assertValues("December 6, 8:35AM") + } + + @Test fun getCheckIntervalMs() { + viewModel.checkIntervalValue.value = 3 + viewModel.checkIntervalUnit.value = 200 + Truth.assertThat(viewModel.getCheckIntervalMs()) + .isEqualTo(600L) + } + + @Test fun getValidationArgs() { + viewModel.validationSearchTerm.value = "One" + viewModel.validationScript.value = "Two" + + viewModel.validationMode.value = STATUS_CODE + Truth.assertThat(viewModel.getValidationArgs()) + .isNull() + + viewModel.validationMode.value = TERM_SEARCH + Truth.assertThat(viewModel.getValidationArgs()) + .isEqualTo("One") + + viewModel.validationMode.value = JAVASCRIPT + Truth.assertThat(viewModel.getValidationArgs()) + .isEqualTo("Two") + } + + @Test fun commit_nameError() { + val onNameError = viewModel.onNameError() + .test() + val onUrlError = viewModel.onUrlError() + .test() + val onTimeoutError = viewModel.onTimeoutError() + .test() + val onCheckIntervalError = viewModel.onCheckIntervalError() + .test() + val onSearchTermError = viewModel.onValidationSearchTermError() + .test() + val onScriptError = viewModel.onValidationScriptError() + .test() + + fillInModel().apply { + name.value = "" + } + val onDone = mock<() -> Unit>() + viewModel.commit(onDone) + + verify(validationManager, never()) + .scheduleCheck(any(), any(), any(), any()) + onNameError.assertValues(R.string.please_enter_name) + onUrlError.assertNoValues() + onTimeoutError.assertNoValues() + onCheckIntervalError.assertNoValues() + onSearchTermError.assertNoValues() + onScriptError.assertNoValues() + + verify(onDone, never()).invoke() + } + + @Test fun commit_urlEmptyError() { + val onNameError = viewModel.onNameError() + .test() + val onUrlError = viewModel.onUrlError() + .test() + val onTimeoutError = viewModel.onTimeoutError() + .test() + val onCheckIntervalError = viewModel.onCheckIntervalError() + .test() + val onSearchTermError = viewModel.onValidationSearchTermError() + .test() + val onScriptError = viewModel.onValidationScriptError() + .test() + + fillInModel().apply { + url.value = "" + } + val onDone = mock<() -> Unit>() + viewModel.commit(onDone) + + verify(validationManager, never()) + .scheduleCheck(any(), any(), any(), any()) + onNameError.assertNoValues() + onUrlError.assertValues(R.string.please_enter_url) + onTimeoutError.assertNoValues() + onCheckIntervalError.assertNoValues() + onSearchTermError.assertNoValues() + onScriptError.assertNoValues() + + verify(onDone, never()).invoke() + } + + @Test fun commit_urlFormatError() { + val onNameError = viewModel.onNameError() + .test() + val onUrlError = viewModel.onUrlError() + .test() + val onTimeoutError = viewModel.onTimeoutError() + .test() + val onCheckIntervalError = viewModel.onCheckIntervalError() + .test() + val onSearchTermError = viewModel.onValidationSearchTermError() + .test() + val onScriptError = viewModel.onValidationScriptError() + .test() + + fillInModel().apply { + url.value = "ftp://www.idk.com" + } + val onDone = mock<() -> Unit>() + viewModel.commit(onDone) + + verify(validationManager, never()) + .scheduleCheck(any(), any(), any(), any()) + onNameError.assertNoValues() + onUrlError.assertValues(R.string.please_enter_valid_url) + onTimeoutError.assertNoValues() + onCheckIntervalError.assertNoValues() + onSearchTermError.assertNoValues() + onScriptError.assertNoValues() + + verify(onDone, never()).invoke() + } + + @Test fun commit_networkTimeout_error() { + val onNameError = viewModel.onNameError() + .test() + val onUrlError = viewModel.onUrlError() + .test() + val onTimeoutError = viewModel.onTimeoutError() + .test() + val onCheckIntervalError = viewModel.onCheckIntervalError() + .test() + val onSearchTermError = viewModel.onValidationSearchTermError() + .test() + val onScriptError = viewModel.onValidationScriptError() + .test() + + fillInModel().apply { + timeout.value = 0 + } + val onDone = mock<() -> Unit>() + viewModel.commit(onDone) + + verify(validationManager, never()) + .scheduleCheck(any(), any(), any(), any()) + onNameError.assertNoValues() + onUrlError.assertNoValues() + onTimeoutError.assertValues(R.string.please_enter_networkTimeout) + onCheckIntervalError.assertNoValues() + onSearchTermError.assertNoValues() + onScriptError.assertNoValues() + + verify(onDone, never()).invoke() + } + + @Test fun commit_checkIntervalError() { + val onNameError = viewModel.onNameError() + .test() + val onUrlError = viewModel.onUrlError() + .test() + val onTimeoutError = viewModel.onTimeoutError() + .test() + val onCheckIntervalError = viewModel.onCheckIntervalError() + .test() + val onSearchTermError = viewModel.onValidationSearchTermError() + .test() + val onScriptError = viewModel.onValidationScriptError() + .test() + + fillInModel().apply { + checkIntervalValue.value = 0 + } + val onDone = mock<() -> Unit>() + viewModel.commit(onDone) + + verify(validationManager, never()) + .scheduleCheck(any(), any(), any(), any()) + onNameError.assertNoValues() + onUrlError.assertNoValues() + onTimeoutError.assertNoValues() + onCheckIntervalError.assertValues(R.string.please_enter_check_interval) + onSearchTermError.assertNoValues() + onScriptError.assertNoValues() + + verify(onDone, never()).invoke() + } + + @Test fun commit_termSearchError() { + val onNameError = viewModel.onNameError() + .test() + val onUrlError = viewModel.onUrlError() + .test() + val onTimeoutError = viewModel.onTimeoutError() + .test() + val onCheckIntervalError = viewModel.onCheckIntervalError() + .test() + val onSearchTermError = viewModel.onValidationSearchTermError() + .test() + val onScriptError = viewModel.onValidationScriptError() + .test() + + fillInModel().apply { + validationMode.value = TERM_SEARCH + validationSearchTerm.value = "" + } + val onDone = mock<() -> Unit>() + viewModel.commit(onDone) + + verify(validationManager, never()) + .scheduleCheck(any(), any(), any(), any()) + onNameError.assertNoValues() + onUrlError.assertNoValues() + onTimeoutError.assertNoValues() + onCheckIntervalError.assertNoValues() + onSearchTermError.assertValues(R.string.please_enter_search_term) + onScriptError.assertNoValues() + + verify(onDone, never()).invoke() + } + + @Test fun commit_javaScript_error() { + val onNameError = viewModel.onNameError() + .test() + val onUrlError = viewModel.onUrlError() + .test() + val onTimeoutError = viewModel.onTimeoutError() + .test() + val onCheckIntervalError = viewModel.onCheckIntervalError() + .test() + val onSearchTermError = viewModel.onValidationSearchTermError() + .test() + val onScriptError = viewModel.onValidationScriptError() + .test() + + fillInModel().apply { + validationMode.value = JAVASCRIPT + validationScript.value = "" + } + val onDone = mock<() -> Unit>() + viewModel.commit(onDone) + + verify(validationManager, never()) + .scheduleCheck(any(), any(), any(), any()) + onNameError.assertNoValues() + onUrlError.assertNoValues() + onTimeoutError.assertNoValues() + onCheckIntervalError.assertNoValues() + onSearchTermError.assertNoValues() + onScriptError.assertValues(R.string.please_enter_javaScript) + + verify(onDone, never()).invoke() + } + + @Test fun commit_success() = runBlocking { + val isLoading = viewModel.onIsLoading() + .test() + val onNameError = viewModel.onNameError() + .test() + val onUrlError = viewModel.onUrlError() + .test() + val onTimeoutError = viewModel.onTimeoutError() + .test() + val onSearchTermError = viewModel.onValidationSearchTermError() + .test() + val onScriptError = viewModel.onValidationScriptError() + .test() + val onCheckIntervalError = viewModel.onCheckIntervalError() + .test() + + fillInModel() + val onDone = mock<() -> Unit>() + + viewModel.site = MOCK_MODEL_1 + viewModel.commit(onDone) + + val siteCaptor = argumentCaptor() + val settingsCaptor = argumentCaptor() + val resultCaptor = argumentCaptor() + + isLoading.assertValues(true, false) + verify(database.siteDao()).update(siteCaptor.capture()) + verify(database.siteSettingsDao()).update(settingsCaptor.capture()) + verify(database.validationResultsDao()).update(resultCaptor.capture()) + + // From fillInModel() below + val updatedSettings = MOCK_MODEL_1.settings!!.copy( + networkTimeout = 30000, + validationMode = JAVASCRIPT, + validationArgs = "throw 'Oh no!'", + disabled = false, + validationIntervalMs = 24 * 60000 + ) + val updatedResult = MOCK_MODEL_1.lastResult!!.copy( + status = WAITING + ) + val updatedModel = MOCK_MODEL_1.copy( + name = "Hello There", + url = "https://www.hellothere.com", + settings = updatedSettings, + lastResult = updatedResult + ) + + assertThat(siteCaptor.firstValue).isEqualTo(updatedModel) + assertThat(settingsCaptor.firstValue).isEqualTo(updatedSettings) + assertThat(resultCaptor.firstValue).isEqualTo(updatedResult) + + verify(validationManager).scheduleCheck( + site = updatedModel, + rightNow = true, + cancelPrevious = true, + fromFinishingJob = false + ) + + onNameError.assertNoValues() + onUrlError.assertNoValues() + onTimeoutError.assertNoValues() + onCheckIntervalError.assertNoValues() + onSearchTermError.assertNoValues() + onScriptError.assertNoValues() + + verify(onDone).invoke() + } + + @Test fun checkNow() { + val status = viewModel.status.test() + + viewModel.site = MOCK_MODEL_1 + val expectedModel = MOCK_MODEL_1.copy( + lastResult = MOCK_MODEL_1.lastResult!!.copy( + status = WAITING + ) + ) + + viewModel.checkNow() + verify(validationManager).scheduleCheck( + site = expectedModel, + rightNow = true, + cancelPrevious = true + ) + status.assertValues(WAITING) + } + + @Test fun removeSite() { + val isLoading = viewModel.onIsLoading() + .test() + val onDone = mock<() -> Unit>() + + viewModel.site = MOCK_MODEL_1 + viewModel.removeSite(onDone) + isLoading.assertValues(true, false) + + verify(validationManager).cancelCheck(MOCK_MODEL_1) + verify(notificationManager).cancelStatusNotification(MOCK_MODEL_1) + verify(database.siteDao()).delete(MOCK_MODEL_1) + verify(database.siteSettingsDao()).delete(MOCK_MODEL_1.settings!!) + verify(database.validationResultsDao()).delete(MOCK_MODEL_1.lastResult!!) + verify(onDone).invoke() + } + + @Test fun disableSite() { + val isLoading = viewModel.onIsLoading() + .test() + val disabled = viewModel.disabled.test() + + viewModel.site = MOCK_MODEL_1 + viewModel.disableSite() + isLoading.assertValues(true, false) + disabled.assertValues(true) + + val expectedSite = MOCK_MODEL_1.copy( + settings = MOCK_MODEL_1.settings!!.copy( + disabled = true + ) + ) + + verify(validationManager).cancelCheck(MOCK_MODEL_1) + verify(notificationManager).cancelStatusNotification(MOCK_MODEL_1) + verify(database.siteDao()).update(expectedSite) + verify(database.siteSettingsDao()).update(expectedSite.settings!!) + verify(database.validationResultsDao()).update(expectedSite.lastResult!!) + } + + private fun fillInModel() = viewModel.apply { + name.value = "Hello There" + url.value = "https://www.hellothere.com" + timeout.value = 30000 + validationMode.value = JAVASCRIPT + validationSearchTerm.value = null + validationScript.value = "throw 'Oh no!'" + checkIntervalValue.value = 24 + checkIntervalUnit.value = 60000 + } +} diff --git a/common/src/main/java/com/afollestad/nocknock/utilities/ext/DateExt.kt b/common/src/main/java/com/afollestad/nocknock/utilities/ext/DateExt.kt index 5ea3d77..64c1008 100644 --- a/common/src/main/java/com/afollestad/nocknock/utilities/ext/DateExt.kt +++ b/common/src/main/java/com/afollestad/nocknock/utilities/ext/DateExt.kt @@ -23,6 +23,6 @@ fun Long.formatDate(): String { if (this <= 0) { return "(None)" } - val df = SimpleDateFormat("MMMM dd, hh:mm:ss a", Locale.getDefault()) + val df = SimpleDateFormat("MMMM d, h:mma", Locale.getDefault()) return df.format(Date(this)) }