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 ead83aa985a..2d6bcafaa82 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,14 @@ import dagger.BindsInstance import dagger.Component import org.oppia.app.activity.ActivityComponent import org.oppia.data.backends.gae.NetworkModule +import org.oppia.domain.classify.InteractionsModule +import org.oppia.domain.classify.rules.continueinteraction.ContinueModule +import org.oppia.domain.classify.rules.fractioninput.FractionInputModule +import org.oppia.domain.classify.rules.itemselectioninput.ItemSelectionInputModule +import org.oppia.domain.classify.rules.multiplechoiceinput.MultipleChoiceInputModule +import org.oppia.domain.classify.rules.numberwithunits.NumberWithUnitsRuleModule +import org.oppia.domain.classify.rules.numericinput.NumericInputRuleModule +import org.oppia.domain.classify.rules.textinput.TextInputRuleModule import org.oppia.util.logging.LoggerModule import org.oppia.util.threading.DispatcherModule import javax.inject.Provider @@ -12,7 +20,11 @@ import javax.inject.Singleton /** Root Dagger component for the application. All application-scoped modules should be included in this component. */ @Singleton -@Component(modules = [ApplicationModule::class, DispatcherModule::class, NetworkModule::class, LoggerModule::class]) +@Component(modules = [ + ApplicationModule::class, DispatcherModule::class, NetworkModule::class, LoggerModule::class, + ContinueModule::class, FractionInputModule::class, ItemSelectionInputModule::class, MultipleChoiceInputModule::class, + NumberWithUnitsRuleModule::class, NumericInputRuleModule::class, TextInputRuleModule::class, InteractionsModule::class +]) interface ApplicationComponent { @Component.Builder interface Builder { diff --git a/domain/src/main/java/org/oppia/domain/classify/AnswerClassificationController.kt b/domain/src/main/java/org/oppia/domain/classify/AnswerClassificationController.kt index d6a8b65b8f5..f2f734da486 100644 --- a/domain/src/main/java/org/oppia/domain/classify/AnswerClassificationController.kt +++ b/domain/src/main/java/org/oppia/domain/classify/AnswerClassificationController.kt @@ -1,9 +1,10 @@ package org.oppia.domain.classify +import org.oppia.app.model.AnswerGroup import org.oppia.app.model.Interaction import org.oppia.app.model.InteractionObject import org.oppia.app.model.Outcome -import org.oppia.app.model.State +import java.lang.IllegalStateException import javax.inject.Inject // TODO(#59): Restrict the visibility of this class to only other controllers. @@ -14,66 +15,46 @@ import javax.inject.Inject * * This controller should only be interacted with via background threads. */ -class AnswerClassificationController @Inject constructor() { - // TODO(#114): Add support for classifying answers based on an actual exploration. Also, classify() should take an - // Interaction, not a State. - +class AnswerClassificationController @Inject constructor( + private val interactionClassifiers: Map +) { /** * Classifies the specified answer in the context of the specified [Interaction] and returns the [Outcome] that best * matches the learner's answer. */ - internal fun classify(currentState: State, answer: InteractionObject): Outcome { - return when (currentState.name) { - // Exp 5 - "Welcome!" -> simulateMultipleChoiceForWelcomeStateExp5(currentState, answer) - "What language" -> simulateTextInputForWhatLanguageStateExp5(currentState, answer) - "Numeric input" -> simulateNumericInputForNumericInputStateExp5(currentState, answer) - "Things you can do" -> currentState.interaction.defaultOutcome - // Exp 6 - "First State" -> currentState.interaction.defaultOutcome - "So what can I tell you" -> simulateMultipleChoiceForWelcomeStateExp6(currentState, answer) - "Example1" -> currentState.interaction.defaultOutcome // TextInput with ignored answer. - "Example3" -> currentState.interaction.defaultOutcome - "End Card" -> currentState.interaction.defaultOutcome - else -> throw Exception("Cannot submit answer to unexpected state: ${currentState.name}.") + internal fun classify(interaction: Interaction, answer: InteractionObject): Outcome { + val interactionClassifier = checkNotNull(interactionClassifiers[interaction.id]) { + "Encountered unknown interaction type: ${interaction.id}, expected one of: ${interactionClassifiers.keys}" } + // TODO(#207): Add support for additional classification types. + return classifyAnswer( + answer, interaction.answerGroupsList, interaction.defaultOutcome, interactionClassifier, interaction.id) } - private fun simulateMultipleChoiceForWelcomeStateExp5(currentState: State, answer: InteractionObject): Outcome { - return when { - answer.objectTypeCase != InteractionObject.ObjectTypeCase.NON_NEGATIVE_INT -> - throw Exception("Expected non-negative int answer, not $answer.") - answer.nonNegativeInt == 0 -> currentState.interaction.answerGroupsList[0].outcome - answer.nonNegativeInt == 2 -> currentState.interaction.answerGroupsList[1].outcome - else -> currentState.interaction.defaultOutcome + // Based on the Oppia web version: + // https://github.com/oppia/oppia/blob/edb62f/core/templates/dev/head/pages/exploration-player-page/services/answer-classification.service.ts#L57. + private fun classifyAnswer( + answer: InteractionObject, answerGroups: List, defaultOutcome: Outcome, + interactionClassifier: InteractionClassifier, interactionId: String + ): Outcome { + for (answerGroup in answerGroups) { + for (ruleSpec in answerGroup.ruleSpecsList) { + val ruleClassifier = checkNotNull(interactionClassifier.getRuleClassifier(ruleSpec.ruleType)) { + "Expected interaction $interactionId to have classifier for rule type: ${ruleSpec.ruleType}, but only" + + " has: ${interactionClassifier.getRuleTypes()}" + } + try { + if (ruleClassifier.matches(answer, ruleSpec.inputMap)) { + // Explicit classification matched. + return answerGroup.outcome + } + } catch (e: Exception) { + throw IllegalStateException("Failed when classifying answer $answer for interaction $interactionId", e) + } + } } - } - - private fun simulateTextInputForWhatLanguageStateExp5(currentState: State, answer: InteractionObject): Outcome { - return when { - answer.objectTypeCase != InteractionObject.ObjectTypeCase.NORMALIZED_STRING -> - throw Exception("Expected string answer, not $answer.") - answer.normalizedString.toLowerCase() == "finnish" -> currentState.interaction.getAnswerGroups(6).outcome - else -> currentState.interaction.defaultOutcome - } - } - private fun simulateNumericInputForNumericInputStateExp5(currentState: State, answer: InteractionObject): Outcome { - return when { - answer.objectTypeCase != InteractionObject.ObjectTypeCase.SIGNED_INT -> - throw Exception("Expected signed int answer, not $answer.") - answer.signedInt == 121 -> currentState.interaction.answerGroupsList.first().outcome - else -> currentState.interaction.defaultOutcome - } - } - - private fun simulateMultipleChoiceForWelcomeStateExp6(currentState: State, answer: InteractionObject): Outcome { - return when { - answer.objectTypeCase != InteractionObject.ObjectTypeCase.NON_NEGATIVE_INT -> - throw Exception("Expected non-negative int answer, not $answer.") - answer.nonNegativeInt == 3 -> currentState.interaction.answerGroupsList[1].outcome - answer.nonNegativeInt == 0 -> currentState.interaction.answerGroupsList[2].outcome - else -> currentState.interaction.defaultOutcome - } + // Default outcome classification. + return defaultOutcome } } diff --git a/domain/src/main/java/org/oppia/domain/classify/GenericInteractionClassifier.kt b/domain/src/main/java/org/oppia/domain/classify/GenericInteractionClassifier.kt new file mode 100644 index 00000000000..21fd8aaa3b2 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/GenericInteractionClassifier.kt @@ -0,0 +1,14 @@ +package org.oppia.domain.classify + +/** A general-purpose [InteractionClassifier] that utilizes a Dagger-bound [RuleClassifier] map. */ +internal class GenericInteractionClassifier( + private val ruleClassifiers: Map +): InteractionClassifier { + override fun getRuleTypes(): Set { + return ruleClassifiers.keys + } + + override fun getRuleClassifier(ruleType: String): RuleClassifier? { + return ruleClassifiers[ruleType] + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/InteractionClassifier.kt b/domain/src/main/java/org/oppia/domain/classify/InteractionClassifier.kt new file mode 100644 index 00000000000..5fc1dd8097b --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/InteractionClassifier.kt @@ -0,0 +1,13 @@ +package org.oppia.domain.classify + +/** + * An answer classifier for a specific interaction type. Instances of this classifier should be bound to a map of + * interaction IDs to classifier instances so that they can be used by the [AnswerClassificationController]. + */ +interface InteractionClassifier { + /** Returns a set of rule types that this interaction supports classifying. */ + fun getRuleTypes(): Set + + /** Returns the [RuleClassifier] corresponding to the specified rule type. */ + fun getRuleClassifier(ruleType: String): RuleClassifier? +} diff --git a/domain/src/main/java/org/oppia/domain/classify/InteractionsModule.kt b/domain/src/main/java/org/oppia/domain/classify/InteractionsModule.kt new file mode 100644 index 00000000000..e1779354ace --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/InteractionsModule.kt @@ -0,0 +1,80 @@ +package org.oppia.domain.classify + +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import dagger.multibindings.StringKey +import org.oppia.domain.classify.rules.ContinueRules +import org.oppia.domain.classify.rules.FractionInputRules +import org.oppia.domain.classify.rules.ItemSelectionInputRules +import org.oppia.domain.classify.rules.MultipleChoiceInputRules +import org.oppia.domain.classify.rules.NumberWithUnitsRules +import org.oppia.domain.classify.rules.NumericInputRules +import org.oppia.domain.classify.rules.TextInputRules + +/** Module that provides a map of [InteractionClassifier]s. */ +@Module +class InteractionsModule { + @Provides + @IntoMap + @StringKey("Continue") + fun provideContinueInteractionClassifier( + @ContinueRules ruleClassifiers: Map + ): InteractionClassifier { + return GenericInteractionClassifier(ruleClassifiers) + } + + @Provides + @IntoMap + @StringKey("FractionInput") + fun provideFractionInputInteractionClassifier( + @FractionInputRules ruleClassifiers: Map + ): InteractionClassifier { + return GenericInteractionClassifier(ruleClassifiers) + } + + @Provides + @IntoMap + @StringKey("ItemSelectionInput") + fun provideItemSelectionInputInteractionClassifier( + @ItemSelectionInputRules ruleClassifiers: Map + ): InteractionClassifier { + return GenericInteractionClassifier(ruleClassifiers) + } + + @Provides + @IntoMap + @StringKey("MultipleChoiceInput") + fun provideMultipleChoiceInputInteractionClassifier( + @MultipleChoiceInputRules ruleClassifiers: Map + ): InteractionClassifier { + return GenericInteractionClassifier(ruleClassifiers) + } + + @Provides + @IntoMap + @StringKey("NumberWithUnits") + fun provideNumberWithUnitsInteractionClassifier( + @NumberWithUnitsRules ruleClassifiers: Map + ): InteractionClassifier { + return GenericInteractionClassifier(ruleClassifiers) + } + + @Provides + @IntoMap + @StringKey("NumericInput") + fun provideNumericInputInteractionClassifier( + @NumericInputRules ruleClassifiers: Map + ): InteractionClassifier { + return GenericInteractionClassifier(ruleClassifiers) + } + + @Provides + @IntoMap + @StringKey("TextInput") + fun provideTextInputInteractionClassifier( + @TextInputRules ruleClassifiers: Map + ): InteractionClassifier { + return GenericInteractionClassifier(ruleClassifiers) + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/RuleClassifier.kt b/domain/src/main/java/org/oppia/domain/classify/RuleClassifier.kt new file mode 100644 index 00000000000..729545db581 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/RuleClassifier.kt @@ -0,0 +1,11 @@ +package org.oppia.domain.classify + +import org.oppia.app.model.InteractionObject + +/** An answer classifier for a specific interaction rule. */ +interface RuleClassifier { + /** + * Returns whether the specified answer matches the rule's parameter inputs per this rule's classification strategy. + */ + fun matches(answer: InteractionObject, inputs: Map): Boolean +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/GenericRuleClassifier.kt b/domain/src/main/java/org/oppia/domain/classify/rules/GenericRuleClassifier.kt new file mode 100644 index 00000000000..a8e3a945d33 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/GenericRuleClassifier.kt @@ -0,0 +1,175 @@ +package org.oppia.domain.classify.rules + +import org.oppia.app.model.InteractionObject +import org.oppia.domain.classify.RuleClassifier +import javax.inject.Inject + +/** + * A convenience [RuleClassifier] which performs parameter extraction and sanitation to simplify classifiers, with the + * possible configurations for zero, one, or two parameters with the same or differing types compared to the answer + * being matched. + * + * Child classes must ensure all specified types properly correspond to the type to which the parameter's specified + * [InteractionObject.ObjectTypeCase] also corresponds. + */ +internal class GenericRuleClassifier private constructor( + private val expectedAnswerObjectType: InteractionObject.ObjectTypeCase, + private val orderedExpectedParameterTypes: LinkedHashMap, + private val matcherDelegate: MatcherDelegate +): RuleClassifier { + override fun matches(answer: InteractionObject, inputs: Map): Boolean { + check(answer.objectTypeCase == expectedAnswerObjectType) { + "Expected answer to be of type ${expectedAnswerObjectType.name} not ${answer.objectTypeCase.name}" + } + val parameterInputs = orderedExpectedParameterTypes.toList().map { (parameterName, expectedObjectType) -> + retrieveInputObject(parameterName, expectedObjectType, inputs) + } + return matcherDelegate.matches(answer, parameterInputs) + } + + private fun retrieveInputObject( + parameterName: String, expectedObjectType: InteractionObject.ObjectTypeCase, inputs: Map + ): InteractionObject { + val input = checkNotNull(inputs[parameterName]) { + "Expected classifier inputs to contain parameter with name '$parameterName' but had: ${inputs.keys}" + } + check(input.objectTypeCase == expectedObjectType) { + "Expected input value to be of type ${expectedObjectType.name} not ${input.objectTypeCase.name}" + } + return input + } + + internal interface NoInputInputMatcher { + /** + * Returns whether the validated and extracted answer matches the expectations per the specification of this + * classifier. + */ + fun matches(answer: T): Boolean + } + + internal interface SingleInputMatcher { + /** + * Returns whether the validated and extracted answer matches the single validated and extracted input parameter per + * the specification of this classifier. + */ + fun matches(answer: T, input: T): Boolean + } + + internal interface MultiTypeSingleInputMatcher { + /** + * Returns whether the validated and extracted answer matches the single validated and extracted input parameter per + * the specification of this classifier. + */ + fun matches(answer: AT, input: IT): Boolean + } + + internal interface DoubleInputMatcher { + /** + * Returns whether the validated and extracted answer matches the two validated and extracted input parameters per + * the specification of this classifier. + */ + fun matches(answer: T, firstInput: T, secondInput: T): Boolean + } + + internal sealed class MatcherDelegate { + + abstract fun matches(answer: InteractionObject, inputs: List): Boolean + + internal class NoInputMatcherDelegate( + private val matcher: NoInputInputMatcher, + private val extractObject: (InteractionObject) -> T + ): MatcherDelegate() { + override fun matches(answer: InteractionObject, inputs: List): Boolean { + check(inputs.isEmpty()) + return matcher.matches(extractObject(answer)) + } + } + + internal class SingleInputMatcherDelegate( + private val matcher: SingleInputMatcher, + private val extractObject: (InteractionObject) -> T + ): MatcherDelegate() { + override fun matches(answer: InteractionObject, inputs: List): Boolean { + check(inputs.size == 1) + return matcher.matches(extractObject(answer), extractObject(inputs.first())) + } + } + + internal class MultiTypeSingleInputMatcherDelegate( + private val matcher: MultiTypeSingleInputMatcher, + private val extractAnswerObject: (InteractionObject) -> AT, + private val extractInputObject: (InteractionObject) -> IT + ): MatcherDelegate() { + override fun matches(answer: InteractionObject, inputs: List): Boolean { + check(inputs.size == 1) + return matcher.matches(extractAnswerObject(answer), extractInputObject(inputs.first())) + } + } + + internal class DoubleInputMatcherDelegate( + private val matcher: DoubleInputMatcher, + private val extractObject: (InteractionObject) -> T + ): MatcherDelegate() { + override fun matches(answer: InteractionObject, inputs: List): Boolean { + check(inputs.size == 2) + return matcher.matches(extractObject(answer), extractObject(inputs[0]), extractObject(inputs[1])) + } + } + } + + /** Factory to create new [GenericRuleClassifier]s. */ + internal class Factory @Inject constructor( + private val interactionObjectTypeExtractorRepository: InteractionObjectTypeExtractorRepository + ) { + /** Returns a new [GenericRuleClassifier] for an answer that is not matched to any input values. */ + inline fun createNoInputClassifier( + expectedObjectType: InteractionObject.ObjectTypeCase, matcher: NoInputInputMatcher + ): GenericRuleClassifier { + val objectExtractor = interactionObjectTypeExtractorRepository.getExtractor(expectedObjectType) + return GenericRuleClassifier( + expectedObjectType, LinkedHashMap(), MatcherDelegate.NoInputMatcherDelegate(matcher, objectExtractor)) + } + + /** + * Returns a new [GenericRuleClassifier] for a single input value with the same type as the answer being classified. + */ + inline fun createSingleInputClassifier( + expectedObjectType: InteractionObject.ObjectTypeCase, inputParameterName: String, matcher: SingleInputMatcher + ): GenericRuleClassifier { + val objectExtractor = interactionObjectTypeExtractorRepository.getExtractor(expectedObjectType) + return GenericRuleClassifier( + expectedObjectType, linkedMapOf(inputParameterName to expectedObjectType), + MatcherDelegate.SingleInputMatcherDelegate(matcher, objectExtractor)) + } + + /** + * Returns a new [GenericRuleClassifier] for a single input value that has a different type than the answer being + * classified. + */ + inline fun createMultiTypeSingleInputClassifier( + expectedAnswerObjectType: InteractionObject.ObjectTypeCase, + expectedInputObjectType: InteractionObject.ObjectTypeCase, inputParameterName: String, + matcher: MultiTypeSingleInputMatcher + ): GenericRuleClassifier { + val answerObjectExtractor = interactionObjectTypeExtractorRepository.getExtractor(expectedAnswerObjectType) + val inputObjectExtractor = interactionObjectTypeExtractorRepository.getExtractor(expectedInputObjectType) + return GenericRuleClassifier( + expectedAnswerObjectType, linkedMapOf(inputParameterName to expectedInputObjectType), + MatcherDelegate.MultiTypeSingleInputMatcherDelegate(matcher, answerObjectExtractor, inputObjectExtractor)) + } + + /** Returns a new [GenericRuleClassifier] for two input values of the same type as the answer it classifies. */ + inline fun createDoubleInputClassifier( + expectedObjectType: InteractionObject.ObjectTypeCase, firstInputParameterName: String, + secondInputParameterName: String, matcher: DoubleInputMatcher + ): GenericRuleClassifier { + val objectExtractor = interactionObjectTypeExtractorRepository.getExtractor(expectedObjectType) + val parameters = linkedMapOf( + firstInputParameterName to expectedObjectType, + secondInputParameterName to expectedObjectType + ) + return GenericRuleClassifier( + expectedObjectType, parameters, MatcherDelegate.DoubleInputMatcherDelegate(matcher, objectExtractor)) + } + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/InteractionObjectTypeExtractorRepository.kt b/domain/src/main/java/org/oppia/domain/classify/rules/InteractionObjectTypeExtractorRepository.kt new file mode 100644 index 00000000000..c1901a448f0 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/InteractionObjectTypeExtractorRepository.kt @@ -0,0 +1,65 @@ +package org.oppia.domain.classify.rules + +import org.oppia.app.model.InteractionObject +import org.oppia.app.model.InteractionObject.ObjectTypeCase +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.reflect.KClass + +/** + * A convenience utility for mapping [ObjectTypeCase] constants to methods that can extract the corresponding value + * from instances of [InteractionObject]. This utility is preferred over manually maintaining the mapping since it's a + * single source of truth that allows code requiring an extraction method to rely only on the type enum rather than + * managing the enum->method relationship directly. + */ +@Singleton // Avoid recomputing the mapping multiple times. +internal class InteractionObjectTypeExtractorRepository @Inject constructor() { + private val extractors: Map> by lazy { + computeExtractorMap() + } + + /** + * Returns a function that can be used to extract an element of type [T] from an [InteractionObject] where [T] + * corresponds to the specified [ObjectTypeCase]. Note that referencing the wrong type will result in a runtime type + * check failure. + */ + inline fun getExtractor(objectTypeCase: ObjectTypeCase): (InteractionObject) -> T { + val (extractionType, genericExtractor) = checkNotNull(extractors[objectTypeCase]) { + "No mapping found for interaction object type: ${objectTypeCase.name}. Was it not yet registered?" + } + check(extractionType.java.isAssignableFrom(T::class.java)) { + "Trying to retrieve incompatible extractor type: ${T::class.java} expected: ${extractionType.java}" + } + // Note that a new conversion method is returned since it's not clear whether it's safe to simply cast the + // extractor. + return { interactionObject -> + genericExtractor(interactionObject) as T // The runtime check above makes this cast safe. + } + } + + internal data class ExtractorMapping( + val extractionType: KClass, + val genericExtractor: (InteractionObject) -> T + ) + + private companion object { + private fun computeExtractorMap(): Map> { + return mapOf( + ObjectTypeCase.NORMALIZED_STRING to createMapping(InteractionObject::getNormalizedString), + ObjectTypeCase.SIGNED_INT to createMapping(InteractionObject::getSignedInt), + ObjectTypeCase.NON_NEGATIVE_INT to createMapping(InteractionObject::getNonNegativeInt), + ObjectTypeCase.REAL to createMapping(InteractionObject::getReal), + ObjectTypeCase.BOOL_VALUE to createMapping(InteractionObject::getBoolValue), + ObjectTypeCase.NUMBER_WITH_UNITS to createMapping(InteractionObject::getNumberWithUnits), + ObjectTypeCase.SET_OF_HTML_STRING to createMapping(InteractionObject::getSetOfHtmlString), + ObjectTypeCase.FRACTION to createMapping(InteractionObject::getFraction) + ) + } + + private inline fun createMapping( + noinline extractor: (InteractionObject) -> T + ): ExtractorMapping { + return ExtractorMapping(T::class, extractor) + } + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/RuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/RuleClassifierProvider.kt new file mode 100644 index 00000000000..e73ac1d2920 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/RuleClassifierProvider.kt @@ -0,0 +1,9 @@ +package org.oppia.domain.classify.rules + +import org.oppia.domain.classify.RuleClassifier + +/** Provider for [RuleClassifier]s. */ +interface RuleClassifierProvider { + /** Returns a new [RuleClassifier]. */ + fun createRuleClassifier(): RuleClassifier +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/RuleQualifiers.kt b/domain/src/main/java/org/oppia/domain/classify/rules/RuleQualifiers.kt new file mode 100644 index 00000000000..73d6f475a2a --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/RuleQualifiers.kt @@ -0,0 +1,24 @@ +package org.oppia.domain.classify.rules + +import javax.inject.Qualifier + +/** Corresponds to [org.oppia.domain.classify.RuleClassifier]s that can be used by the continue interaction. */ +@Qualifier annotation class ContinueRules + +/** Corresponds to [org.oppia.domain.classify.RuleClassifier]s that can be used by the fraction input interaction. */ +@Qualifier annotation class FractionInputRules + +/** Corresponds to [org.oppia.domain.classify.RuleClassifier]s that can be used by the item selection interaction. */ +@Qualifier annotation class ItemSelectionInputRules + +/** Corresponds to [org.oppia.domain.classify.RuleClassifier]s that can be used by the multiple choice interaction. */ +@Qualifier annotation class MultipleChoiceInputRules + +/** Corresponds to [org.oppia.domain.classify.RuleClassifier]s that can be used by the number with units interaction. */ +@Qualifier annotation class NumberWithUnitsRules + +/** Corresponds to [org.oppia.domain.classify.RuleClassifier]s that can be used by the text input interaction. */ +@Qualifier annotation class TextInputRules + +/** Corresponds to [org.oppia.domain.classify.RuleClassifier]s that can be used by the numeric input interaction. */ +@Qualifier annotation class NumericInputRules diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/continueinteraction/ContinueModule.kt b/domain/src/main/java/org/oppia/domain/classify/rules/continueinteraction/ContinueModule.kt new file mode 100644 index 00000000000..1f1e5f84cb0 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/continueinteraction/ContinueModule.kt @@ -0,0 +1,13 @@ +package org.oppia.domain.classify.rules.continueinteraction + +import dagger.Module +import dagger.multibindings.Multibinds +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.ContinueRules + +/** Module that binds rule classifiers corresponding to the continue interaction. */ +@Module +abstract class ContinueModule { + // No rules are bound since tapping the continue button for this interaction should always succeed. + @Multibinds @ContinueRules abstract fun provideContinueInteractionRules(): Map +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputHasDenominatorEqualToRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputHasDenominatorEqualToRuleClassifierProvider.kt new file mode 100644 index 00000000000..7e91899ac34 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputHasDenominatorEqualToRuleClassifierProvider.kt @@ -0,0 +1,29 @@ +package org.oppia.domain.classify.rules.fractioninput + +import org.oppia.app.model.Fraction +import org.oppia.app.model.InteractionObject +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.GenericRuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether a fraction has a denominator equal to the specified value per the + * fraction input interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/FractionInput/directives/fraction-input-rules.service.ts#L55 + */ +internal class FractionInputHasDenominatorEqualToRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.MultiTypeSingleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createMultiTypeSingleInputClassifier( + InteractionObject.ObjectTypeCase.FRACTION, InteractionObject.ObjectTypeCase.NON_NEGATIVE_INT, "x", this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: Fraction, input: Int): Boolean { + return answer.denominator == input + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputHasFractionalPartExactlyEqualToRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputHasFractionalPartExactlyEqualToRuleClassifierProvider.kt new file mode 100644 index 00000000000..e7f798f69d7 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputHasFractionalPartExactlyEqualToRuleClassifierProvider.kt @@ -0,0 +1,28 @@ +package org.oppia.domain.classify.rules.fractioninput + +import org.oppia.app.model.Fraction +import org.oppia.app.model.InteractionObject +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import org.oppia.domain.classify.rules.GenericRuleClassifier +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether a fraction has a fractional part exactly equal to the fractional + * part of an input fraction per the fraction input interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/FractionInput/directives/fraction-input-rules.service.ts#L61 + */ +internal class FractionInputHasFractionalPartExactlyEqualToRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.SingleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createSingleInputClassifier(InteractionObject.ObjectTypeCase.FRACTION, "f", this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: Fraction, input: Fraction): Boolean { + return answer.numerator == input.numerator && answer.denominator == input.denominator + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputHasIntegerPartEqualToRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputHasIntegerPartEqualToRuleClassifierProvider.kt new file mode 100644 index 00000000000..d4a2aab4f13 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputHasIntegerPartEqualToRuleClassifierProvider.kt @@ -0,0 +1,29 @@ +package org.oppia.domain.classify.rules.fractioninput + +import org.oppia.app.model.Fraction +import org.oppia.app.model.InteractionObject +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.GenericRuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether a fraction has an integer part equal to the specified value per the + * fraction input interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/FractionInput/directives/fraction-input-rules.service.ts#L48 + */ +internal class FractionInputHasIntegerPartEqualToRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.MultiTypeSingleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createMultiTypeSingleInputClassifier( + InteractionObject.ObjectTypeCase.FRACTION, InteractionObject.ObjectTypeCase.NON_NEGATIVE_INT, "x", this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: Fraction, input: Int): Boolean { + return answer.wholeNumber == input + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputHasNoFractionalPartRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputHasNoFractionalPartRuleClassifierProvider.kt new file mode 100644 index 00000000000..f34d57d1edd --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputHasNoFractionalPartRuleClassifierProvider.kt @@ -0,0 +1,28 @@ +package org.oppia.domain.classify.rules.fractioninput + +import org.oppia.app.model.Fraction +import org.oppia.app.model.InteractionObject +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.GenericRuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether a fraction has no fractional part per the fraction input + * interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/FractionInput/directives/fraction-input-rules.service.ts#L58 + */ +internal class FractionInputHasNoFractionalPartRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.NoInputInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createNoInputClassifier(InteractionObject.ObjectTypeCase.FRACTION, this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: Fraction): Boolean { + return answer.numerator == 0 + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputHasNumeratorEqualToRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputHasNumeratorEqualToRuleClassifierProvider.kt new file mode 100644 index 00000000000..09ef5abd53d --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputHasNumeratorEqualToRuleClassifierProvider.kt @@ -0,0 +1,29 @@ +package org.oppia.domain.classify.rules.fractioninput + +import org.oppia.app.model.Fraction +import org.oppia.app.model.InteractionObject +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.GenericRuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether a fraction has a numerator equal to the specified value per the + * fraction input interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/FractionInput/directives/fraction-input-rules.service.ts#L52 + */ +internal class FractionInputHasNumeratorEqualToRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.MultiTypeSingleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createMultiTypeSingleInputClassifier( + InteractionObject.ObjectTypeCase.FRACTION, InteractionObject.ObjectTypeCase.NON_NEGATIVE_INT, "x", this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: Fraction, input: Int): Boolean { + return answer.numerator == input + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputIsEquivalentToAndInSimplestFormRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputIsEquivalentToAndInSimplestFormRuleClassifierProvider.kt new file mode 100644 index 00000000000..2374f752504 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputIsEquivalentToAndInSimplestFormRuleClassifierProvider.kt @@ -0,0 +1,31 @@ +package org.oppia.domain.classify.rules.fractioninput + +import org.oppia.app.model.Fraction +import org.oppia.app.model.InteractionObject +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import org.oppia.domain.classify.rules.GenericRuleClassifier +import org.oppia.domain.util.approximatelyEquals +import org.oppia.domain.util.toFloat +import org.oppia.domain.util.toSimplestForm +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether a fraction is both effectively equal to another fraction and equal + * in its simplest form per the fraction input interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/FractionInput/directives/fraction-input-rules.service.ts#L32 + */ +internal class FractionInputIsEquivalentToAndInSimplestFormRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.SingleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createSingleInputClassifier(InteractionObject.ObjectTypeCase.FRACTION, "f", this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: Fraction, input: Fraction): Boolean { + return answer.toFloat().approximatelyEquals(input.toFloat()) && answer == input.toSimplestForm() + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputIsEquivalentToRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputIsEquivalentToRuleClassifierProvider.kt new file mode 100644 index 00000000000..bbd16b8b619 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputIsEquivalentToRuleClassifierProvider.kt @@ -0,0 +1,30 @@ +package org.oppia.domain.classify.rules.fractioninput + +import org.oppia.app.model.Fraction +import org.oppia.app.model.InteractionObject +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import org.oppia.domain.classify.rules.GenericRuleClassifier +import org.oppia.domain.util.approximatelyEquals +import org.oppia.domain.util.toFloat +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether a fraction effectively equal to another fraction per the fraction + * input interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/FractionInput/directives/fraction-input-rules.service.ts#L29 + */ +internal class FractionInputIsEquivalentToRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.SingleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createSingleInputClassifier(InteractionObject.ObjectTypeCase.FRACTION, "f", this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: Fraction, input: Fraction): Boolean { + return answer.toFloat().approximatelyEquals(input.toFloat()) + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputIsExactlyEqualToRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputIsExactlyEqualToRuleClassifierProvider.kt new file mode 100644 index 00000000000..ff0e73e6992 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputIsExactlyEqualToRuleClassifierProvider.kt @@ -0,0 +1,28 @@ +package org.oppia.domain.classify.rules.fractioninput + +import org.oppia.app.model.Fraction +import org.oppia.app.model.InteractionObject +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import org.oppia.domain.classify.rules.GenericRuleClassifier +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether a fraction is exactly equal to another fraction per the fraction + * input interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/FractionInput/directives/fraction-input-rules.service.ts#L38 + */ +internal class FractionInputIsExactlyEqualToRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.SingleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createSingleInputClassifier(InteractionObject.ObjectTypeCase.FRACTION, "f", this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: Fraction, input: Fraction): Boolean { + return answer == input + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputIsGreaterThanRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputIsGreaterThanRuleClassifierProvider.kt new file mode 100644 index 00000000000..48168244325 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputIsGreaterThanRuleClassifierProvider.kt @@ -0,0 +1,29 @@ +package org.oppia.domain.classify.rules.fractioninput + +import org.oppia.app.model.Fraction +import org.oppia.app.model.InteractionObject +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import org.oppia.domain.classify.rules.GenericRuleClassifier +import org.oppia.domain.util.toFloat +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether a fraction is greater than another fraction per the fraction input + * interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/FractionInput/directives/fraction-input-rules.service.ts#L45 + */ +internal class FractionInputIsGreaterThanRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.SingleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createSingleInputClassifier(InteractionObject.ObjectTypeCase.FRACTION, "f", this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: Fraction, input: Fraction): Boolean { + return answer.toFloat() > input.toFloat() + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputIsLessThanRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputIsLessThanRuleClassifierProvider.kt new file mode 100644 index 00000000000..4833dd53279 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputIsLessThanRuleClassifierProvider.kt @@ -0,0 +1,29 @@ +package org.oppia.domain.classify.rules.fractioninput + +import org.oppia.app.model.Fraction +import org.oppia.app.model.InteractionObject +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import org.oppia.domain.classify.rules.GenericRuleClassifier +import org.oppia.domain.util.toFloat +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether a fraction is less than another fraction per the fraction input + * interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/FractionInput/directives/fraction-input-rules.service.ts#L42 + */ +internal class FractionInputIsLessThanRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.SingleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createSingleInputClassifier(InteractionObject.ObjectTypeCase.FRACTION, "f", this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: Fraction, input: Fraction): Boolean { + return answer.toFloat() < input.toFloat() + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputModule.kt b/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputModule.kt new file mode 100644 index 00000000000..3ef57b4be5c --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputModule.kt @@ -0,0 +1,92 @@ +package org.oppia.domain.classify.rules.fractioninput + +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import dagger.multibindings.StringKey +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.FractionInputRules + +/** Module that binds rule classifiers corresponding to the fraction input interaction. */ +@Module +class FractionInputModule { + @Provides + @IntoMap + @StringKey("HasDenominatorEqualTo") + @FractionInputRules + internal fun provideFractionInputHasDenominatorEqualToRuleClassifier( + classifierProvider: FractionInputHasDenominatorEqualToRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() + + @Provides + @IntoMap + @StringKey("HasFractionalPartExactlyEqualTo") + @FractionInputRules + internal fun provideFractionInputHasFractionalPartExactlyEqualToRuleClassifier( + classifierProvider: FractionInputHasFractionalPartExactlyEqualToRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() + + @Provides + @IntoMap + @StringKey("HasIntegerPartEqualTo") + @FractionInputRules + internal fun provideFractionInputHasIntegerPartEqualToRuleClassifier( + classifierProvider: FractionInputHasIntegerPartEqualToRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() + + @Provides + @IntoMap + @StringKey("HasNoFractionalPart") + @FractionInputRules + internal fun provideFractionInputHasNoFractionalPartRuleClassifier( + classifierProvider: FractionInputHasNoFractionalPartRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() + + @Provides + @IntoMap + @StringKey("HasNumeratorEqualTo") + @FractionInputRules + internal fun provideFractionInputHasNumeratorEqualToRuleClassifier( + classifierProvider: FractionInputHasNumeratorEqualToRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() + + @Provides + @IntoMap + @StringKey("IsEquivalentToAndInSimplestForm") + @FractionInputRules + internal fun provideFractionInputIsEquivalentToAndInSimplestFormRuleClassifier( + classifierProvider: FractionInputIsEquivalentToAndInSimplestFormRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() + + @Provides + @IntoMap + @StringKey("IsEquivalentTo") + @FractionInputRules + internal fun provideFractionInputIsEquivalentToRuleClassifier( + classifierProvider: FractionInputIsEquivalentToRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() + + @Provides + @IntoMap + @StringKey("IsExactlyEqualTo") + @FractionInputRules + internal fun provideFractionInputIsExactlyEqualToRuleClassifier( + classifierProvider: FractionInputIsExactlyEqualToRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() + + @Provides + @IntoMap + @StringKey("IsGreaterThan") + @FractionInputRules + internal fun provideFractionInputIsGreaterThanRuleClassifier( + classifierProvider: FractionInputIsGreaterThanRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() + + @Provides + @IntoMap + @StringKey("IsLessThan") + @FractionInputRules + internal fun provideFractionInputIsLessThanRuleClassifier( + classifierProvider: FractionInputIsLessThanRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/itemselectioninput/ItemSelectionInputContainsAtLeastOneOfRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/itemselectioninput/ItemSelectionInputContainsAtLeastOneOfRuleClassifierProvider.kt new file mode 100644 index 00000000000..b85a2de1672 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/itemselectioninput/ItemSelectionInputContainsAtLeastOneOfRuleClassifierProvider.kt @@ -0,0 +1,28 @@ +package org.oppia.domain.classify.rules.itemselectioninput + +import org.oppia.app.model.InteractionObject +import org.oppia.app.model.StringList +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import org.oppia.domain.classify.rules.GenericRuleClassifier +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether an item selection answer contains at least one of a set of options + * per the item selection input interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/ItemSelectionInput/directives/item-selection-input-rules.service.ts#L32 + */ +internal class ItemSelectionInputContainsAtLeastOneOfRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.SingleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createSingleInputClassifier(InteractionObject.ObjectTypeCase.SET_OF_HTML_STRING, "x", this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: StringList, input: StringList): Boolean { + return answer.htmlList.toSet().intersect(input.htmlList).isNotEmpty() + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/itemselectioninput/ItemSelectionInputDoesNotContainAtLeastOneOfRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/itemselectioninput/ItemSelectionInputDoesNotContainAtLeastOneOfRuleClassifierProvider.kt new file mode 100644 index 00000000000..de2e7a100ac --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/itemselectioninput/ItemSelectionInputDoesNotContainAtLeastOneOfRuleClassifierProvider.kt @@ -0,0 +1,28 @@ +package org.oppia.domain.classify.rules.itemselectioninput + +import org.oppia.app.model.InteractionObject +import org.oppia.app.model.StringList +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import org.oppia.domain.classify.rules.GenericRuleClassifier +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether an item selection answer does not contain any of a set of options + * per the item selection input interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/ItemSelectionInput/directives/item-selection-input-rules.service.ts#L41 + */ +internal class ItemSelectionInputDoesNotContainAtLeastOneOfRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.SingleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createSingleInputClassifier(InteractionObject.ObjectTypeCase.SET_OF_HTML_STRING, "x", this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: StringList, input: StringList): Boolean { + return answer.htmlList.toSet().intersect(input.htmlList).isEmpty() + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/itemselectioninput/ItemSelectionInputEqualsRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/itemselectioninput/ItemSelectionInputEqualsRuleClassifierProvider.kt new file mode 100644 index 00000000000..c789313c626 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/itemselectioninput/ItemSelectionInputEqualsRuleClassifierProvider.kt @@ -0,0 +1,28 @@ +package org.oppia.domain.classify.rules.itemselectioninput + +import org.oppia.app.model.InteractionObject +import org.oppia.app.model.StringList +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import org.oppia.domain.classify.rules.GenericRuleClassifier +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether an item selection answer has exactly the same elements as an input + * set per the item selection input interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/ItemSelectionInput/directives/item-selection-input-rules.service.ts#L24 + */ +internal class ItemSelectionInputEqualsRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.SingleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createSingleInputClassifier(InteractionObject.ObjectTypeCase.SET_OF_HTML_STRING, "x", this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: StringList, input: StringList): Boolean { + return answer.htmlList.toSet() == input.htmlList.toSet() + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/itemselectioninput/ItemSelectionInputIsProperSubsetOfRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/itemselectioninput/ItemSelectionInputIsProperSubsetOfRuleClassifierProvider.kt new file mode 100644 index 00000000000..74f6db2c4a1 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/itemselectioninput/ItemSelectionInputIsProperSubsetOfRuleClassifierProvider.kt @@ -0,0 +1,30 @@ +package org.oppia.domain.classify.rules.itemselectioninput + +import org.oppia.app.model.InteractionObject +import org.oppia.app.model.StringList +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import org.oppia.domain.classify.rules.GenericRuleClassifier +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether an item selection answer is a proper subset of an input set of + * values per the item selection input interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/ItemSelectionInput/directives/item-selection-input-rules.service.ts#L50 + */ +internal class ItemSelectionInputIsProperSubsetOfRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.SingleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createSingleInputClassifier(InteractionObject.ObjectTypeCase.SET_OF_HTML_STRING, "x", this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: StringList, input: StringList): Boolean { + val answerSet = answer.htmlList.toSet() + val inputSet = input.htmlList.toSet() + return answerSet.size < inputSet.size && inputSet.containsAll(answerSet) + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/itemselectioninput/ItemSelectionInputModule.kt b/domain/src/main/java/org/oppia/domain/classify/rules/itemselectioninput/ItemSelectionInputModule.kt new file mode 100644 index 00000000000..9b39da06b19 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/itemselectioninput/ItemSelectionInputModule.kt @@ -0,0 +1,44 @@ +package org.oppia.domain.classify.rules.itemselectioninput + +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import dagger.multibindings.StringKey +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.ItemSelectionInputRules + +/** Module that binds rule classifiers corresponding to the item selection choice input interaction. */ +@Module +class ItemSelectionInputModule { + @Provides + @IntoMap + @StringKey("ContainsAtLeastOneOf") + @ItemSelectionInputRules + internal fun provideItemSelectionInputContainsAtLeastOneOfRuleClassifier( + classifierProvider: ItemSelectionInputContainsAtLeastOneOfRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() + + @Provides + @IntoMap + @StringKey("DoesNotContainAtLeastOneOf") + @ItemSelectionInputRules + internal fun provideItemSelectionInputDoesNotContainAtLeastOneOfRuleClassifier( + classifierProvider: ItemSelectionInputDoesNotContainAtLeastOneOfRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() + + @Provides + @IntoMap + @StringKey("Equals") + @ItemSelectionInputRules + internal fun provideItemSelectionInputEqualsRuleClassifier( + classifierProvider: ItemSelectionInputEqualsRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() + + @Provides + @IntoMap + @StringKey("IsProperSubsetOf") + @ItemSelectionInputRules + internal fun provideItemSelectionInputIsProperSubsetOfRuleClassifier( + classifierProvider: ItemSelectionInputIsProperSubsetOfRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/multiplechoiceinput/MultipleChoiceInputEqualsRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/multiplechoiceinput/MultipleChoiceInputEqualsRuleClassifierProvider.kt new file mode 100644 index 00000000000..3396b00cd30 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/multiplechoiceinput/MultipleChoiceInputEqualsRuleClassifierProvider.kt @@ -0,0 +1,27 @@ +package org.oppia.domain.classify.rules.multiplechoiceinput + +import org.oppia.app.model.InteractionObject +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import org.oppia.domain.classify.rules.GenericRuleClassifier +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether a multiple choice answer matches a specific option per the multiple + * choice input interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/MultipleChoiceInput/directives/multiple-choice-input-rules.service.ts#L21 + */ +internal class MultipleChoiceInputEqualsRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.SingleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createSingleInputClassifier(InteractionObject.ObjectTypeCase.NON_NEGATIVE_INT, "x", this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: Int, input: Int): Boolean { + return answer == input + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/multiplechoiceinput/MultipleChoiceInputModule.kt b/domain/src/main/java/org/oppia/domain/classify/rules/multiplechoiceinput/MultipleChoiceInputModule.kt new file mode 100644 index 00000000000..ee6c0e22839 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/multiplechoiceinput/MultipleChoiceInputModule.kt @@ -0,0 +1,20 @@ +package org.oppia.domain.classify.rules.multiplechoiceinput + +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import dagger.multibindings.StringKey +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.MultipleChoiceInputRules + +/** Module that binds rule classifiers corresponding to the multiple choice input interaction. */ +@Module +class MultipleChoiceInputModule { + @Provides + @IntoMap + @StringKey("Equals") + @MultipleChoiceInputRules + internal fun provideMultipleChoiceInputEqualsRuleClassifier( + classifierProvider: MultipleChoiceInputEqualsRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/numberwithunits/NumberWithUnitsIsEqualToRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/numberwithunits/NumberWithUnitsIsEqualToRuleClassifierProvider.kt new file mode 100644 index 00000000000..b427217bf7e --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/numberwithunits/NumberWithUnitsIsEqualToRuleClassifierProvider.kt @@ -0,0 +1,52 @@ +package org.oppia.domain.classify.rules.numberwithunits + +import org.oppia.app.model.Fraction +import org.oppia.app.model.InteractionObject +import org.oppia.app.model.NumberWithUnits +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import org.oppia.domain.classify.rules.GenericRuleClassifier +import org.oppia.domain.util.approximatelyEquals +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether two numbers with units are equal per the numbers with units + * interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/NumberWithUnits/directives/number-with-units-rules.service.ts#L34 + */ +internal class NumberWithUnitsIsEqualToRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.SingleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createSingleInputClassifier(InteractionObject.ObjectTypeCase.NUMBER_WITH_UNITS, "f", this) + } + + // TODO(#209): Determine whether additional sanitation of the input is necessary here. + // TODO(#210): Add tests for this classifier. + override fun matches(answer: NumberWithUnits, input: NumberWithUnits): Boolean { + // The number types must match. + if (answer.numberTypeCase != input.numberTypeCase) { + return false + } + // Units must match, but in different orders is fine. + if (answer.unitList.toSet() != input.unitList.toSet()) { + return false + } + // Otherwise, verify the value itself matches. + return when (answer.numberTypeCase) { + NumberWithUnits.NumberTypeCase.REAL -> realMatches(answer.real, input.real) + NumberWithUnits.NumberTypeCase.FRACTION -> fractionMatches(answer.fraction, input.fraction) + else -> false // Unknown type never matches. + } + } + + private fun realMatches(answer: Float, input: Float): Boolean { + return input.approximatelyEquals(answer) + } + + private fun fractionMatches(answer: Fraction, input: Fraction): Boolean { + return input == answer + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/numberwithunits/NumberWithUnitsIsEquivalentToRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/numberwithunits/NumberWithUnitsIsEquivalentToRuleClassifierProvider.kt new file mode 100644 index 00000000000..5bc71da1957 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/numberwithunits/NumberWithUnitsIsEquivalentToRuleClassifierProvider.kt @@ -0,0 +1,45 @@ +package org.oppia.domain.classify.rules.numberwithunits + +import org.oppia.app.model.InteractionObject +import org.oppia.app.model.NumberWithUnits +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import org.oppia.domain.classify.rules.GenericRuleClassifier +import org.oppia.domain.util.approximatelyEquals +import org.oppia.domain.util.toFloat +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether two numbers with units are effectively equal per the number with + * units interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/NumberWithUnits/directives/number-with-units-rules.service.ts#L48 + */ +internal class NumberWithUnitsIsEquivalentToRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.SingleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createSingleInputClassifier(InteractionObject.ObjectTypeCase.NUMBER_WITH_UNITS, "f", this) + } + + // TODO(#209): Determine whether additional normalization of the input is necessary here. + // TODO(#210): Add tests for this classifier. + override fun matches(answer: NumberWithUnits, input: NumberWithUnits): Boolean { + // Units must match, but in different orders is fine. + if (answer.unitList.toSet() != input.unitList.toSet()) { + return false + } + + // Verify the float version of the value for approximate comparison. + return extractRealValue(input).approximatelyEquals(extractRealValue(answer)) + } + + private fun extractRealValue(number: NumberWithUnits): Float { + return when (number.numberTypeCase) { + NumberWithUnits.NumberTypeCase.REAL -> number.real + NumberWithUnits.NumberTypeCase.FRACTION -> number.fraction.toFloat() + else -> throw IllegalArgumentException("Invalid number type: ${number.numberTypeCase.name}") + } + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/numberwithunits/NumberWithUnitsRuleModule.kt b/domain/src/main/java/org/oppia/domain/classify/rules/numberwithunits/NumberWithUnitsRuleModule.kt new file mode 100644 index 00000000000..076a57cf3f3 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/numberwithunits/NumberWithUnitsRuleModule.kt @@ -0,0 +1,28 @@ +package org.oppia.domain.classify.rules.numberwithunits + +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import dagger.multibindings.StringKey +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.NumberWithUnitsRules + +/** Module that binds rule classifiers corresponding to the number with units interaction. */ +@Module +class NumberWithUnitsRuleModule { + @Provides + @IntoMap + @StringKey("IsEqualTo") + @NumberWithUnitsRules + internal fun provideNumberWithUnitsIsEqualToRuleClassifier( + classifierProvider: NumberWithUnitsIsEqualToRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() + + @Provides + @IntoMap + @StringKey("IsEquivalentTo") + @NumberWithUnitsRules + internal fun provideNumberWithUnitsIsEquivalentToRuleClassifier( + classifierProvider: NumberWithUnitsIsEquivalentToRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputEqualsRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputEqualsRuleClassifierProvider.kt new file mode 100644 index 00000000000..b732043ddf7 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputEqualsRuleClassifierProvider.kt @@ -0,0 +1,27 @@ +package org.oppia.domain.classify.rules.numericinput + +import org.oppia.app.model.InteractionObject +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import org.oppia.domain.classify.rules.GenericRuleClassifier +import org.oppia.domain.util.approximatelyEquals +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether two integers are equal per the numeric input interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/NumericInput/directives/numeric-input-rules.service.ts#L21 + */ +internal class NumericInputEqualsRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.SingleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createSingleInputClassifier(InteractionObject.ObjectTypeCase.REAL, "x", this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: Double, input: Double): Boolean { + return input.approximatelyEquals(answer) + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputIsGreaterThanOrEqualToRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputIsGreaterThanOrEqualToRuleClassifierProvider.kt new file mode 100644 index 00000000000..a984e2f2698 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputIsGreaterThanOrEqualToRuleClassifierProvider.kt @@ -0,0 +1,26 @@ +package org.oppia.domain.classify.rules.numericinput + +import org.oppia.app.model.InteractionObject +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import org.oppia.domain.classify.rules.GenericRuleClassifier +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether an answer is >= an input per the numeric input interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/NumericInput/directives/numeric-input-rules.service.ts#L33 + */ +internal class NumericInputIsGreaterThanOrEqualToRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.SingleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createSingleInputClassifier(InteractionObject.ObjectTypeCase.REAL, "x", this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: Double, input: Double): Boolean { + return answer >= input + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputIsGreaterThanRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputIsGreaterThanRuleClassifierProvider.kt new file mode 100644 index 00000000000..d030bbc15c4 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputIsGreaterThanRuleClassifierProvider.kt @@ -0,0 +1,26 @@ +package org.oppia.domain.classify.rules.numericinput + +import org.oppia.app.model.InteractionObject +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import org.oppia.domain.classify.rules.GenericRuleClassifier +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether an answer is > an input per the numeric input interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/NumericInput/directives/numeric-input-rules.service.ts#L27 + */ +internal class NumericInputIsGreaterThanRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.SingleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createSingleInputClassifier(InteractionObject.ObjectTypeCase.REAL, "x", this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: Double, input: Double): Boolean { + return answer > input + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputIsInclusivelyBetweenRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputIsInclusivelyBetweenRuleClassifierProvider.kt new file mode 100644 index 00000000000..b23944bad2b --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputIsInclusivelyBetweenRuleClassifierProvider.kt @@ -0,0 +1,27 @@ +package org.oppia.domain.classify.rules.numericinput + +import org.oppia.app.model.InteractionObject +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.GenericRuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether an answer is >= one input and <= another input value per the + * numeric input interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/NumericInput/directives/numeric-input-rules.service.ts#L36 + */ +internal class NumericInputIsInclusivelyBetweenRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.DoubleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createDoubleInputClassifier(InteractionObject.ObjectTypeCase.REAL, "a", "b", this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: Double, firstInput: Double, secondInput: Double): Boolean { + return answer in firstInput..secondInput + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputIsLessThanOrEqualToRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputIsLessThanOrEqualToRuleClassifierProvider.kt new file mode 100644 index 00000000000..edbf7cfdacc --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputIsLessThanOrEqualToRuleClassifierProvider.kt @@ -0,0 +1,26 @@ +package org.oppia.domain.classify.rules.numericinput + +import org.oppia.app.model.InteractionObject +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import org.oppia.domain.classify.rules.GenericRuleClassifier +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether an answer is <= an input per the numeric input interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/NumericInput/directives/numeric-input-rules.service.ts#L30 + */ +internal class NumericInputIsLessThanOrEqualToRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.SingleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createSingleInputClassifier(InteractionObject.ObjectTypeCase.REAL, "x", this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: Double, input: Double): Boolean { + return answer <= input + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputIsLessThanRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputIsLessThanRuleClassifierProvider.kt new file mode 100644 index 00000000000..33b982c7798 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputIsLessThanRuleClassifierProvider.kt @@ -0,0 +1,26 @@ +package org.oppia.domain.classify.rules.numericinput + +import org.oppia.app.model.InteractionObject +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import org.oppia.domain.classify.rules.GenericRuleClassifier +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether an answer is < an input per the numeric input interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/NumericInput/directives/numeric-input-rules.service.ts#L24 + */ +internal class NumericInputIsLessThanRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.SingleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createSingleInputClassifier(InteractionObject.ObjectTypeCase.REAL, "x", this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: Double, input: Double): Boolean { + return answer < input + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputIsWithinToleranceRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputIsWithinToleranceRuleClassifierProvider.kt new file mode 100644 index 00000000000..4352992a94a --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputIsWithinToleranceRuleClassifierProvider.kt @@ -0,0 +1,27 @@ +package org.oppia.domain.classify.rules.numericinput + +import org.oppia.app.model.InteractionObject +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.GenericRuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether an answer is within the input-specified tolerance of another input + * value per the numeric input interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/NumericInput/directives/numeric-input-rules.service.ts#L41 + */ +internal class NumericInputIsWithinToleranceRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.DoubleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createDoubleInputClassifier(InteractionObject.ObjectTypeCase.REAL, "x", "tol", this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: Double, firstInput: Double, secondInput: Double): Boolean { + return answer in (firstInput - secondInput)..(firstInput + secondInput) + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputRuleModule.kt b/domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputRuleModule.kt new file mode 100644 index 00000000000..eb47261a5c0 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputRuleModule.kt @@ -0,0 +1,68 @@ +package org.oppia.domain.classify.rules.numericinput + +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import dagger.multibindings.StringKey +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.NumericInputRules + +/** Module that binds rule classifiers corresponding to the numeric input interaction. */ +@Module +class NumericInputRuleModule { + @Provides + @IntoMap + @StringKey("Equals") + @NumericInputRules + internal fun provideNumericInputEqualsRuleClassifier( + classifierProvider: NumericInputEqualsRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() + + @Provides + @IntoMap + @StringKey("IsGreaterThanOrEqualTo") + @NumericInputRules + internal fun provideNumericInputIsGreaterThanOrEqualToRuleClassifier( + classifierProvider: NumericInputIsGreaterThanOrEqualToRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() + + @Provides + @IntoMap + @StringKey("IsGreaterThan") + @NumericInputRules + internal fun provideNumericInputIsGreaterThanRuleClassifier( + classifierProvider: NumericInputIsGreaterThanRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() + + @Provides + @IntoMap + @StringKey("IsInclusivelyBetween") + @NumericInputRules + internal fun provideNumericInputIsInclusivelyBetweenRuleClassifier( + classifierProvider: NumericInputIsInclusivelyBetweenRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() + + @Provides + @IntoMap + @StringKey("IsLessThanOrEqualTo") + @NumericInputRules + internal fun provideNumericInputIsLessThanOrEqualToRuleClassifier( + classifierProvider: NumericInputIsLessThanOrEqualToRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() + + @Provides + @IntoMap + @StringKey("IsLessThan") + @NumericInputRules + internal fun provideNumericInputIsLessThanRuleClassifier( + classifierProvider: NumericInputIsLessThanRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() + + @Provides + @IntoMap + @StringKey("IsWithinTolerance") + @NumericInputRules + internal fun provideNumericInputIsWithinToleranceRuleClassifier( + classifierProvider: NumericInputIsWithinToleranceRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/textinput/TextInputCaseSensitiveEqualsRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/textinput/TextInputCaseSensitiveEqualsRuleClassifierProvider.kt new file mode 100644 index 00000000000..3c66af19b42 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/textinput/TextInputCaseSensitiveEqualsRuleClassifierProvider.kt @@ -0,0 +1,28 @@ +package org.oppia.domain.classify.rules.textinput + +import org.oppia.app.model.InteractionObject +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import org.oppia.domain.classify.rules.GenericRuleClassifier +import org.oppia.domain.util.normalizeWhitespace +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether two strings are case sensitively equal per the text input + * interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/TextInput/directives/text-input-rules.service.ts#L59 + */ +internal class TextInputCaseSensitiveEqualsRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.SingleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createSingleInputClassifier(InteractionObject.ObjectTypeCase.NORMALIZED_STRING, "x", this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: String, input: String): Boolean { + return answer.normalizeWhitespace() == input.normalizeWhitespace() + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/textinput/TextInputContainsRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/textinput/TextInputContainsRuleClassifierProvider.kt new file mode 100644 index 00000000000..429e120522d --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/textinput/TextInputContainsRuleClassifierProvider.kt @@ -0,0 +1,28 @@ +package org.oppia.domain.classify.rules.textinput + +import org.oppia.app.model.InteractionObject +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import org.oppia.domain.classify.rules.GenericRuleClassifier +import org.oppia.domain.util.normalizeWhitespace +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether an answer contains the rule's input per the text input interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/TextInput/directives/text-input-rules.service.ts#L70 + */ +internal class TextInputContainsRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.SingleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createSingleInputClassifier(InteractionObject.ObjectTypeCase.NORMALIZED_STRING, "x", this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: String, input: String): Boolean { + return answer.normalizeWhitespace().contains(input.normalizeWhitespace()) + } +} + diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/textinput/TextInputEqualsRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/textinput/TextInputEqualsRuleClassifierProvider.kt new file mode 100644 index 00000000000..cb85790cd17 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/textinput/TextInputEqualsRuleClassifierProvider.kt @@ -0,0 +1,27 @@ +package org.oppia.domain.classify.rules.textinput + +import org.oppia.app.model.InteractionObject +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import org.oppia.domain.classify.rules.GenericRuleClassifier +import org.oppia.domain.util.normalizeWhitespace +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether two strings are equal per the text input interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/TextInput/directives/text-input-rules.service.ts#L24 + */ +internal class TextInputEqualsRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.SingleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createSingleInputClassifier(InteractionObject.ObjectTypeCase.NORMALIZED_STRING, "x", this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: String, input: String): Boolean { + return answer.normalizeWhitespace().equals(input.normalizeWhitespace(), /* ignoreCase= */ true) + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/textinput/TextInputFuzzyEqualsRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/textinput/TextInputFuzzyEqualsRuleClassifierProvider.kt new file mode 100644 index 00000000000..26753c7cd85 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/textinput/TextInputFuzzyEqualsRuleClassifierProvider.kt @@ -0,0 +1,60 @@ +package org.oppia.domain.classify.rules.textinput + +import org.oppia.app.model.InteractionObject +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import org.oppia.domain.classify.rules.GenericRuleClassifier +import org.oppia.domain.util.normalizeWhitespace +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether two strings are fuzzily equal per the text input interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/TextInput/directives/text-input-rules.service.ts#L29. + */ +internal class TextInputFuzzyEqualsRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.SingleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createSingleInputClassifier(InteractionObject.ObjectTypeCase.NORMALIZED_STRING, "x", this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: String, input: String): Boolean { + val lowerInput = input.normalizeWhitespace().toLowerCase() + val lowerAnswer = answer.normalizeWhitespace().toLowerCase() + if (lowerInput == lowerAnswer) { + return true + } + + val editDistance = mutableListOf>() + for (i in 0..lowerInput.length) { + editDistance.add(mutableListOf(i)) + } + for (j in 1..lowerAnswer.length) { + editDistance[0].add(j) + } + + for (i in 1..lowerInput.length) { + for (j in 1..lowerAnswer.length) { + check(j == editDistance[i].size) { + "Something went wrong. Expected index $j to not yet be in array: ${editDistance[i]}" + } + if (lowerInput[i - 1] == lowerAnswer[j - 1]) { + editDistance[i].add(editDistance[i - 1][j - 1]) + } else { + editDistance[i].add( + minOf( + editDistance[i - 1][j - 1], + editDistance[i][j - 1], + editDistance[i - 1][j] + ) + 1 + ) + } + } + } + return editDistance[lowerInput.length][lowerAnswer.length] == 1 + } +} + diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/textinput/TextInputRuleModule.kt b/domain/src/main/java/org/oppia/domain/classify/rules/textinput/TextInputRuleModule.kt new file mode 100644 index 00000000000..cfb9cd46cfa --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/textinput/TextInputRuleModule.kt @@ -0,0 +1,52 @@ +package org.oppia.domain.classify.rules.textinput + +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import dagger.multibindings.StringKey +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.TextInputRules + +/** Module that binds rule classifiers corresponding to the text input interaction. */ +@Module +class TextInputRuleModule { + @Provides + @IntoMap + @StringKey("CaseSensitiveEquals") + @TextInputRules + internal fun provideTextInputCaseSensitiveEqualsRuleClassifier( + classifierProvider: TextInputCaseSensitiveEqualsRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() + + @Provides + @IntoMap + @StringKey("Contains") + @TextInputRules + internal fun provideTextInputContainsRuleClassifier( + classifierProvider: TextInputContainsRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() + + @Provides + @IntoMap + @StringKey("Equals") + @TextInputRules + internal fun provideTextInputEqualsRuleClassifier( + classifierProvider: TextInputEqualsRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() + + @Provides + @IntoMap + @StringKey("FuzzyEquals") + @TextInputRules + internal fun provideTextInputFuzzyEqualsRuleClassifier( + classifierProvider: TextInputFuzzyEqualsRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() + + @Provides + @IntoMap + @StringKey("StartsWith") + @TextInputRules + internal fun provideTextInputStartsWithRuleClassifier( + classifierProvider: TextInputStartsWithRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/textinput/TextInputStartsWithRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/textinput/TextInputStartsWithRuleClassifierProvider.kt new file mode 100644 index 00000000000..7a8d73fbefe --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/textinput/TextInputStartsWithRuleClassifierProvider.kt @@ -0,0 +1,28 @@ +package org.oppia.domain.classify.rules.textinput + +import org.oppia.app.model.InteractionObject +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import org.oppia.domain.classify.rules.GenericRuleClassifier +import org.oppia.domain.util.normalizeWhitespace +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether an answer starts with the rule's input per the text input + * interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/TextInput/directives/text-input-rules.service.ts#L64 + */ +internal class TextInputStartsWithRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.SingleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createSingleInputClassifier(InteractionObject.ObjectTypeCase.NORMALIZED_STRING, "x", this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: String, input: String): Boolean { + return answer.normalizeWhitespace().startsWith(input.normalizeWhitespace()) + } +} diff --git a/domain/src/main/java/org/oppia/domain/exploration/ExplorationProgressController.kt b/domain/src/main/java/org/oppia/domain/exploration/ExplorationProgressController.kt index f439d5463cd..8269b6b073f 100644 --- a/domain/src/main/java/org/oppia/domain/exploration/ExplorationProgressController.kt +++ b/domain/src/main/java/org/oppia/domain/exploration/ExplorationProgressController.kt @@ -124,17 +124,22 @@ class ExplorationProgressController @Inject constructor( explorationProgress.advancePlayStageTo(PlayStage.SUBMITTING_ANSWER) asyncDataSubscriptionManager.notifyChangeAsync(CURRENT_STATE_DATA_PROVIDER_ID) - val topPendingState = explorationProgress.stateDeck.getPendingTopState() - val outcome = answerClassificationController.classify(topPendingState, answer) - val answerOutcome = explorationProgress.stateGraph.computeAnswerOutcomeForResult(topPendingState, outcome) - explorationProgress.stateDeck.submitAnswer(answer, answerOutcome.feedback) - // Follow the answer's outcome to another part of the graph if it's different. - if (answerOutcome.destinationCase == AnswerOutcome.DestinationCase.STATE_NAME) { - explorationProgress.stateDeck.pushState(explorationProgress.stateGraph.getState(answerOutcome.stateName)) + lateinit var answerOutcome: AnswerOutcome + try { + val topPendingState = explorationProgress.stateDeck.getPendingTopState() + val outcome = answerClassificationController.classify(topPendingState.interaction, answer) + answerOutcome = explorationProgress.stateGraph.computeAnswerOutcomeForResult(topPendingState, outcome) + explorationProgress.stateDeck.submitAnswer(answer, answerOutcome.feedback) + // Follow the answer's outcome to another part of the graph if it's different. + if (answerOutcome.destinationCase == AnswerOutcome.DestinationCase.STATE_NAME) { + explorationProgress.stateDeck.pushState(explorationProgress.stateGraph.getState(answerOutcome.stateName)) + } + } finally { + // Ensure that the user always returns to the VIEWING_STATE stage to avoid getting stuck in an 'always + // submitting answer' situation. This can specifically happen if answer classification throws an exception. + explorationProgress.advancePlayStageTo(PlayStage.VIEWING_STATE) } - // Return to viewing the state. - explorationProgress.advancePlayStageTo(PlayStage.VIEWING_STATE) asyncDataSubscriptionManager.notifyChangeAsync(CURRENT_STATE_DATA_PROVIDER_ID) return MutableLiveData(AsyncResult.success(answerOutcome)) diff --git a/domain/src/main/java/org/oppia/domain/exploration/ExplorationRetriever.kt b/domain/src/main/java/org/oppia/domain/exploration/ExplorationRetriever.kt index 65f41980fed..2962eaa8276 100644 --- a/domain/src/main/java/org/oppia/domain/exploration/ExplorationRetriever.kt +++ b/domain/src/main/java/org/oppia/domain/exploration/ExplorationRetriever.kt @@ -177,19 +177,15 @@ class ExplorationRetriever @Inject constructor(private val context: Context) { return ruleSpecList } for (i in 0 until ruleSpecJson.length()) { - ruleSpecList.add( - RuleSpec.newBuilder() - .setRuleType( - ruleSpecJson.getJSONObject(i).getString("rule_type") - ) - .setInput( - createInputFromJson( - ruleSpecJson.getJSONObject(i).getJSONObject("inputs"), - /* keyName= */"x", interactionId - ) - ) - .build() - ) + val ruleSpecBuilder = RuleSpec.newBuilder() + ruleSpecBuilder.ruleType = ruleSpecJson.getJSONObject(i).getString("rule_type") + val inputsJson = ruleSpecJson.getJSONObject(i).getJSONObject("inputs") + val inputKeysIterator = inputsJson.keys() + while (inputKeysIterator.hasNext()) { + val inputName = inputKeysIterator.next() + ruleSpecBuilder.putInput(inputName, createInputFromJson(inputsJson, inputName, interactionId)) + } + ruleSpecList.add(ruleSpecBuilder.build()) } return ruleSpecList } @@ -258,7 +254,7 @@ class ExplorationRetriever @Inject constructor(private val context: Context) { val stringList = mutableListOf() if (value[0] is String) { stringList.addAll(value as List) - return StringList.newBuilder().addAllStringList(stringList).build() + return StringList.newBuilder().addAllHtml(stringList).build() } return StringList.getDefaultInstance() } diff --git a/domain/src/main/java/org/oppia/domain/util/FloatExtensions.kt b/domain/src/main/java/org/oppia/domain/util/FloatExtensions.kt new file mode 100644 index 00000000000..886dfa1eeda --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/util/FloatExtensions.kt @@ -0,0 +1,15 @@ +package org.oppia.domain.util + +import kotlin.math.abs + +private const val EPSILON = 1e-5 + +/** Returns whether this float approximately equals another based on a consistent epsilon value. */ +fun Float.approximatelyEquals(other: Float): Boolean { + return abs(this - other) < EPSILON +} + +/** Returns whether this double approximately equals another based on a consistent epsilon value. */ +fun Double.approximatelyEquals(other: Double): Boolean { + return abs(this - other) < EPSILON +} diff --git a/domain/src/main/java/org/oppia/domain/util/FractionExtensions.kt b/domain/src/main/java/org/oppia/domain/util/FractionExtensions.kt new file mode 100644 index 00000000000..ec336625886 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/util/FractionExtensions.kt @@ -0,0 +1,29 @@ +package org.oppia.domain.util + +import org.oppia.app.model.Fraction + +/** + * Returns a float version of this fraction. + * + * See: https://github.com/oppia/oppia/blob/37285a/core/templates/dev/head/domain/objects/FractionObjectFactory.ts#L73. + */ +fun Fraction.toFloat(): Float { + val totalParts = ((wholeNumber * denominator) + numerator).toFloat() + val floatVal = totalParts / denominator.toFloat() + return if (isNegative) -floatVal else floatVal +} + +/** + * Returns this fraction in its most simplified form. + * + * See: https://github.com/oppia/oppia/blob/37285a/core/templates/dev/head/domain/objects/FractionObjectFactory.ts#L83. + */ +fun Fraction.toSimplestForm(): Fraction { + val commonDenominator = gcd(numerator, denominator) + return toBuilder().setNumerator(numerator / commonDenominator).setDenominator(denominator / commonDenominator).build() +} + +/** Returns the greatest common divisor between two integers. */ +private fun gcd(x: Int, y: Int): Int { + return if (y == 0) x else gcd(y, x % y) +} diff --git a/domain/src/main/java/org/oppia/domain/util/StringExtensions.kt b/domain/src/main/java/org/oppia/domain/util/StringExtensions.kt new file mode 100644 index 00000000000..cc3f660fb84 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/util/StringExtensions.kt @@ -0,0 +1,9 @@ +package org.oppia.domain.util + +/** + * Normalizes whitespace in the specified string in a way consistent with Oppia web: + * https://github.com/oppia/oppia/blob/392323/core/templates/dev/head/filters/string-utility-filters/normalize-whitespace.filter.ts. + */ +fun String.normalizeWhitespace(): String { + return trim().replace(Regex.fromLiteral("\\s{2,}"), " ") +} diff --git a/domain/src/test/java/org/oppia/domain/classify/AnswerClassificationControllerTest.kt b/domain/src/test/java/org/oppia/domain/classify/AnswerClassificationControllerTest.kt index 690388c65a7..81796e86877 100644 --- a/domain/src/test/java/org/oppia/domain/classify/AnswerClassificationControllerTest.kt +++ b/domain/src/test/java/org/oppia/domain/classify/AnswerClassificationControllerTest.kt @@ -9,39 +9,100 @@ import dagger.BindsInstance import dagger.Component import dagger.Module import dagger.Provides +import org.junit.Assert.fail import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.oppia.app.model.AnswerGroup +import org.oppia.app.model.Fraction import org.oppia.app.model.Interaction import org.oppia.app.model.InteractionObject +import org.oppia.app.model.NumberUnit +import org.oppia.app.model.NumberWithUnits import org.oppia.app.model.Outcome -import org.oppia.app.model.State +import org.oppia.app.model.RuleSpec +import org.oppia.app.model.StringList import org.oppia.app.model.SubtitledHtml +import org.oppia.domain.classify.rules.continueinteraction.ContinueModule +import org.oppia.domain.classify.rules.fractioninput.FractionInputModule +import org.oppia.domain.classify.rules.itemselectioninput.ItemSelectionInputModule +import org.oppia.domain.classify.rules.multiplechoiceinput.MultipleChoiceInputModule +import org.oppia.domain.classify.rules.numberwithunits.NumberWithUnitsRuleModule +import org.oppia.domain.classify.rules.numericinput.NumericInputRuleModule +import org.oppia.domain.classify.rules.textinput.TextInputRuleModule import org.robolectric.annotation.Config import javax.inject.Inject import javax.inject.Singleton +import kotlin.reflect.KClass +import kotlin.reflect.full.cast + +// For context: +// https://github.com/oppia/oppia/blob/37285a/extensions/interactions/Continue/directives/oppia-interactive-continue.directive.ts. +private const val DEFAULT_CONTINUE_INTERACTION_TEXT_ANSWER = "Please continue." /** Tests for [AnswerClassificationController]. */ @RunWith(AndroidJUnit4::class) @Config(manifest = Config.NONE) class AnswerClassificationControllerTest { - private val TEST_STRING_ANSWER = InteractionObject.newBuilder().setNormalizedString("Some value").build() - private val TEST_INT_2_ANSWER = InteractionObject.newBuilder().setNonNegativeInt(1).build() - private val TEST_SIGNED_INT_121_ANSWER = InteractionObject.newBuilder().setSignedInt(121).build() - - private val OUTCOME_0 = Outcome.newBuilder() - .setDestStateName("First state") - .setFeedback(SubtitledHtml.newBuilder().setContentId("content_id_0").setHtml("Feedback 1")) - .build() - private val OUTCOME_1 = Outcome.newBuilder() - .setDestStateName("Second state") - .setFeedback(SubtitledHtml.newBuilder().setContentId("content_id_1").setHtml("Feedback 2")) - .build() - private val OUTCOME_2 = Outcome.newBuilder() - .setDestStateName("Third state") - .setFeedback(SubtitledHtml.newBuilder().setContentId("content_id_2").setHtml("Feedback 3")) - .build() + companion object { + private val TEST_STRING_0 = InteractionObject.newBuilder().setNormalizedString("Test string 0").build() + private val TEST_STRING_1 = InteractionObject.newBuilder().setNormalizedString("Test string 1").build() + + private val TEST_FRACTION_0 = InteractionObject.newBuilder() + .setFraction( + Fraction.newBuilder() + .setNumerator(1) + .setDenominator(2) + ) + .build() + private val TEST_FRACTION_1 = InteractionObject.newBuilder() + .setFraction( + Fraction.newBuilder() + .setIsNegative(true) + .setWholeNumber(5) + .setNumerator(1) + .setDenominator(2) + ) + .build() + + private val TEST_ITEM_SELECTION_SET_0 = InteractionObject.newBuilder() + .setSetOfHtmlString(StringList.newBuilder().addHtml("Elem 1").addHtml("Elem 2")) + .build() + private val TEST_ITEM_SELECTION_SET_1 = InteractionObject.newBuilder() + .setSetOfHtmlString(StringList.newBuilder().addHtml("Elem 0").addHtml("Elem 2").addHtml("Elem 3")) + .build() + + private val TEST_MULTIPLE_CHOICE_OPTION_0 = InteractionObject.newBuilder().setNonNegativeInt(0).build() + private val TEST_MULTIPLE_CHOICE_OPTION_1 = InteractionObject.newBuilder().setNonNegativeInt(1).build() + + private val TEST_NUMBER_WITH_UNITS_0 = InteractionObject.newBuilder() + .setNumberWithUnits(NumberWithUnits.newBuilder().setReal(1.0f).addUnit(NumberUnit.newBuilder().setUnit("cm"))) + .build() + private val TEST_NUMBER_WITH_UNITS_1 = InteractionObject.newBuilder() + .setNumberWithUnits(NumberWithUnits.newBuilder().setReal(1.0f).addUnit(NumberUnit.newBuilder().setUnit("m"))) + .build() + + private val TEST_NUMBER_0 = InteractionObject.newBuilder().setReal(1.0).build() + private val TEST_NUMBER_1 = InteractionObject.newBuilder().setReal(-3.5).build() + + private val CONTINUE_ANSWER = InteractionObject.newBuilder() + .setNormalizedString(DEFAULT_CONTINUE_INTERACTION_TEXT_ANSWER) + .build() + + private val DEFAULT_OUTCOME = Outcome.newBuilder() + .setDestStateName("Default state dest") + .setFeedback(SubtitledHtml.newBuilder().setContentId("content_id_def").setHtml("Default feedback.")) + .build() + + private val OUTCOME_0 = Outcome.newBuilder() + .setDestStateName("First state") + .setFeedback(SubtitledHtml.newBuilder().setContentId("content_id_0").setHtml("Feedback 1")) + .build() + private val OUTCOME_1 = Outcome.newBuilder() + .setDestStateName("Second state") + .setFeedback(SubtitledHtml.newBuilder().setContentId("content_id_1").setHtml("Feedback 2")) + .build() + } @Inject lateinit var answerClassificationController: AnswerClassificationController @@ -52,53 +113,338 @@ class AnswerClassificationControllerTest { } @Test - fun testClassify_testInteraction_withOnlyDefaultOutcome_returnsDefaultOutcome() { + fun testClassify_forUnknownInteraction_throwsException() { + val interaction = Interaction.getDefaultInstance() + + val exception = assertThrows(IllegalStateException::class) { + answerClassificationController.classify(interaction, TEST_STRING_0) + } + + assertThat(exception).hasMessageThat().contains("Encountered unknown interaction type") + } + + @Test + fun testClassify_forUnknownRuleType_throwsException() { val interaction = Interaction.newBuilder() - .setDefaultOutcome(OUTCOME_0) + .setId("TextInput") + .addAnswerGroups(AnswerGroup.newBuilder().addRuleSpecs(RuleSpec.getDefaultInstance())) .build() - val state = createTestState("Things you can do", interaction) - val outcome = answerClassificationController.classify(state, TEST_STRING_ANSWER) + val exception = assertThrows(IllegalStateException::class) { + answerClassificationController.classify(interaction, TEST_STRING_0) + } + assertThat(exception).hasMessageThat().contains("Expected interaction TextInput to have classifier for rule type") + } + + @Test + fun testClassify_forNoAnswerGroups_returnsFeedbackAndDestOfDefaultOutcome() { + val interaction = Interaction.newBuilder() + .setId("TextInput") + .setDefaultOutcome(DEFAULT_OUTCOME) + .build() + + val outcome = answerClassificationController.classify(interaction, TEST_STRING_0) + + assertThat(outcome).isEqualTo(DEFAULT_OUTCOME) + } + + @Test + fun testClassify_forOneAnswerGroup_oneRuleSpec_doesNotMatch_returnsDefaultOutcome() { + val interaction = Interaction.newBuilder() + .setId("TextInput") + .setDefaultOutcome(DEFAULT_OUTCOME) + .addAnswerGroups(AnswerGroup.newBuilder() + .addRuleSpecs(RuleSpec.newBuilder().putInput("x", TEST_STRING_1).setRuleType("Equals")) + .setOutcome(OUTCOME_0)) + .build() + + val outcome = answerClassificationController.classify(interaction, TEST_STRING_0) + + // The test string does not match the rule spec. + assertThat(outcome).isEqualTo(DEFAULT_OUTCOME) + } + + @Test + fun testClassify_forContinueInteraction_returnsDefaultOutcome() { + val interaction = Interaction.newBuilder() + .setId("Continue") + .setDefaultOutcome(DEFAULT_OUTCOME) + .build() + + val outcome = answerClassificationController.classify(interaction, CONTINUE_ANSWER) + + // The continue interaction always returns the default outcome because it has no rule classifiers. + assertThat(outcome).isEqualTo(DEFAULT_OUTCOME) + } + + @Test + fun testClassify_forFractionInput_matches_returnAnswerGroup() { + val interaction = Interaction.newBuilder() + .setId("FractionInput") + .addAnswerGroups(AnswerGroup.newBuilder() + .addRuleSpecs(RuleSpec.newBuilder().setRuleType("IsEquivalentTo").putInput("f", TEST_FRACTION_0)) + .setOutcome(OUTCOME_0)) + .setDefaultOutcome(DEFAULT_OUTCOME) + .build() + + val outcome = answerClassificationController.classify(interaction, TEST_FRACTION_0) + + // The first group should match. + assertThat(outcome).isEqualTo(OUTCOME_0) + } + + @Test + fun testClassify_forFractionInput_doesNotMatch_returnDefaultOutcome() { + val interaction = Interaction.newBuilder() + .setId("FractionInput") + .addAnswerGroups(AnswerGroup.newBuilder() + .addRuleSpecs(RuleSpec.newBuilder().setRuleType("IsEquivalentTo").putInput("f", TEST_FRACTION_0)) + .setOutcome(OUTCOME_0)) + .setDefaultOutcome(DEFAULT_OUTCOME) + .build() + + val outcome = answerClassificationController.classify(interaction, TEST_FRACTION_1) + + // The default outcome should be returned since the answer didn't match. + assertThat(outcome).isEqualTo(DEFAULT_OUTCOME) + } + + @Test + fun testClassify_forItemSelectionInput_matches_returnAnswerGroup() { + val interaction = Interaction.newBuilder() + .setId("ItemSelectionInput") + .addAnswerGroups(AnswerGroup.newBuilder() + .addRuleSpecs(RuleSpec.newBuilder().setRuleType("Equals").putInput("x", TEST_ITEM_SELECTION_SET_0)) + .setOutcome(OUTCOME_0)) + .setDefaultOutcome(DEFAULT_OUTCOME) + .build() + + val outcome = answerClassificationController.classify(interaction, TEST_ITEM_SELECTION_SET_0) + + // The first group should match. assertThat(outcome).isEqualTo(OUTCOME_0) } @Test - fun testClassify_testInteraction_withMultipleOutcomes_wrongAnswer_returnsDefaultOutcome() { + fun testClassify_forItemSelectionInput_doesNotMatch_returnDefaultOutcome() { + val interaction = Interaction.newBuilder() + .setId("ItemSelectionInput") + .addAnswerGroups(AnswerGroup.newBuilder() + .addRuleSpecs(RuleSpec.newBuilder().setRuleType("Equals").putInput("x", TEST_ITEM_SELECTION_SET_0)) + .setOutcome(OUTCOME_0)) + .setDefaultOutcome(DEFAULT_OUTCOME) + .build() + + val outcome = answerClassificationController.classify(interaction, TEST_ITEM_SELECTION_SET_1) + + // The default outcome should be returned since the answer didn't match. + assertThat(outcome).isEqualTo(DEFAULT_OUTCOME) + } + + @Test + fun testClassify_forMultipleChoiceInput_matches_returnAnswerGroup() { val interaction = Interaction.newBuilder() - .setDefaultOutcome(OUTCOME_1) - .addAnswerGroups(AnswerGroup.newBuilder().setOutcome(OUTCOME_2)) + .setId("MultipleChoiceInput") + .addAnswerGroups(AnswerGroup.newBuilder() + .addRuleSpecs(RuleSpec.newBuilder().setRuleType("Equals").putInput("x", TEST_MULTIPLE_CHOICE_OPTION_0)) + .setOutcome(OUTCOME_0)) + .setDefaultOutcome(DEFAULT_OUTCOME) .build() - val state = createTestState("Welcome!", interaction) - val outcome = answerClassificationController.classify(state, TEST_INT_2_ANSWER) + val outcome = answerClassificationController.classify(interaction, TEST_MULTIPLE_CHOICE_OPTION_0) + // The first group should match. + assertThat(outcome).isEqualTo(OUTCOME_0) + } + + @Test + fun testClassify_forMultipleChoiceInput_doesNotMatch_returnDefaultOutcome() { + val interaction = Interaction.newBuilder() + .setId("MultipleChoiceInput") + .addAnswerGroups(AnswerGroup.newBuilder() + .addRuleSpecs(RuleSpec.newBuilder().setRuleType("Equals").putInput("x", TEST_MULTIPLE_CHOICE_OPTION_0)) + .setOutcome(OUTCOME_0)) + .setDefaultOutcome(DEFAULT_OUTCOME) + .build() + + val outcome = answerClassificationController.classify(interaction, TEST_MULTIPLE_CHOICE_OPTION_1) + + // The default outcome should be returned since the answer didn't match. + assertThat(outcome).isEqualTo(DEFAULT_OUTCOME) + } + + @Test + fun testClassify_forNumberWithUnits_matches_returnAnswerGroup() { + val interaction = Interaction.newBuilder() + .setId("NumberWithUnits") + .addAnswerGroups(AnswerGroup.newBuilder() + .addRuleSpecs(RuleSpec.newBuilder().setRuleType("IsEqualTo").putInput("f", TEST_NUMBER_WITH_UNITS_0)) + .setOutcome(OUTCOME_0)) + .setDefaultOutcome(DEFAULT_OUTCOME) + .build() + + val outcome = answerClassificationController.classify(interaction, TEST_NUMBER_WITH_UNITS_0) + + // The first group should match. + assertThat(outcome).isEqualTo(OUTCOME_0) + } + + @Test + fun testClassify_forNumberWithUnits_doesNotMatch_returnDefaultOutcome() { + val interaction = Interaction.newBuilder() + .setId("NumberWithUnits") + .addAnswerGroups(AnswerGroup.newBuilder() + .addRuleSpecs(RuleSpec.newBuilder().setRuleType("IsEqualTo").putInput("f", TEST_NUMBER_WITH_UNITS_0)) + .setOutcome(OUTCOME_0)) + .setDefaultOutcome(DEFAULT_OUTCOME) + .build() + + val outcome = answerClassificationController.classify(interaction, TEST_NUMBER_WITH_UNITS_1) + + // The default outcome should be returned since the answer didn't match. + assertThat(outcome).isEqualTo(DEFAULT_OUTCOME) + } + + @Test + fun testClassify_forNumericInput_matches_returnAnswerGroup() { + val interaction = Interaction.newBuilder() + .setId("NumericInput") + .addAnswerGroups(AnswerGroup.newBuilder() + .addRuleSpecs(RuleSpec.newBuilder().setRuleType("Equals").putInput("x", TEST_NUMBER_0)) + .setOutcome(OUTCOME_0)) + .setDefaultOutcome(DEFAULT_OUTCOME) + .build() + + val outcome = answerClassificationController.classify(interaction, TEST_NUMBER_0) + + // The first group should match. + assertThat(outcome).isEqualTo(OUTCOME_0) + } + + @Test + fun testClassify_forNumericInput_doesNotMatch_returnDefaultOutcome() { + val interaction = Interaction.newBuilder() + .setId("NumericInput") + .addAnswerGroups(AnswerGroup.newBuilder() + .addRuleSpecs(RuleSpec.newBuilder().setRuleType("Equals").putInput("x", TEST_NUMBER_0)) + .setOutcome(OUTCOME_0)) + .setDefaultOutcome(DEFAULT_OUTCOME) + .build() + + val outcome = answerClassificationController.classify(interaction, TEST_NUMBER_1) + + // The default outcome should be returned since the answer didn't match. + assertThat(outcome).isEqualTo(DEFAULT_OUTCOME) + } + + @Test + fun testClassify_forTextInput_matches_returnAnswerGroup() { + val interaction = Interaction.newBuilder() + .setId("TextInput") + .addAnswerGroups(AnswerGroup.newBuilder() + .addRuleSpecs(RuleSpec.newBuilder().setRuleType("Equals").putInput("x", TEST_STRING_0)) + .setOutcome(OUTCOME_0)) + .setDefaultOutcome(DEFAULT_OUTCOME) + .build() + + val outcome = answerClassificationController.classify(interaction, TEST_STRING_0) + + // The first group should match. + assertThat(outcome).isEqualTo(OUTCOME_0) + } + + @Test + fun testClassify_forTextInput_doesNotMatch_returnDefaultOutcome() { + val interaction = Interaction.newBuilder() + .setId("TextInput") + .addAnswerGroups(AnswerGroup.newBuilder() + .addRuleSpecs(RuleSpec.newBuilder().setRuleType("Equals").putInput("x", TEST_STRING_0)) + .setOutcome(OUTCOME_0)) + .setDefaultOutcome(DEFAULT_OUTCOME) + .build() + + val outcome = answerClassificationController.classify(interaction, TEST_STRING_1) + + // The default outcome should be returned since the answer didn't match. + assertThat(outcome).isEqualTo(DEFAULT_OUTCOME) + } + + @Test + fun testClassify_multipleAnswerGroups_matchesOneRuleSpec_returnsAnswerGroupOutcome() { + val interaction = Interaction.newBuilder() + .setId("TextInput") + .addAnswerGroups(AnswerGroup.newBuilder() + .addRuleSpecs(RuleSpec.newBuilder().setRuleType("Equals").putInput("x", TEST_STRING_0)) + .setOutcome(OUTCOME_0)) + .addAnswerGroups(AnswerGroup.newBuilder() + .addRuleSpecs(RuleSpec.newBuilder().setRuleType("Equals").putInput("x", TEST_STRING_1)) + .setOutcome(OUTCOME_1)) + .setDefaultOutcome(DEFAULT_OUTCOME) + .build() + + val outcome = answerClassificationController.classify(interaction, TEST_STRING_1) + + // The outcome of the singly matched answer group should be returned. assertThat(outcome).isEqualTo(OUTCOME_1) } @Test - fun testClassify_afterPreviousInteraction_returnsDefaultOutcomeOfSecondInteraction() { - val interaction1 = Interaction.newBuilder() - .setDefaultOutcome(OUTCOME_1) - .addAnswerGroups(AnswerGroup.newBuilder().setOutcome(OUTCOME_0)) + fun testClassify_multipleAnswerGroups_matchesMultipleRuleSpecs_returnsAnswerGroupOutcome() { + val interaction = Interaction.newBuilder() + .setId("TextInput") + .addAnswerGroups(AnswerGroup.newBuilder() + .addRuleSpecs(RuleSpec.newBuilder().setRuleType("Equals").putInput("x", TEST_STRING_0)) + .addRuleSpecs(RuleSpec.newBuilder().setRuleType("CaseSensitiveEquals").putInput("x", TEST_STRING_0)) + .setOutcome(OUTCOME_0)) + .addAnswerGroups(AnswerGroup.newBuilder() + .addRuleSpecs(RuleSpec.newBuilder().setRuleType("Equals").putInput("x", TEST_STRING_1)) + .setOutcome(OUTCOME_1)) + .setDefaultOutcome(DEFAULT_OUTCOME) .build() - val interaction2 = Interaction.newBuilder() - .setDefaultOutcome(OUTCOME_2) + + val outcome = answerClassificationController.classify(interaction, TEST_STRING_0) + + // The outcome of the singly matched answer group should be returned. Matching multiple rule specs doesn't matter. + assertThat(outcome).isEqualTo(OUTCOME_0) + } + + @Test + fun testClassify_multipleAnswerGroups_matchesMultipleGroups_returnsFirstMatchedGroupOutcome() { + val interaction = Interaction.newBuilder() + .setId("TextInput") + .addAnswerGroups(AnswerGroup.newBuilder() + .addRuleSpecs(RuleSpec.newBuilder().setRuleType("Equals").putInput("x", TEST_STRING_0)) + .setOutcome(OUTCOME_0)) + .addAnswerGroups(AnswerGroup.newBuilder() + .addRuleSpecs(RuleSpec.newBuilder().setRuleType("CaseSensitiveEquals").putInput("x", TEST_STRING_0)) + .setOutcome(OUTCOME_1)) + .setDefaultOutcome(DEFAULT_OUTCOME) .build() - val state1 = createTestState("Numeric input", interaction1) - answerClassificationController.classify(state1, TEST_SIGNED_INT_121_ANSWER) - val state2 = createTestState("Things you can do", interaction2) - val outcome = answerClassificationController.classify(state2, TEST_STRING_ANSWER) + val outcome = answerClassificationController.classify(interaction, TEST_STRING_0) - assertThat(outcome).isEqualTo(OUTCOME_2) + // The first matched group should be returned even though multiple groups are matching. + assertThat(outcome).isEqualTo(OUTCOME_0) } - private fun createTestState(stateName: String, interaction: Interaction): State { - return State.newBuilder() - .setName(stateName) - .setInteraction(interaction) + @Test + fun testClassify_multipleAnswerGroups_matchesNone_returnsDefaultOutcome() { + val interaction = Interaction.newBuilder() + .setId("TextInput") + .addAnswerGroups(AnswerGroup.newBuilder() + .addRuleSpecs(RuleSpec.newBuilder().setRuleType("Equals").putInput("x", TEST_STRING_0)) + .setOutcome(OUTCOME_0)) + .addAnswerGroups(AnswerGroup.newBuilder() + .addRuleSpecs(RuleSpec.newBuilder().setRuleType("CaseSensitiveEquals").putInput("x", TEST_STRING_0)) + .setOutcome(OUTCOME_1)) + .setDefaultOutcome(DEFAULT_OUTCOME) .build() + + val outcome = answerClassificationController.classify(interaction, TEST_STRING_1) + + // No matching groups should always yield the default outcome. + assertThat(outcome).isEqualTo(DEFAULT_OUTCOME) } private fun setUpTestApplicationComponent() { @@ -108,6 +454,22 @@ class AnswerClassificationControllerTest { .inject(this) } + // TODO(#89): Move to a common test library. + /** A replacement to JUnit5's assertThrows(). */ + private fun assertThrows(type: KClass, operation: () -> Unit): T { + try { + operation() + fail("Expected to encounter exception of $type") + } catch (t: Throwable) { + if (type.isInstance(t)) { + return type.cast(t) + } + // Unexpected exception; throw it. + throw t + } + throw AssertionError("Reached an impossible state when verifying that an exception was thrown.") + } + // TODO(#89): Move this to a common test application component. @Module class TestModule { @@ -120,7 +482,11 @@ class AnswerClassificationControllerTest { // TODO(#89): Move this to a common test application component. @Singleton - @Component(modules = [TestModule::class]) + @Component(modules = [ + TestModule::class, ContinueModule::class, FractionInputModule::class, ItemSelectionInputModule::class, + MultipleChoiceInputModule::class, NumberWithUnitsRuleModule::class, NumericInputRuleModule::class, + TextInputRuleModule::class, InteractionsModule::class + ]) interface TestApplicationComponent { @Component.Builder interface Builder { diff --git a/domain/src/test/java/org/oppia/domain/exploration/ExplorationDataControllerTest.kt b/domain/src/test/java/org/oppia/domain/exploration/ExplorationDataControllerTest.kt index da49561b8cd..927bb1ce96e 100644 --- a/domain/src/test/java/org/oppia/domain/exploration/ExplorationDataControllerTest.kt +++ b/domain/src/test/java/org/oppia/domain/exploration/ExplorationDataControllerTest.kt @@ -33,6 +33,14 @@ import org.mockito.Mockito.verify import org.mockito.junit.MockitoJUnit import org.mockito.junit.MockitoRule import org.oppia.app.model.Exploration +import org.oppia.domain.classify.InteractionsModule +import org.oppia.domain.classify.rules.continueinteraction.ContinueModule +import org.oppia.domain.classify.rules.fractioninput.FractionInputModule +import org.oppia.domain.classify.rules.itemselectioninput.ItemSelectionInputModule +import org.oppia.domain.classify.rules.multiplechoiceinput.MultipleChoiceInputModule +import org.oppia.domain.classify.rules.numberwithunits.NumberWithUnitsRuleModule +import org.oppia.domain.classify.rules.numericinput.NumericInputRuleModule +import org.oppia.domain.classify.rules.textinput.TextInputRuleModule import org.oppia.util.data.AsyncResult import org.oppia.util.logging.EnableConsoleLog import org.oppia.util.logging.EnableFileLog @@ -205,7 +213,11 @@ class ExplorationDataControllerTest { // TODO(#89): Move this to a common test application component. @Singleton - @Component(modules = [TestModule::class]) + @Component(modules = [ + TestModule::class, ContinueModule::class, FractionInputModule::class, ItemSelectionInputModule::class, + MultipleChoiceInputModule::class, NumberWithUnitsRuleModule::class, NumericInputRuleModule::class, + TextInputRuleModule::class, InteractionsModule::class + ]) interface TestApplicationComponent { @Component.Builder interface Builder { diff --git a/domain/src/test/java/org/oppia/domain/exploration/ExplorationProgressControllerTest.kt b/domain/src/test/java/org/oppia/domain/exploration/ExplorationProgressControllerTest.kt index 0738bfdbbd0..48214795510 100644 --- a/domain/src/test/java/org/oppia/domain/exploration/ExplorationProgressControllerTest.kt +++ b/domain/src/test/java/org/oppia/domain/exploration/ExplorationProgressControllerTest.kt @@ -33,6 +33,14 @@ import org.oppia.app.model.EphemeralState.StateTypeCase.PENDING_STATE import org.oppia.app.model.EphemeralState.StateTypeCase.TERMINAL_STATE import org.oppia.app.model.Exploration import org.oppia.app.model.InteractionObject +import org.oppia.domain.classify.InteractionsModule +import org.oppia.domain.classify.rules.continueinteraction.ContinueModule +import org.oppia.domain.classify.rules.fractioninput.FractionInputModule +import org.oppia.domain.classify.rules.itemselectioninput.ItemSelectionInputModule +import org.oppia.domain.classify.rules.multiplechoiceinput.MultipleChoiceInputModule +import org.oppia.domain.classify.rules.numberwithunits.NumberWithUnitsRuleModule +import org.oppia.domain.classify.rules.numericinput.NumericInputRuleModule +import org.oppia.domain.classify.rules.textinput.TextInputRuleModule import org.oppia.util.data.AsyncResult import org.oppia.util.threading.BackgroundDispatcher import org.oppia.util.threading.BlockingDispatcher @@ -828,7 +836,7 @@ class ExplorationProgressControllerTest { submitMultipleChoiceAnswerAndMoveToNextState(0) submitTextInputAnswerAndMoveToNextState("Finnish") - val result = explorationProgressController.submitAnswer(createNumericInputAnswer(121)) + val result = explorationProgressController.submitAnswer(createNumericInputAnswer(121.0)) result.observeForever(mockAsyncAnswerOutcomeObserver) advanceUntilIdle() @@ -841,23 +849,21 @@ class ExplorationProgressControllerTest { @Test @ExperimentalCoroutinesApi - fun testSubmitAnswer_forNumericInput_wrongAnswer_returnsDefaultOutcome() = runBlockingTest(coroutineContext) { + fun testSubmitAnswer_forNumericInput_wrongAnswer_returnsOutcomeWithTransition() = runBlockingTest(coroutineContext) { subscribeToCurrentStateToAllowExplorationToLoad() playExploration(TEST_EXPLORATION_ID_5) submitMultipleChoiceAnswerAndMoveToNextState(0) submitTextInputAnswerAndMoveToNextState("Finnish") - val result = explorationProgressController.submitAnswer(createNumericInputAnswer(0)) + val result = explorationProgressController.submitAnswer(createNumericInputAnswer(122.0)) result.observeForever(mockAsyncAnswerOutcomeObserver) advanceUntilIdle() - // Verify that the answer was wrong, and that there's no handler for it so the default outcome is returned. - // TODO(#114): Update this test to target a non-default outcome since the default outcome *should* be impossible to - // encounter. + // Verify that the answer submission failed as expected. verify(mockAsyncAnswerOutcomeObserver, atLeastOnce()).onChanged(asyncAnswerOutcomeCaptor.capture()) val answerOutcome = asyncAnswerOutcomeCaptor.value.getOrThrow() assertThat(answerOutcome.destinationCase).isEqualTo(AnswerOutcome.DestinationCase.SAME_STATE) - assertThat(answerOutcome.feedback.html).contains("If you got here, something's gone a bit wrong") + assertThat(answerOutcome.feedback.html).contains("You are actually very close.") } @Test @@ -867,7 +873,7 @@ class ExplorationProgressControllerTest { playExploration(TEST_EXPLORATION_ID_5) submitMultipleChoiceAnswerAndMoveToNextState(0) submitTextInputAnswerAndMoveToNextState("Finnish") - submitNumericInputAnswerAndMoveToNextState(121) + submitNumericInputAnswerAndMoveToNextState(121.0) val result = explorationProgressController.submitAnswer(createContinueButtonAnswer()) result.observeForever(mockAsyncAnswerOutcomeObserver) @@ -888,7 +894,7 @@ class ExplorationProgressControllerTest { playExploration(TEST_EXPLORATION_ID_5) submitMultipleChoiceAnswerAndMoveToNextState(0) submitTextInputAnswerAndMoveToNextState("Finnish") - submitNumericInputAnswerAndMoveToNextState(121) + submitNumericInputAnswerAndMoveToNextState(121.0) submitContinueButtonAnswerAndMoveToNextState() @@ -929,7 +935,7 @@ class ExplorationProgressControllerTest { playExploration(TEST_EXPLORATION_ID_5) submitMultipleChoiceAnswerAndMoveToNextState(0) submitTextInputAnswerAndMoveToNextState("Finnish") - submitNumericInputAnswerAndMoveToNextState(121) + submitNumericInputAnswerAndMoveToNextState(121.0) submitContinueButtonAnswerAndMoveToNextState() val moveToStateResult = explorationProgressController.moveToNextState() @@ -1049,7 +1055,7 @@ class ExplorationProgressControllerTest { } @ExperimentalCoroutinesApi - private fun submitNumericInputAnswer(numericAnswer: Int) { + private fun submitNumericInputAnswer(numericAnswer: Double) { explorationProgressController.submitAnswer(createNumericInputAnswer(numericAnswer)) testDispatcher.advanceUntilIdle() } @@ -1073,7 +1079,7 @@ class ExplorationProgressControllerTest { } @ExperimentalCoroutinesApi - private fun submitNumericInputAnswerAndMoveToNextState(numericAnswer: Int) { + private fun submitNumericInputAnswerAndMoveToNextState(numericAnswer: Double) { submitNumericInputAnswer(numericAnswer) moveToNextState() } @@ -1107,7 +1113,7 @@ class ExplorationProgressControllerTest { playExploration(TEST_EXPLORATION_ID_5) submitMultipleChoiceAnswerAndMoveToNextState(0) submitTextInputAnswerAndMoveToNextState("Finnish") - submitNumericInputAnswerAndMoveToNextState(121) + submitNumericInputAnswerAndMoveToNextState(121.0) submitContinueButtonAnswerAndMoveToNextState() endExploration() } @@ -1120,8 +1126,8 @@ class ExplorationProgressControllerTest { return InteractionObject.newBuilder().setNormalizedString(textAnswer).build() } - private fun createNumericInputAnswer(numericAnswer: Int): InteractionObject { - return InteractionObject.newBuilder().setSignedInt(numericAnswer).build() + private fun createNumericInputAnswer(numericAnswer: Double): InteractionObject { + return InteractionObject.newBuilder().setReal(numericAnswer).build() } private fun createContinueButtonAnswer() = createTextInputAnswer(DEFAULT_CONTINUE_INTERACTION_TEXT_ANSWER) @@ -1164,7 +1170,11 @@ class ExplorationProgressControllerTest { // TODO(#89): Move this to a common test application component. @Singleton - @Component(modules = [TestModule::class]) + @Component(modules = [ + TestModule::class, ContinueModule::class, FractionInputModule::class, ItemSelectionInputModule::class, + MultipleChoiceInputModule::class, NumberWithUnitsRuleModule::class, NumericInputRuleModule::class, + TextInputRuleModule::class, InteractionsModule::class + ]) interface TestApplicationComponent { @Component.Builder interface Builder { diff --git a/model/src/main/proto/exploration.proto b/model/src/main/proto/exploration.proto index 338994bbd94..1bb435382a2 100644 --- a/model/src/main/proto/exploration.proto +++ b/model/src/main/proto/exploration.proto @@ -134,7 +134,7 @@ message RuleSpec { // Mapping from the name of the rule template to the value of the rule template. // The keys and values for this map can be deduced from // https://github.com/oppia/oppia/blob/develop/extensions/interactions/rule_templates.json - InteractionObject input = 1; + map input = 1; string rule_type = 2; } diff --git a/model/src/main/proto/interaction_object.proto b/model/src/main/proto/interaction_object.proto index 0fb753b3671..662337f37b6 100644 --- a/model/src/main/proto/interaction_object.proto +++ b/model/src/main/proto/interaction_object.proto @@ -17,12 +17,13 @@ message InteractionObject { // repeated fields cannot be within a oneof so lists are // wrapped into a new message. StringList set_of_html_string = 7; + Fraction fraction = 8; } } // Structure containing a string list. message StringList { - repeated string string_list = 1; + repeated string html = 1; } // Structure for a number with units object. @@ -31,11 +32,11 @@ message NumberWithUnits { float real = 1; Fraction fraction = 2; } - repeated Unit units = 3; + repeated NumberUnit unit = 3; } // Structure for a unit -message Unit { +message NumberUnit { string unit = 1; int32 exponent = 2; } @@ -43,7 +44,7 @@ message Unit { // Structure for a fraction object. message Fraction { bool is_negative = 1; - bool whole_number = 2; + int32 whole_number = 2; int32 numerator = 3; int32 denominator = 4; }