From 43a919c8b32180c8c48fcf7b3e514f46373bc096 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Tue, 19 Nov 2019 15:38:24 -0800 Subject: [PATCH 01/36] Add support for showing concept cards in feedback, and add a concept card as one of the remediation pathways for 'the meaning of equal parts' lesson. --- .../player/exploration/ExplorationActivity.kt | 5 +- .../ExplorationActivityPresenter.kt | 4 + .../player/exploration/ExplorationFragment.kt | 2 + .../ExplorationFragmentPresenter.kt | 2 + .../oppia/app/player/state/StateFragment.kt | 2 + .../player/state/StateFragmentPresenter.kt | 39 +++-- .../ConceptCardFragmentTestActivity.kt | 2 +- .../oppia/app/testing/TopicTestActivity.kt | 2 +- .../app/testing/TopicTestActivityForStory.kt | 2 +- .../java/org/oppia/app/topic/TopicActivity.kt | 2 +- .../ConceptCardFragmentPresenter.kt | 2 +- .../topic/conceptcard/ConceptCardListener.kt | 2 +- .../main/assets/fractions_exploration1.json | 2 +- .../util/parser/CustomHtmlContentHandler.kt | 141 ++++++++++++++++++ .../java/org/oppia/util/parser/HtmlParser.kt | 77 +++++++--- 15 files changed, 248 insertions(+), 38 deletions(-) create mode 100644 utility/src/main/java/org/oppia/util/parser/CustomHtmlContentHandler.kt diff --git a/app/src/main/java/org/oppia/app/player/exploration/ExplorationActivity.kt b/app/src/main/java/org/oppia/app/player/exploration/ExplorationActivity.kt index 21e8291a551..e8e9b5a8975 100755 --- a/app/src/main/java/org/oppia/app/player/exploration/ExplorationActivity.kt +++ b/app/src/main/java/org/oppia/app/player/exploration/ExplorationActivity.kt @@ -9,13 +9,14 @@ import org.oppia.app.R import org.oppia.app.activity.InjectableAppCompatActivity import org.oppia.app.player.stopexploration.StopExplorationDialogFragment import org.oppia.app.player.stopexploration.StopExplorationInterface +import org.oppia.app.topic.conceptcard.ConceptCardListener import javax.inject.Inject const val EXPLORATION_ACTIVITY_TOPIC_ID_ARGUMENT_KEY = "ExplorationActivity.exploration_id" private const val TAG_STOP_EXPLORATION_DIALOG = "STOP_EXPLORATION_DIALOG" /** The starting point for exploration. */ -class ExplorationActivity : InjectableAppCompatActivity(), StopExplorationInterface { +class ExplorationActivity : InjectableAppCompatActivity(), StopExplorationInterface, ConceptCardListener { @Inject lateinit var explorationActivityPresenter: ExplorationActivityPresenter private lateinit var explorationId: String @@ -64,4 +65,6 @@ class ExplorationActivity : InjectableAppCompatActivity(), StopExplorationInterf } return super.onOptionsItemSelected(item) } + + override fun dismissConceptCard() = explorationActivityPresenter.dismissConceptCard() } diff --git a/app/src/main/java/org/oppia/app/player/exploration/ExplorationActivityPresenter.kt b/app/src/main/java/org/oppia/app/player/exploration/ExplorationActivityPresenter.kt index 6d7780088dc..3afed04b68a 100755 --- a/app/src/main/java/org/oppia/app/player/exploration/ExplorationActivityPresenter.kt +++ b/app/src/main/java/org/oppia/app/player/exploration/ExplorationActivityPresenter.kt @@ -67,6 +67,10 @@ class ExplorationActivityPresenter @Inject constructor( getExplorationFragment()?.handlePlayAudio() } + fun dismissConceptCard() { + getExplorationFragment()?.dismissConceptCard() + } + private fun updateToolbarTitle(explorationId: String) { subscribeToExploration(explorationDataController.getExplorationById(explorationId)) } diff --git a/app/src/main/java/org/oppia/app/player/exploration/ExplorationFragment.kt b/app/src/main/java/org/oppia/app/player/exploration/ExplorationFragment.kt index 998a5bd52ba..d9fd17b8c9d 100755 --- a/app/src/main/java/org/oppia/app/player/exploration/ExplorationFragment.kt +++ b/app/src/main/java/org/oppia/app/player/exploration/ExplorationFragment.kt @@ -22,4 +22,6 @@ class ExplorationFragment : InjectableFragment() { } fun handlePlayAudio() = explorationFragmentPresenter.handlePlayAudio() + + fun dismissConceptCard() = explorationFragmentPresenter.dismissConceptCard() } diff --git a/app/src/main/java/org/oppia/app/player/exploration/ExplorationFragmentPresenter.kt b/app/src/main/java/org/oppia/app/player/exploration/ExplorationFragmentPresenter.kt index e3bea8ae910..9f966e62eb4 100755 --- a/app/src/main/java/org/oppia/app/player/exploration/ExplorationFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/app/player/exploration/ExplorationFragmentPresenter.kt @@ -34,6 +34,8 @@ class ExplorationFragmentPresenter @Inject constructor( getStateFragment()?.handlePlayAudio() } + fun dismissConceptCard() = getStateFragment()?.dismissConceptCard() + private fun getStateFragment(): StateFragment? { return fragment.childFragmentManager.findFragmentById(R.id.state_fragment_placeholder) as StateFragment? } diff --git a/app/src/main/java/org/oppia/app/player/state/StateFragment.kt b/app/src/main/java/org/oppia/app/player/state/StateFragment.kt index 5455b216797..57a7fd5e66f 100755 --- a/app/src/main/java/org/oppia/app/player/state/StateFragment.kt +++ b/app/src/main/java/org/oppia/app/player/state/StateFragment.kt @@ -53,4 +53,6 @@ class StateFragment : InjectableFragment(), CellularDataInterface, InteractionAn } fun handlePlayAudio() = stateFragmentPresenter.handleAudioClick() + + fun dismissConceptCard() = stateFragmentPresenter.dismissConceptCard() } diff --git a/app/src/main/java/org/oppia/app/player/state/StateFragmentPresenter.kt b/app/src/main/java/org/oppia/app/player/state/StateFragmentPresenter.kt index 8875cce1866..45ad81a2fd9 100755 --- a/app/src/main/java/org/oppia/app/player/state/StateFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/app/player/state/StateFragmentPresenter.kt @@ -46,6 +46,7 @@ import org.oppia.app.player.state.itemviewmodel.StateNavigationButtonViewModel.C import org.oppia.app.player.state.itemviewmodel.TextInputViewModel import org.oppia.app.player.state.listener.StateNavigationButtonListener import org.oppia.app.recyclerview.BindableAdapter +import org.oppia.app.topic.conceptcard.ConceptCardFragment import org.oppia.app.viewmodel.ViewModelProvider import org.oppia.domain.audio.CellularDialogController import org.oppia.domain.exploration.ExplorationDataController @@ -57,13 +58,14 @@ import org.oppia.util.parser.HtmlParser import javax.inject.Inject const val STATE_FRAGMENT_EXPLORATION_ID_ARGUMENT_KEY = "STATE_FRAGMENT_EXPLORATION_ID_ARGUMENT_KEY" -private const val TAG_CELLULAR_DATA_DIALOG = "CELLULAR_DATA_DIALOG" -private const val TAG_AUDIO_FRAGMENT = "AUDIO_FRAGMENT" +private const val CELLULAR_DATA_DIALOG_FRAGMENT_TAG = "CELLULAR_DATA_DIALOG_FRAGMENT" +private const val AUDIO_FRAGMENT_TAG = "AUDIO_FRAGMENT" +private const val CONCEPT_CARD_DIALOG_FRAGMENT_TAG = "CONCEPT_CARD_FRAGMENT" /** The presenter for [StateFragment]. */ @FragmentScope class StateFragmentPresenter @Inject constructor( - @ExplorationHtmlParserEntityType private val entityType: String, + @ExplorationHtmlParserEntityType private val htmlParserEntityType: String, private val activity: AppCompatActivity, private val fragment: Fragment, private val cellularDialogController: CellularDialogController, @@ -74,11 +76,14 @@ class StateFragmentPresenter @Inject constructor( private val htmlParserFactory: HtmlParser.Factory, private val context: Context, private val interactionViewModelFactoryMap: Map -) : StateNavigationButtonListener { +) : StateNavigationButtonListener, HtmlParser.CustomOppiaTagActionListener { private var showCellularDataDialog = true private var useCellularData = false private lateinit var explorationId: String + private val htmlParser: HtmlParser by lazy { + htmlParserFactory.create(htmlParserEntityType, explorationId, customOppiaTagActionListener = this) + } private lateinit var currentStateName: String private lateinit var binding: StateFragmentBinding private lateinit var recyclerViewAdapter: RecyclerView.Adapter<*> @@ -144,8 +149,8 @@ class StateFragmentPresenter @Inject constructor( }, bindView = { view, viewModel -> val binding = DataBindingUtil.findBinding(view)!! - binding.htmlContent = htmlParserFactory.create(entityType, explorationId).parseOppiaHtml( - (viewModel as ContentViewModel).htmlContent.toString(), binding.contentTextView + binding.htmlContent = htmlParser.parseOppiaHtml( + (viewModel as ContentViewModel).htmlContent.toString(), binding.contentTextView, supportsLinks = true ) } ) @@ -156,8 +161,8 @@ class StateFragmentPresenter @Inject constructor( }, bindView = { view, viewModel -> val binding = DataBindingUtil.findBinding(view)!! - binding.htmlContent = htmlParserFactory.create(entityType, explorationId).parseOppiaHtml( - (viewModel as FeedbackViewModel).htmlContent.toString(), binding.feedbackTextView + binding.htmlContent = htmlParser.parseOppiaHtml( + (viewModel as FeedbackViewModel).htmlContent.toString(), binding.feedbackTextView, supportsLinks = true ) } ) @@ -226,12 +231,12 @@ class StateFragmentPresenter @Inject constructor( } private fun showCellularDataDialogFragment() { - val previousFragment = fragment.childFragmentManager.findFragmentByTag(TAG_CELLULAR_DATA_DIALOG) + val previousFragment = fragment.childFragmentManager.findFragmentByTag(CELLULAR_DATA_DIALOG_FRAGMENT_TAG) if (previousFragment != null) { fragment.childFragmentManager.beginTransaction().remove(previousFragment).commitNow() } val dialogFragment = CellularDataDialogFragment.newInstance() - dialogFragment.showNow(fragment.childFragmentManager, TAG_CELLULAR_DATA_DIALOG) + dialogFragment.showNow(fragment.childFragmentManager, CELLULAR_DATA_DIALOG_FRAGMENT_TAG) } private fun getStateViewModel(): StateViewModel { @@ -239,7 +244,7 @@ class StateFragmentPresenter @Inject constructor( } private fun getAudioFragment(): Fragment? { - return fragment.childFragmentManager.findFragmentByTag(TAG_AUDIO_FRAGMENT) + return fragment.childFragmentManager.findFragmentByTag(AUDIO_FRAGMENT_TAG) } private fun showHideAudioFragment(isVisible: Boolean) { @@ -249,7 +254,7 @@ class StateFragmentPresenter @Inject constructor( val audioFragment = AudioFragment.newInstance(explorationId, currentStateName) fragment.childFragmentManager.beginTransaction().add( R.id.audio_fragment_placeholder, audioFragment, - TAG_AUDIO_FRAGMENT + AUDIO_FRAGMENT_TAG ).commitNow() } @@ -378,6 +383,16 @@ class StateFragmentPresenter @Inject constructor( override fun onNextButtonClicked() = moveToNextState() + override fun onConceptCardLinkClicked(view: View, skillId: String) { + ConceptCardFragment.newInstance(skillId).showNow(fragment.childFragmentManager, CONCEPT_CARD_DIALOG_FRAGMENT_TAG) + } + + fun dismissConceptCard() { + fragment.childFragmentManager.findFragmentByTag(CONCEPT_CARD_DIALOG_FRAGMENT_TAG)?.let { dialogFragment -> + fragment.childFragmentManager.beginTransaction().remove(dialogFragment).commitNow() + } + } + private fun moveToNextState() { explorationProgressController.moveToNextState() } diff --git a/app/src/main/java/org/oppia/app/testing/ConceptCardFragmentTestActivity.kt b/app/src/main/java/org/oppia/app/testing/ConceptCardFragmentTestActivity.kt index 244d4e32e53..9c52d6727ee 100644 --- a/app/src/main/java/org/oppia/app/testing/ConceptCardFragmentTestActivity.kt +++ b/app/src/main/java/org/oppia/app/testing/ConceptCardFragmentTestActivity.kt @@ -17,7 +17,7 @@ class ConceptCardFragmentTestActivity : InjectableAppCompatActivity(), ConceptCa conceptCardFragmentTestActivityController.handleOnCreate() } - override fun dismiss() { + override fun dismissConceptCard() { getConceptCardFragment()?.dismiss() } diff --git a/app/src/main/java/org/oppia/app/testing/TopicTestActivity.kt b/app/src/main/java/org/oppia/app/testing/TopicTestActivity.kt index 4a076283dd5..eea882d9d55 100644 --- a/app/src/main/java/org/oppia/app/testing/TopicTestActivity.kt +++ b/app/src/main/java/org/oppia/app/testing/TopicTestActivity.kt @@ -51,7 +51,7 @@ class TopicTestActivity : InjectableAppCompatActivity(), RouteToQuestionPlayerLi } } - override fun dismiss() { + override fun dismissConceptCard() { getConceptCardFragment()?.dismiss() } diff --git a/app/src/main/java/org/oppia/app/testing/TopicTestActivityForStory.kt b/app/src/main/java/org/oppia/app/testing/TopicTestActivityForStory.kt index 8ddecadc5a6..d852e5934b6 100644 --- a/app/src/main/java/org/oppia/app/testing/TopicTestActivityForStory.kt +++ b/app/src/main/java/org/oppia/app/testing/TopicTestActivityForStory.kt @@ -53,7 +53,7 @@ class TopicTestActivityForStory : InjectableAppCompatActivity(), RouteToQuestion } } - override fun dismiss() { + override fun dismissConceptCard() { getConceptCardFragment()?.dismiss() } diff --git a/app/src/main/java/org/oppia/app/topic/TopicActivity.kt b/app/src/main/java/org/oppia/app/topic/TopicActivity.kt index a3516c769f4..1d439b46485 100644 --- a/app/src/main/java/org/oppia/app/topic/TopicActivity.kt +++ b/app/src/main/java/org/oppia/app/topic/TopicActivity.kt @@ -53,7 +53,7 @@ class TopicActivity : InjectableAppCompatActivity(), RouteToQuestionPlayerListen } } - override fun dismiss() { + override fun dismissConceptCard() { getConceptCardFragment()?.dismiss() } diff --git a/app/src/main/java/org/oppia/app/topic/conceptcard/ConceptCardFragmentPresenter.kt b/app/src/main/java/org/oppia/app/topic/conceptcard/ConceptCardFragmentPresenter.kt index a0868ded824..19292643495 100644 --- a/app/src/main/java/org/oppia/app/topic/conceptcard/ConceptCardFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/app/topic/conceptcard/ConceptCardFragmentPresenter.kt @@ -31,7 +31,7 @@ class ConceptCardFragmentPresenter @Inject constructor( binding.conceptCardToolbar.setNavigationIcon(R.drawable.ic_close_white_24dp) binding.conceptCardToolbar.setNavigationOnClickListener { - (fragment.requireActivity() as? ConceptCardListener)?.dismiss() + (fragment.requireActivity() as? ConceptCardListener)?.dismissConceptCard() } binding.let { diff --git a/app/src/main/java/org/oppia/app/topic/conceptcard/ConceptCardListener.kt b/app/src/main/java/org/oppia/app/topic/conceptcard/ConceptCardListener.kt index 7901f4006e7..d1aa2d64f89 100644 --- a/app/src/main/java/org/oppia/app/topic/conceptcard/ConceptCardListener.kt +++ b/app/src/main/java/org/oppia/app/topic/conceptcard/ConceptCardListener.kt @@ -3,5 +3,5 @@ package org.oppia.app.topic.conceptcard /** Allows parent activity to dismiss the [ConceptCardFragment] */ interface ConceptCardListener { /** Called when the concept card dialog should be dismissed. */ - fun dismiss() + fun dismissConceptCard() } diff --git a/domain/src/main/assets/fractions_exploration1.json b/domain/src/main/assets/fractions_exploration1.json index b8b43af1509..d589b96f0c2 100644 --- a/domain/src/main/assets/fractions_exploration1.json +++ b/domain/src/main/assets/fractions_exploration1.json @@ -1928,7 +1928,7 @@ "param_changes": [], "feedback": { "content_id": "feedback_3", - "html": "

That's not correct -- it looks like you've forgotten what the numerator and denominator represent. Take a look at the short  to refresh your memory if you need to.

" + "html": "

That's not correct -- it looks like you've forgotten what the numerator and denominator represent. Take a look at the short refresher lesson to refresh your memory if you need to.

" }, "dest": "Matthew gets conned", "refresher_exploration_id": null, diff --git a/utility/src/main/java/org/oppia/util/parser/CustomHtmlContentHandler.kt b/utility/src/main/java/org/oppia/util/parser/CustomHtmlContentHandler.kt new file mode 100644 index 00000000000..9b76348f84d --- /dev/null +++ b/utility/src/main/java/org/oppia/util/parser/CustomHtmlContentHandler.kt @@ -0,0 +1,141 @@ +package org.oppia.util.parser + +import android.text.Editable +import android.text.Html +import android.text.Spannable +import org.xml.sax.Attributes +import org.xml.sax.ContentHandler +import org.xml.sax.Locator +import org.xml.sax.XMLReader + +/** + * A custom [ContentHandler] and [Html.TagHandler] for processing custom HTML tags. This class must be used if a custom + * tag attribute must be parsed. + * + * This is based on the implementation provided in https://stackoverflow.com/a/36528149. + */ +class CustomHtmlContentHandler private constructor( + private val customTagHandlers: Map +): ContentHandler, Html.TagHandler { + private var originalContentHandler: ContentHandler? = null + private var currentTrackedTag: TrackedTag? = null + private var currentTrackedCustomTag: TrackedCustomTag? = null + + override fun endElement(uri: String?, localName: String?, qName: String?) { + originalContentHandler?.endElement(uri, localName, qName) + currentTrackedTag = null + } + + override fun processingInstruction(target: String?, data: String?) { + originalContentHandler?.processingInstruction(target, data) + } + + override fun startPrefixMapping(prefix: String?, uri: String?) { + originalContentHandler?.startPrefixMapping(prefix, uri) + } + + override fun ignorableWhitespace(ch: CharArray?, start: Int, length: Int) { + originalContentHandler?.ignorableWhitespace(ch, start, length) + } + + override fun characters(ch: CharArray?, start: Int, length: Int) { + originalContentHandler?.characters(ch, start, length) + } + + override fun endDocument() { + originalContentHandler?.endDocument() + } + + override fun startElement(uri: String?, localName: String?, qName: String?, atts: Attributes?) { + // Defer custom tag management to the tag handler so that Android's element parsing takes precedence. + currentTrackedTag = TrackedTag(checkNotNull(localName), checkNotNull(atts)) + originalContentHandler?.startElement(uri, localName, qName, atts) + } + + override fun skippedEntity(name: String?) { + originalContentHandler?.skippedEntity(name) + } + + override fun setDocumentLocator(locator: Locator?) { + originalContentHandler?.setDocumentLocator(locator) + } + + override fun endPrefixMapping(prefix: String?) { + originalContentHandler?.endPrefixMapping(prefix) + } + + override fun startDocument() { + originalContentHandler?.startDocument() + } + + override fun handleTag(opening: Boolean, tag: String?, output: Editable?, xmlReader: XMLReader?) { + check(output != null) { "Expected non-null editable." } + when { + originalContentHandler == null -> { + check(tag == "init-custom-handler") { "Expected first custom tag to be initializing the custom handler." } + checkNotNull(xmlReader) { "Expected reader to not be null" } + originalContentHandler = xmlReader.contentHandler + xmlReader.contentHandler = this + } + opening -> { + if (tag in customTagHandlers) { + val localCurrentTrackedTag = currentTrackedTag + check(localCurrentTrackedTag != null) { "Expected tag details to be to be cached for current tag." } + check(localCurrentTrackedTag.tag == tag) { + "Expected tracked tag $currentTrackedTag to match custom tag: $tag" + } + check(currentTrackedCustomTag == null) { "Custom content handler does not support nested custom tags." } + currentTrackedCustomTag = TrackedCustomTag( + localCurrentTrackedTag.tag, localCurrentTrackedTag.attributes, output.length + ) + } + } + tag in customTagHandlers -> { + val localCurrentTrackedCustomTag = currentTrackedCustomTag + check(localCurrentTrackedCustomTag != null) { "Expected custom tag to be initialized tracked." } + check(localCurrentTrackedCustomTag.tag == tag) { + "Expected tracked tag $currentTrackedTag to match custom tag: $tag" + } + val (_, attributes, openTagIndex) = localCurrentTrackedCustomTag + customTagHandlers.getValue(tag).handleTag(attributes, openTagIndex, output.length, output) + } + } + } + + private data class TrackedTag(val tag: String, val attributes: Attributes) + private data class TrackedCustomTag(val tag: String, val attributes: Attributes, val openTagIndex: Int) + + /** Handler interface for a custom tag and its attributes. */ + interface CustomTagHandler { + /** + * Called when a custom tag is encountered. This is always called after the closing tag. + * + * @param attributes The tag's attributes + * @param openIndex The index in the output [Editable] at which this tag begins + * @param closeIndex The index in the output [Editable] at which this tag ends + * @param output The destination [Editable] to which spans can be added + */ + fun handleTag(attributes: Attributes, openIndex: Int, closeIndex: Int, output: Editable) + } + + companion object { + /** + * Returns a new [Spannable] with HTML parsed from [html] using the specified [imageGetter] for handling image + * retrieval, and map of tags to [CustomTagHandler]s for handling custom tags. All possible custom tags must be + * registered in the [customTagHandlers] map. + */ + fun fromHtml( + html: String, imageGetter: Html.ImageGetter, customTagHandlers: Map + ): Spannable { + // Adjust the HTML to allow the custom content handler to properly initialize custom tag tracking. + val adjustedHtml = "$html" + return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) { + Html.fromHtml( + adjustedHtml, Html.FROM_HTML_MODE_LEGACY, imageGetter, CustomHtmlContentHandler(customTagHandlers) + ) as Spannable + } else { + Html.fromHtml(adjustedHtml, imageGetter, CustomHtmlContentHandler(customTagHandlers)) as Spannable + } + } + } +} diff --git a/utility/src/main/java/org/oppia/util/parser/HtmlParser.kt b/utility/src/main/java/org/oppia/util/parser/HtmlParser.kt index 6ee8e0dad30..b44bdbef05d 100755 --- a/utility/src/main/java/org/oppia/util/parser/HtmlParser.kt +++ b/utility/src/main/java/org/oppia/util/parser/HtmlParser.kt @@ -1,8 +1,12 @@ package org.oppia.util.parser -import android.text.Html +import android.text.Editable import android.text.Spannable +import android.text.method.LinkMovementMethod +import android.text.style.ClickableSpan +import android.view.View import android.widget.TextView +import org.xml.sax.Attributes import javax.inject.Inject private const val CUSTOM_IMG_TAG = "oppia-noninteractive-image" @@ -10,41 +14,78 @@ private const val REPLACE_IMG_TAG = "img" private const val CUSTOM_IMG_FILE_PATH_ATTRIBUTE = "filepath-with-value" private const val REPLACE_IMG_FILE_PATH_ATTRIBUTE = "src" +private const val CUSTOM_CONCEPT_CARD_TAG = "oppia-concept-card-link" + /** Html Parser to parse custom Oppia tags with Android-compatible versions. */ class HtmlParser private constructor( private val urlImageParserFactory : UrlImageParser.Factory, private val entityType: String, - private val entityId: String + private val entityId: String, + customOppiaTagActionListener: CustomOppiaTagActionListener? ) { + private val conceptCardTagHandler = ConceptCardTagHandler(customOppiaTagActionListener) /** - * This method replaces custom Oppia tags with Android-compatible versions for a given raw HTML string, and returns the HTML [Spannable]. - * @param rawString rawString argument is the string from the string-content - * @param htmlContentTextView htmlContentTextView argument is the TextView, that need to be passed as argument to ImageGetter class for image parsing - * @return Spannable Spannable represents the styled text. + * Parses a raw HTML string with support for custom Oppia tags. + * + * @param rawString raw HTML to parse + * @param htmlContentTextView the [TextView] that will contain the returned [Spannable] + * @param supportsLinks whether the provided [TextView] should support link forwarding (it's recommended not to use + * this for [TextView]s that are within other layouts that need to support clicking (default false) + * @return a [Spannable] representing the styled text. */ - fun parseOppiaHtml(rawString: String, htmlContentTextView: TextView): Spannable { + fun parseOppiaHtml(rawString: String, htmlContentTextView: TextView, supportsLinks: Boolean = false): Spannable { var htmlContent = rawString if (htmlContent.contains(CUSTOM_IMG_TAG)) { - htmlContent = htmlContent.replace(CUSTOM_IMG_TAG, REPLACE_IMG_TAG, /* ignoreCase= */false) - htmlContent = htmlContent.replace( - CUSTOM_IMG_FILE_PATH_ATTRIBUTE, - REPLACE_IMG_FILE_PATH_ATTRIBUTE, /* ignoreCase= */false - ) + htmlContent = htmlContent.replace(CUSTOM_IMG_TAG, REPLACE_IMG_TAG) + htmlContent = htmlContent.replace( CUSTOM_IMG_FILE_PATH_ATTRIBUTE, REPLACE_IMG_FILE_PATH_ATTRIBUTE) htmlContent = htmlContent.replace("&quot;", "") } + // https://stackoverflow.com/a/8662457 + if (supportsLinks) { + htmlContentTextView.movementMethod = LinkMovementMethod.getInstance() + } + val imageGetter = urlImageParserFactory.create(htmlContentTextView, entityType, entityId) - return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) { - Html.fromHtml(htmlContent, Html.FROM_HTML_MODE_LEGACY, imageGetter, /* tagHandler= */ null) as Spannable - } else { - Html.fromHtml(htmlContent, imageGetter, /* tagHandler= */ null) as Spannable + return CustomHtmlContentHandler.fromHtml( + htmlContent, imageGetter, mapOf(CUSTOM_CONCEPT_CARD_TAG to conceptCardTagHandler) + ) + } + + // https://mohammedlakkadshaw.com/blog/handling-custom-tags-in-android-using-html-taghandler.html/ + private class ConceptCardTagHandler( + private val customOppiaTagActionListener: CustomOppiaTagActionListener? + ): CustomHtmlContentHandler.CustomTagHandler { + override fun handleTag(attributes: Attributes, openIndex: Int, closeIndex: Int, output: Editable) { + val skillId = attributes.getValue("skill-id") + output.setSpan(object : ClickableSpan() { + override fun onClick(view: View) { + customOppiaTagActionListener?.onConceptCardLinkClicked(view, skillId) + } + }, openIndex, closeIndex, Spannable.SPAN_INCLUSIVE_EXCLUSIVE) } } + /** Listener that's called when a custom tag triggers an event. */ + interface CustomOppiaTagActionListener { + /** + * Called when an embedded concept card link is clicked in the specified view with the skillId corresponding to the + * card that should be shown. + */ + fun onConceptCardLinkClicked(view: View, skillId: String) + } + + /** Factory for creating new [HtmlParser]s. */ class Factory @Inject constructor(private val urlImageParserFactory: UrlImageParser.Factory) { - fun create(entityType: String, entityId: String): HtmlParser { - return HtmlParser(urlImageParserFactory, entityType, entityId) + /** + * Returns a new [HtmlParser] with the specified entity type and ID for loading images, and an optionally specified + * [CustomOppiaTagActionListener] for handling custom Oppia tag events. + */ + fun create( + entityType: String, entityId: String, customOppiaTagActionListener: CustomOppiaTagActionListener? = null + ): HtmlParser { + return HtmlParser(urlImageParserFactory, entityType, entityId, customOppiaTagActionListener) } } } From 170b16b711282ab97b91407d4b98551ca3c27dcd Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Mon, 10 Aug 2020 11:17:02 -0700 Subject: [PATCH 02/36] Introduce test coroutine dispatchers support in Espresso. This piggybacks off of the solution introduced in #1276 for Robolectric. That PR allows Robolectric tests in app module to share dependencies with production components by creating a test application & telling Robolectric to use it instead of OppiaApplication via a @Config annotation. This PR achieves the same thing by using a custom test runner that reads the same annotation and forces Espresso & instrumentation to use the test application instead of the default OppiaApplication. This setup will be easier once #59 is finished since we can specify the application in per-test manifests that both Robolectric & Espresso will respect. Note that while this lets the same test coroutine dispatchers work in both production & test code for Espresso, some additional work was needed to ensure the coroutines behave correctly. In particular, the coroutines use a fake system clock in Robolectric that requires explicit synchronization points in the tests to allow the clock to move forward & properly coordinate execution between background & main thread operations. However, in Espresso, since everything is using a real clock an idling resource is the preferred way to synchronize execution: it allows the background coroutines to operate in real-time much like they would in production, and then notify Espresso when completed. The test dispatchers API is now setup to support both synchronization mechanisms for both Robolectric & Espresso (the idling resource does nothing on Robolectric and the force synchronization effectively does nothing on Espresso). The first test being demonstrated as now stable is SplashActivityTest (as part of downstream work in #1397. --- app/build.gradle | 3 +- .../oppia/app/splash/SplashActivityTest.kt | 222 ++++++++--------- testing/build.gradle | 2 + .../java/org/oppia/testing/FakeSystemClock.kt | 90 ++++++- .../java/org/oppia/testing/OppiaTestRunner.kt | 98 ++++++++ .../oppia/testing/TestCoroutineDispatcher.kt | 208 +--------------- .../TestCoroutineDispatcherEspressoImpl.kt | 164 +++++++++++++ .../TestCoroutineDispatcherRobolectricImpl.kt | 232 ++++++++++++++++++ .../oppia/testing/TestCoroutineDispatchers.kt | 123 +--------- .../TestCoroutineDispatchersEspressoImpl.kt | 104 ++++++++ ...TestCoroutineDispatchersRobolectricImpl.kt | 174 +++++++++++++ .../org/oppia/testing/TestDispatcherModule.kt | 36 +++ .../org/oppia/testing/TestingQualifiers.kt | 6 + 13 files changed, 1024 insertions(+), 438 deletions(-) create mode 100644 testing/src/main/java/org/oppia/testing/OppiaTestRunner.kt create mode 100644 testing/src/main/java/org/oppia/testing/TestCoroutineDispatcherEspressoImpl.kt create mode 100644 testing/src/main/java/org/oppia/testing/TestCoroutineDispatcherRobolectricImpl.kt create mode 100644 testing/src/main/java/org/oppia/testing/TestCoroutineDispatchersEspressoImpl.kt create mode 100644 testing/src/main/java/org/oppia/testing/TestCoroutineDispatchersRobolectricImpl.kt create mode 100644 testing/src/main/java/org/oppia/testing/TestingQualifiers.kt diff --git a/app/build.gradle b/app/build.gradle index 6fe382cc8fd..8f4eb695a6d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -15,7 +15,7 @@ android { versionCode 1 versionName "1.0" multiDexEnabled true - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunner "org.oppia.testing.OppiaTestRunner" // https://developer.android.com/training/testing/junit-runner#ato-gradle testInstrumentationRunnerArguments clearPackageData: 'true' javaCompileOptions { @@ -117,7 +117,6 @@ dependencies { 'androidx.test.espresso:espresso-core:3.2.0', 'androidx.test.espresso:espresso-intents:3.1.0', 'androidx.test.ext:junit:1.1.1', - 'androidx.test:runner:1.2.0', 'com.google.truth:truth:0.43', 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.2.2', 'org.mockito:mockito-android:2.7.22', diff --git a/app/src/sharedTest/java/org/oppia/app/splash/SplashActivityTest.kt b/app/src/sharedTest/java/org/oppia/app/splash/SplashActivityTest.kt index 3bf6a1c9b92..6112f01c90a 100644 --- a/app/src/sharedTest/java/org/oppia/app/splash/SplashActivityTest.kt +++ b/app/src/sharedTest/java/org/oppia/app/splash/SplashActivityTest.kt @@ -1,47 +1,61 @@ package org.oppia.app.splash import android.app.Application +import android.app.Instrumentation import android.content.Context -import android.os.Handler -import android.os.Looper -import androidx.test.core.app.ActivityScenario.launch +import androidx.appcompat.app.AppCompatActivity import androidx.test.core.app.ApplicationProvider -import androidx.test.espresso.Espresso.onIdle -import androidx.test.espresso.IdlingRegistry -import androidx.test.espresso.idling.CountingIdlingResource import androidx.test.espresso.intent.Intents import androidx.test.espresso.intent.Intents.intended import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.ActivityTestRule import com.google.firebase.FirebaseApp import dagger.BindsInstance import dagger.Component import dagger.Module import dagger.Provides -import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.asCoroutineDispatcher -import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.InternalCoroutinesApi import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import org.oppia.app.activity.ActivityComponent +import org.oppia.app.application.ActivityComponentFactory +import org.oppia.app.application.ApplicationComponent +import org.oppia.app.application.ApplicationModule import org.oppia.app.onboarding.OnboardingActivity import org.oppia.app.profile.ProfileActivity +import org.oppia.data.backends.gae.NetworkModule +import org.oppia.domain.classify.InteractionsModule +import org.oppia.domain.classify.rules.continueinteraction.ContinueModule +import org.oppia.domain.classify.rules.dragAndDropSortInput.DragDropSortInputModule +import org.oppia.domain.classify.rules.fractioninput.FractionInputModule +import org.oppia.domain.classify.rules.imageClickInput.ImageClickInputModule +import org.oppia.domain.classify.rules.itemselectioninput.ItemSelectionInputModule +import org.oppia.domain.classify.rules.multiplechoiceinput.MultipleChoiceInputModule +import org.oppia.domain.classify.rules.numberwithunits.NumberWithUnitsRuleModule +import org.oppia.domain.classify.rules.numericinput.NumericInputRuleModule +import org.oppia.domain.classify.rules.textinput.TextInputRuleModule import org.oppia.domain.onboarding.OnboardingFlowController +import org.oppia.domain.oppialogger.LogStorageModule +import org.oppia.domain.question.QuestionModule +import org.oppia.testing.TestAccessibilityModule +import org.oppia.testing.TestCoroutineDispatchers +import org.oppia.testing.TestDispatcherModule import org.oppia.testing.TestLogReportingModule -import org.oppia.util.logging.EnableConsoleLog -import org.oppia.util.logging.EnableFileLog -import org.oppia.util.logging.GlobalLogLevel -import org.oppia.util.logging.LogLevel -import org.oppia.util.threading.BackgroundDispatcher -import org.oppia.util.threading.BlockingDispatcher -import java.util.concurrent.AbstractExecutorService -import java.util.concurrent.TimeUnit +import org.oppia.util.caching.CacheAssetsLocally +import org.oppia.util.gcsresource.GcsResourceModule +import org.oppia.util.logging.LoggerModule +import org.oppia.util.parser.GlideImageLoaderModule +import org.oppia.util.parser.HtmlParserEntityTypeModule +import org.oppia.util.parser.ImageParsingModule +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode import javax.inject.Inject -import javax.inject.Qualifier import javax.inject.Singleton /** @@ -49,21 +63,25 @@ import javax.inject.Singleton * https://jabknowsnothing.wordpress.com/2015/11/05/activitytestrule-espressos-test-lifecycle/. */ @RunWith(AndroidJUnit4::class) +@Config(application = SplashActivityTest.TestApplication::class, qualifiers = "port-xxhdpi") +@LooperMode(LooperMode.Mode.PAUSED) class SplashActivityTest { @Inject lateinit var context: Context + @InternalCoroutinesApi @Inject lateinit var testCoroutineDispatchers: TestCoroutineDispatchers @Before + @ExperimentalCoroutinesApi + @InternalCoroutinesApi fun setUp() { Intents.init() - IdlingRegistry.getInstance().register(MainThreadExecutor.countingResource) - simulateNewAppInstance() - FirebaseApp.initializeApp(context) } @After + @ExperimentalCoroutinesApi + @InternalCoroutinesApi fun tearDown() { - IdlingRegistry.getInstance().unregister(MainThreadExecutor.countingResource) + testCoroutineDispatchers.unregisterIdlingResource() Intents.release() } @@ -77,91 +95,76 @@ class SplashActivityTest { ) @Test + @ExperimentalCoroutinesApi + @InternalCoroutinesApi fun testSplashActivity_initialOpen_routesToOnboardingActivity() { + initializeTestApplication() + activityTestRule.launchActivity(null) + testCoroutineDispatchers.advanceUntilIdle() + intended(hasComponent(OnboardingActivity::class.java.name)) } @Test + @ExperimentalCoroutinesApi + @InternalCoroutinesApi fun testSplashActivity_secondOpen_routesToChooseProfileActivity() { simulateAppAlreadyOnboarded() - launch(SplashActivity::class.java).use { - intended(hasComponent(ProfileActivity::class.java.name)) - } - } + initializeTestApplication() + + activityTestRule.launchActivity(null) + testCoroutineDispatchers.advanceUntilIdle() - private fun simulateNewAppInstance() { - // Simulate a fresh app install by clearing any potential on-disk caches using an isolated onboarding flow controller. - createTestRootComponent() - onIdle() + intended(hasComponent(ProfileActivity::class.java.name)) } + @InternalCoroutinesApi + @ExperimentalCoroutinesApi private fun simulateAppAlreadyOnboarded() { - // Simulate the app was already onboarded by creating an isolated onboarding flow controller and saving the onboarding status - // on the system before the activity is opened. - createTestRootComponent().getOnboardingFlowController().markOnboardingFlowCompleted() - onIdle() + // Simulate the app was already onboarded by creating an isolated onboarding flow controller and + // saving the onboarding status on the system before the activity is opened. Note that this has + // to be done in an isolated test application since the test application of this class shares + // state with production code under test. The isolated test application must be created through + // Instrumentation to ensure it's properly attached. + val testApplication = Instrumentation.newApplication( + TestApplication::class.java, + InstrumentationRegistry.getInstrumentation().targetContext + ) as TestApplication + testApplication.getOnboardingFlowController().markOnboardingFlowCompleted() + testApplication.getTestCoroutineDispatchers().advanceUntilIdle() } - private fun createTestRootComponent(): TestApplicationComponent { - return DaggerSplashActivityTest_TestApplicationComponent.builder() - .setApplication(ApplicationProvider.getApplicationContext()) - .build() + @ExperimentalCoroutinesApi + @InternalCoroutinesApi + private fun initializeTestApplication() { + ApplicationProvider.getApplicationContext().inject(this) + testCoroutineDispatchers.registerIdlingResource() + FirebaseApp.initializeApp(context) } - @Qualifier - annotation class TestDispatcher - @Module class TestModule { + // Do not use caching to ensure URLs are always used as the main data source when loading audio. @Provides - @Singleton - fun provideContext(application: Application): Context { - return application - } - - @ExperimentalCoroutinesApi - @Singleton - @Provides - @TestDispatcher - fun provideTestDispatcher(): CoroutineDispatcher { - return TestCoroutineDispatcher() - } - - @Singleton - @Provides - @BackgroundDispatcher - fun provideBackgroundDispatcher( - @TestDispatcher testDispatcher: CoroutineDispatcher - ): CoroutineDispatcher { - return testDispatcher - } - - // TODO(#59): Either isolate these to their own shared test module, or use the real logging - // module in tests to avoid needing to specify these settings for tests. - @EnableConsoleLog - @Provides - fun provideEnableConsoleLog(): Boolean = true - - @EnableFileLog - @Provides - fun provideEnableFileLog(): Boolean = false - - @GlobalLogLevel - @Provides - fun provideGlobalLogLevel(): LogLevel = LogLevel.VERBOSE - - @Singleton - @Provides - @BlockingDispatcher - fun provideBlockingDispatcher(): CoroutineDispatcher { - return MainThreadExecutor.asCoroutineDispatcher() - } + @CacheAssetsLocally + fun provideCacheAssetsLocally(): Boolean = false } @Singleton - @Component(modules = [TestModule::class, TestLogReportingModule::class]) - interface TestApplicationComponent { + @Component( + modules = [ + TestModule::class, TestDispatcherModule::class, ApplicationModule::class, + NetworkModule::class, LoggerModule::class, ContinueModule::class, FractionInputModule::class, + ItemSelectionInputModule::class, MultipleChoiceInputModule::class, + NumberWithUnitsRuleModule::class, NumericInputRuleModule::class, TextInputRuleModule::class, + DragDropSortInputModule::class, ImageClickInputModule::class, InteractionsModule::class, + GcsResourceModule::class, GlideImageLoaderModule::class, ImageParsingModule::class, + HtmlParserEntityTypeModule::class, QuestionModule::class, TestLogReportingModule::class, + TestAccessibilityModule::class, LogStorageModule::class + ] + ) + interface TestApplicationComponent: ApplicationComponent { @Component.Builder interface Builder { @BindsInstance @@ -171,48 +174,31 @@ class SplashActivityTest { } fun getOnboardingFlowController(): OnboardingFlowController + + @InternalCoroutinesApi + fun getTestCoroutineDispatchers(): TestCoroutineDispatchers + fun inject(splashActivityTest: SplashActivityTest) } - // TODO(#59): Move this to a general-purpose testing library that replaces all CoroutineExecutors with an - // Espresso-enabled executor service. This service should also allow for background threads to run in both Espresso - // and Robolectric to help catch potential race conditions, rather than forcing parallel execution to be sequential - // and immediate. - // NB: This also blocks on #59 to be able to actually create a test-only library. - /** - * An executor service that schedules all [Runnable]s to run asynchronously on the main thread. This is based on: - * https://android.googlesource.com/platform/packages/apps/TV/+/android-live-tv/src/com/android/tv/util/MainThreadExecutor.java. - */ - private object MainThreadExecutor : AbstractExecutorService() { - override fun isTerminated(): Boolean = false - - private val handler = Handler(Looper.getMainLooper()) - val countingResource = - CountingIdlingResource("main_thread_executor_counting_idling_resource") - - override fun execute(command: Runnable?) { - countingResource.increment() - handler.post { - try { - command?.run() - } finally { - countingResource.decrement() - } - } + class TestApplication : Application(), ActivityComponentFactory { + private val component: TestApplicationComponent by lazy { + DaggerSplashActivityTest_TestApplicationComponent.builder() + .setApplication(this) + .build() } - override fun shutdown() { - throw UnsupportedOperationException() + fun inject(splashActivityTest: SplashActivityTest) { + component.inject(splashActivityTest) } - override fun shutdownNow(): MutableList { - throw UnsupportedOperationException() - } + fun getOnboardingFlowController() = component.getOnboardingFlowController() - override fun isShutdown(): Boolean = false + @InternalCoroutinesApi + fun getTestCoroutineDispatchers() = component.getTestCoroutineDispatchers() - override fun awaitTermination(timeout: Long, unit: TimeUnit?): Boolean { - throw UnsupportedOperationException() + override fun createActivityComponent(activity: AppCompatActivity): ActivityComponent { + return component.getActivityComponentBuilderProvider().get().setActivity(activity).build() } } } diff --git a/testing/build.gradle b/testing/build.gradle index adbe745f13d..0bac1facdc5 100644 --- a/testing/build.gradle +++ b/testing/build.gradle @@ -28,6 +28,8 @@ dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) implementation( 'androidx.annotation:annotation:1.1.0', + 'androidx.test.espresso:espresso-core:3.2.0', + 'androidx.test:runner:1.2.0', 'com.google.dagger:dagger:2.24', 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.2.2', 'org.robolectric:robolectric:4.3', diff --git a/testing/src/main/java/org/oppia/testing/FakeSystemClock.kt b/testing/src/main/java/org/oppia/testing/FakeSystemClock.kt index d345d398b8e..eba1f8f0074 100644 --- a/testing/src/main/java/org/oppia/testing/FakeSystemClock.kt +++ b/testing/src/main/java/org/oppia/testing/FakeSystemClock.kt @@ -1,7 +1,7 @@ package org.oppia.testing import android.os.SystemClock -import org.robolectric.Robolectric +import java.lang.reflect.Method import java.util.concurrent.atomic.AtomicLong import javax.inject.Inject import javax.inject.Singleton @@ -13,12 +13,13 @@ import javax.inject.Singleton * consistent way. */ @Singleton -class FakeSystemClock @Inject constructor() { +class FakeSystemClock @Inject constructor(@IsOnRobolectric private val isOnRobolectric: Boolean) { + private val timeCoordinator by lazy { TimeCoordinator.retrieveTimeCoordinator(isOnRobolectric) } private val currentTimeMillis: AtomicLong init { - val initialMillis = Robolectric.getForegroundThreadScheduler().currentTime - SystemClock.setCurrentTimeMillis(initialMillis) + val initialMillis = timeCoordinator.getCurrentTime() + timeCoordinator.setCurrentTime(initialMillis) currentTimeMillis = AtomicLong(initialMillis) } @@ -33,8 +34,85 @@ class FakeSystemClock @Inject constructor() { */ fun advanceTime(millis: Long): Long { val newTime = currentTimeMillis.addAndGet(millis) - Robolectric.getForegroundThreadScheduler().advanceTo(newTime) - SystemClock.setCurrentTimeMillis(newTime) + timeCoordinator.advanceTimeTo(newTime) return newTime } + + private sealed class TimeCoordinator { + abstract fun getCurrentTime(): Long + + abstract fun advanceTimeTo(timeMillis: Long) + + abstract fun setCurrentTime(timeMillis: Long) + + internal companion object { + internal fun retrieveTimeCoordinator(isOnRobolectric: Boolean): TimeCoordinator { + return if (isOnRobolectric) { + RobolectricTimeCoordinator + } else { + EspressoTimeCoordinator + } + } + } + + private object RobolectricTimeCoordinator : TimeCoordinator() { + private val robolectricClass by lazy { loadRobolectricClass() } + private val foregroundScheduler by lazy { loadForegroundScheduler() } + private val retrieveCurrentTimeMethod by lazy { loadRetrieveCurrentTimeMethod() } + private val retrieveAdvanceToMethod by lazy { loadAdvanceToMethod() } + + override fun getCurrentTime(): Long { + return retrieveCurrentTimeMethod.invoke(foregroundScheduler) as Long + } + + override fun advanceTimeTo(timeMillis: Long) { + retrieveAdvanceToMethod.invoke(foregroundScheduler, timeMillis) + setCurrentTime(timeMillis) + } + + override fun setCurrentTime(timeMillis: Long) { + SystemClock.setCurrentTimeMillis(timeMillis) + } + + private fun loadRobolectricClass(): Class<*> { + val classLoader = FakeSystemClock::class.java.classLoader!! + return classLoader.loadClass("org.robolectric.Robolectric") + } + + private fun loadForegroundScheduler(): Any { + val retrieveSchedulerMethod = + robolectricClass.getDeclaredMethod("getForegroundThreadScheduler") + return retrieveSchedulerMethod.invoke(/* obj= */ null) + } + + private fun loadRetrieveCurrentTimeMethod(): Method { + val schedulerClass = foregroundScheduler.javaClass + return schedulerClass.getDeclaredMethod("getCurrentTime") + } + + private fun loadAdvanceToMethod(): Method { + val schedulerClass = foregroundScheduler.javaClass + return schedulerClass.getDeclaredMethod("advanceTo", Long::class.java) + } + } + + private object EspressoTimeCoordinator : TimeCoordinator() { + override fun getCurrentTime(): Long { + // Assume that time remains fixed. + return 0 + } + + override fun advanceTimeTo(timeMillis: Long) { + // Espresso runs in real-time. Delays don't actually work in the same way. Callers should + // make use of idling resource to properly communicate to Espresso when coroutines have + // finished executing. + } + + override fun setCurrentTime(timeMillis: Long) { + // Don't override the system time on Espresso since devices require apps to have special + // permissions to do so. It's also unnecessary since the coroutine dispatchers only need to + // synchronize on the fake clock's internal time. + } + } + } } diff --git a/testing/src/main/java/org/oppia/testing/OppiaTestRunner.kt b/testing/src/main/java/org/oppia/testing/OppiaTestRunner.kt new file mode 100644 index 00000000000..c38e104c6e8 --- /dev/null +++ b/testing/src/main/java/org/oppia/testing/OppiaTestRunner.kt @@ -0,0 +1,98 @@ +package org.oppia.testing + +import android.app.Application +import android.content.Context +import android.os.Bundle +import androidx.test.runner.AndroidJUnitRunner +import java.lang.reflect.Field + +// TODO(#59): Remove this runner once application classes can be specified per-test suite. +/** + * Custom test runner for Oppia's AndroidX tests to facilitate loading custom Dagger applications in + * a way that's interoperable with Espresso. + * + * Loosely based on https://stackoverflow.com/a/42541784 and https://stackoverflow.com/a/36778841. + * + * This runner will load the default OppiaApplication that the production app uses unless the test + * declares a Robolectric @Config annotation specifying the test class that should be used, instead. + * This allows tests to declare a test application class to be used in both Espresso & Robolectric + * contexts with the same code declaration. + */ +@Suppress("unused") // This class is used directly by Gradle during instrumentation test setup. +class OppiaTestRunner: AndroidJUnitRunner() { + private lateinit var applicationClassLoader: ClassLoader + private lateinit var application: Application + + override fun onCreate(arguments: Bundle?) { + // Load a new application if it's different than the original. + val bindApplication = retrieveTestApplicationName(arguments?.getString("class"))?.let { + newApplication(applicationClassLoader, it, targetContext) + } ?: targetContext.applicationContext as Application + + // Ensure the bound application is forcibly overwritten in the target context, and used + // subsequently throughout the runner since it's replacing the previous application. + overrideApplicationInContext(targetContext, bindApplication) + application = bindApplication + + super.onCreate(arguments) + } + + override fun callApplicationOnCreate(app: Application?) { + // Use the overridden application, instead. + super.callApplicationOnCreate(application) + } + + override fun newApplication( + cl: ClassLoader?, + className: String?, + context: Context? + ): Application { + applicationClassLoader = checkNotNull(cl) { + "Expected non-null class loader to be passed to newApplication" + } + return super.newApplication(cl, className, context) + } + + @Suppress("UNCHECKED_CAST") + private fun retrieveTestApplicationName(className: String?): String? { + val testClassName = className?.substringBefore('#') + val classLoader = OppiaTestRunner::class.java.classLoader!! + val testClass = classLoader.loadClass(testClassName) + val configClass = + classLoader.loadClass("org.robolectric.annotation.Config") as Class + val configAnnotation = testClass.getAnnotation(configClass) + // Only consider overriding the application if it's defined via a Robolectric configuration (to + // have parity with the Robolectric version of the test). + if (configAnnotation != null) { + val applicationMethod = configClass.getDeclaredMethod("application") + val applicationClass = applicationMethod.invoke(configAnnotation) as Class<*> + val defaultApplicationClass = + classLoader.loadClass("org.robolectric.annotation.DefaultApplication") + if (!defaultApplicationClass.isAssignableFrom(applicationClass)) { + // Only consider taking the test application if it's been defined, otherwise use the default + // for Espresso. + return applicationClass.name + } + } + return null + } + + private fun overrideApplicationInContext(context: Context, application: Application) { + val packageInfo = getPrivateFieldFromObject(context, "mPackageInfo") + setPrivateFieldFromObject(packageInfo, "mApplication", application) + } + + private fun getPrivateFieldFromObject(container: Any, fieldName: String): Any { + return retrieveAccessibleFieldFromObject(container, fieldName).get(container) + } + + private fun setPrivateFieldFromObject(container: Any, fieldName: String, newValue: Any) { + retrieveAccessibleFieldFromObject(container, fieldName).set(container, newValue) + } + + private fun retrieveAccessibleFieldFromObject(container: Any, fieldName: String): Field { + return container.javaClass.getDeclaredField(fieldName).apply { + isAccessible = true + } + } +} diff --git a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcher.kt b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcher.kt index 777f8ae9b5d..246f0b57d84 100644 --- a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcher.kt +++ b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcher.kt @@ -1,216 +1,34 @@ package org.oppia.testing; -import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Delay import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.InternalCoroutinesApi -import kotlinx.coroutines.Runnable import kotlinx.coroutines.test.DelayController -import kotlinx.coroutines.test.UncompletedCoroutinesError -import java.util.TreeSet -import java.util.concurrent.CopyOnWriteArraySet -import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicInteger -import javax.inject.Inject -import kotlin.Comparator -import kotlin.coroutines.CoroutineContext -// TODO(#89): Audit & adjust the thread safety of this class, and determine if there's a way to move -// off of the internal coroutine API. - -/** - * Replacement for Kotlin's test coroutine dispatcher that can be used to replace coroutine - * dispatching functionality in a Robolectric test in a way that can be coordinated across multiple - * dispatchers for execution synchronization. - * - * Developers should never use this dispatcher directly. Integrating with it should be done via - * [TestDispatcherModule] and ensuring thread synchronization should be done via - * [TestCoroutineDispatchers]. Attempting to interact directly with this dispatcher may cause timing - * inconsistencies between the UI thread and other application coroutine dispatchers. - */ @InternalCoroutinesApi -@Suppress("EXPERIMENTAL_API_USAGE") -class TestCoroutineDispatcher private constructor( - private val fakeSystemClock: FakeSystemClock, - private val realCoroutineDispatcher: CoroutineDispatcher -): CoroutineDispatcher(), Delay, DelayController { - - /** Sorted set that first sorts on when a task should be executed, then insertion order. */ - private val taskQueue = CopyOnWriteArraySet() - private val isRunning = AtomicBoolean(true) - private val executingTaskCount = AtomicInteger(0) - private val totalTaskCount = AtomicInteger(0) - - @ExperimentalCoroutinesApi - override val currentTime: Long - get() = fakeSystemClock.getTimeMillis() - - override fun dispatch(context: CoroutineContext, block: Runnable) { - enqueueTask(createDeferredRunnable(context, block)) - } - - override fun scheduleResumeAfterDelay( - timeMillis: Long, - continuation: CancellableContinuation - ) { - enqueueTask(createContinuationRunnable(continuation), delayMillis = timeMillis) - } - - @ExperimentalCoroutinesApi - override fun advanceTimeBy(delayTimeMillis: Long): Long { - flushTaskQueue(fakeSystemClock.advanceTime(delayTimeMillis)) - return delayTimeMillis - } - - @ExperimentalCoroutinesApi - override fun advanceUntilIdle(): Long { - throw UnsupportedOperationException( - "Use TestCoroutineDispatchers.advanceUntilIdle() to ensure the dispatchers are properly " + - "coordinated" - ) - } - - @ExperimentalCoroutinesApi - override fun cleanupTestCoroutines() { - flushTaskQueue(fakeSystemClock.getTimeMillis()) - val remainingTaskCount = taskQueue.size - if (remainingTaskCount != 0) { - throw UncompletedCoroutinesError( - "Expected no remaining tasks for test dispatcher, but found $remainingTaskCount" - ) - } - } - - @ExperimentalCoroutinesApi - override fun pauseDispatcher() { - isRunning.set(false) - } - - @ExperimentalCoroutinesApi - override suspend fun pauseDispatcher(block: suspend () -> Unit) { - // There's not a clear way to handle this block while maintaining the thread of the dispatcher, - // so disable it for now until it's later needed. - throw UnsupportedOperationException() - } - - @ExperimentalCoroutinesApi - override fun resumeDispatcher() { - isRunning.set(true) - flushTaskQueue(fakeSystemClock.getTimeMillis()) - } - - @ExperimentalCoroutinesApi - override fun runCurrent() { - flushTaskQueue(fakeSystemClock.getTimeMillis()) - } - - internal fun hasPendingTasks(): Boolean = taskQueue.isNotEmpty() +@ExperimentalCoroutinesApi +abstract class TestCoroutineDispatcher: CoroutineDispatcher(), Delay, DelayController { + abstract fun hasPendingTasks(): Boolean /** * Returns the clock time at which the next future task will execute ('future' indicates that the * task cannot execute right now due to its execution time being in the future). */ - internal fun getNextFutureTaskCompletionTimeMillis(timeMillis: Long): Long? { - return createSortedTaskSet().firstOrNull { task -> task.timeMillis > timeMillis }?.timeMillis - } - - internal fun hasPendingCompletableTasks(): Boolean { - return taskQueue.hasPendingCompletableTasks(fakeSystemClock.getTimeMillis()) - } + abstract fun getNextFutureTaskCompletionTimeMillis(timeMillis: Long): Long? + abstract fun hasPendingCompletableTasks(): Boolean - private fun enqueueTask(block: Runnable, delayMillis: Long = 0L) { - taskQueue += Task( - timeMillis = fakeSystemClock.getTimeMillis() + delayMillis, - block = block, - insertionOrder = totalTaskCount.incrementAndGet() - ) - } + abstract fun setTaskIdleListener(taskIdleListener: TaskIdleListener) - @Suppress("ControlFlowWithEmptyBody") - private fun flushTaskQueue(currentTimeMillis: Long) { - // TODO(#89): Add timeout support so that the dispatcher can't effectively deadlock or livelock - // for inappropriately behaved tests. - while (isRunning.get()) { - if (!flushActiveTaskQueue(currentTimeMillis)) { - break - } - } - while (executingTaskCount.get() > 0) {} - } - - /** Flushes the current task queue and returns whether any tasks were executed. */ - private fun flushActiveTaskQueue(currentTimeMillis: Long): Boolean { - if (isTaskQueueActive(currentTimeMillis)) { - // Create a copy of the task queue in case it's changed during modification. - val tasksToRemove = createSortedTaskSet().filter { task -> - if (isRunning.get()) { - if (task.timeMillis <= currentTimeMillis) { - // Only remove the task if it was executed. - task.block.run() - return@filter true - } - } - return@filter false - } - return taskQueue.removeAll(tasksToRemove) - } - return false - } + interface TaskIdleListener { + // Can be called on different threads. + fun onDispatcherRunning() - private fun isTaskQueueActive(currentTimeMillis: Long): Boolean { - return taskQueue.hasPendingCompletableTasks(currentTimeMillis) || executingTaskCount.get() != 0 + // Can be called on different threads. + fun onDispatcherIdle() } - private fun createDeferredRunnable(context: CoroutineContext, block: Runnable): Runnable { - return Runnable { - executingTaskCount.incrementAndGet() - realCoroutineDispatcher.dispatch(context, Runnable { - try { - block.run() - } finally { - executingTaskCount.decrementAndGet() - } - }) - } + interface Factory { + fun createDispatcher(realDispatcher: CoroutineDispatcher): TestCoroutineDispatcher } - - private fun createContinuationRunnable(continuation: CancellableContinuation): Runnable { - val block: CancellableContinuation.() -> Unit = { - realCoroutineDispatcher.resumeUndispatched(Unit) - } - return Runnable { - try { - executingTaskCount.incrementAndGet() - continuation.block() - } finally { - executingTaskCount.decrementAndGet() - } - } - } - - private fun createSortedTaskSet(): Set { - val sortedSet = TreeSet( - Comparator.comparingLong(Task::timeMillis) - .thenComparing(Task::insertionOrder) - ) - sortedSet.addAll(taskQueue) - return sortedSet - } - - class Factory @Inject constructor(private val fakeSystemClock: FakeSystemClock) { - fun createDispatcher(realDispatcher: CoroutineDispatcher): TestCoroutineDispatcher { - return TestCoroutineDispatcher(fakeSystemClock, realDispatcher) - } - } -} - -private data class Task( - internal val block: Runnable, - internal val timeMillis: Long, - internal val insertionOrder: Int -) - -private fun CopyOnWriteArraySet.hasPendingCompletableTasks(currentTimeMilis: Long): Boolean { - return any { task -> task.timeMillis <= currentTimeMilis } } diff --git a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcherEspressoImpl.kt b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcherEspressoImpl.kt new file mode 100644 index 00000000000..3d0cef775ed --- /dev/null +++ b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcherEspressoImpl.kt @@ -0,0 +1,164 @@ +package org.oppia.testing + +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.UncompletedCoroutinesError +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger +import javax.inject.Inject +import kotlin.coroutines.CoroutineContext +import kotlin.math.min + +@InternalCoroutinesApi +@ExperimentalCoroutinesApi +class TestCoroutineDispatcherEspressoImpl private constructor( + private val realCoroutineDispatcher: CoroutineDispatcher +): TestCoroutineDispatcher() { + + private val realCoroutineScope by lazy { CoroutineScope(realCoroutineDispatcher) } + private val executingTaskCount = AtomicInteger(0) + private val totalTaskCount = AtomicInteger(0) + /** Map of task ID (based on [totalTaskCount]) to the time in millis when that task will run. */ + private val taskCompletionTimes = ConcurrentHashMap() + private var taskIdleListener: TaskIdleListener? = null + + @ExperimentalCoroutinesApi + override val currentTime: Long + get() = System.currentTimeMillis() + + override fun dispatch(context: CoroutineContext, block: Runnable) { + val taskId = totalTaskCount.incrementAndGet() + taskCompletionTimes[taskId] = currentTime + + // Tasks immediately will start running, so track the task immediately. + executingTaskCount.incrementAndGet() + notifyIfRunning() + realCoroutineDispatcher.dispatch(context, kotlinx.coroutines.Runnable { + try { + block.run() + } finally { + executingTaskCount.decrementAndGet() + taskCompletionTimes.remove(taskId) + } + notifyIfIdle() + }) + } + + override fun scheduleResumeAfterDelay( + timeMillis: Long, + continuation: CancellableContinuation + ) { + val taskId = totalTaskCount.incrementAndGet() + taskCompletionTimes[taskId] = timeMillis + val block: CancellableContinuation.() -> Unit = { + realCoroutineDispatcher.resumeUndispatched(Unit) + } + + // Treat the continuation as a delayed dispatch. Even though it's executing in the future, it + // should be assumed to be 'running' since the dispatcher is executing tasks in real-time. + executingTaskCount.incrementAndGet() + notifyIfRunning() + val delayResult = realCoroutineScope.async { + delay(timeMillis) + } + delayResult.invokeOnCompletion { + try { + continuation.block() + } finally { + executingTaskCount.decrementAndGet() + taskCompletionTimes.remove(taskId) + } + notifyIfIdle() + } + } + + @ExperimentalCoroutinesApi + override fun advanceTimeBy(delayTimeMillis: Long): Long { + throw UnsupportedOperationException("Cannot advance time for real-time dispatchers") + } + + @ExperimentalCoroutinesApi + override fun advanceUntilIdle(): Long { + throw UnsupportedOperationException( + "Use TestCoroutineDispatchers.advanceUntilIdle() to ensure the dispatchers are properly " + + "coordinated" + ) + } + + @ExperimentalCoroutinesApi + override fun cleanupTestCoroutines() { + val remainingTaskCount = executingTaskCount.get() + if (remainingTaskCount != 0) { + throw UncompletedCoroutinesError( + "Expected no remaining tasks for test dispatcher, but found $remainingTaskCount" + ) + } + } + + @ExperimentalCoroutinesApi + override fun pauseDispatcher() { + throw UnsupportedOperationException("Real-time dispatchers cannot be paused/resumed") + } + + @ExperimentalCoroutinesApi + override suspend fun pauseDispatcher(block: suspend () -> Unit) { + // There's not a clear way to handle this block while maintaining the thread of the dispatcher, + // so disable it for now until it's later needed. + throw UnsupportedOperationException() + } + + @ExperimentalCoroutinesApi + override fun resumeDispatcher() { + throw UnsupportedOperationException("Real-time dispatchers cannot be paused/resumed") + } + + @ExperimentalCoroutinesApi + override fun runCurrent() { + // Nothing to do; the queue is always continuously running. + } + + override fun hasPendingTasks(): Boolean = executingTaskCount.get() != 0 + + override fun getNextFutureTaskCompletionTimeMillis(timeMillis: Long): Long? { + var nextFutureTaskTime: Long? = null + // Find the next most recent task completion time that's after the specified time. + for (entry in taskCompletionTimes.entries) { + if (entry.value > timeMillis) { + nextFutureTaskTime = nextFutureTaskTime?.let { min(it, entry.value) } ?: entry.value + } + } + return nextFutureTaskTime + } + + override fun hasPendingCompletableTasks(): Boolean { + // Any pending tasks are always considered completable since the dispatcher runs in real-time. + return hasPendingTasks() + } + + override fun setTaskIdleListener(taskIdleListener: TaskIdleListener) { + this.taskIdleListener = taskIdleListener + if (executingTaskCount.get() > 0) { + notifyIfRunning() + } else { + notifyIfIdle() + } + } + + private fun notifyIfRunning() { + taskIdleListener?.takeIf { executingTaskCount.get() > 0 }?.onDispatcherRunning() + } + + private fun notifyIfIdle() { + taskIdleListener?.takeIf { executingTaskCount.get() == 0 }?.onDispatcherIdle() + } + + class FactoryImpl @Inject constructor() : Factory { + override fun createDispatcher(realDispatcher: CoroutineDispatcher): TestCoroutineDispatcher { + return TestCoroutineDispatcherEspressoImpl(realDispatcher) + } + } +} diff --git a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcherRobolectricImpl.kt b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcherRobolectricImpl.kt new file mode 100644 index 00000000000..1f06a50814b --- /dev/null +++ b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcherRobolectricImpl.kt @@ -0,0 +1,232 @@ +package org.oppia.testing + +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi +import kotlinx.coroutines.test.UncompletedCoroutinesError +import java.util.TreeSet +import java.util.concurrent.CopyOnWriteArraySet +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import javax.inject.Inject +import kotlin.Comparator +import kotlin.coroutines.CoroutineContext + +// TODO(#89): Audit & adjust the thread safety of this class, and determine if there's a way to move +// off of the internal coroutine API. + +/** + * Replacement for Kotlin's test coroutine dispatcher that can be used to replace coroutine + * dispatching functionality in a Robolectric test in a way that can be coordinated across multiple + * dispatchers for execution synchronization. + * + * Developers should never use this dispatcher directly. Integrating with it should be done via + * [TestDispatcherModule] and ensuring thread synchronization should be done via + * [TestCoroutineDispatchers]. Attempting to interact directly with this dispatcher may cause timing + * inconsistencies between the UI thread and other application coroutine dispatchers. + */ +@InternalCoroutinesApi +@ExperimentalCoroutinesApi +class TestCoroutineDispatcherRobolectricImpl private constructor( + private val fakeSystemClock: FakeSystemClock, + private val realCoroutineDispatcher: CoroutineDispatcher +): TestCoroutineDispatcher() { + + /** Sorted set that first sorts on when a task should be executed, then insertion order. */ + private val taskQueue = CopyOnWriteArraySet() + private val isRunning = AtomicBoolean(true) + private val executingTaskCount = AtomicInteger(0) + private val totalTaskCount = AtomicInteger(0) + private var taskIdleListener: TaskIdleListener? = null + + @ExperimentalCoroutinesApi + override val currentTime: Long + get() = fakeSystemClock.getTimeMillis() + + override fun dispatch(context: CoroutineContext, block: Runnable) { + enqueueTask(createDeferredRunnable(context, block)) + } + + override fun scheduleResumeAfterDelay( + timeMillis: Long, + continuation: CancellableContinuation + ) { + enqueueTask(createContinuationRunnable(continuation), delayMillis = timeMillis) + } + + @ExperimentalCoroutinesApi + override fun advanceTimeBy(delayTimeMillis: Long): Long { + flushTaskQueue(fakeSystemClock.advanceTime(delayTimeMillis)) + return delayTimeMillis + } + + @ExperimentalCoroutinesApi + override fun advanceUntilIdle(): Long { + throw UnsupportedOperationException( + "Use TestCoroutineDispatchers.advanceUntilIdle() to ensure the dispatchers are properly " + + "coordinated" + ) + } + + @ExperimentalCoroutinesApi + override fun cleanupTestCoroutines() { + flushTaskQueue(fakeSystemClock.getTimeMillis()) + val remainingTaskCount = taskQueue.size + if (remainingTaskCount != 0) { + throw UncompletedCoroutinesError( + "Expected no remaining tasks for test dispatcher, but found $remainingTaskCount" + ) + } + } + + @ExperimentalCoroutinesApi + override fun pauseDispatcher() { + isRunning.set(false) + } + + @ExperimentalCoroutinesApi + override suspend fun pauseDispatcher(block: suspend () -> Unit) { + // There's not a clear way to handle this block while maintaining the thread of the dispatcher, + // so disable it for now until it's later needed. + throw UnsupportedOperationException() + } + + @ExperimentalCoroutinesApi + override fun resumeDispatcher() { + isRunning.set(true) + flushTaskQueue(fakeSystemClock.getTimeMillis()) + } + + @ExperimentalCoroutinesApi + override fun runCurrent() { + flushTaskQueue(fakeSystemClock.getTimeMillis()) + } + + override fun hasPendingTasks(): Boolean = taskQueue.isNotEmpty() + + override fun getNextFutureTaskCompletionTimeMillis(timeMillis: Long): Long? { + return createSortedTaskSet().firstOrNull { task -> task.timeMillis > timeMillis }?.timeMillis + } + + override fun hasPendingCompletableTasks(): Boolean { + return taskQueue.hasPendingCompletableTasks(fakeSystemClock.getTimeMillis()) + } + + override fun setTaskIdleListener(taskIdleListener: TaskIdleListener) { + this.taskIdleListener = taskIdleListener + if (executingTaskCount.get() > 0) { + notifyIfRunning() + } else { + notifyIfIdle() + } + } + + private fun enqueueTask(block: Runnable, delayMillis: Long = 0L) { + taskQueue += Task( + timeMillis = fakeSystemClock.getTimeMillis() + delayMillis, + block = block, + insertionOrder = totalTaskCount.incrementAndGet() + ) + notifyIfRunning() + } + + @Suppress("ControlFlowWithEmptyBody") + private fun flushTaskQueue(currentTimeMillis: Long) { + // TODO(#89): Add timeout support so that the dispatcher can't effectively deadlock or livelock + // for inappropriately behaved tests. + while (isRunning.get()) { + if (!flushActiveTaskQueue(currentTimeMillis)) { + break + } + } + while (executingTaskCount.get() > 0); + + notifyIfIdle() + } + + /** Flushes the current task queue and returns whether any tasks were executed. */ + private fun flushActiveTaskQueue(currentTimeMillis: Long): Boolean { + if (isTaskQueueActive(currentTimeMillis)) { + // Create a copy of the task queue in case it's changed during modification. + val tasksToRemove = createSortedTaskSet().filter { task -> + if (isRunning.get()) { + if (task.timeMillis <= currentTimeMillis) { + // Only remove the task if it was executed. + task.block.run() + return@filter true + } + } + return@filter false + } + return taskQueue.removeAll(tasksToRemove) + } + return false + } + + private fun isTaskQueueActive(currentTimeMillis: Long): Boolean { + return taskQueue.hasPendingCompletableTasks(currentTimeMillis) || executingTaskCount.get() != 0 + } + + private fun createDeferredRunnable(context: CoroutineContext, block: Runnable): Runnable { + return kotlinx.coroutines.Runnable { + executingTaskCount.incrementAndGet() + realCoroutineDispatcher.dispatch(context, kotlinx.coroutines.Runnable { + try { + block.run() + } finally { + executingTaskCount.decrementAndGet() + } + }) + } + } + + private fun createContinuationRunnable(continuation: CancellableContinuation): Runnable { + val block: CancellableContinuation.() -> Unit = { + realCoroutineDispatcher.resumeUndispatched(Unit) + } + return kotlinx.coroutines.Runnable { + try { + executingTaskCount.incrementAndGet() + continuation.block() + } finally { + executingTaskCount.decrementAndGet() + } + } + } + + private fun createSortedTaskSet(): Set { + val sortedSet = TreeSet( + Comparator.comparingLong(Task::timeMillis) + .thenComparing(Task::insertionOrder) + ) + sortedSet.addAll(taskQueue) + return sortedSet + } + + private fun notifyIfRunning() { + taskIdleListener?.takeIf { executingTaskCount.get() > 0 }?.onDispatcherRunning() + } + + private fun notifyIfIdle() { + taskIdleListener?.takeIf { executingTaskCount.get() == 0 }?.onDispatcherIdle() + } + + class FactoryImpl @Inject constructor( + private val fakeSystemClock: FakeSystemClock + ) : TestCoroutineDispatcher.Factory { + override fun createDispatcher(realDispatcher: CoroutineDispatcher): TestCoroutineDispatcher { + return TestCoroutineDispatcherRobolectricImpl(fakeSystemClock, realDispatcher) + } + } +} + +private data class Task( + internal val block: Runnable, + internal val timeMillis: Long, + internal val insertionOrder: Int +) + +private fun CopyOnWriteArraySet.hasPendingCompletableTasks(currentTimeMillis: Long): Boolean { + return any { task -> task.timeMillis <= currentTimeMillis } +} diff --git a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchers.kt b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchers.kt index b9185965d4c..72381ede582 100644 --- a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchers.kt +++ b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchers.kt @@ -1,10 +1,6 @@ package org.oppia.testing import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.InternalCoroutinesApi -import org.robolectric.shadows.ShadowLooper -import java.util.TreeSet -import javax.inject.Inject // TODO(#1274): Add thorough testing for this class. @@ -28,13 +24,9 @@ import javax.inject.Inject * Specific cases will be allowed to integrate with if other options are infeasible. Other tests * should rely on existing mechanisms until this utility is ready for broad use. */ -@InternalCoroutinesApi -class TestCoroutineDispatchers @Inject constructor( - @BackgroundTestDispatcher private val backgroundTestDispatcher: TestCoroutineDispatcher, - @BlockingTestDispatcher private val blockingTestDispatcher: TestCoroutineDispatcher, - private val fakeSystemClock: FakeSystemClock -) { - private val shadowUiLooper = ShadowLooper.shadowMainLooper() +interface TestCoroutineDispatchers { + fun registerIdlingResource() + fun unregisterIdlingResource() /** * Runs all current tasks pending, but does not follow up with executing any tasks that are @@ -45,11 +37,7 @@ class TestCoroutineDispatchers @Inject constructor( * operation, it should use [advanceTimeBy]. */ @ExperimentalCoroutinesApi - fun runCurrent() { - do { - flushNextTasks() - } while (hasPendingCompletableTasks()) - } + fun runCurrent() /** * Advances the system clock by the specified time in milliseconds and then ensures any new tasks @@ -65,20 +53,7 @@ class TestCoroutineDispatchers @Inject constructor( * wait for a future operation, but doesn't know how long. */ @ExperimentalCoroutinesApi - fun advanceTimeBy(delayTimeMillis: Long) { - var remainingDelayMillis = delayTimeMillis - while (remainingDelayMillis > 0) { - val currentTimeMillis = fakeSystemClock.getTimeMillis() - val taskDelayMillis = - advanceToNextFutureTask(currentTimeMillis, maxDelayMs = remainingDelayMillis) - if (taskDelayMillis == null) { - // If there are no delayed tasks, advance by the full time requested. - fakeSystemClock.advanceTime(remainingDelayMillis) - runCurrent() - } - remainingDelayMillis -= taskDelayMillis ?: remainingDelayMillis - } - } + fun advanceTimeBy(delayTimeMillis: Long) /** * Runs all tasks on all tracked threads & coroutine dispatchers until no other tasks are pending. @@ -92,91 +67,5 @@ class TestCoroutineDispatchers @Inject constructor( * unintentional side effect of executing future tasks before the test anticipates it. */ @ExperimentalCoroutinesApi - fun advanceUntilIdle() { - // First, run through all tasks that are currently pending and can be run immediately. - runCurrent() - - // Now, the dispatchers can't proceed until time moves forward. Execute the next most recent - // task schedule, and everything subsequently scheduled until the dispatchers are in a waiting - // state again. Repeat until all tasks have been executed (and thus the dispatchers enter an - // idle state). - while (hasPendingTasks()) { - val currentTimeMillis = fakeSystemClock.getTimeMillis() - val taskDelayMillis = checkNotNull(advanceToNextFutureTask(currentTimeMillis)) { - "Expected to find task with delay for waiting dispatchers with non-empty task queues" - } - fakeSystemClock.advanceTime(taskDelayMillis) - runCurrent() - } - } - - /** - * Advances the clock to the next most recently scheduled task, then runs all tasks until the - * dispatcher enters a waiting state (meaning they cannot execute anything until the clock is - * advanced). If a task was executed, returns the delay added to the current system time in order - * to execute it. Returns null if the time to the next task is beyond the specified maximum delay, - * if any. - */ - @ExperimentalCoroutinesApi - private fun advanceToNextFutureTask( - currentTimeMillis: Long, maxDelayMs: Long = Long.MAX_VALUE - ): Long? { - val nextFutureTimeMillis = getNextFutureTaskTimeMillis(currentTimeMillis) - val timeToTaskMillis = nextFutureTimeMillis?.let { it - currentTimeMillis } - val timeToAdvanceBy = timeToTaskMillis?.takeIf { it < maxDelayMs } - return timeToAdvanceBy?.let { - fakeSystemClock.advanceTime(it) - runCurrent() - return@let it - } - } - - @ExperimentalCoroutinesApi - private fun flushNextTasks() { - if (backgroundTestDispatcher.hasPendingCompletableTasks()) { - backgroundTestDispatcher.runCurrent() - } - if (blockingTestDispatcher.hasPendingCompletableTasks()) { - blockingTestDispatcher.runCurrent() - } - if (!shadowUiLooper.isIdle) { - shadowUiLooper.idle() - } - } - - /** Returns whether any of the dispatchers have any tasks to run, including in the future. */ - private fun hasPendingTasks(): Boolean { - return backgroundTestDispatcher.hasPendingTasks() || - blockingTestDispatcher.hasPendingTasks() || - getNextUiThreadFutureTaskTimeMillis(fakeSystemClock.getTimeMillis()) != null - } - - /** Returns whether any of the dispatchers have tasks that can be run now. */ - private fun hasPendingCompletableTasks(): Boolean { - return backgroundTestDispatcher.hasPendingCompletableTasks() || - blockingTestDispatcher.hasPendingCompletableTasks() || - !shadowUiLooper.isIdle - } - - private fun getNextFutureTaskTimeMillis(timeMillis: Long): Long? { - val nextBackgroundFutureTaskTimeMills = - backgroundTestDispatcher.getNextFutureTaskCompletionTimeMillis(timeMillis) - val nextBlockingFutureTaskTimeMills = - backgroundTestDispatcher.getNextFutureTaskCompletionTimeMillis(timeMillis) - val nextUiFutureTaskTimeMills = getNextUiThreadFutureTaskTimeMillis(timeMillis) - val futureTimes: TreeSet = sortedSetOf() - nextBackgroundFutureTaskTimeMills?.let { futureTimes.add(it) } - nextBlockingFutureTaskTimeMills?.let { futureTimes.add(it) } - nextUiFutureTaskTimeMills?.let { futureTimes.add(it) } - return futureTimes.firstOrNull() - } - - private fun getNextUiThreadFutureTaskTimeMillis(timeMillis: Long): Long? { - val delayMs = shadowUiLooper.nextScheduledTaskTime.toMillis() - if (delayMs == 0L && shadowUiLooper.isIdle) { - // If there's no delay and the looper is idle, that means there are no scheduled tasks. - return null - } - return timeMillis + delayMs - } + fun advanceUntilIdle() } diff --git a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchersEspressoImpl.kt b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchersEspressoImpl.kt new file mode 100644 index 00000000000..75cfe918701 --- /dev/null +++ b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchersEspressoImpl.kt @@ -0,0 +1,104 @@ +package org.oppia.testing + +import androidx.test.espresso.Espresso.onIdle +import androidx.test.espresso.IdlingRegistry +import androidx.test.espresso.IdlingResource +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi +import org.oppia.testing.TestCoroutineDispatcher.TaskIdleListener +import javax.inject.Inject + +@InternalCoroutinesApi +@ExperimentalCoroutinesApi +class TestCoroutineDispatchersEspressoImpl @Inject constructor( + @BackgroundTestDispatcher private val backgroundTestDispatcher: TestCoroutineDispatcher, + @BlockingTestDispatcher private val blockingTestDispatcher: TestCoroutineDispatcher +) : TestCoroutineDispatchers { + private val idlingResource by lazy { TestCoroutineDispatcherIdlingResource() } + private val dispatcherIdlenessTracker = DispatcherIdlenessTracker( + arrayOf(backgroundTestDispatcher, blockingTestDispatcher) + ) + + override fun registerIdlingResource() { + IdlingRegistry.getInstance().register(idlingResource) + dispatcherIdlenessTracker.initialize() + } + + override fun unregisterIdlingResource() { + IdlingRegistry.getInstance().unregister(idlingResource) + } + + @ExperimentalCoroutinesApi + override fun runCurrent() { + advanceUntilIdle() + } + + @ExperimentalCoroutinesApi + override fun advanceTimeBy(delayTimeMillis: Long) { + // No actual sleep is needed since Espresso will automatically run until all tasks are + // completed since idleness ties to all tasks, even future ones. + advanceUntilIdle() + } + + @ExperimentalCoroutinesApi + override fun advanceUntilIdle() { + // Test coroutine dispatchers run in real-time, so let Espresso run until it idles. + onIdle() + } + + /** Returns whether any of the dispatchers have tasks that can be run now. */ + private fun hasPendingCompletableTasks(): Boolean { + return backgroundTestDispatcher.hasPendingCompletableTasks() || + blockingTestDispatcher.hasPendingCompletableTasks() + } + + // TODO: make this based on real-time. If running in Espresso, make the test coroutine dispatchers + // use real-time and no-op the runCurrent/advanceUntilIdle since they are implied (or, rather, + // make them call onIdle() since that's effectively what they're proxying in Robolectric land). + private inner class TestCoroutineDispatcherIdlingResource : IdlingResource { + private var resourceCallback: IdlingResource.ResourceCallback? = null + + override fun getName(): String { + return "TestCoroutineDispatcherIdlingResource" + } + + override fun isIdleNow(): Boolean { + return !hasPendingCompletableTasks() + } + + override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback?) { + resourceCallback = callback + } + + internal fun setToIdle() { + resourceCallback?.onTransitionToIdle() + } + } + + private inner class DispatcherIdlenessTracker( + private val dispatchers: Array + ) { + private val dispatcherRunningStates = Array(dispatchers.size) { false } + + internal fun initialize() { + dispatchers.forEachIndexed { index, dispatcher -> + dispatcher.setTaskIdleListener(object : TaskIdleListener { + override fun onDispatcherRunning() { + dispatcherRunningStates[index] = true + } + + override fun onDispatcherIdle() { + dispatcherRunningStates[index] = false + notifyIfDispatchersAreIdle() + } + }) + } + } + + private fun notifyIfDispatchersAreIdle() { + if (!hasPendingCompletableTasks()) { + idlingResource.setToIdle() + } + } + } +} diff --git a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchersRobolectricImpl.kt b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchersRobolectricImpl.kt new file mode 100644 index 00000000000..e9d2f9a0a03 --- /dev/null +++ b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchersRobolectricImpl.kt @@ -0,0 +1,174 @@ +package org.oppia.testing + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi +import java.lang.reflect.Method +import java.time.Duration +import java.util.* +import javax.inject.Inject + +@InternalCoroutinesApi +@ExperimentalCoroutinesApi +class TestCoroutineDispatchersRobolectricImpl @Inject constructor( + @BackgroundTestDispatcher private val backgroundTestDispatcher: TestCoroutineDispatcher, + @BlockingTestDispatcher private val blockingTestDispatcher: TestCoroutineDispatcher, + private val fakeSystemClock: FakeSystemClock +) : TestCoroutineDispatchers { + private val uiTaskCoordinator = RobolectricUiTaskCoordinator() + + override fun registerIdlingResource() { + // Do nothing; idling resources aren't used in Robolectric. + } + + override fun unregisterIdlingResource() { + // Do nothing; idling resources aren't used in Robolectric. + } + + @ExperimentalCoroutinesApi + override fun runCurrent() { + do { + flushNextTasks() + } while (hasPendingCompletableTasks()) + } + + @ExperimentalCoroutinesApi + override fun advanceTimeBy(delayTimeMillis: Long) { + var remainingDelayMillis = delayTimeMillis + while (remainingDelayMillis > 0) { + val currentTimeMillis = fakeSystemClock.getTimeMillis() + val taskDelayMillis = + advanceToNextFutureTask(currentTimeMillis, maxDelayMs = remainingDelayMillis) + if (taskDelayMillis == null) { + // If there are no delayed tasks, advance by the full time requested. + fakeSystemClock.advanceTime(remainingDelayMillis) + runCurrent() + } + remainingDelayMillis -= taskDelayMillis ?: remainingDelayMillis + } + } + + @ExperimentalCoroutinesApi + override fun advanceUntilIdle() { + // First, run through all tasks that are currently pending and can be run immediately. + runCurrent() + + // Now, the dispatchers can't proceed until time moves forward. Execute the next most recent + // task schedule, and everything subsequently scheduled until the dispatchers are in a waiting + // state again. Repeat until all tasks have been executed (and thus the dispatchers enter an + // idle state). + while (hasPendingTasks()) { + val currentTimeMillis = fakeSystemClock.getTimeMillis() + val taskDelayMillis = checkNotNull(advanceToNextFutureTask(currentTimeMillis)) { + "Expected to find task with delay for waiting dispatchers with non-empty task queues" + } + fakeSystemClock.advanceTime(taskDelayMillis) + runCurrent() + } + } + + @ExperimentalCoroutinesApi + private fun advanceToNextFutureTask( + currentTimeMillis: Long, maxDelayMs: Long = Long.MAX_VALUE + ): Long? { + val nextFutureTimeMillis = getNextFutureTaskTimeMillis(currentTimeMillis) + val timeToTaskMillis = nextFutureTimeMillis?.let { it - currentTimeMillis } + val timeToAdvanceBy = timeToTaskMillis?.takeIf { it < maxDelayMs } + return timeToAdvanceBy?.let { + fakeSystemClock.advanceTime(it) + runCurrent() + return@let it + } + } + + @ExperimentalCoroutinesApi + private fun flushNextTasks() { + if (backgroundTestDispatcher.hasPendingCompletableTasks()) { + backgroundTestDispatcher.runCurrent() + } + if (blockingTestDispatcher.hasPendingCompletableTasks()) { + blockingTestDispatcher.runCurrent() + } + if (!uiTaskCoordinator.isIdle()) { + uiTaskCoordinator.idle() + } + } + + /** Returns whether any of the dispatchers have any tasks to run, including in the future. */ + private fun hasPendingTasks(): Boolean { + return backgroundTestDispatcher.hasPendingTasks() || + blockingTestDispatcher.hasPendingTasks() || + getNextUiThreadFutureTaskTimeMillis(fakeSystemClock.getTimeMillis()) != null + } + + /** Returns whether any of the dispatchers have tasks that can be run now. */ + private fun hasPendingCompletableTasks(): Boolean { + return backgroundTestDispatcher.hasPendingCompletableTasks() || + blockingTestDispatcher.hasPendingCompletableTasks() || + !uiTaskCoordinator.isIdle() + } + + private fun getNextFutureTaskTimeMillis(timeMillis: Long): Long? { + val nextBackgroundFutureTaskTimeMills = + backgroundTestDispatcher.getNextFutureTaskCompletionTimeMillis(timeMillis) + val nextBlockingFutureTaskTimeMills = + backgroundTestDispatcher.getNextFutureTaskCompletionTimeMillis(timeMillis) + val nextUiFutureTaskTimeMills = getNextUiThreadFutureTaskTimeMillis(timeMillis) + val futureTimes: TreeSet = sortedSetOf() + nextBackgroundFutureTaskTimeMills?.let { futureTimes.add(it) } + nextBlockingFutureTaskTimeMills?.let { futureTimes.add(it) } + nextUiFutureTaskTimeMills?.let { futureTimes.add(it) } + return futureTimes.firstOrNull() + } + + private fun getNextUiThreadFutureTaskTimeMillis(timeMillis: Long): Long? { + return uiTaskCoordinator.getNextUiThreadFutureTaskTimeMillis(timeMillis) + } + + private class RobolectricUiTaskCoordinator { + private val shadowLooperClass by lazy { loadShadowLooperClass() } + private val shadowUiLooper by lazy { loadMainShadowLooper() } + private val isIdleMethod by lazy { loadIsIdleMethod() } + private val idleMethod by lazy { loadIdleMethod() } + private val nextScheduledTimeMethod by lazy { loadGetNextScheduledTaskTimeMethod() } + + internal fun isIdle(): Boolean { + return isIdleMethod.invoke(shadowUiLooper) as Boolean + } + + internal fun idle() { + idleMethod.invoke(shadowUiLooper) + } + + internal fun getNextUiThreadFutureTaskTimeMillis(timeMillis: Long): Long? { + val nextScheduledTime = nextScheduledTimeMethod.invoke(shadowUiLooper) as Duration + val delayMs = nextScheduledTime.toMillis() + if (delayMs == 0L && isIdle()) { + // If there's no delay and the looper is idle, that means there are no scheduled tasks. + return null + } + return timeMillis + delayMs + } + + private fun loadShadowLooperClass(): Class<*> { + val classLoader = TestCoroutineDispatchers::class.java.classLoader!! + return classLoader.loadClass("org.robolectric.shadows.ShadowLooper") + } + + private fun loadMainShadowLooper(): Any { + val shadowMainLooperMethod = shadowLooperClass.getDeclaredMethod("shadowMainLooper") + return shadowMainLooperMethod.invoke(/* obj= */ null) + } + + private fun loadIsIdleMethod(): Method { + return shadowLooperClass.getDeclaredMethod("isIdle") + } + + private fun loadIdleMethod(): Method { + return shadowLooperClass.getDeclaredMethod("idle") + } + + private fun loadGetNextScheduledTaskTimeMethod(): Method { + return shadowLooperClass.getDeclaredMethod("getNextScheduledTaskTime") + } + } +} diff --git a/testing/src/main/java/org/oppia/testing/TestDispatcherModule.kt b/testing/src/main/java/org/oppia/testing/TestDispatcherModule.kt index 910666ad994..f5366a62e72 100644 --- a/testing/src/main/java/org/oppia/testing/TestDispatcherModule.kt +++ b/testing/src/main/java/org/oppia/testing/TestDispatcherModule.kt @@ -1,13 +1,16 @@ package org.oppia.testing +import android.os.Build import dagger.Module import dagger.Provides import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.InternalCoroutinesApi import kotlinx.coroutines.asCoroutineDispatcher import org.oppia.util.threading.BackgroundDispatcher import org.oppia.util.threading.BlockingDispatcher import java.util.concurrent.Executors +import javax.inject.Provider import javax.inject.Singleton /** @@ -18,6 +21,7 @@ import javax.inject.Singleton class TestDispatcherModule { @Provides @InternalCoroutinesApi + @ExperimentalCoroutinesApi @BackgroundDispatcher fun provideBackgroundDispatcher( @BackgroundTestDispatcher testCoroutineDispatcher: TestCoroutineDispatcher @@ -27,6 +31,7 @@ class TestDispatcherModule { @Provides @InternalCoroutinesApi + @ExperimentalCoroutinesApi @BlockingDispatcher fun provideBlockingDispatcher( @BlockingTestDispatcher testCoroutineDispatcher: TestCoroutineDispatcher @@ -37,6 +42,7 @@ class TestDispatcherModule { @Provides @BackgroundTestDispatcher @InternalCoroutinesApi + @ExperimentalCoroutinesApi @Singleton fun provideBackgroundTestDispatcher( factory: TestCoroutineDispatcher.Factory @@ -49,10 +55,40 @@ class TestDispatcherModule { @Provides @BlockingTestDispatcher @InternalCoroutinesApi + @ExperimentalCoroutinesApi @Singleton fun provideBlockingTestDispatcher( factory: TestCoroutineDispatcher.Factory ): TestCoroutineDispatcher { return factory.createDispatcher(Executors.newSingleThreadExecutor().asCoroutineDispatcher()) } + + @Provides + @InternalCoroutinesApi + @ExperimentalCoroutinesApi + fun provideTestCoroutineDispatchers( + @IsOnRobolectric isOnRobolectric: Boolean, + robolectricImplProvider: Provider, + espressoImplProvider: Provider + ): TestCoroutineDispatchers { + return if (isOnRobolectric) robolectricImplProvider.get() else espressoImplProvider.get() + } + + @Provides + @InternalCoroutinesApi + @ExperimentalCoroutinesApi + fun provideTestCoroutineDispatcherFactory( + @IsOnRobolectric isOnRobolectric: Boolean, + robolectricFactoryProvider: Provider, + espressoFactoryProvider: Provider + ): TestCoroutineDispatcher.Factory { + return if (isOnRobolectric) robolectricFactoryProvider.get() else espressoFactoryProvider.get() + } + + @Provides + @IsOnRobolectric + @Singleton + fun provideIsOnRobolectric(): Boolean { + return Build.FINGERPRINT.contains("robolectric", ignoreCase = true) + } } diff --git a/testing/src/main/java/org/oppia/testing/TestingQualifiers.kt b/testing/src/main/java/org/oppia/testing/TestingQualifiers.kt new file mode 100644 index 00000000000..4fc4b5e7251 --- /dev/null +++ b/testing/src/main/java/org/oppia/testing/TestingQualifiers.kt @@ -0,0 +1,6 @@ +package org.oppia.testing + +import javax.inject.Qualifier + +@Qualifier +annotation class IsOnRobolectric From eb4b02fc852d7512ba1f7fb130caa35ce6077de6 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Mon, 10 Aug 2020 12:00:22 -0700 Subject: [PATCH 03/36] Revert "Fixes #941: Add radar effect in Hints and solution (#1475)" This reverts commit 41eb10bd0c04596cb18c354da7db7268121a09be. --- .../app/databinding/ViewBindingAdapter.kt | 36 ------------------- .../main/res/drawable/radar_moving_circle.xml | 7 ---- .../layout-land/question_player_fragment.xml | 10 ------ .../main/res/layout-land/state_fragment.xml | 10 ------ .../question_player_fragment.xml | 10 ------ .../layout-sw600dp-land/state_fragment.xml | 10 ------ .../question_player_fragment.xml | 10 ------ .../layout-sw600dp-port/state_fragment.xml | 10 ------ .../res/layout/question_player_fragment.xml | 10 ------ app/src/main/res/layout/state_fragment.xml | 10 ------ app/src/main/res/values/colors.xml | 1 - 11 files changed, 124 deletions(-) delete mode 100644 app/src/main/res/drawable/radar_moving_circle.xml diff --git a/app/src/main/java/org/oppia/app/databinding/ViewBindingAdapter.kt b/app/src/main/java/org/oppia/app/databinding/ViewBindingAdapter.kt index 99b146a225e..e70cee676ec 100644 --- a/app/src/main/java/org/oppia/app/databinding/ViewBindingAdapter.kt +++ b/app/src/main/java/org/oppia/app/databinding/ViewBindingAdapter.kt @@ -1,45 +1,9 @@ package org.oppia.app.databinding -import android.animation.AnimatorSet import android.animation.ValueAnimator import android.view.View -import androidx.core.animation.doOnEnd import androidx.databinding.BindingAdapter -private val appearAnimator: ValueAnimator = ValueAnimator.ofFloat(0f, 1f) -private val disappearAnimator: ValueAnimator = ValueAnimator.ofFloat(1f, 2f) -private val animatorSet = AnimatorSet() - -@BindingAdapter("app:flashingAnimation") -fun setFlashingAnimation(view: View, isFlashing: Boolean) { - appearAnimator.addUpdateListener { - view.scaleX = it.animatedValue as Float - view.scaleY = it.animatedValue as Float - view.alpha = it.animatedValue as Float - } - appearAnimator.duration = 1500 - - disappearAnimator.addUpdateListener { - view.scaleX = it.animatedValue as Float - view.scaleY = it.animatedValue as Float - view.alpha = 2f - it.animatedValue as Float - } - disappearAnimator.duration = 500 - - if (isFlashing) { - animatorSet.playSequentially(appearAnimator, disappearAnimator) - animatorSet.start() - animatorSet.doOnEnd { - animatorSet.start() - } - } else { - animatorSet.cancel() - view.scaleX = 0f - view.scaleY = 0f - view.alpha = 0f - } -} - /** BindingAdapter to set the height of a View.*/ @BindingAdapter("android:layout_height") fun setLayoutHeight(view: View, height: Float) { diff --git a/app/src/main/res/drawable/radar_moving_circle.xml b/app/src/main/res/drawable/radar_moving_circle.xml deleted file mode 100644 index baaea787bf6..00000000000 --- a/app/src/main/res/drawable/radar_moving_circle.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/app/src/main/res/layout-land/question_player_fragment.xml b/app/src/main/res/layout-land/question_player_fragment.xml index 590b5507928..f13d1c9bdeb 100644 --- a/app/src/main/res/layout-land/question_player_fragment.xml +++ b/app/src/main/res/layout-land/question_player_fragment.xml @@ -134,16 +134,6 @@ android:background="@drawable/hints_background" android:visibility="@{viewModel.isHintBulbVisible() ? View.VISIBLE : View.GONE}"> - - - - - - - - - - - - - - - - #FF000000 #999999 - #FFCD2A From 6d175b2adfe088c992a790616d809b9e7a29f131 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Mon, 10 Aug 2020 22:57:40 -0700 Subject: [PATCH 04/36] Stabilize StateFragmentTest such that it passes on both Robolectric and Espresso. Note that some issues were found during this: #1612 (#1611 was found a few weeks ago, but it also affects these tests). To ensure the tests can still be run, a @RunOn annotation was added to allow tests to target specific test platforms. The tests that currently fail on Robolectric due to #1611 and #1612 are disabled for that platform. The test suite as a whole has been verified to pass in its current state on both Robolectric and Espresso (on a Pixel XL). The aim of this PR is to actually enable critical state fragment tests in CI, so both StateFragmentTest and StateFragmentLocalTest are being enabled in GitHub actions. --- .../app/player/state/StateFragmentTest.kt | 1077 +++++++++-------- .../org/oppia/testing/OppiaTestAnnotations.kt | 28 + .../java/org/oppia/testing/OppiaTestRule.kt | 56 + .../oppia/testing/TestCoroutineDispatchers.kt | 5 - 4 files changed, 644 insertions(+), 522 deletions(-) create mode 100644 testing/src/main/java/org/oppia/testing/OppiaTestAnnotations.kt create mode 100644 testing/src/main/java/org/oppia/testing/OppiaTestRule.kt diff --git a/app/src/sharedTest/java/org/oppia/app/player/state/StateFragmentTest.kt b/app/src/sharedTest/java/org/oppia/app/player/state/StateFragmentTest.kt index d97185aafda..2b3ec885885 100644 --- a/app/src/sharedTest/java/org/oppia/app/player/state/StateFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/app/player/state/StateFragmentTest.kt @@ -2,9 +2,10 @@ package org.oppia.app.player.state import android.app.Application import android.content.Context -import android.os.Handler -import android.os.Looper +import android.os.Build import android.view.View +import android.widget.EditText +import androidx.appcompat.app.AppCompatActivity import androidx.recyclerview.widget.RecyclerView import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ActivityScenario.launch @@ -21,9 +22,7 @@ import androidx.test.espresso.action.ViewActions.closeSoftKeyboard import androidx.test.espresso.action.ViewActions.typeText import androidx.test.espresso.assertion.ViewAssertions.doesNotExist import androidx.test.espresso.assertion.ViewAssertions.matches -import androidx.test.espresso.contrib.RecyclerViewActions -import androidx.test.espresso.contrib.RecyclerViewActions.scrollToPosition -import androidx.test.espresso.idling.CountingIdlingResource +import androidx.test.espresso.contrib.RecyclerViewActions.scrollToHolder import androidx.test.espresso.intent.Intents import androidx.test.espresso.matcher.ViewMatchers.hasChildCount import androidx.test.espresso.matcher.ViewMatchers.isClickable @@ -39,8 +38,6 @@ import dagger.BindsInstance import dagger.Component import dagger.Module import dagger.Provides -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.asCoroutineDispatcher import org.hamcrest.BaseMatcher import org.hamcrest.CoreMatchers.allOf import org.hamcrest.CoreMatchers.containsString @@ -50,14 +47,28 @@ import org.hamcrest.Matcher import org.hamcrest.TypeSafeMatcher import org.junit.After import org.junit.Before +import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.oppia.app.R +import org.oppia.app.activity.ActivityComponent +import org.oppia.app.application.ActivityComponentFactory +import org.oppia.app.application.ApplicationComponent +import org.oppia.app.application.ApplicationModule import org.oppia.app.player.state.itemviewmodel.StateItemViewModel +import org.oppia.app.player.state.itemviewmodel.StateItemViewModel.ViewType.CONTENT +import org.oppia.app.player.state.itemviewmodel.StateItemViewModel.ViewType.CONTINUE_INTERACTION import org.oppia.app.player.state.itemviewmodel.StateItemViewModel.ViewType.CONTINUE_NAVIGATION_BUTTON +import org.oppia.app.player.state.itemviewmodel.StateItemViewModel.ViewType.DRAG_DROP_SORT_INTERACTION import org.oppia.app.player.state.itemviewmodel.StateItemViewModel.ViewType.FEEDBACK +import org.oppia.app.player.state.itemviewmodel.StateItemViewModel.ViewType.FRACTION_INPUT_INTERACTION +import org.oppia.app.player.state.itemviewmodel.StateItemViewModel.ViewType.NEXT_NAVIGATION_BUTTON +import org.oppia.app.player.state.itemviewmodel.StateItemViewModel.ViewType.NUMERIC_INPUT_INTERACTION +import org.oppia.app.player.state.itemviewmodel.StateItemViewModel.ViewType.RETURN_TO_TOPIC_NAVIGATION_BUTTON +import org.oppia.app.player.state.itemviewmodel.StateItemViewModel.ViewType.SELECTION_INTERACTION import org.oppia.app.player.state.itemviewmodel.StateItemViewModel.ViewType.SUBMITTED_ANSWER import org.oppia.app.player.state.itemviewmodel.StateItemViewModel.ViewType.SUBMIT_ANSWER_BUTTON +import org.oppia.app.player.state.itemviewmodel.StateItemViewModel.ViewType.TEXT_INPUT_INTERACTION import org.oppia.app.player.state.testing.StateFragmentTestActivity import org.oppia.app.recyclerview.RecyclerViewMatcher.Companion.atPositionOnView import org.oppia.app.utility.ChildViewCoordinatesProvider @@ -66,71 +77,107 @@ import org.oppia.app.utility.DragViewAction import org.oppia.app.utility.OrientationChangeAction.Companion.orientationLandscape import org.oppia.app.utility.RecyclerViewCoordinatesProvider import org.oppia.app.utility.clickPoint +import org.oppia.data.backends.gae.NetworkModule +import org.oppia.domain.classify.InteractionsModule +import org.oppia.domain.classify.rules.continueinteraction.ContinueModule +import org.oppia.domain.classify.rules.dragAndDropSortInput.DragDropSortInputModule +import org.oppia.domain.classify.rules.fractioninput.FractionInputModule +import org.oppia.domain.classify.rules.imageClickInput.ImageClickInputModule +import org.oppia.domain.classify.rules.itemselectioninput.ItemSelectionInputModule +import org.oppia.domain.classify.rules.multiplechoiceinput.MultipleChoiceInputModule +import org.oppia.domain.classify.rules.numberwithunits.NumberWithUnitsRuleModule +import org.oppia.domain.classify.rules.numericinput.NumericInputRuleModule +import org.oppia.domain.classify.rules.textinput.TextInputRuleModule +import org.oppia.domain.oppialogger.LogStorageModule import org.oppia.domain.profile.ProfileTestHelper +import org.oppia.domain.question.QuestionModule import org.oppia.domain.topic.TEST_EXPLORATION_ID_0 import org.oppia.domain.topic.TEST_EXPLORATION_ID_2 import org.oppia.domain.topic.TEST_EXPLORATION_ID_4 import org.oppia.domain.topic.TEST_EXPLORATION_ID_5 import org.oppia.domain.topic.TEST_STORY_ID_0 import org.oppia.domain.topic.TEST_TOPIC_ID_0 +import org.oppia.testing.OppiaTestRule +import org.oppia.testing.RunOn +import org.oppia.testing.TestAccessibilityModule +import org.oppia.testing.TestCoroutineDispatchers +import org.oppia.testing.TestDispatcherModule import org.oppia.testing.TestLogReportingModule +import org.oppia.testing.TestPlatform import org.oppia.util.caching.CacheAssetsLocally -import org.oppia.util.logging.EnableConsoleLog -import org.oppia.util.logging.EnableFileLog -import org.oppia.util.logging.GlobalLogLevel -import org.oppia.util.logging.LogLevel -import org.oppia.util.threading.BackgroundDispatcher -import org.oppia.util.threading.BlockingDispatcher -import java.util.concurrent.AbstractExecutorService -import java.util.concurrent.TimeUnit +import org.oppia.util.gcsresource.GcsResourceModule +import org.oppia.util.logging.LoggerModule +import org.oppia.util.parser.GlideImageLoaderModule +import org.oppia.util.parser.HtmlParserEntityTypeModule +import org.oppia.util.parser.ImageParsingModule +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode import java.util.concurrent.TimeoutException import javax.inject.Inject import javax.inject.Singleton /** Tests for [StateFragment]. */ @RunWith(AndroidJUnit4::class) +@Config(application = StateFragmentTest.TestApplication::class, qualifiers = "port-xxhdpi") +@LooperMode(LooperMode.Mode.PAUSED) class StateFragmentTest { + @get:Rule + val oppiaTestRule = OppiaTestRule() + @Inject lateinit var profileTestHelper: ProfileTestHelper @Inject lateinit var context: Context + @Inject + lateinit var testCoroutineDispatchers: TestCoroutineDispatchers + private val internalProfileId: Int = 1 @Before fun setUp() { Intents.init() setUpTestApplicationComponent() + testCoroutineDispatchers.registerIdlingResource() profileTestHelper.initializeProfiles() FirebaseApp.initializeApp(context) } @After fun tearDown() { + testCoroutineDispatchers.unregisterIdlingResource() Intents.release() } // TODO(#388): Add more test-cases - // 1. Actually going through each of the exploration states with typing text/clicking the correct answers for each of the interactions. - // 2. Verifying the button visibility state based on whether text is missing, then present/missing for text input or numeric input. + // 1. Actually going through each of the exploration states with typing text/clicking the correct + // answers for each of the interactions. + // 2. Verifying the button visibility state based on whether text is missing, then + // present/missing for text input or numeric input. // 3. Testing providing the wrong answer and showing feedback and the same question again. - // 4. Configuration change with typed text (e.g. for numeric or text input) retains that temporary text and you can continue with the exploration after rotating. - // 5. Configuration change after submitting the wrong answer to show that the old answer & re-ask of the question stay the same. - // 6. Backward/forward navigation along with configuration changes to verify that you stay on the navigated state. + // 4. Configuration change with typed text (e.g. for numeric or text input) retains that + // temporary + // text and you can continue with the exploration after rotating. + // 5. Configuration change after submitting the wrong answer to show that the old answer & re-ask + // of the question stay the same. + // 6. Backward/forward navigation along with configuration changes to verify that you stay on the + // navigated state. // 7. Verifying that old answers were present when navigation backward/forward. // 8. Testing providing the wrong answer and showing hints. // 9. Testing all possible invalid/error input cases for each interaction. - // 10. Testing interactions with custom Oppia tags (including images) render correctly (when manually inspected) and are correctly functional. - // 11. Update the tests to work properly on Robolectric (requires idling resource + replacing the dispatchers to leverage a coordinated test dispatcher library). - // 12. Add tests for hints & solutions. - // 13. Add tests for audio states. - // TODO(#56): Add support for testing that previous/next button states are properly retained on config changes. + // 10. Testing interactions with custom Oppia tags (including images) render correctly (when + // manually inspected) and are correctly functional. + // 11. Add tests for hints & solutions. + // 12. Add tests for audio states. + // TODO(#56): Add support for testing that previous/next button states are properly retained on + // config changes. @Test fun testStateFragment_loadExp_explorationLoads() { launchForExploration(TEST_EXPLORATION_ID_2).use { startPlayingExploration() + // Due to the exploration activity loading, the play button should no longer be visible. onView(withId(R.id.play_test_exploration_button)).check(matches(not(isDisplayed()))) } @@ -140,7 +187,9 @@ class StateFragmentTest { fun testStateFragment_loadExp_explorationLoads_changeConfiguration_buttonIsNotVisible() { launchForExploration(TEST_EXPLORATION_ID_2).use { startPlayingExploration() - onView(isRoot()).perform(orientationLandscape()) + + rotateToLandscape() + // Due to the exploration activity loading, the play button should no longer be visible. onView(withId(R.id.play_test_exploration_button)).check(matches(not(isDisplayed()))) } @@ -150,6 +199,9 @@ class StateFragmentTest { fun testStateFragment_loadExp_explorationHasContinueButton() { launchForExploration(TEST_EXPLORATION_ID_2).use { startPlayingExploration() + + scrollToViewType(CONTINUE_INTERACTION) + onView(withId(R.id.continue_button)).check(matches(isDisplayed())) } } @@ -158,7 +210,10 @@ class StateFragmentTest { fun testStateFragment_loadExp_changeConfiguration_explorationHasContinueButton() { launchForExploration(TEST_EXPLORATION_ID_2).use { startPlayingExploration() - onView(isRoot()).perform(orientationLandscape()) + + rotateToLandscape() + + scrollToViewType(CONTINUE_INTERACTION) onView(withId(R.id.continue_button)).check(matches(isDisplayed())) } } @@ -167,7 +222,10 @@ class StateFragmentTest { fun testStateFragment_loadExp_secondState_hasSubmitButton() { launchForExploration(TEST_EXPLORATION_ID_2).use { startPlayingExploration() - onView(withId(R.id.continue_button)).perform(click()) + + clickContinueInteractionButton() + + scrollToViewType(SUBMIT_ANSWER_BUTTON) onView(withId(R.id.submit_answer_button)).check( matches(withText(R.string.state_submit_button)) ) @@ -179,9 +237,11 @@ class StateFragmentTest { fun testStateFragment_loadExp_changeConfiguration_secondState_hasSubmitButton() { launchForExploration(TEST_EXPLORATION_ID_2).use { startPlayingExploration() - onView(isRoot()).perform(orientationLandscape()) - onView(withId(R.id.continue_button)).perform(click()) - onView(withId(R.id.state_recycler_view)).perform(scrollToPosition(2)) + rotateToLandscape() + + clickContinueInteractionButton() + + scrollToViewType(SUBMIT_ANSWER_BUTTON) onView(withId(R.id.submit_answer_button)).check( matches(withText(R.string.state_submit_button)) ) @@ -189,17 +249,28 @@ class StateFragmentTest { } @Test - fun testStateFragment_loadExp_secondState_submitAnswer_submitChangesToContinueButton() { + fun testStateFragment_loadExp_secondState_submitAnswer_submitButtonIsClickable() { launchForExploration(TEST_EXPLORATION_ID_2).use { startPlayingExploration() - onView(withId(R.id.continue_button)).perform(click()) - onView(withId(R.id.fraction_input_interaction_view)).perform( - typeText("1/2"), - closeSoftKeyboard() - ) - onView(withId(R.id.state_recycler_view)).perform(scrollToPosition(2)) + clickContinueInteractionButton() + + typeFractionText("1/2") + + scrollToViewType(SUBMIT_ANSWER_BUTTON) onView(withId(R.id.submit_answer_button)).check(matches(isClickable())) - onView(withId(R.id.submit_answer_button)).perform(click()) + } + } + + @Test + fun testStateFragment_loadExp_secondState_submitAnswer_clickSubmit_continueButtonIsVisible() { + launchForExploration(TEST_EXPLORATION_ID_2).use { + startPlayingExploration() + clickContinueInteractionButton() + typeFractionText("1/2") + + clickSubmitAnswerButton() + + scrollToViewType(CONTINUE_NAVIGATION_BUTTON) onView(withId(R.id.continue_navigation_button)).check( matches(withText(R.string.state_continue_button)) ) @@ -207,18 +278,30 @@ class StateFragmentTest { } @Test - fun testStateFragment_loadExp_changeConfiguration_secondState_submitAnswer_submitChangesToContinueButton() { // ktlint-disable max-line-length + fun testStateFragment_loadExp_landscape_secondState_submitAnswer_submitButtonIsClickable() { launchForExploration(TEST_EXPLORATION_ID_2).use { startPlayingExploration() - onView(isRoot()).perform(orientationLandscape()) - onView(withId(R.id.state_recycler_view)).perform(scrollToPosition(1)) - onView(withId(R.id.continue_button)).perform(click()) - onView(withId(R.id.fraction_input_interaction_view)).perform( - typeText("1/2"), - closeSoftKeyboard() - ) - onView(withId(R.id.state_recycler_view)).perform(scrollToPosition(2)) - onView(withId(R.id.submit_answer_button)).perform(click()) + rotateToLandscape() + clickContinueInteractionButton() + + typeFractionText("1/2") + + scrollToViewType(SUBMIT_ANSWER_BUTTON) + onView(withId(R.id.submit_answer_button)).check(matches(isClickable())) + } + } + + @Test + fun testStateFragment_loadExp_land_secondState_submitAnswer_clickSubmit_continueIsVisible() { + launchForExploration(TEST_EXPLORATION_ID_2).use { + startPlayingExploration() + rotateToLandscape() + clickContinueInteractionButton() + typeFractionText("1/2") + + clickSubmitAnswerButton() + + scrollToViewType(CONTINUE_NAVIGATION_BUTTON) onView(withId(R.id.continue_navigation_button)).check( matches(withText(R.string.state_continue_button)) ) @@ -229,87 +312,97 @@ class StateFragmentTest { fun testStateFragment_loadExp_secondState_submitInvalidAnswer_disablesSubmitAndShowsError() { launchForExploration(TEST_EXPLORATION_ID_2).use { startPlayingExploration() - onView(withId(R.id.continue_button)).perform(click()) + clickContinueInteractionButton() // Attempt to submit an invalid answer. - onView(withId(R.id.fraction_input_interaction_view)).perform( - typeText("1/"), - closeSoftKeyboard() - ) - onView(withId(R.id.submit_answer_button)).perform(click()) + typeFractionText("1/") + clickSubmitAnswerButton() // The submission button should now be disabled and there should be an error. + scrollToViewType(SUBMIT_ANSWER_BUTTON) onView(withId(R.id.submit_answer_button)).check(matches(not(isClickable()))) onView(withId(R.id.fraction_input_error)).check(matches(isDisplayed())) } } @Test - fun testStateFragment_loadExp_changeConfiguration_secondState_submitInvalidAnswer_disablesSubmitAndShowsError() { // ktlint-disable max-line-length + fun testStateFragment_loadExp_land_secondState_submitInvalidAnswer_disablesSubmitAndShowsError() { launchForExploration(TEST_EXPLORATION_ID_2).use { startPlayingExploration() - onView(isRoot()).perform(orientationLandscape()) - onView(withId(R.id.continue_button)).perform(click()) + rotateToLandscape() + clickContinueInteractionButton() // Attempt to submit an invalid answer. - onView(withId(R.id.state_recycler_view)).perform(scrollToPosition(1)) - onView(withId(R.id.fraction_input_interaction_view)).perform( - typeText("1/"), - closeSoftKeyboard() - ) - onView(withId(R.id.state_recycler_view)).perform(scrollToPosition(2)) - onView(withId(R.id.submit_answer_button)).perform(click()) + typeFractionText("1/") + clickSubmitAnswerButton() // The submission button should now be disabled and there should be an error. + scrollToViewType(SUBMIT_ANSWER_BUTTON) onView(withId(R.id.submit_answer_button)).check(matches(not(isClickable()))) onView(withId(R.id.fraction_input_error)).check(matches(isDisplayed())) } } @Test - fun testStateFragment_loadExp_secondState_invalidAnswer_updated_reenabledSubmitButton() { + fun testStateFragment_loadExp_secondState_invalidAnswer_submitAnswerIsNotEnabled() { launchForExploration(TEST_EXPLORATION_ID_2).use { startPlayingExploration() - onView(withId(R.id.continue_button)).perform(click()) - onView(withId(R.id.fraction_input_interaction_view)).perform( - typeText("1/"), - closeSoftKeyboard() - ) - onView(withId(R.id.state_recycler_view)).perform(scrollToPosition(2)) - onView(withId(R.id.submit_answer_button)).perform(click()) + clickContinueInteractionButton() + typeFractionText("1/") + clickSubmitAnswerButton() + + scrollToViewType(SUBMIT_ANSWER_BUTTON) onView(withId(R.id.submit_answer_button)).check(matches(not(isClickable()))) + } + } + + @Test + fun testStateFragment_loadExp_secondState_invalidAnswer_updated_submitAnswerIsEnabled() { + launchForExploration(TEST_EXPLORATION_ID_2).use { + startPlayingExploration() + clickContinueInteractionButton() + typeFractionText("1/") + clickSubmitAnswerButton() + // Add another '2' to change the pending input text. - onView(withId(R.id.fraction_input_interaction_view)).perform( - typeText("2"), - closeSoftKeyboard() - ) + typeFractionText("2") // The submit button should be re-enabled since the text view changed. + scrollToViewType(SUBMIT_ANSWER_BUTTON) onView(withId(R.id.submit_answer_button)).check(matches(isClickable())) } } @Test - fun testStateFragment_loadExp_changeConfiguration_secondState_invalidAnswer_updated_reenabledSubmitButton() { // ktlint-disable max-line-length + fun testStateFragment_loadExp_land_secondState_invalidAnswer_submitAnswerIsNotEnabled() { launchForExploration(TEST_EXPLORATION_ID_2).use { startPlayingExploration() - onView(isRoot()).perform(orientationLandscape()) - onView(withId(R.id.continue_button)).perform(click()) - onView(withId(R.id.fraction_input_interaction_view)).perform( - typeText("1/"), - closeSoftKeyboard() - ) - onView(withId(R.id.state_recycler_view)).perform(scrollToPosition(2)) - onView(withId(R.id.submit_answer_button)).perform(click()) + rotateToLandscape() + clickContinueInteractionButton() + + typeFractionText("1/") + clickSubmitAnswerButton() + + scrollToViewType(SUBMIT_ANSWER_BUTTON) + onView(withId(R.id.submit_answer_button)).check(matches(not(isClickable()))) + } + } + + @Test + fun testStateFragment_loadExp_land_secondState_invalidAnswer_updated_submitAnswerIsEnabled() { + launchForExploration(TEST_EXPLORATION_ID_2).use { + startPlayingExploration() + rotateToLandscape() + clickContinueInteractionButton() + typeFractionText("1/") + clickSubmitAnswerButton() // Add another '2' to change the pending input text. - onView(withId(R.id.fraction_input_interaction_view)).perform( - typeText("2"), - closeSoftKeyboard() - ) + typeFractionText("2") // The submit button should be re-enabled since the text view changed. + scrollToViewType(SUBMIT_ANSWER_BUTTON) onView(withId(R.id.submit_answer_button)).check(matches(isClickable())) } } @@ -325,17 +418,14 @@ class StateFragmentTest { } @Test + @RunOn(TestPlatform.ESPRESSO) // TODO(#1612): Enable for Robolectric. fun testStateFragment_loadDragDropExp_mergeFirstTwoItems_worksCorrectly() { launchForExploration(TEST_EXPLORATION_ID_4).use { startPlayingExploration() - onView( - atPositionOnView( - recyclerViewId = R.id.drag_drop_interaction_recycler_view, - position = 0, - targetViewId = R.id.drag_drop_content_group_item - ) - ).perform(click()) + mergeDragAndDropItems(position = 0) + + scrollToViewType(DRAG_DROP_SORT_INTERACTION) onView( atPositionOnView( recyclerViewId = R.id.drag_drop_interaction_recycler_view, @@ -347,18 +437,15 @@ class StateFragmentTest { } @Test + @RunOn(TestPlatform.ESPRESSO) // TODO(#1612): Enable for Robolectric. fun testStateFragment_loadDragDropExp_mergeFirstTwoItems_invalidAnswer_correctItemCount() { launchForExploration(TEST_EXPLORATION_ID_4).use { startPlayingExploration() - onView( - atPositionOnView( - recyclerViewId = R.id.drag_drop_interaction_recycler_view, - position = 0, - targetViewId = R.id.drag_drop_content_group_item - ) - ).perform(click()) - onView(withId(R.id.submit_answer_button)).perform(click()) + mergeDragAndDropItems(position = 0) + clickSubmitAnswerButton() + + scrollToViewType(SUBMITTED_ANSWER) onView(withId(R.id.submitted_answer_recycler_view)).check(matches(hasChildCount(3))) onView( atPositionOnView( @@ -371,30 +458,18 @@ class StateFragmentTest { } @Test + @RunOn(TestPlatform.ESPRESSO) // TODO(#1612): Enable for Robolectric. fun testStateFragment_loadDragDropExp_mergeFirstTwoItems_dragItem_worksCorrectly() { + // Note to self: current setup allows the user to drag the view without issues (now that + // event interception isn't a problem), however the view is going partly offscreen which + // is triggering an infinite animation loop in ItemTouchHelper). launchForExploration(TEST_EXPLORATION_ID_4).use { startPlayingExploration() - onView( - atPositionOnView( - recyclerViewId = R.id.drag_drop_interaction_recycler_view, - position = 0, - targetViewId = R.id.drag_drop_content_group_item - ) - ).perform(click()) - onView(withId(R.id.drag_drop_interaction_recycler_view)).perform( - DragViewAction( - RecyclerViewCoordinatesProvider( - 0, - ChildViewCoordinatesProvider( - R.id.drag_drop_item_container, - GeneralLocation.CENTER - ) - ), - RecyclerViewCoordinatesProvider(2, CustomGeneralLocation.UNDER_RIGHT), - Press.FINGER - ) - ) + mergeDragAndDropItems(position = 0) + dragAndDropItem(fromPosition = 0, toPosition = 2) + + scrollToViewType(DRAG_DROP_SORT_INTERACTION) onView( atPositionOnView( recyclerViewId = R.id.drag_drop_interaction_recycler_view, @@ -406,24 +481,15 @@ class StateFragmentTest { } @Test + @RunOn(TestPlatform.ESPRESSO) // TODO(#1612): Enable for Robolectric. fun testStateFragment_loadDragDropExp_mergeFirstTwoItems_unlinkFirstItem_worksCorrectly() { launchForExploration(TEST_EXPLORATION_ID_4).use { startPlayingExploration() - onView( - atPositionOnView( - recyclerViewId = R.id.drag_drop_interaction_recycler_view, - position = 0, - targetViewId = R.id.drag_drop_content_group_item - ) - ).perform(click()) - onView( - atPositionOnView( - recyclerViewId = R.id.drag_drop_interaction_recycler_view, - position = 0, - targetViewId = R.id.drag_drop_content_unlink_items - ) - ).perform(click()) + mergeDragAndDropItems(position = 0) + unlinkDragAndDropItems(position = 0) + + scrollToViewType(DRAG_DROP_SORT_INTERACTION) onView( atPositionOnView( recyclerViewId = R.id.drag_drop_interaction_recycler_view, @@ -435,23 +501,30 @@ class StateFragmentTest { } @Test - fun testStateFragment_loadImageRegion_clickRegion6_region6Clicked() { + @RunOn(TestPlatform.ESPRESSO) // TODO(#1611): Enable for Robolectric. + fun testStateFragment_loadImageRegion_clickRegion6_submitButtonClickable() { launchForExploration(TEST_EXPLORATION_ID_5).use { startPlayingExploration() - waitForExplorationToBeLoaded() - onView(withId(R.id.submit_answer_button)).check(matches(not(isClickable()))) - // TODO(#669): Remove explicit delay - https://github.com/oppia/oppia-android/issues/1523 - waitForTheView( - allOf( - withId(R.id.image_click_interaction_image_view), - WithNonZeroDimensionsMatcher() - ) - ) - onView(withId(R.id.image_click_interaction_image_view)).perform( - clickPoint(0.5f, 0.5f) - ) + waitForImageViewInteractionToFullyLoad() + + clickImageRegion(pointX = 0.5f, pointY = 0.5f) + + scrollToViewType(SUBMIT_ANSWER_BUTTON) onView(withId(R.id.submit_answer_button)).check(matches(isClickable())) - onView(withId(R.id.submit_answer_button)).perform(click()) + } + } + + @Test + @RunOn(TestPlatform.ESPRESSO) // TODO(#1611): Enable for Robolectric. + fun testStateFragment_loadImageRegion_clickRegion6_clickSubmit_receivesCorrectFeedback() { + launchForExploration(TEST_EXPLORATION_ID_5).use { + startPlayingExploration() + waitForImageViewInteractionToFullyLoad() + + clickImageRegion(pointX = 0.5f, pointY = 0.5f) + clickSubmitAnswerButton() + + scrollToViewType(FEEDBACK) onView(withId(R.id.feedback_text_view)).check( matches( withText(containsString("Saturn")) @@ -461,80 +534,57 @@ class StateFragmentTest { } @Test + @RunOn(TestPlatform.ESPRESSO) // TODO(#1611): Enable for Robolectric. fun testStateFragment_loadImageRegion_submitButtonDisabled() { launchForExploration(TEST_EXPLORATION_ID_5).use { startPlayingExploration() - waitForExplorationToBeLoaded() - // TODO(#669): Remove explicit delay - https://github.com/oppia/oppia-android/issues/1523 - waitForTheView( - allOf( - withId(R.id.image_click_interaction_image_view), - WithNonZeroDimensionsMatcher() - ) - ) - scrollToSubmit() + waitForImageViewInteractionToFullyLoad() + + scrollToViewType(SUBMIT_ANSWER_BUTTON) + onView(withId(R.id.submit_answer_button)).check(matches(not(isClickable()))) } } @Test + @RunOn(TestPlatform.ESPRESSO) // TODO(#1611): Enable for Robolectric. fun loadImageRegion_defaultRegionClick_defaultRegionClicked_submitButtonDisabled() { launchForExploration(TEST_EXPLORATION_ID_5).use { startPlayingExploration() - waitForExplorationToBeLoaded() - // TODO(#669): Remove explicit delay - https://github.com/oppia/oppia-android/issues/1523 - waitForTheView( - allOf( - withId(R.id.image_click_interaction_image_view), - WithNonZeroDimensionsMatcher() - ) - ) - onView(withId(R.id.image_click_interaction_image_view)).perform( - clickPoint(0.1f, 0.5f) - ) - scrollToSubmit() + waitForImageViewInteractionToFullyLoad() + + clickImageRegion(pointX = 0.1f, pointY = 0.5f) + + scrollToViewType(SUBMIT_ANSWER_BUTTON) onView(withId(R.id.submit_answer_button)).check(matches(not(isClickable()))) } } @Test + @RunOn(TestPlatform.ESPRESSO) // TODO(#1611): Enable for Robolectric. fun testStateFragment_loadImageRegion_clickedRegion6_region6Clicked_submitButtonEnabled() { launchForExploration(TEST_EXPLORATION_ID_5).use { startPlayingExploration() - waitForExplorationToBeLoaded() - // TODO(#669): Remove explicit delay - https://github.com/oppia/oppia-android/issues/1523 - waitForTheView( - allOf( - withId(R.id.image_click_interaction_image_view), - WithNonZeroDimensionsMatcher() - ) - ) - onView(withId(R.id.image_click_interaction_image_view)).perform( - clickPoint(0.5f, 0.5f) - ) - scrollToSubmit() + waitForImageViewInteractionToFullyLoad() + + clickImageRegion(pointX = 0.5f, pointY = 0.5f) + + scrollToViewType(SUBMIT_ANSWER_BUTTON) onView(withId(R.id.submit_answer_button)).check(matches(isClickable())) } } @Test + @RunOn(TestPlatform.ESPRESSO) // TODO(#1611): Enable for Robolectric. fun testStateFragment_loadImageRegion_clickedRegion6_region6Clicked_correctFeedback() { launchForExploration(TEST_EXPLORATION_ID_5).use { startPlayingExploration() - waitForExplorationToBeLoaded() - // TODO(#669): Remove explicit delay - https://github.com/oppia/oppia-android/issues/1523 - waitForTheView( - allOf( - withId(R.id.image_click_interaction_image_view), - WithNonZeroDimensionsMatcher() - ) - ) - onView(withId(R.id.image_click_interaction_image_view)).perform( - clickPoint(0.5f, 0.5f) - ) - scrollToSubmit() - onView(withId(R.id.submit_answer_button)).perform(click()) - scrollToFeedback() + waitForImageViewInteractionToFullyLoad() + + clickImageRegion(pointX = 0.5f, pointY = 0.5f) + clickSubmitAnswerButton() + + scrollToViewType(FEEDBACK) onView(withId(R.id.feedback_text_view)).check( matches( withText(containsString("Saturn")) @@ -544,23 +594,16 @@ class StateFragmentTest { } @Test + @RunOn(TestPlatform.ESPRESSO) // TODO(#1611): Enable for Robolectric. fun testStateFragment_loadImageRegion_clickedRegion6_region6Clicked_correctAnswer() { launchForExploration(TEST_EXPLORATION_ID_5).use { startPlayingExploration() - waitForExplorationToBeLoaded() - // TODO(#669): Remove explicit delay - https://github.com/oppia/oppia-android/issues/1523 - waitForTheView( - allOf( - withId(R.id.image_click_interaction_image_view), - WithNonZeroDimensionsMatcher() - ) - ) - onView(withId(R.id.image_click_interaction_image_view)).perform( - clickPoint(0.5f, 0.5f) - ) - scrollToSubmit() - onView(withId(R.id.submit_answer_button)).perform(click()) - scrollToAnswer() + waitForImageViewInteractionToFullyLoad() + + clickImageRegion(pointX = 0.5f, pointY = 0.5f) + clickSubmitAnswerButton() + + scrollToViewType(SUBMITTED_ANSWER) onView(withId(R.id.submitted_answer_text_view)).check( matches( withText("Clicks on Saturn") @@ -570,49 +613,32 @@ class StateFragmentTest { } @Test + @RunOn(TestPlatform.ESPRESSO) // TODO(#1611): Enable for Robolectric. fun testStateFragment_loadImageRegion_clickedRegion6_region6Clicked_continueButtonIsDisplayed() { launchForExploration(TEST_EXPLORATION_ID_5).use { startPlayingExploration() - waitForExplorationToBeLoaded() - // TODO(#669): Remove explicit delay - https://github.com/oppia/oppia-android/issues/1523 - waitForTheView( - allOf( - withId(R.id.image_click_interaction_image_view), - WithNonZeroDimensionsMatcher() - ) - ) - onView(withId(R.id.image_click_interaction_image_view)).perform( - clickPoint(0.5f, 0.5f) - ) - scrollToSubmit() - onView(withId(R.id.submit_answer_button)).perform(click()) - scrollToContinue() + waitForImageViewInteractionToFullyLoad() + + clickImageRegion(pointX = 0.5f, pointY = 0.5f) + clickSubmitAnswerButton() + + scrollToViewType(CONTINUE_NAVIGATION_BUTTON) onView(withId(R.id.continue_navigation_button)).check(matches(isDisplayed())) } } @Test + @RunOn(TestPlatform.ESPRESSO) // TODO(#1611): Enable for Robolectric. fun loadImageRegion_clickRegion6_clickedRegion5_region5Clicked_correctFeedback() { launchForExploration(TEST_EXPLORATION_ID_5).use { startPlayingExploration() - waitForExplorationToBeLoaded() - onView(withId(R.id.submit_answer_button)).check(matches(not(isClickable()))) - // TODO(#669): Remove explicit delay - https://github.com/oppia/oppia-android/issues/1523 - waitForTheView( - allOf( - withId(R.id.image_click_interaction_image_view), - WithNonZeroDimensionsMatcher() - ) - ) - onView(withId(R.id.image_click_interaction_image_view)).perform( - clickPoint(0.5f, 0.5f) - ) - onView(withId(R.id.image_click_interaction_image_view)).perform( - clickPoint(0.2f, 0.5f) - ) - scrollToSubmit() - onView(withId(R.id.submit_answer_button)).perform(click()) - scrollToFeedback() + waitForImageViewInteractionToFullyLoad() + + clickImageRegion(pointX = 0.5f, pointY = 0.5f) + clickImageRegion(pointX = 0.2f, pointY = 0.5f) + clickSubmitAnswerButton() + + scrollToViewType(FEEDBACK) onView(withId(R.id.feedback_text_view)).check( matches( withText(containsString("Jupiter")) @@ -622,10 +648,12 @@ class StateFragmentTest { } @Test - fun testStateFragment_loadExp_changeConfiguration_firstState_previousAndNextButtonIsNotDisplayed() { // ktlint-disable max-line-length + fun testStateFragment_loadExp_changeConfiguration_firstState_prevAndNextButtonIsNotDisplayed() { launchForExploration(TEST_EXPLORATION_ID_2).use { startPlayingExploration() - onView(isRoot()).perform(orientationLandscape()) + + rotateToLandscape() + onView(withId(R.id.previous_state_navigation_button)).check(matches(not(isDisplayed()))) onView(withId(R.id.next_state_navigation_button)).check(doesNotExist()) } @@ -636,21 +664,20 @@ class StateFragmentTest { launchForExploration(TEST_EXPLORATION_ID_2).use { startPlayingExploration() - onView(withId(R.id.continue_button)).perform(click()) + clickContinueInteractionButton() - onView(withId(R.id.state_recycler_view)).perform(scrollToPosition(1)) onView(withId(R.id.previous_state_navigation_button)).check(matches(isDisplayed())) } } @Test - fun testStateFragment_loadExp_changeConfiguration_submitAnswer_clickContinueButton_previousButtonIsDisplayed() { // ktlint-disable max-line-length + fun testStateFragment_loadExp_changeConfig_submitAnswer_clickContinue_prevButtonIsDisplayed() { launchForExploration(TEST_EXPLORATION_ID_2).use { startPlayingExploration() - onView(isRoot()).perform(orientationLandscape()) - onView(withId(R.id.continue_button)).perform(click()) + rotateToLandscape() + + clickContinueInteractionButton() - onView(withId(R.id.state_recycler_view)).perform(scrollToPosition(2)) onView(withId(R.id.previous_state_navigation_button)).check(matches(isDisplayed())) } } @@ -659,44 +686,45 @@ class StateFragmentTest { fun testStateFragment_loadExp_submitAnswer_clickContinueThenPrevious_onlyNextButtonIsShown() { launchForExploration(TEST_EXPLORATION_ID_2).use { startPlayingExploration() - onView(withId(R.id.continue_button)).perform(click()) + clickContinueInteractionButton() - onView(withId(R.id.state_recycler_view)).perform(scrollToPosition(1)) - onView(withId(R.id.previous_state_navigation_button)).perform(click()) + clickPreviousNavigationButton() // Since we navigated back to the first state, only the next navigation button is visible. + scrollToViewType(NEXT_NAVIGATION_BUTTON) onView(withId(R.id.previous_state_navigation_button)).check(matches(not(isDisplayed()))) onView(withId(R.id.next_state_navigation_button)).check(matches(isDisplayed())) } } @Test - fun testStateFragment_loadExp_changeConfiguration_submitAnswer_clickContinueThenPrevious_onlyNextButtonIsShown() { // ktlint-disable max-line-length + fun testStateFragment_loadExp_changeConfig_submit_clickContinueThenPrev_onlyNextButtonShown() { launchForExploration(TEST_EXPLORATION_ID_2).use { startPlayingExploration() - onView(isRoot()).perform(orientationLandscape()) - onView(withId(R.id.continue_button)).perform(click()) + rotateToLandscape() + clickContinueInteractionButton() - onView(withId(R.id.state_recycler_view)).perform(scrollToPosition(2)) - onView(withId(R.id.previous_state_navigation_button)).perform(click()) + clickPreviousNavigationButton() // Since we navigated back to the first state, only the next navigation button is visible. + scrollToViewType(NEXT_NAVIGATION_BUTTON) onView(withId(R.id.previous_state_navigation_button)).check(matches(not(isDisplayed()))) onView(withId(R.id.next_state_navigation_button)).check(matches(isDisplayed())) } } @Test - fun testStateFragment_loadExp_submitAnswer_clickContinueThenPreviousThenNext_prevAndSubmitShown() { // ktlint-disable max-line-length + fun testStateFragment_loadExp_submitAnswer_clickContinueThenPrevThenNext_prevAndSubmitShown() { launchForExploration(TEST_EXPLORATION_ID_2).use { startPlayingExploration() - onView(withId(R.id.continue_button)).perform(click()) + clickContinueInteractionButton() - onView(withId(R.id.state_recycler_view)).perform(scrollToPosition(1)) - onView(withId(R.id.previous_state_navigation_button)).perform(click()) - onView(withId(R.id.next_state_navigation_button)).perform(click()) + clickPreviousNavigationButton() + clickNextNavigationButton() - // Navigating back to the second state should show the previous & submit buttons, but not the next button. + // Navigating back to the second state should show the previous & submit buttons, but not the + // next button. + scrollToViewType(SUBMIT_ANSWER_BUTTON) onView(withId(R.id.previous_state_navigation_button)).check(matches(isDisplayed())) onView(withId(R.id.submit_answer_button)).check(matches(isDisplayed())) onView(withId(R.id.next_state_navigation_button)).check(doesNotExist()) @@ -704,16 +732,18 @@ class StateFragmentTest { } @Test - fun testStateFragment_loadExp_changeConfiguration_submitAnswer_clickContinueThenPreviousThenNext_prevAndSubmitShown() { // ktlint-disable max-line-length + fun testStateFragment_loadExp_land_submit_clickContinueThenPrevThenNext_prevAndSubmitShown() { launchForExploration(TEST_EXPLORATION_ID_2).use { startPlayingExploration() - onView(withId(R.id.continue_button)).perform(click()) + rotateToLandscape() + clickContinueInteractionButton() - onView(withId(R.id.state_recycler_view)).perform(scrollToPosition(1)) - onView(withId(R.id.previous_state_navigation_button)).perform(click()) - onView(withId(R.id.next_state_navigation_button)).perform(click()) + clickPreviousNavigationButton() + clickNextNavigationButton() - // Navigating back to the second state should show the previous & submit buttons, but not the next button. + // Navigating back to the second state should show the previous & submit buttons, but not the + // next button. + scrollToViewType(SUBMIT_ANSWER_BUTTON) onView(withId(R.id.previous_state_navigation_button)).check(matches(isDisplayed())) onView(withId(R.id.submit_answer_button)).check(matches(isDisplayed())) onView(withId(R.id.next_state_navigation_button)).check(doesNotExist()) @@ -721,6 +751,7 @@ class StateFragmentTest { } @Test + @RunOn(TestPlatform.ESPRESSO) // TODO(#1612): Enable for Robolectric. fun testStateFragment_loadExp_continueToEndExploration_hasReturnToTopicButton() { launchForExploration(TEST_EXPLORATION_ID_2).use { startPlayingExploration() @@ -728,6 +759,7 @@ class StateFragmentTest { playThroughPrototypeExploration() // Ninth state: end exploration. + scrollToViewType(RETURN_TO_TOPIC_NAVIGATION_BUTTON) onView(withId(R.id.return_to_topic_button)).check( matches(withText(R.string.state_end_exploration_button)) ) @@ -735,13 +767,16 @@ class StateFragmentTest { } @Test - fun testStateFragment_loadExp_changeConfiguration_continueToEndExploration_hasReturnToTopicButton() { // ktlint-disable max-line-length + @RunOn(TestPlatform.ESPRESSO) // TODO(#1612): Enable for Robolectric. + fun testStateFragment_loadExp_changeConfiguration_continueToEnd_hasReturnToTopicButton() { launchForExploration(TEST_EXPLORATION_ID_2).use { startPlayingExploration() + rotateToLandscape() playThroughPrototypeExploration() // Ninth state: end exploration. + scrollToViewType(RETURN_TO_TOPIC_NAVIGATION_BUTTON) onView(withId(R.id.return_to_topic_button)).check( matches(withText(R.string.state_end_exploration_button)) ) @@ -749,12 +784,13 @@ class StateFragmentTest { } @Test + @RunOn(TestPlatform.ESPRESSO) // TODO(#1612): Enable for Robolectric. fun testStateFragment_loadExp_continueToEndExploration_clickReturnToTopic_destroysActivity() { launchForExploration(TEST_EXPLORATION_ID_2).use { startPlayingExploration() playThroughPrototypeExploration() - onView(withId(R.id.return_to_topic_button)).perform(click()) + clickReturnToTopicButton() // Due to the exploration activity finishing, the play button should be visible again. onView(withId(R.id.play_test_exploration_button)).check(matches(isDisplayed())) @@ -762,12 +798,14 @@ class StateFragmentTest { } @Test - fun testStateFragment_loadExp_changeConfiguration_continueToEndExploration_clickReturnToTopic_destroysActivity() { // ktlint-disable max-line-length + @RunOn(TestPlatform.ESPRESSO) // TODO(#1612): Enable for Robolectric. + fun testStateFragment_loadExp_changeConfig_continueToEnd_clickReturnToTopic_destroysActivity() { launchForExploration(TEST_EXPLORATION_ID_2).use { startPlayingExploration() + rotateToLandscape() playThroughPrototypeExploration() - onView(withId(R.id.return_to_topic_button)).perform(click()) + clickReturnToTopicButton() // Due to the exploration activity finishing, the play button should be visible again. onView(withId(R.id.play_test_exploration_button)).check(matches(isDisplayed())) @@ -779,6 +817,8 @@ class StateFragmentTest { launchForExploration(TEST_EXPLORATION_ID_0).use { startPlayingExploration() + scrollToViewType(CONTENT) + val htmlResult = "Hi, welcome to Oppia! is a tool that helps you create interactive learning " + "activities that can be continually improved over time.\n\nIncidentally, do you " + @@ -792,10 +832,12 @@ class StateFragmentTest { } @Test - fun testContentCard_forDemoExploration_changeConfiguration_withCustomOppiaTags_displaysParsedHtml() { // ktlint-disable max-line-length + fun testContentCard_forDemoExploration_changeConfig_withCustomOppiaTags_displaysParsedHtml() { launchForExploration(TEST_EXPLORATION_ID_0).use { startPlayingExploration() + scrollToViewType(CONTENT) + val htmlResult = "Hi, welcome to Oppia! is a tool that helps you create interactive learning activities " + "that can be continually improved over time.\n\nIncidentally, do you know where " + @@ -820,159 +862,218 @@ class StateFragmentTest { private fun startPlayingExploration() { onView(withId(R.id.play_test_exploration_button)).perform(click()) - waitForExplorationToBeLoaded() - } - - private fun waitForExplorationToBeLoaded() { - // TODO(#89): We should instead rely on IdlingResource to wait for the exploration to be fully loaded. Using - // standard activity transitions seems to work better than a fragment transaction for Espresso, but this isn't - // compatible with Robolectric since only one activity can be loaded at a time in Robolectric. - waitForTheView(withId(R.id.content_text_view)) + testCoroutineDispatchers.runCurrent() } private fun playThroughPrototypeExploration() { // First state: Continue interaction. - onView(withId(R.id.continue_button)).perform(click()) + clickContinueInteractionButton() // Second state: Fraction input. Correct answer: 1/2. - onView(withId(R.id.fraction_input_interaction_view)).perform( - typeText("1/2"), - closeSoftKeyboard() - ) - onView(withId(R.id.state_recycler_view)).perform(scrollToPosition(2)) - onView(withId(R.id.submit_answer_button)).perform(click()) - onView(withId(R.id.continue_navigation_button)).perform(click()) + typeFractionText("1/2") + clickSubmitAnswerButton() + clickContinueNavigationButton() // Third state: Multiple choice. Correct answer: Eagle. - onView( - atPositionOnView( - recyclerViewId = R.id.selection_interaction_recyclerview, - position = 2, - targetViewId = R.id.multiple_choice_radio_button - ) - ).perform(click()) - onView(withId(R.id.continue_navigation_button)).perform(click()) + selectMultipleChoiceOption(optionPosition = 2) + clickContinueNavigationButton() // Fourth state: Item selection (radio buttons). Correct answer: Green. - onView( - atPositionOnView( - recyclerViewId = R.id.selection_interaction_recyclerview, - position = 0, - targetViewId = R.id.multiple_choice_radio_button - ) - ).perform(click()) - onView(withId(R.id.continue_navigation_button)).perform(click()) + selectMultipleChoiceOption(optionPosition = 0) + clickContinueNavigationButton() // Fourth state: Item selection (checkboxes). Correct answer: {Red, Green, Blue}. + selectItemSelectionCheckbox(optionPosition = 0) + selectItemSelectionCheckbox(optionPosition = 2) + selectItemSelectionCheckbox(optionPosition = 3) + clickSubmitAnswerButton() + clickContinueNavigationButton() + + // Fifth state: Numeric input. Correct answer: 121. + typeNumericInput("121") + clickSubmitAnswerButton() + clickContinueNavigationButton() + + // Sixth state: Text input. Correct answer: finnish. + typeTextInput("finnish") + clickSubmitAnswerButton() + clickContinueNavigationButton() + + // Seventh state: Drag Drop Sort. Correct answer: Move 1st item to 4th position. + dragAndDropItem(fromPosition = 0, toPosition = 3) + clickSubmitAnswerButton() onView( atPositionOnView( - recyclerViewId = R.id.selection_interaction_recyclerview, + recyclerViewId = R.id.submitted_answer_recycler_view, position = 0, - targetViewId = R.id.item_selection_checkbox - ) - ).perform(click()) - onView( - atPositionOnView( - recyclerViewId = R.id.selection_interaction_recyclerview, - position = 2, - targetViewId = R.id.item_selection_checkbox + targetViewId = R.id.submitted_answer_content_text_view ) - ).perform(click()) + ).check(matches(withText("3/5"))) + clickContinueNavigationButton() + + // Eighth state: Drag Drop Sort with grouping. Correct answer: Merge First Two and after merging + // move 2nd item to 3rd position. + mergeDragAndDropItems(position = 1) + unlinkDragAndDropItems(position = 1) + mergeDragAndDropItems(position = 0) + dragAndDropItem(fromPosition = 1, toPosition = 2) + clickSubmitAnswerButton() onView( atPositionOnView( - recyclerViewId = R.id.selection_interaction_recyclerview, - position = 3, - targetViewId = R.id.item_selection_checkbox + recyclerViewId = R.id.submitted_answer_recycler_view, + position = 0, + targetViewId = R.id.submitted_answer_content_text_view ) - ).perform(click()) - onView(withId(R.id.state_recycler_view)).perform(scrollToPosition(2)) - onView(withId(R.id.submit_answer_button)).perform(click()) - onView(withId(R.id.continue_navigation_button)).perform(click()) + ).check(matches(withText("0.6"))) + clickContinueNavigationButton() + } - // Fifth state: Numeric input. Correct answer: 121. - onView(withId(R.id.numeric_input_interaction_view)).perform( - typeText("121"), - closeSoftKeyboard() - ) - onView(withId(R.id.submit_answer_button)).perform(click()) - onView(withId(R.id.continue_navigation_button)).perform(click()) + private fun rotateToLandscape() { + onView(isRoot()).perform(orientationLandscape()) + testCoroutineDispatchers.runCurrent() + } - // Sixth state: Text input. Correct answer: finnish. - onView(withId(R.id.text_input_interaction_view)).perform( - typeText("finnish"), - closeSoftKeyboard() - ) - onView(withId(R.id.submit_answer_button)).perform(click()) - onView(withId(R.id.continue_navigation_button)).perform(click()) + private fun clickContinueInteractionButton() { + scrollToViewType(CONTINUE_INTERACTION) + onView(withId(R.id.continue_button)).perform(click()) + testCoroutineDispatchers.runCurrent() + } - // Seventh state: Drag Drop Sort. Correct answer: Move 1st item to 4th position. + private fun typeFractionText(text: String) { + scrollToViewType(FRACTION_INPUT_INTERACTION) + typeTextIntoInteraction(text, interactionViewId = R.id.fraction_input_interaction_view) + } + + @Suppress("SameParameterValue") + private fun typeNumericInput(text: String) { + scrollToViewType(NUMERIC_INPUT_INTERACTION) + typeTextIntoInteraction(text, interactionViewId = R.id.numeric_input_interaction_view) + } + + @Suppress("SameParameterValue") + private fun typeTextInput(text: String) { + scrollToViewType(TEXT_INPUT_INTERACTION) + typeTextIntoInteraction(text, interactionViewId = R.id.text_input_interaction_view) + } + + private fun selectMultipleChoiceOption(optionPosition: Int) { + clickSelection(optionPosition, targetViewId = R.id.multiple_choice_radio_button) + } + + private fun selectItemSelectionCheckbox(optionPosition: Int) { + clickSelection(optionPosition, targetViewId = R.id.item_selection_checkbox) + } + + private fun dragAndDropItem(fromPosition: Int, toPosition: Int) { + scrollToViewType(DRAG_DROP_SORT_INTERACTION) onView(withId(R.id.drag_drop_interaction_recycler_view)).perform( DragViewAction( RecyclerViewCoordinatesProvider( - 0, + fromPosition, ChildViewCoordinatesProvider( R.id.drag_drop_item_container, GeneralLocation.CENTER ) ), - RecyclerViewCoordinatesProvider(3, CustomGeneralLocation.UNDER_RIGHT), + RecyclerViewCoordinatesProvider(toPosition, CustomGeneralLocation.UNDER_RIGHT), Press.FINGER ) ) + testCoroutineDispatchers.runCurrent() + } + + private fun mergeDragAndDropItems(position: Int) { + clickDragAndDropOption(position, targetViewId = R.id.drag_drop_content_group_item) + } + + private fun unlinkDragAndDropItems(position: Int) { + clickDragAndDropOption(position, targetViewId = R.id.drag_drop_content_unlink_items) + } + + @Suppress("SameParameterValue") + private fun clickImageRegion(pointX: Float, pointY: Float) { + onView(withId(R.id.image_click_interaction_image_view)).perform( + clickPoint(pointX, pointY) + ) + testCoroutineDispatchers.runCurrent() + } + + private fun clickSubmitAnswerButton() { + scrollToViewType(SUBMIT_ANSWER_BUTTON) onView(withId(R.id.submit_answer_button)).perform(click()) - onView( - atPositionOnView( - recyclerViewId = R.id.submitted_answer_recycler_view, - position = 0, - targetViewId = R.id.submitted_answer_content_text_view - ) - ).check(matches(withText("3/5"))) + testCoroutineDispatchers.runCurrent() + } + + private fun clickContinueNavigationButton() { + scrollToViewType(CONTINUE_NAVIGATION_BUTTON) onView(withId(R.id.continue_navigation_button)).perform(click()) + testCoroutineDispatchers.runCurrent() + } - // Eighth state: Drag Drop Sort with grouping. Correct answer: Merge First Two and after merging move 2nd item to 3rd position . - onView( - atPositionOnView( - recyclerViewId = R.id.drag_drop_interaction_recycler_view, - position = 1, - targetViewId = R.id.drag_drop_content_group_item + private fun clickReturnToTopicButton() { + scrollToViewType(RETURN_TO_TOPIC_NAVIGATION_BUTTON) + onView(withId(R.id.return_to_topic_button)).perform(click()) + testCoroutineDispatchers.runCurrent() + } + + private fun clickPreviousNavigationButton() { + onView(withId(R.id.previous_state_navigation_button)).perform(click()) + testCoroutineDispatchers.runCurrent() + } + + private fun clickNextNavigationButton() { + scrollToViewType(NEXT_NAVIGATION_BUTTON) + onView(withId(R.id.next_state_navigation_button)).perform(click()) + testCoroutineDispatchers.runCurrent() + } + + private fun waitForImageViewInteractionToFullyLoad() { + // TODO(#669): Remove explicit delay - https://github.com/oppia/oppia-android/issues/1523 + waitForTheView( + allOf( + withId(R.id.image_click_interaction_image_view), + WithNonZeroDimensionsMatcher() ) - ).perform(click()) + ) + } + + private fun typeTextIntoInteraction(text: String, interactionViewId: Int) { + onView(withId(interactionViewId)).perform( + appendText(text), + closeSoftKeyboard() + ) + testCoroutineDispatchers.runCurrent() + } + + private fun clickSelection(optionPosition: Int, targetViewId: Int) { + scrollToViewType(SELECTION_INTERACTION) onView( atPositionOnView( - recyclerViewId = R.id.drag_drop_interaction_recycler_view, - position = 1, - targetViewId = R.id.drag_drop_content_unlink_items + recyclerViewId = R.id.selection_interaction_recyclerview, + position = optionPosition, + targetViewId = targetViewId ) ).perform(click()) + testCoroutineDispatchers.runCurrent() + } + + private fun clickDragAndDropOption(position: Int, targetViewId: Int) { + scrollToViewType(DRAG_DROP_SORT_INTERACTION) onView( atPositionOnView( recyclerViewId = R.id.drag_drop_interaction_recycler_view, - position = 0, - targetViewId = R.id.drag_drop_content_group_item + position = position, + targetViewId = targetViewId ) ).perform(click()) - onView(withId(R.id.drag_drop_interaction_recycler_view)).perform( - DragViewAction( - RecyclerViewCoordinatesProvider( - 1, - ChildViewCoordinatesProvider( - R.id.drag_drop_item_container, - GeneralLocation.CENTER - ) - ), - RecyclerViewCoordinatesProvider(2, CustomGeneralLocation.UNDER_RIGHT), - Press.FINGER - ) + testCoroutineDispatchers.runCurrent() + } + + private fun scrollToViewType(viewType: StateItemViewModel.ViewType) { + onView(withId(R.id.state_recycler_view)).perform( + scrollToHolder(StateViewHolderTypeMatcher(viewType)) ) - onView(withId(R.id.submit_answer_button)).perform(click()) - onView( - atPositionOnView( - recyclerViewId = R.id.submitted_answer_recycler_view, - position = 0, - targetViewId = R.id.submitted_answer_content_text_view - ) - ).check(matches(withText("0.6"))) - onView(withId(R.id.continue_navigation_button)).perform(click()) + testCoroutineDispatchers.runCurrent() } private fun waitForTheView(viewMatcher: Matcher): ViewInteraction { @@ -980,10 +1081,7 @@ class StateFragmentTest { } private fun setUpTestApplicationComponent() { - DaggerStateFragmentTest_TestApplicationComponent.builder() - .setApplication(ApplicationProvider.getApplicationContext()) - .build() - .inject(this) + ApplicationProvider.getApplicationContext().inject(this) } // TODO(#59): Remove these waits once we can ensure that the production executors are not depended on in tests. @@ -996,6 +1094,7 @@ class StateFragmentTest { * Perform action of waiting for a specific matcher to finish. Adapted from: * https://stackoverflow.com/a/22563297/3689782. */ + @Suppress("SameParameterValue") private fun waitForMatch(viewMatcher: Matcher, millis: Long): ViewAction { return object : ViewAction { override fun getDescription(): String { @@ -1029,55 +1128,82 @@ class StateFragmentTest { } } - @Module - class TestModule { - @Provides - @Singleton - fun provideContext(application: Application): Context { - return application + /** + * [BaseMatcher] that matches against the first occurrence of the specified view holder type in + * StateFragment's RecyclerView. + */ + private class StateViewHolderTypeMatcher( + private val viewType: StateItemViewModel.ViewType + ) : BaseMatcher() { + override fun describeTo(description: Description?) { + description?.appendText("item view type of $viewType") } - // TODO(#89): Introduce a proper IdlingResource for background dispatchers to ensure they all complete before - // proceeding in an Espresso test. This solution should also be interoperative with Robolectric contexts by using a - // test coroutine dispatcher. + override fun matches(item: Any?): Boolean { + return (item as? RecyclerView.ViewHolder)?.itemViewType == viewType.ordinal + } + } - @Singleton - @Provides - @BackgroundDispatcher - fun provideBackgroundDispatcher(@BlockingDispatcher blockingDispatcher: CoroutineDispatcher): - CoroutineDispatcher { - return blockingDispatcher - } + /*** Returns a matcher that matches view based on non-zero width and height.*/ + private class WithNonZeroDimensionsMatcher : TypeSafeMatcher() { - @Singleton - @Provides - @BlockingDispatcher - fun provideBlockingDispatcher(): CoroutineDispatcher { - return MainThreadExecutor.asCoroutineDispatcher() + override fun matchesSafely(target: View): Boolean { + val targetWidth = target.width + val targetHeight = target.height + return targetWidth > 0 && targetHeight > 0 } - // TODO(#59): Either isolate these to their own shared test module, or use the real logging - // module in tests to avoid needing to specify these settings for tests. - @EnableConsoleLog - @Provides - fun provideEnableConsoleLog(): Boolean = true + override fun describeTo(description: Description) { + description.appendText("with non-zero width and height") + } + } - @EnableFileLog - @Provides - fun provideEnableFileLog(): Boolean = false + /** + * Appends the specified text to a view. This is needed because Robolectric doesn't seem to + * properly input digits for text views using 'android:digits'. See + * https://github.com/robolectric/robolectric/issues/5110 for specifics. + */ + private fun appendText(text: String): ViewAction { + val typeTextViewAction = typeText(text) + return object : ViewAction { + override fun getDescription(): String = typeTextViewAction.description - @GlobalLogLevel - @Provides - fun provideGlobalLogLevel(): LogLevel = LogLevel.VERBOSE + override fun getConstraints(): Matcher = typeTextViewAction.constraints - @CacheAssetsLocally + override fun perform(uiController: UiController?, view: View?) { + // Appending text only works on Robolectric, whereas Espresso needs to use typeText(). + if (Build.FINGERPRINT.contains("robolectric", ignoreCase = true)) { + (view as? EditText)?.append(text) + testCoroutineDispatchers.runCurrent() + } else { + typeTextViewAction.perform(uiController, view) + } + } + } + } + + @Module + class TestModule { + // Do not use caching to ensure URLs are always used as the main data source when loading audio. @Provides - fun provideCacheAssetsLocally(): Boolean = true + @CacheAssetsLocally + fun provideCacheAssetsLocally(): Boolean = false } @Singleton - @Component(modules = [TestModule::class, TestLogReportingModule::class]) - interface TestApplicationComponent { + @Component( + modules = [ + TestModule::class, TestDispatcherModule::class, ApplicationModule::class, + NetworkModule::class, LoggerModule::class, ContinueModule::class, FractionInputModule::class, + ItemSelectionInputModule::class, MultipleChoiceInputModule::class, + NumberWithUnitsRuleModule::class, NumericInputRuleModule::class, TextInputRuleModule::class, + DragDropSortInputModule::class, ImageClickInputModule::class, InteractionsModule::class, + GcsResourceModule::class, GlideImageLoaderModule::class, ImageParsingModule::class, + HtmlParserEntityTypeModule::class, QuestionModule::class, TestLogReportingModule::class, + TestAccessibilityModule::class, LogStorageModule::class + ] + ) + interface TestApplicationComponent: ApplicationComponent { @Component.Builder interface Builder { @BindsInstance @@ -1089,102 +1215,19 @@ class StateFragmentTest { fun inject(stateFragmentTest: StateFragmentTest) } - // TODO(#59): Move this to a general-purpose testing library that replaces all CoroutineExecutors with an - // Espresso-enabled executor service. This service should also allow for background threads to run in both Espresso - // and Robolectric to help catch potential race conditions, rather than forcing parallel execution to be sequential - // and immediate. - // NB: This also blocks on #59 to be able to actually create a test-only library. - /** - * An executor service that schedules all [Runnable]s to run asynchronously on the main thread. This is based on: - * https://android.googlesource.com/platform/packages/apps/TV/+/android-live-tv/src/com/android/tv/util/MainThreadExecutor.java. - */ - private object MainThreadExecutor : AbstractExecutorService() { - override fun isTerminated(): Boolean = false - - private val handler = Handler(Looper.getMainLooper()) - val countingResource = CountingIdlingResource("main_thread_executor_counting_idling_resource") - - override fun execute(command: Runnable?) { - countingResource.increment() - handler.post { - try { - command?.run() - } finally { - countingResource.decrement() - } - } - } - - override fun shutdown() { - throw UnsupportedOperationException() + class TestApplication : Application(), ActivityComponentFactory { + private val component: TestApplicationComponent by lazy { + DaggerStateFragmentTest_TestApplicationComponent.builder() + .setApplication(this) + .build() } - override fun shutdownNow(): MutableList { - throw UnsupportedOperationException() + fun inject(stateFragmentTest: StateFragmentTest) { + component.inject(stateFragmentTest) } - override fun isShutdown(): Boolean = false - - override fun awaitTermination(timeout: Long, unit: TimeUnit?): Boolean { - throw UnsupportedOperationException() - } - } - - private fun scrollToViewType(viewType: StateItemViewModel.ViewType): ViewAction { - return RecyclerViewActions.scrollToHolder(StateViewHolderTypeMatcher(viewType)) - } - - private fun scrollToSubmit() { - onView(withId(R.id.state_recycler_view)).perform( - scrollToViewType(SUBMIT_ANSWER_BUTTON) - ) - } - - private fun scrollToFeedback() { - onView(withId(R.id.state_recycler_view)).perform( - scrollToViewType(FEEDBACK) - ) - } - - private fun scrollToAnswer() { - onView(withId(R.id.state_recycler_view)).perform( - scrollToViewType(SUBMITTED_ANSWER) - ) - } - - private fun scrollToContinue() { - onView(withId(R.id.state_recycler_view)).perform( - scrollToViewType(CONTINUE_NAVIGATION_BUTTON) - ) - } - - /** - * [BaseMatcher] that matches against the first occurrence of the specified view holder type in - * StateFragment's RecyclerView. - */ - private class StateViewHolderTypeMatcher( - private val viewType: StateItemViewModel.ViewType - ) : BaseMatcher() { - override fun describeTo(description: Description?) { - description?.appendText("item view type of $viewType") - } - - override fun matches(item: Any?): Boolean { - return (item as? RecyclerView.ViewHolder)?.itemViewType == viewType.ordinal - } - } - - /*** Returns a matcher that matches view based on non-zero width and height.*/ - private class WithNonZeroDimensionsMatcher : TypeSafeMatcher() { - - override fun matchesSafely(target: View): Boolean { - val targetWidth = target.width - val targetHeight = target.height - return targetWidth > 0 && targetHeight > 0 - } - - override fun describeTo(description: Description) { - description.appendText("with non-zero width and height") + override fun createActivityComponent(activity: AppCompatActivity): ActivityComponent { + return component.getActivityComponentBuilderProvider().get().setActivity(activity).build() } } } diff --git a/testing/src/main/java/org/oppia/testing/OppiaTestAnnotations.kt b/testing/src/main/java/org/oppia/testing/OppiaTestAnnotations.kt new file mode 100644 index 00000000000..90cd692f802 --- /dev/null +++ b/testing/src/main/java/org/oppia/testing/OppiaTestAnnotations.kt @@ -0,0 +1,28 @@ +package org.oppia.testing + +import org.oppia.testing.TestPlatform.ROBOLECTRIC +import org.oppia.testing.TestPlatform.ESPRESSO + +/** Specifies a test platform to target in conjunction with [RunOn]. */ +enum class TestPlatform { + /** Corresponds to local tests run in the Java VM via Robolectric. */ + ROBOLECTRIC, + + /** Corresponds to instrumented tests that can run on a real device or emulator via Espresso. */ + ESPRESSO +} + +/** + * Test class or method annotation for specifying all of platforms which either the tests of the + * class or the specific method may run on. By default, tests are assumed to be able to run on both + * Espresso & Robolectric. + * + * The target platforms are specified as varargs of [TestPlatform]s. + * + * Note that this annotation only works if the test also has an [OppiaTestRule] hooked up. + * + * Note that when defined on both a class and a method, the list of platforms defined on the method + * is used and any defined at the class level are ignored. + */ +@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION) +annotation class RunOn(vararg val testPlatforms: TestPlatform = [ROBOLECTRIC, ESPRESSO]) diff --git a/testing/src/main/java/org/oppia/testing/OppiaTestRule.kt b/testing/src/main/java/org/oppia/testing/OppiaTestRule.kt new file mode 100644 index 00000000000..358ec27bec5 --- /dev/null +++ b/testing/src/main/java/org/oppia/testing/OppiaTestRule.kt @@ -0,0 +1,56 @@ +package org.oppia.testing + +import android.os.Build +import org.junit.AssumptionViolatedException +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement + +/** JUnit rule to enable [RunOn] test targeting. */ +class OppiaTestRule : TestRule { + override fun apply(base: Statement?, description: Description?): Statement { + return object : Statement() { + override fun evaluate() { + val targetPlatforms = description.getTargetPlatforms() + val currentPlatform = getCurrentPlatform() + if (currentPlatform in targetPlatforms) { + // Only run this test if it's targeting the current platform. + base?.evaluate() + } else { + // See https://github.com/junit-team/junit4/issues/116 for context. + throw AssumptionViolatedException( + "Test targeting ${targetPlatforms.toPluralDescription()} ignored on $currentPlatform" + ) + } + } + } + } + + private fun getCurrentPlatform(): TestPlatform { + return if (Build.FINGERPRINT.contains("robolectric", ignoreCase = true)) { + TestPlatform.ROBOLECTRIC + } else { + TestPlatform.ESPRESSO + } + } + + private companion object { + private fun Array.toPluralDescription(): String { + return if (size > 1) "platforms ${this.joinToString()}" else "platform ${this.first()}" + } + + private fun Description?.getTargetPlatforms(): Array { + val methodTargetPlatforms = this?.getTargetTestPlatforms() + val classTargetPlatforms = this?.testClass?.getTargetTestPlatforms() + return methodTargetPlatforms ?: classTargetPlatforms ?: TestPlatform.values() + } + + private fun Description.getTargetTestPlatforms(): Array? { + return getAnnotation(RunOn::class.java)?.testPlatforms + } + + private fun Class.getTargetTestPlatforms(): Array? { + return getAnnotation(RunOn::class.java)?.testPlatforms + } + } +} diff --git a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchers.kt b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchers.kt index 72381ede582..316a31550ec 100644 --- a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchers.kt +++ b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchers.kt @@ -1,7 +1,5 @@ package org.oppia.testing -import kotlinx.coroutines.ExperimentalCoroutinesApi - // TODO(#1274): Add thorough testing for this class. /** @@ -36,7 +34,6 @@ interface TestCoroutineDispatchers { * state since it doesn't change the clock time. If a test needs to advance time to complete some * operation, it should use [advanceTimeBy]. */ - @ExperimentalCoroutinesApi fun runCurrent() /** @@ -52,7 +49,6 @@ interface TestCoroutineDispatchers { * should be used, instead. [advanceUntilIdle] should be reserved for cases when the test needs to * wait for a future operation, but doesn't know how long. */ - @ExperimentalCoroutinesApi fun advanceTimeBy(delayTimeMillis: Long) /** @@ -66,6 +62,5 @@ interface TestCoroutineDispatchers { * preferred methods for synchronizing execution with tests since this method may have the * unintentional side effect of executing future tasks before the test anticipates it. */ - @ExperimentalCoroutinesApi fun advanceUntilIdle() } From 51eb59edd9f614e5357792e5cd985855e7f72c6c Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Mon, 10 Aug 2020 23:21:21 -0700 Subject: [PATCH 05/36] Enable StateFragmentTest (Robolectric) & StateFragmentLocalTest for CI. --- .github/workflows/main.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 42b612e201a..1b2c6c41dc5 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -110,10 +110,10 @@ jobs: with: java-version: 1.9 - - name: Robolectric tests - FAQ, Help, Mydownloads, Parser, ProfileProgress, RecyclerView, Story, Utility tests + - name: Robolectric tests - FAQ, Help, Mydownloads, Parser, ProfileProgress, RecyclerView, State, Story, Utility tests # We require 'sudo' to avoid an error of the existing android sdk. See https://github.com/actions/starter-workflows/issues/58 run: | - sudo ./gradlew :app:testDebugUnitTest --tests org.oppia.app.faq* --tests org.oppia.app.help* --tests org.oppia.app.mydownloads* --tests org.oppia.app.parser* --tests org.oppia.app.profileprogress* --tests org.oppia.app.recyclerview* --tests org.oppia.app.story* --tests org.oppia.app.utility* + sudo ./gradlew :app:testDebugUnitTest --tests org.oppia.app.faq* --tests org.oppia.app.help* --tests org.oppia.app.mydownloads* --tests org.oppia.app.parser* --tests org.oppia.app.player.state* --tests org.oppia.app.profileprogress* --tests org.oppia.app.recyclerview* --tests org.oppia.app.story* --tests org.oppia.app.utility* - name: Upload App Test Reports uses: actions/upload-artifact@v2 if: ${{ always() }} # IMPORTANT: Upload reports regardless of status From ee0de598677129ca4950ac59ab8b54202df09fd3 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Tue, 11 Aug 2020 12:44:21 -0700 Subject: [PATCH 06/36] Add thorough documentation for new dispatchers. --- .../java/org/oppia/testing/FakeSystemClock.kt | 51 +++++++++++------ .../oppia/testing/TestCoroutineDispatcher.kt | 55 ++++++++++++++++++- .../TestCoroutineDispatcherEspressoImpl.kt | 13 +++++ .../TestCoroutineDispatcherRobolectricImpl.kt | 21 ++++--- .../oppia/testing/TestCoroutineDispatchers.kt | 46 ++++++++++++---- 5 files changed, 146 insertions(+), 40 deletions(-) diff --git a/testing/src/main/java/org/oppia/testing/FakeSystemClock.kt b/testing/src/main/java/org/oppia/testing/FakeSystemClock.kt index eba1f8f0074..78e68d13fd8 100644 --- a/testing/src/main/java/org/oppia/testing/FakeSystemClock.kt +++ b/testing/src/main/java/org/oppia/testing/FakeSystemClock.kt @@ -6,11 +6,12 @@ import java.util.concurrent.atomic.AtomicLong import javax.inject.Inject import javax.inject.Singleton -// TODO(#89): Actually finish this implementation so that it properly works across Robolectric and -// Espresso, and add tests for it. +// TODO(#89): Add tests for this implementation. /** - * A Robolectric-specific fake for the system clock that can be used to manipulate time in a - * consistent way. + * A fake for the system clock that can be used to manipulate time in a consistent way. + * + * Note that time manipulation only applies to Robolectric--this clock will no-op in Espresso tests + * since those tests always run in real-time. */ @Singleton class FakeSystemClock @Inject constructor(@IsOnRobolectric private val isOnRobolectric: Boolean) { @@ -18,8 +19,8 @@ class FakeSystemClock @Inject constructor(@IsOnRobolectric private val isOnRobol private val currentTimeMillis: AtomicLong init { - val initialMillis = timeCoordinator.getCurrentTime() - timeCoordinator.setCurrentTime(initialMillis) + val initialMillis = timeCoordinator.getCurrentTimeMillis() + timeCoordinator.setCurrentTimeMillis(initialMillis) currentTimeMillis = AtomicLong(initialMillis) } @@ -34,18 +35,29 @@ class FakeSystemClock @Inject constructor(@IsOnRobolectric private val isOnRobol */ fun advanceTime(millis: Long): Long { val newTime = currentTimeMillis.addAndGet(millis) - timeCoordinator.advanceTimeTo(newTime) + timeCoordinator.advanceTimeMillisTo(newTime) return newTime } + /** A test platform-specific time coordinator. */ private sealed class TimeCoordinator { - abstract fun getCurrentTime(): Long + /** Returns the current wall time, in milliseconds. */ + abstract fun getCurrentTimeMillis(): Long - abstract fun advanceTimeTo(timeMillis: Long) + /** + * Advances the fake clock to the specified time in milliseconds, including running any + * operations that are pending up to this point. + */ + abstract fun advanceTimeMillisTo(timeMillis: Long) - abstract fun setCurrentTime(timeMillis: Long) + /** + * Advances the fake clock to the specified time in milliseconds, but does not explicitly + * execute any tasks scheduled between now and the new time. + */ + abstract fun setCurrentTimeMillis(timeMillis: Long) internal companion object { + /** Returns the [TimeCoordinator] based on the current test platform being used. */ internal fun retrieveTimeCoordinator(isOnRobolectric: Boolean): TimeCoordinator { return if (isOnRobolectric) { RobolectricTimeCoordinator @@ -55,22 +67,26 @@ class FakeSystemClock @Inject constructor(@IsOnRobolectric private val isOnRobol } } + /** + * Robolectric-specific [TimeCoordinator] that manages fake time to simplify test execution + * coordination. + */ private object RobolectricTimeCoordinator : TimeCoordinator() { private val robolectricClass by lazy { loadRobolectricClass() } private val foregroundScheduler by lazy { loadForegroundScheduler() } private val retrieveCurrentTimeMethod by lazy { loadRetrieveCurrentTimeMethod() } private val retrieveAdvanceToMethod by lazy { loadAdvanceToMethod() } - override fun getCurrentTime(): Long { + override fun getCurrentTimeMillis(): Long { return retrieveCurrentTimeMethod.invoke(foregroundScheduler) as Long } - override fun advanceTimeTo(timeMillis: Long) { + override fun advanceTimeMillisTo(timeMillis: Long) { retrieveAdvanceToMethod.invoke(foregroundScheduler, timeMillis) - setCurrentTime(timeMillis) + setCurrentTimeMillis(timeMillis) } - override fun setCurrentTime(timeMillis: Long) { + override fun setCurrentTimeMillis(timeMillis: Long) { SystemClock.setCurrentTimeMillis(timeMillis) } @@ -96,19 +112,20 @@ class FakeSystemClock @Inject constructor(@IsOnRobolectric private val isOnRobol } } + /** Espresso-specific [TimeCoordinator] that no-ops in favor of using the real clock. */ private object EspressoTimeCoordinator : TimeCoordinator() { - override fun getCurrentTime(): Long { + override fun getCurrentTimeMillis(): Long { // Assume that time remains fixed. return 0 } - override fun advanceTimeTo(timeMillis: Long) { + override fun advanceTimeMillisTo(timeMillis: Long) { // Espresso runs in real-time. Delays don't actually work in the same way. Callers should // make use of idling resource to properly communicate to Espresso when coroutines have // finished executing. } - override fun setCurrentTime(timeMillis: Long) { + override fun setCurrentTimeMillis(timeMillis: Long) { // Don't override the system time on Espresso since devices require apps to have special // permissions to do so. It's also unnecessary since the coroutine dispatchers only need to // synchronize on the fake clock's internal time. diff --git a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcher.kt b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcher.kt index 246f0b57d84..84b3b162e8c 100644 --- a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcher.kt +++ b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcher.kt @@ -1,4 +1,4 @@ -package org.oppia.testing; +package org.oppia.testing import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Delay @@ -6,9 +6,37 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.InternalCoroutinesApi import kotlinx.coroutines.test.DelayController +/** + * Replacement for Kotlin's test coroutine dispatcher that can be used to replace coroutine + * dispatching functionality in Robolectric & Espresso tests in a way that can be coordinated across + * multiple dispatchers for execution synchronization. + * + * Developers should never use this dispatcher directly. Integrating with it should be done via + * [TestDispatcherModule] and ensuring thread synchronization should be done via + * [TestCoroutineDispatchers]. Attempting to interact directly with this dispatcher may cause timing + * inconsistencies between the UI thread and other application coroutine dispatchers. + * + * Further, no assumptions should be made about the coordination functionality of this utility on + * Robolectric or Espresso. The implementation carefully manages the differences between these two + * platforms, so tests should only rely on the API. See [TestCoroutineDispatchers] for more details + * on how to properly integrate with the test coroutine dispatcher API. + */ @InternalCoroutinesApi @ExperimentalCoroutinesApi abstract class TestCoroutineDispatcher: CoroutineDispatcher(), Delay, DelayController { + /** + * Returns whether there are any tasks known to the dispatcher that have not yet been started. + * + * Note that some of these tasks may be scheduled for the future. This is meant to be used in + * conjunction with [advanceTimeBy] since that will execute all tasks up to the new time. If the + * time returned by [getNextFutureTaskCompletionTimeMillis] is passed to [advanceTimeBy], this + * dispatcher guarantees that [hasPendingTasks] will return false after [advanceTimeBy] returns. + * + * This function makes no guarantees about idleness with respect to other dispatchers (e.g. even + * if all tasks are executed, another dispatcher could schedule another task on this dispatcher in + * response to a task from this dispatcher being executed). Cross-thread communication should be + * managed using [TestCoroutineDispatchers], instead. + */ abstract fun hasPendingTasks(): Boolean /** @@ -16,19 +44,40 @@ abstract class TestCoroutineDispatcher: CoroutineDispatcher(), Delay, DelayContr * task cannot execute right now due to its execution time being in the future). */ abstract fun getNextFutureTaskCompletionTimeMillis(timeMillis: Long): Long? + + /** + * Returns whether there are any tasks that are immediately executable and pending. + * + * If [runCurrent] is used, this function is guaranteed to return false after that function + * returns. Note that the same threading caveats mentioned for [hasPendingTasks] also pertains to + * this function. + */ abstract fun hasPendingCompletableTasks(): Boolean + /** Sets a [TaskIdleListener] to observe when the dispatcher becomes idle/non-idle. */ abstract fun setTaskIdleListener(taskIdleListener: TaskIdleListener) + /** A listener for whether the test coroutine dispatcher has become idle. */ interface TaskIdleListener { - // Can be called on different threads. + /** + * Called when the dispatcher has become non-idle. This may be called immediately after + * registration, and may be called on different threads. + */ fun onDispatcherRunning() - // Can be called on different threads. + /** + * Called when the dispatcher has become idle. This may be called immediately after + * registration, and may be called on different threads. + */ fun onDispatcherIdle() } + /** Injectable factory for creating the correct dispatcher for current test platform. */ interface Factory { + /** + * Returns a new [TestCoroutineDispatcher] with the specified [CoroutineDispatcher] to back it + * up for actual task execution. + */ fun createDispatcher(realDispatcher: CoroutineDispatcher): TestCoroutineDispatcher } } diff --git a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcherEspressoImpl.kt b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcherEspressoImpl.kt index 3d0cef775ed..440873d956b 100644 --- a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcherEspressoImpl.kt +++ b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcherEspressoImpl.kt @@ -13,6 +13,15 @@ import javax.inject.Inject import kotlin.coroutines.CoroutineContext import kotlin.math.min +/** + * Espresso-specific implementation of [TestCoroutineDispatcher]. + * + * This class forwards all execution immediately to the backing [CoroutineDispatcher] since it + * assumes all tasks are running with a real-time clock to mimic production behavior. This + * implementation also tracks the running state of tasks in order to support Espresso-specific + * idling resources (though it's up to the caller of this class to actually hook up an idling + * resource for this purpose). + */ @InternalCoroutinesApi @ExperimentalCoroutinesApi class TestCoroutineDispatcherEspressoImpl private constructor( @@ -156,6 +165,10 @@ class TestCoroutineDispatcherEspressoImpl private constructor( taskIdleListener?.takeIf { executingTaskCount.get() == 0 }?.onDispatcherIdle() } + /** + * Injectable implementation of [TestCoroutineDispatcher.Factory] for + * [TestCoroutineDispatcherEspressoImpl]. + */ class FactoryImpl @Inject constructor() : Factory { override fun createDispatcher(realDispatcher: CoroutineDispatcher): TestCoroutineDispatcher { return TestCoroutineDispatcherEspressoImpl(realDispatcher) diff --git a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcherRobolectricImpl.kt b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcherRobolectricImpl.kt index 1f06a50814b..e6d697c610c 100644 --- a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcherRobolectricImpl.kt +++ b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcherRobolectricImpl.kt @@ -17,14 +17,15 @@ import kotlin.coroutines.CoroutineContext // off of the internal coroutine API. /** - * Replacement for Kotlin's test coroutine dispatcher that can be used to replace coroutine - * dispatching functionality in a Robolectric test in a way that can be coordinated across multiple - * dispatchers for execution synchronization. + * Robolectric-specific implementation of [TestCoroutineDispatcher]. * - * Developers should never use this dispatcher directly. Integrating with it should be done via - * [TestDispatcherModule] and ensuring thread synchronization should be done via - * [TestCoroutineDispatchers]. Attempting to interact directly with this dispatcher may cause timing - * inconsistencies between the UI thread and other application coroutine dispatchers. + * This implementation makes use of a fake clock & event queue to manage tasks scheduled both for + * the present and the future. It executes tasks on a real coroutine dispatcher, but only when it's + * time for the task to run per the fake clock & the task's location in the event queue. + * + * Note that not all functionality in [TestCoroutineDispatcher]'s superclasses are implemented here, + * and other functionality is delegated to [TestCoroutineDispatchers] to ensure proper thread + * safety. */ @InternalCoroutinesApi @ExperimentalCoroutinesApi @@ -212,9 +213,13 @@ class TestCoroutineDispatcherRobolectricImpl private constructor( taskIdleListener?.takeIf { executingTaskCount.get() == 0 }?.onDispatcherIdle() } + /** + * Injectable implementation of [TestCoroutineDispatcher.Factory] for + * [TestCoroutineDispatcherEspressoImpl]. + */ class FactoryImpl @Inject constructor( private val fakeSystemClock: FakeSystemClock - ) : TestCoroutineDispatcher.Factory { + ) : Factory { override fun createDispatcher(realDispatcher: CoroutineDispatcher): TestCoroutineDispatcher { return TestCoroutineDispatcherRobolectricImpl(fakeSystemClock, realDispatcher) } diff --git a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchers.kt b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchers.kt index 72381ede582..91bba618ad3 100644 --- a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchers.kt +++ b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchers.kt @@ -1,13 +1,11 @@ package org.oppia.testing -import kotlinx.coroutines.ExperimentalCoroutinesApi - // TODO(#1274): Add thorough testing for this class. /** - * Helper class to coordinate execution between all threads currently running in a test environment, - * using both Robolectric for the main thread and [TestCoroutineDispatcher] for application-specific - * threads. + * Helper class to coordinate execution between all threads currently running in a test environment + * in a way that's interoperable with both Robolectric & Espresso to guarantee background & UI + * thread synchronization and determinism. * * This class should be used at any point in a test where the test should ensure that a clean thread * synchronization point is needed (such as after an async operation is kicked off). This class can @@ -19,13 +17,40 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi * mode so that clock coordination is consistent between Robolectric's scheduler and this utility * class, otherwise unexpected inconsistencies may arise. * - * *NOTE TO DEVELOPERS*: This class is NOT yet ready for broad use until after #89 is resolved. - * Please ask in oppia-android-dev if you have a use case that you think requires this class. - * Specific cases will be allowed to integrate with if other options are infeasible. Other tests - * should rely on existing mechanisms until this utility is ready for broad use. + * It's also recommended that all Espresso tests register the idling resource provided by this class + * (see [registerIdlingResource]) to get the same synchronization benefits of the + * Robolectric-specific API methods (e.g. [runCurrent], [advanceTimeBy], and [advanceUntilIdle]). + * + * *NOTE TO DEVELOPERS*: This class is NOT yet ready for broad use until after #89 is fully + * resolved. Please ask in oppia-android-dev if you have a use case that you think requires this + * class. Specific cases will be allowed to integrate with if other options are infeasible. Other + * tests should rely on existing mechanisms until this utility is ready for broad use. */ interface TestCoroutineDispatchers { + /** + * Registers an Espresso idling resource. + * + * Espresso tests use a real-time clock which means the normal synchronization mechanisms that + * this API provides (e.g. [runCurrent] and [advanceTimeBy]) are insufficient for proper + * synchronization (in fact, these methods effectively do nothing for Espresso tests). Instead, an + * idling resource allows the Espresso framework to synchronize the main thread against background + * coroutine dispatchers--it will stop Espresso actions & matchers from running while there are + * pending tasks. To ensure deterministic behavior, this class guarantees *all* coroutines will be + * completed prior to Espresso reaching an idle state (even if those coroutines are scheduled for + * the feature or are scheduled as the result of another coroutine executing). + * + * All tests targeting Espresso & Robolectric should make use of both the idling resource & direct + * synchronization APIs that this class provides. + * + * [unregisterIdlingResource] should be used during test tear-down to ensure the resource is + * de-registered. + */ fun registerIdlingResource() + + /** + * Unregisters a previously registered idling resource. See [registerIdlingResource] for + * specifics. + */ fun unregisterIdlingResource() /** @@ -36,7 +61,6 @@ interface TestCoroutineDispatchers { * state since it doesn't change the clock time. If a test needs to advance time to complete some * operation, it should use [advanceTimeBy]. */ - @ExperimentalCoroutinesApi fun runCurrent() /** @@ -52,7 +76,6 @@ interface TestCoroutineDispatchers { * should be used, instead. [advanceUntilIdle] should be reserved for cases when the test needs to * wait for a future operation, but doesn't know how long. */ - @ExperimentalCoroutinesApi fun advanceTimeBy(delayTimeMillis: Long) /** @@ -66,6 +89,5 @@ interface TestCoroutineDispatchers { * preferred methods for synchronizing execution with tests since this method may have the * unintentional side effect of executing future tasks before the test anticipates it. */ - @ExperimentalCoroutinesApi fun advanceUntilIdle() } From a30280a7af2414f36e38d3b70dd431bc3ffd59ff Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Tue, 11 Aug 2020 18:06:11 -0700 Subject: [PATCH 07/36] Clean up comments & add additional documentation. --- .../testing/TestCoroutineDispatchersEspressoImpl.kt | 9 ++++++--- .../testing/TestCoroutineDispatchersRobolectricImpl.kt | 7 +++++++ .../src/main/java/org/oppia/testing/TestingQualifiers.kt | 4 ++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchersEspressoImpl.kt b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchersEspressoImpl.kt index 75cfe918701..3c475904ff1 100644 --- a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchersEspressoImpl.kt +++ b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchersEspressoImpl.kt @@ -8,6 +8,12 @@ import kotlinx.coroutines.InternalCoroutinesApi import org.oppia.testing.TestCoroutineDispatcher.TaskIdleListener import javax.inject.Inject +/** + * Espresso-specific implementation of [TestCoroutineDispatchers]. + * + * This utilizes a real-time clock and provides hooks for an [IdlingResource] that Espresso can use + * to monitor background coroutines being run as part of the application. + */ @InternalCoroutinesApi @ExperimentalCoroutinesApi class TestCoroutineDispatchersEspressoImpl @Inject constructor( @@ -52,9 +58,6 @@ class TestCoroutineDispatchersEspressoImpl @Inject constructor( blockingTestDispatcher.hasPendingCompletableTasks() } - // TODO: make this based on real-time. If running in Espresso, make the test coroutine dispatchers - // use real-time and no-op the runCurrent/advanceUntilIdle since they are implied (or, rather, - // make them call onIdle() since that's effectively what they're proxying in Robolectric land). private inner class TestCoroutineDispatcherIdlingResource : IdlingResource { private var resourceCallback: IdlingResource.ResourceCallback? = null diff --git a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchersRobolectricImpl.kt b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchersRobolectricImpl.kt index e9d2f9a0a03..3485d8a7f19 100644 --- a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchersRobolectricImpl.kt +++ b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchersRobolectricImpl.kt @@ -7,6 +7,13 @@ import java.time.Duration import java.util.* import javax.inject.Inject +/** + * Robolectric-specific implementation of [TestCoroutineDispatchers]. + * + * Unlike its Espresso counterpart, this implementation does not provide an idling resource. + * Instead, tests should leverage functions like [runCurrent] and [advanceTimeBy] to run tasks in a + * coordinated, deterministic, and thread-safe way. + */ @InternalCoroutinesApi @ExperimentalCoroutinesApi class TestCoroutineDispatchersRobolectricImpl @Inject constructor( diff --git a/testing/src/main/java/org/oppia/testing/TestingQualifiers.kt b/testing/src/main/java/org/oppia/testing/TestingQualifiers.kt index 4fc4b5e7251..79e64528634 100644 --- a/testing/src/main/java/org/oppia/testing/TestingQualifiers.kt +++ b/testing/src/main/java/org/oppia/testing/TestingQualifiers.kt @@ -2,5 +2,9 @@ package org.oppia.testing import javax.inject.Qualifier +/** + * Qualifier that corresponds to an injectable boolean indicating whether the current test is + * running on Robolectric (true) or Espresso (false). + */ @Qualifier annotation class IsOnRobolectric From e33830f4c66f91b1f95e2431eb72eb2e09902fd1 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Tue, 11 Aug 2020 18:12:14 -0700 Subject: [PATCH 08/36] Fix lint errors. --- .../oppia/app/splash/SplashActivityTest.kt | 2 +- .../org/oppia/testing/FakeExceptionLogger.kt | 1 - ...estingQualifiers.kt => IsOnRobolectric.kt} | 0 .../java/org/oppia/testing/OppiaTestRunner.kt | 2 +- .../oppia/testing/TestAccessibilityModule.kt | 4 +++- .../oppia/testing/TestCoroutineDispatcher.kt | 2 +- .../TestCoroutineDispatcherEspressoImpl.kt | 21 +++++++++++-------- .../TestCoroutineDispatcherRobolectricImpl.kt | 21 +++++++++++-------- ...TestCoroutineDispatchersRobolectricImpl.kt | 5 +++-- .../oppia/testing/TestLogReportingModule.kt | 1 - 10 files changed, 33 insertions(+), 26 deletions(-) rename testing/src/main/java/org/oppia/testing/{TestingQualifiers.kt => IsOnRobolectric.kt} (100%) diff --git a/app/src/sharedTest/java/org/oppia/app/splash/SplashActivityTest.kt b/app/src/sharedTest/java/org/oppia/app/splash/SplashActivityTest.kt index 6112f01c90a..3649c3f369a 100644 --- a/app/src/sharedTest/java/org/oppia/app/splash/SplashActivityTest.kt +++ b/app/src/sharedTest/java/org/oppia/app/splash/SplashActivityTest.kt @@ -164,7 +164,7 @@ class SplashActivityTest { TestAccessibilityModule::class, LogStorageModule::class ] ) - interface TestApplicationComponent: ApplicationComponent { + interface TestApplicationComponent : ApplicationComponent { @Component.Builder interface Builder { @BindsInstance diff --git a/testing/src/main/java/org/oppia/testing/FakeExceptionLogger.kt b/testing/src/main/java/org/oppia/testing/FakeExceptionLogger.kt index ef3336aa540..a9a0c504ff5 100644 --- a/testing/src/main/java/org/oppia/testing/FakeExceptionLogger.kt +++ b/testing/src/main/java/org/oppia/testing/FakeExceptionLogger.kt @@ -25,5 +25,4 @@ class FakeExceptionLogger @Inject constructor() : ExceptionLogger { /** Returns true if there are no exceptions logged. */ fun noExceptionsPresent(): Boolean = exceptionList.isEmpty() - } diff --git a/testing/src/main/java/org/oppia/testing/TestingQualifiers.kt b/testing/src/main/java/org/oppia/testing/IsOnRobolectric.kt similarity index 100% rename from testing/src/main/java/org/oppia/testing/TestingQualifiers.kt rename to testing/src/main/java/org/oppia/testing/IsOnRobolectric.kt diff --git a/testing/src/main/java/org/oppia/testing/OppiaTestRunner.kt b/testing/src/main/java/org/oppia/testing/OppiaTestRunner.kt index c38e104c6e8..f45023c153f 100644 --- a/testing/src/main/java/org/oppia/testing/OppiaTestRunner.kt +++ b/testing/src/main/java/org/oppia/testing/OppiaTestRunner.kt @@ -19,7 +19,7 @@ import java.lang.reflect.Field * contexts with the same code declaration. */ @Suppress("unused") // This class is used directly by Gradle during instrumentation test setup. -class OppiaTestRunner: AndroidJUnitRunner() { +class OppiaTestRunner : AndroidJUnitRunner() { private lateinit var applicationClassLoader: ClassLoader private lateinit var application: Application diff --git a/testing/src/main/java/org/oppia/testing/TestAccessibilityModule.kt b/testing/src/main/java/org/oppia/testing/TestAccessibilityModule.kt index fe30e2a8c29..13753bded32 100644 --- a/testing/src/main/java/org/oppia/testing/TestAccessibilityModule.kt +++ b/testing/src/main/java/org/oppia/testing/TestAccessibilityModule.kt @@ -10,5 +10,7 @@ import org.oppia.util.accessibility.FakeAccessibilityManager interface TestAccessibilityModule { @Binds - fun bindFakeAccessibilityManager(fakeAccessibilityManager: FakeAccessibilityManager): CustomAccessibilityManager + fun bindFakeAccessibilityManager( + fakeAccessibilityManager: FakeAccessibilityManager + ): CustomAccessibilityManager } diff --git a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcher.kt b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcher.kt index 84b3b162e8c..239d3eb162e 100644 --- a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcher.kt +++ b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcher.kt @@ -23,7 +23,7 @@ import kotlinx.coroutines.test.DelayController */ @InternalCoroutinesApi @ExperimentalCoroutinesApi -abstract class TestCoroutineDispatcher: CoroutineDispatcher(), Delay, DelayController { +abstract class TestCoroutineDispatcher : CoroutineDispatcher(), Delay, DelayController { /** * Returns whether there are any tasks known to the dispatcher that have not yet been started. * diff --git a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcherEspressoImpl.kt b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcherEspressoImpl.kt index 440873d956b..081f47f32f4 100644 --- a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcherEspressoImpl.kt +++ b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcherEspressoImpl.kt @@ -26,7 +26,7 @@ import kotlin.math.min @ExperimentalCoroutinesApi class TestCoroutineDispatcherEspressoImpl private constructor( private val realCoroutineDispatcher: CoroutineDispatcher -): TestCoroutineDispatcher() { +) : TestCoroutineDispatcher() { private val realCoroutineScope by lazy { CoroutineScope(realCoroutineDispatcher) } private val executingTaskCount = AtomicInteger(0) @@ -46,15 +46,18 @@ class TestCoroutineDispatcherEspressoImpl private constructor( // Tasks immediately will start running, so track the task immediately. executingTaskCount.incrementAndGet() notifyIfRunning() - realCoroutineDispatcher.dispatch(context, kotlinx.coroutines.Runnable { - try { - block.run() - } finally { - executingTaskCount.decrementAndGet() - taskCompletionTimes.remove(taskId) + realCoroutineDispatcher.dispatch( + context, + kotlinx.coroutines.Runnable { + try { + block.run() + } finally { + executingTaskCount.decrementAndGet() + taskCompletionTimes.remove(taskId) + } + notifyIfIdle() } - notifyIfIdle() - }) + ) } override fun scheduleResumeAfterDelay( diff --git a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcherRobolectricImpl.kt b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcherRobolectricImpl.kt index e6d697c610c..811617f4c0a 100644 --- a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcherRobolectricImpl.kt +++ b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcherRobolectricImpl.kt @@ -32,7 +32,7 @@ import kotlin.coroutines.CoroutineContext class TestCoroutineDispatcherRobolectricImpl private constructor( private val fakeSystemClock: FakeSystemClock, private val realCoroutineDispatcher: CoroutineDispatcher -): TestCoroutineDispatcher() { +) : TestCoroutineDispatcher() { /** Sorted set that first sorts on when a task should be executed, then insertion order. */ private val taskQueue = CopyOnWriteArraySet() @@ -141,9 +141,9 @@ class TestCoroutineDispatcherRobolectricImpl private constructor( break } } - while (executingTaskCount.get() > 0); + while (executingTaskCount.get() > 0) - notifyIfIdle() + notifyIfIdle() } /** Flushes the current task queue and returns whether any tasks were executed. */ @@ -172,13 +172,16 @@ class TestCoroutineDispatcherRobolectricImpl private constructor( private fun createDeferredRunnable(context: CoroutineContext, block: Runnable): Runnable { return kotlinx.coroutines.Runnable { executingTaskCount.incrementAndGet() - realCoroutineDispatcher.dispatch(context, kotlinx.coroutines.Runnable { - try { - block.run() - } finally { - executingTaskCount.decrementAndGet() + realCoroutineDispatcher.dispatch( + context, + kotlinx.coroutines.Runnable { + try { + block.run() + } finally { + executingTaskCount.decrementAndGet() + } } - }) + ) } } diff --git a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchersRobolectricImpl.kt b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchersRobolectricImpl.kt index 3485d8a7f19..9735c526b3c 100644 --- a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchersRobolectricImpl.kt +++ b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchersRobolectricImpl.kt @@ -4,7 +4,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.InternalCoroutinesApi import java.lang.reflect.Method import java.time.Duration -import java.util.* +import java.util.TreeSet import javax.inject.Inject /** @@ -75,7 +75,8 @@ class TestCoroutineDispatchersRobolectricImpl @Inject constructor( @ExperimentalCoroutinesApi private fun advanceToNextFutureTask( - currentTimeMillis: Long, maxDelayMs: Long = Long.MAX_VALUE + currentTimeMillis: Long, + maxDelayMs: Long = Long.MAX_VALUE ): Long? { val nextFutureTimeMillis = getNextFutureTaskTimeMillis(currentTimeMillis) val timeToTaskMillis = nextFutureTimeMillis?.let { it - currentTimeMillis } diff --git a/testing/src/main/java/org/oppia/testing/TestLogReportingModule.kt b/testing/src/main/java/org/oppia/testing/TestLogReportingModule.kt index 1f2c229e0e7..b01f1a8723e 100644 --- a/testing/src/main/java/org/oppia/testing/TestLogReportingModule.kt +++ b/testing/src/main/java/org/oppia/testing/TestLogReportingModule.kt @@ -14,5 +14,4 @@ interface TestLogReportingModule { @Binds fun bindFakeEventLogger(fakeEventLogger: FakeEventLogger): EventLogger - } From d5b9f7470855160d21993afdd108729390dbb3c7 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Tue, 11 Aug 2020 18:40:17 -0700 Subject: [PATCH 09/36] Fix broken test after changes to FakeSystemClock. --- utility/src/test/java/org/oppia/util/data/AsyncResultTest.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/utility/src/test/java/org/oppia/util/data/AsyncResultTest.kt b/utility/src/test/java/org/oppia/util/data/AsyncResultTest.kt index 66423e269ed..6cab76720c0 100644 --- a/utility/src/test/java/org/oppia/util/data/AsyncResultTest.kt +++ b/utility/src/test/java/org/oppia/util/data/AsyncResultTest.kt @@ -12,6 +12,7 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.oppia.testing.FakeSystemClock +import org.oppia.testing.TestDispatcherModule import org.robolectric.annotation.LooperMode import javax.inject.Inject import javax.inject.Singleton @@ -916,7 +917,7 @@ class AsyncResultTest { // TODO(#89): Move this to a common test application component. @Singleton - @Component(modules = []) + @Component(modules = [TestDispatcherModule::class]) interface TestApplicationComponent { @Component.Builder interface Builder { From bf2fd82265703de5bfc457c5c5dfe303f0498893 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Tue, 11 Aug 2020 23:36:13 -0700 Subject: [PATCH 10/36] Fix linter errors. --- .../java/org/oppia/app/player/state/StateFragmentTest.kt | 2 +- testing/src/main/java/org/oppia/testing/OppiaTestAnnotations.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/sharedTest/java/org/oppia/app/player/state/StateFragmentTest.kt b/app/src/sharedTest/java/org/oppia/app/player/state/StateFragmentTest.kt index 2b3ec885885..00153ed5ab4 100644 --- a/app/src/sharedTest/java/org/oppia/app/player/state/StateFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/app/player/state/StateFragmentTest.kt @@ -1203,7 +1203,7 @@ class StateFragmentTest { TestAccessibilityModule::class, LogStorageModule::class ] ) - interface TestApplicationComponent: ApplicationComponent { + interface TestApplicationComponent : ApplicationComponent { @Component.Builder interface Builder { @BindsInstance diff --git a/testing/src/main/java/org/oppia/testing/OppiaTestAnnotations.kt b/testing/src/main/java/org/oppia/testing/OppiaTestAnnotations.kt index 90cd692f802..04e02c76fa5 100644 --- a/testing/src/main/java/org/oppia/testing/OppiaTestAnnotations.kt +++ b/testing/src/main/java/org/oppia/testing/OppiaTestAnnotations.kt @@ -1,7 +1,7 @@ package org.oppia.testing -import org.oppia.testing.TestPlatform.ROBOLECTRIC import org.oppia.testing.TestPlatform.ESPRESSO +import org.oppia.testing.TestPlatform.ROBOLECTRIC /** Specifies a test platform to target in conjunction with [RunOn]. */ enum class TestPlatform { From 251a74029b227d597c85724adace7f9bf1a098fa Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Wed, 12 Aug 2020 19:37:12 -0700 Subject: [PATCH 11/36] Update test lesson to include references to concept cards. --- domain/src/main/assets/MjZzEVOG47_1.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/domain/src/main/assets/MjZzEVOG47_1.json b/domain/src/main/assets/MjZzEVOG47_1.json index 666a076c8d7..7b268f59512 100644 --- a/domain/src/main/assets/MjZzEVOG47_1.json +++ b/domain/src/main/assets/MjZzEVOG47_1.json @@ -1926,7 +1926,7 @@ "param_changes": [], "feedback": { "content_id": "feedback_3", - "html": "

That's not correct -- it looks like you've forgotten what the numerator and denominator represent. Take a look at the short  to refresh your memory if you need to.

" + "html": "

That's not correct -- it looks like you've forgotten what the numerator and denominator represent. Take a look at the short to refresh your memory if you need to.

" }, "dest": "Matthew gets conned", "refresher_exploration_id": null, @@ -1954,7 +1954,7 @@ "param_changes": [], "feedback": { "content_id": "feedback_10", - "html": "

That's not correct -- it looks like you've forgotten what the denominator represents. Take a look at the short  to refresh your memory if you need to.

" + "html": "

That's not correct -- it looks like you've forgotten what the numerator and denominator represent. Take a look at the short to refresh your memory if you need to.

" }, "dest": "Matthew gets conned", "refresher_exploration_id": null, From f162edd1a0521b2c9674b4b93a8cdb2c0f027ced Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Wed, 12 Aug 2020 19:44:08 -0700 Subject: [PATCH 12/36] Lint fixes & use HtmlCompat instead of Html. --- .../player/exploration/ExplorationActivity.kt | 5 +- .../player/state/StateFragmentPresenter.kt | 4 +- .../state/StatePlayerRecyclerViewAssembler.kt | 5 +- .../util/parser/CustomHtmlContentHandler.kt | 59 ++++++++++++------- .../java/org/oppia/util/parser/HtmlParser.kt | 58 ++++++++++++------ 5 files changed, 87 insertions(+), 44 deletions(-) diff --git a/app/src/main/java/org/oppia/app/player/exploration/ExplorationActivity.kt b/app/src/main/java/org/oppia/app/player/exploration/ExplorationActivity.kt index 889b1e1e01d..4635499ddd9 100755 --- a/app/src/main/java/org/oppia/app/player/exploration/ExplorationActivity.kt +++ b/app/src/main/java/org/oppia/app/player/exploration/ExplorationActivity.kt @@ -7,7 +7,6 @@ import android.view.Menu import android.view.MenuItem import org.oppia.app.R import org.oppia.app.activity.InjectableAppCompatActivity -import org.oppia.app.topic.conceptcard.ConceptCardListener import org.oppia.app.hintsandsolution.HintsAndSolutionDialogFragment import org.oppia.app.hintsandsolution.HintsAndSolutionListener import org.oppia.app.hintsandsolution.RevealHintListener @@ -19,6 +18,7 @@ import org.oppia.app.player.state.listener.RouteToHintsAndSolutionListener import org.oppia.app.player.state.listener.StateKeyboardButtonListener import org.oppia.app.player.stopplaying.StopExplorationDialogFragment import org.oppia.app.player.stopplaying.StopStatePlayingSessionListener +import org.oppia.app.topic.conceptcard.ConceptCardListener import javax.inject.Inject private const val TAG_STOP_EXPLORATION_DIALOG = "STOP_EXPLORATION_DIALOG" @@ -35,7 +35,8 @@ class ExplorationActivity : RevealHintListener, RevealSolutionInterface, DefaultFontSizeStateListener, - HintsAndSolutionExplorationManagerListener, ConceptCardListener { + HintsAndSolutionExplorationManagerListener, + ConceptCardListener { @Inject lateinit var explorationActivityPresenter: ExplorationActivityPresenter diff --git a/app/src/main/java/org/oppia/app/player/state/StateFragmentPresenter.kt b/app/src/main/java/org/oppia/app/player/state/StateFragmentPresenter.kt index b64b50f1270..2a7f2324161 100755 --- a/app/src/main/java/org/oppia/app/player/state/StateFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/app/player/state/StateFragmentPresenter.kt @@ -450,7 +450,9 @@ class StateFragmentPresenter @Inject constructor( } fun dismissConceptCard() { - fragment.childFragmentManager.findFragmentByTag(CONCEPT_CARD_DIALOG_FRAGMENT_TAG)?.let { dialogFragment -> + fragment.childFragmentManager.findFragmentByTag( + CONCEPT_CARD_DIALOG_FRAGMENT_TAG + )?.let { dialogFragment -> fragment.childFragmentManager.beginTransaction().remove(dialogFragment).commitNow() } } diff --git a/app/src/main/java/org/oppia/app/player/state/StatePlayerRecyclerViewAssembler.kt b/app/src/main/java/org/oppia/app/player/state/StatePlayerRecyclerViewAssembler.kt index bd51fdad38d..54aa1b0739d 100644 --- a/app/src/main/java/org/oppia/app/player/state/StatePlayerRecyclerViewAssembler.kt +++ b/app/src/main/java/org/oppia/app/player/state/StatePlayerRecyclerViewAssembler.kt @@ -91,7 +91,6 @@ private typealias AudioUiManagerRetriever = () -> AudioUiManager? /** The fragment tag corresponding to the concept card dialog fragment. */ const val CONCEPT_CARD_DIALOG_FRAGMENT_TAG = "CONCEPT_CARD_FRAGMENT" - /** * An assembler for generating the list of view models to bind to the state player recycler view. * This class also handles some non-recycler view feature management, such as the congratulations @@ -170,7 +169,9 @@ class StatePlayerRecyclerViewAssembler private constructor( private val isSplitView = ObservableField(false) override fun onConceptCardLinkClicked(view: View, skillId: String) { - ConceptCardFragment.newInstance(skillId).showNow(fragment.childFragmentManager, CONCEPT_CARD_DIALOG_FRAGMENT_TAG) + ConceptCardFragment + .newInstance(skillId) + .showNow(fragment.childFragmentManager, CONCEPT_CARD_DIALOG_FRAGMENT_TAG) } /** diff --git a/utility/src/main/java/org/oppia/util/parser/CustomHtmlContentHandler.kt b/utility/src/main/java/org/oppia/util/parser/CustomHtmlContentHandler.kt index 9b76348f84d..b2c72fa369e 100644 --- a/utility/src/main/java/org/oppia/util/parser/CustomHtmlContentHandler.kt +++ b/utility/src/main/java/org/oppia/util/parser/CustomHtmlContentHandler.kt @@ -3,20 +3,21 @@ package org.oppia.util.parser import android.text.Editable import android.text.Html import android.text.Spannable +import androidx.core.text.HtmlCompat import org.xml.sax.Attributes import org.xml.sax.ContentHandler import org.xml.sax.Locator import org.xml.sax.XMLReader /** - * A custom [ContentHandler] and [Html.TagHandler] for processing custom HTML tags. This class must be used if a custom - * tag attribute must be parsed. + * A custom [ContentHandler] and [Html.TagHandler] for processing custom HTML tags. This class must + * be used if a custom tag attribute must be parsed. * * This is based on the implementation provided in https://stackoverflow.com/a/36528149. */ class CustomHtmlContentHandler private constructor( private val customTagHandlers: Map -): ContentHandler, Html.TagHandler { +) : ContentHandler, Html.TagHandler { private var originalContentHandler: ContentHandler? = null private var currentTrackedTag: TrackedTag? = null private var currentTrackedCustomTag: TrackedCustomTag? = null @@ -47,7 +48,8 @@ class CustomHtmlContentHandler private constructor( } override fun startElement(uri: String?, localName: String?, qName: String?, atts: Attributes?) { - // Defer custom tag management to the tag handler so that Android's element parsing takes precedence. + // Defer custom tag management to the tag handler so that Android's element parsing takes + // precedence. currentTrackedTag = TrackedTag(checkNotNull(localName), checkNotNull(atts)) originalContentHandler?.startElement(uri, localName, qName, atts) } @@ -72,7 +74,9 @@ class CustomHtmlContentHandler private constructor( check(output != null) { "Expected non-null editable." } when { originalContentHandler == null -> { - check(tag == "init-custom-handler") { "Expected first custom tag to be initializing the custom handler." } + check(tag == "init-custom-handler") { + "Expected first custom tag to be initializing the custom handler." + } checkNotNull(xmlReader) { "Expected reader to not be null" } originalContentHandler = xmlReader.contentHandler xmlReader.contentHandler = this @@ -80,11 +84,15 @@ class CustomHtmlContentHandler private constructor( opening -> { if (tag in customTagHandlers) { val localCurrentTrackedTag = currentTrackedTag - check(localCurrentTrackedTag != null) { "Expected tag details to be to be cached for current tag." } + check(localCurrentTrackedTag != null) { + "Expected tag details to be to be cached for current tag." + } check(localCurrentTrackedTag.tag == tag) { "Expected tracked tag $currentTrackedTag to match custom tag: $tag" } - check(currentTrackedCustomTag == null) { "Custom content handler does not support nested custom tags." } + check(currentTrackedCustomTag == null) { + "Custom content handler does not support nested custom tags." + } currentTrackedCustomTag = TrackedCustomTag( localCurrentTrackedTag.tag, localCurrentTrackedTag.attributes, output.length ) @@ -92,7 +100,9 @@ class CustomHtmlContentHandler private constructor( } tag in customTagHandlers -> { val localCurrentTrackedCustomTag = currentTrackedCustomTag - check(localCurrentTrackedCustomTag != null) { "Expected custom tag to be initialized tracked." } + check(localCurrentTrackedCustomTag != null) { + "Expected custom tag to be initialized tracked." + } check(localCurrentTrackedCustomTag.tag == tag) { "Expected tracked tag $currentTrackedTag to match custom tag: $tag" } @@ -103,7 +113,11 @@ class CustomHtmlContentHandler private constructor( } private data class TrackedTag(val tag: String, val attributes: Attributes) - private data class TrackedCustomTag(val tag: String, val attributes: Attributes, val openTagIndex: Int) + private data class TrackedCustomTag( + val tag: String, + val attributes: Attributes, + val openTagIndex: Int + ) /** Handler interface for a custom tag and its attributes. */ interface CustomTagHandler { @@ -120,22 +134,23 @@ class CustomHtmlContentHandler private constructor( companion object { /** - * Returns a new [Spannable] with HTML parsed from [html] using the specified [imageGetter] for handling image - * retrieval, and map of tags to [CustomTagHandler]s for handling custom tags. All possible custom tags must be - * registered in the [customTagHandlers] map. + * Returns a new [Spannable] with HTML parsed from [html] using the specified [imageGetter] for + * handling image retrieval, and map of tags to [CustomTagHandler]s for handling custom tags. + * All possible custom tags must be registered in the [customTagHandlers] map. */ fun fromHtml( - html: String, imageGetter: Html.ImageGetter, customTagHandlers: Map + html: String, + imageGetter: Html.ImageGetter, + customTagHandlers: Map ): Spannable { - // Adjust the HTML to allow the custom content handler to properly initialize custom tag tracking. - val adjustedHtml = "$html" - return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) { - Html.fromHtml( - adjustedHtml, Html.FROM_HTML_MODE_LEGACY, imageGetter, CustomHtmlContentHandler(customTagHandlers) - ) as Spannable - } else { - Html.fromHtml(adjustedHtml, imageGetter, CustomHtmlContentHandler(customTagHandlers)) as Spannable - } + // Adjust the HTML to allow the custom content handler to properly initialize custom tag + // tracking. + return HtmlCompat.fromHtml( + "$html", + HtmlCompat.FROM_HTML_MODE_LEGACY, + imageGetter, + CustomHtmlContentHandler(customTagHandlers) + ) as Spannable } } } diff --git a/utility/src/main/java/org/oppia/util/parser/HtmlParser.kt b/utility/src/main/java/org/oppia/util/parser/HtmlParser.kt index f57e00a76fe..70c71af9a04 100755 --- a/utility/src/main/java/org/oppia/util/parser/HtmlParser.kt +++ b/utility/src/main/java/org/oppia/util/parser/HtmlParser.kt @@ -35,11 +35,16 @@ class HtmlParser private constructor( * * @param rawString raw HTML to parse * @param htmlContentTextView the [TextView] that will contain the returned [Spannable] - * @param supportsLinks whether the provided [TextView] should support link forwarding (it's recommended not to use - * this for [TextView]s that are within other layouts that need to support clicking (default false) + * @param supportsLinks whether the provided [TextView] should support link forwarding (it's + * recommended not to use this for [TextView]s that are within other layouts that need to + * support clicking (default false) * @return a [Spannable] representing the styled text. */ - fun parseOppiaHtml(rawString: String, htmlContentTextView: TextView, supportsLinks: Boolean = false): Spannable { + fun parseOppiaHtml( + rawString: String, + htmlContentTextView: TextView, + supportsLinks: Boolean = false + ): Spannable { var htmlContent = rawString if (htmlContent.contains("\n\t")) { htmlContent = htmlContent.replace("\n\t", "") @@ -48,7 +53,7 @@ class HtmlParser private constructor( htmlContent = htmlContent.replace("\n\n", "") } - // TODO: add support for imageCenterAlign & other fixes to HtmlParser since #422 (consider #731). + // TODO: support for imageCenterAlign & other fixes to HtmlParser since #422 (consider #731). if (htmlContent.contains(CUSTOM_IMG_TAG)) { htmlContent = htmlContent.replace(CUSTOM_IMG_TAG, REPLACE_IMG_TAG) htmlContent = @@ -112,14 +117,22 @@ class HtmlParser private constructor( // https://mohammedlakkadshaw.com/blog/handling-custom-tags-in-android-using-html-taghandler.html/ private class ConceptCardTagHandler( private val customOppiaTagActionListener: CustomOppiaTagActionListener? - ): CustomHtmlContentHandler.CustomTagHandler { - override fun handleTag(attributes: Attributes, openIndex: Int, closeIndex: Int, output: Editable) { + ) : CustomHtmlContentHandler.CustomTagHandler { + override fun handleTag( + attributes: Attributes, + openIndex: Int, + closeIndex: Int, + output: Editable + ) { val skillId = attributes.getValue("skill-id") - output.setSpan(object : ClickableSpan() { - override fun onClick(view: View) { - customOppiaTagActionListener?.onConceptCardLinkClicked(view, skillId) - } - }, openIndex, closeIndex, Spannable.SPAN_INCLUSIVE_EXCLUSIVE) + output.setSpan( + object : ClickableSpan() { + override fun onClick(view: View) { + customOppiaTagActionListener?.onConceptCardLinkClicked(view, skillId) + } + }, + openIndex, closeIndex, Spannable.SPAN_INCLUSIVE_EXCLUSIVE + ) } } @@ -146,8 +159,8 @@ class HtmlParser private constructor( /** Listener that's called when a custom tag triggers an event. */ interface CustomOppiaTagActionListener { /** - * Called when an embedded concept card link is clicked in the specified view with the skillId corresponding to the - * card that should be shown. + * Called when an embedded concept card link is clicked in the specified view with the skillId + * corresponding to the card that should be shown. */ fun onConceptCardLinkClicked(view: View, skillId: String) } @@ -155,13 +168,24 @@ class HtmlParser private constructor( /** Factory for creating new [HtmlParser]s. */ class Factory @Inject constructor(private val urlImageParserFactory: UrlImageParser.Factory) { /** - * Returns a new [HtmlParser] with the specified entity type and ID for loading images, and an optionally specified - * [CustomOppiaTagActionListener] for handling custom Oppia tag events. + * Returns a new [HtmlParser] with the specified entity type and ID for loading images, and an + * optionally specified [CustomOppiaTagActionListener] for handling custom Oppia tag events. */ fun create( - gcsResourceName: String, entityType: String, entityId: String, imageCenterAlign: Boolean, customOppiaTagActionListener: CustomOppiaTagActionListener? = null + gcsResourceName: String, + entityType: String, + entityId: String, + imageCenterAlign: Boolean, + customOppiaTagActionListener: CustomOppiaTagActionListener? = null ): HtmlParser { - return HtmlParser(urlImageParserFactory, gcsResourceName, entityType, entityId, imageCenterAlign, customOppiaTagActionListener) + return HtmlParser( + urlImageParserFactory, + gcsResourceName, + entityType, + entityId, + imageCenterAlign, + customOppiaTagActionListener + ) } } } From 7895ef62e470bb7ffeffbc8205d948c9410c579c Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Wed, 12 Aug 2020 20:15:02 -0700 Subject: [PATCH 13/36] Add support for the newer & finalized tag format. --- .../main/java/org/oppia/util/parser/HtmlParser.kt | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/utility/src/main/java/org/oppia/util/parser/HtmlParser.kt b/utility/src/main/java/org/oppia/util/parser/HtmlParser.kt index 70c71af9a04..68e1a057876 100755 --- a/utility/src/main/java/org/oppia/util/parser/HtmlParser.kt +++ b/utility/src/main/java/org/oppia/util/parser/HtmlParser.kt @@ -17,7 +17,7 @@ private const val REPLACE_IMG_TAG = "img" private const val CUSTOM_IMG_FILE_PATH_ATTRIBUTE = "filepath-with-value" private const val REPLACE_IMG_FILE_PATH_ATTRIBUTE = "src" -private const val CUSTOM_CONCEPT_CARD_TAG = "oppia-concept-card-link" +private const val CUSTOM_CONCEPT_CARD_TAG = "oppia-noninteractive-skillreview" /** Html Parser to parse custom Oppia tags with Android-compatible versions. */ class HtmlParser private constructor( @@ -124,15 +124,18 @@ class HtmlParser private constructor( closeIndex: Int, output: Editable ) { - val skillId = attributes.getValue("skill-id") - output.setSpan( + // Replace the custom tag with a clickable piece of text based on the tag's customizations. + val skillId = attributes.getValue("skill_id-with-value") + val text = attributes.getValue("text-with-value") + val spannableBuilder = SpannableStringBuilder(text) + spannableBuilder.setSpan( object : ClickableSpan() { override fun onClick(view: View) { customOppiaTagActionListener?.onConceptCardLinkClicked(view, skillId) } }, - openIndex, closeIndex, Spannable.SPAN_INCLUSIVE_EXCLUSIVE - ) + 0, text.length, Spannable.SPAN_INCLUSIVE_EXCLUSIVE) + output.replace(openIndex, closeIndex, spannableBuilder) } } From 122c25d6a2d7ef42e91afcde747ee6fed51457c4 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Wed, 12 Aug 2020 20:15:31 -0700 Subject: [PATCH 14/36] Lint fixes. --- utility/src/main/java/org/oppia/util/parser/HtmlParser.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/utility/src/main/java/org/oppia/util/parser/HtmlParser.kt b/utility/src/main/java/org/oppia/util/parser/HtmlParser.kt index 68e1a057876..d7d69f9c98e 100755 --- a/utility/src/main/java/org/oppia/util/parser/HtmlParser.kt +++ b/utility/src/main/java/org/oppia/util/parser/HtmlParser.kt @@ -134,7 +134,8 @@ class HtmlParser private constructor( customOppiaTagActionListener?.onConceptCardLinkClicked(view, skillId) } }, - 0, text.length, Spannable.SPAN_INCLUSIVE_EXCLUSIVE) + 0, text.length, Spannable.SPAN_INCLUSIVE_EXCLUSIVE + ) output.replace(openIndex, closeIndex, spannableBuilder) } } From 9f9d643755f0c1e11e55f153eb2b0606ca346fe3 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Tue, 1 Sep 2020 20:29:02 -0700 Subject: [PATCH 15/36] Use a custom executor service for Glide requests that coordinates with Oppia's test dispatchers. Note that this does not actually introduce the service--that will happen in a new branch. --- app/build.gradle | 3 +- .../player/state/StateFragmentLocalTest.kt | 29 +++++++++++++++++-- utility/build.gradle | 4 +-- 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 8f4eb695a6d..abb2509726e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -84,7 +84,7 @@ dependencies { 'androidx.multidex:multidex:2.0.1', 'androidx.recyclerview:recyclerview:1.0.0', 'com.chaos.view:pinview:1.4.3', - 'com.github.bumptech.glide:glide:4.9.0', + 'com.github.bumptech.glide:glide:4.11.0', 'com.google.android.material:material:1.2.0-alpha02', 'com.google.dagger:dagger:2.24', 'com.google.firebase:firebase-analytics-ktx:17.4.2', @@ -105,6 +105,7 @@ dependencies { 'androidx.test.espresso:espresso-intents:3.1.0', 'androidx.test.ext:junit:1.1.1', 'com.google.truth:truth:0.43', + 'com.github.bumptech.glide:mocks:4.11.0', 'org.robolectric:annotations:4.3', 'org.robolectric:robolectric:4.3', 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.2.2', diff --git a/app/src/test/java/org/oppia/app/player/state/StateFragmentLocalTest.kt b/app/src/test/java/org/oppia/app/player/state/StateFragmentLocalTest.kt index a2445e0a565..56f18c0f2e3 100644 --- a/app/src/test/java/org/oppia/app/player/state/StateFragmentLocalTest.kt +++ b/app/src/test/java/org/oppia/app/player/state/StateFragmentLocalTest.kt @@ -25,10 +25,14 @@ import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withSubstring import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.bumptech.glide.Glide +import com.bumptech.glide.GlideBuilder +import com.bumptech.glide.load.engine.executor.MockGlideExecutor import com.google.common.truth.Truth.assertThat import dagger.Component import dagger.Module import dagger.Provides +import kotlinx.coroutines.CoroutineDispatcher import org.hamcrest.BaseMatcher import org.hamcrest.CoreMatchers.allOf import org.hamcrest.CoreMatchers.not @@ -67,6 +71,7 @@ import org.oppia.domain.question.QuestionModule import org.oppia.domain.topic.FRACTIONS_EXPLORATION_ID_1 import org.oppia.domain.topic.TEST_STORY_ID_0 import org.oppia.domain.topic.TEST_TOPIC_ID_0 +import org.oppia.testing.CoroutineExecutorService import org.oppia.testing.TestAccessibilityModule import org.oppia.testing.TestCoroutineDispatchers import org.oppia.testing.TestDispatcherModule @@ -78,6 +83,7 @@ import org.oppia.util.logging.LoggerModule import org.oppia.util.parser.GlideImageLoaderModule import org.oppia.util.parser.HtmlParserEntityTypeModule import org.oppia.util.parser.ImageParsingModule +import org.oppia.util.threading.BackgroundDispatcher import org.robolectric.annotation.Config import org.robolectric.annotation.LooperMode import org.robolectric.shadows.ShadowMediaPlayer @@ -102,12 +108,29 @@ class StateFragmentLocalTest { @Inject lateinit var profileTestHelper: ProfileTestHelper @Inject lateinit var testCoroutineDispatchers: TestCoroutineDispatchers @Inject @field:ApplicationContext lateinit var context: Context + @Inject + @field:BackgroundDispatcher + lateinit var backgroundCoroutineDispatcher: CoroutineDispatcher private val internalProfileId: Int = 1 @Before fun setUp() { setUpTestApplicationComponent() + + // Initialize Glide such that all of its executors use the same shared dispatcher pool as the + // rest of Oppia so that thread execution can be synchronized via Oppia's test coroutine + // dispatchers. + val executorService = MockGlideExecutor.newTestExecutor( + CoroutineExecutorService(backgroundCoroutineDispatcher) + ) + Glide.init( + context, + GlideBuilder().setDiskCacheExecutor(executorService) + .setAnimationExecutor(executorService) + .setSourceExecutor(executorService) + ) + profileTestHelper.initializeProfiles() ShadowMediaPlayer.addException(audioDataSource1, IOException("Test does not have networking")) } @@ -129,11 +152,11 @@ class StateFragmentLocalTest { onView(withId(R.id.state_recycler_view)).perform(scrollToViewType(SELECTION_INTERACTION)) onView(withSubstring("the pieces must be the same size.")).perform(click()) - testCoroutineDispatchers.advanceUntilIdle() + testCoroutineDispatchers.runCurrent() onView(withId(R.id.state_recycler_view)).perform(scrollToViewType(CONTINUE_NAVIGATION_BUTTON)) - testCoroutineDispatchers.advanceUntilIdle() + testCoroutineDispatchers.runCurrent() onView(withId(R.id.continue_navigation_button)).perform(click()) - testCoroutineDispatchers.advanceUntilIdle() + testCoroutineDispatchers.runCurrent() onView(withSubstring("of the above circle is red?")).check(matches(isDisplayed())) } diff --git a/utility/build.gradle b/utility/build.gradle index 90929c30bae..9317b4d87bc 100644 --- a/utility/build.gradle +++ b/utility/build.gradle @@ -48,7 +48,7 @@ dependencies { 'androidx.appcompat:appcompat:1.0.2', 'androidx.lifecycle:lifecycle-livedata-ktx:2.2.0-alpha03', 'com.caverock:androidsvg-aar:1.4', - 'com.github.bumptech.glide:glide:4.9.0', + 'com.github.bumptech.glide:glide:4.11.0', 'com.google.dagger:dagger:2.24', 'com.google.firebase:firebase-analytics-ktx:17.4.2', 'com.google.firebase:firebase-core:17.4.2', @@ -68,7 +68,7 @@ dependencies { project(":testing"), ) kapt( - 'com.github.bumptech.glide:compiler:4.9.0', + 'com.github.bumptech.glide:compiler:4.11.0', 'com.google.dagger:dagger-compiler:2.24' ) kaptTest( From 8abc5146a42bdf715396b432e50b3c3c8ba07e65 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Tue, 1 Sep 2020 20:30:15 -0700 Subject: [PATCH 16/36] Introduce new executor service which allows interop with Kotlin coroutines, plus a test to verify that it fundamentally follows one interpretation of ExecutorService's API. --- .../oppia/testing/CoroutineExecutorService.kt | 328 +++++ .../oppia/testing/TestCoroutineDispatcher.kt | 78 +- .../TestCoroutineDispatcherEspressoImpl.kt | 10 +- .../TestCoroutineDispatcherRobolectricImpl.kt | 76 +- .../oppia/testing/TestCoroutineDispatchers.kt | 18 + .../TestCoroutineDispatchersEspressoImpl.kt | 11 + ...TestCoroutineDispatchersRobolectricImpl.kt | 25 +- .../testing/CoroutineExecutorServiceTest.kt | 1091 +++++++++++++++++ 8 files changed, 1621 insertions(+), 16 deletions(-) create mode 100644 testing/src/main/java/org/oppia/testing/CoroutineExecutorService.kt create mode 100644 testing/src/test/java/org/oppia/testing/CoroutineExecutorServiceTest.kt diff --git a/testing/src/main/java/org/oppia/testing/CoroutineExecutorService.kt b/testing/src/main/java/org/oppia/testing/CoroutineExecutorService.kt new file mode 100644 index 00000000000..a77b32a2fb8 --- /dev/null +++ b/testing/src/main/java/org/oppia/testing/CoroutineExecutorService.kt @@ -0,0 +1,328 @@ +package org.oppia.testing + +import androidx.annotation.VisibleForTesting +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.selects.select +import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import java.lang.IllegalStateException +import java.lang.NullPointerException +import java.util.concurrent.Callable +import java.util.concurrent.ExecutionException +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.Future +import java.util.concurrent.RejectedExecutionException +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock +import kotlin.coroutines.EmptyCoroutineContext + +/** + * Listener for being notified when [CoroutineExecutorService] has arranged state and is immediately + * about to block the current thread on that operation completed. This can be used by tests to + * synchronize background threads to prevent deadlocking in cases when blocking operations are being + * tested. + */ +typealias PriorToBlockingCallback = () -> Unit + +// TODO: doc +typealias AfterSelectionSetup = () -> Unit + +private typealias TimeoutBlock = suspend CoroutineScope.() -> T + +// https://github.com/Kotlin/kotlinx.coroutines/issues/1450 for reference on using a coroutine +// dispatcher an an executor service. +// TODO: doc +class CoroutineExecutorService( + private val backgroundDispatcher: CoroutineDispatcher +) : ExecutorService { + private val serviceLock = ReentrantLock() + private val taskCount = AtomicInteger() + private var isShutdown = false + private val pendingTasks = mutableMapOf>() + private val cachedThreadPool by lazy { Executors.newCachedThreadPool() } + private val cachedThreadCoroutineDispatcher by lazy { cachedThreadPool.asCoroutineDispatcher() } + private var priorToBlockingCallback: PriorToBlockingCallback? = null + private var afterSelectionSetup: AfterSelectionSetup? = null + + /** + * Coroutine scope for executing consecutive tasks for blocking the calling thread and without + * interfering with other potentially blocked operations leveraging this scope. This is done using + * a cached thread pool that creates new threads as others become blocked. + */ + private val cachedThreadCoroutineScope by lazy { CoroutineScope(cachedThreadCoroutineDispatcher) } + + override fun shutdown() { + serviceLock.withLock { isShutdown = true } + } + + override fun submit(task: Callable?): Future { + return dispatchAsync(task ?: throw NullPointerException()).toFuture() + } + + override fun submit(task: Runnable?, result: T): Future { + if (task == null) { + throw NullPointerException() + } + return submit( + Callable { + task.run() + return@Callable result + } + ) + } + + override fun submit(task: Runnable?): Future<*> { + return dispatchAsync(task ?: throw NullPointerException()).toFuture() + } + + override fun shutdownNow(): MutableList { + shutdown() + val incompleteTasks = serviceLock.withLock { pendingTasks.values } + incompleteTasks.map { it.deferred }.forEach { it.cancel() } + return incompleteTasks.map { it.runnable }.toMutableList() + } + + override fun isShutdown(): Boolean = serviceLock.withLock { isShutdown } + + override fun awaitTermination(timeout: Long, unit: TimeUnit?): Boolean { + check(serviceLock.withLock { isShutdown }) + val incompleteTasks = serviceLock.withLock { pendingTasks.values } + val timeoutMillis = unit?.toMillis(timeout) ?: 0 + + // Wait for each task to complete within the specified time. Note that this behaves similarly to + // invokeAll() below. + val futureTasks = cachedThreadCoroutineScope.async { + withTimeoutOrNull(timeoutMillis) { + incompleteTasks.forEach { task -> + // Wait for the task to be completed. + task.deferred.await() + } + } + } + priorToBlockingCallback?.invoke() + return runBlocking { futureTasks.await() } != null // All tasks completed without timing out. + } + + override fun invokeAny(tasks: MutableCollection>?): T = + invokeAny(tasks, 0, TimeUnit.MILLISECONDS) + + override fun invokeAny( + tasks: MutableCollection>?, + timeout: Long, + unit: TimeUnit? + ): T { + if (tasks == null) { + throw NullPointerException() + } + // The channel to receive completed tasks. Note that a channel is used over a combination of + // select + awaitAll() or per-deferred onAwait clauses since this approach allows manual + // handling of failures: a failure should not result in all other tasks being cancelled (which + // is the default behavior for select). + val resultChannel = Channel() + val taskDeferreds = tasks.map { dispatchAsync(it) } + taskDeferreds.forEach { deferred -> + @Suppress("DeferredResultUnused") // Intentionally silence failures (including the service's). + cachedThreadCoroutineScope.async { + try { + val result = deferred.await() + resultChannel.send(result) + } catch (e: Exception) { + // Ignore the exception, and send nothing to the result channel. This catch avoids the + // cached thread scope from entering a broken state. + } + } + } + + // Wait until the first result is posted to the channel. Note that this is slightly different + // than the expected behavior of this function: it can exit before all tasks are completed. + priorToBlockingCallback?.invoke() + return runBlocking { + maybeWithTimeout(unit?.toMillis(timeout) ?: 0) { + select { + resultChannel.onReceive { it } + afterSelectionSetup?.invoke() + } ?: throw ExecutionException(IllegalStateException("All tasks failed to run")) + } + } + } + + override fun isTerminated(): Boolean { + return serviceLock.withLock { isShutdown && pendingTasks.isEmpty() } + } + + override fun invokeAll( + tasks: MutableCollection>? + ): MutableList> = invokeAll(tasks, 0, TimeUnit.MILLISECONDS) + + override fun invokeAll( + tasks: MutableCollection>?, + timeout: Long, + unit: TimeUnit? + ): MutableList> { + if (tasks == null) { + throw NullPointerException() + } + val timeoutMillis = unit?.toMillis(timeout) ?: 0 + val deferredTasks = tasks.map { dispatchAsync(it) } + // Wait for each task to complete within the specified time, otherwise cancel the task. + val futureTasks = cachedThreadCoroutineScope.async { + deferredTasks.map { task -> + // Wait for each task to complete within the selected time + val result = withTimeoutOrNull(timeoutMillis) { + task.await() + } + val future = task.toFuture() + if (result == null && timeoutMillis > 0) { + // Cancel the operation if it's taking too long, but only if there's a timeout set. + future.cancel(/* mayInterruptIfRunning= */ true) + } + return@map future + } + } + priorToBlockingCallback?.invoke() + return runBlocking { futureTasks.await() }.toMutableList() + } + + @Suppress("DeferredResultUnused") // Cleanup is handled in dispatchAsync. + override fun execute(command: Runnable?) { + dispatchAsync(command ?: throw NullPointerException()) + } + + /** + * Sets a [PriorToBlockingListener] to observe this service's internal state. Note that since this + * is an intentional backdoor built into the service, it should only be used for very specific + * circumstances (such as testing blocking operations of this service). + */ + @VisibleForTesting + fun setPriorToBlockingCallback(priorToBlockingCallback: PriorToBlockingCallback) { + this.priorToBlockingCallback = priorToBlockingCallback + } + + // TODO: doc + @VisibleForTesting + fun setAfterSelectionSetup(afterSelectionSetup: AfterSelectionSetup) { + this.afterSelectionSetup = afterSelectionSetup + } + + private fun dispatchAsync(command: Runnable): Deferred<*> { + return dispatchAsync(command.let { Callable { it.run() } }) + } + + private fun dispatchAsync(command: Callable): Deferred { + return command.let { dispatchAsync { it.call() } } + } + + private fun dispatchAsync(command: suspend () -> T): Deferred { + if (serviceLock.withLock { isShutdown }) { + throw RejectedExecutionException() + } + + // A new scope is created to allow the underlying async task to fail without affecting future + // tasks. An alternative approach would be to use a supervised scope that's allowed to fail. + // This would required monitoring using a separate dispatcher scope that can fail without + // affecting future operations. + val taskId = taskCount.incrementAndGet() + val deferred = CoroutineScope(backgroundDispatcher).async { runAsync(taskId, command) } + + // Note: this Runnable is *probably* incorrect, but ExecutorService doesn't indicate which + // Runnables are provided, what they should do when run, or how they tie back to submitted + // Callables. + val task = Task(Runnable { runBlocking { command() } }, deferred) + serviceLock.withLock { pendingTasks.put(taskId, task) } + deferred.invokeOnCompletion { + serviceLock.withLock { pendingTasks.remove(taskId) } + } + return deferred + } + + private suspend fun runAsync(taskId: Int, command: suspend () -> T): T { + // This should never fail since cleanup of tasks only happens after this completes, or is + // cancelled. It can't execute after being cleaned up with the current implementation. + check(serviceLock.withLock { taskId in pendingTasks }) + return command() + } + + private data class Task(val runnable: Runnable, val deferred: Deferred) + + private fun Deferred.toFuture(): Future { + val deferred: Deferred = this + return object : Future { + override fun isDone(): Boolean = deferred.isCompleted + + override fun get(): T = get(/* timeout= */ 0, TimeUnit.MILLISECONDS) + + override fun get(timeout: Long, unit: TimeUnit?): T { + return runBlocking { + try { + maybeWithTimeout(unit?.toMillis(timeout) ?: 0) { + deferred.await() + } + } catch (e: Exception) { + // Rethrow the failure if the computation failed. + throw ExecutionException(e) + } + } + } + + override fun cancel(mayInterruptIfRunning: Boolean): Boolean { + return if (!deferred.isCompleted) { + deferred.cancel() + true + } else { + false + } + } + + override fun isCancelled(): Boolean = deferred.isCancelled + } + } + + private companion object { + private suspend fun maybeWithTimeout( + timeoutMillis: Long, block: TimeoutBlock + ): T { + return maybeWithTimeoutDelegated(timeoutMillis, block, ::withTimeout) + } + + private suspend fun maybeWithTimeoutOrNull( + timeoutMillis: Long, block: TimeoutBlock + ): T? { + return maybeWithTimeoutDelegated(timeoutMillis, block, ::withTimeoutOrNull) + } + + private suspend fun maybeWithTimeoutDelegated( + timeoutMillis: Long, + block: TimeoutBlock, + withTimeoutDelegate: suspend (Long, TimeoutBlock) -> R + ): R { + return coroutineScope { + if (timeoutMillis > 0) { + try { + withTimeoutDelegate(timeoutMillis, block) + } catch (e: TimeoutCancellationException) { + // Treat timeouts in this service as a standard TimeoutException (which should result in + // the coroutine being completed with a failure). + throw TimeoutException(e.message) + } + } else { + block() + } + } + } + } +} diff --git a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcher.kt b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcher.kt index 36d5a0768a2..b96adfe0caa 100644 --- a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcher.kt +++ b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcher.kt @@ -1,6 +1,8 @@ package org.oppia.testing +import android.os.Build import kotlinx.coroutines.CoroutineDispatcher +import java.util.concurrent.TimeUnit /** * Replacement for Kotlin's test coroutine dispatcher that can be used to replace coroutine @@ -18,6 +20,15 @@ import kotlinx.coroutines.CoroutineDispatcher * on how to properly integrate with the test coroutine dispatcher API. */ abstract class TestCoroutineDispatcher : CoroutineDispatcher() { + /** The default time value (in seconds) used for methods with timeouts. */ + @Suppress("PropertyName") + val DEFAULT_TIMEOUT_SECONDS + get() = computeTimeout() + + /** The default time unit used for methods that execute with timeouts. */ + @Suppress("PropertyName") + val DEFAULT_TIMEOUT_UNIT = TimeUnit.SECONDS + /** * Returns whether there are any tasks known to the dispatcher that have not yet been started. * @@ -55,8 +66,37 @@ abstract class TestCoroutineDispatcher : CoroutineDispatcher() { /** * Runs all tasks currently scheduled to be run in the dispatcher, but none scheduled for the * future. + * + * @param timeout the timeout value in the specified unit after which this run attempt should fail + * @param timeoutUnit the unit for [timeout] corresponding to how long this method should wait + * when trying to execute tasks before giving up + */ + abstract fun runCurrent( + timeout: Long = DEFAULT_TIMEOUT_SECONDS, timeoutUnit: TimeUnit = DEFAULT_TIMEOUT_UNIT + ) + + /** + * Runs all tasks currently scheduled, including future sheduled tasks. Normal use cases should + * use [TestCoroutineDispatchers], not this method. This is reserved for special cases (like + * isolated test dispatchers). + */ + abstract fun runUntilIdle( + timeout: Long = DEFAULT_TIMEOUT_SECONDS, timeoutUnit: TimeUnit = DEFAULT_TIMEOUT_UNIT + ) + + /** + * Resumes the dispatcher, resulting in it running all tasks in real-time rather than requiring + * explicit synchronization. The default sate of a test dispatcher is paused. Calling + * [pauseDispatcher] will restore the default pause behavior. + */ + abstract fun resumeDispatcher() + + /** + * Pauses the dispatcher, resulting in it running all tasks only when synchronized using + * [runCurrent] or [runUntilIdle]. The default sate of a test dispatcher is paused. Calling + * [resumeDispatcher] will change this state. */ - abstract fun runCurrent() + abstract fun pauseDispatcher() /** A listener for whether the test coroutine dispatcher has become idle. */ interface TaskIdleListener { @@ -81,4 +121,40 @@ abstract class TestCoroutineDispatcher : CoroutineDispatcher() { */ fun createDispatcher(realDispatcher: CoroutineDispatcher): TestCoroutineDispatcher } + + private companion object { + private const val STANDARD_TIMEOUT_SECONDS = 10L + private val TIMEOUT_WHEN_DEBUGGING_SECONDS = TimeUnit.HOURS.toSeconds(1) + + private fun computeTimeout(): Long { + // When debugging tests, allow for significantly more time so that breakpoint debugging + // doesn't trigger timeouts during investigation. + return if (isDebuggerAttached()) { + TIMEOUT_WHEN_DEBUGGING_SECONDS + } else { + STANDARD_TIMEOUT_SECONDS + } + } + + private fun isDebuggerAttached(): Boolean { + return if (Build.FINGERPRINT.contains("robolectric", ignoreCase = true)) { + isIntelliJDebuggerAttachedWithRobolectric() + } else { + isDebuggerAttachedWithEspresso() + } + } + + /** + * Returns whether there's an IntelliJ debugger attached. This only needed for Robolectric tests + * since [android.os.Debug.isDebuggerConnected] doesn't work in Robolectric. This approach only + * works for Android Studio, unfortunately. + */ + private fun isIntelliJDebuggerAttachedWithRobolectric(): Boolean { + return System.getProperty("intellij.debug.agent")?.toBoolean() ?: false + } + + private fun isDebuggerAttachedWithEspresso(): Boolean { + return android.os.Debug.isDebuggerConnected() + } + } } diff --git a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcherEspressoImpl.kt b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcherEspressoImpl.kt index 925817480dd..843da343dc9 100644 --- a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcherEspressoImpl.kt +++ b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcherEspressoImpl.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.test.DelayController import kotlinx.coroutines.test.UncompletedCoroutinesError import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger import javax.inject.Inject import kotlin.coroutines.CoroutineContext @@ -130,11 +131,18 @@ class TestCoroutineDispatcherEspressoImpl private constructor( throw UnsupportedOperationException("Real-time dispatchers cannot be paused/resumed") } - @ExperimentalCoroutinesApi override fun runCurrent() { // Nothing to do; the queue is always continuously running. } + override fun runCurrent(timeout: Long, timeoutUnit: TimeUnit) { + // Nothing to do; the queue is always continuously running. + } + + override fun runUntilIdle(timeout: Long, timeoutUnit: TimeUnit) { + // Nothing to do; the queue is always continuously running. + } + override fun hasPendingTasks(): Boolean = executingTaskCount.get() != 0 override fun getNextFutureTaskCompletionTimeMillis(timeMillis: Long): Long? { diff --git a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcherRobolectricImpl.kt b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcherRobolectricImpl.kt index ca5705fe760..6b696116e91 100644 --- a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcherRobolectricImpl.kt +++ b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcherRobolectricImpl.kt @@ -2,13 +2,20 @@ package org.oppia.testing import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Delay import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.InternalCoroutinesApi +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.DelayController import kotlinx.coroutines.test.UncompletedCoroutinesError +import kotlinx.coroutines.withTimeout import java.util.TreeSet import java.util.concurrent.CopyOnWriteArraySet +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger import javax.inject.Inject @@ -43,6 +50,17 @@ class TestCoroutineDispatcherRobolectricImpl private constructor( private val totalTaskCount = AtomicInteger(0) private var taskIdleListener: TaskIdleListener? = null + /** + * A coroutine dispatcher used to monitor flushing the dispatcher queue. This is not used as a + * true coroutine dispatcher since interactions with it always block the calling thread. + */ + private val queueCoroutineDispatcher by lazy { + Executors.newSingleThreadExecutor().asCoroutineDispatcher() + } + private val queueCoroutineScope by lazy { + CoroutineScope(queueCoroutineDispatcher) + } + @ExperimentalCoroutinesApi override val currentTime: Long get() = fakeSystemClock.getTimeMillis() @@ -60,7 +78,10 @@ class TestCoroutineDispatcherRobolectricImpl private constructor( @ExperimentalCoroutinesApi override fun advanceTimeBy(delayTimeMillis: Long): Long { - flushTaskQueue(fakeSystemClock.advanceTime(delayTimeMillis)) + flushTaskQueueBlocking( + fakeSystemClock.advanceTime(delayTimeMillis), + DEFAULT_TIMEOUT_UNIT.toMillis(DEFAULT_TIMEOUT_SECONDS) + ) return delayTimeMillis } @@ -74,7 +95,9 @@ class TestCoroutineDispatcherRobolectricImpl private constructor( @ExperimentalCoroutinesApi override fun cleanupTestCoroutines() { - flushTaskQueue(fakeSystemClock.getTimeMillis()) + flushTaskQueueBlocking( + fakeSystemClock.getTimeMillis(), DEFAULT_TIMEOUT_UNIT.toMillis(DEFAULT_TIMEOUT_SECONDS) + ) val remainingTaskCount = taskQueue.size if (remainingTaskCount != 0) { throw UncompletedCoroutinesError( @@ -98,12 +121,37 @@ class TestCoroutineDispatcherRobolectricImpl private constructor( @ExperimentalCoroutinesApi override fun resumeDispatcher() { isRunning.set(true) - flushTaskQueue(fakeSystemClock.getTimeMillis()) + flushTaskQueueBlocking( + fakeSystemClock.getTimeMillis(), DEFAULT_TIMEOUT_UNIT.toMillis(DEFAULT_TIMEOUT_SECONDS) + ) } @ExperimentalCoroutinesApi override fun runCurrent() { - flushTaskQueue(fakeSystemClock.getTimeMillis()) + runCurrent(DEFAULT_TIMEOUT_SECONDS, DEFAULT_TIMEOUT_UNIT) + } + + override fun runCurrent(timeout: Long, timeoutUnit: TimeUnit) { + flushTaskQueueBlocking(fakeSystemClock.getTimeMillis(), timeoutUnit.toMillis(timeout)) + } + + override fun runUntilIdle(timeout: Long, timeoutUnit: TimeUnit) { + val runUntilIdleDeferred = queueCoroutineScope.async { + var nextTaskTimeMillis: Long? + do { + val currentTimeMillis = fakeSystemClock.getTimeMillis() + flushTaskQueueNonBlocking(fakeSystemClock.getTimeMillis()) + nextTaskTimeMillis = getNextFutureTaskCompletionTimeMillis(currentTimeMillis) + if (nextTaskTimeMillis != null) { + fakeSystemClock.advanceTime(nextTaskTimeMillis - currentTimeMillis) + } + } while (nextTaskTimeMillis != null) + } + runBlocking { + withTimeout(timeoutUnit.toMillis(timeout)) { + runUntilIdleDeferred.await() + } + } } override fun hasPendingTasks(): Boolean = taskQueue.isNotEmpty() @@ -134,18 +182,26 @@ class TestCoroutineDispatcherRobolectricImpl private constructor( notifyIfRunning() } + private fun flushTaskQueueBlocking(currentTimeMillis: Long, timeoutMillis: Long) { + val flushTaskDeferred = queueCoroutineScope.async { + flushTaskQueueNonBlocking(currentTimeMillis) + } + runBlocking { + withTimeout(timeoutMillis) { + flushTaskDeferred.await() + } + } + } + @Suppress("ControlFlowWithEmptyBody") - private fun flushTaskQueue(currentTimeMillis: Long) { - // TODO(#89): Add timeout support so that the dispatcher can't effectively deadlock or livelock - // for inappropriately behaved tests. + private fun flushTaskQueueNonBlocking(currentTimeMillis: Long) { while (isRunning.get()) { if (!flushActiveTaskQueue(currentTimeMillis)) { break } } - while (executingTaskCount.get() > 0) - - notifyIfIdle() + while (executingTaskCount.get() > 0); + notifyIfIdle() } /** Flushes the current task queue and returns whether any tasks were executed. */ diff --git a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchers.kt b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchers.kt index e0a71dd415e..e16169758df 100644 --- a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchers.kt +++ b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchers.kt @@ -64,6 +64,12 @@ interface TestCoroutineDispatchers { */ fun runCurrent() + /** + * Same as [runCurrent] except additional dispatchers are considered. See [advanceUntilIdleWith] + * for more details. + */ + fun runCurrentWith(vararg additionalDispatchers: TestCoroutineDispatcher) + /** * Advances the system clock by the specified time in milliseconds and then ensures any new tasks * that were scheduled are fully executed before proceeding. This does not guarantee the @@ -91,4 +97,16 @@ interface TestCoroutineDispatchers { * unintentional side effect of executing future tasks before the test anticipates it. */ fun advanceUntilIdle() + + /** + * Same as [advanceUntilIdle] except this coordinates both the internal coroutine dispatchers of + * this class with the additional, optional dispatchers specified. This is only expected to be + * used in cases when blocking calls need to be verified when there are bidirectional blocks + * between the blocking thread and an internal dispatcher of this class. This function helps + * prevent deadlocking in such situations. + * + * @param additionalDispatchers a list of [TestCoroutineDispatcher]s whose internal task queue + * will be synchronized with the internal dispatchers of this class + */ + fun advanceUntilIdleWith(vararg additionalDispatchers: TestCoroutineDispatcher) } diff --git a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchersEspressoImpl.kt b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchersEspressoImpl.kt index 0a23e798c90..1ca9d9cb586 100644 --- a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchersEspressoImpl.kt +++ b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchersEspressoImpl.kt @@ -34,6 +34,10 @@ class TestCoroutineDispatchersEspressoImpl @Inject constructor( advanceUntilIdle() } + override fun runCurrentWith(vararg additionalDispatchers: TestCoroutineDispatcher) { + throw UnsupportedOperationException("This function is not supported in Espresso-enabled tests") + } + override fun advanceTimeBy(delayTimeMillis: Long) { // No actual sleep is needed since Espresso will automatically run until all tasks are // completed since idleness ties to all tasks, even future ones. @@ -45,6 +49,13 @@ class TestCoroutineDispatchersEspressoImpl @Inject constructor( onIdle() } + override fun advanceUntilIdleWith(vararg additionalDispatchers: TestCoroutineDispatcher) { + // This functionality could be added in the future, but it's a bit complex since it requires + // temporarily coordinating this class's idling resource with the new dispatchers, then + // triggering an onIdle call. + throw UnsupportedOperationException("This function is not supported in Espresso-enabled tests") + } + /** Returns whether any of the dispatchers have tasks that can be run now. */ private fun hasPendingCompletableTasks(): Boolean { return backgroundTestDispatcher.hasPendingCompletableTasks() || diff --git a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchersRobolectricImpl.kt b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchersRobolectricImpl.kt index 01abe336a82..efd013ff00f 100644 --- a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchersRobolectricImpl.kt +++ b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchersRobolectricImpl.kt @@ -28,8 +28,12 @@ class TestCoroutineDispatchersRobolectricImpl @Inject constructor( } override fun runCurrent() { + runCurrentWith(/* no extra dispatchers */) + } + + override fun runCurrentWith(vararg additionalDispatchers: TestCoroutineDispatcher) { do { - flushNextTasks() + flushNextTasks(additionalDispatchers) } while (hasPendingCompletableTasks()) } @@ -49,8 +53,12 @@ class TestCoroutineDispatchersRobolectricImpl @Inject constructor( } override fun advanceUntilIdle() { + advanceUntilIdleWith(/* no extra dispatchers */) + } + + override fun advanceUntilIdleWith(vararg additionalDispatchers: TestCoroutineDispatcher) { // First, run through all tasks that are currently pending and can be run immediately. - runCurrent() + runCurrentWith(*additionalDispatchers) // Now, the dispatchers can't proceed until time moves forward. Execute the next most recent // task schedule, and everything subsequently scheduled until the dispatchers are in a waiting @@ -62,7 +70,7 @@ class TestCoroutineDispatchersRobolectricImpl @Inject constructor( "Expected to find task with delay for waiting dispatchers with non-empty task queues" } fakeSystemClock.advanceTime(taskDelayMillis) - runCurrent() + runCurrentWith(*additionalDispatchers) } } @@ -80,7 +88,7 @@ class TestCoroutineDispatchersRobolectricImpl @Inject constructor( } } - private fun flushNextTasks() { + private fun flushNextTasks(additionalDispatchers: Array) { if (backgroundTestDispatcher.hasPendingCompletableTasks()) { backgroundTestDispatcher.runCurrent() } @@ -90,6 +98,15 @@ class TestCoroutineDispatchersRobolectricImpl @Inject constructor( if (!uiTaskCoordinator.isIdle()) { uiTaskCoordinator.idle() } + + // Flush the additional dispatchers last. While there are some obscure inconsistencies that can + // occur due to the order of task flushing, it's unlikely to introduce real issues since this + // function is run in a loop. + additionalDispatchers.forEach { + if (it.hasPendingCompletableTasks()) { + it.runCurrent() + } + } } /** Returns whether any of the dispatchers have any tasks to run, including in the future. */ diff --git a/testing/src/test/java/org/oppia/testing/CoroutineExecutorServiceTest.kt b/testing/src/test/java/org/oppia/testing/CoroutineExecutorServiceTest.kt new file mode 100644 index 00000000000..2a0a5d1171e --- /dev/null +++ b/testing/src/test/java/org/oppia/testing/CoroutineExecutorServiceTest.kt @@ -0,0 +1,1091 @@ +package org.oppia.testing + +import android.app.Application +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import dagger.BindsInstance +import dagger.Component +import dagger.Module +import dagger.Provides +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.junit.MockitoJUnit +import org.mockito.junit.MockitoRule +import org.oppia.util.data.AsyncResult +import org.oppia.util.threading.BackgroundDispatcher +import org.robolectric.annotation.LooperMode +import java.util.concurrent.Callable +import java.util.concurrent.ExecutionException +import java.util.concurrent.Executor +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.RejectedExecutionException +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.reflect.KClass +import kotlin.reflect.full.cast + +/** + * Tests for [CoroutineExecutorService]. NOTE: significant care should be taken when modifying these + * tests since they combine several different coroutine dispatchers, including a real-time + * dispatcher, the ones coordinated by [TestCoroutineDispatchers], and custom test dispatchers in + * order to test complex scenarios like whether certain blocking operations block/timeout/complete + * as expected. + * + * Many of these tests also depend on real time, which means different performing machines may + * introduce flakes when running these tests. Please reach out to oppia-android-dev@googlegroups.com + * if you find yourself in this situation. + * + * For developers changing this suite: note that an n-threaded real dispatcher is used to test + * blocking operations since coordinating multiple co-dependent test dispatchers creates a circular + * dependency that effectively always results in a deadlock (since there's no way to control the + * order of tasks being executed in a coroutine dispatcher). + */ +@RunWith(AndroidJUnit4::class) +@LooperMode(LooperMode.Mode.PAUSED) +class CoroutineExecutorServiceTest { + @Rule + @JvmField + val mockitoRule: MockitoRule = MockitoJUnit.rule() + + @Inject + @field:BackgroundDispatcher + lateinit var backgroundCoroutineDispatcher: CoroutineDispatcher + + @Inject lateinit var testCoroutineDispatchers: TestCoroutineDispatchers + + @Inject lateinit var testDispatcherFactory: TestCoroutineDispatcher.Factory + + @Mock lateinit var mockRunnable: Runnable + @Mock lateinit var mockCallable: Callable + + private val testDispatcher by lazy { + testDispatcherFactory.createDispatcher( + Executors.newSingleThreadExecutor().asCoroutineDispatcher() + ) + } + private val testDispatcherScope by lazy { CoroutineScope(testDispatcher) } + + private val testDispatcher2 by lazy { + testDispatcherFactory.createDispatcher( + Executors.newSingleThreadExecutor().asCoroutineDispatcher() + ) + } + private val testDispatcherScope2 by lazy { CoroutineScope(testDispatcher2) } + + // Dispatcher that can continually execute blocking tasks without deadlocking. + private val realDispatcher = Executors.newCachedThreadPool().asCoroutineDispatcher() + private val realDispatcherScope = CoroutineScope(realDispatcher) + + @Before + fun setUp() { + setUpTestApplicationComponent() + } + + @Test + fun testExecute_withoutRunningPendingTasks_doesNotRunScheduledTask() { + val executor = createExecutorServiceAsExecutor() + + executor.execute(mockRunnable) + + verify(mockRunnable, never()).run() + } + + @Test + fun testExecute_afterRunningPendingTasks_runsScheduledTask() { + val executor = createExecutorServiceAsExecutor() + + executor.execute(mockRunnable) + testCoroutineDispatchers.runCurrent() + + verify(mockRunnable).run() + } + + @Test + fun testExecute_nullParameter_throwsException() { + val executorService = createExecutorService() + + assertThrows(NullPointerException::class) { executorService.execute(/* command= */ null) } + } + + @Test + fun testExecute_afterShutdown_throwsException() { + val executorService = createExecutorService() + executorService.shutdown() + + assertThrows(RejectedExecutionException::class) { executorService.execute(mockRunnable) } + } + + @Test + fun testExecute_afterShutdownNow_throwsException() { + val executorService = createExecutorService() + executorService.shutdownNow() + + assertThrows(RejectedExecutionException::class) { executorService.execute(mockRunnable) } + } + + @Test + fun testSubmitRunnable_withoutRunningPendingTasks_doesNotRunScheduledTask() { + val executorService = createExecutorService() + + executorService.submit(mockRunnable) + + verify(mockRunnable, never()).run() + } + + @Test + fun testSubmitRunnable_afterRunningPendingTasks_runsScheduledTask() { + val executorService = createExecutorService() + + executorService.submit(mockRunnable) + testCoroutineDispatchers.runCurrent() + + verify(mockRunnable).run() + } + + @Test + fun testSubmitRunnable_nullParameter_throwsException() { + val executorService = createExecutorService() + + val nullRunnable: Runnable? = null + assertThrows(NullPointerException::class) { executorService.submit(nullRunnable) } + } + + @Test + fun testSubmitRunnable_afterShutdown_throwsException() { + val executorService = createExecutorService() + executorService.shutdown() + + assertThrows(RejectedExecutionException::class) { executorService.submit(mockRunnable) } + } + + @Test + fun testSubmitRunnable_afterShutdownNow_throwsException() { + val executorService = createExecutorService() + executorService.shutdownNow() + + assertThrows(RejectedExecutionException::class) { executorService.submit(mockRunnable) } + } + + @Test + fun testSubmitCallable_withoutRunningPendingTasks_doesNotRunScheduledTask() { + val executorService = createExecutorService() + + executorService.submit(mockCallable) + + verify(mockCallable, never()).call() + } + + @Test + fun testSubmitCallable_afterRunningPendingTasks_runsScheduledTask() { + val executorService = createExecutorService() + + executorService.submit(mockCallable) + testCoroutineDispatchers.runCurrent() + + verify(mockCallable).call() + } + + @Test + fun testSubmitCallable_nullParameter_throwsException() { + val executorService = createExecutorService() + + val nullCallable: Callable? = null + assertThrows(NullPointerException::class) { executorService.submit(nullCallable) } + } + + @Test + fun testSubmitCallable_afterShutdown_throwsException() { + val executorService = createExecutorService() + executorService.shutdown() + + assertThrows(RejectedExecutionException::class) { executorService.submit(mockCallable) } + } + + @Test + fun testSubmitCallable_afterShutdownNow_throwsException() { + val executorService = createExecutorService() + executorService.shutdownNow() + + assertThrows(RejectedExecutionException::class) { executorService.submit(mockCallable) } + } + + @Test + fun testSubmitCallable_returnedFuture_withoutRunningTasks_isNotCompleted() { + val executorService = createExecutorService() + val callable = Callable { "Test" } + + val callableFuture = executorService.submit(callable) + + assertThat(callableFuture.isDone).isFalse() + } + + @Test + fun testSubmitCallable_returnedFuture_afterRunningTasks_isCompleted() { + val executorService = createExecutorService() + val callable = Callable { "Test" } + + val callableFuture = executorService.submit(callable) + testCoroutineDispatchers.runCurrent() + + assertThat(callableFuture.isDone).isTrue() + } + + @Test + fun testSubmitCallable_failed_returnedFuture_afterRunningTasks_hasFailure() { + val executorService = createExecutorService() + val callable = Callable { throw Exception("Task failed") } + + val callableFuture = executorService.submit(callable) + testCoroutineDispatchers.runCurrent() + + assertThat(callableFuture.isDone).isTrue() + val exception = assertThrows(ExecutionException::class) { callableFuture.get() } + assertThat(exception).hasCauseThat().isInstanceOf(Exception::class.java) + assertThat(exception).hasCauseThat().hasMessageThat().contains("Task failed") + } + + @Test + fun testSubmitCallable_successfulTask_afterFailure_returnedFutureSucceeds() { + val executorService = createExecutorService() + val failingCallable = Callable { throw Exception("Task failed") } + val succeedingCallable = Callable { "Task succeeded" } + + // Note that order matters here: this test is verifying that a successful task completing after + // a failing task can still succeed (rather than getting the same failure as the failing task). + // Note that the two runCurrent calls and the isDone verification below are important to ensure + // the task failure is recognized before evaluating whether the successful task succeeded. + val failingFuture = executorService.submit(failingCallable) + testCoroutineDispatchers.runCurrent() + val succeedingFuture = executorService.submit(succeedingCallable) + testCoroutineDispatchers.runCurrent() + + assertThat(failingFuture.isDone).isTrue() + assertThat(succeedingFuture.isDone).isTrue() + assertThat(succeedingFuture.get()).contains("Task succeeded") + } + + @Test + @Suppress("BlockingMethodInNonBlockingContext") // Intentional for testing purposes. + fun testSubmitCallable_returnedFuture_pendingTask_tasksNotRun_getFunctionBlocks() { + val executorService = createExecutorService() + val callable = Callable { "Test" } + val callableFuture = executorService.submit(callable) + + val getResult = testDispatcherScope.async { + callableFuture.get() + } + + // The getter should not return since the task isn't yet completed. + assertThat(getResult.isCompleted).isFalse() + } + + @Test + @Suppress("BlockingMethodInNonBlockingContext") // Intentional for testing purposes. + @ExperimentalCoroutinesApi + fun testSubmitCallable_returnedFuture_pendingTask_runTasks_getFunctionReturnsComputedValue() { + val executorService = createExecutorService() + val callable = Callable { "Test" } + val callableFuture = executorService.submit(callable) + + val getResult = testDispatcherScope.async { + callableFuture.get() + } + testCoroutineDispatchers.runCurrent() + testDispatcher.runUntilIdle() + + // The getter should return since the task has finished. + assertThat(getResult.isCompleted).isTrue() + assertThat(getResult.getCompleted()).isEqualTo("Test") + } + + @Test + @Suppress("BlockingMethodInNonBlockingContext") // Intentional for testing purposes. + @ExperimentalCoroutinesApi + fun testSubmitCallable_returnedFuture_pendingTask_tasksNotRun_timedGetFuncTimesOut() { + val executorService = createExecutorService() + val callable = Callable { "Test" } + val callableFuture = executorService.submit(callable) + + val getResult = testDispatcherScope.async { + try { + AsyncResult.success(callableFuture.get(/* timeout= */ 1, TimeUnit.SECONDS)) + } catch (e: ExecutionException) { + AsyncResult.failed(e) + } + } + testDispatcher.runUntilIdle() + + // The getter should return since the task has finished. + assertThat(getResult.isCompleted).isTrue() + assertThat(getResult.getCompleted().isFailure()).isTrue() + assertThat(getResult.getCompleted().getErrorOrNull()) + .isInstanceOf(ExecutionException::class.java) + assertThat(getResult.getCompleted().getErrorOrNull()?.cause) + .isInstanceOf(TimeoutException::class.java) + } + + @Test + @Suppress("BlockingMethodInNonBlockingContext") // Intentional for testing purposes. + @ExperimentalCoroutinesApi + fun testSubmitCallable_returnedFuture_pendingTask_runTasks_timedGetFuncDoesNotTimeOut() { + val executorService = createExecutorService() + val callable = Callable { "Test" } + val callableFuture = executorService.submit(callable) + + val getResult = realDispatcherScope.async { + callableFuture.get(/* timeout= */ 1, TimeUnit.SECONDS) + } + testCoroutineDispatchers.runCurrent() + waitForDeferredWithDispatcher1(getResult) + + // The getter should return since the task has finished. + assertThat(getResult.getCompleted()).isEqualTo("Test") + } + + @Test + fun testSubmitCallable_returnedFuture_afterRunningTasks_getsComputedValue() { + val executorService = createExecutorService() + val callable = Callable { "Test" } + + val callableFuture = executorService.submit(callable) + testCoroutineDispatchers.runCurrent() + + assertThat(callableFuture.get()).isEqualTo("Test") + } + + @Test + fun testSubmitCallable_returnedFuture_pendingTask_cancel_isCancelled() { + val executorService = createExecutorService() + val callable = Callable { "Test" } + val callableFuture = executorService.submit(callable) + + callableFuture.cancel(/* mayInterruptIfRunning= */ false) + testCoroutineDispatchers.runCurrent() + + assertThat(callableFuture.isCancelled).isTrue() + } + + @Test + fun testSubmitRunnable_withResult_withoutRunningPendingTasks_doesNotRunScheduledTask() { + val executorService = createExecutorService() + + executorService.submit(mockRunnable, /* result= */ "Test") + + verify(mockRunnable, never()).run() + } + + @Test + fun testSubmitRunnable_withResult_afterRunningPendingTasks_runsScheduledTask() { + val executorService = createExecutorService() + + executorService.submit(mockRunnable, /* result= */ "Test") + testCoroutineDispatchers.runCurrent() + + verify(mockRunnable).run() + } + + @Test + fun testSubmitRunnable_withResult_nullParameter_throwsException() { + val executorService = createExecutorService() + + val nullRunnable: Runnable? = null + assertThrows(NullPointerException::class) { + executorService.submit(nullRunnable, /* result= */ "Test") + } + } + + @Test + fun testSubmitRunnable_withResult_afterShutdown_throwsException() { + val executorService = createExecutorService() + executorService.shutdown() + + assertThrows(RejectedExecutionException::class) { + executorService.submit(mockRunnable, /* result= */ "Test") + } + } + + @Test + fun testSubmitRunnable_withResult_afterShutdownNow_throwsException() { + val executorService = createExecutorService() + executorService.shutdownNow() + + assertThrows(RejectedExecutionException::class) { + executorService.submit(mockRunnable, /* result= */ "Test") + } + } + + @Test + fun testSubmitRunnable_withResult_afterRunningTasks_returnsFutureWithResult() { + val executorService = createExecutorService() + + val resultFuture = executorService.submit(mockRunnable, /* result= */ "Test") + testCoroutineDispatchers.runCurrent() + + // Verify that the result value is propagated to the finished future. + assertThat(resultFuture.isDone).isTrue() + assertThat(resultFuture.get()).isEqualTo("Test") + } + + /* Note that the tests to verify shutdown-before-execution fails are elsewhere in the suite. */ + @Test + fun testShutdown_afterExecute_doNotRunPendingTasks_doesNotRunTask() { + val executorService = createExecutorService() + executorService.submit(mockRunnable) + + executorService.shutdown() + + // Verify that shutdown() does not immediately force tasks to run. + verify(mockRunnable, never()).run() + } + + @Test + fun testShutdown_afterExecute_thenRunPendingTasks_finishesTask() { + val executorService = createExecutorService() + executorService.submit(mockRunnable) + + executorService.shutdown() + testCoroutineDispatchers.runCurrent() + + // The task is run because shutdown() doesn't stop existing tasks from being run. + verify(mockRunnable).run() + } + + @Test + fun testShutdownNow_noTasks_returnsEmptyList() { + val executorService = createExecutorService() + + val pendingTasks = executorService.shutdownNow() + testCoroutineDispatchers.runCurrent() + + // No tasks were cancelled. + assertThat(pendingTasks).isEmpty() + } + + @Test + fun testShutdownNow_finishedTask_returnsEmptyList() { + val executorService = createExecutorService() + executorService.submit(mockRunnable) + testCoroutineDispatchers.runCurrent() + + val pendingTasks = executorService.shutdownNow() + testCoroutineDispatchers.runCurrent() + + // No tasks were cancelled. + assertThat(pendingTasks).isEmpty() + } + + @Test + fun testShutdownNow_afterExecute_thenRunPendingTasks_doesNotRunTask() { + val executorService = createExecutorService() + executorService.submit(mockRunnable) + + executorService.shutdownNow() + testCoroutineDispatchers.runCurrent() + + // The task should not be run because shutdownNow() prevents non-started tasks from beginning. + verify(mockRunnable, never()).run() + } + + @Test + fun testShutdownNow_afterExecute_thenRunPendingTasks_returnsRunnableForPendingTask() { + val executorService = createExecutorService() + executorService.submit(mockRunnable) + + val pendingTasks = executorService.shutdownNow() + testCoroutineDispatchers.runCurrent() + + // Only verify that a single pending task was cancelled. No additional verification is done + // because ExecutorService doesn't clearly define what the returned Runnable should represent. + assertThat(pendingTasks).hasSize(1) + } + + /* + * Note that the equivalent of this test does not exist for shutdown() because: + * 1) It would effectively be a more complicated version of the existing test that verifies + * shutdown does not stop late task execution. + * 2) It's harder to arrange because there's a circular blocking dependency between the real + * dispatcher, the test thread, the custom test dispatcher, and the coordinated test + * dispatchers that can probably only be resolved by punching another hole in the executor + * service. Unlike other tests where this was done, it doesn't seem worth it for this situation + * since there is an existing test for the late-task execution behavior verification. + */ + @Test + fun testShutdownNow_afterStartingLongTask_taskAllowedToComplete_doesNotFinishTask() { + val executorService = createExecutorService() + // Create a long task that waits 1 second before calling the runnable. + val longTask = wrapRunnableWithOneSecondDelayUsingDispatcher1(mockRunnable) + executorService.submit(longTask) + // Kick-off the task, but don't complete it. Note that this is done via a real dispatcher since + // it will block until the test dispatcher is run. + val syncDeferred = realDispatcherScope.async { testCoroutineDispatchers.runCurrent() } + + executorService.shutdownNow() + testDispatcher.runUntilIdle() // Allow the task to complete. + waitForDeferredWithDispatcher1(syncDeferred) + + // The runnable should not have been run because shutdownNow() interrupts it. + verify(mockRunnable, never()).run() + } + + @Test + fun testIsShutdown_withoutShutdown_returnsFalse() { + val executorService = createExecutorService() + + assertThat(executorService.isShutdown).isFalse() + } + + @Test + fun testIsShutdown_afterShutdown_noTasks_returnsTrue() { + val executorService = createExecutorService() + + executorService.shutdown() + + assertThat(executorService.isShutdown).isTrue() + } + + @Test + fun testIsShutdown_afterShutdown_withIncompleteTask_returnsTrue() { + val executorService = createExecutorService() + executorService.submit(mockRunnable) + + executorService.shutdown() + + assertThat(executorService.isShutdown).isTrue() + } + + @Test + fun testIsShutdown_afterShutdown_afterPendingTaSksFinish_returnsTrue() { + val executorService = createExecutorService() + executorService.submit(mockRunnable) + + executorService.shutdown() + testCoroutineDispatchers.runCurrent() + + // Tasks finishing after shutdown is called should not affect whether it's shutdown. + assertThat(executorService.isShutdown).isTrue() + } + + @Test + fun testIsTerminated_withoutShutdown_returnsFalse() { + val executorService = createExecutorService() + + assertThat(executorService.isTerminated).isFalse() + } + + @Test + fun testIsTerminated_afterShutdown_noTasks_returnsTrue() { + val executorService = createExecutorService() + + executorService.shutdown() + + assertThat(executorService.isTerminated).isTrue() + } + + @Test + fun testIsTerminated_afterShutdown_withIncompleteTasks_returnsFalse() { + val executorService = createExecutorService() + executorService.submit(mockRunnable) + + executorService.shutdown() + + // While the service is shutdown, it's not terminated since not all tasks have finished. + assertThat(executorService.isTerminated).isFalse() + } + + @Test + fun testIsTerminated_afterShutdown_afterPendingTaSksFinish_returnsTrue() { + val executorService = createExecutorService() + executorService.submit(mockRunnable) + + executorService.shutdown() + testCoroutineDispatchers.runCurrent() + + // The service is considered terminated only after tasks have completed. + assertThat(executorService.isTerminated).isTrue() + } + + @Test + fun testAwaitTermination_beforeShutdown_throwsException() { + val executorService = createExecutorService() + + // Note that this is not documented in the ExecutorService documentation, it seems necessary + // since it doesn't make sense to return false (per the documentation) or block unless a + // shutdown request was actually initiated. + assertThrows(IllegalStateException::class) { + executorService.awaitTermination(/* timeout= */ 1, TimeUnit.SECONDS) + } + } + + @Test + fun testAwaitTermination_afterShutdown_noTasks_returnsTrue() { + val executorService = createExecutorService() + executorService.shutdown() + + val isTerminated = executorService.awaitTermination(/* timeout= */ 1, TimeUnit.SECONDS) + + assertThat(isTerminated).isTrue() + } + + @Test + @Suppress("BlockingMethodInNonBlockingContext") // Intentional to test blocking. + @ExperimentalCoroutinesApi + fun testAwaitTermination_afterShutdown_withLongTask_exceedTimeout_returnsFalse() { + val executorService = createExecutorService() + val delayMs = 10L + executorService.submit( + lateFinishingCallableWithDispatcher2( + Callable { "Test 1" }, timeToWaitMillis = delayMs * 10 + ) + ) + executorService.shutdown() + autoSettleServiceBeforeBlocking(executorService) + + val terminationDeferred = realDispatcherScope.async { + executorService.awaitTermination(delayMs, TimeUnit.MILLISECONDS) + } + waitForDeferredWithDispatcher1(terminationDeferred) + + // The long task did not not complete in time, so the awaitTermination should fail. + assertThat(terminationDeferred.getCompleted()).isFalse() + } + + @Test + @Suppress("BlockingMethodInNonBlockingContext") // Intentional to test blocking. + @ExperimentalCoroutinesApi + fun testAwaitTermination_afterShutdown_withTasks_finishWithinTimeout_returnsTrue() { + val executorService = createExecutorService() + val delayMs = 10L + executorService.submit( + lateFinishingCallableWithDispatcher2( + Callable { "Test 1" }, timeToWaitMillis = delayMs + ) + ) + executorService.shutdown() + autoSettleServiceBeforeBlocking(executorService) + + val terminationDeferred = realDispatcherScope.async { + executorService.awaitTermination(delayMs * 10, TimeUnit.MILLISECONDS) + } + waitForDeferredWithDispatcher1(terminationDeferred) + + // The long task did not not complete in time, so the awaitTermination should fail. + // The long task finished, so the awaitTermination should succeed. + assertThat(terminationDeferred.getCompleted()).isTrue() + } + + @Test + @Suppress("BlockingMethodInNonBlockingContext") // Intentional to test blocking. + fun testInvokeAll_doNotRunTasks_blocks() { + val executorService = createExecutorService() + val callable1 = Callable { "Test 1" } + val callable2 = Callable { "Test 2" } + + val deferred = testDispatcherScope2.async { + executorService.invokeAll(listOf(callable1, callable2)) + } + + assertThat(deferred.isCompleted).isFalse() + } + + @Test + @Suppress("BlockingMethodInNonBlockingContext") // Intentional to test blocking interruption. + @ExperimentalCoroutinesApi + fun testInvokeAll_oneTask_afterShutdown_throwsException() { + val executorService = createExecutorService() + val callable1 = Callable { "Test 1" } + val callable2 = Callable { "Test 2" } + executorService.shutdown() + + val deferred = realDispatcherScope.async { + executorService.invokeAll(listOf(callable1, callable2)) + } + waitForDeferredWithDispatcher1(deferred) + + assertThat(deferred.getCompletionExceptionOrNull()) + .isInstanceOf(RejectedExecutionException::class.java) + } + + @Test + fun testInvokeAll_nullTasks_throwsException() { + val executorService = createExecutorService() + + assertThrows(NullPointerException::class) { executorService.invokeAll(/* tasks= */ null) } + } + + @Test + @Suppress("BlockingMethodInNonBlockingContext") // Intentional to test blocking interruption. + @ExperimentalCoroutinesApi + fun testInvokeAll_oneTask_afterShutdownNow_throwsException() { + val executorService = createExecutorService() + val callable1 = Callable { "Test 1" } + val callable2 = Callable { "Test 2" } + executorService.shutdownNow() + + val deferred = realDispatcherScope.async { + executorService.invokeAll(listOf(callable1, callable2)) + } + waitForDeferredWithDispatcher1(deferred) + + assertThat(deferred.getCompletionExceptionOrNull()) + .isInstanceOf(RejectedExecutionException::class.java) + } + + @Test + @Suppress("BlockingMethodInNonBlockingContext") // Intentional to test blocking. + @ExperimentalCoroutinesApi + fun testInvokeAll_runTasks_returnsListOfCompletedFuturesWithCorrectValuesInOrder() { + val executorService = createExecutorService() + val callable1 = Callable { "Test 1" } + val callable2 = Callable { "Test 2" } + autoSettleServiceBeforeBlocking(executorService) + + val deferred = realDispatcherScope.async { + executorService.invokeAll(listOf(callable1, callable2)) + } + waitForDeferredWithDispatcher1(deferred) + + // Since the executor finished execution, the invokeAll() call should return. + val (future1, future2) = deferred.getCompleted() + assertThat(future1.isDone).isTrue() + assertThat(future2.isDone).isTrue() + assertThat(future1.get()).isEqualTo("Test 1") + assertThat(future2.get()).isEqualTo("Test 2") + } + + @Test + @Suppress("BlockingMethodInNonBlockingContext") // Intentional to test blocking. + @ExperimentalCoroutinesApi + fun testInvokeAll_oneTaskFails_runTasks_returnsListOfCompletedFuturesWithCorrectValuesInOrder() { + val executorService = createExecutorService() + val callable1 = Callable { throw Exception("Task 1 failed") } + val callable2 = Callable { "Test 2" } + autoSettleServiceBeforeBlocking(executorService) + + val deferred = realDispatcherScope.async { + executorService.invokeAll(listOf(callable1, callable2)) + } + waitForDeferredWithDispatcher1(deferred) + + // Since the executor finished execution, the invokeAll() call should return. + val (future1, future2) = deferred.getCompleted() + assertThat(future1.isDone).isTrue() + assertThat(future2.isDone).isTrue() + assertThrows(ExecutionException::class) { future1.get() } + assertThat(future2.get()).isEqualTo("Test 2") + } + + @Test + @Suppress("BlockingMethodInNonBlockingContext") // Intentional to test blocking. + @ExperimentalCoroutinesApi + fun testInvokeAll_withTimeout_doNotFinishTasksOnTime_timesOut() { + val executorService = createExecutorService() + val delayMs = 10L + val callable1 = lateFinishingCallableWithDispatcher2( + Callable { "Test 1" }, timeToWaitMillis = delayMs * 10 + ) + val callable2 = Callable { "Test 2" } + autoSettleServiceBeforeBlocking(executorService) + + val deferred = realDispatcherScope.async { + executorService.invokeAll(listOf(callable1, callable2), delayMs, TimeUnit.MILLISECONDS) + } + // Note that this must be different than the dispatcher used to block callable1 to prevent + // deadlocking. + waitForDeferredWithDispatcher1(deferred) + + // Verify that the first task doesn't complete since it took too long to run. + val (future1, future2) = deferred.getCompleted() + assertThat(future1.isCancelled).isTrue() + assertThat(future2.isDone).isTrue() + assertThat(future2.get()).isEqualTo("Test 2") + } + + @Test + @Suppress("BlockingMethodInNonBlockingContext") // Intentional to test blocking. + fun testInvokeAny_doNotRunTasks_blocks() { + val executorService = createExecutorService() + val callable = Callable { "Test 1" } + + val deferred = testDispatcherScope2.async { + executorService.invokeAny(listOf(callable)) + } + + assertThat(deferred.isCompleted).isFalse() + } + + @Test + @Suppress("BlockingMethodInNonBlockingContext") // Intentional to test blocking. + @ExperimentalCoroutinesApi + fun testInvokeAny_oneTask_runTasks_returnsValueOfFirstTask() { + val executorService = createExecutorService() + val callable = Callable { "Test 1" } + autoSettleServiceBeforeBlocking(executorService) + + val deferred = realDispatcherScope.async { + executorService.invokeAny(listOf(callable)) + } + waitForDeferredWithDispatcher1(deferred) + + assertThat(deferred.getCompleted()).isEqualTo("Test 1") + } + + @Test + fun testInvokeAny_nullTasks_throwsException() { + val executorService = createExecutorService() + + assertThrows(NullPointerException::class) { executorService.invokeAny(/* tasks= */ null) } + } + + @Test + @Suppress("BlockingMethodInNonBlockingContext") // Intentional to test blocking interruption. + @ExperimentalCoroutinesApi + fun testInvokeAny_oneTask_afterShutdown_throwsException() { + val executorService = createExecutorService() + val callable = Callable { "Test 1" } + executorService.shutdown() + autoSettleServiceBeforeBlocking(executorService) + + val deferred = realDispatcherScope.async { + executorService.invokeAny(listOf(callable)) + } + waitForDeferredWithDispatcher1(deferred) + + assertThat(deferred.getCompletionExceptionOrNull()) + .isInstanceOf(RejectedExecutionException::class.java) + } + + @Test + @Suppress("BlockingMethodInNonBlockingContext") // Intentional to test blocking interruption. + @ExperimentalCoroutinesApi + fun testInvokeAny_oneTask_afterShutdownNow_throwsException() { + val executorService = createExecutorService() + val callable = Callable { "Test 1" } + executorService.shutdownNow() + autoSettleServiceBeforeBlocking(executorService) + + val deferred = realDispatcherScope.async { + executorService.invokeAny(listOf(callable)) + } + waitForDeferredWithDispatcher1(deferred) + + assertThat(deferred.getCompletionExceptionOrNull()) + .isInstanceOf(RejectedExecutionException::class.java) + } + + @Test + @Suppress("BlockingMethodInNonBlockingContext") // Intentional to test blocking. + @ExperimentalCoroutinesApi + fun testInvokeAny_oneShortTask_oneLongTask_runTasks_returnsValueOfShortTask() { + val executorService = createExecutorService() + val delayMs = 10L + val callable1 = lateFinishingCallableWithDispatcher2( + Callable { "Long task" }, timeToWaitMillis = delayMs * 10 + ) + val callable2 = Callable { "Short task" } + // Wait for the invokeAny selection to be fully arranged before executing, otherwise the delayed + // task is guaranteed to be finished before selection happens (invalidating the selection + // behavior). This is highly dependent on an implementation detail, but there's no other way to + // force execution order to verify the service is doing the right thing for invokeAny. + autoSettleServiceAfterSelection(executorService) + + val deferred = realDispatcherScope.async { + executorService.invokeAny(listOf(callable1, callable2)) + } + // Note that this must be different than the dispatcher used to block callable1 to prevent + // deadlocking. + waitForDeferredWithDispatcher1(deferred) + + assertThat(deferred.getCompleted()).isEqualTo("Short task") + } + + @Test + @Suppress("BlockingMethodInNonBlockingContext") // Intentional to test blocking. + @ExperimentalCoroutinesApi + fun testInvokeAny_oneShortTask_oneLongTask_shortTaskFails_runTasks_returnsValueOfLongTask() { + val executorService = createExecutorService() + val delayMs = 10L + val callable1 = lateFinishingCallableWithDispatcher2( + Callable { "Long task" }, timeToWaitMillis = delayMs * 10 + ) + val callable2 = Callable { throw Exception("Failed task") } + autoSettleServiceBeforeBlocking(executorService) + + val deferred = realDispatcherScope.async { + executorService.invokeAny(listOf(callable1, callable2)) + } + // Note that this must be different than the dispatcher used to block callable1 to prevent + // deadlocking. + waitForDeferredWithDispatcher1(deferred) + + // The short task failing should trigger the long task's result being received. + assertThat(deferred.getCompleted()).isEqualTo("Long task") + } + + @Test + @Suppress("BlockingMethodInNonBlockingContext") // Intentional to test blocking. + @ExperimentalCoroutinesApi + fun testInvokeAny_noTaskCompletesOnTime_throwsTimeoutException() { + val executorService = createExecutorService() + val delayMs = 10L + val callable = lateFinishingCallableWithDispatcher2( + Callable { "Long task" }, timeToWaitMillis = delayMs * 10 + ) + autoSettleServiceAfterSelection(executorService) + + val deferred = realDispatcherScope.async { + executorService.invokeAny(listOf(callable), delayMs, TimeUnit.MILLISECONDS) + } + // Note that this must be different than the dispatcher used to block callable1 to prevent + // deadlocking. + waitForDeferredWithDispatcher1(deferred) + + // The invokeAny call itself should fail with a TimeoutException since nothing finished in time. + assertThat(deferred.getCompletionExceptionOrNull()).isInstanceOf(TimeoutException::class.java) + } + + private fun setUpTestApplicationComponent() { + DaggerCoroutineExecutorServiceTest_TestApplicationComponent.builder() + .setApplication(ApplicationProvider.getApplicationContext()) + .build() + .inject(this) + } + + private fun createExecutorService(): ExecutorService { + return CoroutineExecutorService(backgroundCoroutineDispatcher) + } + + private fun createExecutorServiceAsExecutor(): Executor { + return CoroutineExecutorService(backgroundCoroutineDispatcher) + } + + private fun autoSettleServiceBeforeBlocking(executorService: ExecutorService) { + (executorService as CoroutineExecutorService).setPriorToBlockingCallback { + testCoroutineDispatchers.advanceUntilIdle() + } + } + + private fun autoSettleServiceAfterSelection(executorService: ExecutorService) { + (executorService as CoroutineExecutorService).setAfterSelectionSetup { + testCoroutineDispatchers.advanceUntilIdle() + } + } + + private fun wrapRunnableWithOneSecondDelayUsingDispatcher1(runnable: Runnable): Runnable { + return Runnable { + wrapCallableWithOneSecondDelayUsingDispatcher1(Callable { runnable.run() }).call() + } + } + + private fun wrapCallableWithOneSecondDelayUsingDispatcher1( + callable: Callable + ): Callable { + return wrapCallableWithOneSecondDelay(callable, testDispatcherScope) + } + + private fun wrapCallableWithOneSecondDelay( + callable: Callable, coroutineScope: CoroutineScope + ): Callable { + return Callable { + val deferred = coroutineScope.async { delay(TimeUnit.SECONDS.toMillis(1)) } + runBlocking { + deferred.await() + callable.call() + } + } + } + + /** Waits for the specified deferred, or times out according to the test coroutine dispatchers. */ + private fun waitForDeferredWithDispatcher1(deferred: Deferred) { + @Suppress("BlockingMethodInNonBlockingContext") // Intentional to test blocking. + val deferredWait = testDispatcherScope.async { + runBlocking { + deferred.await() + } + } + testDispatcher.runUntilIdle() + assertThat(deferredWait.isCompleted).isTrue() // Sanity check. + } + + @Suppress("DeferredResultUnused") // Deferred is indirectly blocked on via withContext. + private fun lateFinishingCallableWithDispatcher2( + callable: Callable, + timeToWaitMillis: Long + ): Callable { + return Callable { + runBlocking { + realDispatcherScope.async { + delay(timeToWaitMillis) + testDispatcher2.runUntilIdle() + } + withContext(testDispatcher2) { + callable.call() + } + } + } + } + + // TODO(#89): Move to a common test library. + private fun assertThrows(type: KClass, operation: () -> Unit): T { + try { + operation() + fail("Expected to encounter exception of $type") + } catch (t: Throwable) { + if (type.isInstance(t)) { + return type.cast(t) + } + // Unexpected exception; throw it. + throw t + } + throw AssertionError( + "Reached an impossible state when verifying that an exception was thrown." + ) + } + + // TODO(#89): Move this to a common test application component. + @Module + class TestModule { + @Provides + @Singleton + fun provideContext(application: Application): Context { + return application + } + } + + // TODO(#89): Move this to a common test application component. + @Singleton + @Component( + modules = [ + TestDispatcherModule::class, TestModule::class, TestLogReportingModule::class + ] + ) + interface TestApplicationComponent { + @Component.Builder + interface Builder { + @BindsInstance + fun setApplication(application: Application): Builder + fun build(): TestApplicationComponent + } + + fun inject(coroutineExecutorServiceTest: CoroutineExecutorServiceTest) + } +} From 668d12682abcb785f7bb33c55be8eb271675b352 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Tue, 1 Sep 2020 21:38:52 -0700 Subject: [PATCH 17/36] Fix flaky timeout tests by improving cancellation cooperation for invokeAny() and provide longer timeouts for tests that are CPU-sensitive. --- testing/BUILD.bazel | 7 +++ .../oppia/testing/CoroutineExecutorService.kt | 46 +++++++++++++------ .../testing/CoroutineExecutorServiceTest.kt | 19 +++++--- 3 files changed, 53 insertions(+), 19 deletions(-) diff --git a/testing/BUILD.bazel b/testing/BUILD.bazel index 0637ca73e54..fa7a4744437 100644 --- a/testing/BUILD.bazel +++ b/testing/BUILD.bazel @@ -53,6 +53,13 @@ TEST_DEPS = [ artifact("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.2.2"), ] +testing_test( + name = "CoroutineExecutorServiceTest", + srcs = ["src/test/java/org/oppia/testing/CoroutineExecutorServiceTest.kt"], + test_class = "org.oppia.testing.CoroutineExecutorServiceTest", + deps = TEST_DEPS, +) + testing_test( name = "FakeEventLoggerTest", srcs = ["src/test/java/org/oppia/testing/FakeEventLoggerTest.kt"], diff --git a/testing/src/main/java/org/oppia/testing/CoroutineExecutorService.kt b/testing/src/main/java/org/oppia/testing/CoroutineExecutorService.kt index a77b32a2fb8..599adbe7674 100644 --- a/testing/src/main/java/org/oppia/testing/CoroutineExecutorService.kt +++ b/testing/src/main/java/org/oppia/testing/CoroutineExecutorService.kt @@ -55,16 +55,14 @@ class CoroutineExecutorService( private var isShutdown = false private val pendingTasks = mutableMapOf>() private val cachedThreadPool by lazy { Executors.newCachedThreadPool() } - private val cachedThreadCoroutineDispatcher by lazy { cachedThreadPool.asCoroutineDispatcher() } - private var priorToBlockingCallback: PriorToBlockingCallback? = null - private var afterSelectionSetup: AfterSelectionSetup? = null - /** - * Coroutine scope for executing consecutive tasks for blocking the calling thread and without + * Coroutine dispatcher for executing consecutive tasks for blocking the calling thread and without * interfering with other potentially blocked operations leveraging this scope. This is done using * a cached thread pool that creates new threads as others become blocked. */ - private val cachedThreadCoroutineScope by lazy { CoroutineScope(cachedThreadCoroutineDispatcher) } + private val cachedThreadCoroutineDispatcher by lazy { cachedThreadPool.asCoroutineDispatcher() } + private var priorToBlockingCallback: PriorToBlockingCallback? = null + private var afterSelectionSetup: AfterSelectionSetup? = null override fun shutdown() { serviceLock.withLock { isShutdown = true } @@ -104,6 +102,10 @@ class CoroutineExecutorService( val incompleteTasks = serviceLock.withLock { pendingTasks.values } val timeoutMillis = unit?.toMillis(timeout) ?: 0 + // Create a separate scope in case one of the operation fails--it shouldn't cause later + // operations to fail. + val cachedThreadCoroutineScope = CoroutineScope(cachedThreadCoroutineDispatcher) + // Wait for each task to complete within the specified time. Note that this behaves similarly to // invokeAll() below. val futureTasks = cachedThreadCoroutineScope.async { @@ -137,7 +139,9 @@ class CoroutineExecutorService( val taskDeferreds = tasks.map { dispatchAsync(it) } taskDeferreds.forEach { deferred -> @Suppress("DeferredResultUnused") // Intentionally silence failures (including the service's). - cachedThreadCoroutineScope.async { + // Create a separate scope in case one of the operation fails--it shouldn't cause later + // operations to fail. + CoroutineScope(cachedThreadCoroutineDispatcher).async { try { val result = deferred.await() resultChannel.send(result) @@ -152,12 +156,23 @@ class CoroutineExecutorService( // than the expected behavior of this function: it can exit before all tasks are completed. priorToBlockingCallback?.invoke() return runBlocking { - maybeWithTimeout(unit?.toMillis(timeout) ?: 0) { - select { - resultChannel.onReceive { it } - afterSelectionSetup?.invoke() - } ?: throw ExecutionException(IllegalStateException("All tasks failed to run")) - } + select { + // Use a timeout here instead of wrapping the select since select does not support + // cooperative cancellation. That approach leads to a race between the timeout and the + // selection actually completing in time whereas this ensures early cancellation from + // timeout due to cooperation. + val timeoutMillis = unit?.toMillis(timeout) ?: 0 + if (timeoutMillis > 0) { + @Suppress("EXPERIMENTAL_API_USAGE") + onTimeout(timeoutMillis) { + throw TimeoutException("Timed out after $timeoutMillis") + } + } + resultChannel.onReceive { + it + } + afterSelectionSetup?.invoke() + } ?: throw ExecutionException(IllegalStateException("All tasks failed to run")) } } @@ -179,6 +194,11 @@ class CoroutineExecutorService( } val timeoutMillis = unit?.toMillis(timeout) ?: 0 val deferredTasks = tasks.map { dispatchAsync(it) } + + // Create a separate scope in case one of the operation fails--it shouldn't cause later + // operations to fail. + val cachedThreadCoroutineScope = CoroutineScope(cachedThreadCoroutineDispatcher) + // Wait for each task to complete within the specified time, otherwise cancel the task. val futureTasks = cachedThreadCoroutineScope.async { deferredTasks.map { task -> diff --git a/testing/src/test/java/org/oppia/testing/CoroutineExecutorServiceTest.kt b/testing/src/test/java/org/oppia/testing/CoroutineExecutorServiceTest.kt index 2a0a5d1171e..fd2cbe39185 100644 --- a/testing/src/test/java/org/oppia/testing/CoroutineExecutorServiceTest.kt +++ b/testing/src/test/java/org/oppia/testing/CoroutineExecutorServiceTest.kt @@ -799,15 +799,19 @@ class CoroutineExecutorServiceTest { @ExperimentalCoroutinesApi fun testInvokeAll_withTimeout_doNotFinishTasksOnTime_timesOut() { val executorService = createExecutorService() - val delayMs = 10L + // Note that a longer delay is used here since testing for timeouts is inherently flaky: slower + // machines are more likely to trigger a flake since this relies on a real dispatcher. To guard + // against flakes, a long timeout is picked. val callable1 = lateFinishingCallableWithDispatcher2( - Callable { "Test 1" }, timeToWaitMillis = delayMs * 10 + Callable { "Test 1" }, timeToWaitMillis = 2500L ) val callable2 = Callable { "Test 2" } autoSettleServiceBeforeBlocking(executorService) val deferred = realDispatcherScope.async { - executorService.invokeAll(listOf(callable1, callable2), delayMs, TimeUnit.MILLISECONDS) + executorService.invokeAll( + listOf(callable1, callable2), /* timeout= */ 1, TimeUnit.MILLISECONDS + ) } // Note that this must be different than the dispatcher used to block callable1 to prevent // deadlocking. @@ -817,6 +821,7 @@ class CoroutineExecutorServiceTest { val (future1, future2) = deferred.getCompleted() assertThat(future1.isCancelled).isTrue() assertThat(future2.isDone).isTrue() + assertThat(future2.isCancelled).isFalse() assertThat(future2.get()).isEqualTo("Test 2") } @@ -946,14 +951,16 @@ class CoroutineExecutorServiceTest { @ExperimentalCoroutinesApi fun testInvokeAny_noTaskCompletesOnTime_throwsTimeoutException() { val executorService = createExecutorService() - val delayMs = 10L + // Note that a longer delay is used here since testing for timeouts is inherently flaky: slower + // machines are more likely to trigger a flake since this relies on a real dispatcher. To guard + // against flakes, a long timeout is picked. val callable = lateFinishingCallableWithDispatcher2( - Callable { "Long task" }, timeToWaitMillis = delayMs * 10 + Callable { "Long task" }, timeToWaitMillis = 2500L ) autoSettleServiceAfterSelection(executorService) val deferred = realDispatcherScope.async { - executorService.invokeAny(listOf(callable), delayMs, TimeUnit.MILLISECONDS) + executorService.invokeAny(listOf(callable), /* timeout= */ 1, TimeUnit.MILLISECONDS) } // Note that this must be different than the dispatcher used to block callable1 to prevent // deadlocking. From 76d5310f8bdda470a066aca676c17bb48ae44103 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Tue, 1 Sep 2020 21:51:16 -0700 Subject: [PATCH 18/36] Add documentation & clean up unused code. --- .../oppia/testing/CoroutineExecutorService.kt | 71 ++++++++----------- .../oppia/testing/TestCoroutineDispatcher.kt | 14 ---- .../oppia/testing/TestCoroutineDispatchers.kt | 18 ----- .../TestCoroutineDispatchersEspressoImpl.kt | 11 --- ...TestCoroutineDispatchersRobolectricImpl.kt | 25 ++----- .../testing/CoroutineExecutorServiceTest.kt | 12 +--- 6 files changed, 35 insertions(+), 116 deletions(-) diff --git a/testing/src/main/java/org/oppia/testing/CoroutineExecutorService.kt b/testing/src/main/java/org/oppia/testing/CoroutineExecutorService.kt index 599adbe7674..26b46690bec 100644 --- a/testing/src/main/java/org/oppia/testing/CoroutineExecutorService.kt +++ b/testing/src/main/java/org/oppia/testing/CoroutineExecutorService.kt @@ -7,17 +7,12 @@ import kotlinx.coroutines.Deferred import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.runBlocking import kotlinx.coroutines.selects.select -import kotlinx.coroutines.supervisorScope -import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeoutOrNull -import java.lang.IllegalStateException -import java.lang.NullPointerException import java.util.concurrent.Callable import java.util.concurrent.ExecutionException import java.util.concurrent.ExecutorService @@ -29,7 +24,6 @@ import java.util.concurrent.TimeoutException import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.locks.ReentrantLock import kotlin.concurrent.withLock -import kotlin.coroutines.EmptyCoroutineContext /** * Listener for being notified when [CoroutineExecutorService] has arranged state and is immediately @@ -39,14 +33,24 @@ import kotlin.coroutines.EmptyCoroutineContext */ typealias PriorToBlockingCallback = () -> Unit -// TODO: doc -typealias AfterSelectionSetup = () -> Unit - -private typealias TimeoutBlock = suspend CoroutineScope.() -> T - // https://github.com/Kotlin/kotlinx.coroutines/issues/1450 for reference on using a coroutine // dispatcher an an executor service. -// TODO: doc +/** + * An [ExecutorService] that uses Oppia's [CoroutineDispatcher]s for interop with tests. + * + * Note while this service is being thoroughly tested, both the implementation and tests are based + * on a specific interpretation of the [ExecutorService] API. As a result, This class is + * **NOT PRODUCTION READY**. It should _only_ be used for testing purposes. This class should be + * used for APIs that rely on a Java executor for background activity that must be synchronized with + * other Oppia operations in tests. Uses of this class will automatically be compatible with + * [TestCoroutineDispatchers] and its idling resource. + * + * Note also that the built-in executor service (as suggested by + * https://github.com/Kotlin/kotlinx.coroutines/issues/1450) is not used because that assumes the + * underlying dispatcher is an ExecutorService which may not necessarily be the case, and it doesn't + * allow cooperation with Oppia's test coroutine dispatchers utility (which is the purpose of this + * class). + */ class CoroutineExecutorService( private val backgroundDispatcher: CoroutineDispatcher ) : ExecutorService { @@ -62,7 +66,6 @@ class CoroutineExecutorService( */ private val cachedThreadCoroutineDispatcher by lazy { cachedThreadPool.asCoroutineDispatcher() } private var priorToBlockingCallback: PriorToBlockingCallback? = null - private var afterSelectionSetup: AfterSelectionSetup? = null override fun shutdown() { serviceLock.withLock { isShutdown = true } @@ -164,14 +167,9 @@ class CoroutineExecutorService( val timeoutMillis = unit?.toMillis(timeout) ?: 0 if (timeoutMillis > 0) { @Suppress("EXPERIMENTAL_API_USAGE") - onTimeout(timeoutMillis) { - throw TimeoutException("Timed out after $timeoutMillis") - } - } - resultChannel.onReceive { - it + onTimeout(timeoutMillis) { throw TimeoutException("Timed out after $timeoutMillis") } } - afterSelectionSetup?.invoke() + resultChannel.onReceive { it } } ?: throw ExecutionException(IllegalStateException("All tasks failed to run")) } } @@ -224,7 +222,7 @@ class CoroutineExecutorService( } /** - * Sets a [PriorToBlockingListener] to observe this service's internal state. Note that since this + * Sets a [PriorToBlockingCallback] to observe this service's internal state. Note that since this * is an intentional backdoor built into the service, it should only be used for very specific * circumstances (such as testing blocking operations of this service). */ @@ -233,12 +231,6 @@ class CoroutineExecutorService( this.priorToBlockingCallback = priorToBlockingCallback } - // TODO: doc - @VisibleForTesting - fun setAfterSelectionSetup(afterSelectionSetup: AfterSelectionSetup) { - this.afterSelectionSetup = afterSelectionSetup - } - private fun dispatchAsync(command: Runnable): Deferred<*> { return dispatchAsync(command.let { Callable { it.run() } }) } @@ -279,6 +271,10 @@ class CoroutineExecutorService( private data class Task(val runnable: Runnable, val deferred: Deferred) + /** + * Returns a new [Future] based on a [Deferred]. Note that the APIs between these two async + * constructs are different, so there may be some subtle inconsistencies in practice. + */ private fun Deferred.toFuture(): Future { val deferred: Deferred = this return object : Future { @@ -313,27 +309,16 @@ class CoroutineExecutorService( } private companion object { + /** + * Wraps the specified block in a withTimeout() only if the specified timeout is larger than 0. + */ private suspend fun maybeWithTimeout( - timeoutMillis: Long, block: TimeoutBlock + timeoutMillis: Long, block: suspend CoroutineScope.() -> T ): T { - return maybeWithTimeoutDelegated(timeoutMillis, block, ::withTimeout) - } - - private suspend fun maybeWithTimeoutOrNull( - timeoutMillis: Long, block: TimeoutBlock - ): T? { - return maybeWithTimeoutDelegated(timeoutMillis, block, ::withTimeoutOrNull) - } - - private suspend fun maybeWithTimeoutDelegated( - timeoutMillis: Long, - block: TimeoutBlock, - withTimeoutDelegate: suspend (Long, TimeoutBlock) -> R - ): R { return coroutineScope { if (timeoutMillis > 0) { try { - withTimeoutDelegate(timeoutMillis, block) + withTimeout(timeoutMillis, block) } catch (e: TimeoutCancellationException) { // Treat timeouts in this service as a standard TimeoutException (which should result in // the coroutine being completed with a failure). diff --git a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcher.kt b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcher.kt index b96adfe0caa..daa36ad97bd 100644 --- a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcher.kt +++ b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcher.kt @@ -84,20 +84,6 @@ abstract class TestCoroutineDispatcher : CoroutineDispatcher() { timeout: Long = DEFAULT_TIMEOUT_SECONDS, timeoutUnit: TimeUnit = DEFAULT_TIMEOUT_UNIT ) - /** - * Resumes the dispatcher, resulting in it running all tasks in real-time rather than requiring - * explicit synchronization. The default sate of a test dispatcher is paused. Calling - * [pauseDispatcher] will restore the default pause behavior. - */ - abstract fun resumeDispatcher() - - /** - * Pauses the dispatcher, resulting in it running all tasks only when synchronized using - * [runCurrent] or [runUntilIdle]. The default sate of a test dispatcher is paused. Calling - * [resumeDispatcher] will change this state. - */ - abstract fun pauseDispatcher() - /** A listener for whether the test coroutine dispatcher has become idle. */ interface TaskIdleListener { /** diff --git a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchers.kt b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchers.kt index e16169758df..e0a71dd415e 100644 --- a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchers.kt +++ b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchers.kt @@ -64,12 +64,6 @@ interface TestCoroutineDispatchers { */ fun runCurrent() - /** - * Same as [runCurrent] except additional dispatchers are considered. See [advanceUntilIdleWith] - * for more details. - */ - fun runCurrentWith(vararg additionalDispatchers: TestCoroutineDispatcher) - /** * Advances the system clock by the specified time in milliseconds and then ensures any new tasks * that were scheduled are fully executed before proceeding. This does not guarantee the @@ -97,16 +91,4 @@ interface TestCoroutineDispatchers { * unintentional side effect of executing future tasks before the test anticipates it. */ fun advanceUntilIdle() - - /** - * Same as [advanceUntilIdle] except this coordinates both the internal coroutine dispatchers of - * this class with the additional, optional dispatchers specified. This is only expected to be - * used in cases when blocking calls need to be verified when there are bidirectional blocks - * between the blocking thread and an internal dispatcher of this class. This function helps - * prevent deadlocking in such situations. - * - * @param additionalDispatchers a list of [TestCoroutineDispatcher]s whose internal task queue - * will be synchronized with the internal dispatchers of this class - */ - fun advanceUntilIdleWith(vararg additionalDispatchers: TestCoroutineDispatcher) } diff --git a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchersEspressoImpl.kt b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchersEspressoImpl.kt index 1ca9d9cb586..0a23e798c90 100644 --- a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchersEspressoImpl.kt +++ b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchersEspressoImpl.kt @@ -34,10 +34,6 @@ class TestCoroutineDispatchersEspressoImpl @Inject constructor( advanceUntilIdle() } - override fun runCurrentWith(vararg additionalDispatchers: TestCoroutineDispatcher) { - throw UnsupportedOperationException("This function is not supported in Espresso-enabled tests") - } - override fun advanceTimeBy(delayTimeMillis: Long) { // No actual sleep is needed since Espresso will automatically run until all tasks are // completed since idleness ties to all tasks, even future ones. @@ -49,13 +45,6 @@ class TestCoroutineDispatchersEspressoImpl @Inject constructor( onIdle() } - override fun advanceUntilIdleWith(vararg additionalDispatchers: TestCoroutineDispatcher) { - // This functionality could be added in the future, but it's a bit complex since it requires - // temporarily coordinating this class's idling resource with the new dispatchers, then - // triggering an onIdle call. - throw UnsupportedOperationException("This function is not supported in Espresso-enabled tests") - } - /** Returns whether any of the dispatchers have tasks that can be run now. */ private fun hasPendingCompletableTasks(): Boolean { return backgroundTestDispatcher.hasPendingCompletableTasks() || diff --git a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchersRobolectricImpl.kt b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchersRobolectricImpl.kt index efd013ff00f..01abe336a82 100644 --- a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchersRobolectricImpl.kt +++ b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchersRobolectricImpl.kt @@ -28,12 +28,8 @@ class TestCoroutineDispatchersRobolectricImpl @Inject constructor( } override fun runCurrent() { - runCurrentWith(/* no extra dispatchers */) - } - - override fun runCurrentWith(vararg additionalDispatchers: TestCoroutineDispatcher) { do { - flushNextTasks(additionalDispatchers) + flushNextTasks() } while (hasPendingCompletableTasks()) } @@ -53,12 +49,8 @@ class TestCoroutineDispatchersRobolectricImpl @Inject constructor( } override fun advanceUntilIdle() { - advanceUntilIdleWith(/* no extra dispatchers */) - } - - override fun advanceUntilIdleWith(vararg additionalDispatchers: TestCoroutineDispatcher) { // First, run through all tasks that are currently pending and can be run immediately. - runCurrentWith(*additionalDispatchers) + runCurrent() // Now, the dispatchers can't proceed until time moves forward. Execute the next most recent // task schedule, and everything subsequently scheduled until the dispatchers are in a waiting @@ -70,7 +62,7 @@ class TestCoroutineDispatchersRobolectricImpl @Inject constructor( "Expected to find task with delay for waiting dispatchers with non-empty task queues" } fakeSystemClock.advanceTime(taskDelayMillis) - runCurrentWith(*additionalDispatchers) + runCurrent() } } @@ -88,7 +80,7 @@ class TestCoroutineDispatchersRobolectricImpl @Inject constructor( } } - private fun flushNextTasks(additionalDispatchers: Array) { + private fun flushNextTasks() { if (backgroundTestDispatcher.hasPendingCompletableTasks()) { backgroundTestDispatcher.runCurrent() } @@ -98,15 +90,6 @@ class TestCoroutineDispatchersRobolectricImpl @Inject constructor( if (!uiTaskCoordinator.isIdle()) { uiTaskCoordinator.idle() } - - // Flush the additional dispatchers last. While there are some obscure inconsistencies that can - // occur due to the order of task flushing, it's unlikely to introduce real issues since this - // function is run in a loop. - additionalDispatchers.forEach { - if (it.hasPendingCompletableTasks()) { - it.runCurrent() - } - } } /** Returns whether any of the dispatchers have any tasks to run, including in the future. */ diff --git a/testing/src/test/java/org/oppia/testing/CoroutineExecutorServiceTest.kt b/testing/src/test/java/org/oppia/testing/CoroutineExecutorServiceTest.kt index fd2cbe39185..6f7af9ec105 100644 --- a/testing/src/test/java/org/oppia/testing/CoroutineExecutorServiceTest.kt +++ b/testing/src/test/java/org/oppia/testing/CoroutineExecutorServiceTest.kt @@ -911,7 +911,7 @@ class CoroutineExecutorServiceTest { // task is guaranteed to be finished before selection happens (invalidating the selection // behavior). This is highly dependent on an implementation detail, but there's no other way to // force execution order to verify the service is doing the right thing for invokeAny. - autoSettleServiceAfterSelection(executorService) + autoSettleServiceBeforeBlocking(executorService) val deferred = realDispatcherScope.async { executorService.invokeAny(listOf(callable1, callable2)) @@ -957,7 +957,7 @@ class CoroutineExecutorServiceTest { val callable = lateFinishingCallableWithDispatcher2( Callable { "Long task" }, timeToWaitMillis = 2500L ) - autoSettleServiceAfterSelection(executorService) + autoSettleServiceBeforeBlocking(executorService) val deferred = realDispatcherScope.async { executorService.invokeAny(listOf(callable), /* timeout= */ 1, TimeUnit.MILLISECONDS) @@ -987,13 +987,7 @@ class CoroutineExecutorServiceTest { private fun autoSettleServiceBeforeBlocking(executorService: ExecutorService) { (executorService as CoroutineExecutorService).setPriorToBlockingCallback { - testCoroutineDispatchers.advanceUntilIdle() - } - } - - private fun autoSettleServiceAfterSelection(executorService: ExecutorService) { - (executorService as CoroutineExecutorService).setAfterSelectionSetup { - testCoroutineDispatchers.advanceUntilIdle() + testCoroutineDispatchers.runCurrent() } } From 96d28254da89e2999d52be2f83692f7b6e2ab6bd Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Tue, 1 Sep 2020 21:53:03 -0700 Subject: [PATCH 19/36] Lint fixes. --- .../main/java/org/oppia/testing/CoroutineExecutorService.kt | 3 ++- .../main/java/org/oppia/testing/TestCoroutineDispatcher.kt | 6 ++++-- .../oppia/testing/TestCoroutineDispatcherRobolectricImpl.kt | 2 +- .../java/org/oppia/testing/CoroutineExecutorServiceTest.kt | 3 ++- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/testing/src/main/java/org/oppia/testing/CoroutineExecutorService.kt b/testing/src/main/java/org/oppia/testing/CoroutineExecutorService.kt index 26b46690bec..c22dc6f612d 100644 --- a/testing/src/main/java/org/oppia/testing/CoroutineExecutorService.kt +++ b/testing/src/main/java/org/oppia/testing/CoroutineExecutorService.kt @@ -313,7 +313,8 @@ class CoroutineExecutorService( * Wraps the specified block in a withTimeout() only if the specified timeout is larger than 0. */ private suspend fun maybeWithTimeout( - timeoutMillis: Long, block: suspend CoroutineScope.() -> T + timeoutMillis: Long, + block: suspend CoroutineScope.() -> T ): T { return coroutineScope { if (timeoutMillis > 0) { diff --git a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcher.kt b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcher.kt index daa36ad97bd..756a0ccd2ea 100644 --- a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcher.kt +++ b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcher.kt @@ -72,7 +72,8 @@ abstract class TestCoroutineDispatcher : CoroutineDispatcher() { * when trying to execute tasks before giving up */ abstract fun runCurrent( - timeout: Long = DEFAULT_TIMEOUT_SECONDS, timeoutUnit: TimeUnit = DEFAULT_TIMEOUT_UNIT + timeout: Long = DEFAULT_TIMEOUT_SECONDS, + timeoutUnit: TimeUnit = DEFAULT_TIMEOUT_UNIT ) /** @@ -81,7 +82,8 @@ abstract class TestCoroutineDispatcher : CoroutineDispatcher() { * isolated test dispatchers). */ abstract fun runUntilIdle( - timeout: Long = DEFAULT_TIMEOUT_SECONDS, timeoutUnit: TimeUnit = DEFAULT_TIMEOUT_UNIT + timeout: Long = DEFAULT_TIMEOUT_SECONDS, + timeoutUnit: TimeUnit = DEFAULT_TIMEOUT_UNIT ) /** A listener for whether the test coroutine dispatcher has become idle. */ diff --git a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcherRobolectricImpl.kt b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcherRobolectricImpl.kt index 6b696116e91..0f693d06c85 100644 --- a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcherRobolectricImpl.kt +++ b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcherRobolectricImpl.kt @@ -200,7 +200,7 @@ class TestCoroutineDispatcherRobolectricImpl private constructor( break } } - while (executingTaskCount.get() > 0); + while (executingTaskCount.get() > 0) notifyIfIdle() } diff --git a/testing/src/test/java/org/oppia/testing/CoroutineExecutorServiceTest.kt b/testing/src/test/java/org/oppia/testing/CoroutineExecutorServiceTest.kt index 6f7af9ec105..4b3b70edb65 100644 --- a/testing/src/test/java/org/oppia/testing/CoroutineExecutorServiceTest.kt +++ b/testing/src/test/java/org/oppia/testing/CoroutineExecutorServiceTest.kt @@ -1004,7 +1004,8 @@ class CoroutineExecutorServiceTest { } private fun wrapCallableWithOneSecondDelay( - callable: Callable, coroutineScope: CoroutineScope + callable: Callable, + coroutineScope: CoroutineScope ): Callable { return Callable { val deferred = coroutineScope.async { delay(TimeUnit.SECONDS.toMillis(1)) } From 5b0948a875f98fe0b63f83dc579916d7e86fc3d4 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Wed, 2 Sep 2020 00:24:05 -0700 Subject: [PATCH 20/36] Significantly reorganize invokeAll() to try and make it more cooperative for cancellation, and increase timeout times in tests to reduce flakiness for time-sensitive tests. Some tests are remaining flaky, so ignoring those. Re-add maybeWithTimeoutOrNull since it actually was needed. --- .../oppia/testing/CoroutineExecutorService.kt | 104 +++++++++---- .../testing/CoroutineExecutorServiceTest.kt | 142 ++++++++++++------ 2 files changed, 167 insertions(+), 79 deletions(-) diff --git a/testing/src/main/java/org/oppia/testing/CoroutineExecutorService.kt b/testing/src/main/java/org/oppia/testing/CoroutineExecutorService.kt index c22dc6f612d..ebaf4289f46 100644 --- a/testing/src/main/java/org/oppia/testing/CoroutineExecutorService.kt +++ b/testing/src/main/java/org/oppia/testing/CoroutineExecutorService.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.Deferred import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.runBlocking @@ -27,16 +28,17 @@ import kotlin.concurrent.withLock /** * Listener for being notified when [CoroutineExecutorService] has arranged state and is immediately - * about to block the current thread on that operation completed. This can be used by tests to - * synchronize background threads to prevent deadlocking in cases when blocking operations are being - * tested. + * about to block some sort of thread that requires the calling test to coordinate to avoid a + * deadlock. */ -typealias PriorToBlockingCallback = () -> Unit +typealias BlockingCallback = () -> Unit + +private typealias TimeoutBlock = suspend CoroutineScope.() -> T // https://github.com/Kotlin/kotlinx.coroutines/issues/1450 for reference on using a coroutine // dispatcher an an executor service. /** - * An [ExecutorService] that uses Oppia's [CoroutineDispatcher]s for interop with tests. + * An [ExecutorService] that uses Oppia's [CoroutineDispatcher]s for interoperability with tests. * * Note while this service is being thoroughly tested, both the implementation and tests are based * on a specific interpretation of the [ExecutorService] API. As a result, This class is @@ -65,7 +67,8 @@ class CoroutineExecutorService( * a cached thread pool that creates new threads as others become blocked. */ private val cachedThreadCoroutineDispatcher by lazy { cachedThreadPool.asCoroutineDispatcher() } - private var priorToBlockingCallback: PriorToBlockingCallback? = null + private var priorToBlockingCallback: BlockingCallback? = null + private var afterSelectionSetupCallback: BlockingCallback? = null override fun shutdown() { serviceLock.withLock { isShutdown = true } @@ -105,14 +108,14 @@ class CoroutineExecutorService( val incompleteTasks = serviceLock.withLock { pendingTasks.values } val timeoutMillis = unit?.toMillis(timeout) ?: 0 - // Create a separate scope in case one of the operation fails--it shouldn't cause later + // Create a separate scope in case one of the operations fails--it shouldn't cause later // operations to fail. val cachedThreadCoroutineScope = CoroutineScope(cachedThreadCoroutineDispatcher) // Wait for each task to complete within the specified time. Note that this behaves similarly to // invokeAll() below. val futureTasks = cachedThreadCoroutineScope.async { - withTimeoutOrNull(timeoutMillis) { + maybeWithTimeoutOrNull(timeoutMillis) { incompleteTasks.forEach { task -> // Wait for the task to be completed. task.deferred.await() @@ -142,7 +145,7 @@ class CoroutineExecutorService( val taskDeferreds = tasks.map { dispatchAsync(it) } taskDeferreds.forEach { deferred -> @Suppress("DeferredResultUnused") // Intentionally silence failures (including the service's). - // Create a separate scope in case one of the operation fails--it shouldn't cause later + // Create a separate scope in case one of the operations fails--it shouldn't cause later // operations to fail. CoroutineScope(cachedThreadCoroutineDispatcher).async { try { @@ -170,6 +173,7 @@ class CoroutineExecutorService( onTimeout(timeoutMillis) { throw TimeoutException("Timed out after $timeoutMillis") } } resultChannel.onReceive { it } + afterSelectionSetupCallback?.invoke() } ?: throw ExecutionException(IllegalStateException("All tasks failed to run")) } } @@ -193,27 +197,35 @@ class CoroutineExecutorService( val timeoutMillis = unit?.toMillis(timeout) ?: 0 val deferredTasks = tasks.map { dispatchAsync(it) } - // Create a separate scope in case one of the operation fails--it shouldn't cause later - // operations to fail. - val cachedThreadCoroutineScope = CoroutineScope(cachedThreadCoroutineDispatcher) - - // Wait for each task to complete within the specified time, otherwise cancel the task. - val futureTasks = cachedThreadCoroutineScope.async { - deferredTasks.map { task -> - // Wait for each task to complete within the selected time - val result = withTimeoutOrNull(timeoutMillis) { - task.await() + // Wait for each task to complete within the specified time, otherwise cancel the task. Note + // that the timeout needs to be set up for each task in parallel to avoid a sequentialization of + // the tasks being executed (potentially causing tasks later in the list to not time out). + val futureTasks = deferredTasks.map { task -> + // Create a separate scope in case one of the operations fails--it shouldn't cause later + // operations to fail. + CoroutineScope(cachedThreadCoroutineDispatcher).async { + // Note that the 'or null' part may unfortunately interfere with a legitimate null return + // for the underlying callable. + val result = maybeWithTimeoutOrNull(timeoutMillis) { + return@maybeWithTimeoutOrNull try { + task.await() + } catch (e: Exception) { + // Do not allow failures to cause the coroutine scope to fail. + null + } } val future = task.toFuture() if (result == null && timeoutMillis > 0) { - // Cancel the operation if it's taking too long, but only if there's a timeout set. - future.cancel(/* mayInterruptIfRunning= */ true) + // Cancel the operation if it's taking too long, but only if there's a timeout set. This + // won't cancel the future if the deferred is completed with a failure, or passing with a + // null result. + check(future.cancel(/* mayInterruptIfRunning= */ true)) { "Failed to cancel task." } } - return@map future + return@async future } } priorToBlockingCallback?.invoke() - return runBlocking { futureTasks.await() }.toMutableList() + return runBlocking { futureTasks.awaitAll() }.toMutableList() } @Suppress("DeferredResultUnused") // Cleanup is handled in dispatchAsync. @@ -222,15 +234,32 @@ class CoroutineExecutorService( } /** - * Sets a [PriorToBlockingCallback] to observe this service's internal state. Note that since this - * is an intentional backdoor built into the service, it should only be used for very specific + * Sets a [BlockingCallback] to observe this service's internal state. Note that since this is an + * intentional backdoor built into the service, it should only be used for very specific * circumstances (such as testing blocking operations of this service). + * + * Tracks when the service is about to immediately block the calling thread. */ @VisibleForTesting - fun setPriorToBlockingCallback(priorToBlockingCallback: PriorToBlockingCallback) { + fun setPriorToBlockingCallback(priorToBlockingCallback: BlockingCallback) { this.priorToBlockingCallback = priorToBlockingCallback } + /** + * Sets a [BlockingCallback] to observe this service's internal state. Note that since this is an + * intentional backdoor built into the service, it should only be used for very specific + * circumstances (such as testing blocking operations of this service). + * + * Tracks when the service has finished setting up a Kotlin select block. This should be used in + * relevant cases instead of [setPriorToBlockingCallback] to avoid the selection automatically + * completing for relevant tasks (which will happen if blocking tasks are fully resolved before + * setting up the select block). + */ + @VisibleForTesting + fun setAfterSelectionSetupCallback(afterSelectionSetupCallback: BlockingCallback) { + this.afterSelectionSetupCallback = afterSelectionSetupCallback + } + private fun dispatchAsync(command: Runnable): Deferred<*> { return dispatchAsync(command.let { Callable { it.run() } }) } @@ -313,13 +342,30 @@ class CoroutineExecutorService( * Wraps the specified block in a withTimeout() only if the specified timeout is larger than 0. */ private suspend fun maybeWithTimeout( - timeoutMillis: Long, - block: suspend CoroutineScope.() -> T + timeoutMillis: Long, block: TimeoutBlock ): T { + return maybeWithTimeoutDelegated(timeoutMillis, block, ::withTimeout) + } + + /** + * Wraps the specified block in a withTimeoutOrNull() only if the specified timeout is larger + * than 0. + */ + private suspend fun maybeWithTimeoutOrNull( + timeoutMillis: Long, block: TimeoutBlock + ): T? { + return maybeWithTimeoutDelegated(timeoutMillis, block, ::withTimeoutOrNull) + } + + private suspend fun maybeWithTimeoutDelegated( + timeoutMillis: Long, + block: TimeoutBlock, + withTimeoutDelegate: suspend (Long, TimeoutBlock) -> R + ): R { return coroutineScope { if (timeoutMillis > 0) { try { - withTimeout(timeoutMillis, block) + withTimeoutDelegate(timeoutMillis, block) } catch (e: TimeoutCancellationException) { // Treat timeouts in this service as a standard TimeoutException (which should result in // the coroutine being completed with a failure). diff --git a/testing/src/test/java/org/oppia/testing/CoroutineExecutorServiceTest.kt b/testing/src/test/java/org/oppia/testing/CoroutineExecutorServiceTest.kt index 4b3b70edb65..6b9ebd7e03b 100644 --- a/testing/src/test/java/org/oppia/testing/CoroutineExecutorServiceTest.kt +++ b/testing/src/test/java/org/oppia/testing/CoroutineExecutorServiceTest.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import org.junit.Assert.fail import org.junit.Before +import org.junit.Ignore import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -232,7 +233,7 @@ class CoroutineExecutorServiceTest { @Test fun testSubmitCallable_returnedFuture_withoutRunningTasks_isNotCompleted() { val executorService = createExecutorService() - val callable = Callable { "Test" } + val callable = Callable { "Task" } val callableFuture = executorService.submit(callable) @@ -242,7 +243,7 @@ class CoroutineExecutorServiceTest { @Test fun testSubmitCallable_returnedFuture_afterRunningTasks_isCompleted() { val executorService = createExecutorService() - val callable = Callable { "Test" } + val callable = Callable { "Task" } val callableFuture = executorService.submit(callable) testCoroutineDispatchers.runCurrent() @@ -288,7 +289,7 @@ class CoroutineExecutorServiceTest { @Suppress("BlockingMethodInNonBlockingContext") // Intentional for testing purposes. fun testSubmitCallable_returnedFuture_pendingTask_tasksNotRun_getFunctionBlocks() { val executorService = createExecutorService() - val callable = Callable { "Test" } + val callable = Callable { "Task" } val callableFuture = executorService.submit(callable) val getResult = testDispatcherScope.async { @@ -304,7 +305,7 @@ class CoroutineExecutorServiceTest { @ExperimentalCoroutinesApi fun testSubmitCallable_returnedFuture_pendingTask_runTasks_getFunctionReturnsComputedValue() { val executorService = createExecutorService() - val callable = Callable { "Test" } + val callable = Callable { "Task" } val callableFuture = executorService.submit(callable) val getResult = testDispatcherScope.async { @@ -315,7 +316,7 @@ class CoroutineExecutorServiceTest { // The getter should return since the task has finished. assertThat(getResult.isCompleted).isTrue() - assertThat(getResult.getCompleted()).isEqualTo("Test") + assertThat(getResult.getCompleted()).isEqualTo("Task") } @Test @@ -323,7 +324,7 @@ class CoroutineExecutorServiceTest { @ExperimentalCoroutinesApi fun testSubmitCallable_returnedFuture_pendingTask_tasksNotRun_timedGetFuncTimesOut() { val executorService = createExecutorService() - val callable = Callable { "Test" } + val callable = Callable { "Task" } val callableFuture = executorService.submit(callable) val getResult = testDispatcherScope.async { @@ -349,7 +350,7 @@ class CoroutineExecutorServiceTest { @ExperimentalCoroutinesApi fun testSubmitCallable_returnedFuture_pendingTask_runTasks_timedGetFuncDoesNotTimeOut() { val executorService = createExecutorService() - val callable = Callable { "Test" } + val callable = Callable { "Task" } val callableFuture = executorService.submit(callable) val getResult = realDispatcherScope.async { @@ -359,24 +360,24 @@ class CoroutineExecutorServiceTest { waitForDeferredWithDispatcher1(getResult) // The getter should return since the task has finished. - assertThat(getResult.getCompleted()).isEqualTo("Test") + assertThat(getResult.getCompleted()).isEqualTo("Task") } @Test fun testSubmitCallable_returnedFuture_afterRunningTasks_getsComputedValue() { val executorService = createExecutorService() - val callable = Callable { "Test" } + val callable = Callable { "Task" } val callableFuture = executorService.submit(callable) testCoroutineDispatchers.runCurrent() - assertThat(callableFuture.get()).isEqualTo("Test") + assertThat(callableFuture.get()).isEqualTo("Task") } @Test fun testSubmitCallable_returnedFuture_pendingTask_cancel_isCancelled() { val executorService = createExecutorService() - val callable = Callable { "Test" } + val callable = Callable { "Task" } val callableFuture = executorService.submit(callable) callableFuture.cancel(/* mayInterruptIfRunning= */ false) @@ -389,7 +390,7 @@ class CoroutineExecutorServiceTest { fun testSubmitRunnable_withResult_withoutRunningPendingTasks_doesNotRunScheduledTask() { val executorService = createExecutorService() - executorService.submit(mockRunnable, /* result= */ "Test") + executorService.submit(mockRunnable, /* result= */ "Task") verify(mockRunnable, never()).run() } @@ -398,7 +399,7 @@ class CoroutineExecutorServiceTest { fun testSubmitRunnable_withResult_afterRunningPendingTasks_runsScheduledTask() { val executorService = createExecutorService() - executorService.submit(mockRunnable, /* result= */ "Test") + executorService.submit(mockRunnable, /* result= */ "Task") testCoroutineDispatchers.runCurrent() verify(mockRunnable).run() @@ -410,7 +411,7 @@ class CoroutineExecutorServiceTest { val nullRunnable: Runnable? = null assertThrows(NullPointerException::class) { - executorService.submit(nullRunnable, /* result= */ "Test") + executorService.submit(nullRunnable, /* result= */ "Task") } } @@ -420,7 +421,7 @@ class CoroutineExecutorServiceTest { executorService.shutdown() assertThrows(RejectedExecutionException::class) { - executorService.submit(mockRunnable, /* result= */ "Test") + executorService.submit(mockRunnable, /* result= */ "Task") } } @@ -430,7 +431,7 @@ class CoroutineExecutorServiceTest { executorService.shutdownNow() assertThrows(RejectedExecutionException::class) { - executorService.submit(mockRunnable, /* result= */ "Test") + executorService.submit(mockRunnable, /* result= */ "Task") } } @@ -438,12 +439,12 @@ class CoroutineExecutorServiceTest { fun testSubmitRunnable_withResult_afterRunningTasks_returnsFutureWithResult() { val executorService = createExecutorService() - val resultFuture = executorService.submit(mockRunnable, /* result= */ "Test") + val resultFuture = executorService.submit(mockRunnable, /* result= */ "Task") testCoroutineDispatchers.runCurrent() // Verify that the result value is propagated to the finished future. assertThat(resultFuture.isDone).isTrue() - assertThat(resultFuture.get()).isEqualTo("Test") + assertThat(resultFuture.get()).isEqualTo("Task") } /* Note that the tests to verify shutdown-before-execution fails are elsewhere in the suite. */ @@ -535,11 +536,14 @@ class CoroutineExecutorServiceTest { // Create a long task that waits 1 second before calling the runnable. val longTask = wrapRunnableWithOneSecondDelayUsingDispatcher1(mockRunnable) executorService.submit(longTask) + // Kick-off the task, but don't complete it. Note that this is done via a real dispatcher since // it will block until the test dispatcher is run. - val syncDeferred = realDispatcherScope.async { testCoroutineDispatchers.runCurrent() } - - executorService.shutdownNow() + val syncDeferred = realDispatcherScope.async { + // Run in a real thread to avoid races against task availability within the executor service. + executorService.shutdownNow() + testCoroutineDispatchers.runCurrent() + } testDispatcher.runUntilIdle() // Allow the task to complete. waitForDeferredWithDispatcher1(syncDeferred) @@ -654,7 +658,7 @@ class CoroutineExecutorServiceTest { val delayMs = 10L executorService.submit( lateFinishingCallableWithDispatcher2( - Callable { "Test 1" }, timeToWaitMillis = delayMs * 10 + Callable { "Task 1" }, timeToWaitMillis = delayMs * 10 ) ) executorService.shutdown() @@ -672,12 +676,13 @@ class CoroutineExecutorServiceTest { @Test @Suppress("BlockingMethodInNonBlockingContext") // Intentional to test blocking. @ExperimentalCoroutinesApi + @Ignore("Flaky test") // TODO(#1763): Remove & stabilize test. fun testAwaitTermination_afterShutdown_withTasks_finishWithinTimeout_returnsTrue() { val executorService = createExecutorService() val delayMs = 10L executorService.submit( lateFinishingCallableWithDispatcher2( - Callable { "Test 1" }, timeToWaitMillis = delayMs + Callable { "Task 1" }, timeToWaitMillis = delayMs ) ) executorService.shutdown() @@ -697,8 +702,8 @@ class CoroutineExecutorServiceTest { @Suppress("BlockingMethodInNonBlockingContext") // Intentional to test blocking. fun testInvokeAll_doNotRunTasks_blocks() { val executorService = createExecutorService() - val callable1 = Callable { "Test 1" } - val callable2 = Callable { "Test 2" } + val callable1 = Callable { "Task 1" } + val callable2 = Callable { "Task 2" } val deferred = testDispatcherScope2.async { executorService.invokeAll(listOf(callable1, callable2)) @@ -712,8 +717,8 @@ class CoroutineExecutorServiceTest { @ExperimentalCoroutinesApi fun testInvokeAll_oneTask_afterShutdown_throwsException() { val executorService = createExecutorService() - val callable1 = Callable { "Test 1" } - val callable2 = Callable { "Test 2" } + val callable1 = Callable { "Task 1" } + val callable2 = Callable { "Task 2" } executorService.shutdown() val deferred = realDispatcherScope.async { @@ -737,8 +742,8 @@ class CoroutineExecutorServiceTest { @ExperimentalCoroutinesApi fun testInvokeAll_oneTask_afterShutdownNow_throwsException() { val executorService = createExecutorService() - val callable1 = Callable { "Test 1" } - val callable2 = Callable { "Test 2" } + val callable1 = Callable { "Task 1" } + val callable2 = Callable { "Task 2" } executorService.shutdownNow() val deferred = realDispatcherScope.async { @@ -755,8 +760,8 @@ class CoroutineExecutorServiceTest { @ExperimentalCoroutinesApi fun testInvokeAll_runTasks_returnsListOfCompletedFuturesWithCorrectValuesInOrder() { val executorService = createExecutorService() - val callable1 = Callable { "Test 1" } - val callable2 = Callable { "Test 2" } + val callable1 = Callable { "Task 1" } + val callable2 = Callable { "Task 2" } autoSettleServiceBeforeBlocking(executorService) val deferred = realDispatcherScope.async { @@ -768,8 +773,8 @@ class CoroutineExecutorServiceTest { val (future1, future2) = deferred.getCompleted() assertThat(future1.isDone).isTrue() assertThat(future2.isDone).isTrue() - assertThat(future1.get()).isEqualTo("Test 1") - assertThat(future2.get()).isEqualTo("Test 2") + assertThat(future1.get()).isEqualTo("Task 1") + assertThat(future2.get()).isEqualTo("Task 2") } @Test @@ -778,7 +783,7 @@ class CoroutineExecutorServiceTest { fun testInvokeAll_oneTaskFails_runTasks_returnsListOfCompletedFuturesWithCorrectValuesInOrder() { val executorService = createExecutorService() val callable1 = Callable { throw Exception("Task 1 failed") } - val callable2 = Callable { "Test 2" } + val callable2 = Callable { "Task 2" } autoSettleServiceBeforeBlocking(executorService) val deferred = realDispatcherScope.async { @@ -791,26 +796,26 @@ class CoroutineExecutorServiceTest { assertThat(future1.isDone).isTrue() assertThat(future2.isDone).isTrue() assertThrows(ExecutionException::class) { future1.get() } - assertThat(future2.get()).isEqualTo("Test 2") + assertThat(future2.get()).isEqualTo("Task 2") } @Test @Suppress("BlockingMethodInNonBlockingContext") // Intentional to test blocking. @ExperimentalCoroutinesApi - fun testInvokeAll_withTimeout_doNotFinishTasksOnTime_timesOut() { + fun testInvokeAll_withTimeout_doNotFinishFirstTaskOnTime_timesOut() { val executorService = createExecutorService() // Note that a longer delay is used here since testing for timeouts is inherently flaky: slower // machines are more likely to trigger a flake since this relies on a real dispatcher. To guard // against flakes, a long timeout is picked. val callable1 = lateFinishingCallableWithDispatcher2( - Callable { "Test 1" }, timeToWaitMillis = 2500L + Callable { "Task 1" }, timeToWaitMillis = 2500L ) - val callable2 = Callable { "Test 2" } + val callable2 = Callable { "Task 2" } autoSettleServiceBeforeBlocking(executorService) val deferred = realDispatcherScope.async { executorService.invokeAll( - listOf(callable1, callable2), /* timeout= */ 1, TimeUnit.MILLISECONDS + listOf(callable1, callable2), /* timeout= */ 500, TimeUnit.MILLISECONDS ) } // Note that this must be different than the dispatcher used to block callable1 to prevent @@ -822,14 +827,45 @@ class CoroutineExecutorServiceTest { assertThat(future1.isCancelled).isTrue() assertThat(future2.isDone).isTrue() assertThat(future2.isCancelled).isFalse() - assertThat(future2.get()).isEqualTo("Test 2") + assertThat(future2.get()).isEqualTo("Task 2") + } + + @Test + @Suppress("BlockingMethodInNonBlockingContext") // Intentional to test blocking. + @ExperimentalCoroutinesApi + fun testInvokeAll_withTimeout_doNotFinishSecondTaskOnTime_timesOut() { + val executorService = createExecutorService() + // Note that a longer delay is used here since testing for timeouts is inherently flaky: slower + // machines are more likely to trigger a flake since this relies on a real dispatcher. To guard + // against flakes, a long timeout is picked. + val callable1 = Callable { "Task 1" } + val callable2 = lateFinishingCallableWithDispatcher2( + Callable { "Task 2" }, timeToWaitMillis = 2500L + ) + autoSettleServiceBeforeBlocking(executorService) + + val deferred = realDispatcherScope.async { + executorService.invokeAll( + listOf(callable1, callable2), /* timeout= */ 500, TimeUnit.MILLISECONDS + ) + } + // Note that this must be different than the dispatcher used to block callable1 to prevent + // deadlocking. + waitForDeferredWithDispatcher1(deferred) + + // Verify that the first task doesn't complete since it took too long to run. + val (future1, future2) = deferred.getCompleted() + assertThat(future1.isDone).isTrue() + assertThat(future1.isCancelled).isFalse() + assertThat(future1.get()).isEqualTo("Task 1") + assertThat(future2.isCancelled).isTrue() } @Test @Suppress("BlockingMethodInNonBlockingContext") // Intentional to test blocking. fun testInvokeAny_doNotRunTasks_blocks() { val executorService = createExecutorService() - val callable = Callable { "Test 1" } + val callable = Callable { "Task 1" } val deferred = testDispatcherScope2.async { executorService.invokeAny(listOf(callable)) @@ -843,7 +879,7 @@ class CoroutineExecutorServiceTest { @ExperimentalCoroutinesApi fun testInvokeAny_oneTask_runTasks_returnsValueOfFirstTask() { val executorService = createExecutorService() - val callable = Callable { "Test 1" } + val callable = Callable { "Task 1" } autoSettleServiceBeforeBlocking(executorService) val deferred = realDispatcherScope.async { @@ -851,7 +887,7 @@ class CoroutineExecutorServiceTest { } waitForDeferredWithDispatcher1(deferred) - assertThat(deferred.getCompleted()).isEqualTo("Test 1") + assertThat(deferred.getCompleted()).isEqualTo("Task 1") } @Test @@ -866,7 +902,7 @@ class CoroutineExecutorServiceTest { @ExperimentalCoroutinesApi fun testInvokeAny_oneTask_afterShutdown_throwsException() { val executorService = createExecutorService() - val callable = Callable { "Test 1" } + val callable = Callable { "Task 1" } executorService.shutdown() autoSettleServiceBeforeBlocking(executorService) @@ -884,7 +920,7 @@ class CoroutineExecutorServiceTest { @ExperimentalCoroutinesApi fun testInvokeAny_oneTask_afterShutdownNow_throwsException() { val executorService = createExecutorService() - val callable = Callable { "Test 1" } + val callable = Callable { "Task 1" } executorService.shutdownNow() autoSettleServiceBeforeBlocking(executorService) @@ -911,7 +947,7 @@ class CoroutineExecutorServiceTest { // task is guaranteed to be finished before selection happens (invalidating the selection // behavior). This is highly dependent on an implementation detail, but there's no other way to // force execution order to verify the service is doing the right thing for invokeAny. - autoSettleServiceBeforeBlocking(executorService) + autoSettleServiceAfterSelection(executorService) val deferred = realDispatcherScope.async { executorService.invokeAny(listOf(callable1, callable2)) @@ -949,6 +985,7 @@ class CoroutineExecutorServiceTest { @Test @Suppress("BlockingMethodInNonBlockingContext") // Intentional to test blocking. @ExperimentalCoroutinesApi + @Ignore("Flaky test") // TODO(#1763): Remove & stabilize test. fun testInvokeAny_noTaskCompletesOnTime_throwsTimeoutException() { val executorService = createExecutorService() // Note that a longer delay is used here since testing for timeouts is inherently flaky: slower @@ -957,10 +994,10 @@ class CoroutineExecutorServiceTest { val callable = lateFinishingCallableWithDispatcher2( Callable { "Long task" }, timeToWaitMillis = 2500L ) - autoSettleServiceBeforeBlocking(executorService) + autoSettleServiceAfterSelection(executorService) val deferred = realDispatcherScope.async { - executorService.invokeAny(listOf(callable), /* timeout= */ 1, TimeUnit.MILLISECONDS) + executorService.invokeAny(listOf(callable), /* timeout= */ 500, TimeUnit.MILLISECONDS) } // Note that this must be different than the dispatcher used to block callable1 to prevent // deadlocking. @@ -991,6 +1028,12 @@ class CoroutineExecutorServiceTest { } } + private fun autoSettleServiceAfterSelection(executorService: ExecutorService) { + (executorService as CoroutineExecutorService).setAfterSelectionSetupCallback { + testCoroutineDispatchers.runCurrent() + } + } + private fun wrapRunnableWithOneSecondDelayUsingDispatcher1(runnable: Runnable): Runnable { return Runnable { wrapCallableWithOneSecondDelayUsingDispatcher1(Callable { runnable.run() }).call() @@ -1004,8 +1047,7 @@ class CoroutineExecutorServiceTest { } private fun wrapCallableWithOneSecondDelay( - callable: Callable, - coroutineScope: CoroutineScope + callable: Callable, coroutineScope: CoroutineScope ): Callable { return Callable { val deferred = coroutineScope.async { delay(TimeUnit.SECONDS.toMillis(1)) } From e7019d1950d7e5ebab31870e432d5dcbc63840a3 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Wed, 2 Sep 2020 00:28:02 -0700 Subject: [PATCH 21/36] Lint fixes. --- .../main/java/org/oppia/testing/CoroutineExecutorService.kt | 6 ++++-- .../oppia/testing/TestCoroutineDispatcherRobolectricImpl.kt | 2 +- .../java/org/oppia/testing/CoroutineExecutorServiceTest.kt | 3 ++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/testing/src/main/java/org/oppia/testing/CoroutineExecutorService.kt b/testing/src/main/java/org/oppia/testing/CoroutineExecutorService.kt index ebaf4289f46..bdbbac6ca7d 100644 --- a/testing/src/main/java/org/oppia/testing/CoroutineExecutorService.kt +++ b/testing/src/main/java/org/oppia/testing/CoroutineExecutorService.kt @@ -342,7 +342,8 @@ class CoroutineExecutorService( * Wraps the specified block in a withTimeout() only if the specified timeout is larger than 0. */ private suspend fun maybeWithTimeout( - timeoutMillis: Long, block: TimeoutBlock + timeoutMillis: Long, + block: TimeoutBlock ): T { return maybeWithTimeoutDelegated(timeoutMillis, block, ::withTimeout) } @@ -352,7 +353,8 @@ class CoroutineExecutorService( * than 0. */ private suspend fun maybeWithTimeoutOrNull( - timeoutMillis: Long, block: TimeoutBlock + timeoutMillis: Long, + block: TimeoutBlock ): T? { return maybeWithTimeoutDelegated(timeoutMillis, block, ::withTimeoutOrNull) } diff --git a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcherRobolectricImpl.kt b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcherRobolectricImpl.kt index 0f693d06c85..2949e488fac 100644 --- a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcherRobolectricImpl.kt +++ b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcherRobolectricImpl.kt @@ -201,7 +201,7 @@ class TestCoroutineDispatcherRobolectricImpl private constructor( } } while (executingTaskCount.get() > 0) - notifyIfIdle() + notifyIfIdle() } /** Flushes the current task queue and returns whether any tasks were executed. */ diff --git a/testing/src/test/java/org/oppia/testing/CoroutineExecutorServiceTest.kt b/testing/src/test/java/org/oppia/testing/CoroutineExecutorServiceTest.kt index 6b9ebd7e03b..b7341bb535c 100644 --- a/testing/src/test/java/org/oppia/testing/CoroutineExecutorServiceTest.kt +++ b/testing/src/test/java/org/oppia/testing/CoroutineExecutorServiceTest.kt @@ -1047,7 +1047,8 @@ class CoroutineExecutorServiceTest { } private fun wrapCallableWithOneSecondDelay( - callable: Callable, coroutineScope: CoroutineScope + callable: Callable, + coroutineScope: CoroutineScope ): Callable { return Callable { val deferred = coroutineScope.async { delay(TimeUnit.SECONDS.toMillis(1)) } From 09a0df3b636117fc78829d1357f5ef7ffb8a7b0a Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Wed, 2 Sep 2020 00:54:05 -0700 Subject: [PATCH 22/36] Post-merge module fixes. --- .../app/player/state/StateFragmentTest.kt | 25 ++++++++----------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/app/src/sharedTest/java/org/oppia/app/player/state/StateFragmentTest.kt b/app/src/sharedTest/java/org/oppia/app/player/state/StateFragmentTest.kt index a69c4f0e27f..66ae6a0bd53 100644 --- a/app/src/sharedTest/java/org/oppia/app/player/state/StateFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/app/player/state/StateFragmentTest.kt @@ -37,8 +37,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.firebase.FirebaseApp import dagger.BindsInstance import dagger.Component -import dagger.Module -import dagger.Provides import org.hamcrest.BaseMatcher import org.hamcrest.CoreMatchers.allOf import org.hamcrest.CoreMatchers.containsString @@ -56,6 +54,7 @@ import org.oppia.app.activity.ActivityComponent import org.oppia.app.application.ActivityComponentFactory import org.oppia.app.application.ApplicationComponent import org.oppia.app.application.ApplicationModule +import org.oppia.app.application.ApplicationStartupListenerModule import org.oppia.app.player.state.itemviewmodel.StateItemViewModel import org.oppia.app.player.state.itemviewmodel.StateItemViewModel.ViewType.CONTENT import org.oppia.app.player.state.itemviewmodel.StateItemViewModel.ViewType.CONTINUE_INTERACTION @@ -72,6 +71,7 @@ import org.oppia.app.player.state.itemviewmodel.StateItemViewModel.ViewType.SUBM import org.oppia.app.player.state.itemviewmodel.StateItemViewModel.ViewType.TEXT_INPUT_INTERACTION import org.oppia.app.player.state.testing.StateFragmentTestActivity import org.oppia.app.recyclerview.RecyclerViewMatcher.Companion.atPositionOnView +import org.oppia.app.shim.ViewBindingShimModule import org.oppia.app.utility.ChildViewCoordinatesProvider import org.oppia.app.utility.CustomGeneralLocation import org.oppia.app.utility.DragViewAction @@ -88,9 +88,12 @@ import org.oppia.domain.classify.rules.itemselectioninput.ItemSelectionInputModu import org.oppia.domain.classify.rules.multiplechoiceinput.MultipleChoiceInputModule import org.oppia.domain.classify.rules.numberwithunits.NumberWithUnitsRuleModule import org.oppia.domain.classify.rules.numericinput.NumericInputRuleModule +import org.oppia.domain.classify.rules.ratioinput.RatioInputModule import org.oppia.domain.classify.rules.textinput.TextInputRuleModule +import org.oppia.domain.onboarding.ExpirationMetaDataRetrieverModule import org.oppia.domain.oppialogger.LogStorageModule import org.oppia.domain.question.QuestionModule +import org.oppia.domain.topic.PrimeTopicAssetsControllerModule import org.oppia.domain.topic.TEST_EXPLORATION_ID_0 import org.oppia.domain.topic.TEST_EXPLORATION_ID_2 import org.oppia.domain.topic.TEST_EXPLORATION_ID_4 @@ -106,7 +109,7 @@ import org.oppia.testing.TestDispatcherModule import org.oppia.testing.TestLogReportingModule import org.oppia.testing.TestPlatform import org.oppia.testing.profile.ProfileTestHelper -import org.oppia.util.caching.CacheAssetsLocally +import org.oppia.util.caching.testing.CachingTestModule import org.oppia.util.gcsresource.GcsResourceModule import org.oppia.util.logging.LoggerModule import org.oppia.util.parser.GlideImageLoaderModule @@ -1207,25 +1210,19 @@ class StateFragmentTest { } } - @Module - class TestModule { - // Do not use caching to ensure URLs are always used as the main data source when loading audio. - @Provides - @CacheAssetsLocally - fun provideCacheAssetsLocally(): Boolean = false - } - @Singleton @Component( modules = [ - TestModule::class, TestDispatcherModule::class, ApplicationModule::class, - NetworkModule::class, LoggerModule::class, ContinueModule::class, FractionInputModule::class, + TestDispatcherModule::class, ApplicationModule::class, NetworkModule::class, + LoggerModule::class, ContinueModule::class, FractionInputModule::class, ItemSelectionInputModule::class, MultipleChoiceInputModule::class, NumberWithUnitsRuleModule::class, NumericInputRuleModule::class, TextInputRuleModule::class, DragDropSortInputModule::class, ImageClickInputModule::class, InteractionsModule::class, GcsResourceModule::class, GlideImageLoaderModule::class, ImageParsingModule::class, HtmlParserEntityTypeModule::class, QuestionModule::class, TestLogReportingModule::class, - TestAccessibilityModule::class, LogStorageModule::class + TestAccessibilityModule::class, LogStorageModule::class, CachingTestModule::class, + PrimeTopicAssetsControllerModule::class, ExpirationMetaDataRetrieverModule::class, + ViewBindingShimModule::class, RatioInputModule::class, ApplicationStartupListenerModule::class ] ) interface TestApplicationComponent : ApplicationComponent { From 051e5637df5d04a0e416b2bce3379e45e5874e98 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Wed, 2 Sep 2020 01:23:41 -0700 Subject: [PATCH 23/36] Post-merge fixes with ratio input & add a TODO to improve speed of the new coroutine executor service. --- .../app/player/state/StateFragmentTest.kt | 25 +++++++++++-------- .../player/state/StateFragmentLocalTest.kt | 1 + 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/app/src/sharedTest/java/org/oppia/app/player/state/StateFragmentTest.kt b/app/src/sharedTest/java/org/oppia/app/player/state/StateFragmentTest.kt index 66ae6a0bd53..7a9d85b770a 100644 --- a/app/src/sharedTest/java/org/oppia/app/player/state/StateFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/app/player/state/StateFragmentTest.kt @@ -64,6 +64,7 @@ import org.oppia.app.player.state.itemviewmodel.StateItemViewModel.ViewType.FEED import org.oppia.app.player.state.itemviewmodel.StateItemViewModel.ViewType.FRACTION_INPUT_INTERACTION import org.oppia.app.player.state.itemviewmodel.StateItemViewModel.ViewType.NEXT_NAVIGATION_BUTTON import org.oppia.app.player.state.itemviewmodel.StateItemViewModel.ViewType.NUMERIC_INPUT_INTERACTION +import org.oppia.app.player.state.itemviewmodel.StateItemViewModel.ViewType.RATIO_EXPRESSION_INPUT_INTERACTION import org.oppia.app.player.state.itemviewmodel.StateItemViewModel.ViewType.RETURN_TO_TOPIC_NAVIGATION_BUTTON import org.oppia.app.player.state.itemviewmodel.StateItemViewModel.ViewType.SELECTION_INTERACTION import org.oppia.app.player.state.itemviewmodel.StateItemViewModel.ViewType.SUBMITTED_ANSWER @@ -860,11 +861,10 @@ class StateFragmentTest { fun testStateFragment_inputRatio_submit_correctAnswerDisplayed() { launchForExploration(TEST_EXPLORATION_ID_6).use { startPlayingExploration() - onView(withId(R.id.ratio_input_interaction_view)).perform( - typeText("4:5"), - closeSoftKeyboard() - ) - onView(withId(R.id.submit_answer_button)).perform(click()) + typeRatioExpression("4:5") + + clickSubmitAnswerButton() + onView(withId(R.id.submitted_answer_text_view)) .check(matches(ViewMatchers.withContentDescription("4 to 5"))) } @@ -915,12 +915,9 @@ class StateFragmentTest { clickContinueNavigationButton() // Sixth state: Ratio input. Correct answer: 4:5. - onView(withId(R.id.ratio_input_interaction_view)).perform( - typeText("4:5"), - closeSoftKeyboard() - ) - onView(withId(R.id.submit_answer_button)).perform(click()) - onView(withId(R.id.continue_navigation_button)).perform(click()) + typeRatioExpression("4:5") + clickSubmitAnswerButton() + clickContinueNavigationButton() // Seventh state: Text input. Correct answer: finnish. typeTextInput("finnish") @@ -984,6 +981,12 @@ class StateFragmentTest { typeTextIntoInteraction(text, interactionViewId = R.id.text_input_interaction_view) } + @Suppress("SameParameterValue") + private fun typeRatioExpression(text: String) { + scrollToViewType(RATIO_EXPRESSION_INPUT_INTERACTION) + typeTextIntoInteraction(text, interactionViewId = R.id.ratio_input_interaction_view) + } + private fun selectMultipleChoiceOption(optionPosition: Int) { clickSelection(optionPosition, targetViewId = R.id.multiple_choice_radio_button) } diff --git a/app/src/test/java/org/oppia/app/player/state/StateFragmentLocalTest.kt b/app/src/test/java/org/oppia/app/player/state/StateFragmentLocalTest.kt index 350e7cb38fe..4ea2fb1bd89 100644 --- a/app/src/test/java/org/oppia/app/player/state/StateFragmentLocalTest.kt +++ b/app/src/test/java/org/oppia/app/player/state/StateFragmentLocalTest.kt @@ -131,6 +131,7 @@ class StateFragmentLocalTest { // Initialize Glide such that all of its executors use the same shared dispatcher pool as the // rest of Oppia so that thread execution can be synchronized via Oppia's test coroutine // dispatchers. + // TODO(#1765): Improve the s val executorService = MockGlideExecutor.newTestExecutor( CoroutineExecutorService(backgroundCoroutineDispatcher) ) From 068a4e54c772d3e59479309ef5bb1ca37d12b06b Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Wed, 2 Sep 2020 02:04:47 -0700 Subject: [PATCH 24/36] Revert "Fixes part of #40 & #42: Generalisation Highfi Mobile Portrait + Landscape - Buttons (#1653)" This reverts commit 1bb1ffa97b2c14e002fa0371991f4f25351a79cd. --- .../layout-land/continue_interaction_item.xml | 32 ++-------- .../continue_navigation_button_item.xml | 44 ++++--------- .../drag_drop_interaction_item.xml | 10 +-- .../layout-land/fraction_interaction_item.xml | 2 + .../main/res/layout-land/next_button_item.xml | 36 +++-------- .../numeric_input_interaction_item.xml | 2 + .../res/layout-land/previous_button_item.xml | 34 +++------- .../layout-land/question_player_fragment.xml | 18 +++--- .../res/layout-land/replay_button_item.xml | 11 ++-- .../return_to_topic_button_item.xml | 34 +++------- .../main/res/layout-land/state_fragment.xml | 6 +- .../res/layout-land/submit_button_item.xml | 34 +++------- .../continue_interaction_item.xml | 50 +++++++++------ .../continue_navigation_button_item.xml | 60 +++++++++++------- .../drag_drop_interaction_item.xml | 9 +-- .../fraction_interaction_item.xml | 2 + .../layout-sw600dp-land/next_button_item.xml | 51 +++++++++------ .../numeric_input_interaction_item.xml | 2 + .../previous_button_item.xml | 49 ++++++++++----- .../question_player_fragment.xml | 4 -- .../replay_button_item.xml | 48 +++++++++++--- .../return_to_topic_button_item.xml | 54 ++++++++++------ .../submit_button_item.xml | 52 +++++++++------ .../continue_interaction_item.xml | 50 +++++++++------ .../continue_navigation_button_item.xml | 60 +++++++++++------- .../drag_drop_interaction_item.xml | 9 +-- .../fraction_interaction_item.xml | 4 +- .../layout-sw600dp-port/next_button_item.xml | 51 +++++++++------ .../numeric_input_interaction_item.xml | 2 + .../previous_button_item.xml | 49 ++++++++++----- .../question_player_fragment.xml | 4 -- .../replay_button_item.xml | 48 +++++++++++--- .../return_to_topic_button_item.xml | 54 ++++++++++------ .../submit_button_item.xml | 52 +++++++++------ .../res/layout/continue_interaction_item.xml | 52 +++++++++------ .../continue_navigation_button_item.xml | 63 ++++++++++++------- .../res/layout/drag_drop_interaction_item.xml | 12 ++-- .../res/layout/fraction_interaction_item.xml | 2 + app/src/main/res/layout/next_button_item.xml | 55 ++++++++++------ .../layout/numeric_input_interaction_item.xml | 2 + .../main/res/layout/previous_button_item.xml | 53 ++++++++++------ .../res/layout/question_player_fragment.xml | 8 +-- .../main/res/layout/replay_button_item.xml | 49 ++++++++++++--- .../layout/return_to_topic_button_item.xml | 59 ++++++++++------- app/src/main/res/layout/state_fragment.xml | 6 +- .../main/res/layout/submit_button_item.xml | 55 ++++++++++------ app/src/main/res/values/dimens.xml | 4 -- 47 files changed, 833 insertions(+), 614 deletions(-) diff --git a/app/src/main/res/layout-land/continue_interaction_item.xml b/app/src/main/res/layout-land/continue_interaction_item.xml index 10930a91fcc..610821f3b5c 100644 --- a/app/src/main/res/layout-land/continue_interaction_item.xml +++ b/app/src/main/res/layout-land/continue_interaction_item.xml @@ -10,35 +10,12 @@ name="viewModel" type="org.oppia.app.player.state.itemviewmodel.ContinueInteractionViewModel" /> - - - - - - + + android:layout_marginTop="16dp" + android:padding="16dp"> - - - - - - + + android:paddingStart="24dp" + android:layout_marginTop="56dp" + android:layout_marginBottom="@dimen/divider_margin_bottom" + android:paddingEnd="24dp" + android:paddingBottom="28dp"> + android:visibility="@{buttonViewModel.hasPreviousButton ? View.VISIBLE: View.GONE, default=gone}" />