diff --git a/app/src/main/java/org/mozilla/fenix/loginexceptions/LoginExceptionsAdapter.kt b/app/src/main/java/org/mozilla/fenix/loginexceptions/LoginExceptionsAdapter.kt index 5a5031490..a04a705fe 100644 --- a/app/src/main/java/org/mozilla/fenix/loginexceptions/LoginExceptionsAdapter.kt +++ b/app/src/main/java/org/mozilla/fenix/loginexceptions/LoginExceptionsAdapter.kt @@ -14,29 +14,22 @@ import org.mozilla.fenix.loginexceptions.viewholders.LoginExceptionsDeleteButton import org.mozilla.fenix.loginexceptions.viewholders.LoginExceptionsHeaderViewHolder import org.mozilla.fenix.loginexceptions.viewholders.LoginExceptionsListItemViewHolder -sealed class AdapterItem { - object DeleteButton : AdapterItem() - object Header : AdapterItem() - data class Item(val item: LoginException) : AdapterItem() -} - /** * Adapter for a list of sites that are exempted from saving logins, * along with controls to remove the exception. */ class LoginExceptionsAdapter( private val interactor: LoginExceptionsInteractor -) : ListAdapter(DiffCallback) { +) : ListAdapter(DiffCallback) { /** * Change the list of items that are displayed. * Header and footer items are added to the list as well. */ fun updateData(exceptions: List) { - val adapterItems: List = - listOf(AdapterItem.Header) + exceptions.map { AdapterItem.Item(it) } + listOf( - AdapterItem.DeleteButton - ) + val adapterItems: List = listOf(AdapterItem.Header) + + exceptions.map { AdapterItem.Item(it) } + + listOf(AdapterItem.DeleteButton) submitList(adapterItems) } @@ -70,9 +63,18 @@ class LoginExceptionsAdapter( } } - private object DiffCallback : DiffUtil.ItemCallback() { + sealed class AdapterItem { + object DeleteButton : AdapterItem() + object Header : AdapterItem() + data class Item(val item: LoginException) : AdapterItem() + } + + internal object DiffCallback : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: AdapterItem, newItem: AdapterItem) = - areContentsTheSame(oldItem, newItem) + when (oldItem) { + AdapterItem.DeleteButton, AdapterItem.Header -> oldItem === newItem + is AdapterItem.Item -> newItem is AdapterItem.Item && oldItem.item.id == newItem.item.id + } @Suppress("DiffUtilEquals") override fun areContentsTheSame(oldItem: AdapterItem, newItem: AdapterItem) = diff --git a/app/src/main/java/org/mozilla/fenix/loginexceptions/LoginExceptionsFragment.kt b/app/src/main/java/org/mozilla/fenix/loginexceptions/LoginExceptionsFragment.kt index d2be2d66f..c1519bb00 100644 --- a/app/src/main/java/org/mozilla/fenix/loginexceptions/LoginExceptionsFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/loginexceptions/LoginExceptionsFragment.kt @@ -9,9 +9,9 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment -import androidx.lifecycle.Observer import androidx.lifecycle.asLiveData import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.observe import kotlinx.android.synthetic.main.fragment_exceptions.view.* import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -57,18 +57,15 @@ class LoginExceptionsFragment : Fragment() { return view } - private fun subscribeToLoginExceptions(): Observer> { - return Observer> { exceptions -> - exceptionsStore.dispatch(ExceptionsFragmentAction.Change(exceptions)) - }.also { observer -> - requireComponents.core.loginExceptionStorage.getLoginExceptions().asLiveData() - .observe(viewLifecycleOwner, observer) - } + private fun subscribeToLoginExceptions() { + requireComponents.core.loginExceptionStorage.getLoginExceptions().asLiveData() + .observe(viewLifecycleOwner) { exceptions -> + exceptionsStore.dispatch(ExceptionsFragmentAction.Change(exceptions)) + } } @ExperimentalCoroutinesApi override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) consumeFrom(exceptionsStore) { exceptionsView.update(it) } diff --git a/app/src/main/java/org/mozilla/fenix/loginexceptions/LoginExceptionsView.kt b/app/src/main/java/org/mozilla/fenix/loginexceptions/LoginExceptionsView.kt index 26854bba7..f6924870b 100644 --- a/app/src/main/java/org/mozilla/fenix/loginexceptions/LoginExceptionsView.kt +++ b/app/src/main/java/org/mozilla/fenix/loginexceptions/LoginExceptionsView.kt @@ -10,7 +10,7 @@ import android.widget.FrameLayout import androidx.core.view.isVisible import androidx.recyclerview.widget.LinearLayoutManager import kotlinx.android.extensions.LayoutContainer -import kotlinx.android.synthetic.main.component_exceptions.view.* +import kotlinx.android.synthetic.main.component_exceptions.* import mozilla.components.feature.logins.exceptions.LoginException import org.mozilla.fenix.R @@ -34,29 +34,29 @@ interface ExceptionsViewInteractor { * View that contains and configures the Exceptions List */ class LoginExceptionsView( - override val containerView: ViewGroup, + container: ViewGroup, val interactor: LoginExceptionsInteractor ) : LayoutContainer { - val view: FrameLayout = LayoutInflater.from(containerView.context) - .inflate(R.layout.component_exceptions, containerView, true) + override val containerView: FrameLayout = LayoutInflater.from(container.context) + .inflate(R.layout.component_exceptions, container, true) .findViewById(R.id.exceptions_wrapper) private val exceptionsAdapter = LoginExceptionsAdapter(interactor) init { - view.exceptions_learn_more.isVisible = false - view.exceptions_empty_message.text = - view.context.getString(R.string.preferences_passwords_exceptions_description_empty) - view.exceptions_list.apply { + exceptions_learn_more.isVisible = false + exceptions_empty_message.text = + containerView.context.getString(R.string.preferences_passwords_exceptions_description_empty) + exceptions_list.apply { adapter = exceptionsAdapter layoutManager = LinearLayoutManager(containerView.context) } } fun update(state: ExceptionsFragmentState) { - view.exceptions_empty_view.isVisible = state.items.isEmpty() - view.exceptions_list.isVisible = state.items.isNotEmpty() + exceptions_empty_view.isVisible = state.items.isEmpty() + exceptions_list.isVisible = state.items.isNotEmpty() exceptionsAdapter.updateData(state.items) } } diff --git a/app/src/main/java/org/mozilla/fenix/loginexceptions/viewholders/LoginExceptionsHeaderViewHolder.kt b/app/src/main/java/org/mozilla/fenix/loginexceptions/viewholders/LoginExceptionsHeaderViewHolder.kt index 0a2033f16..7733563f7 100644 --- a/app/src/main/java/org/mozilla/fenix/loginexceptions/viewholders/LoginExceptionsHeaderViewHolder.kt +++ b/app/src/main/java/org/mozilla/fenix/loginexceptions/viewholders/LoginExceptionsHeaderViewHolder.kt @@ -12,12 +12,13 @@ import org.mozilla.fenix.R class LoginExceptionsHeaderViewHolder( view: View ) : RecyclerView.ViewHolder(view) { - companion object { - const val LAYOUT_ID = R.layout.exceptions_description - } init { view.exceptions_description.text = view.context.getString(R.string.preferences_passwords_exceptions_description) } + + companion object { + const val LAYOUT_ID = R.layout.exceptions_description + } } diff --git a/app/src/test/java/org/mozilla/fenix/loginexceptions/LoginExceptionsAdapterTest.kt b/app/src/test/java/org/mozilla/fenix/loginexceptions/LoginExceptionsAdapterTest.kt new file mode 100644 index 000000000..cada0d946 --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/loginexceptions/LoginExceptionsAdapterTest.kt @@ -0,0 +1,113 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.loginexceptions + +import android.widget.LinearLayout +import androidx.appcompat.view.ContextThemeWrapper +import io.mockk.every +import io.mockk.mockk +import mozilla.components.support.test.robolectric.testContext +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.fenix.R +import org.mozilla.fenix.helpers.FenixRobolectricTestRunner +import org.mozilla.fenix.loginexceptions.viewholders.LoginExceptionsDeleteButtonViewHolder +import org.mozilla.fenix.loginexceptions.viewholders.LoginExceptionsHeaderViewHolder +import org.mozilla.fenix.loginexceptions.viewholders.LoginExceptionsListItemViewHolder + +@RunWith(FenixRobolectricTestRunner::class) +class LoginExceptionsAdapterTest { + + private lateinit var interactor: LoginExceptionsInteractor + private lateinit var adapter: LoginExceptionsAdapter + + @Before + fun setup() { + interactor = mockk() + adapter = LoginExceptionsAdapter(interactor) + } + + @Test + fun `creates correct view holder type`() { + val parent = LinearLayout(ContextThemeWrapper(testContext, R.style.NormalTheme)) + adapter.updateData(listOf(mockk(), mockk())) + assertEquals(4, adapter.itemCount) + + val holders = (0 until adapter.itemCount).asSequence() + .map { i -> adapter.getItemViewType(i) } + .map { viewType -> adapter.onCreateViewHolder(parent, viewType) } + .toList() + assertEquals(4, holders.size) + + assertTrue(holders[0] is LoginExceptionsHeaderViewHolder) + assertTrue(holders[1] is LoginExceptionsListItemViewHolder) + assertTrue(holders[2] is LoginExceptionsListItemViewHolder) + assertTrue(holders[3] is LoginExceptionsDeleteButtonViewHolder) + } + + @Test + fun `headers and delete should check if the other object is the same`() { + assertTrue(LoginExceptionsAdapter.DiffCallback.areItemsTheSame( + LoginExceptionsAdapter.AdapterItem.Header, + LoginExceptionsAdapter.AdapterItem.Header + )) + assertTrue(LoginExceptionsAdapter.DiffCallback.areItemsTheSame( + LoginExceptionsAdapter.AdapterItem.DeleteButton, + LoginExceptionsAdapter.AdapterItem.DeleteButton + )) + assertFalse(LoginExceptionsAdapter.DiffCallback.areItemsTheSame( + LoginExceptionsAdapter.AdapterItem.Header, + LoginExceptionsAdapter.AdapterItem.DeleteButton + )) + assertTrue(LoginExceptionsAdapter.DiffCallback.areContentsTheSame( + LoginExceptionsAdapter.AdapterItem.Header, + LoginExceptionsAdapter.AdapterItem.Header + )) + assertTrue(LoginExceptionsAdapter.DiffCallback.areContentsTheSame( + LoginExceptionsAdapter.AdapterItem.DeleteButton, + LoginExceptionsAdapter.AdapterItem.DeleteButton + )) + assertFalse(LoginExceptionsAdapter.DiffCallback.areContentsTheSame( + LoginExceptionsAdapter.AdapterItem.DeleteButton, + LoginExceptionsAdapter.AdapterItem.Header + )) + } + + @Test + fun `items with the same id should be marked as same`() { + assertTrue(LoginExceptionsAdapter.DiffCallback.areItemsTheSame( + LoginExceptionsAdapter.AdapterItem.Item(mockk { + every { id } returns 12L + }), + LoginExceptionsAdapter.AdapterItem.Item(mockk { + every { id } returns 12L + }) + )) + assertFalse(LoginExceptionsAdapter.DiffCallback.areItemsTheSame( + LoginExceptionsAdapter.AdapterItem.Item(mockk { + every { id } returns 14L + }), + LoginExceptionsAdapter.AdapterItem.Item(mockk { + every { id } returns 12L + }) + )) + assertFalse(LoginExceptionsAdapter.DiffCallback.areItemsTheSame( + LoginExceptionsAdapter.AdapterItem.Item(mockk { + every { id } returns 14L + }), + LoginExceptionsAdapter.AdapterItem.Header + )) + assertFalse(LoginExceptionsAdapter.DiffCallback.areItemsTheSame( + LoginExceptionsAdapter.AdapterItem.DeleteButton, + LoginExceptionsAdapter.AdapterItem.Item(mockk { + every { id } returns 14L + }) + )) + } +} diff --git a/app/src/test/java/org/mozilla/fenix/loginexceptions/LoginExceptionsViewTest.kt b/app/src/test/java/org/mozilla/fenix/loginexceptions/LoginExceptionsViewTest.kt new file mode 100644 index 000000000..e716bffba --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/loginexceptions/LoginExceptionsViewTest.kt @@ -0,0 +1,65 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.loginexceptions + +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.core.view.isVisible +import androidx.recyclerview.widget.LinearLayoutManager +import io.mockk.mockk +import kotlinx.android.synthetic.main.component_exceptions.* +import mozilla.components.support.test.robolectric.testContext +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.fenix.helpers.FenixRobolectricTestRunner + +@RunWith(FenixRobolectricTestRunner::class) +class LoginExceptionsViewTest { + + private lateinit var parent: ViewGroup + private lateinit var interactor: LoginExceptionsInteractor + private lateinit var view: LoginExceptionsView + + @Before + fun setup() { + parent = FrameLayout(testContext) + interactor = mockk() + view = LoginExceptionsView(parent, interactor) + } + + @Test + fun `sets empty message text`() { + assertEquals( + "Logins and passwords that are not saved will be shown here.", + view.exceptions_empty_message.text + ) + assertTrue(view.exceptions_list.adapter is LoginExceptionsAdapter) + assertTrue(view.exceptions_list.layoutManager is LinearLayoutManager) + } + + @Test + fun `hide list when there are no items`() { + view.update(ExceptionsFragmentState( + items = emptyList() + )) + + assertTrue(view.exceptions_empty_view.isVisible) + assertFalse(view.exceptions_list.isVisible) + } + + @Test + fun `shows list when there are items`() { + view.update(ExceptionsFragmentState( + items = listOf(mockk()) + )) + + assertFalse(view.exceptions_empty_view.isVisible) + assertTrue(view.exceptions_list.isVisible) + } +} diff --git a/app/src/test/java/org/mozilla/fenix/loginexceptions/viewholders/LoginExceptionsDeleteButtonViewHolderTest.kt b/app/src/test/java/org/mozilla/fenix/loginexceptions/viewholders/LoginExceptionsDeleteButtonViewHolderTest.kt new file mode 100644 index 000000000..35721e5b0 --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/loginexceptions/viewholders/LoginExceptionsDeleteButtonViewHolderTest.kt @@ -0,0 +1,45 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.loginexceptions.viewholders + +import android.view.View +import com.google.android.material.button.MaterialButton +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.junit.Before +import org.junit.Test +import org.mozilla.fenix.R +import org.mozilla.fenix.loginexceptions.LoginExceptionsInteractor + +class LoginExceptionsDeleteButtonViewHolderTest { + + private lateinit var view: View + private lateinit var deleteButton: MaterialButton + private lateinit var interactor: LoginExceptionsInteractor + + @Before + fun setup() { + deleteButton = mockk() + view = mockk { + every { findViewById(R.id.removeAllExceptions) } returns deleteButton + } + interactor = mockk() + } + + @Test + fun `delete button calls interactor`() { + val slot = slot() + every { deleteButton.setOnClickListener(capture(slot)) } just Runs + LoginExceptionsDeleteButtonViewHolder(view, interactor) + + every { interactor.onDeleteAll() } just Runs + slot.captured.onClick(mockk()) + verify { interactor.onDeleteAll() } + } +} diff --git a/app/src/test/java/org/mozilla/fenix/loginexceptions/viewholders/LoginExceptionsHeaderViewHolderTest.kt b/app/src/test/java/org/mozilla/fenix/loginexceptions/viewholders/LoginExceptionsHeaderViewHolderTest.kt new file mode 100644 index 000000000..08121d22e --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/loginexceptions/viewholders/LoginExceptionsHeaderViewHolderTest.kt @@ -0,0 +1,37 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.loginexceptions.viewholders + +import android.view.View +import android.widget.TextView +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Before +import org.junit.Test +import org.mozilla.fenix.R + +class LoginExceptionsHeaderViewHolderTest { + + private lateinit var view: View + private lateinit var description: TextView + + @Before + fun setup() { + description = mockk(relaxUnitFun = true) + view = mockk { + every { findViewById(R.id.exceptions_description) } returns description + every { + context.getString(R.string.preferences_passwords_exceptions_description) + } returns "Logins and passwords will not be saved for these sites." + } + } + + @Test + fun `sets description text`() { + LoginExceptionsHeaderViewHolder(view) + verify { description.text = "Logins and passwords will not be saved for these sites." } + } +} diff --git a/app/src/test/java/org/mozilla/fenix/loginexceptions/viewholders/LoginExceptionsListItemViewHolderTest.kt b/app/src/test/java/org/mozilla/fenix/loginexceptions/viewholders/LoginExceptionsListItemViewHolderTest.kt new file mode 100644 index 000000000..1243f665b --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/loginexceptions/viewholders/LoginExceptionsListItemViewHolderTest.kt @@ -0,0 +1,63 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.loginexceptions.viewholders + +import android.view.View +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.TextView +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import mozilla.components.feature.logins.exceptions.LoginException +import org.junit.Before +import org.junit.Test +import org.mozilla.fenix.R +import org.mozilla.fenix.loginexceptions.LoginExceptionsInteractor + +class LoginExceptionsListItemViewHolderTest { + + private lateinit var view: View + private lateinit var url: TextView + private lateinit var deleteButton: ImageButton + private lateinit var interactor: LoginExceptionsInteractor + + @Before + fun setup() { + url = mockk(relaxUnitFun = true) + deleteButton = mockk(relaxUnitFun = true) + view = mockk { + every { findViewById(R.id.webAddressView) } returns url + every { findViewById(R.id.delete_exception) } returns deleteButton + every { findViewById(R.id.favicon_image) } returns mockk() + } + interactor = mockk() + } + + @Test + fun `sets url text`() { + LoginExceptionsListItemViewHolder(view, interactor).bind(mockk { + every { origin } returns "mozilla.org" + }) + verify { url.text = "mozilla.org" } + } + + @Test + fun `delete button calls interactor`() { + val slot = slot() + val loginException = mockk { + every { origin } returns "mozilla.org" + } + every { deleteButton.setOnClickListener(capture(slot)) } just Runs + LoginExceptionsListItemViewHolder(view, interactor).bind(loginException) + + every { interactor.onDeleteOne(loginException) } just Runs + slot.captured.onClick(mockk()) + verify { interactor.onDeleteOne(loginException) } + } +}