Move majority of view site logic to a presenter, add unit tests

This commit is contained in:
Aidan Follestad 2018-12-01 12:31:16 -08:00
parent cf802dfa2f
commit 2d81575e4b
7 changed files with 782 additions and 238 deletions

View file

@ -9,6 +9,8 @@ import com.afollestad.nocknock.ui.addsite.AddSitePresenter
import com.afollestad.nocknock.ui.addsite.RealAddSitePresenter
import com.afollestad.nocknock.ui.main.MainPresenter
import com.afollestad.nocknock.ui.main.RealMainPresenter
import com.afollestad.nocknock.ui.viewsite.RealViewSitePresenter
import com.afollestad.nocknock.ui.viewsite.ViewSitePresenter
import dagger.Binds
import dagger.Module
import javax.inject.Singleton
@ -28,4 +30,10 @@ abstract class MainBindModule {
abstract fun provideAddSitePresenter(
presenter: RealAddSitePresenter
): AddSitePresenter
@Binds
@Singleton
abstract fun provideViewSitePresenter(
presenter: RealViewSitePresenter
): ViewSitePresenter
}

View file

@ -10,20 +10,12 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.Uri
import android.os.Bundle
import android.util.Log
import android.util.Patterns.WEB_URL
import android.view.MenuItem
import android.view.View
import android.widget.ArrayAdapter
import androidx.annotation.CheckResult
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar
import androidx.core.text.HtmlCompat
import androidx.core.text.HtmlCompat.FROM_HTML_MODE_LEGACY
import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.nocknock.BuildConfig
import com.afollestad.nocknock.R
import com.afollestad.nocknock.data.LAST_CHECK_NONE
import com.afollestad.nocknock.data.ServerModel
@ -35,24 +27,17 @@ import com.afollestad.nocknock.data.ValidationMode.STATUS_CODE
import com.afollestad.nocknock.data.ValidationMode.TERM_SEARCH
import com.afollestad.nocknock.data.indexToValidationMode
import com.afollestad.nocknock.data.textRes
import com.afollestad.nocknock.engine.db.ServerModelStore
import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.ACTION_STATUS_UPDATE
import com.afollestad.nocknock.engine.statuscheck.CheckStatusManager
import com.afollestad.nocknock.notifications.NockNotificationManager
import com.afollestad.nocknock.ui.main.MainActivity
import com.afollestad.nocknock.utilities.ext.ScopeReceiver
import com.afollestad.nocknock.utilities.ext.formatDate
import com.afollestad.nocknock.utilities.ext.injector
import com.afollestad.nocknock.utilities.ext.isHttpOrHttps
import com.afollestad.nocknock.utilities.ext.safeRegisterReceiver
import com.afollestad.nocknock.utilities.ext.safeUnregisterReceiver
import com.afollestad.nocknock.utilities.ext.scopeWhileAttached
import com.afollestad.nocknock.viewcomponents.ext.dimenFloat
import com.afollestad.nocknock.viewcomponents.ext.disable
import com.afollestad.nocknock.viewcomponents.ext.enable
import com.afollestad.nocknock.viewcomponents.ext.hide
import com.afollestad.nocknock.viewcomponents.ext.onItemSelected
import com.afollestad.nocknock.viewcomponents.ext.onScroll
import com.afollestad.nocknock.viewcomponents.ext.show
import com.afollestad.nocknock.viewcomponents.ext.showOrHide
import com.afollestad.nocknock.viewcomponents.ext.trimmedText
import kotlinx.android.synthetic.main.activity_viewsite.checkIntervalLayout
@ -72,13 +57,8 @@ import kotlinx.android.synthetic.main.activity_viewsite.textNextCheck
import kotlinx.android.synthetic.main.activity_viewsite.textUrlWarning
import kotlinx.android.synthetic.main.activity_viewsite.toolbar
import kotlinx.android.synthetic.main.activity_viewsite.validationModeDescription
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import javax.inject.Inject
private const val KEY_VIEW_MODEL = "site_model"
import kotlin.coroutines.CoroutineContext
/** @author Aidan Follestad (@afollestad) */
fun MainActivity.intentToView(model: ServerModel) =
@ -87,40 +67,15 @@ fun MainActivity.intentToView(model: ServerModel) =
}
/** @author Aidan Follestad (@afollestad) */
class ViewSiteActivity : AppCompatActivity(),
View.OnClickListener,
Toolbar.OnMenuItemClickListener {
companion object {
private fun log(message: String) {
if (BuildConfig.DEBUG) {
Log.d("ViewSiteActivity", message)
}
}
}
class ViewSiteActivity : AppCompatActivity(), ViewSiteView {
private lateinit var currentModel: ServerModel
@Inject lateinit var serverModelStore: ServerModelStore
@Inject lateinit var notificationManager: NockNotificationManager
@Inject lateinit var checkStatusManager: CheckStatusManager
@Inject lateinit var presenter: ViewSitePresenter
private val intentReceiver = object : BroadcastReceiver() {
override fun onReceive(
context: Context,
intent: Intent
) {
log(
"Received broadcast ${intent.action}"
)
val model = intent.getSerializableExtra(KEY_VIEW_MODEL) as? ServerModel
if (model != null) {
this@ViewSiteActivity.currentModel = model
log(
"Received model update: $currentModel"
)
displayCurrentModel()
}
}
) = presenter.onBroadcast(intent)
}
@SuppressLint("SetTextI18n")
@ -133,7 +88,13 @@ class ViewSiteActivity : AppCompatActivity(),
toolbar.run {
setNavigationOnClickListener { finish() }
inflateMenu(R.menu.menu_viewsite)
setOnMenuItemClickListener(this@ViewSiteActivity)
setOnMenuItemClickListener {
when (it.itemId) {
R.id.refresh -> presenter.checkNow()
R.id.remove -> maybeRemoveSite()
}
return@setOnMenuItemClickListener true
}
}
scrollView.onScroll {
@ -145,25 +106,7 @@ class ViewSiteActivity : AppCompatActivity(),
}
inputUrl.setOnFocusChangeListener { _, hasFocus ->
if (!hasFocus) {
val inputStr = inputUrl.text
.toString()
.trim()
if (inputStr.isEmpty()) {
return@setOnFocusChangeListener
}
val uri = Uri.parse(inputStr)
if (uri.scheme == null) {
inputUrl.setText("http://$inputStr")
textUrlWarning.hide()
} else if (!uri.isHttpOrHttps()) {
textUrlWarning.show()
textUrlWarning.setText(R.string.warning_http_url)
} else {
textUrlWarning.hide()
}
}
presenter.onUrlInputFocusChange(hasFocus, inputUrl.trimmedText())
}
val validationOptionsAdapter = ArrayAdapter(
@ -174,34 +117,56 @@ class ViewSiteActivity : AppCompatActivity(),
validationOptionsAdapter.setDropDownViewResource(R.layout.list_item_spinner_dropdown)
responseValidationMode.adapter = validationOptionsAdapter
responseValidationMode.onItemSelected { pos ->
responseValidationSearchTerm.showOrHide(pos == 1)
scriptInputLayout.showOrHide(pos == 2)
responseValidationMode.onItemSelected(presenter::onValidationModeSelected)
validationModeDescription.setText(
when (pos) {
0 -> R.string.validation_mode_status_desc
1 -> R.string.validation_mode_term_desc
2 -> R.string.validation_mode_javascript_desc
else -> throw IllegalStateException("Unexpected position: $pos")
}
doneBtn.setOnClickListener {
val checkInterval = checkIntervalLayout.getSelectedCheckInterval()
val validationMode =
responseValidationMode.selectedItemPosition.indexToValidationMode()
presenter.commit(
name = inputName.trimmedText(),
url = inputUrl.trimmedText(),
checkInterval = checkInterval,
validationMode = validationMode,
validationContent = validationMode.validationContent()
)
}
currentModel = intent.getSerializableExtra(KEY_VIEW_MODEL) as ServerModel
displayCurrentModel()
disableChecksButton.setOnClickListener { maybeDisableChecks() }
presenter.takeView(this, intent)
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
if (intent != null && intent.hasExtra(KEY_VIEW_MODEL)) {
currentModel = intent.getSerializableExtra(KEY_VIEW_MODEL) as ServerModel
displayCurrentModel()
presenter.onNewIntent(intent)
}
override fun onDestroy() {
presenter.dropView()
super.onDestroy()
}
override fun setLoading() = loadingProgress.setLoading()
override fun setDoneLoading() = loadingProgress.setDone()
override fun showOrHideUrlSchemeWarning(show: Boolean) {
textUrlWarning.showOrHide(show)
if (show) {
textUrlWarning.setText(R.string.warning_http_url)
}
}
@SuppressLint("SetTextI18n")
private fun displayCurrentModel() = with(currentModel) {
override fun showOrHideValidationSearchTerm(show: Boolean) =
responseValidationSearchTerm.showOrHide(show)
override fun showOrHideScriptInput(show: Boolean) = scriptInputLayout.showOrHide(show)
override fun setValidationModeDescription(res: Int) = validationModeDescription.setText(res)
override fun displayModel(model: ServerModel) = with(model) {
iconStatus.setStatus(this.status)
inputName.setText(this.name)
inputUrl.setText(this.url)
@ -217,26 +182,25 @@ class ViewSiteActivity : AppCompatActivity(),
}
}
textNextCheck.text = (this.lastCheck + this.checkInterval).formatDate()
if (this.disabled) {
textNextCheck.setText(R.string.auto_checks_disabled)
} else {
textNextCheck.text = (this.lastCheck + this.checkInterval).formatDate()
}
checkIntervalLayout.set(this.checkInterval)
responseValidationMode.setSelection(validationMode.value - 1)
when (this.validationMode) {
TERM_SEARCH -> responseValidationSearchTerm.setText(this.validationContent ?: "")
JAVASCRIPT -> {
scriptInputLayout.setCode(this.validationContent)
}
JAVASCRIPT -> scriptInputLayout.setCode(this.validationContent)
else -> {
responseValidationSearchTerm.setText("")
scriptInputLayout.clear()
}
}
disableChecksButton.setOnClickListener(this@ViewSiteActivity)
disableChecksButton.showOrHide(!this.disabled)
doneBtn.setOnClickListener(this@ViewSiteActivity)
doneBtn.setText(
if (this.disabled) R.string.renable_and_save_changes
else R.string.save_changes
@ -245,6 +209,43 @@ class ViewSiteActivity : AppCompatActivity(),
invalidateMenuForStatus()
}
override fun setInputErrors(errors: InputErrors) {
inputName.error = if (errors.name != null) {
getString(errors.name!!)
} else {
null
}
inputUrl.error = if (errors.url != null) {
getString(errors.url!!)
} else {
null
}
checkIntervalLayout.setError(
if (errors.checkInterval != null) {
getString(errors.checkInterval!!)
} else {
null
}
)
responseValidationSearchTerm.error = if (errors.termSearch != null) {
getString(errors.termSearch!!)
} else {
null
}
scriptInputLayout.setError(
if (errors.javaScript != null) {
getString(errors.javaScript!!)
} else {
null
}
)
}
override fun scopeWhileAttached(
context: CoroutineContext,
exec: ScopeReceiver
) = rootView.scopeWhileAttached(context, exec)
override fun onResume() {
super.onResume()
val filter = IntentFilter().apply {
@ -258,175 +259,41 @@ class ViewSiteActivity : AppCompatActivity(),
safeUnregisterReceiver(intentReceiver)
}
override fun onClick(view: View) = when (view.id) {
R.id.doneBtn -> performSaveChangesAndFinish()
R.id.disableChecksButton -> maybeDisableChecks()
else -> Unit
}
override fun onMenuItemClick(item: MenuItem): Boolean {
when (item.itemId) {
R.id.refresh -> performCheckNow()
R.id.remove -> maybeRemoveSite()
}
return true
}
private fun performCheckNow() {
rootView.scopeWhileAttached(Main) {
launch(coroutineContext) {
disableChecksButton.disable()
loadingProgress.setLoading()
updateModelFromInput(false)
currentModel = currentModel.copy(status = WAITING)
displayCurrentModel()
async(IO) { serverModelStore.update(currentModel) }.await()
checkStatusManager.scheduleCheck(
site = currentModel,
rightNow = true,
cancelPrevious = true
)
loadingProgress.setDone()
disableChecksButton.enable()
}
}
}
private fun maybeRemoveSite() {
val model = presenter.currentModel()
MaterialDialog(this).show {
title(R.string.remove_site)
message(
text = HtmlCompat.fromHtml(
context.getString(R.string.remove_site_prompt, currentModel.name),
context.getString(R.string.remove_site_prompt, model.name),
FROM_HTML_MODE_LEGACY
)
)
positiveButton(R.string.remove) {
checkStatusManager.cancelCheck(currentModel)
notificationManager.cancelStatusNotification(currentModel)
performRemoveSite()
}
positiveButton(R.string.remove) { presenter.removeSite() }
negativeButton(android.R.string.cancel)
}
}
private fun performRemoveSite() {
rootView.scopeWhileAttached(Main) {
launch(coroutineContext) {
loadingProgress.setLoading()
async(IO) { serverModelStore.delete(currentModel) }.await()
loadingProgress.setDone()
finish()
}
}
}
private fun maybeDisableChecks() {
val model = presenter.currentModel()
MaterialDialog(this).show {
title(R.string.disable_automatic_checks)
message(
text = HtmlCompat.fromHtml(
context.getString(R.string.disable_automatic_checks_prompt, currentModel.name),
context.getString(R.string.disable_automatic_checks_prompt, model.name),
FROM_HTML_MODE_LEGACY
)
)
positiveButton(R.string.disable) {
checkStatusManager.cancelCheck(currentModel)
notificationManager.cancelStatusNotification(currentModel)
performDisableChecks()
}
positiveButton(R.string.disable) { presenter.disableChecks() }
negativeButton(android.R.string.cancel)
}
}
private fun performDisableChecks() {
rootView.scopeWhileAttached(Main) {
launch(coroutineContext) {
loadingProgress.setLoading()
currentModel = currentModel.copy(
disabled = true,
lastCheck = LAST_CHECK_NONE
)
async(IO) { serverModelStore.update(currentModel) }.await()
loadingProgress.setDone()
displayCurrentModel() // invalidate UI to reflect disabled state
}
}
}
private fun performSaveChangesAndFinish() {
rootView.scopeWhileAttached(Main) {
launch(coroutineContext) {
loadingProgress.setLoading()
if (!updateModelFromInput(true)) {
// Validation didn't pass
loadingProgress.setDone()
return@launch
}
async(IO) { serverModelStore.update(currentModel) }.await()
checkStatusManager.scheduleCheck(
site = currentModel,
rightNow = true,
cancelPrevious = true
)
loadingProgress.setDone()
setResult(RESULT_OK)
finish()
}
}
}
private fun invalidateMenuForStatus() {
val model = presenter.currentModel()
val item = toolbar.menu.findItem(R.id.refresh)
item.isEnabled = currentModel.status != CHECKING && currentModel.status != WAITING
}
@CheckResult private fun updateModelFromInput(withValidation: Boolean): Boolean {
currentModel = currentModel.copy(
name = inputName.trimmedText(),
url = inputUrl.trimmedText(),
status = WAITING,
disabled = false
)
if (withValidation && currentModel.name.isEmpty()) {
inputName.error = getString(R.string.please_enter_name)
return false
} else {
inputName.error = null
}
if (withValidation && currentModel.url.isEmpty()) {
inputUrl.error = getString(R.string.please_enter_url)
return false
} else {
inputUrl.error = null
if (withValidation && !WEB_URL.matcher(currentModel.url).find()) {
inputUrl.error = getString(R.string.please_enter_valid_url)
return false
} else {
val uri = Uri.parse(currentModel.url)
if (uri.scheme == null) {
currentModel = currentModel.copy(url = "http://${currentModel.url}")
}
}
}
val selectedCheckInterval = checkIntervalLayout.getSelectedCheckInterval()
val selectedValidationMode =
responseValidationMode.selectedItemPosition.indexToValidationMode()
currentModel = currentModel.copy(
checkInterval = selectedCheckInterval,
validationMode = selectedValidationMode,
validationContent = selectedValidationMode.validationContent()
)
return true
item.isEnabled = model.status != CHECKING &&
model.status != WAITING
}
private fun ValidationMode.validationContent() = when (this) {

View file

@ -0,0 +1,268 @@
/*
* Licensed under Apache-2.0
*
* Designed and developed by Aidan Follestad (@afollestad)
*/
package com.afollestad.nocknock.ui.viewsite
import android.content.Intent
import androidx.annotation.CheckResult
import com.afollestad.nocknock.R
import com.afollestad.nocknock.data.ServerModel
import com.afollestad.nocknock.data.ServerStatus.WAITING
import com.afollestad.nocknock.data.ValidationMode
import com.afollestad.nocknock.data.ValidationMode.JAVASCRIPT
import com.afollestad.nocknock.data.ValidationMode.TERM_SEARCH
import com.afollestad.nocknock.engine.db.ServerModelStore
import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.ACTION_STATUS_UPDATE
import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.KEY_UPDATE_MODEL
import com.afollestad.nocknock.engine.statuscheck.CheckStatusManager
import com.afollestad.nocknock.notifications.NockNotificationManager
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import okhttp3.HttpUrl
import org.jetbrains.annotations.TestOnly
import javax.inject.Inject
const val KEY_VIEW_MODEL = "site_model"
/** @author Aidan Follestad (@afollestad) */
data class InputErrors(
var name: Int? = null,
var url: Int? = null,
var checkInterval: Int? = null,
var termSearch: Int? = null,
var javaScript: Int? = null
) {
@CheckResult fun any(): Boolean {
return name != null || url != null || checkInterval != null ||
termSearch != null || javaScript != null
}
}
/** @author Aidan Follestad (@afollestad) */
interface ViewSitePresenter {
fun takeView(
view: ViewSiteView,
intent: Intent
)
fun onBroadcast(intent: Intent)
fun onNewIntent(intent: Intent?)
fun onUrlInputFocusChange(
focused: Boolean,
content: String
)
fun onValidationModeSelected(index: Int)
fun commit(
name: String,
url: String,
checkInterval: Long,
validationMode: ValidationMode,
validationContent: String?
)
fun checkNow()
fun disableChecks()
fun removeSite()
fun currentModel(): ServerModel
fun dropView()
}
/** @author Aidan Follestad (@afollestad) */
class RealViewSitePresenter @Inject constructor(
private val serverModelStore: ServerModelStore,
private val checkStatusManager: CheckStatusManager,
private val notificationManager: NockNotificationManager
) : ViewSitePresenter {
private var view: ViewSiteView? = null
private var currentModel: ServerModel? = null
override fun takeView(
view: ViewSiteView,
intent: Intent
) {
this.currentModel = intent.getSerializableExtra(KEY_VIEW_MODEL) as ServerModel
this.view = view.apply {
displayModel(currentModel!!)
}
}
override fun onBroadcast(intent: Intent) {
if (intent.action == ACTION_STATUS_UPDATE) {
val model = intent.getSerializableExtra(KEY_UPDATE_MODEL) as? ServerModel ?: return
this.currentModel = model
view?.run {
displayModel(model)
setDoneLoading() // in case this is the result of a manual refresh
}
}
}
override fun onNewIntent(intent: Intent?) {
if (intent != null && intent.hasExtra(KEY_VIEW_MODEL)) {
currentModel = intent.getSerializableExtra(KEY_VIEW_MODEL) as ServerModel
view?.displayModel(currentModel!!)
}
}
override fun onUrlInputFocusChange(
focused: Boolean,
content: String
) {
if (content.isEmpty() || focused) {
return
}
val url = HttpUrl.parse(content)
if (url == null ||
(url.scheme() != "http" &&
url.scheme() != "https")
) {
view?.showOrHideUrlSchemeWarning(true)
} else {
view?.showOrHideUrlSchemeWarning(false)
}
}
override fun onValidationModeSelected(index: Int) = with(view!!) {
showOrHideValidationSearchTerm(index == 1)
showOrHideScriptInput(index == 2)
setValidationModeDescription(
when (index) {
0 -> R.string.validation_mode_status_desc
1 -> R.string.validation_mode_term_desc
2 -> R.string.validation_mode_javascript_desc
else -> throw IllegalStateException("Unknown validation mode position: $index")
}
)
}
override fun commit(
name: String,
url: String,
checkInterval: Long,
validationMode: ValidationMode,
validationContent: String?
) {
val inputErrors = InputErrors()
if (name.isEmpty()) {
inputErrors.name = R.string.please_enter_name
}
if (url.isEmpty()) {
inputErrors.url = R.string.please_enter_url
} else if (HttpUrl.parse(url) == null) {
inputErrors.url = R.string.please_enter_valid_url
}
if (checkInterval <= 0) {
inputErrors.checkInterval = R.string.please_enter_check_interval
}
if (validationMode == TERM_SEARCH && validationContent.isNullOrEmpty()) {
inputErrors.termSearch = R.string.please_enter_search_term
} else if (validationMode == JAVASCRIPT && validationContent.isNullOrEmpty()) {
inputErrors.javaScript = R.string.please_enter_javaScript
}
if (inputErrors.any()) {
view?.setInputErrors(inputErrors)
return
}
val newModel = currentModel!!.copy(
name = name,
url = url,
status = WAITING,
checkInterval = checkInterval,
validationMode = validationMode,
validationContent = validationContent,
disabled = false
)
with(view!!) {
scopeWhileAttached(Main) {
launch(coroutineContext) {
setLoading()
async(IO) { serverModelStore.update(newModel) }.await()
checkStatusManager.scheduleCheck(
site = newModel,
rightNow = true,
cancelPrevious = true
)
setDoneLoading()
view?.finish()
}
}
}
}
override fun checkNow() = with(view!!) {
setLoading()
val checkModel = currentModel!!.copy(
status = WAITING
)
view?.displayModel(checkModel)
checkStatusManager.scheduleCheck(
site = checkModel,
rightNow = true,
cancelPrevious = true
)
}
override fun disableChecks() {
val site = currentModel!!
checkStatusManager.cancelCheck(site)
notificationManager.cancelStatusNotification(site)
with(view!!) {
scopeWhileAttached(Main) {
launch(coroutineContext) {
setLoading()
currentModel = currentModel!!.copy(disabled = true)
async(IO) { serverModelStore.update(currentModel!!) }.await()
setDoneLoading()
view?.displayModel(currentModel!!)
}
}
}
}
override fun removeSite() {
val site = currentModel!!
checkStatusManager.cancelCheck(site)
notificationManager.cancelStatusNotification(site)
with(view!!) {
scopeWhileAttached(Main) {
launch(coroutineContext) {
setLoading()
async(IO) { serverModelStore.delete(site) }.await()
setDoneLoading()
view?.finish()
}
}
}
}
override fun currentModel() = this.currentModel!!
override fun dropView() {
view = null
currentModel = null
}
@TestOnly fun setModel(model: ServerModel) {
this.currentModel = model
}
}

View file

@ -0,0 +1,38 @@
/*
* Licensed under Apache-2.0
*
* Designed and developed by Aidan Follestad (@afollestad)
*/
package com.afollestad.nocknock.ui.viewsite
import androidx.annotation.StringRes
import com.afollestad.nocknock.data.ServerModel
import com.afollestad.nocknock.utilities.ext.ScopeReceiver
import kotlin.coroutines.CoroutineContext
/** @author Aidan Follestad (@afollestad) */
interface ViewSiteView {
fun setLoading()
fun setDoneLoading()
fun displayModel(model: ServerModel)
fun showOrHideUrlSchemeWarning(show: Boolean)
fun showOrHideValidationSearchTerm(show: Boolean)
fun showOrHideScriptInput(show: Boolean)
fun setValidationModeDescription(@StringRes res: Int)
fun setInputErrors(errors: InputErrors)
fun scopeWhileAttached(
context: CoroutineContext,
exec: ScopeReceiver
)
fun finish()
}

View file

@ -0,0 +1,362 @@
/*
* Licensed under Apache-2.0
*
* Designed and developed by Aidan Follestad (@afollestad)
*/
package com.afollestad.nocknock
import android.content.Intent
import com.afollestad.nocknock.data.LAST_CHECK_NONE
import com.afollestad.nocknock.data.ServerModel
import com.afollestad.nocknock.data.ServerStatus.WAITING
import com.afollestad.nocknock.data.ValidationMode.JAVASCRIPT
import com.afollestad.nocknock.data.ValidationMode.STATUS_CODE
import com.afollestad.nocknock.data.ValidationMode.TERM_SEARCH
import com.afollestad.nocknock.engine.db.ServerModelStore
import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.ACTION_STATUS_UPDATE
import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.KEY_UPDATE_MODEL
import com.afollestad.nocknock.engine.statuscheck.CheckStatusManager
import com.afollestad.nocknock.notifications.NockNotificationManager
import com.afollestad.nocknock.ui.viewsite.InputErrors
import com.afollestad.nocknock.ui.viewsite.KEY_VIEW_MODEL
import com.afollestad.nocknock.ui.viewsite.RealViewSitePresenter
import com.afollestad.nocknock.ui.viewsite.ViewSiteView
import com.afollestad.nocknock.utilities.ext.ScopeReceiver
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.doReturn
import com.nhaarman.mockitokotlin2.mock
import com.nhaarman.mockitokotlin2.never
import com.nhaarman.mockitokotlin2.times
import com.nhaarman.mockitokotlin2.verify
import com.nhaarman.mockitokotlin2.verifyNoMoreInteractions
import com.nhaarman.mockitokotlin2.whenever
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Before
import org.junit.Test
class ViewSitePresenterTest {
private val serverModelStore = mock<ServerModelStore> {
on { runBlocking { put(any()) } } doAnswer { inv ->
inv.getArgument<ServerModel>(0)
}
}
private val checkStatusManager = mock<CheckStatusManager>()
private val notificationManager = mock<NockNotificationManager>()
private val view = mock<ViewSiteView>()
private val presenter = RealViewSitePresenter(
serverModelStore,
checkStatusManager,
notificationManager
)
@Before fun setup() {
doAnswer {
val exec = it.getArgument<ScopeReceiver>(1)
runBlocking { exec() }
Unit
}.whenever(view)
.scopeWhileAttached(any(), any())
val model = fakeModel().copy(lastCheck = 0)
val intent = fakeIntent("")
whenever(intent.getSerializableExtra(KEY_VIEW_MODEL))
.doReturn(model)
presenter.takeView(view, intent)
assertThat(presenter.currentModel()).isEqualTo(model)
verify(view, times(1)).displayModel(model)
}
@After fun destroy() {
presenter.dropView()
}
@Test fun onBroadcast() {
val badIntent = fakeIntent("Hello World")
presenter.onBroadcast(badIntent)
val model = fakeModel()
val goodIntent = fakeIntent(ACTION_STATUS_UPDATE)
whenever(goodIntent.getSerializableExtra(KEY_UPDATE_MODEL))
.doReturn(model)
presenter.onBroadcast(goodIntent)
assertThat(presenter.currentModel()).isEqualTo(model)
verify(view, times(1)).displayModel(model)
verify(view).setDoneLoading()
}
@Test fun onNewIntent() {
val badIntent = fakeIntent(ACTION_STATUS_UPDATE)
presenter.onBroadcast(badIntent)
val model = fakeModel()
val goodIntent = fakeIntent(ACTION_STATUS_UPDATE)
whenever(goodIntent.getSerializableExtra(KEY_VIEW_MODEL))
.doReturn(model)
presenter.onBroadcast(goodIntent)
verify(view, times(1)).displayModel(model)
}
@Test fun onUrlInputFocusChange_focused() {
presenter.onUrlInputFocusChange(true, "hello")
verifyNoMoreInteractions(view)
}
@Test fun onUrlInputFocusChange_empty() {
presenter.onUrlInputFocusChange(false, "")
verifyNoMoreInteractions(view)
}
@Test fun onUrlInputFocusChange_notHttpHttps() {
presenter.onUrlInputFocusChange(false, "ftp://hello.com")
verify(view).showOrHideUrlSchemeWarning(true)
}
@Test fun onUrlInputFocusChange_isHttpOrHttps() {
presenter.onUrlInputFocusChange(false, "http://hello.com")
presenter.onUrlInputFocusChange(false, "https://hello.com")
verify(view, times(2)).showOrHideUrlSchemeWarning(false)
}
@Test fun onValidationModeSelected_statusCode() {
presenter.onValidationModeSelected(0)
verify(view).showOrHideValidationSearchTerm(false)
verify(view).showOrHideScriptInput(false)
verify(view).setValidationModeDescription(R.string.validation_mode_status_desc)
}
@Test fun onValidationModeSelected_termSearch() {
presenter.onValidationModeSelected(1)
verify(view).showOrHideValidationSearchTerm(true)
verify(view).showOrHideScriptInput(false)
verify(view).setValidationModeDescription(R.string.validation_mode_term_desc)
}
@Test fun onValidationModeSelected_javaScript() {
presenter.onValidationModeSelected(2)
verify(view).showOrHideValidationSearchTerm(false)
verify(view).showOrHideScriptInput(true)
verify(view).setValidationModeDescription(R.string.validation_mode_javascript_desc)
}
@Test(expected = IllegalStateException::class)
fun onValidationModeSelected_other() {
presenter.onValidationModeSelected(3)
}
@Test fun commit_nameError() {
presenter.commit(
"",
"https://test.com",
1,
STATUS_CODE,
null
)
val inputErrorsCaptor = argumentCaptor<InputErrors>()
verify(view).setInputErrors(inputErrorsCaptor.capture())
verify(checkStatusManager, never())
.scheduleCheck(any(), any(), any(), any())
val errors = inputErrorsCaptor.firstValue
assertThat(errors.name).isEqualTo(R.string.please_enter_name)
}
@Test fun commit_urlEmptyError() {
presenter.commit(
"Testing",
"",
1,
STATUS_CODE,
null
)
val inputErrorsCaptor = argumentCaptor<InputErrors>()
verify(view).setInputErrors(inputErrorsCaptor.capture())
verify(checkStatusManager, never())
.scheduleCheck(any(), any(), any(), any())
val errors = inputErrorsCaptor.firstValue
assertThat(errors.url).isEqualTo(R.string.please_enter_url)
}
@Test fun commit_urlFormatError() {
presenter.commit(
"Testing",
"ftp://hello.com",
1,
STATUS_CODE,
null
)
val inputErrorsCaptor = argumentCaptor<InputErrors>()
verify(view).setInputErrors(inputErrorsCaptor.capture())
verify(checkStatusManager, never())
.scheduleCheck(any(), any(), any(), any())
val errors = inputErrorsCaptor.firstValue
assertThat(errors.url).isEqualTo(R.string.please_enter_valid_url)
}
@Test fun commit_checkIntervalError() {
presenter.commit(
"Testing",
"https://hello.com",
-1,
STATUS_CODE,
null
)
val inputErrorsCaptor = argumentCaptor<InputErrors>()
verify(view).setInputErrors(inputErrorsCaptor.capture())
verify(checkStatusManager, never())
.scheduleCheck(any(), any(), any(), any())
val errors = inputErrorsCaptor.firstValue
assertThat(errors.checkInterval).isEqualTo(R.string.please_enter_check_interval)
}
@Test fun commit_termSearchError() {
presenter.commit(
"Testing",
"https://hello.com",
1,
TERM_SEARCH,
null
)
val inputErrorsCaptor = argumentCaptor<InputErrors>()
verify(view).setInputErrors(inputErrorsCaptor.capture())
verify(checkStatusManager, never())
.scheduleCheck(any(), any(), any(), any())
val errors = inputErrorsCaptor.firstValue
assertThat(errors.termSearch).isEqualTo(R.string.please_enter_search_term)
}
@Test fun commit_javaScript_error() {
presenter.commit(
"Testing",
"https://hello.com",
1,
JAVASCRIPT,
null
)
val inputErrorsCaptor = argumentCaptor<InputErrors>()
verify(view).setInputErrors(inputErrorsCaptor.capture())
verify(checkStatusManager, never())
.scheduleCheck(any(), any(), any(), any())
val errors = inputErrorsCaptor.firstValue
assertThat(errors.javaScript).isEqualTo(R.string.please_enter_javaScript)
}
@Test fun commit_success() = runBlocking {
val name = "Testing"
val url = "https://hello.com"
val checkInterval = 60000L
val validationMode = TERM_SEARCH
val validationContent = "Hello World"
val disabledModel = presenter.currentModel()
.copy(disabled = true)
presenter.setModel(disabledModel)
presenter.commit(
name,
url,
checkInterval,
validationMode,
validationContent
)
val modelCaptor = argumentCaptor<ServerModel>()
verify(view).setLoading()
verify(serverModelStore).update(modelCaptor.capture())
val model = modelCaptor.firstValue
assertThat(model.name).isEqualTo(name)
assertThat(model.url).isEqualTo(url)
assertThat(model.checkInterval).isEqualTo(checkInterval)
assertThat(model.validationMode).isEqualTo(validationMode)
assertThat(model.validationContent).isEqualTo(validationContent)
assertThat(model.disabled).isFalse()
verify(view, never()).setInputErrors(any())
verify(checkStatusManager).scheduleCheck(
site = model,
rightNow = true,
cancelPrevious = true,
fromFinishingJob = false
)
verify(view).setDoneLoading()
verify(view).finish()
}
@Test fun checkNow() {
val newModel = presenter.currentModel()
.copy(
status = WAITING
)
presenter.checkNow()
verify(view).setLoading()
verify(view).displayModel(newModel)
verify(checkStatusManager).scheduleCheck(
site = newModel,
rightNow = true,
cancelPrevious = true
)
}
@Test fun disableChecks() = runBlocking {
val model = presenter.currentModel()
presenter.disableChecks()
verify(checkStatusManager).cancelCheck(model)
verify(notificationManager).cancelStatusNotification(model)
verify(view).setLoading()
val modelCaptor = argumentCaptor<ServerModel>()
verify(serverModelStore).update(modelCaptor.capture())
val newModel = modelCaptor.firstValue
assertThat(newModel.disabled).isTrue()
assertThat(newModel.lastCheck).isEqualTo(LAST_CHECK_NONE)
verify(view).setDoneLoading()
verify(view, times(1)).displayModel(newModel)
}
@Test fun removeSite() = runBlocking {
val model = presenter.currentModel()
presenter.removeSite()
verify(checkStatusManager).cancelCheck(model)
verify(notificationManager).cancelStatusNotification(model)
verify(view).setLoading()
verify(serverModelStore).delete(model)
verify(view).setDoneLoading()
verify(view).finish()
}
private fun fakeModel() = ServerModel(
id = 1,
name = "Test",
url = "https://test.com",
validationMode = STATUS_CODE
)
private fun fakeIntent(action: String): Intent {
return mock {
on { getAction() } doReturn action
}
}
}

View file

@ -1,3 +1,4 @@
<resources>
<string name="checks_disabled">Checks Disabled</string>
<string name="auto_checks_disabled">Automatic Checks Disabled</string>
</resources>

View file

@ -18,7 +18,7 @@ class LoadingIndicatorFrame(
attrs: AttributeSet? = null
) : FrameLayout(context, attrs) {
companion object {
private const val SHOW_DELAY_MS = 200L
private const val SHOW_DELAY_MS = 100L
}
private val showRunnable = Runnable { show() }