From 56e91e475a33b047ec77f5b6062eefc4722fd2b4 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Thu, 3 Sep 2020 00:05:48 -0700 Subject: [PATCH] Fix #387, #1771: Show concept cards (#1637) * 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. * 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. * Revert "Fixes #941: Add radar effect in Hints and solution (#1475)" This reverts commit 41eb10bd0c04596cb18c354da7db7268121a09be. * 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. * Enable StateFragmentTest (Robolectric) & StateFragmentLocalTest for CI. * Add thorough documentation for new dispatchers. * Clean up comments & add additional documentation. * Fix lint errors. * Fix broken test after changes to FakeSystemClock. * Fix linter errors. * Update test lesson to include references to concept cards. * Lint fixes & use HtmlCompat instead of Html. * Add support for the newer & finalized tag format. * Lint fixes. * 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. * 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. * Fix flaky timeout tests by improving cancellation cooperation for invokeAny() and provide longer timeouts for tests that are CPU-sensitive. * Add documentation & clean up unused code. * Lint fixes. * 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. * Lint fixes. * Post-merge module fixes. * Post-merge fixes with ratio input & add a TODO to improve speed of the new coroutine executor service. * Revert "Fixes part of #40 & #42: Generalisation Highfi Mobile Portrait + Landscape - Buttons (#1653)" This reverts commit 1bb1ffa97b2c14e002fa0371991f4f25351a79cd. * Ensure terminated tasks do not interfere with one another (timeouts should happen individually for each task during termination). This fixes a failure observed in StateFragmentLocalTest in #1630. * Ignore failing tests until #1769 is resolved. * Fix awaitTermination & improve test. Improve stack trace for test dispatcher timeouts. * Fix slow & broken tests in Robolectric for StateFragmentLocalTest. * Add missing deps for StateFragmentLocalTest. * Address TODOs (including adding support for list tags which replaces the old handler & adds nested custom tag support), and add tests. * Lint fixes. * Address reviewer comments. * Address review comments. Fix new concept card tests on Espresso & add landscape versions (required configuring hints to show quickly to avoid delaying the test, and fixing a bug in the espresso test dispatcher). Add support for disabling concept cards of they aren't enabled for parsing particular HTML (the default behavior is to ignore the custom tag). * Add support for concept cards in questions. Note that it's not clear how to test verifying that pressing the exit button closes the concept card since the exit button is part of the dialog's toolbar. * Fix image-breaking duplicated code in HtmlParser, fix a paragraph parsing issue in BulletTagHandler, and add tests for CustomHtmlContentHandler. * Lint fixes. --- app/build.gradle | 3 +- .../app/application/ApplicationComponent.kt | 4 +- .../player/exploration/ExplorationActivity.kt | 6 +- .../ExplorationActivityPresenter.kt | 4 + .../player/exploration/ExplorationFragment.kt | 2 + .../ExplorationFragmentPresenter.kt | 2 + .../oppia/app/player/state/StateFragment.kt | 2 + .../player/state/StateFragmentPresenter.kt | 14 +- .../state/StatePlayerRecyclerViewAssembler.kt | 160 ++++++-- ...howAdditionalHintsFromWrongAnswerMillis.kt | 10 + .../DelayShowAdditionalHintsMillis.kt | 10 + .../DelayShowInitialHintMillis.kt | 10 + ...intsAndSolutionConfigFastShowTestModule.kt | 20 + .../HintsAndSolutionConfigModule.kt | 21 + .../state/itemviewmodel/ContentViewModel.kt | 3 +- .../state/itemviewmodel/FeedbackViewModel.kt | 3 +- .../itemviewmodel/SubmittedAnswerViewModel.kt | 3 +- .../ConceptCardFragmentTestActivity.kt | 2 +- .../ConceptCardFragmentPresenter.kt | 2 +- .../topic/conceptcard/ConceptCardListener.kt | 2 +- .../questionplayer/QuestionPlayerActivity.kt | 8 +- .../QuestionPlayerActivityPresenter.kt | 2 + .../questionplayer/QuestionPlayerFragment.kt | 2 + .../QuestionPlayerFragmentPresenter.kt | 10 + .../app/player/state/StateFragmentTest.kt | 212 ++++++++++- .../oppia/app/splash/SplashActivityTest.kt | 4 +- .../QuestionPlayerActivityTest.kt | 359 ++++++++++++++++-- .../oppia/app/home/HomeActivityLocalTest.kt | 3 +- .../ExplorationActivityLocalTest.kt | 3 +- .../player/state/StateFragmentLocalTest.kt | 4 +- .../ProfileChooserFragmentLocalTest.kt | 4 +- .../oppia/app/story/StoryActivityLocalTest.kt | 4 +- .../options/AppLanguageFragmentTest.kt | 3 +- .../options/DefaultAudioFragmentTest.kt | 3 +- .../options/ReadingTextSizeFragmentTest.kt | 3 +- .../state/StateFragmentAccessibilityTest.kt | 3 +- .../topic/info/TopicInfoFragmentLocalTest.kt | 4 +- .../lessons/TopicLessonsFragmentLocalTest.kt | 4 +- .../QuestionPlayerActivityLocalTest.kt | 3 +- .../RevisionCardActivityLocalTest.kt | 4 +- domain/src/main/assets/MjZzEVOG47_1.json | 4 +- domain/src/main/assets/questions.json | 2 +- .../TestCoroutineDispatcherEspressoImpl.kt | 3 +- .../org/oppia/util/parser/BulletTagHandler.kt | 37 ++ .../util/parser/CustomHtmlContentHandler.kt | 154 ++++++++ .../java/org/oppia/util/parser/HtmlParser.kt | 140 +++++-- .../org/oppia/util/parser/LiTagHandler.kt | 37 -- .../parser/CustomHtmlContentHandlerTest.kt | 174 +++++++++ 48 files changed, 1299 insertions(+), 177 deletions(-) create mode 100644 app/src/main/java/org/oppia/app/player/state/hintsandsolution/DelayShowAdditionalHintsFromWrongAnswerMillis.kt create mode 100644 app/src/main/java/org/oppia/app/player/state/hintsandsolution/DelayShowAdditionalHintsMillis.kt create mode 100644 app/src/main/java/org/oppia/app/player/state/hintsandsolution/DelayShowInitialHintMillis.kt create mode 100644 app/src/main/java/org/oppia/app/player/state/hintsandsolution/HintsAndSolutionConfigFastShowTestModule.kt create mode 100644 app/src/main/java/org/oppia/app/player/state/hintsandsolution/HintsAndSolutionConfigModule.kt create mode 100644 utility/src/main/java/org/oppia/util/parser/BulletTagHandler.kt create mode 100644 utility/src/main/java/org/oppia/util/parser/CustomHtmlContentHandler.kt delete mode 100755 utility/src/main/java/org/oppia/util/parser/LiTagHandler.kt create mode 100644 utility/src/test/java/org/oppia/util/parser/CustomHtmlContentHandlerTest.kt diff --git a/app/build.gradle b/app/build.gradle index 01e18f2512b..5ea497d7b75 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -105,8 +105,8 @@ dependencies { 'androidx.test.espresso:espresso-core:3.2.0', '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', + 'com.google.truth:truth:0.43', 'org.robolectric:annotations:4.3', 'org.robolectric:robolectric:4.3', 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.2.2', @@ -121,6 +121,7 @@ dependencies { 'androidx.test.espresso:espresso-core:3.2.0', 'androidx.test.espresso:espresso-intents:3.1.0', 'androidx.test.ext:junit:1.1.1', + 'com.github.bumptech.glide:mocks:4.11.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/main/java/org/oppia/app/application/ApplicationComponent.kt b/app/src/main/java/org/oppia/app/application/ApplicationComponent.kt index f2f2a28ea89..6173c2863bf 100644 --- a/app/src/main/java/org/oppia/app/application/ApplicationComponent.kt +++ b/app/src/main/java/org/oppia/app/application/ApplicationComponent.kt @@ -5,6 +5,7 @@ import android.app.Application import dagger.BindsInstance import dagger.Component import org.oppia.app.activity.ActivityComponent +import org.oppia.app.player.state.hintsandsolution.HintsAndSolutionConfigModule import org.oppia.app.shim.IntentFactoryShimModule import org.oppia.app.shim.ViewBindingShimModule import org.oppia.domain.classify.InteractionsModule @@ -57,7 +58,8 @@ import javax.inject.Singleton LogStorageModule::class, IntentFactoryShimModule::class, ViewBindingShimModule::class, PrimeTopicAssetsControllerModule::class, ExpirationMetaDataRetrieverModule::class, RatioInputModule::class, - UncaughtExceptionLoggerModule::class, ApplicationStartupListenerModule::class + UncaughtExceptionLoggerModule::class, ApplicationStartupListenerModule::class, + HintsAndSolutionConfigModule::class ] ) 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 16e9af95e57..3722a413325 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 @@ -18,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" @@ -34,7 +35,8 @@ class ExplorationActivity : RevealHintListener, RevealSolutionInterface, DefaultFontSizeStateListener, - HintsAndSolutionExplorationManagerListener { + HintsAndSolutionExplorationManagerListener, + ConceptCardListener { @Inject lateinit var explorationActivityPresenter: ExplorationActivityPresenter @@ -181,4 +183,6 @@ class ExplorationActivity : override fun onExplorationStateLoaded(state: State) { this.state = state } + + 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 ed6d0b1f19d..04e86cf55ca 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 @@ -224,6 +224,10 @@ class ExplorationActivityPresenter @Inject constructor( } } + 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 0d63c28c2b4..592bb449105 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 @@ -107,4 +107,6 @@ class ExplorationFragment : InjectableFragment() { fun revealSolution(saveUserChoice: Boolean) { explorationFragmentPresenter.revealSolution(saveUserChoice) } + + 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 fd7e9173042..371dd206bae 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 @@ -62,6 +62,8 @@ class ExplorationFragmentPresenter @Inject constructor( getStateFragment()?.revealSolution(saveUserChoice) } + fun dismissConceptCard() = getStateFragment()?.dismissConceptCard() + private fun getStateFragment(): StateFragment? { return fragment .childFragmentManager 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 5bfbf9a9c80..78d096f70d1 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 @@ -129,4 +129,6 @@ class StateFragment : fun revealSolution(saveUserChoice: Boolean) { stateFragmentPresenter.revealSolution(saveUserChoice) } + + 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 b938f5aba77..28e5f119432 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 @@ -235,7 +235,9 @@ class StateFragmentPresenter @Inject constructor( .addAudioVoiceoverSupport( explorationId, viewModel.currentStateName, viewModel.isAudioBarVisible, this::getAudioUiManager - ).build() + ) + .addConceptCardSupport() + .build() } fun revealHint(saveUserChoice: Boolean, hintIndex: Int) { @@ -461,6 +463,14 @@ class StateFragmentPresenter @Inject constructor( subscribeToAnswerOutcome(explorationProgressController.submitAnswer(answer)) } + fun dismissConceptCard() { + fragment.childFragmentManager.findFragmentByTag( + CONCEPT_CARD_DIALOG_FRAGMENT_TAG + )?.let { dialogFragment -> + fragment.childFragmentManager.beginTransaction().remove(dialogFragment).commitNow() + } + } + private fun moveToNextState() { viewModel.setCanSubmitAnswer(canSubmitAnswer = false) explorationProgressController.moveToNextState().observe( @@ -487,8 +497,6 @@ class StateFragmentPresenter @Inject constructor( binding.stateRecyclerView.smoothScrollToPosition(0) } - private fun isAudioShowing(): Boolean = viewModel.isAudioBarVisible.get()!! - /** Updates submit button UI as active if pendingAnswerError null else inactive. */ fun updateSubmitButton(pendingAnswerError: String?, inputAnswerAvailable: Boolean) { if (inputAnswerAvailable) { 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 fed7f20633a..83b42313348 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 @@ -50,6 +50,9 @@ import org.oppia.app.player.state.StatePlayerRecyclerViewAssembler.Builder.Facto import org.oppia.app.player.state.answerhandling.InteractionAnswerErrorOrAvailabilityCheckReceiver import org.oppia.app.player.state.answerhandling.InteractionAnswerHandler import org.oppia.app.player.state.answerhandling.InteractionAnswerReceiver +import org.oppia.app.player.state.hintsandsolution.DelayShowAdditionalHintsFromWrongAnswerMillis +import org.oppia.app.player.state.hintsandsolution.DelayShowAdditionalHintsMillis +import org.oppia.app.player.state.hintsandsolution.DelayShowInitialHintMillis import org.oppia.app.player.state.itemviewmodel.ContentViewModel import org.oppia.app.player.state.itemviewmodel.ContinueInteractionViewModel import org.oppia.app.player.state.itemviewmodel.ContinueNavigationButtonViewModel @@ -79,16 +82,17 @@ import org.oppia.app.player.state.listener.ReturnToTopicNavigationButtonListener import org.oppia.app.player.state.listener.ShowHintAvailabilityListener import org.oppia.app.player.state.listener.SubmitNavigationButtonListener import org.oppia.app.recyclerview.BindableAdapter +import org.oppia.app.topic.conceptcard.ConceptCardFragment import org.oppia.app.utility.LifecycleSafeTimerFactory import org.oppia.util.parser.HtmlParser import org.oppia.util.threading.BackgroundDispatcher import javax.inject.Inject -private const val DELAY_SHOW_INITIAL_HINT_MS = 60_000L -private const val DELAY_SHOW_ADDITIONAL_HINTS_MS = 30_000L -private const val DELAY_SHOW_ADDITIONAL_HINTS_FROM_WRONG_ANSWER_MS = 10_000L 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 @@ -125,8 +129,11 @@ class StatePlayerRecyclerViewAssembler private constructor( private val interactionViewModelFactoryMap: Map< String, @JvmSuppressWildcards InteractionViewModelFactory>, backgroundCoroutineDispatcher: CoroutineDispatcher, - private val hasConversationView: Boolean -) { + private val hasConversationView: Boolean, + delayShowInitialHintMs: Long, + delayShowAdditionalHintsMs: Long, + delayShowAdditionalHintsFromWrongAnswerMs: Long +) : HtmlParser.CustomOppiaTagActionListener { /** * A list of view models corresponding to past view models that are hidden by default. These are * intentionally not retained upon configuration changes since the user can just re-expand the @@ -145,7 +152,13 @@ class StatePlayerRecyclerViewAssembler private constructor( private val lifecycleSafeTimerFactory = LifecycleSafeTimerFactory(backgroundCoroutineDispatcher) - private val hintHandler = HintHandler(lifecycleSafeTimerFactory, fragment) + private val hintHandler = HintHandler( + lifecycleSafeTimerFactory, + fragment, + delayShowInitialHintMs, + delayShowAdditionalHintsMs, + delayShowAdditionalHintsFromWrongAnswerMs + ) /** The most recent content ID read by the audio system. */ private var audioPlaybackContentId: String? = null @@ -166,6 +179,12 @@ 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) + } + /** * Computes a list of view models corresponding to the specified [EphemeralState] and the * configuration of this assembler, as well as the GCS entity ID that should be associated with @@ -287,7 +306,8 @@ class StatePlayerRecyclerViewAssembler private constructor( contentSubtitledHtml.html, gcsEntityId, hasConversationView, - isSplitView.get()!! + isSplitView.get()!!, + playerFeatureSet.conceptCardSupport ) } @@ -489,7 +509,13 @@ class StatePlayerRecyclerViewAssembler private constructor( isAnswerCorrect: Boolean ): SubmittedAnswerViewModel { val submittedAnswerViewModel = - SubmittedAnswerViewModel(userAnswer, gcsEntityId, hasConversationView, isSplitView.get()!!) + SubmittedAnswerViewModel( + userAnswer, + gcsEntityId, + hasConversationView, + isSplitView.get()!!, + playerFeatureSet.conceptCardSupport + ) submittedAnswerViewModel.isCorrectAnswer.set(isAnswerCorrect) submittedAnswerViewModel.isExtraInteractionAnswerCorrect.set(isAnswerCorrect) return submittedAnswerViewModel @@ -501,7 +527,13 @@ class StatePlayerRecyclerViewAssembler private constructor( ): FeedbackViewModel? { // Only show feedback if there's some to show. if (feedback.html.isNotEmpty()) { - return FeedbackViewModel(feedback.html, gcsEntityId, hasConversationView, isSplitView.get()!!) + return FeedbackViewModel( + feedback.html, + gcsEntityId, + hasConversationView, + isSplitView.get()!!, + playerFeatureSet.conceptCardSupport + ) } return null } @@ -715,7 +747,10 @@ class StatePlayerRecyclerViewAssembler private constructor( private val entityType: String, private val fragment: Fragment, private val interactionViewModelFactoryMap: Map, - private val backgroundCoroutineDispatcher: CoroutineDispatcher + private val backgroundCoroutineDispatcher: CoroutineDispatcher, + private val delayShowInitialHintMs: Long, + private val delayShowAdditionalHintsMs: Long, + private val delayShowAdditionalHintsFromWrongAnswerMs: Long ) { private val adapterBuilder = BindableAdapter.MultiTypeBuilder.newBuilder( StateItemViewModel::viewType @@ -732,6 +767,13 @@ class StatePlayerRecyclerViewAssembler private constructor( private var currentStateName: ObservableField? = null private var isAudioPlaybackEnabled: ObservableField? = null private var audioUiManagerRetriever: AudioUiManagerRetriever? = null + private val customTagListener = object : HtmlParser.CustomOppiaTagActionListener { + var proxyListener: HtmlParser.CustomOppiaTagActionListener? = null + + override fun onConceptCardLinkClicked(view: View, skillId: String) { + proxyListener?.onConceptCardLinkClicked(view, skillId) + } + } /** Adds support for displaying state content to the learner. */ fun addContentSupport(): Builder { @@ -753,9 +795,13 @@ class StatePlayerRecyclerViewAssembler private constructor( resourceBucketName, entityType, contentViewModel.gcsEntityId, - imageCenterAlign = true + imageCenterAlign = true, + customOppiaTagActionListener = customTagListener ).parseOppiaHtml( - contentViewModel.htmlContent.toString(), binding.contentTextView + contentViewModel.htmlContent.toString(), + binding.contentTextView, + supportsLinks = true, + supportsConceptCards = contentViewModel.supportsConceptCards ) } ) @@ -783,10 +829,13 @@ class StatePlayerRecyclerViewAssembler private constructor( resourceBucketName, entityType, feedbackViewModel.gcsEntityId, - imageCenterAlign = true + imageCenterAlign = true, + customOppiaTagActionListener = customTagListener ).parseOppiaHtml( feedbackViewModel.htmlContent.toString(), - binding.feedbackTextView + binding.feedbackTextView, + supportsLinks = true, + supportsConceptCards = feedbackViewModel.supportsConceptCards ) } ) @@ -870,18 +919,23 @@ class StatePlayerRecyclerViewAssembler private constructor( resourceBucketName, entityType, submittedAnswerViewModel.gcsEntityId, - imageCenterAlign = false + imageCenterAlign = false, + customOppiaTagActionListener = customTagListener ) binding.submittedAnswer = htmlParser.parseOppiaHtml( userAnswer.htmlAnswer, - binding.submittedAnswerTextView + binding.submittedAnswerTextView, + supportsConceptCards = submittedAnswerViewModel.supportsConceptCards ) } UserAnswer.TextualAnswerCase.LIST_OF_HTML_ANSWERS -> { showListOfAnswers(binding) binding.submittedListAnswer = userAnswer.listOfHtmlAnswers binding.submittedAnswerRecyclerView.adapter = - createListAnswerAdapter(submittedAnswerViewModel.gcsEntityId) + createListAnswerAdapter( + submittedAnswerViewModel.gcsEntityId, + submittedAnswerViewModel.supportsConceptCards + ) } else -> { showSingleAnswer(binding) @@ -895,7 +949,10 @@ class StatePlayerRecyclerViewAssembler private constructor( return this } - private fun createListAnswerAdapter(gcsEntityId: String): BindableAdapter { + private fun createListAnswerAdapter( + gcsEntityId: String, + supportsConceptCards: Boolean + ): BindableAdapter { return BindableAdapter.SingleTypeBuilder .newBuilder() .registerViewBinder( @@ -907,13 +964,17 @@ class StatePlayerRecyclerViewAssembler private constructor( bindView = { view, viewModel -> val binding = DataBindingUtil.findBinding(view)!! binding.answerItem = viewModel - binding.submittedHtmlAnswerRecyclerView.adapter = createNestedAdapter(gcsEntityId) + binding.submittedHtmlAnswerRecyclerView.adapter = + createNestedAdapter(gcsEntityId, supportsConceptCards) } ) .build() } - private fun createNestedAdapter(gcsEntityId: String): BindableAdapter { + private fun createNestedAdapter( + gcsEntityId: String, + supportsConceptCards: Boolean + ): BindableAdapter { return BindableAdapter.SingleTypeBuilder .newBuilder() .registerViewBinder( @@ -929,9 +990,12 @@ class StatePlayerRecyclerViewAssembler private constructor( resourceBucketName, entityType, gcsEntityId, - /* imageCenterAlign= */ false + imageCenterAlign = false, + customOppiaTagActionListener = customTagListener ).parseOppiaHtml( - viewModel, binding.submittedAnswerContentTextView + viewModel, + binding.submittedAnswerContentTextView, + supportsConceptCards = supportsConceptCards ) } ) @@ -1091,13 +1155,19 @@ class StatePlayerRecyclerViewAssembler private constructor( return this } + /** Adds support for enabling concept cards links in explorations when the user gets stuck. */ + fun addConceptCardSupport(): Builder { + featureSets += PlayerFeatureSet(conceptCardSupport = true) + return this + } + /** * Returns a new [StatePlayerRecyclerViewAssembler] based on the builder-specified * configuration. */ fun build(): StatePlayerRecyclerViewAssembler { val playerFeatureSet = featureSets.reduce(PlayerFeatureSet::union) - return StatePlayerRecyclerViewAssembler( + val assembler = StatePlayerRecyclerViewAssembler( /* adapter= */ adapterBuilder.build(), /* rhsAdapter= */ adapterBuilder.build(), playerFeatureSet, @@ -1110,8 +1180,15 @@ class StatePlayerRecyclerViewAssembler private constructor( audioUiManagerRetriever, interactionViewModelFactoryMap, backgroundCoroutineDispatcher, - hasConversationView + hasConversationView, + delayShowInitialHintMs, + delayShowAdditionalHintsMs, + delayShowAdditionalHintsFromWrongAnswerMs ) + if (playerFeatureSet.conceptCardSupport) { + customTagListener.proxyListener = assembler + } + return assembler } /** Fragment injectable factory to create new [Builder]s. */ @@ -1120,7 +1197,10 @@ class StatePlayerRecyclerViewAssembler private constructor( private val fragment: Fragment, private val interactionViewModelFactoryMap: Map< String, @JvmSuppressWildcards InteractionViewModelFactory>, - @BackgroundDispatcher private val backgroundCoroutineDispatcher: CoroutineDispatcher + @BackgroundDispatcher private val backgroundCoroutineDispatcher: CoroutineDispatcher, + @DelayShowInitialHintMillis private val delayShowInitialHintMs: Long, + @DelayShowAdditionalHintsMillis private val delayShowAdditionalHintsMs: Long, + @DelayShowAdditionalHintsFromWrongAnswerMillis private val additionalAnswerHintDelayMs: Long ) { /** * Returns a new [Builder] for the specified GCS resource bucket information for loading @@ -1133,7 +1213,10 @@ class StatePlayerRecyclerViewAssembler private constructor( entityType, fragment, interactionViewModelFactoryMap, - backgroundCoroutineDispatcher + backgroundCoroutineDispatcher, + delayShowInitialHintMs, + delayShowAdditionalHintsMs, + additionalAnswerHintDelayMs ) } } @@ -1152,7 +1235,8 @@ class StatePlayerRecyclerViewAssembler private constructor( val returnToTopicNavigation: Boolean = false, val showCongratulationsOnCorrectAnswer: Boolean = false, val hintsAndSolutionsSupport: Boolean = false, - val supportAudioVoiceovers: Boolean = false + val supportAudioVoiceovers: Boolean = false, + val conceptCardSupport: Boolean = false ) { /** * Returns a union of this feature set with other one. Loosely based on @@ -1172,7 +1256,8 @@ class StatePlayerRecyclerViewAssembler private constructor( showCongratulationsOnCorrectAnswer = showCongratulationsOnCorrectAnswer || other.showCongratulationsOnCorrectAnswer, hintsAndSolutionsSupport = hintsAndSolutionsSupport || other.hintsAndSolutionsSupport, - supportAudioVoiceovers = supportAudioVoiceovers || other.supportAudioVoiceovers + supportAudioVoiceovers = supportAudioVoiceovers || other.supportAudioVoiceovers, + conceptCardSupport = conceptCardSupport || other.conceptCardSupport ) } } @@ -1216,7 +1301,10 @@ class StatePlayerRecyclerViewAssembler private constructor( */ private class HintHandler( private val lifecycleSafeTimerFactory: LifecycleSafeTimerFactory, - private val fragment: Fragment + private val fragment: Fragment, + private val delayShowInitialHintMs: Long, + private val delayShowAdditionalHintsMs: Long, + private val delayShowAdditionalHintsFromWrongAnswerMs: Long ) { private var trackedWrongAnswerCount = 0 private var previousHelpIndex: HelpIndex = HelpIndex.getDefaultInstance() @@ -1251,11 +1339,9 @@ class StatePlayerRecyclerViewAssembler private constructor( return } - // If hint was visibile in the current state show all previous hints - // coming back to current state. - // If any hint was revealed and user move between current and completed states then - // show those relevead hints back by making icon visible - // else use the previous help index + // If hint was visible in the current state show all previous hints coming back to the current + // state. If any hint was revealed and user move between current and completed states, then + // show those revealed hints back by making icon visible else use the previous help index. if (isHintVisibleInLatestState) { if (state.interaction.hintList[previousHelpIndex.hintIndex].hintIsRevealed) { (fragment as ShowHintAvailabilityListener).onHintAvailable( @@ -1281,9 +1367,9 @@ class StatePlayerRecyclerViewAssembler private constructor( if (isFirstHint) { // The learner needs to wait longer for the initial hint to show since they need some time // to read through and consider the question. - scheduleShowHint(DELAY_SHOW_INITIAL_HINT_MS, nextUnrevealedHintIndex) + scheduleShowHint(delayShowInitialHintMs, nextUnrevealedHintIndex) } else { - scheduleShowHint(DELAY_SHOW_ADDITIONAL_HINTS_MS, nextUnrevealedHintIndex) + scheduleShowHint(delayShowAdditionalHintsMs, nextUnrevealedHintIndex) } } else { // See if the learner's new wrong answer justifies showing a hint. @@ -1296,7 +1382,7 @@ class StatePlayerRecyclerViewAssembler private constructor( } else { // Otherwise, always schedule to show a hint on a new wrong answer for subsequent hints. scheduleShowHint( - DELAY_SHOW_ADDITIONAL_HINTS_FROM_WRONG_ANSWER_MS, + delayShowAdditionalHintsFromWrongAnswerMs, nextUnrevealedHintIndex ) } diff --git a/app/src/main/java/org/oppia/app/player/state/hintsandsolution/DelayShowAdditionalHintsFromWrongAnswerMillis.kt b/app/src/main/java/org/oppia/app/player/state/hintsandsolution/DelayShowAdditionalHintsFromWrongAnswerMillis.kt new file mode 100644 index 00000000000..b2a13d421ce --- /dev/null +++ b/app/src/main/java/org/oppia/app/player/state/hintsandsolution/DelayShowAdditionalHintsFromWrongAnswerMillis.kt @@ -0,0 +1,10 @@ +package org.oppia.app.player.state.hintsandsolution + +import javax.inject.Qualifier + +/** + * Qualifier for a [Long] representing how many milliseconds to wait before showing hints after the + * user submits one wrong answer (subsequent wrong answers will immediately show the next hint). + */ +@Qualifier +annotation class DelayShowAdditionalHintsFromWrongAnswerMillis diff --git a/app/src/main/java/org/oppia/app/player/state/hintsandsolution/DelayShowAdditionalHintsMillis.kt b/app/src/main/java/org/oppia/app/player/state/hintsandsolution/DelayShowAdditionalHintsMillis.kt new file mode 100644 index 00000000000..fe90b11af3c --- /dev/null +++ b/app/src/main/java/org/oppia/app/player/state/hintsandsolution/DelayShowAdditionalHintsMillis.kt @@ -0,0 +1,10 @@ +package org.oppia.app.player.state.hintsandsolution + +import javax.inject.Qualifier + +/** + * Qualifier for a [Long] representing how many milliseconds to wait before showing subsequent hints + * if the user has no activity other than seeing the previous hint. + */ +@Qualifier +annotation class DelayShowAdditionalHintsMillis diff --git a/app/src/main/java/org/oppia/app/player/state/hintsandsolution/DelayShowInitialHintMillis.kt b/app/src/main/java/org/oppia/app/player/state/hintsandsolution/DelayShowInitialHintMillis.kt new file mode 100644 index 00000000000..6ed6293aaaf --- /dev/null +++ b/app/src/main/java/org/oppia/app/player/state/hintsandsolution/DelayShowInitialHintMillis.kt @@ -0,0 +1,10 @@ +package org.oppia.app.player.state.hintsandsolution + +import javax.inject.Qualifier + +/** + * Qualifier for a [Long] representing how many milliseconds to initially wait before showing hints + * to potentially stuck users. + */ +@Qualifier +annotation class DelayShowInitialHintMillis diff --git a/app/src/main/java/org/oppia/app/player/state/hintsandsolution/HintsAndSolutionConfigFastShowTestModule.kt b/app/src/main/java/org/oppia/app/player/state/hintsandsolution/HintsAndSolutionConfigFastShowTestModule.kt new file mode 100644 index 00000000000..ec36968018d --- /dev/null +++ b/app/src/main/java/org/oppia/app/player/state/hintsandsolution/HintsAndSolutionConfigFastShowTestModule.kt @@ -0,0 +1,20 @@ +package org.oppia.app.player.state.hintsandsolution + +import dagger.Module +import dagger.Provides + +/** Test-only module for providing configurations to quickly reveal hints & solutions */ +@Module +class HintsAndSolutionConfigFastShowTestModule { + @Provides + @DelayShowInitialHintMillis + fun provideInitialDelayForShowingHintsMillis(): Long = 1L + + @Provides + @DelayShowAdditionalHintsMillis + fun provideDelayForShowingAdditionalHintsMillis(): Long = 1L + + @Provides + @DelayShowAdditionalHintsFromWrongAnswerMillis + fun provideDelayForShowingHintsAfterOneWrongAnswerMillis(): Long = 1L +} diff --git a/app/src/main/java/org/oppia/app/player/state/hintsandsolution/HintsAndSolutionConfigModule.kt b/app/src/main/java/org/oppia/app/player/state/hintsandsolution/HintsAndSolutionConfigModule.kt new file mode 100644 index 00000000000..bd6499f24b0 --- /dev/null +++ b/app/src/main/java/org/oppia/app/player/state/hintsandsolution/HintsAndSolutionConfigModule.kt @@ -0,0 +1,21 @@ +package org.oppia.app.player.state.hintsandsolution + +import dagger.Module +import dagger.Provides +import java.util.concurrent.TimeUnit + +/** Production module for providing configurations for hints & solutions */ +@Module +class HintsAndSolutionConfigModule { + @Provides + @DelayShowInitialHintMillis + fun provideInitialDelayForShowingHintsMillis(): Long = TimeUnit.SECONDS.toMillis(60) + + @Provides + @DelayShowAdditionalHintsMillis + fun provideDelayForShowingAdditionalHintsMillis(): Long = TimeUnit.SECONDS.toMillis(30) + + @Provides + @DelayShowAdditionalHintsFromWrongAnswerMillis + fun provideDelayForShowingHintsAfterOneWrongAnswerMillis(): Long = TimeUnit.SECONDS.toMillis(10) +} diff --git a/app/src/main/java/org/oppia/app/player/state/itemviewmodel/ContentViewModel.kt b/app/src/main/java/org/oppia/app/player/state/itemviewmodel/ContentViewModel.kt index 3e3890e30c7..3d2c53cd2d7 100644 --- a/app/src/main/java/org/oppia/app/player/state/itemviewmodel/ContentViewModel.kt +++ b/app/src/main/java/org/oppia/app/player/state/itemviewmodel/ContentViewModel.kt @@ -5,5 +5,6 @@ class ContentViewModel( val htmlContent: CharSequence, val gcsEntityId: String, val hasConversationView: Boolean, - val isSplitView: Boolean + val isSplitView: Boolean, + val supportsConceptCards: Boolean ) : StateItemViewModel(ViewType.CONTENT) diff --git a/app/src/main/java/org/oppia/app/player/state/itemviewmodel/FeedbackViewModel.kt b/app/src/main/java/org/oppia/app/player/state/itemviewmodel/FeedbackViewModel.kt index 4ff666ec411..105c0114b54 100644 --- a/app/src/main/java/org/oppia/app/player/state/itemviewmodel/FeedbackViewModel.kt +++ b/app/src/main/java/org/oppia/app/player/state/itemviewmodel/FeedbackViewModel.kt @@ -5,5 +5,6 @@ class FeedbackViewModel( val htmlContent: CharSequence, val gcsEntityId: String, val hasConversationView: Boolean, - val isSplitView: Boolean + val isSplitView: Boolean, + val supportsConceptCards: Boolean ) : StateItemViewModel(ViewType.FEEDBACK) diff --git a/app/src/main/java/org/oppia/app/player/state/itemviewmodel/SubmittedAnswerViewModel.kt b/app/src/main/java/org/oppia/app/player/state/itemviewmodel/SubmittedAnswerViewModel.kt index b1b3d02b52c..8ed55fb0da4 100644 --- a/app/src/main/java/org/oppia/app/player/state/itemviewmodel/SubmittedAnswerViewModel.kt +++ b/app/src/main/java/org/oppia/app/player/state/itemviewmodel/SubmittedAnswerViewModel.kt @@ -8,7 +8,8 @@ class SubmittedAnswerViewModel( val submittedUserAnswer: UserAnswer, val gcsEntityId: String, val hasConversationView: Boolean, - val isSplitView: Boolean + val isSplitView: Boolean, + val supportsConceptCards: Boolean ) : StateItemViewModel(ViewType.SUBMITTED_ANSWER) { val isCorrectAnswer = ObservableField(false) val isExtraInteractionAnswerCorrect = ObservableField(false) 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 0a0b0e026cb..1022569783a 100644 --- a/app/src/main/java/org/oppia/app/testing/ConceptCardFragmentTestActivity.kt +++ b/app/src/main/java/org/oppia/app/testing/ConceptCardFragmentTestActivity.kt @@ -18,7 +18,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/topic/conceptcard/ConceptCardFragmentPresenter.kt b/app/src/main/java/org/oppia/app/topic/conceptcard/ConceptCardFragmentPresenter.kt index 1078ae4763a..85fd2db10fa 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 @@ -45,7 +45,7 @@ class ConceptCardFragmentPresenter @Inject constructor( R.string.concept_card_close_icon_description ) 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/app/src/main/java/org/oppia/app/topic/questionplayer/QuestionPlayerActivity.kt b/app/src/main/java/org/oppia/app/topic/questionplayer/QuestionPlayerActivity.kt index 1a1420737de..2d5a67118f2 100644 --- a/app/src/main/java/org/oppia/app/topic/questionplayer/QuestionPlayerActivity.kt +++ b/app/src/main/java/org/oppia/app/topic/questionplayer/QuestionPlayerActivity.kt @@ -15,6 +15,7 @@ import org.oppia.app.player.state.listener.StateKeyboardButtonListener import org.oppia.app.player.stopplaying.RestartPlayingSessionListener 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 const val QUESTION_PLAYER_ACTIVITY_SKILL_ID_LIST_ARGUMENT_KEY = @@ -31,7 +32,8 @@ class QuestionPlayerActivity : RouteToHintsAndSolutionListener, RevealHintListener, RevealSolutionInterface, - HintsAndSolutionQuestionManagerListener { + HintsAndSolutionQuestionManagerListener, + ConceptCardListener { @Inject lateinit var questionPlayerActivityPresenter: QuestionPlayerActivityPresenter @@ -119,4 +121,8 @@ class QuestionPlayerActivity : override fun onQuestionStateLoaded(state: State) { this.state = state } + + override fun dismissConceptCard() { + questionPlayerActivityPresenter.dismissConceptCard() + } } diff --git a/app/src/main/java/org/oppia/app/topic/questionplayer/QuestionPlayerActivityPresenter.kt b/app/src/main/java/org/oppia/app/topic/questionplayer/QuestionPlayerActivityPresenter.kt index 46ca699ba15..8e104d87bd8 100644 --- a/app/src/main/java/org/oppia/app/topic/questionplayer/QuestionPlayerActivityPresenter.kt +++ b/app/src/main/java/org/oppia/app/topic/questionplayer/QuestionPlayerActivityPresenter.kt @@ -170,4 +170,6 @@ class QuestionPlayerActivityPresenter @Inject constructor( ) as QuestionPlayerFragment questionPlayerFragment.revealSolution(saveUserChoice) } + + fun dismissConceptCard() = getQuestionPlayerFragment()?.dismissConceptCard() } diff --git a/app/src/main/java/org/oppia/app/topic/questionplayer/QuestionPlayerFragment.kt b/app/src/main/java/org/oppia/app/topic/questionplayer/QuestionPlayerFragment.kt index 080b1069885..849b0ef9804 100644 --- a/app/src/main/java/org/oppia/app/topic/questionplayer/QuestionPlayerFragment.kt +++ b/app/src/main/java/org/oppia/app/topic/questionplayer/QuestionPlayerFragment.kt @@ -82,6 +82,8 @@ class QuestionPlayerFragment : questionPlayerFragmentPresenter.revealSolution(saveUserChoice) } + fun dismissConceptCard() = questionPlayerFragmentPresenter.dismissConceptCard() + override fun onHintAvailable(helpIndex: HelpIndex) = questionPlayerFragmentPresenter.onHintAvailable(helpIndex) } diff --git a/app/src/main/java/org/oppia/app/topic/questionplayer/QuestionPlayerFragmentPresenter.kt b/app/src/main/java/org/oppia/app/topic/questionplayer/QuestionPlayerFragmentPresenter.kt index abf9e8b8478..958cfd5c2ff 100644 --- a/app/src/main/java/org/oppia/app/topic/questionplayer/QuestionPlayerFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/app/topic/questionplayer/QuestionPlayerFragmentPresenter.kt @@ -22,6 +22,7 @@ import org.oppia.app.model.Hint import org.oppia.app.model.Solution import org.oppia.app.model.State import org.oppia.app.model.UserAnswer +import org.oppia.app.player.state.CONCEPT_CARD_DIALOG_FRAGMENT_TAG import org.oppia.app.player.state.StatePlayerRecyclerViewAssembler import org.oppia.app.player.state.listener.RouteToHintsAndSolutionListener import org.oppia.app.player.stopplaying.RestartPlayingSessionListener @@ -117,6 +118,14 @@ class QuestionPlayerFragmentPresenter @Inject constructor( ) } + fun dismissConceptCard() { + fragment.childFragmentManager.findFragmentByTag( + CONCEPT_CARD_DIALOG_FRAGMENT_TAG + )?.let { dialogFragment -> + fragment.childFragmentManager.beginTransaction().remove(dialogFragment).commitNow() + } + } + fun onHintAvailable(helpIndex: HelpIndex) { when (helpIndex.indexTypeCase) { HelpIndex.IndexTypeCase.HINT_INDEX, HelpIndex.IndexTypeCase.SHOW_SOLUTION -> { @@ -407,6 +416,7 @@ class QuestionPlayerFragmentPresenter @Inject constructor( .addReturnToTopicSupport() .addHintsAndSolutionsSupport() .addCongratulationsForCorrectAnswers(congratulationsTextView) + .addConceptCardSupport() .build() } 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 7e0c6b5ad06..25f56ed9836 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 @@ -3,8 +3,11 @@ package org.oppia.app.player.state import android.app.Application import android.content.Context import android.os.Build +import android.text.Spannable +import android.text.style.ClickableSpan import android.view.View import android.widget.EditText +import android.widget.TextView import androidx.appcompat.app.AppCompatActivity import androidx.recyclerview.widget.RecyclerView import androidx.test.core.app.ActivityScenario @@ -24,6 +27,7 @@ import androidx.test.espresso.assertion.ViewAssertions.doesNotExist import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.contrib.RecyclerViewActions.scrollToHolder import androidx.test.espresso.intent.Intents +import androidx.test.espresso.matcher.RootMatchers.isDialog import androidx.test.espresso.matcher.ViewMatchers import androidx.test.espresso.matcher.ViewMatchers.hasChildCount import androidx.test.espresso.matcher.ViewMatchers.isClickable @@ -34,9 +38,13 @@ import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.espresso.util.HumanReadables import androidx.test.espresso.util.TreeIterables 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.firebase.FirebaseApp import dagger.BindsInstance import dagger.Component +import kotlinx.coroutines.CoroutineDispatcher import org.hamcrest.BaseMatcher import org.hamcrest.CoreMatchers.allOf import org.hamcrest.CoreMatchers.containsString @@ -56,6 +64,7 @@ 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.hintsandsolution.HintsAndSolutionConfigFastShowTestModule 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 @@ -95,6 +104,7 @@ 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.FRACTIONS_EXPLORATION_ID_1 import org.oppia.domain.topic.PrimeTopicAssetsControllerModule import org.oppia.domain.topic.TEST_EXPLORATION_ID_0 import org.oppia.domain.topic.TEST_EXPLORATION_ID_2 @@ -103,6 +113,8 @@ import org.oppia.domain.topic.TEST_EXPLORATION_ID_5 import org.oppia.domain.topic.TEST_EXPLORATION_ID_6 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.IsOnRobolectric import org.oppia.testing.OppiaTestRule import org.oppia.testing.RunOn import org.oppia.testing.TestAccessibilityModule @@ -117,8 +129,10 @@ 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 java.io.IOException import java.util.concurrent.TimeoutException import javax.inject.Inject import javax.inject.Singleton @@ -140,6 +154,10 @@ class StateFragmentTest { @Inject lateinit var testCoroutineDispatchers: TestCoroutineDispatchers + @Inject + @field:BackgroundDispatcher + lateinit var backgroundCoroutineDispatcher: CoroutineDispatcher + private val internalProfileId: Int = 1 @Before @@ -149,6 +167,28 @@ class StateFragmentTest { testCoroutineDispatchers.registerIdlingResource() profileTestHelper.initializeProfiles() FirebaseApp.initializeApp(context) + + // 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) + ) + + // Only initialize the Robolectric shadows when running on Robolectric (and use reflection since + // Espresso can't load Robolectric into its classpath). + if (isOnRobolectric()) { + val dataSource = createAudioDataSource( + explorationId = FRACTIONS_EXPLORATION_ID_1, audioFileName = "content-en-ouqm7j21vt8.mp3" + ) + addShadowMediaPlayerException(dataSource, IOException("Test does not have networking")) + } } @After @@ -873,6 +913,113 @@ class StateFragmentTest { } } + @Test + fun testStateFragment_forMisconception_showsLinkTextForConceptCard() { + launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { + startPlayingExploration() + selectMultipleChoiceOption(optionPosition = 3) // No, pieces must be the same size. + clickContinueNavigationButton() + + // This answer is incorrect and a detected misconception. + typeFractionText("3/2") + clickSubmitAnswerButton() + scrollToViewType(FEEDBACK) + + onView(withId(R.id.feedback_text_view)).check( + matches( + withText(containsString("Take a look at the short refresher lesson")) + ) + ) + } + } + + @Test + fun testStateFragment_landscape_forMisconception_showsLinkTextForConceptCard() { + launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { + rotateToLandscape() + startPlayingExploration() + selectMultipleChoiceOption(optionPosition = 3) // No, pieces must be the same size. + clickContinueNavigationButton() + + // This answer is incorrect and a detected misconception. + typeFractionText("3/2") + clickSubmitAnswerButton() + scrollToViewType(FEEDBACK) + + onView(withId(R.id.feedback_text_view)).check( + matches( + withText(containsString("Take a look at the short refresher lesson")) + ) + ) + } + } + + @Test + fun testStateFragment_forMisconception_clickLinkText_opensConceptCard() { + launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { + startPlayingExploration() + selectMultipleChoiceOption(optionPosition = 3) // No, pieces must be the same size. + clickContinueNavigationButton() + typeFractionText("3/2") // Misconception. + clickSubmitAnswerButton() + + onView(withId(R.id.feedback_text_view)).perform(openClickableSpan("refresher lesson")) + testCoroutineDispatchers.runCurrent() + + onView(withText("Concept Card")).inRoot(isDialog()).check(matches(isDisplayed())) + onView(withId(R.id.concept_card_heading_text)) + .inRoot(isDialog()) + .check(matches(withText(containsString("Identify the numerator and denominator")))) + } + } + + @Test + fun testStateFragment_landscape_forMisconception_clickLinkText_opensConceptCard() { + launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { + rotateToLandscape() + startPlayingExploration() + selectMultipleChoiceOption(optionPosition = 3) // No, pieces must be the same size. + clickContinueNavigationButton() + typeFractionText("3/2") // Misconception. + clickSubmitAnswerButton() + + onView(withId(R.id.feedback_text_view)).perform(openClickableSpan("refresher lesson")) + testCoroutineDispatchers.runCurrent() + + onView(withText("Concept Card")).inRoot(isDialog()).check(matches(isDisplayed())) + onView(withId(R.id.concept_card_heading_text)) + .inRoot(isDialog()) + .check(matches(withText(containsString("Identify the numerator and denominator")))) + } + } + + private fun addShadowMediaPlayerException(dataSource: Any, exception: Exception) { + val classLoader = StateFragmentTest::class.java.classLoader!! + val shadowMediaPlayerClass = classLoader.loadClass("org.robolectric.shadows.ShadowMediaPlayer") + val addException = + shadowMediaPlayerClass.getDeclaredMethod( + "addException", dataSource.javaClass, IOException::class.java + ) + addException.invoke(/* obj= */ null, dataSource, exception) + } + + @Suppress("SameParameterValue") + private fun createAudioDataSource(explorationId: String, audioFileName: String): Any { + val audioUrl = createAudioUrl(explorationId, audioFileName) + val classLoader = StateFragmentTest::class.java.classLoader!! + val dataSourceClass = classLoader.loadClass("org.robolectric.shadows.util.DataSource") + val toDataSource = + dataSourceClass.getDeclaredMethod( + "toDataSource", String::class.java, Map::class.java + ) + return toDataSource.invoke(/* obj= */ null, audioUrl, /* headers= */ null) + } + + private fun createAudioUrl(explorationId: String, audioFileName: String): String { + return "https://storage.googleapis.com/oppiaserver-resources/" + + "exploration/$explorationId/assets/audio/$audioFileName" + } + private fun launchForExploration( explorationId: String ): ActivityScenario { @@ -1118,6 +1265,10 @@ class StateFragmentTest { ApplicationProvider.getApplicationContext().inject(this) } + private fun isOnRobolectric(): Boolean { + return ApplicationProvider.getApplicationContext().isOnRobolectric() + } + // TODO(#59): Remove these waits once we can ensure that the production executors are not depended on in tests. // Sleeping is really bad practice in Espresso tests, and can lead to test flakiness. It shouldn't be necessary if we // use a test executor service with a counting idle resource, but right now Gradle mixes dependencies such that both @@ -1216,6 +1367,56 @@ class StateFragmentTest { } } + /** + * Returns an action that finds a TextView containing the specific text, finds a ClickableSpan + * within that text view that contains the specified text, then clicks it. The need for this was + * inspired by https://stackoverflow.com/q/38314077. + */ + @Suppress("SameParameterValue") + private fun openClickableSpan(text: String): ViewAction { + return object : ViewAction { + override fun getDescription(): String = "openClickableSpan" + + override fun getConstraints(): Matcher = hasClickableSpanWithText(text) + + override fun perform(uiController: UiController?, view: View?) { + // The view shouldn't be null if the constraints are being met. + (view as? TextView)?.getClickableSpans()?.findMatchingTextOrNull(text)?.onClick(view) + } + } + } + + /** + * Returns a matcher that matches against text views with clickable spans that contain the + * specified text. + */ + private fun hasClickableSpanWithText(text: String): Matcher { + return object : TypeSafeMatcher(TextView::class.java) { + override fun describeTo(description: Description?) { + description?.appendText("has ClickableSpan with text")?.appendValue(text) + } + + override fun matchesSafely(item: View?): Boolean { + return (item as? TextView)?.getClickableSpans()?.findMatchingTextOrNull(text) != null + } + } + } + + private fun TextView.getClickableSpans(): List> { + val viewText = text + return (viewText as Spannable).getSpans( + /* start= */ 0, /* end= */ text.length, ClickableSpan::class.java + ).map { + viewText.subSequence(viewText.getSpanStart(it), viewText.getSpanEnd(it)).toString() to it + } + } + + private fun List>.findMatchingTextOrNull( + text: String + ): ClickableSpan? { + return find { text in it.first }?.second + } + @Singleton @Component( modules = [ @@ -1228,7 +1429,8 @@ class StateFragmentTest { HtmlParserEntityTypeModule::class, QuestionModule::class, TestLogReportingModule::class, TestAccessibilityModule::class, LogStorageModule::class, CachingTestModule::class, PrimeTopicAssetsControllerModule::class, ExpirationMetaDataRetrieverModule::class, - ViewBindingShimModule::class, RatioInputModule::class, ApplicationStartupListenerModule::class + ViewBindingShimModule::class, RatioInputModule::class, + ApplicationStartupListenerModule::class, HintsAndSolutionConfigFastShowTestModule::class ] ) interface TestApplicationComponent : ApplicationComponent { @@ -1241,6 +1443,8 @@ class StateFragmentTest { } fun inject(stateFragmentTest: StateFragmentTest) + + @IsOnRobolectric fun isOnRobolectric(): Boolean } class TestApplication : Application(), ActivityComponentFactory { @@ -1250,9 +1454,9 @@ class StateFragmentTest { .build() } - fun inject(stateFragmentTest: StateFragmentTest) { - component.inject(stateFragmentTest) - } + fun inject(stateFragmentTest: StateFragmentTest) = component.inject(stateFragmentTest) + + fun isOnRobolectric(): Boolean = component.isOnRobolectric() override fun createActivityComponent(activity: AppCompatActivity): ActivityComponent { return component.getActivityComponentBuilderProvider().get().setActivity(activity).build() 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 9b1355befcc..dd35e25fc0c 100644 --- a/app/src/sharedTest/java/org/oppia/app/splash/SplashActivityTest.kt +++ b/app/src/sharedTest/java/org/oppia/app/splash/SplashActivityTest.kt @@ -35,6 +35,7 @@ import org.oppia.app.application.ApplicationInjectorProvider import org.oppia.app.application.ApplicationModule import org.oppia.app.application.ApplicationStartupListenerModule import org.oppia.app.onboarding.OnboardingActivity +import org.oppia.app.player.state.hintsandsolution.HintsAndSolutionConfigModule import org.oppia.app.profile.ProfileChooserActivity import org.oppia.app.shim.ViewBindingShimModule import org.oppia.domain.classify.InteractionsModule @@ -279,7 +280,8 @@ class SplashActivityTest { HtmlParserEntityTypeModule::class, QuestionModule::class, TestLogReportingModule::class, TestAccessibilityModule::class, LogStorageModule::class, CachingTestModule::class, PrimeTopicAssetsControllerModule::class, ExpirationMetaDataRetrieverTestModule::class, - ViewBindingShimModule::class, RatioInputModule::class, ApplicationStartupListenerModule::class + ViewBindingShimModule::class, RatioInputModule::class, + ApplicationStartupListenerModule::class, HintsAndSolutionConfigModule::class ] ) interface TestApplicationComponent : ApplicationComponent, ApplicationInjector { diff --git a/app/src/sharedTest/java/org/oppia/app/topic/questionplayer/QuestionPlayerActivityTest.kt b/app/src/sharedTest/java/org/oppia/app/topic/questionplayer/QuestionPlayerActivityTest.kt index cabd2976d95..7141ee85e63 100644 --- a/app/src/sharedTest/java/org/oppia/app/topic/questionplayer/QuestionPlayerActivityTest.kt +++ b/app/src/sharedTest/java/org/oppia/app/topic/questionplayer/QuestionPlayerActivityTest.kt @@ -2,70 +2,375 @@ package org.oppia.app.topic.questionplayer import android.app.Application import android.content.Context +import android.text.Spannable +import android.text.style.ClickableSpan +import android.view.View +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import androidx.recyclerview.widget.RecyclerView +import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ApplicationProvider +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.UiController +import androidx.test.espresso.ViewAction +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.contrib.RecyclerViewActions.scrollToHolder +import androidx.test.espresso.matcher.RootMatchers.isDialog +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import androidx.test.espresso.matcher.ViewMatchers.withId +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.firebase.FirebaseApp -import dagger.BindsInstance import dagger.Component import dagger.Module import dagger.Provides import kotlinx.coroutines.CoroutineDispatcher +import org.hamcrest.BaseMatcher +import org.hamcrest.CoreMatchers.containsString +import org.hamcrest.Description +import org.hamcrest.Matcher +import org.hamcrest.TypeSafeMatcher +import org.junit.After import org.junit.Before 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.ApplicationInjector +import org.oppia.app.application.ApplicationInjectorProvider +import org.oppia.app.application.ApplicationModule +import org.oppia.app.application.ApplicationStartupListenerModule +import org.oppia.app.player.state.hintsandsolution.HintsAndSolutionConfigFastShowTestModule +import org.oppia.app.player.state.itemviewmodel.StateItemViewModel +import org.oppia.app.player.state.itemviewmodel.StateItemViewModel.ViewType.FEEDBACK +import org.oppia.app.player.state.itemviewmodel.StateItemViewModel.ViewType.SELECTION_INTERACTION +import org.oppia.app.recyclerview.RecyclerViewMatcher.Companion.atPositionOnView +import org.oppia.app.shim.ViewBindingShimModule +import org.oppia.app.utility.OrientationChangeAction.Companion.orientationLandscape +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.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.QuestionCountPerTrainingSession +import org.oppia.domain.question.QuestionTrainingSeed +import org.oppia.domain.topic.FRACTIONS_SKILL_ID_0 +import org.oppia.domain.topic.PrimeTopicAssetsControllerModule +import org.oppia.testing.CoroutineExecutorService +import org.oppia.testing.TestAccessibilityModule +import org.oppia.testing.TestCoroutineDispatchers +import org.oppia.testing.TestDispatcherModule +import org.oppia.testing.TestLogReportingModule +import org.oppia.testing.profile.ProfileTestHelper +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 +import org.oppia.util.parser.HtmlParserEntityTypeModule +import org.oppia.util.parser.ImageParsingModule import org.oppia.util.threading.BackgroundDispatcher -import org.oppia.util.threading.BlockingDispatcher +import org.robolectric.annotation.Config import org.robolectric.annotation.LooperMode +import javax.inject.Inject import javax.inject.Singleton +private val SKILL_ID_LIST = listOf(FRACTIONS_SKILL_ID_0) + /** Tests for [QuestionPlayerActivity]. */ @RunWith(AndroidJUnit4::class) @LooperMode(LooperMode.Mode.PAUSED) +@Config(application = QuestionPlayerActivityTest.TestApplication::class, qualifiers = "port-xxhdpi") class QuestionPlayerActivityTest { // TODO(#503): add tests for QuestionPlayerActivity (use StateFragmentTest for a reference). - // TODO(#1273): add tests for Hints and Solution in Question Player . + // TODO(#1273): add tests for Hints and Solution in Question Player. + + @Inject + lateinit var testCoroutineDispatchers: TestCoroutineDispatchers + + @Inject + lateinit var profileTestHelper: ProfileTestHelper + + @Inject + lateinit var context: Context + + @Inject + @field:BackgroundDispatcher + lateinit var backgroundCoroutineDispatcher: CoroutineDispatcher @Before fun setUp() { - FirebaseApp.initializeApp(ApplicationProvider.getApplicationContext()) + setUpTestApplicationComponent() + testCoroutineDispatchers.registerIdlingResource() + profileTestHelper.initializeProfiles() + FirebaseApp.initializeApp(context) + + // 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) + ) + } + + @After + fun tearDown() { + testCoroutineDispatchers.unregisterIdlingResource() + } + + @Test + fun testQuestionPlayer_forMisconception_showsLinkTextForConceptCard() { + launchForSkillList(SKILL_ID_LIST).use { + // Option 3 is the wrong answer and should trigger showing a concept card. + selectMultipleChoiceOption(optionPosition = 3) + scrollToViewType(FEEDBACK) + + onView(withId(R.id.feedback_text_view)).check( + matches( + withText(containsString("To refresh your memory, take a look at this refresher lesson")) + ) + ) + } } @Test - fun tempTest() { - // TODO(#503): remove. + fun testQuestionPlayer_landscape_forMisconception_showsLinkTextForConceptCard() { + launchForSkillList(SKILL_ID_LIST).use { + rotateToLandscape() + + // Option 3 is the wrong answer and should trigger showing a concept card. + selectMultipleChoiceOption(optionPosition = 3) + scrollToViewType(FEEDBACK) + + onView(withId(R.id.feedback_text_view)).check( + matches( + withText(containsString("To refresh your memory, take a look at this refresher lesson")) + ) + ) + } + } + + @Test + fun testQuestionPlayer_forMisconception_clickLinkText_opensConceptCard() { + launchForSkillList(SKILL_ID_LIST).use { + selectMultipleChoiceOption(optionPosition = 3) // Misconception. + scrollToViewType(FEEDBACK) + + onView(withId(R.id.feedback_text_view)).perform(openClickableSpan("refresher lesson")) + testCoroutineDispatchers.runCurrent() + + onView(withText("Concept Card")).inRoot(isDialog()).check(matches(isDisplayed())) + onView(withId(R.id.concept_card_heading_text)) + .inRoot(isDialog()) + .check(matches(withText(containsString("Identify the numerator and denominator")))) + } + } + + @Test + fun testQuestionPlayer_landscape_forMisconception_clickLinkText_opensConceptCard() { + launchForSkillList(SKILL_ID_LIST).use { + rotateToLandscape() + selectMultipleChoiceOption(optionPosition = 3) // Misconception. + scrollToViewType(FEEDBACK) + + onView(withId(R.id.feedback_text_view)).perform(openClickableSpan("refresher lesson")) + testCoroutineDispatchers.runCurrent() + + onView(withText("Concept Card")).inRoot(isDialog()).check(matches(isDisplayed())) + onView(withId(R.id.concept_card_heading_text)) + .inRoot(isDialog()) + .check(matches(withText(containsString("Identify the numerator and denominator")))) + } + } + + private fun setUpTestApplicationComponent() { + ApplicationProvider.getApplicationContext().inject(this) + } + + private fun launchForSkillList( + skillIdList: List + ): ActivityScenario { + val scenario = ActivityScenario.launch( + QuestionPlayerActivity.createQuestionPlayerActivityIntent( + context, ArrayList(skillIdList) + ) + ) + testCoroutineDispatchers.runCurrent() + onView(withId(R.id.question_recycler_view)).check(matches(isDisplayed())) + return scenario + } + + private fun rotateToLandscape() { + onView(isRoot()).perform(orientationLandscape()) + testCoroutineDispatchers.runCurrent() + } + + // TODO(#1778): Share the following utilities with StateFragmentTest. + + @Suppress("SameParameterValue") + private fun selectMultipleChoiceOption(optionPosition: Int) { + clickSelection(optionPosition, targetViewId = R.id.multiple_choice_radio_button) + } + + @Suppress("SameParameterValue") + private fun clickSelection(optionPosition: Int, targetViewId: Int) { + scrollToViewType(SELECTION_INTERACTION) + onView( + atPositionOnView( + recyclerViewId = R.id.selection_interaction_recyclerview, + position = optionPosition, + targetViewId = targetViewId + ) + ).perform(click()) + testCoroutineDispatchers.runCurrent() + } + + private fun scrollToViewType(viewType: StateItemViewModel.ViewType) { + onView(withId(R.id.question_recycler_view)).perform( + scrollToHolder(StateViewHolderTypeMatcher(viewType)) + ) + testCoroutineDispatchers.runCurrent() + } + + /** + * [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 an action that finds a TextView containing the specific text, finds a ClickableSpan + * within that text view that contains the specified text, then clicks it. The need for this was + * inspired by https://stackoverflow.com/q/38314077. + */ + @Suppress("SameParameterValue") + private fun openClickableSpan(text: String): ViewAction { + return object : ViewAction { + override fun getDescription(): String = "openClickableSpan" + + override fun getConstraints(): Matcher = hasClickableSpanWithText(text) + + override fun perform(uiController: UiController?, view: View?) { + // The view shouldn't be null if the constraints are being met. + (view as? TextView)?.getClickableSpans()?.findMatchingTextOrNull(text)?.onClick(view) + } + } + } + + /** + * Returns a matcher that matches against text views with clickable spans that contain the + * specified text. + */ + private fun hasClickableSpanWithText(text: String): Matcher { + return object : TypeSafeMatcher(TextView::class.java) { + override fun describeTo(description: Description?) { + description?.appendText("has ClickableSpan with text")?.appendValue(text) + } + + override fun matchesSafely(item: View?): Boolean { + return (item as? TextView)?.getClickableSpans()?.findMatchingTextOrNull(text) != null + } + } + } + + private fun TextView.getClickableSpans(): List> { + val viewText = text + return (viewText as Spannable).getSpans( + /* start= */ 0, /* end= */ text.length, ClickableSpan::class.java + ).map { + viewText.subSequence(viewText.getSpanStart(it), viewText.getSpanEnd(it)).toString() to it + } + } + + private fun List>.findMatchingTextOrNull( + text: String + ): ClickableSpan? { + return find { text in it.first }?.second } @Module class TestModule { @Provides - @Singleton - fun provideContext(application: Application): Context { - return application - } - - // 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. + @QuestionCountPerTrainingSession + fun provideQuestionCountPerTrainingSession(): Int = 3 - @Singleton + // Ensure that the question seed is consistent for all runs of the tests to keep question order + // predictable. @Provides - @BackgroundDispatcher - fun provideBackgroundDispatcher( - @BlockingDispatcher blockingDispatcher: CoroutineDispatcher - ): CoroutineDispatcher { - return blockingDispatcher - } + @QuestionTrainingSeed + fun provideQuestionTrainingSeed(): Long = 3 } + // TODO(#59): Figure out a way to reuse modules instead of needing to re-declare them. + // TODO(#1675): Add NetworkModule once data module is migrated off of Moshi. @Singleton - @Component(modules = [TestModule::class]) - interface TestApplicationComponent { + @Component( + modules = [ + TestModule::class, TestDispatcherModule::class, ApplicationModule::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, TestLogReportingModule::class, + TestAccessibilityModule::class, LogStorageModule::class, CachingTestModule::class, + PrimeTopicAssetsControllerModule::class, ExpirationMetaDataRetrieverModule::class, + ViewBindingShimModule::class, ApplicationStartupListenerModule::class, + RatioInputModule::class, HintsAndSolutionConfigFastShowTestModule::class + ] + ) + interface TestApplicationComponent : ApplicationComponent, ApplicationInjector { @Component.Builder - interface Builder { - @BindsInstance - fun setApplication(application: Application): Builder + interface Builder : ApplicationComponent.Builder + + fun inject(questionPlayerActivityTest: QuestionPlayerActivityTest) + } + + class TestApplication : Application(), ActivityComponentFactory, ApplicationInjectorProvider { + private val component: TestApplicationComponent by lazy { + DaggerQuestionPlayerActivityTest_TestApplicationComponent.builder() + .setApplication(this) + .build() as TestApplicationComponent + } + + fun inject(questionPlayerActivityTest: QuestionPlayerActivityTest) { + component.inject(questionPlayerActivityTest) + } - fun build(): TestApplicationComponent + override fun createActivityComponent(activity: AppCompatActivity): ActivityComponent { + return component.getActivityComponentBuilderProvider().get().setActivity(activity).build() } + + override fun getApplicationInjector(): ApplicationInjector = component } } diff --git a/app/src/test/java/org/oppia/app/home/HomeActivityLocalTest.kt b/app/src/test/java/org/oppia/app/home/HomeActivityLocalTest.kt index 9c15b98f14b..db734e56dec 100644 --- a/app/src/test/java/org/oppia/app/home/HomeActivityLocalTest.kt +++ b/app/src/test/java/org/oppia/app/home/HomeActivityLocalTest.kt @@ -20,6 +20,7 @@ import org.oppia.app.application.ApplicationModule import org.oppia.app.application.ApplicationStartupListenerModule import org.oppia.app.model.EventLog import org.oppia.app.model.EventLog.Context.ActivityContextCase.ACTIVITYCONTEXT_NOT_SET +import org.oppia.app.player.state.hintsandsolution.HintsAndSolutionConfigModule import org.oppia.app.shim.IntentFactoryShimModule import org.oppia.app.shim.ViewBindingShimModule import org.oppia.domain.classify.InteractionsModule @@ -110,7 +111,7 @@ class HomeActivityLocalTest { ImageClickInputModule::class, LogStorageModule::class, IntentFactoryShimModule::class, ViewBindingShimModule::class, CachingTestModule::class, RatioInputModule::class, PrimeTopicAssetsControllerModule::class, ExpirationMetaDataRetrieverModule::class, - ApplicationStartupListenerModule::class + ApplicationStartupListenerModule::class, HintsAndSolutionConfigModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/test/java/org/oppia/app/player/exploration/ExplorationActivityLocalTest.kt b/app/src/test/java/org/oppia/app/player/exploration/ExplorationActivityLocalTest.kt index 3ede967d380..3d4332fc900 100644 --- a/app/src/test/java/org/oppia/app/player/exploration/ExplorationActivityLocalTest.kt +++ b/app/src/test/java/org/oppia/app/player/exploration/ExplorationActivityLocalTest.kt @@ -18,6 +18,7 @@ import org.oppia.app.application.ApplicationModule import org.oppia.app.application.ApplicationStartupListenerModule import org.oppia.app.model.EventLog import org.oppia.app.model.EventLog.Context.ActivityContextCase.EXPLORATION_CONTEXT +import org.oppia.app.player.state.hintsandsolution.HintsAndSolutionConfigModule import org.oppia.app.shim.IntentFactoryShimModule import org.oppia.app.shim.ViewBindingShimModule import org.oppia.app.testing.ExplorationInjectionActivity @@ -147,7 +148,7 @@ class ExplorationActivityLocalTest { ImageClickInputModule::class, LogStorageModule::class, IntentFactoryShimModule::class, ViewBindingShimModule::class, CachingTestModule::class, RatioInputModule::class, PrimeTopicAssetsControllerModule::class, ExpirationMetaDataRetrieverModule::class, - ApplicationStartupListenerModule::class + ApplicationStartupListenerModule::class, HintsAndSolutionConfigModule::class ] ) interface TestApplicationComponent : ApplicationComponent { 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 3118221a916..7434783724d 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 @@ -53,6 +53,7 @@ import org.oppia.app.application.ApplicationModule import org.oppia.app.application.ApplicationStartupListenerModule import org.oppia.app.hintsandsolution.TAG_REVEAL_SOLUTION_DIALOG import org.oppia.app.player.exploration.TAG_HINTS_AND_SOLUTION_DIALOG +import org.oppia.app.player.state.hintsandsolution.HintsAndSolutionConfigModule import org.oppia.app.player.state.itemviewmodel.StateItemViewModel import org.oppia.app.player.state.itemviewmodel.StateItemViewModel.ViewType.CONTINUE_NAVIGATION_BUTTON import org.oppia.app.player.state.itemviewmodel.StateItemViewModel.ViewType.FRACTION_INPUT_INTERACTION @@ -1136,7 +1137,8 @@ class StateFragmentLocalTest { HtmlParserEntityTypeModule::class, QuestionModule::class, TestLogReportingModule::class, TestAccessibilityModule::class, LogStorageModule::class, CachingTestModule::class, PrimeTopicAssetsControllerModule::class, ExpirationMetaDataRetrieverModule::class, - ViewBindingShimModule::class, RatioInputModule::class, ApplicationStartupListenerModule::class + ViewBindingShimModule::class, RatioInputModule::class, + ApplicationStartupListenerModule::class, HintsAndSolutionConfigModule::class ] ) interface TestApplicationComponent : ApplicationComponent, ApplicationInjector { diff --git a/app/src/test/java/org/oppia/app/profile/ProfileChooserFragmentLocalTest.kt b/app/src/test/java/org/oppia/app/profile/ProfileChooserFragmentLocalTest.kt index ac4c115b9fa..8e73c5f09d7 100644 --- a/app/src/test/java/org/oppia/app/profile/ProfileChooserFragmentLocalTest.kt +++ b/app/src/test/java/org/oppia/app/profile/ProfileChooserFragmentLocalTest.kt @@ -23,6 +23,7 @@ import org.oppia.app.application.ApplicationStartupListenerModule import org.oppia.app.model.EventLog.Context.ActivityContextCase.ACTIVITYCONTEXT_NOT_SET import org.oppia.app.model.EventLog.EventAction import org.oppia.app.model.EventLog.Priority +import org.oppia.app.player.state.hintsandsolution.HintsAndSolutionConfigModule import org.oppia.app.shim.ViewBindingShimModule import org.oppia.domain.classify.InteractionsModule import org.oppia.domain.classify.rules.continueinteraction.ContinueModule @@ -110,7 +111,8 @@ class ProfileChooserFragmentLocalTest { QuestionModule::class, TestLogReportingModule::class, TestAccessibilityModule::class, ImageClickInputModule::class, LogStorageModule::class, CachingTestModule::class, PrimeTopicAssetsControllerModule::class, ExpirationMetaDataRetrieverModule::class, - ViewBindingShimModule::class, RatioInputModule::class, ApplicationStartupListenerModule::class + ViewBindingShimModule::class, RatioInputModule::class, + ApplicationStartupListenerModule::class, HintsAndSolutionConfigModule::class ] ) interface TestApplicationComponent : ApplicationComponent, ApplicationInjector { diff --git a/app/src/test/java/org/oppia/app/story/StoryActivityLocalTest.kt b/app/src/test/java/org/oppia/app/story/StoryActivityLocalTest.kt index 0e48aef39aa..7986a7f765d 100644 --- a/app/src/test/java/org/oppia/app/story/StoryActivityLocalTest.kt +++ b/app/src/test/java/org/oppia/app/story/StoryActivityLocalTest.kt @@ -22,6 +22,7 @@ import org.oppia.app.application.ApplicationModule import org.oppia.app.application.ApplicationStartupListenerModule import org.oppia.app.model.EventLog import org.oppia.app.model.EventLog.Context.ActivityContextCase.STORY_CONTEXT +import org.oppia.app.player.state.hintsandsolution.HintsAndSolutionConfigModule import org.oppia.app.shim.ViewBindingShimModule import org.oppia.domain.classify.InteractionsModule import org.oppia.domain.classify.rules.continueinteraction.ContinueModule @@ -126,7 +127,8 @@ class StoryActivityLocalTest { QuestionModule::class, TestLogReportingModule::class, TestAccessibilityModule::class, ImageClickInputModule::class, LogStorageModule::class, CachingTestModule::class, PrimeTopicAssetsControllerModule::class, ExpirationMetaDataRetrieverModule::class, - ViewBindingShimModule::class, RatioInputModule::class, ApplicationStartupListenerModule::class + ViewBindingShimModule::class, RatioInputModule::class, + ApplicationStartupListenerModule::class, HintsAndSolutionConfigModule::class ] ) interface TestApplicationComponent : ApplicationComponent, ApplicationInjector { diff --git a/app/src/test/java/org/oppia/app/testing/options/AppLanguageFragmentTest.kt b/app/src/test/java/org/oppia/app/testing/options/AppLanguageFragmentTest.kt index 94c8b481b10..a574039584a 100644 --- a/app/src/test/java/org/oppia/app/testing/options/AppLanguageFragmentTest.kt +++ b/app/src/test/java/org/oppia/app/testing/options/AppLanguageFragmentTest.kt @@ -31,6 +31,7 @@ import org.oppia.app.application.ApplicationStartupListenerModule import org.oppia.app.options.APP_LANGUAGE import org.oppia.app.options.AppLanguageActivity import org.oppia.app.options.OptionsActivity +import org.oppia.app.player.state.hintsandsolution.HintsAndSolutionConfigModule import org.oppia.app.recyclerview.RecyclerViewMatcher.Companion.atPositionOnView import org.oppia.app.shim.ViewBindingShimModule import org.oppia.app.utility.OrientationChangeAction.Companion.orientationLandscape @@ -217,7 +218,7 @@ class AppLanguageFragmentTest { ImageClickInputModule::class, LogStorageModule::class, CachingTestModule::class, PrimeTopicAssetsControllerModule::class, ExpirationMetaDataRetrieverModule::class, ViewBindingShimModule::class, ApplicationStartupListenerModule::class, - RatioInputModule::class + RatioInputModule::class, HintsAndSolutionConfigModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/test/java/org/oppia/app/testing/options/DefaultAudioFragmentTest.kt b/app/src/test/java/org/oppia/app/testing/options/DefaultAudioFragmentTest.kt index 2df328aa374..4c95046fcec 100644 --- a/app/src/test/java/org/oppia/app/testing/options/DefaultAudioFragmentTest.kt +++ b/app/src/test/java/org/oppia/app/testing/options/DefaultAudioFragmentTest.kt @@ -31,6 +31,7 @@ import org.oppia.app.application.ApplicationStartupListenerModule import org.oppia.app.options.AUDIO_LANGUAGE import org.oppia.app.options.DefaultAudioActivity import org.oppia.app.options.OptionsActivity +import org.oppia.app.player.state.hintsandsolution.HintsAndSolutionConfigModule import org.oppia.app.recyclerview.RecyclerViewMatcher.Companion.atPositionOnView import org.oppia.app.shim.ViewBindingShimModule import org.oppia.app.utility.OrientationChangeAction.Companion.orientationLandscape @@ -218,7 +219,7 @@ class DefaultAudioFragmentTest { ImageClickInputModule::class, LogStorageModule::class, CachingTestModule::class, PrimeTopicAssetsControllerModule::class, ExpirationMetaDataRetrieverModule::class, ViewBindingShimModule::class, ApplicationStartupListenerModule::class, - RatioInputModule::class + RatioInputModule::class, HintsAndSolutionConfigModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/test/java/org/oppia/app/testing/options/ReadingTextSizeFragmentTest.kt b/app/src/test/java/org/oppia/app/testing/options/ReadingTextSizeFragmentTest.kt index 70eb4d7d33a..715d1b4832c 100644 --- a/app/src/test/java/org/oppia/app/testing/options/ReadingTextSizeFragmentTest.kt +++ b/app/src/test/java/org/oppia/app/testing/options/ReadingTextSizeFragmentTest.kt @@ -39,6 +39,7 @@ import org.oppia.app.application.ApplicationStartupListenerModule import org.oppia.app.options.OptionsActivity import org.oppia.app.options.READING_TEXT_SIZE import org.oppia.app.options.ReadingTextSizeActivity +import org.oppia.app.player.state.hintsandsolution.HintsAndSolutionConfigModule import org.oppia.app.recyclerview.RecyclerViewMatcher.Companion.atPositionOnView import org.oppia.app.shim.ViewBindingShimModule import org.oppia.app.utility.OrientationChangeAction.Companion.orientationLandscape @@ -231,7 +232,7 @@ class ReadingTextSizeFragmentTest { ImageClickInputModule::class, LogStorageModule::class, CachingTestModule::class, PrimeTopicAssetsControllerModule::class, ExpirationMetaDataRetrieverModule::class, ViewBindingShimModule::class, ApplicationStartupListenerModule::class, - RatioInputModule::class + RatioInputModule::class, HintsAndSolutionConfigModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/test/java/org/oppia/app/testing/player/state/StateFragmentAccessibilityTest.kt b/app/src/test/java/org/oppia/app/testing/player/state/StateFragmentAccessibilityTest.kt index 4f48324c063..4393e87ec40 100644 --- a/app/src/test/java/org/oppia/app/testing/player/state/StateFragmentAccessibilityTest.kt +++ b/app/src/test/java/org/oppia/app/testing/player/state/StateFragmentAccessibilityTest.kt @@ -24,6 +24,7 @@ import org.oppia.app.application.ApplicationContext import org.oppia.app.application.ApplicationModule import org.oppia.app.application.ApplicationStartupListenerModule import org.oppia.app.player.state.StateFragment +import org.oppia.app.player.state.hintsandsolution.HintsAndSolutionConfigModule import org.oppia.app.player.state.testing.StateFragmentTestActivity import org.oppia.app.recyclerview.RecyclerViewMatcher import org.oppia.app.shim.IntentFactoryShimModule @@ -169,7 +170,7 @@ class StateFragmentAccessibilityTest { ImageClickInputModule::class, LogStorageModule::class, IntentFactoryShimModule::class, ViewBindingShimModule::class, CachingTestModule::class, RatioInputModule::class, PrimeTopicAssetsControllerModule::class, ExpirationMetaDataRetrieverModule::class, - ApplicationStartupListenerModule::class + ApplicationStartupListenerModule::class, HintsAndSolutionConfigModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/test/java/org/oppia/app/topic/info/TopicInfoFragmentLocalTest.kt b/app/src/test/java/org/oppia/app/topic/info/TopicInfoFragmentLocalTest.kt index c7626263554..bb4f2998136 100644 --- a/app/src/test/java/org/oppia/app/topic/info/TopicInfoFragmentLocalTest.kt +++ b/app/src/test/java/org/oppia/app/topic/info/TopicInfoFragmentLocalTest.kt @@ -19,6 +19,7 @@ import org.oppia.app.application.ApplicationModule import org.oppia.app.application.ApplicationStartupListenerModule import org.oppia.app.model.EventLog import org.oppia.app.model.EventLog.Context.ActivityContextCase.TOPIC_CONTEXT +import org.oppia.app.player.state.hintsandsolution.HintsAndSolutionConfigModule import org.oppia.app.shim.ViewBindingShimModule import org.oppia.app.topic.TopicActivity import org.oppia.domain.classify.InteractionsModule @@ -114,7 +115,8 @@ class TopicInfoFragmentLocalTest { QuestionModule::class, TestLogReportingModule::class, TestAccessibilityModule::class, ImageClickInputModule::class, LogStorageModule::class, CachingTestModule::class, PrimeTopicAssetsControllerModule::class, ExpirationMetaDataRetrieverModule::class, - ViewBindingShimModule::class, RatioInputModule::class, ApplicationStartupListenerModule::class + ViewBindingShimModule::class, RatioInputModule::class, + ApplicationStartupListenerModule::class, HintsAndSolutionConfigModule::class ] ) interface TestApplicationComponent : ApplicationComponent, ApplicationInjector { diff --git a/app/src/test/java/org/oppia/app/topic/lessons/TopicLessonsFragmentLocalTest.kt b/app/src/test/java/org/oppia/app/topic/lessons/TopicLessonsFragmentLocalTest.kt index 6ed0263682a..5199c679aa3 100644 --- a/app/src/test/java/org/oppia/app/topic/lessons/TopicLessonsFragmentLocalTest.kt +++ b/app/src/test/java/org/oppia/app/topic/lessons/TopicLessonsFragmentLocalTest.kt @@ -18,6 +18,7 @@ import org.oppia.app.application.ApplicationInjectorProvider import org.oppia.app.application.ApplicationModule import org.oppia.app.application.ApplicationStartupListenerModule import org.oppia.app.model.EventLog +import org.oppia.app.player.state.hintsandsolution.HintsAndSolutionConfigModule import org.oppia.app.shim.ViewBindingShimModule import org.oppia.app.topic.TopicActivity import org.oppia.domain.classify.InteractionsModule @@ -116,7 +117,8 @@ class TopicLessonsFragmentLocalTest { QuestionModule::class, TestLogReportingModule::class, TestAccessibilityModule::class, ImageClickInputModule::class, LogStorageModule::class, CachingTestModule::class, PrimeTopicAssetsControllerModule::class, ExpirationMetaDataRetrieverModule::class, - ViewBindingShimModule::class, RatioInputModule::class, ApplicationStartupListenerModule::class + ViewBindingShimModule::class, RatioInputModule::class, + ApplicationStartupListenerModule::class, HintsAndSolutionConfigModule::class ] ) interface TestApplicationComponent : ApplicationComponent, ApplicationInjector { diff --git a/app/src/test/java/org/oppia/app/topic/questionplayer/QuestionPlayerActivityLocalTest.kt b/app/src/test/java/org/oppia/app/topic/questionplayer/QuestionPlayerActivityLocalTest.kt index 0a86f70a3e1..4d827c6ba96 100644 --- a/app/src/test/java/org/oppia/app/topic/questionplayer/QuestionPlayerActivityLocalTest.kt +++ b/app/src/test/java/org/oppia/app/topic/questionplayer/QuestionPlayerActivityLocalTest.kt @@ -36,6 +36,7 @@ import org.oppia.app.application.ApplicationInjector import org.oppia.app.application.ApplicationInjectorProvider import org.oppia.app.application.ApplicationModule import org.oppia.app.application.ApplicationStartupListenerModule +import org.oppia.app.player.state.hintsandsolution.HintsAndSolutionConfigModule import org.oppia.app.player.state.itemviewmodel.StateItemViewModel import org.oppia.app.shim.ViewBindingShimModule import org.oppia.domain.classify.InteractionsModule @@ -270,7 +271,7 @@ class QuestionPlayerActivityLocalTest { TestAccessibilityModule::class, LogStorageModule::class, CachingTestModule::class, PrimeTopicAssetsControllerModule::class, ExpirationMetaDataRetrieverModule::class, ViewBindingShimModule::class, ApplicationStartupListenerModule::class, - RatioInputModule::class + RatioInputModule::class, HintsAndSolutionConfigModule::class ] ) interface TestApplicationComponent : ApplicationComponent, ApplicationInjector { diff --git a/app/src/test/java/org/oppia/app/topic/revisioncard/RevisionCardActivityLocalTest.kt b/app/src/test/java/org/oppia/app/topic/revisioncard/RevisionCardActivityLocalTest.kt index 1a3138c21ed..ed169c20dac 100644 --- a/app/src/test/java/org/oppia/app/topic/revisioncard/RevisionCardActivityLocalTest.kt +++ b/app/src/test/java/org/oppia/app/topic/revisioncard/RevisionCardActivityLocalTest.kt @@ -19,6 +19,7 @@ import org.oppia.app.application.ApplicationModule import org.oppia.app.application.ApplicationStartupListenerModule import org.oppia.app.model.EventLog import org.oppia.app.model.EventLog.Context.ActivityContextCase.REVISION_CARD_CONTEXT +import org.oppia.app.player.state.hintsandsolution.HintsAndSolutionConfigModule import org.oppia.app.shim.ViewBindingShimModule import org.oppia.domain.classify.InteractionsModule import org.oppia.domain.classify.rules.continueinteraction.ContinueModule @@ -106,7 +107,8 @@ class RevisionCardActivityLocalTest { QuestionModule::class, TestLogReportingModule::class, TestAccessibilityModule::class, ImageClickInputModule::class, LogStorageModule::class, CachingTestModule::class, PrimeTopicAssetsControllerModule::class, ExpirationMetaDataRetrieverModule::class, - ViewBindingShimModule::class, RatioInputModule::class, ApplicationStartupListenerModule::class + ViewBindingShimModule::class, RatioInputModule::class, + ApplicationStartupListenerModule::class, HintsAndSolutionConfigModule::class ] ) interface TestApplicationComponent : ApplicationComponent, ApplicationInjector { diff --git a/domain/src/main/assets/MjZzEVOG47_1.json b/domain/src/main/assets/MjZzEVOG47_1.json index e5ca2d5dce7..a3515937f5b 100644 --- a/domain/src/main/assets/MjZzEVOG47_1.json +++ b/domain/src/main/assets/MjZzEVOG47_1.json @@ -1873,7 +1873,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, @@ -1900,7 +1900,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, diff --git a/domain/src/main/assets/questions.json b/domain/src/main/assets/questions.json index 162411c541a..3b28c92ca51 100644 --- a/domain/src/main/assets/questions.json +++ b/domain/src/main/assets/questions.json @@ -532,7 +532,7 @@ "param_changes": [], "feedback": { "content_id": "feedback_4", - "html": "

No, that's not correct. It looks like you've forgotten what a numerator and denominator are. To refresh your memory, look up the \"How to Identify the Numerator and Denominator of a Fraction\" concept card.

" + "html": "

No, that's not correct. It looks like you've forgotten what a numerator and denominator are. To refresh your memory, take a look at this .

" }, "dest": null, "refresher_exploration_id": null, diff --git a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcherEspressoImpl.kt b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcherEspressoImpl.kt index 843da343dc9..53bd332423c 100644 --- a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcherEspressoImpl.kt +++ b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcherEspressoImpl.kt @@ -15,6 +15,7 @@ import java.util.concurrent.atomic.AtomicInteger import javax.inject.Inject import kotlin.coroutines.CoroutineContext import kotlin.math.min +import kotlinx.coroutines.delay as delayInScope // Needed to avoid conflict with Delay.delay(). /** * Espresso-specific implementation of [TestCoroutineDispatcher]. @@ -78,7 +79,7 @@ class TestCoroutineDispatcherEspressoImpl private constructor( executingTaskCount.incrementAndGet() notifyIfRunning() val delayResult = realCoroutineScope.async { - delay(timeMillis) + delayInScope(timeMillis) } delayResult.invokeOnCompletion { try { diff --git a/utility/src/main/java/org/oppia/util/parser/BulletTagHandler.kt b/utility/src/main/java/org/oppia/util/parser/BulletTagHandler.kt new file mode 100644 index 00000000000..92757f7e8f0 --- /dev/null +++ b/utility/src/main/java/org/oppia/util/parser/BulletTagHandler.kt @@ -0,0 +1,37 @@ +package org.oppia.util.parser + +import android.text.Editable +import android.text.Spannable +import android.text.SpannableStringBuilder +import android.text.style.BulletSpan +import org.xml.sax.Attributes + +/** The custom tag corresponding to [BulletTagHandler]. */ +const val CUSTOM_BULLET_LIST_TAG = "oppia-li" + +/** + * A custom tag handler for properly formatting bullet items in HTML parsed with + * [CustomHtmlContentHandler]. + */ +class BulletTagHandler : CustomHtmlContentHandler.CustomTagHandler { + override fun handleTag( + attributes: Attributes, + openIndex: Int, + closeIndex: Int, + output: Editable + ) { + val spannableBuilder = SpannableStringBuilder( + output.subSequence( + openIndex, + closeIndex + ) + ) + spannableBuilder.append("\n") + if (openIndex != closeIndex) { + spannableBuilder.setSpan( + BulletSpan(), 0, spannableBuilder.length, Spannable.SPAN_INCLUSIVE_EXCLUSIVE + ) + } + output.replace(openIndex, closeIndex, spannableBuilder) + } +} 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..3e617fd4748 --- /dev/null +++ b/utility/src/main/java/org/oppia/util/parser/CustomHtmlContentHandler.kt @@ -0,0 +1,154 @@ +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 +import java.util.ArrayDeque + +/** + * 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 val currentTrackedCustomTags = ArrayDeque() + + 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" + } + currentTrackedCustomTags += TrackedCustomTag( + localCurrentTrackedTag.tag, localCurrentTrackedTag.attributes, output.length + ) + } + } + tag in customTagHandlers -> { + check(currentTrackedCustomTags.isNotEmpty()) { + "Expected tracked custom tag to be initialized." + } + val currentTrackedCustomTag = currentTrackedCustomTags.removeLast() + check(currentTrackedCustomTag.tag == tag) { + "Expected tracked tag $currentTrackedTag to match custom tag: $tag" + } + val (_, attributes, openTagIndex) = currentTrackedCustomTag + 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. + 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 6eb3291de7a..092c06cc203 100755 --- a/utility/src/main/java/org/oppia/util/parser/HtmlParser.kt +++ b/utility/src/main/java/org/oppia/util/parser/HtmlParser.kt @@ -1,11 +1,15 @@ package org.oppia.util.parser +import android.text.Editable import android.text.Spannable import android.text.SpannableStringBuilder import android.text.Spanned +import android.text.method.LinkMovementMethod import android.text.style.BulletSpan +import android.text.style.ClickableSpan +import android.view.View import android.widget.TextView -import androidx.core.text.HtmlCompat +import org.xml.sax.Attributes import javax.inject.Inject private const val CUSTOM_IMG_TAG = "oppia-noninteractive-image" @@ -13,54 +17,66 @@ 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-noninteractive-skillreview" + /** Html Parser to parse custom Oppia tags with Android-compatible versions. */ class HtmlParser private constructor( private val urlImageParserFactory: UrlImageParser.Factory, private val gcsResourceName: String, private val entityType: String, private val entityId: String, - private val imageCenterAlign: Boolean + private val imageCenterAlign: Boolean, + customOppiaTagActionListener: CustomOppiaTagActionListener? ) { + private val conceptCardTagHandler = ConceptCardTagHandler(customOppiaTagActionListener) + private val bulletTagHandler = BulletTagHandler() /** - * 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, + supportsConceptCards: Boolean = false + ): Spannable { var htmlContent = rawString - if (htmlContent.contains("\n\t")) { + if ("\n\t" in htmlContent) { htmlContent = htmlContent.replace("\n\t", "") } - if (htmlContent.contains("\n\n")) { + if ("\n\n" in htmlContent) { htmlContent = htmlContent.replace("\n\n", "") } + if ("
  • " in htmlContent) { + htmlContent = htmlContent.replace("
  • ", "<$CUSTOM_BULLET_LIST_TAG>") + .replace("
  • ", "") + } - 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 - ) + if (CUSTOM_IMG_TAG in htmlContent) { + 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, gcsResourceName, entityType, entityId, imageCenterAlign ) - - val htmlSpannable = HtmlCompat.fromHtml( - htmlContent, - HtmlCompat.FROM_HTML_MODE_LEGACY, - imageGetter, - LiTagHandler() - ) as Spannable + val htmlSpannable = CustomHtmlContentHandler.fromHtml( + htmlContent, imageGetter, computeCustomTagHandlers(supportsConceptCards) + ) val spannableBuilder = SpannableStringBuilder(htmlSpannable) val bulletSpans = spannableBuilder.getSpans( @@ -79,41 +95,83 @@ class HtmlParser private constructor( Spanned.SPAN_INCLUSIVE_EXCLUSIVE ) } + return trimSpannable(spannableBuilder) } - private fun trimSpannable(spannable: SpannableStringBuilder): SpannableStringBuilder { - var trimStart = 0 - var trimEnd = 0 - - var text = spannable.toString() - - if (text.startsWith("\n")) { - text = text.substring(1) - trimStart += 1 + private fun computeCustomTagHandlers( + supportsConceptCards: Boolean + ): Map { + val handlersMap = mutableMapOf() + handlersMap[CUSTOM_BULLET_LIST_TAG] = bulletTagHandler + if (supportsConceptCards) { + handlersMap[CUSTOM_CONCEPT_CARD_TAG] = conceptCardTagHandler } + return handlersMap + } - if (text.endsWith("\n")) { - text = text.substring(0, text.length - 1) - trimEnd += 2 + // 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 + ) { + // 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) + } + }, + 0, text.length, Spannable.SPAN_INCLUSIVE_EXCLUSIVE + ) + output.replace(openIndex, closeIndex, spannableBuilder) } + } + private fun trimSpannable(spannable: SpannableStringBuilder): SpannableStringBuilder { + val trimmedText = spannable.toString() + val trimStart = if (trimmedText.startsWith("\n")) 1 else 0 + val trimEnd = if (trimmedText.length > 1 && trimmedText.endsWith("\n")) 2 else 0 return spannable.delete(0, trimStart).delete(spannable.length - trimEnd, spannable.length) } + /** 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) { + /** + * 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 + imageCenterAlign: Boolean, + customOppiaTagActionListener: CustomOppiaTagActionListener? = null ): HtmlParser { return HtmlParser( urlImageParserFactory, gcsResourceName, entityType, entityId, - imageCenterAlign + imageCenterAlign, + customOppiaTagActionListener ) } } diff --git a/utility/src/main/java/org/oppia/util/parser/LiTagHandler.kt b/utility/src/main/java/org/oppia/util/parser/LiTagHandler.kt deleted file mode 100755 index f526098a8bb..00000000000 --- a/utility/src/main/java/org/oppia/util/parser/LiTagHandler.kt +++ /dev/null @@ -1,37 +0,0 @@ -package org.oppia.util.parser - -import android.text.Editable -import android.text.Html -import android.text.Spannable -import android.text.Spanned -import android.text.style.BulletSpan -import org.xml.sax.XMLReader - -/** - * [Html.TagHandler] implementation that processes
  • tags and creates bullets. - * - * Reference: https://github.com/davidbilik/bullet-span-sample - */ -class LiTagHandler : Html.TagHandler { - /** - * Helper marker class. Based on [Html.fromHtml] implementation. - */ - class Bullet - - override fun handleTag(opening: Boolean, tag: String, output: Editable, xmlReader: XMLReader) { - if (tag == "li" && opening) { - output.setSpan(Bullet(), output.length, output.length, Spannable.SPAN_INCLUSIVE_EXCLUSIVE) - } - if (tag == "li" && !opening) { - output.append("\n") - val lastMark = output.getSpans(0, output.length, Bullet::class.java).lastOrNull() - lastMark?.let { - val start = output.getSpanStart(it) - output.removeSpan(it) - if (start != output.length) { - output.setSpan(BulletSpan(), start, output.length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE) - } - } - } - } -} diff --git a/utility/src/test/java/org/oppia/util/parser/CustomHtmlContentHandlerTest.kt b/utility/src/test/java/org/oppia/util/parser/CustomHtmlContentHandlerTest.kt new file mode 100644 index 00000000000..f2aa34f6eeb --- /dev/null +++ b/utility/src/test/java/org/oppia/util/parser/CustomHtmlContentHandlerTest.kt @@ -0,0 +1,174 @@ +package org.oppia.util.parser + +import android.text.Editable +import android.text.Html +import android.text.Spannable +import android.text.style.BulletSpan +import android.text.style.StyleSpan +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.anyString +import org.mockito.Mockito.verify +import org.mockito.junit.MockitoJUnit +import org.mockito.junit.MockitoRule +import org.robolectric.annotation.LooperMode +import org.xml.sax.Attributes +import kotlin.reflect.KClass + +/** Tests for [CustomHtmlContentHandler]. */ +@RunWith(AndroidJUnit4::class) +@LooperMode(LooperMode.Mode.PAUSED) +class CustomHtmlContentHandlerTest { + @Rule + @JvmField + val mockitoRule: MockitoRule = MockitoJUnit.rule() + + @Mock lateinit var mockImageGetter: Html.ImageGetter + + @Test + fun testParseHtml_emptyString_returnsEmptyString() { + val parsedHtml = + CustomHtmlContentHandler.fromHtml( + html = "", imageGetter = mockImageGetter, customTagHandlers = mapOf() + ) + + assertThat(parsedHtml.length).isEqualTo(0) + } + + @Test + fun testParseHtml_standardBoldHtml_returnsStringWithBoldSpan() { + val parsedHtml = + CustomHtmlContentHandler.fromHtml( + html = "Text", imageGetter = mockImageGetter, customTagHandlers = mapOf() + ) + + assertThat(parsedHtml.toString()).isEqualTo("Text") + assertThat(parsedHtml.getSpansFromWholeString(StyleSpan::class)).hasLength(1) + } + + @Test + fun testParseHtml_withImage_callsImageGetter() { + CustomHtmlContentHandler.fromHtml( + html = "", + imageGetter = mockImageGetter, + customTagHandlers = mapOf() + ) + + verify(mockImageGetter).getDrawable(anyString()) + } + + @Test + fun testParseHtml_withOneCustomTag_handlerIsCalledWithAttributes() { + val fakeTagHandler = FakeTagHandler() + + val parsedHtml = + CustomHtmlContentHandler.fromHtml( + html = "content", + imageGetter = mockImageGetter, + customTagHandlers = mapOf("custom-tag" to fakeTagHandler) + ) + + assertThat(fakeTagHandler.handleTagCalled).isTrue() + assertThat(fakeTagHandler.attributes.getValue("custom-attribute")).isEqualTo("value") + assertThat(parsedHtml.toString()).isEqualTo("content") + } + + @Test + fun testParseHtml_withOneCustomTag_missingHandler_keepsContent() { + val parsedHtml = + CustomHtmlContentHandler.fromHtml( + html = "content", + imageGetter = mockImageGetter, + customTagHandlers = mapOf() + ) + + assertThat(parsedHtml.toString()).isEqualTo("content") + } + + @Test + fun testParseHtml_withOneCustomTag_handlerReplacesText_correctlyUpdatesText() { + val parsedHtml = + CustomHtmlContentHandler.fromHtml( + html = "content", + imageGetter = mockImageGetter, + customTagHandlers = mapOf( + "custom-tag" to ReplacingTagHandler("custom-attribute") + ) + ) + + // Verify that handlers which wish to replace text can successfully do so. + assertThat(parsedHtml.toString()).isEqualTo("value") + } + + @Test + fun testParseHtml_withNestedTags_successfullyParsesBoth() { + val outerFakeTagHandler = FakeTagHandler() + val innerFakeTagHandler = FakeTagHandler() + + val parsedHtml = + CustomHtmlContentHandler.fromHtml( + html = "some other content", + imageGetter = mockImageGetter, + customTagHandlers = mapOf( + "outer-tag" to outerFakeTagHandler, + "inner-tag" to innerFakeTagHandler + ) + ) + + // Verify that both tag handlers are called (showing support for nesting). + assertThat(outerFakeTagHandler.handleTagCalled).isTrue() + assertThat(innerFakeTagHandler.handleTagCalled).isTrue() + assertThat(parsedHtml.toString()).isEqualTo("some other content") + } + + @Test + fun testCustomListElement_betweenParagraphs_parsesCorrectlyIntoBulletSpan() { + val htmlString = "

    Paragraph 1

      Item

    Paragraph 2.

    " + + val parsedHtml = + CustomHtmlContentHandler.fromHtml( + html = htmlString, imageGetter = mockImageGetter, + customTagHandlers = mapOf( + CUSTOM_BULLET_LIST_TAG to BulletTagHandler() + ) + ) + + assertThat(parsedHtml.toString()).isNotEmpty() + assertThat(parsedHtml.getSpansFromWholeString(BulletSpan::class)).hasLength(1) + } + + private fun Spannable.getSpansFromWholeString(spanClass: KClass): Array = + getSpans(/* start= */ 0, /* end= */ length, spanClass.javaObjectType) + + private class FakeTagHandler : CustomHtmlContentHandler.CustomTagHandler { + var handleTagCalled = false + lateinit var attributes: Attributes + + override fun handleTag( + attributes: Attributes, + openIndex: Int, + closeIndex: Int, + output: Editable + ) { + handleTagCalled = true + this.attributes = attributes + } + } + + private class ReplacingTagHandler( + private val attributeTextToReplaceWith: String + ) : CustomHtmlContentHandler.CustomTagHandler { + override fun handleTag( + attributes: Attributes, + openIndex: Int, + closeIndex: Int, + output: Editable + ) { + output.replace(openIndex, closeIndex, attributes.getValue(attributeTextToReplaceWith)) + } + } +}