Move majority of MainActivity business logic to MainPresenter, write unit test

This commit is contained in:
Aidan Follestad 2018-12-01 00:05:14 -08:00
parent b36b41ca9d
commit f87e1438d2
8 changed files with 315 additions and 101 deletions

View file

@ -34,6 +34,11 @@ dependencies {
kapt 'com.google.dagger:dagger-compiler:' + versions.dagger
implementation 'com.afollestad.material-dialogs:core:' + versions.materialDialogs
testImplementation 'junit:junit:' + versions.junit
testImplementation 'org.mockito:mockito-core:' + versions.mockito
testImplementation 'com.nhaarman.mockitokotlin2:mockito-kotlin:' + versions.mockitoKotlin
testImplementation 'com.google.truth:truth:' + versions.truth
}
apply from: '../spotless.gradle'

View file

@ -27,6 +27,7 @@ import javax.inject.Singleton
@Component(
modules = [
MainModule::class,
MainBindModule::class,
EngineModule::class,
NotificationsModule::class,
UtilitiesModule::class

View file

@ -0,0 +1,23 @@
/*
* Licensed under Apache-2.0
*
* Designed and developed by Aidan Follestad (@afollestad)
*/
package com.afollestad.nocknock.di
import com.afollestad.nocknock.presenters.MainPresenter
import com.afollestad.nocknock.presenters.RealMainPresenter
import dagger.Binds
import dagger.Module
import javax.inject.Singleton
/** @author Aidan Follestad (afollestad) */
@Module
abstract class MainBindModule {
@Binds
@Singleton
abstract fun provideMainPresenter(
presenter: RealMainPresenter
): MainPresenter
}

View file

@ -0,0 +1,104 @@
/*
* Licensed under Apache-2.0
*
* Designed and developed by Aidan Follestad (@afollestad)
*/
package com.afollestad.nocknock.presenters
import android.content.Intent
import com.afollestad.nocknock.data.ServerModel
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 javax.inject.Inject
/** @author Aidan Follestad (afollestad) */
interface MainPresenter {
fun takeView(view: MainView)
fun onBroadcast(intent: Intent)
fun resume()
fun refreshSite(site: ServerModel)
fun removeSite(site: ServerModel)
fun dropView()
}
/** @author Aidan Follestad (afollestad) */
class RealMainPresenter @Inject constructor(
private val serverModelStore: ServerModelStore,
private val notificationManager: NockNotificationManager,
private val checkStatusManager: CheckStatusManager
) : MainPresenter {
private var view: MainView? = null
override fun takeView(view: MainView) {
this.view = view
notificationManager.createChannels()
ensureCheckJobs()
}
override fun onBroadcast(intent: Intent) {
if (intent.action == ACTION_STATUS_UPDATE) {
val model = intent.getSerializableExtra(KEY_UPDATE_MODEL) as? ServerModel ?: return
view?.updateModel(model)
}
}
override fun resume() {
notificationManager.cancelStatusNotifications()
view?.run {
setModels(listOf())
scopeWhileAttached(Main) {
launch(coroutineContext) {
val models = async(IO) {
serverModelStore.get()
}
setModels(models.await())
}
}
}
}
override fun refreshSite(site: ServerModel) {
checkStatusManager.scheduleCheck(
site = site,
rightNow = true,
cancelPrevious = true
)
}
override fun removeSite(site: ServerModel) {
checkStatusManager.cancelCheck(site)
notificationManager.cancelStatusNotification(site)
view?.scopeWhileAttached(Main) {
launch(coroutineContext) {
async(IO) { serverModelStore.delete(site) }.await()
view?.onSiteDeleted(site)
}
}
}
override fun dropView() {
view = null
}
private fun ensureCheckJobs() {
view?.scopeWhileAttached(IO) {
launch(coroutineContext) {
checkStatusManager.ensureScheduledChecks()
}
}
}
}

View file

@ -0,0 +1,25 @@
/*
* Licensed under Apache-2.0
*
* Designed and developed by Aidan Follestad (@afollestad)
*/
package com.afollestad.nocknock.presenters
import com.afollestad.nocknock.data.ServerModel
import com.afollestad.nocknock.utilities.ext.ScopeReceiver
import kotlin.coroutines.CoroutineContext
/** @author Aidan Follestad (afollestad) */
interface MainView {
fun setModels(models: List<ServerModel>)
fun updateModel(model: ServerModel)
fun onSiteDeleted(model: ServerModel)
fun scopeWhileAttached(
context: CoroutineContext,
exec: ScopeReceiver
)
}

View file

@ -12,76 +12,51 @@ import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Bundle
import android.util.Log
import androidx.appcompat.app.AppCompatActivity
import androidx.core.text.HtmlCompat
import androidx.core.text.HtmlCompat.FROM_HTML_MODE_LEGACY
import androidx.core.text.HtmlCompat.fromHtml
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.DividerItemDecoration.VERTICAL
import androidx.recyclerview.widget.LinearLayoutManager
import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.materialdialogs.list.listItems
import com.afollestad.nocknock.BuildConfig
import com.afollestad.nocknock.R
import com.afollestad.nocknock.adapter.ServerAdapter
import com.afollestad.nocknock.data.ServerModel
import com.afollestad.nocknock.dialogs.AboutDialog
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.presenters.MainPresenter
import com.afollestad.nocknock.presenters.MainView
import com.afollestad.nocknock.utilities.ext.ScopeReceiver
import com.afollestad.nocknock.utilities.ext.injector
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.show
import com.afollestad.nocknock.viewcomponents.ext.showOrHide
import kotlinx.android.synthetic.main.activity_main.fab
import kotlinx.android.synthetic.main.activity_main.list
import kotlinx.android.synthetic.main.activity_main.rootView
import kotlinx.android.synthetic.main.activity_main.toolbar
import kotlinx.android.synthetic.main.include_empty_view.emptyText
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.coroutines.CoroutineContext
/** @author Aidan Follestad (afollestad) */
class MainActivity : AppCompatActivity() {
class MainActivity : AppCompatActivity(), MainView {
companion object {
private const val ADD_SITE_RQ = 6969
private const val VIEW_SITE_RQ = 6923
private fun log(message: String) {
if (BuildConfig.DEBUG) {
Log.d("MainActivity", message)
}
}
}
private val intentReceiver = object : BroadcastReceiver() {
override fun onReceive(
context: Context,
intent: Intent
) {
log("Received broadcast ${intent.action}")
when (intent.action) {
ACTION_STATUS_UPDATE -> {
val model = intent.getSerializableExtra(KEY_UPDATE_MODEL) as? ServerModel ?: return
log("Received model update: $model")
list.post { adapter.update(model) }
}
else -> throw IllegalStateException("Unexpected intent: ${intent.action}")
}
}
) = presenter.onBroadcast(intent)
}
@Inject lateinit var serverModelStore: ServerModelStore
@Inject lateinit var notificationManager: NockNotificationManager
@Inject lateinit var checkStatusManager: CheckStatusManager
@Inject lateinit var presenter: MainPresenter
private lateinit var adapter: ServerAdapter
@ -112,16 +87,7 @@ class MainActivity : AppCompatActivity() {
)
}
notificationManager.createChannels()
ensureCheckJobs()
}
private fun ensureCheckJobs() {
rootView.scopeWhileAttached(IO) {
launch(coroutineContext) {
checkStatusManager.ensureScheduledChecks()
}
}
presenter.takeView(this)
}
override fun onResume() {
@ -130,9 +96,7 @@ class MainActivity : AppCompatActivity() {
addAction(ACTION_STATUS_UPDATE)
}
safeRegisterReceiver(intentReceiver, filter)
notificationManager.cancelStatusNotifications()
refreshModels()
presenter.resume()
}
override fun onPause() {
@ -140,29 +104,28 @@ class MainActivity : AppCompatActivity() {
safeUnregisterReceiver(intentReceiver)
}
private fun refreshModels() {
adapter.clear()
emptyText.show()
rootView.scopeWhileAttached(Main) {
launch(coroutineContext) {
val models = async(IO) { serverModelStore.get() }.await()
adapter.set(models)
emptyText.showOrHide(adapter.itemCount == 0)
}
}
override fun onDestroy() {
presenter.dropView()
super.onDestroy()
}
override fun onActivityResult(
requestCode: Int,
resultCode: Int,
data: Intent?
) {
super.onActivityResult(requestCode, resultCode, data)
if (resultCode == RESULT_OK) {
refreshModels()
}
override fun setModels(models: List<ServerModel>) {
adapter.set(models)
emptyText.showOrHide(models.isEmpty())
}
override fun updateModel(model: ServerModel) = adapter.update(model)
override fun onSiteDeleted(model: ServerModel) {
adapter.remove(model)
emptyText.showOrHide(adapter.itemCount == 0)
}
override fun scopeWhileAttached(
context: CoroutineContext,
exec: ScopeReceiver
) = rootView.scopeWhileAttached(context, exec)
private fun onSiteSelected(
model: ServerModel,
longClick: Boolean
@ -172,20 +135,8 @@ class MainActivity : AppCompatActivity() {
title(R.string.options)
listItems(R.array.site_long_options) { _, i, _ ->
when (i) {
0 -> {
checkStatusManager.scheduleCheck(
site = model,
rightNow = true,
cancelPrevious = true
)
}
1 -> {
maybeRemoveSite(model) {
adapter.remove(model)
emptyText.showOrHide(adapter.itemCount == 0)
}
}
else -> throw IllegalStateException("Unexpected index: $i")
0 -> presenter.refreshSite(model)
1 -> maybeRemoveSite(model)
}
}
}
@ -199,35 +150,19 @@ class MainActivity : AppCompatActivity() {
)
}
private fun maybeRemoveSite(
model: ServerModel,
onRemoved: (() -> Unit)?
) {
private fun maybeRemoveSite(model: ServerModel) {
MaterialDialog(this).show {
title(R.string.remove_site)
message(
text = HtmlCompat.fromHtml(
context.getString(R.string.remove_site_prompt, model.name), FROM_HTML_MODE_LEGACY
text = fromHtml(
context.getString(R.string.remove_site_prompt, model.name),
FROM_HTML_MODE_LEGACY
)
)
positiveButton(R.string.remove) {
checkStatusManager.cancelCheck(model)
notificationManager.cancelStatusNotification(model)
performRemoveSite(model, onRemoved)
presenter.removeSite(model)
}
negativeButton(android.R.string.cancel)
}
}
private fun performRemoveSite(
model: ServerModel,
onRemoved: (() -> Unit)?
) {
rootView.scopeWhileAttached(Main) {
launch(coroutineContext) {
async(IO) { serverModelStore.delete(model) }.await()
onRemoved?.invoke()
}
}
}
}

View file

@ -0,0 +1,119 @@
/*
* 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.ServerModel
import com.afollestad.nocknock.data.ValidationMode.STATUS_CODE
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.presenters.MainView
import com.afollestad.nocknock.presenters.RealMainPresenter
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.times
import com.nhaarman.mockitokotlin2.verify
import com.nhaarman.mockitokotlin2.whenever
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Before
import org.junit.Test
class MainPresenterTest {
private val serverModelStore = mock<ServerModelStore>()
private val notificationManager = mock<NockNotificationManager>()
private val checkStatusManager = mock<CheckStatusManager>()
private val view = mock<MainView>()
private val presenter = RealMainPresenter(
serverModelStore,
notificationManager,
checkStatusManager
)
@Before fun setup() {
doAnswer {
val exec = it.getArgument<ScopeReceiver>(1)
runBlocking { exec() }
Unit
}.whenever(view)
.scopeWhileAttached(any(), any())
presenter.takeView(view)
}
@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)
verify(view, times(1)).updateModel(model)
}
@Test fun resume() = runBlocking {
val model = fakeModel()
whenever(serverModelStore.get()).doReturn(listOf(model))
presenter.resume()
verify(notificationManager).cancelStatusNotifications()
val modelsCaptor = argumentCaptor<List<ServerModel>>()
verify(view, times(2)).setModels(modelsCaptor.capture())
assertThat(modelsCaptor.firstValue).isEmpty()
assertThat(modelsCaptor.lastValue.single()).isEqualTo(model)
}
@Test fun refreshSite() {
val model = fakeModel()
presenter.refreshSite(model)
verify(checkStatusManager).scheduleCheck(
site = model,
rightNow = true,
cancelPrevious = true
)
}
@Test fun removeSite() = runBlocking {
val model = fakeModel()
presenter.removeSite(model)
verify(checkStatusManager).cancelCheck(model)
verify(notificationManager).cancelStatusNotification(model)
verify(serverModelStore).delete(model)
verify(view).onSiteDeleted(model)
}
private fun fakeModel() = ServerModel(
name = "Test",
url = "https://test.com",
validationMode = STATUS_CODE
)
private fun fakeIntent(action: String): Intent {
return mock {
on { getAction() } doReturn action
}
}
}

View file

@ -10,9 +10,11 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlin.coroutines.CoroutineContext
typealias ScopeReceiver = CoroutineScope.() -> Unit
fun View.scopeWhileAttached(
context: CoroutineContext,
exec: CoroutineScope.() -> Unit
exec: ScopeReceiver
) {
val job = Job(context[Job])