Senior Software Engineer @ Acorns
data class Item(
val id: Int,
val name: String,
val price: Float,
val daysOld: Int,
val foodType: FoodType
)
enum class FoodType {
CHEESEBURGER,
FRENCH_FRIES,
FRENCH_TOAST,
PIZZA,
STEAK
}
interface ItemRepo {
fun loadItems(count: Int): Single<List<Item>>
}
class ListViewModel(
private val itemRepository: ItemRepo
) : ViewModel() {
private val mutableViewState: MutableLiveData<ViewState> =
MutableLiveData(ViewState.Empty)
val viewState: LiveData<ViewState> = mutableViewState
sealed class ViewState {
object Empty: ViewState()
object Error: ViewState()
object Loading: ViewState()
data class Success(
val items: List<ItemModel>
): ViewState()
}
}
fun loadItems(count: String) {
val num = count.toIntOrNull()
if (num == null) {
mutableViewState.postValue(ViewState.Error)
return
}
itemRepository.loadItems(num)
.subscribeOn(Schedulers.io())
.doOnSubscribe { mutableViewState.postValue(ViewState.Loading)
.subscribe(
{ items ->
if (items.isEmpty()) {
mutableViewState.postValue(ViewState.Empty)
return@subscribe
}
mutableViewState.postValue(ViewState.Success(
items.map { it.toItemModel() }
))
},
{
mutableViewState.postValue(ViewState.Error)
}
)
}
private fun Item.toItemModel() = ItemModel(
id = id,
name = name,
isNew = daysOld < 10,
price = "$%.2f".format(price),
isFastFood = when (foodType) {
FoodType.CHEESEBURGER -> true
FoodType.FRENCH_FRIES -> true
FoodType.FRENCH_TOAST -> false
FoodType.PIZZA -> true
FoodType.STEAK -> false
}
)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.apply {
viewModel.viewState.observe(viewLifecycleOwner, { viewState ->
when (viewState) {
is ListViewModel.ViewState.Loading -> {
loadingBar.visibility = View.VISIBLE
}
is ListViewModel.ViewState.Empty -> {
loadingBar.visibility = View.GONE
message.text = getString(R.string.empty_state_message)
}
is ListViewModel.ViewState.Error -> {
loadingBar.visibility = View.GONE
message.text = getString(R.string.error_state_message)
}
is ListViewModel.ViewState.Success -> {
itemList.visibility = View.VISIBLE
emptyContainer.visibility = View.GONE
loadingBar.visibility = View.GONE
adapter.submitList(viewState.items)
}
}
})
}
}
inner class ItemViewHolder(
private val binding: ViewItemBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(itemModel: ItemModel) {
binding.apply {
name.text = itemModel.name
price.text = itemModel.price
newIcon.visibility = if (itemModel.isNew) {
View.VISIBLE
} else {
View.GONE
}
foodTypeIcon.setImageResource(
if (itemModel.isFastFood) {
R.drawable.ic_outline_fastfood_24
} else {
R.drawable.ic_outline_local_dining_24
}
)
}
}
}
private val itemRepository = object: ItemRepo {
override fun loadItems(count: Int): Single<List<Item>> =
expectedException?.let {
Single.error(expectedException)
} ?: Single.just(expectedResponse)
}
private val viewModelUnderTest = ListViewModel(itemRepository)
private val observedViewStates: MutableList<ListViewModel.ViewState> = mutableListOf()
private val expectedResponse: MutableList<Item> = mutableListOf()
private var expectedException: Throwable? = null
// JvmField needed due to difference in how Java/Kotlin handle public
@Rule @JvmField val instantExecutor = InstantTaskExecutorRule()
@Before
fun setup() {
RxJavaPlugins.setIoSchedulerHandler { Schedulers.trampoline() }
viewModelUnderTest.viewState.observeForever {
observedViewStates.add(it)
}
observedViewStates.clear()
expectedResponse.clear()
expectedException = null
}
@Test
fun `Test Error Response`() {
expectedException = RuntimeException()
viewModelUnderTest.loadItems("0")
assertEquals(
listOf(ListViewModel.ViewState.Loading, ListViewModel.ViewState.Error),
observedViewStates
)
}
@Test
fun `Test Error Due to Invalid Count`() {
viewModelUnderTest.loadItems("a")
assertEquals(
listOf(ListViewModel.ViewState.Error),
observedViewStates
)
}
@Test
fun `Test Empty List`() {
viewModelUnderTest.loadItems("0")
assertEquals(
listOf(ListViewModel.ViewState.Loading, ListViewModel.ViewState.Empty),
observedViewStates
)
}
@Test
fun `Test Success`() {
expectedResponse.addAll(ItemObjectMother.successfulItemList)
viewModelUnderTest.loadItems("5")
assertEquals(
listOf(
ListViewModel.ViewState.Loading,
ListViewModel.ViewState.Success(ItemObjectMother.successfulItemModelList)
),
observedViewStates
)
}
object ItemObjectMother {
val successfulItemList = listOf(..)
val successfulItemModelList = listOf(..)
}
val successfulItemList = listOf(
Item(
id = 1, name = "Steak",
price = 10.2334f, daysOld = 20,
foodType = FoodType.STEAK
),
Item(
id = 2, name = "Pizza Pie",
price = 3.987f, daysOld = 10,
foodType = FoodType.PIZZA
),
Item(
id = 3, name = "Burger",
price = 1f, daysOld = 1,
foodType = FoodType.CHEESEBURGER
),
Item(
id = 4, name = "French Toast",
price = 4.54f, daysOld = 4,
foodType = FoodType.FRENCH_TOAST
),
Item(
id = 5, name = "Fries",
price = 0.564f, daysOld = 0,
foodType = FoodType.FRENCH_FRIES
)
)
val successfulItemModelList = listOf(
ItemModel(
id = 1, name = "Steak",
price = "$10.23", isNew = false,
isFastFood = false
),
ItemModel(
id = 2, name = "Pizza Pie",
price = "$3.99", isNew = false,
isFastFood = true
),
ItemModel(
id = 3, name = "Burger",
price = "$1.00", isNew = true,
isFastFood = true
),
ItemModel(
id = 4, name = "French Toast",
price = "$4.54", isNew = true,
isFastFood = false
),
ItemModel(
id = 5, name = "Fries",
price = "$0.56", isNew = true,
isFastFood = true
)
)