[fenix] For https://github.com/mozilla-mobile/fenix/issues/18993 - Nimbus: Allow internal tooling to opt into specific branches of an experiment (https://github.com/mozilla-mobile/fenix/pull/19333)
parent
9855b1d05a
commit
744e447c64
@ -0,0 +1,95 @@
|
|||||||
|
/* 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.nimbus
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.navigation.fragment.navArgs
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import mozilla.components.lib.state.ext.consumeFrom
|
||||||
|
import mozilla.components.support.base.log.logger.Logger
|
||||||
|
import org.mozilla.fenix.R
|
||||||
|
import org.mozilla.fenix.components.StoreProvider
|
||||||
|
import org.mozilla.fenix.ext.components
|
||||||
|
import org.mozilla.fenix.ext.showToolbar
|
||||||
|
import org.mozilla.fenix.ext.withExperiment
|
||||||
|
import org.mozilla.fenix.nimbus.controller.NimbusBranchesController
|
||||||
|
import org.mozilla.fenix.nimbus.view.NimbusBranchesView
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A fragment to show the branches of a Nimbus experiment.
|
||||||
|
*/
|
||||||
|
@Suppress("TooGenericExceptionCaught")
|
||||||
|
class NimbusBranchesFragment : Fragment() {
|
||||||
|
|
||||||
|
private lateinit var nimbusBranchesStore: NimbusBranchesStore
|
||||||
|
private lateinit var nimbusBranchesView: NimbusBranchesView
|
||||||
|
private lateinit var controller: NimbusBranchesController
|
||||||
|
|
||||||
|
private val args by navArgs<NimbusBranchesFragmentArgs>()
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View? {
|
||||||
|
val view =
|
||||||
|
inflater.inflate(R.layout.mozac_service_nimbus_experiment_details, container, false)
|
||||||
|
|
||||||
|
nimbusBranchesStore = StoreProvider.get(this) {
|
||||||
|
NimbusBranchesStore(NimbusBranchesState(branches = emptyList()))
|
||||||
|
}
|
||||||
|
|
||||||
|
controller = NimbusBranchesController(
|
||||||
|
nimbusBranchesStore = nimbusBranchesStore,
|
||||||
|
experiments = requireContext().components.analytics.experiments,
|
||||||
|
experimentId = args.experimentId
|
||||||
|
)
|
||||||
|
|
||||||
|
nimbusBranchesView =
|
||||||
|
NimbusBranchesView(view.findViewById(R.id.nimbus_experiment_branches_list), controller)
|
||||||
|
|
||||||
|
loadExperimentBranches()
|
||||||
|
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExperimentalCoroutinesApi
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
consumeFrom(nimbusBranchesStore) { state ->
|
||||||
|
nimbusBranchesView.update(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
showToolbar(args.experimentName)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadExperimentBranches() {
|
||||||
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val experiments = requireContext().components.analytics.experiments
|
||||||
|
val branches = experiments.getExperimentBranches(args.experimentId) ?: emptyList()
|
||||||
|
val selectedBranch = experiments.withExperiment(args.experimentId) ?: ""
|
||||||
|
|
||||||
|
nimbusBranchesStore.dispatch(
|
||||||
|
NimbusBranchesAction.UpdateBranches(
|
||||||
|
branches,
|
||||||
|
selectedBranch
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.error("Failed to getActiveExperiments()", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,79 @@
|
|||||||
|
/* 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.nimbus
|
||||||
|
|
||||||
|
import mozilla.components.lib.state.Action
|
||||||
|
import mozilla.components.lib.state.State
|
||||||
|
import mozilla.components.lib.state.Store
|
||||||
|
import org.mozilla.experiments.nimbus.Branch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The [Store] for holding the [NimbusBranchesState] and applying [NimbusBranchesAction]s.
|
||||||
|
*/
|
||||||
|
class NimbusBranchesStore(initialState: NimbusBranchesState) :
|
||||||
|
Store<NimbusBranchesState, NimbusBranchesAction>(
|
||||||
|
initialState, ::nimbusBranchesFragmentStateReducer
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The state for [NimbusBranchesFragment].
|
||||||
|
*
|
||||||
|
* @property branches The list of [Branch]s to display in the branches list.
|
||||||
|
* @property selectedBranch The selected [Branch] slug for a Nimbus experiment.
|
||||||
|
* @property isLoading True if the branches are still being loaded from storage, otherwise false.
|
||||||
|
*/
|
||||||
|
data class NimbusBranchesState(
|
||||||
|
val branches: List<Branch>,
|
||||||
|
val selectedBranch: String = "",
|
||||||
|
val isLoading: Boolean = true
|
||||||
|
) : State
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actions to dispatch through the [NimbusBranchesStore] to modify the [NimbusBranchesState]
|
||||||
|
* through the [nimbusBranchesFragmentStateReducer].
|
||||||
|
*/
|
||||||
|
sealed class NimbusBranchesAction : Action {
|
||||||
|
/**
|
||||||
|
* Updates the list of Nimbus branches and selected branch.
|
||||||
|
*
|
||||||
|
* @param branches The list of [Branch]s to display in the branches list.
|
||||||
|
* @param selectedBranch The selected [Branch] slug for a Nimbus experiment.
|
||||||
|
*/
|
||||||
|
data class UpdateBranches(val branches: List<Branch>, val selectedBranch: String) :
|
||||||
|
NimbusBranchesAction()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the selected branch.
|
||||||
|
*
|
||||||
|
* @param selectedBranch The selected [Branch] slug for a Nimbus experiment.
|
||||||
|
*/
|
||||||
|
data class UpdateSelectedBranch(val selectedBranch: String) : NimbusBranchesAction()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reduces the Nimbus branches state from the current state with the provided [action] to
|
||||||
|
* be performed.
|
||||||
|
*
|
||||||
|
* @param state The current Nimbus branches state.
|
||||||
|
* @param action The action to be performed on the state.
|
||||||
|
* @return the new [NimbusBranchesState] with the [action] executed.
|
||||||
|
*/
|
||||||
|
private fun nimbusBranchesFragmentStateReducer(
|
||||||
|
state: NimbusBranchesState,
|
||||||
|
action: NimbusBranchesAction
|
||||||
|
): NimbusBranchesState {
|
||||||
|
return when (action) {
|
||||||
|
is NimbusBranchesAction.UpdateBranches -> {
|
||||||
|
state.copy(
|
||||||
|
branches = action.branches,
|
||||||
|
selectedBranch = action.selectedBranch,
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is NimbusBranchesAction.UpdateSelectedBranch -> {
|
||||||
|
state.copy(selectedBranch = action.selectedBranch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,59 +0,0 @@
|
|||||||
/* 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.nimbus
|
|
||||||
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.View
|
|
||||||
import androidx.fragment.app.Fragment
|
|
||||||
import androidx.navigation.fragment.navArgs
|
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import mozilla.components.service.nimbus.ui.NimbusDetailAdapter
|
|
||||||
import org.mozilla.fenix.R
|
|
||||||
import org.mozilla.fenix.ext.showToolbar
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A fragment to show the details of a Nimbus experiment.
|
|
||||||
*/
|
|
||||||
class NimbusDetailsFragment : Fragment(R.layout.mozac_service_nimbus_experiment_details) {
|
|
||||||
|
|
||||||
private val args by navArgs<NimbusDetailsFragmentArgs>()
|
|
||||||
private var adapter: NimbusDetailAdapter? = null
|
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
|
||||||
super.onViewCreated(view, savedInstanceState)
|
|
||||||
bindRecyclerView(view)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onResume() {
|
|
||||||
super.onResume()
|
|
||||||
showToolbar(args.experiment)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroyView() {
|
|
||||||
super.onDestroyView()
|
|
||||||
// Letting go of the resources to avoid memory leak.
|
|
||||||
adapter = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun bindRecyclerView(view: View) {
|
|
||||||
val recyclerView = view.findViewById<RecyclerView>(R.id.nimbus_experiment_branches_list)
|
|
||||||
recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
|
||||||
|
|
||||||
val shouldRefresh = adapter != null
|
|
||||||
|
|
||||||
// Dummy data until we have the appropriate Nimbus API.
|
|
||||||
val branches = listOf(
|
|
||||||
"Control",
|
|
||||||
"Treatment"
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!shouldRefresh) {
|
|
||||||
adapter = NimbusDetailAdapter(branches)
|
|
||||||
}
|
|
||||||
|
|
||||||
recyclerView.adapter = adapter
|
|
||||||
}
|
|
||||||
}
|
|
@ -0,0 +1,32 @@
|
|||||||
|
/* 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.nimbus.controller
|
||||||
|
|
||||||
|
import mozilla.components.service.nimbus.NimbusApi
|
||||||
|
import mozilla.components.service.nimbus.ui.NimbusBranchesAdapterDelegate
|
||||||
|
import org.mozilla.experiments.nimbus.Branch
|
||||||
|
import org.mozilla.fenix.nimbus.NimbusBranchesAction
|
||||||
|
import org.mozilla.fenix.nimbus.NimbusBranchesStore
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [NimbusBranchesFragment] controller. This implements [NimbusBranchesAdapterDelegate] to handle
|
||||||
|
* interactions with a Nimbus branch item.
|
||||||
|
*
|
||||||
|
* @param nimbusBranchesStore An instance of [NimbusBranchesStore] for dispatching
|
||||||
|
* [NimbusBranchesAction]s.
|
||||||
|
* @param experiments An instance of [NimbusApi] for interacting with the Nimbus experiments.
|
||||||
|
* @param experimentId The string experiment-id or "slug" for a Nimbus experiment.
|
||||||
|
*/
|
||||||
|
class NimbusBranchesController(
|
||||||
|
private val nimbusBranchesStore: NimbusBranchesStore,
|
||||||
|
private val experiments: NimbusApi,
|
||||||
|
private val experimentId: String
|
||||||
|
) : NimbusBranchesAdapterDelegate {
|
||||||
|
|
||||||
|
override fun onBranchItemClicked(branch: Branch) {
|
||||||
|
experiments.optInWithBranch(experimentId, branch.slug)
|
||||||
|
nimbusBranchesStore.dispatch(NimbusBranchesAction.UpdateSelectedBranch(branch.slug))
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,36 @@
|
|||||||
|
/* 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.nimbus.view
|
||||||
|
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import kotlinx.android.extensions.LayoutContainer
|
||||||
|
import mozilla.components.service.nimbus.ui.NimbusBranchAdapter
|
||||||
|
import org.mozilla.fenix.nimbus.NimbusBranchesState
|
||||||
|
import org.mozilla.fenix.nimbus.controller.NimbusBranchesController
|
||||||
|
|
||||||
|
/**
|
||||||
|
* View used for managing a Nimbus experiment's branches.
|
||||||
|
*/
|
||||||
|
class NimbusBranchesView(
|
||||||
|
override val containerView: ViewGroup,
|
||||||
|
val controller: NimbusBranchesController
|
||||||
|
) : LayoutContainer {
|
||||||
|
|
||||||
|
private val nimbusAdapter = NimbusBranchAdapter(controller)
|
||||||
|
|
||||||
|
init {
|
||||||
|
val recyclerView: RecyclerView = containerView as RecyclerView
|
||||||
|
recyclerView.apply {
|
||||||
|
adapter = nimbusAdapter
|
||||||
|
layoutManager = LinearLayoutManager(containerView.context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun update(state: NimbusBranchesState) {
|
||||||
|
nimbusAdapter.updateData(state.branches, state.selectedBranch)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,49 @@
|
|||||||
|
/* 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.nimbus
|
||||||
|
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.verify
|
||||||
|
import mozilla.components.service.nimbus.NimbusApi
|
||||||
|
import mozilla.components.support.test.mock
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.mozilla.experiments.nimbus.Branch
|
||||||
|
import org.mozilla.experiments.nimbus.FeatureConfig
|
||||||
|
import org.mozilla.fenix.nimbus.controller.NimbusBranchesController
|
||||||
|
|
||||||
|
class NimbusBranchesControllerTest {
|
||||||
|
|
||||||
|
private val experiments: NimbusApi = mockk(relaxed = true)
|
||||||
|
private val experimentId = "id"
|
||||||
|
|
||||||
|
private lateinit var controller: NimbusBranchesController
|
||||||
|
private lateinit var nimbusBranchesStore: NimbusBranchesStore
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
nimbusBranchesStore = mock()
|
||||||
|
controller = NimbusBranchesController(nimbusBranchesStore, experiments, experimentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `WHEN branch item is clicked THEN branch is opted into and selectedBranch state is updated`() {
|
||||||
|
val branch = Branch(
|
||||||
|
slug = "slug",
|
||||||
|
ratio = 1,
|
||||||
|
feature = FeatureConfig(
|
||||||
|
featureId = "1",
|
||||||
|
enabled = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
controller.onBranchItemClicked(branch)
|
||||||
|
|
||||||
|
verify {
|
||||||
|
experiments.optInWithBranch(experimentId, branch.slug)
|
||||||
|
nimbusBranchesStore.dispatch(NimbusBranchesAction.UpdateSelectedBranch(branch.slug))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,52 @@
|
|||||||
|
/* 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.nimbus
|
||||||
|
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
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.mozilla.experiments.nimbus.Branch
|
||||||
|
|
||||||
|
class NimbusBranchesStoreTest {
|
||||||
|
|
||||||
|
private lateinit var nimbusBranchesState: NimbusBranchesState
|
||||||
|
private lateinit var nimbusBranchesStore: NimbusBranchesStore
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
nimbusBranchesState = NimbusBranchesState(branches = emptyList())
|
||||||
|
nimbusBranchesStore = NimbusBranchesStore(nimbusBranchesState)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN a new branch and selected branch WHEN UpdateBranches action is dispatched THEN state is updated`() = runBlocking {
|
||||||
|
assertTrue(nimbusBranchesStore.state.isLoading)
|
||||||
|
|
||||||
|
val branches: List<Branch> = listOf(mockk(), mockk())
|
||||||
|
val selectedBranch = "control"
|
||||||
|
|
||||||
|
nimbusBranchesStore.dispatch(NimbusBranchesAction.UpdateBranches(branches, selectedBranch))
|
||||||
|
.join()
|
||||||
|
|
||||||
|
assertEquals(branches, nimbusBranchesStore.state.branches)
|
||||||
|
assertEquals(selectedBranch, nimbusBranchesStore.state.selectedBranch)
|
||||||
|
assertFalse(nimbusBranchesStore.state.isLoading)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN a new selected branch WHEN UpdateSelectedBranch action is dispatched THEN selectedBranch state is updated`() = runBlocking {
|
||||||
|
assertEquals("", nimbusBranchesStore.state.selectedBranch)
|
||||||
|
|
||||||
|
val selectedBranch = "control"
|
||||||
|
|
||||||
|
nimbusBranchesStore.dispatch(NimbusBranchesAction.UpdateSelectedBranch(selectedBranch)).join()
|
||||||
|
|
||||||
|
assertEquals(selectedBranch, nimbusBranchesStore.state.selectedBranch)
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue