diff --git a/app/BUILD.bazel b/app/BUILD.bazel index fe0105f433b..64572260137 100644 --- a/app/BUILD.bazel +++ b/app/BUILD.bazel @@ -826,6 +826,7 @@ TEST_DEPS = [ ":test_deps", "//app/src/main/java/org/oppia/android/app/testing/activity:test_activity", "//app/src/main/java/org/oppia/android/app/translation/testing:test_module", + "//app/src/main/java/org/oppia/android/app/utility/math:math_expression_accessibility_util", "//domain", "//domain/src/main/java/org/oppia/android/domain/audio:audio_player_controller", "//domain/src/main/java/org/oppia/android/domain/classify/rules/algebraicexpressioninput:algebraic_expression_input_rule_module", @@ -851,6 +852,8 @@ TEST_DEPS = [ "//testing/src/main/java/org/oppia/android/testing/espresso:konfetti_view_matcher", "//testing/src/main/java/org/oppia/android/testing/espresso:text_input_action", "//testing/src/main/java/org/oppia/android/testing/junit:initialize_default_locale_rule", + "//testing/src/main/java/org/oppia/android/testing/math:math_equation_subject", + "//testing/src/main/java/org/oppia/android/testing/math:math_expression_subject", "//testing/src/main/java/org/oppia/android/testing/mockito", "//testing/src/main/java/org/oppia/android/testing/network", "//testing/src/main/java/org/oppia/android/testing/network:test_module", @@ -886,6 +889,7 @@ TEST_DEPS = [ "//utility/src/main/java/org/oppia/android/util/accessibility:test_module", "//utility/src/main/java/org/oppia/android/util/caching:asset_prod_module", "//utility/src/main/java/org/oppia/android/util/caching/testing:caching_test_module", + "//utility/src/main/java/org/oppia/android/util/math:math_expression_parser", "//utility/src/main/java/org/oppia/android/util/parser/html:custom_bullet_span", "//utility/src/main/java/org/oppia/android/util/parser/html:html_parser", "//utility/src/main/java/org/oppia/android/util/parser/html:html_parser_entity_type_module", diff --git a/app/src/main/java/org/oppia/android/app/testing/activity/BUILD.bazel b/app/src/main/java/org/oppia/android/app/testing/activity/BUILD.bazel index 137513d16c5..eb5ef2049c6 100644 --- a/app/src/main/java/org/oppia/android/app/testing/activity/BUILD.bazel +++ b/app/src/main/java/org/oppia/android/app/testing/activity/BUILD.bazel @@ -18,5 +18,6 @@ kt_android_library( deps = [ "//app/src/main/java/org/oppia/android/app/activity:activity_intent_factories_shim", "//app/src/main/java/org/oppia/android/app/activity:injectable_app_compat_activity", + "//app/src/main/java/org/oppia/android/app/utility/math:math_expression_accessibility_util", ], ) diff --git a/app/src/main/java/org/oppia/android/app/testing/activity/TestActivity.kt b/app/src/main/java/org/oppia/android/app/testing/activity/TestActivity.kt index 05307f02df9..605c5699f69 100644 --- a/app/src/main/java/org/oppia/android/app/testing/activity/TestActivity.kt +++ b/app/src/main/java/org/oppia/android/app/testing/activity/TestActivity.kt @@ -7,6 +7,7 @@ import org.oppia.android.app.activity.InjectableAppCompatActivity import org.oppia.android.app.translation.AppLanguageResourceHandler import org.oppia.android.app.translation.AppLanguageWatcherMixin import org.oppia.android.app.utility.datetime.DateTimeUtil +import org.oppia.android.app.utility.math.MathExpressionAccessibilityUtil import javax.inject.Inject // TODO(#3830): Migrate all test activities over to using this test activity & make this closed. @@ -36,6 +37,9 @@ open class TestActivity : InjectableAppCompatActivity() { @Inject lateinit var appLanguageWatcherMixin: AppLanguageWatcherMixin + @Inject + lateinit var mathExpressionAccessibilityUtil: MathExpressionAccessibilityUtil + override fun attachBaseContext(newBase: Context?) { super.attachBaseContext(newBase) (activityComponent as Injector).inject(this) diff --git a/app/src/main/java/org/oppia/android/app/translation/AppLanguageResourceHandler.kt b/app/src/main/java/org/oppia/android/app/translation/AppLanguageResourceHandler.kt index 8dd7787dc15..4887f41175a 100644 --- a/app/src/main/java/org/oppia/android/app/translation/AppLanguageResourceHandler.kt +++ b/app/src/main/java/org/oppia/android/app/translation/AppLanguageResourceHandler.kt @@ -115,6 +115,12 @@ class AppLanguageResourceHandler @Inject constructor( } } + /** See [OppiaLocale.DisplayLocale.formatLong] for specific behavior. */ + fun formatLong(value: Long): String = getDisplayLocale().formatLong(value) + + /** See [OppiaLocale.DisplayLocale.formatDouble] for specific behavior. */ + fun formatDouble(value: Double): String = getDisplayLocale().formatDouble(value) + /** See [OppiaLocale.DisplayLocale.computeDateString]. */ fun computeDateString(timestampMillis: Long): String = getDisplayLocale().computeDateString(timestampMillis) diff --git a/app/src/main/java/org/oppia/android/app/utility/math/BUILD.bazel b/app/src/main/java/org/oppia/android/app/utility/math/BUILD.bazel new file mode 100644 index 00000000000..12d6ae08213 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/utility/math/BUILD.bazel @@ -0,0 +1,35 @@ +""" +General purposes utilities corresponding to displaying math expressions & constructs. +""" + +load("@dagger//:workspace_defs.bzl", "dagger_rules") +load("@io_bazel_rules_kotlin//kotlin:kotlin.bzl", "kt_android_library") + +# Resource shim needed so that MathExpressionAccessibilityUtil can build in both Gradle & Bazel. +genrule( + name = "update_MathExpressionAccessibilityUtil", + srcs = ["MathExpressionAccessibilityUtil.kt"], + outs = ["MathExpressionAccessibilityUtil_updated.kt"], + cmd = """ + cat $(SRCS) | + sed 's/import org.oppia.android.R/import org.oppia.android.app.R/g' > $(OUTS) + """, +) + +kt_android_library( + name = "math_expression_accessibility_util", + srcs = [ + "MathExpressionAccessibilityUtil_updated.kt", + ], + visibility = ["//app:app_visibility"], + deps = [ + ":dagger", + "//app:resources", + "//app/src/main/java/org/oppia/android/app/translation:app_language_resource_handler", + "//model/src/main/proto:languages_java_proto_lite", + "//model/src/main/proto:math_java_proto_lite", + "//utility/src/main/java/org/oppia/android/util/math:extensions", + ], +) + +dagger_rules() diff --git a/app/src/main/java/org/oppia/android/app/utility/math/MathExpressionAccessibilityUtil.kt b/app/src/main/java/org/oppia/android/app/utility/math/MathExpressionAccessibilityUtil.kt new file mode 100644 index 00000000000..3f6c1249efa --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/utility/math/MathExpressionAccessibilityUtil.kt @@ -0,0 +1,269 @@ +package org.oppia.android.app.utility.math + +import org.oppia.android.R +import org.oppia.android.app.model.MathBinaryOperation +import org.oppia.android.app.model.MathBinaryOperation.Operator.ADD +import org.oppia.android.app.model.MathBinaryOperation.Operator.DIVIDE +import org.oppia.android.app.model.MathBinaryOperation.Operator.EXPONENTIATE +import org.oppia.android.app.model.MathBinaryOperation.Operator.MULTIPLY +import org.oppia.android.app.model.MathBinaryOperation.Operator.SUBTRACT +import org.oppia.android.app.model.MathEquation +import org.oppia.android.app.model.MathExpression +import org.oppia.android.app.model.MathExpression.ExpressionTypeCase.BINARY_OPERATION +import org.oppia.android.app.model.MathExpression.ExpressionTypeCase.CONSTANT +import org.oppia.android.app.model.MathExpression.ExpressionTypeCase.EXPRESSIONTYPE_NOT_SET +import org.oppia.android.app.model.MathExpression.ExpressionTypeCase.FUNCTION_CALL +import org.oppia.android.app.model.MathExpression.ExpressionTypeCase.GROUP +import org.oppia.android.app.model.MathExpression.ExpressionTypeCase.UNARY_OPERATION +import org.oppia.android.app.model.MathExpression.ExpressionTypeCase.VARIABLE +import org.oppia.android.app.model.MathFunctionCall.FunctionType +import org.oppia.android.app.model.MathFunctionCall.FunctionType.FUNCTION_UNSPECIFIED +import org.oppia.android.app.model.MathFunctionCall.FunctionType.SQUARE_ROOT +import org.oppia.android.app.model.MathUnaryOperation.Operator.NEGATE +import org.oppia.android.app.model.MathUnaryOperation.Operator.POSITIVE +import org.oppia.android.app.model.OppiaLanguage +import org.oppia.android.app.model.OppiaLanguage.ARABIC +import org.oppia.android.app.model.OppiaLanguage.BRAZILIAN_PORTUGUESE +import org.oppia.android.app.model.OppiaLanguage.ENGLISH +import org.oppia.android.app.model.OppiaLanguage.HINDI +import org.oppia.android.app.model.OppiaLanguage.HINGLISH +import org.oppia.android.app.model.OppiaLanguage.LANGUAGE_UNSPECIFIED +import org.oppia.android.app.model.OppiaLanguage.PORTUGUESE +import org.oppia.android.app.model.OppiaLanguage.UNRECOGNIZED +import org.oppia.android.app.model.Real.RealTypeCase.INTEGER +import org.oppia.android.app.model.Real.RealTypeCase.IRRATIONAL +import org.oppia.android.app.model.Real.RealTypeCase.RATIONAL +import org.oppia.android.app.model.Real.RealTypeCase.REALTYPE_NOT_SET +import org.oppia.android.app.translation.AppLanguageResourceHandler +import javax.inject.Inject +import org.oppia.android.app.model.MathBinaryOperation.Operator as BinaryOperator +import org.oppia.android.app.model.MathUnaryOperation.Operator as UnaryOperator + +/** + * Utility for computing an accessibility string for screenreaders to be able to read out parsed + * [MathExpression]s and [MathEquation]s. + * + * See [convertToHumanReadableString] for the specific function. + */ +class MathExpressionAccessibilityUtil @Inject constructor( + private val resourceHandler: AppLanguageResourceHandler +) { + /** + * Returns the human-readable string (for screenreaders) representation of the specified + * [expression]. + * + * Note that rational ``Real``s are specifically not supported and will result in a null value + * being returned (for custom expression constructs should use a division operation and set + * [divAsFraction] to true. Further, irrational reals may be rounded during formatting if they are + * very large or have long decimals (for an easier time reading). Numbers will be formatted + * according to the user's locale. + * + * @param expression the expression to convert + * @param language the target language for which the expression should be generated + * @param divAsFraction whether divisions should be read out as fractions rather than divisions + * @return the human-readable string, or null if the expression is malformed or the target + * language is unsupported + */ + fun convertToHumanReadableString( + expression: MathExpression, + language: OppiaLanguage, + divAsFraction: Boolean + ): String? { + return when (language) { + ENGLISH -> expression.toHumanReadableEnglishString(divAsFraction) + ARABIC, HINDI, HINGLISH, PORTUGUESE, BRAZILIAN_PORTUGUESE, LANGUAGE_UNSPECIFIED, + UNRECOGNIZED -> null + } + } + + /** + * Returns the human-readable string (for screenreaders) representation of the specified + * [equation]. + * + * This function behaves in the same way as the [MathExpression] version of + * [convertToHumanReadableString]--see that method's documentation for more details. + */ + fun convertToHumanReadableString( + equation: MathEquation, + language: OppiaLanguage, + divAsFraction: Boolean + ): String? { + return when (language) { + ENGLISH -> equation.toHumanReadableEnglishString(divAsFraction) + ARABIC, HINDI, HINGLISH, PORTUGUESE, BRAZILIAN_PORTUGUESE, LANGUAGE_UNSPECIFIED, + UNRECOGNIZED -> null + } + } + + private fun MathEquation.toHumanReadableEnglishString(divAsFraction: Boolean): String? { + val lhsStr = leftSide.toHumanReadableEnglishString(divAsFraction) + val rhsStr = rightSide.toHumanReadableEnglishString(divAsFraction) + return if (lhsStr != null && rhsStr != null) { + resourceHandler.getStringInLocaleWithWrapping( + R.string.math_accessibility_a_equals_b, lhsStr, rhsStr + ) + } else null + } + + private fun MathExpression.toHumanReadableEnglishString(divAsFraction: Boolean): String? { + // Ref: https://docs.google.com/document/d/1SkzAD4k7SWLp5_3L5WNxsnR79ATlOk8pz4irfE2ls-4/view. + + // Note that extra bidi wrapping is occurring here since there's not an obvious way to wrap "at + // the end" for non-equations. + return when (expressionTypeCase) { + CONSTANT -> when (constant.realTypeCase) { + IRRATIONAL -> resourceHandler.formatDouble(constant.irrational) + INTEGER -> resourceHandler.formatLong(constant.integer.toLong()) + // Note that rational types should not actually be encountered in raw expressions, so + // there's no explicit support for reading them out. + RATIONAL, REALTYPE_NOT_SET, null -> null + } + VARIABLE -> when (variable) { + "z", "Z" -> { + val zed = + resourceHandler.getStringInLocale(R.string.math_accessibility_part_zed) + if (variable == "Z") { + resourceHandler.capitalizeForHumans(zed) + } else zed + } + else -> variable + } + BINARY_OPERATION -> { + val lhs = binaryOperation.leftOperand + val rhs = binaryOperation.rightOperand + val lhsStr = lhs.toHumanReadableEnglishString(divAsFraction) + val rhsStr = rhs.toHumanReadableEnglishString(divAsFraction) + if (lhsStr == null || rhsStr == null) return null + when (binaryOperation.operator) { + ADD -> { + resourceHandler.getStringInLocaleWithWrapping( + R.string.math_accessibility_a_plus_b, lhsStr, rhsStr + ) + } + SUBTRACT -> { + resourceHandler.getStringInLocaleWithWrapping( + R.string.math_accessibility_a_minus_b, lhsStr, rhsStr + ) + } + MULTIPLY -> { + val strResId = if (binaryOperation.canBeReadAsImplicitMultiplication()) { + R.string.math_accessibility_implicit_multiplication + } else R.string.math_accessibility_a_times_b + resourceHandler.getStringInLocaleWithWrapping(strResId, lhsStr, rhsStr) + } + DIVIDE -> when { + divAsFraction -> when { + binaryOperation.isOneHalf() -> { + resourceHandler.getStringInLocaleWithWrapping( + R.string.math_accessibility_part_one_half + ) + } + binaryOperation.isSimpleFraction() -> { + resourceHandler.getStringInLocaleWithWrapping( + R.string.math_accessibility_simple_fraction, lhsStr, rhsStr + ) + } + else -> { + resourceHandler.getStringInLocaleWithWrapping( + R.string.math_accessibility_complex_fraction, lhsStr, rhsStr + ) + } + } + else -> { + resourceHandler.getStringInLocaleWithWrapping( + R.string.math_accessibility_a_divides_b, lhsStr, rhsStr + ) + } + } + EXPONENTIATE -> { + resourceHandler.getStringInLocaleWithWrapping( + R.string.math_accessibility_a_exp_b, lhsStr, rhsStr + ) + } + BinaryOperator.OPERATOR_UNSPECIFIED, BinaryOperator.UNRECOGNIZED, null -> null + } + } + UNARY_OPERATION -> { + val operandStr = unaryOperation.operand.toHumanReadableEnglishString(divAsFraction) + when (unaryOperation.operator) { + NEGATE -> operandStr?.let { + resourceHandler.getStringInLocaleWithWrapping( + R.string.math_accessibility_negative_a, it + ) + } + POSITIVE -> operandStr?.let { + resourceHandler.getStringInLocaleWithWrapping( + R.string.math_accessibility_positive_a, it + ) + } + UnaryOperator.OPERATOR_UNSPECIFIED, UnaryOperator.UNRECOGNIZED, null -> null + } + } + FUNCTION_CALL -> { + val argStr = functionCall.argument.toHumanReadableEnglishString(divAsFraction) + when (functionCall.functionType) { + SQUARE_ROOT -> argStr?.let { + if (functionCall.argument.isSingleTerm()) { + resourceHandler.getStringInLocaleWithWrapping( + R.string.math_accessibility_simple_square_root, it + ) + } else { + resourceHandler.getStringInLocaleWithWrapping( + R.string.math_accessibility_complex_square_root, it + ) + } + } + FUNCTION_UNSPECIFIED, FunctionType.UNRECOGNIZED, null -> null + } + } + GROUP -> group.toHumanReadableEnglishString(divAsFraction)?.let { + if (!isSingleTerm()) { + resourceHandler.getStringInLocaleWithWrapping(R.string.math_accessibility_group, it) + } else it + } + EXPRESSIONTYPE_NOT_SET, null -> null + } + } + + private companion object { + private fun MathBinaryOperation.canBeReadAsImplicitMultiplication(): Boolean { + // Note that exponentiation is specialized since it's higher precedence than multiplication + // which means the graph won't look like "constant * variable" for polynomial terms like 2x^4 + // (which are cases the system should read using implicit multiplication, e.g. "two x raised + // to the power of 4"). + if (!isImplicit || !leftOperand.isConstant()) return false + return rightOperand.isVariable() || rightOperand.isExponentiation() + } + + private fun MathBinaryOperation.isSimpleFraction(): Boolean { + // 'Simple' fractions are those with single term numerators and denominators (which are + // subsequently easier to read out), and whose constant numerator/denonominators are integers. + return leftOperand.isSimpleFractionTerm() && rightOperand.isSimpleFractionTerm() + } + + private fun MathBinaryOperation.isOneHalf(): Boolean { + // If the either operand isn't an integer it will default to 0 per proto3 rules. + return leftOperand.constant.integer == 1 && rightOperand.constant.integer == 2 + } + + private fun MathExpression.isConstant(): Boolean = expressionTypeCase == CONSTANT + + private fun MathExpression.isVariable(): Boolean = expressionTypeCase == VARIABLE + + private fun MathExpression.isExponentiation(): Boolean = + expressionTypeCase == BINARY_OPERATION && binaryOperation.operator == EXPONENTIATE + + private fun MathExpression.isSingleTerm(): Boolean = when (expressionTypeCase) { + CONSTANT, VARIABLE, FUNCTION_CALL -> true + BINARY_OPERATION, UNARY_OPERATION -> false + GROUP -> group.isSingleTerm() + EXPRESSIONTYPE_NOT_SET, null -> false + } + + private fun MathExpression.isSimpleFractionTerm(): Boolean = when (expressionTypeCase) { + CONSTANT -> constant.realTypeCase == INTEGER + VARIABLE -> true + BINARY_OPERATION, UNARY_OPERATION, FUNCTION_CALL, GROUP, EXPRESSIONTYPE_NOT_SET, null -> false + } + } +} diff --git a/app/src/main/res/values/untranslated_strings.xml b/app/src/main/res/values/untranslated_strings.xml index 5b1ceec25ab..5494cfc9daf 100644 --- a/app/src/main/res/values/untranslated_strings.xml +++ b/app/src/main/res/values/untranslated_strings.xml @@ -41,4 +41,21 @@ Profile data is currently uploading… All profile data has been uploaded. Please connect to a WiFi or Cellular network in order to upload profile data. + + zed + one half + %s equals %s + %s plus %s + %s minus %s + %s times %s + %s divided by %s + %s raised to the power of %s + negative %s + positive %s + square root of %s + start square root %s end square root + open parenthesis %s close parenthesis + %s %s + %s over %s + the fraction with numerator %s and denominator %s diff --git a/app/src/test/java/org/oppia/android/app/translation/AppLanguageResourceHandlerTest.kt b/app/src/test/java/org/oppia/android/app/translation/AppLanguageResourceHandlerTest.kt index 835f8d78d19..c3ac67aa09b 100644 --- a/app/src/test/java/org/oppia/android/app/translation/AppLanguageResourceHandlerTest.kt +++ b/app/src/test/java/org/oppia/android/app/translation/AppLanguageResourceHandlerTest.kt @@ -424,6 +424,39 @@ class AppLanguageResourceHandlerTest { } } + @Test + fun testFormatLong_forLargeLong_returnsStringWithExactDigits() { + updateAppLanguageTo(OppiaLanguage.ENGLISH) + val handler = retrieveAppLanguageResourceHandler() + + val formattedString = handler.formatLong(123456789) + + assertThat(formattedString.filter { it.isDigit() }).isEqualTo("123456789") + } + + @Test + fun testFormatLong_forDouble_returnsStringWithExactDigits() { + updateAppLanguageTo(OppiaLanguage.ENGLISH) + val handler = retrieveAppLanguageResourceHandler() + + val formattedString = handler.formatDouble(454545456.123) + + val digitsOnly = formattedString.filter { it.isDigit() } + assertThat(digitsOnly).contains("454545456") + assertThat(digitsOnly).contains("123") + } + + @Test + fun testFormatLong_forDouble_returnsStringWithPeriodsOrCommas() { + updateAppLanguageTo(OppiaLanguage.ENGLISH) + val handler = retrieveAppLanguageResourceHandler() + + val formattedString = handler.formatDouble(123456789.123) + + // Depending on formatting, commas and/or periods are used for large doubles. + assertThat(formattedString).containsMatch("[,.]") + } + @Test fun testComputeDateString_forFixedTime_returnMonthDayYearParts() { updateAppLanguageTo(OppiaLanguage.ENGLISH) diff --git a/app/src/test/java/org/oppia/android/app/utility/math/BUILD.bazel b/app/src/test/java/org/oppia/android/app/utility/math/BUILD.bazel new file mode 100644 index 00000000000..3f5ff80c424 --- /dev/null +++ b/app/src/test/java/org/oppia/android/app/utility/math/BUILD.bazel @@ -0,0 +1,48 @@ +""" +Tests for UI-specific math utilities. +""" + +load("@dagger//:workspace_defs.bzl", "dagger_rules") +load("//:oppia_android_test.bzl", "oppia_android_test") + +oppia_android_test( + name = "MathExpressionAccessibilityUtilTest", + srcs = ["MathExpressionAccessibilityUtilTest.kt"], + custom_package = "org.oppia.android.app.utility.math", + test_class = "org.oppia.android.app.utility.math.MathExpressionAccessibilityUtilTest", + test_manifest = "//app:test_manifest", + deps = [ + ":dagger", + "//app", + "//app/src/main/java/org/oppia/android/app/translation/testing:test_module", + "//app/src/main/java/org/oppia/android/app/utility/math:math_expression_accessibility_util", + "//domain/src/main/java/org/oppia/android/domain/onboarding/testing:retriever_test_module", + "//model/src/main/proto:languages_java_proto_lite", + "//model/src/main/proto:math_java_proto_lite", + "//testing", + "//testing/src/main/java/org/oppia/android/testing/data:data_provider_test_monitor", + "//testing/src/main/java/org/oppia/android/testing/junit:initialize_default_locale_rule", + "//testing/src/main/java/org/oppia/android/testing/junit:oppia_parameterized_test_runner", + "//testing/src/main/java/org/oppia/android/testing/junit:parameterized_robolectric_test_runner", + "//testing/src/main/java/org/oppia/android/testing/math:math_equation_subject", + "//testing/src/main/java/org/oppia/android/testing/math:math_expression_subject", + "//testing/src/main/java/org/oppia/android/testing/robolectric:test_module", + "//testing/src/main/java/org/oppia/android/testing/threading:test_module", + "//testing/src/main/java/org/oppia/android/testing/time:test_module", + "//third_party:androidx_test_core", + "//third_party:com_google_truth_truth", + "//third_party:junit_junit", + "//third_party:org_robolectric_robolectric", + "//third_party:robolectric_android-all", + "//utility/src/main/java/org/oppia/android/util/accessibility:test_module", + "//utility/src/main/java/org/oppia/android/util/caching:asset_prod_module", + "//utility/src/main/java/org/oppia/android/util/caching/testing:caching_test_module", + "//utility/src/main/java/org/oppia/android/util/locale/testing:test_module", + "//utility/src/main/java/org/oppia/android/util/logging:prod_module", + "//utility/src/main/java/org/oppia/android/util/math:math_expression_parser", + "//utility/src/main/java/org/oppia/android/util/networking:debug_module", + "//utility/src/main/java/org/oppia/android/util/networking:debug_util_module", + ], +) + +dagger_rules() diff --git a/app/src/test/java/org/oppia/android/app/utility/math/MathExpressionAccessibilityUtilTest.kt b/app/src/test/java/org/oppia/android/app/utility/math/MathExpressionAccessibilityUtilTest.kt new file mode 100644 index 00000000000..b9c58dc7a09 --- /dev/null +++ b/app/src/test/java/org/oppia/android/app/utility/math/MathExpressionAccessibilityUtilTest.kt @@ -0,0 +1,1383 @@ +package org.oppia.android.app.utility.math + +import android.app.Application +import androidx.appcompat.app.AppCompatActivity +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.rules.ActivityScenarioRule +import com.google.common.truth.StringSubject +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import dagger.BindsInstance +import dagger.Component +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.oppia.android.app.activity.ActivityComponent +import org.oppia.android.app.activity.ActivityComponentFactory +import org.oppia.android.app.activity.ActivityIntentFactoriesModule +import org.oppia.android.app.application.ApplicationComponent +import org.oppia.android.app.application.ApplicationInjector +import org.oppia.android.app.application.ApplicationInjectorProvider +import org.oppia.android.app.application.ApplicationModule +import org.oppia.android.app.application.ApplicationStartupListenerModule +import org.oppia.android.app.devoptions.DeveloperOptionsModule +import org.oppia.android.app.devoptions.DeveloperOptionsStarterModule +import org.oppia.android.app.model.MathBinaryOperation +import org.oppia.android.app.model.MathEquation +import org.oppia.android.app.model.MathExpression +import org.oppia.android.app.model.MathFunctionCall +import org.oppia.android.app.model.MathUnaryOperation +import org.oppia.android.app.model.OppiaLanguage +import org.oppia.android.app.model.OppiaLanguage.ARABIC +import org.oppia.android.app.model.OppiaLanguage.BRAZILIAN_PORTUGUESE +import org.oppia.android.app.model.OppiaLanguage.ENGLISH +import org.oppia.android.app.model.OppiaLanguage.HINDI +import org.oppia.android.app.model.OppiaLanguage.HINGLISH +import org.oppia.android.app.model.OppiaLanguage.LANGUAGE_UNSPECIFIED +import org.oppia.android.app.model.OppiaLanguage.PORTUGUESE +import org.oppia.android.app.model.OppiaLanguage.UNRECOGNIZED +import org.oppia.android.app.model.Real +import org.oppia.android.app.shim.ViewBindingShimModule +import org.oppia.android.app.testing.activity.TestActivity +import org.oppia.android.app.topic.PracticeTabModule +import org.oppia.android.app.translation.testing.ActivityRecreatorTestModule +import org.oppia.android.data.backends.gae.NetworkConfigProdModule +import org.oppia.android.data.backends.gae.NetworkModule +import org.oppia.android.domain.classify.InteractionsModule +import org.oppia.android.domain.classify.rules.algebraicexpressioninput.AlgebraicExpressionInputModule +import org.oppia.android.domain.classify.rules.continueinteraction.ContinueModule +import org.oppia.android.domain.classify.rules.dragAndDropSortInput.DragDropSortInputModule +import org.oppia.android.domain.classify.rules.fractioninput.FractionInputModule +import org.oppia.android.domain.classify.rules.imageClickInput.ImageClickInputModule +import org.oppia.android.domain.classify.rules.itemselectioninput.ItemSelectionInputModule +import org.oppia.android.domain.classify.rules.mathequationinput.MathEquationInputModule +import org.oppia.android.domain.classify.rules.multiplechoiceinput.MultipleChoiceInputModule +import org.oppia.android.domain.classify.rules.numberwithunits.NumberWithUnitsRuleModule +import org.oppia.android.domain.classify.rules.numericexpressioninput.NumericExpressionInputModule +import org.oppia.android.domain.classify.rules.numericinput.NumericInputRuleModule +import org.oppia.android.domain.classify.rules.ratioinput.RatioInputModule +import org.oppia.android.domain.classify.rules.textinput.TextInputRuleModule +import org.oppia.android.domain.exploration.lightweightcheckpointing.ExplorationStorageModule +import org.oppia.android.domain.hintsandsolution.HintsAndSolutionConfigModule +import org.oppia.android.domain.hintsandsolution.HintsAndSolutionProdModule +import org.oppia.android.domain.onboarding.testing.ExpirationMetaDataRetrieverTestModule +import org.oppia.android.domain.oppialogger.LogStorageModule +import org.oppia.android.domain.oppialogger.loguploader.LogUploadWorkerModule +import org.oppia.android.domain.platformparameter.PlatformParameterModule +import org.oppia.android.domain.platformparameter.PlatformParameterSingletonModule +import org.oppia.android.domain.question.QuestionModule +import org.oppia.android.domain.topic.PrimeTopicAssetsControllerModule +import org.oppia.android.domain.workmanager.WorkManagerConfigurationModule +import org.oppia.android.testing.TestLogReportingModule +import org.oppia.android.testing.junit.InitializeDefaultLocaleRule +import org.oppia.android.testing.junit.OppiaParameterizedTestRunner +import org.oppia.android.testing.junit.OppiaParameterizedTestRunner.Iteration +import org.oppia.android.testing.junit.OppiaParameterizedTestRunner.Parameter +import org.oppia.android.testing.junit.OppiaParameterizedTestRunner.RunParameterized +import org.oppia.android.testing.junit.OppiaParameterizedTestRunner.SelectRunnerPlatform +import org.oppia.android.testing.junit.ParameterizedRobolectricTestRunner +import org.oppia.android.testing.math.MathEquationSubject +import org.oppia.android.testing.math.MathEquationSubject.Companion.assertThat +import org.oppia.android.testing.math.MathExpressionSubject +import org.oppia.android.testing.math.MathExpressionSubject.Companion.assertThat +import org.oppia.android.testing.robolectric.RobolectricModule +import org.oppia.android.testing.threading.TestDispatcherModule +import org.oppia.android.testing.time.FakeOppiaClockModule +import org.oppia.android.util.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule +import org.oppia.android.util.caching.testing.CachingTestModule +import org.oppia.android.util.gcsresource.GcsResourceModule +import org.oppia.android.util.locale.testing.LocaleTestModule +import org.oppia.android.util.logging.LoggerModule +import org.oppia.android.util.logging.firebase.FirebaseLogUploaderModule +import org.oppia.android.util.math.MathExpressionParser +import org.oppia.android.util.math.MathExpressionParser.Companion.ErrorCheckingMode +import org.oppia.android.util.math.MathExpressionParser.Companion.ErrorCheckingMode.ALL_ERRORS +import org.oppia.android.util.math.MathExpressionParser.Companion.ErrorCheckingMode.REQUIRED_ONLY +import org.oppia.android.util.math.MathExpressionParser.Companion.MathParsingResult +import org.oppia.android.util.math.ONE_HALF +import org.oppia.android.util.networking.NetworkConnectionDebugUtilModule +import org.oppia.android.util.networking.NetworkConnectionUtilDebugModule +import org.oppia.android.util.parser.html.HtmlParserEntityTypeModule +import org.oppia.android.util.parser.image.GlideImageLoaderModule +import org.oppia.android.util.parser.image.ImageParsingModule +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode +import javax.inject.Singleton + +/** + * Tests for [MathExpressionAccessibilityUtil]. + * + * Note that this test suite does not make an effort to differentiate tests for numeric and + * algebraic expressions since it's mainly testing [MathExpression] and [MathEquation] structures, + * and relies on other test suites to verify that raw numeric expressions can be correctly converted + * to [MathExpression]s. + */ +// FunctionName: test names are conventionally named with underscores. +@Suppress("FunctionName") +@RunWith(OppiaParameterizedTestRunner::class) +@SelectRunnerPlatform(ParameterizedRobolectricTestRunner::class) +@LooperMode(LooperMode.Mode.PAUSED) +@Config(application = MathExpressionAccessibilityUtilTest.TestApplication::class) +class MathExpressionAccessibilityUtilTest { + @get:Rule + val initializeDefaultLocaleRule = InitializeDefaultLocaleRule() + + @get:Rule + var activityRule = + ActivityScenarioRule( + TestActivity.createIntent(ApplicationProvider.getApplicationContext()) + ) + + @Parameter lateinit var language: String + @Parameter lateinit var expression: String + @Parameter lateinit var equation: String + @Parameter lateinit var a11yStr: String + + lateinit var util: MathExpressionAccessibilityUtil + + @Before + fun setUp() { + setUpTestApplicationComponent() + activityRule.scenario.onActivity { util = it.mathExpressionAccessibilityUtil } + } + + @Test + fun testConvertToString_defaultExp_english_returnsNull() { + val exp = MathExpression.getDefaultInstance() + + assertThat(exp).forHumanReadable(ENGLISH).doesNotConvertToString() + } + + @Test + fun testConvertToString_defaultEq_english_returnsNull() { + val eq = MathEquation.getDefaultInstance() + + assertThat(eq).forHumanReadable(ENGLISH).doesNotConvertToString() + } + + @Test + @RunParameterized( + Iteration("LANGUAGE_UNSPECIFIED", "language=LANGUAGE_UNSPECIFIED"), + Iteration("ARABIC", "language=ARABIC"), + Iteration("HINDI", "language=HINDI"), + Iteration("HINGLISH", "language=HINGLISH"), + Iteration("PORTUGUESE", "language=PORTUGUESE"), + Iteration("BRAZILIAN_PORTUGUESE", "language=BRAZILIAN_PORTUGUESE"), + Iteration("UNRECOGNIZED", "language=UNRECOGNIZED") + ) + fun testConvertToString_constExp_unsupportedLanguage_returnsNull() { + val exp = parseAlgebraicExpression("2") + val language = OppiaLanguage.valueOf(language) + + assertThat(exp).forHumanReadable(language).doesNotConvertToString() + } + + @Test + @RunParameterized( + Iteration("LANGUAGE_UNSPECIFIED", "language=LANGUAGE_UNSPECIFIED"), + Iteration("ARABIC", "language=ARABIC"), + Iteration("HINDI", "language=HINDI"), + Iteration("HINGLISH", "language=HINGLISH"), + Iteration("PORTUGUESE", "language=PORTUGUESE"), + Iteration("BRAZILIAN_PORTUGUESE", "language=BRAZILIAN_PORTUGUESE"), + Iteration("UNRECOGNIZED", "language=UNRECOGNIZED") + ) + fun testConvertToString_constEq_unsupportedLanguage_returnsNull() { + val eq = parseAlgebraicEquation("x=2") + val language = OppiaLanguage.valueOf(language) + + assertThat(eq).forHumanReadable(language).doesNotConvertToString() + } + + @Test + fun testTestSuite_verifyLanguageCoverage_allLanguagesCovered() { + // NOTE TO DEVELOPERS: This is a meta test to verify that the tests above are covering all + // supported languages. If this test ever fails, please make sure to update both the list below + // and other relevant tests in this suite. + assertThat(OppiaLanguage.values()) + .asList() + .containsExactly( + LANGUAGE_UNSPECIFIED, ENGLISH, ARABIC, HINDI, HINGLISH, PORTUGUESE, BRAZILIAN_PORTUGUESE, + UNRECOGNIZED + ) + } + + @Test + @RunParameterized( + Iteration("2", "expression=2", "a11yStr=2"), + Iteration("123", "expression=123", "a11yStr=123"), + Iteration("1234", "expression=1234", "a11yStr=1,234"), + Iteration("12345", "expression=12345", "a11yStr=12,345"), + Iteration("123456", "expression=123456", "a11yStr=123,456"), + Iteration("1234567", "expression=1234567", "a11yStr=1,234,567") + ) + fun testConvertToString_eng_constIntExp_returnsIntegerConvertedString() { + val exp = parseAlgebraicExpression(expression) + + assertThat(exp).forHumanReadable(ENGLISH).convertsToStringThat().isEqualTo(a11yStr) + } + + @Test + @RunParameterized( + // Note that some rounding occurs when formatting doubles with decimals. + Iteration("2.0", "expression=2.0", "a11yStr=2"), + Iteration("3.14", "expression=3.14", "a11yStr=3.14"), + Iteration( + "long_pi", "expression=3.14159265358979323846264338327950288419716939937510", "a11yStr=3.142" + ), + Iteration("1234.0", "expression=1234.0", "a11yStr=1,234"), + Iteration("12345.0", "expression=12345.0", "a11yStr=12,345"), + Iteration("123456.0", "expression=123456.0", "a11yStr=123,456"), + Iteration("1234567.0", "expression=1234567.0", "a11yStr=1,234,567"), + Iteration("1234567.987654321", "expression=1234567.987654321", "a11yStr=1,234,567.988"), + // Verify that scientific notation isn't used. + Iteration("small_number", "expression=0.000000000000000000001", "a11yStr=0"), + Iteration( + "large_number", "expression=123456789101112131415.0", "a11yStr=123,456,789,101,112,130,000" + ) + ) + fun testConvertToString_eng_constDoubleExp_returnsDoubleConvertedString() { + val exp = parseAlgebraicExpression(expression) + + assertThat(exp).forHumanReadable(ENGLISH).convertsToStringThat().isEqualTo(a11yStr) + } + + @Test + @RunParameterized( + Iteration("x", "expression=x", "a11yStr=x"), + Iteration("y", "expression=y", "a11yStr=y"), + Iteration("z", "expression=z", "a11yStr=zed"), + Iteration("X", "expression=X", "a11yStr=X"), + Iteration("Y", "expression=Y", "a11yStr=Y"), + Iteration("Z", "expression=Z", "a11yStr=Zed"), + Iteration("a", "expression=a", "a11yStr=a") + ) + fun testConvertToString_eng_variableExp_returnsVariableNameWithZed() { + val allowedVariables = listOf("a", "x", "y", "z", "X", "Y", "Z") + val exp = parseAlgebraicExpression(expression, allowedVariables) + + assertThat(exp).forHumanReadable(ENGLISH).convertsToStringThat().isEqualTo(a11yStr) + } + + @Test + @RunParameterized( + Iteration("1+2", "expression=1+2", "a11yStr=1 plus 2"), + Iteration("1+x", "expression=1+x", "a11yStr=1 plus x"), + Iteration("z+1234", "expression=z+1234", "a11yStr=zed plus 1,234"), + Iteration("z+3.14", "expression=z+3.14", "a11yStr=zed plus 3.14"), + Iteration("x+z", "expression=x+z", "a11yStr=x plus zed") + ) + fun testConvertToString_eng_addition_returnsLeftPlusRightString() { + val exp = parseAlgebraicExpression(expression) + + assertThat(exp).forHumanReadable(ENGLISH).convertsToStringThat().isEqualTo(a11yStr) + } + + @Test + @RunParameterized( + Iteration("1-2", "expression=1-2", "a11yStr=1 minus 2"), + Iteration("1-x", "expression=1-x", "a11yStr=1 minus x"), + Iteration("z-1234", "expression=z-1234", "a11yStr=zed minus 1,234"), + Iteration("z-3.14", "expression=z-3.14", "a11yStr=zed minus 3.14"), + Iteration("x-z", "expression=x-z", "a11yStr=x minus zed") + ) + fun testConvertToString_eng_subtraction_returnsLeftMinusRightString() { + val exp = parseAlgebraicExpression(expression) + + assertThat(exp).forHumanReadable(ENGLISH).convertsToStringThat().isEqualTo(a11yStr) + } + + @Test + @RunParameterized( + Iteration("1*2", "expression=1*2", "a11yStr=1 times 2"), + Iteration("1*x", "expression=1*x", "a11yStr=1 times x"), + Iteration("z*1234", "expression=z*1234", "a11yStr=zed times 1,234"), + Iteration("z*3.14", "expression=z*3.14", "a11yStr=zed times 3.14"), + Iteration("x*z", "expression=x*z", "a11yStr=x times zed") + ) + fun testConvertToString_eng_multiplication_returnsLeftTimesRightString() { + val exp = parseAlgebraicExpression(expression) + + assertThat(exp).forHumanReadable(ENGLISH).convertsToStringThat().isEqualTo(a11yStr) + } + + @Test + @RunParameterized( + Iteration("1/2", "expression=1/2", "a11yStr=1 divided by 2"), + Iteration("1/x", "expression=1/x", "a11yStr=1 divided by x"), + Iteration("z/1234", "expression=z/1234", "a11yStr=zed divided by 1,234"), + Iteration("z/3.14", "expression=z/3.14", "a11yStr=zed divided by 3.14"), + Iteration("x/z", "expression=x/z", "a11yStr=x divided by zed") + ) + fun testConvertToString_eng_division_returnsLeftDividedByRightString() { + val exp = parseAlgebraicExpression(expression) + + assertThat(exp).forHumanReadable(ENGLISH).convertsToStringThat().isEqualTo(a11yStr) + } + + @Test + @RunParameterized( + Iteration("1^2", "expression=1^2", "a11yStr=1 raised to the power of 2"), + Iteration("1^x", "expression=1^x", "a11yStr=1 raised to the power of x"), + Iteration("z^1234", "expression=z^1234", "a11yStr=zed raised to the power of 1,234"), + Iteration("z^3.14", "expression=z^3.14", "a11yStr=zed raised to the power of 3.14"), + Iteration("x^z", "expression=x^z", "a11yStr=x raised to the power of zed") + ) + fun testConvertToString_eng_exponentiation_returnsLeftRaisedToThePowerOfRightString() { + // Some expressions may include variable terms as exponents (which normally isn't allowed). + val exp = parseAlgebraicExpression(expression, errorCheckingMode = REQUIRED_ONLY) + + assertThat(exp).forHumanReadable(ENGLISH).convertsToStringThat().isEqualTo(a11yStr) + } + + @Test + @RunParameterized( + Iteration("-2", "expression=-2", "a11yStr=negative 2"), + Iteration("-x", "expression=-x", "a11yStr=negative x"), + Iteration("-1234", "expression=-1234", "a11yStr=negative 1,234"), + Iteration("-3.14", "expression=-3.14", "a11yStr=negative 3.14"), + Iteration("-z", "expression=-z", "a11yStr=negative zed") + ) + fun testConvertToString_eng_negation_returnsNegativeOperandString() { + val exp = parseAlgebraicExpression(expression) + + assertThat(exp).forHumanReadable(ENGLISH).convertsToStringThat().isEqualTo(a11yStr) + } + + @Test + @RunParameterized( + Iteration("+2", "expression=+2", "a11yStr=positive 2"), + Iteration("+x", "expression=+x", "a11yStr=positive x"), + Iteration("+1234", "expression=+1234", "a11yStr=positive 1,234"), + Iteration("+3.14", "expression=+3.14", "a11yStr=positive 3.14"), + Iteration("+z", "expression=+z", "a11yStr=positive zed") + ) + fun testConvertToString_eng_positiveUnary_returnsPositiveOperandString() { + // Allow positive unary operations to verify this case. + val exp = parseAlgebraicExpression(expression, errorCheckingMode = REQUIRED_ONLY) + + assertThat(exp).forHumanReadable(ENGLISH).convertsToStringThat().isEqualTo(a11yStr) + } + + @Test + @RunParameterized( + Iteration("√2", "expression=√2", "a11yStr=square root of 2"), + Iteration("√x", "expression=√x", "a11yStr=square root of x"), + Iteration("√z", "expression=√z", "a11yStr=square root of zed"), + Iteration("√1234", "expression=√1234", "a11yStr=square root of 1,234"), + Iteration("√3.14", "expression=√3.14", "a11yStr=square root of 3.14"), + Iteration("√(2)", "expression=√(2)", "a11yStr=square root of 2"), + Iteration("√(x)", "expression=√(x)", "a11yStr=square root of x"), + Iteration("√(z)", "expression=√(z)", "a11yStr=square root of zed"), + Iteration("√(1234)", "expression=√(1234)", "a11yStr=square root of 1,234"), + Iteration("√(3.14)", "expression=√(3.14)", "a11yStr=square root of 3.14") + ) + fun testConvertToString_eng_inlineSqrt_returnsSquareRootOfArgumentString() { + // Allow for single-term parentheses for testing (even though these cases would normally result + // in errors). + val exp = parseAlgebraicExpression(expression, errorCheckingMode = REQUIRED_ONLY) + + assertThat(exp).forHumanReadable(ENGLISH).convertsToStringThat().isEqualTo(a11yStr) + } + + @Test + @RunParameterized( + Iteration("sqrt(2)", "expression=sqrt(2)", "a11yStr=square root of 2"), + Iteration("sqrt(x)", "expression=sqrt(x)", "a11yStr=square root of x"), + Iteration("sqrt(z)", "expression=sqrt(z)", "a11yStr=square root of zed"), + Iteration("sqrt(1234)", "expression=sqrt(1234)", "a11yStr=square root of 1,234"), + Iteration("sqrt(3.14)", "expression=sqrt(3.14)", "a11yStr=square root of 3.14") + ) + fun testConvertToString_eng_sqrt_returnsSquareRootOfArgumentString() { + val exp = parseAlgebraicExpression(expression) + + assertThat(exp).forHumanReadable(ENGLISH).convertsToStringThat().isEqualTo(a11yStr) + } + + @Test + @RunParameterized( + Iteration("(2)", "expression=(2)", "a11yStr=2"), + Iteration("(x)", "expression=(x)", "a11yStr=x"), + Iteration("(z)", "expression=(z)", "a11yStr=zed"), + Iteration("(1234)", "expression=(1234)", "a11yStr=1,234"), + Iteration("(3.14)", "expression=(3.14)", "a11yStr=3.14"), + Iteration("((2))", "expression=((2))", "a11yStr=2"), + Iteration("((x))", "expression=((x))", "a11yStr=x"), + Iteration("((z))", "expression=((z))", "a11yStr=zed"), + Iteration("((1234))", "expression=((1234))", "a11yStr=1,234"), + Iteration("((3.14))", "expression=((3.14))", "a11yStr=3.14"), + Iteration("(√2)", "expression=(√2)", "a11yStr=square root of 2"), + Iteration("(√x)", "expression=(√x)", "a11yStr=square root of x"), + Iteration("(sqrt(2))", "expression=(sqrt(2))", "a11yStr=square root of 2"), + Iteration("(sqrt(x))", "expression=(sqrt(x))", "a11yStr=square root of x") + ) + fun testConvertToString_eng_group_singleTermOrNestedSingleTerm_returnsDirectString() { + // Allow for single-term parentheses for testing (even though these cases would normally result + // in errors). + val exp = parseAlgebraicExpression(expression, errorCheckingMode = REQUIRED_ONLY) + + // Verify that groups are not included in the final string when they only encapsulate single + // terms. + assertThat(exp).forHumanReadable(ENGLISH).convertsToStringThat().isEqualTo(a11yStr) + } + + @Test + @RunParameterized( + Iteration("(1+2)", "expression=(1+2)", "a11yStr=open parenthesis 1 plus 2 close parenthesis"), + Iteration("(1+x)", "expression=(1+x)", "a11yStr=open parenthesis 1 plus x close parenthesis"), + Iteration("(1+z)", "expression=(1+z)", "a11yStr=open parenthesis 1 plus zed close parenthesis"), + Iteration( + "(1+1234)", "expression=(1+1234)", "a11yStr=open parenthesis 1 plus 1,234 close parenthesis" + ), + Iteration( + "(1+3.14)", "expression=(1+3.14)", "a11yStr=open parenthesis 1 plus 3.14 close parenthesis" + ), + Iteration("(1-2)", "expression=(1-2)", "a11yStr=open parenthesis 1 minus 2 close parenthesis"), + Iteration("(x-2)", "expression=(x-2)", "a11yStr=open parenthesis x minus 2 close parenthesis"), + Iteration("(1*2)", "expression=(1*2)", "a11yStr=open parenthesis 1 times 2 close parenthesis"), + Iteration("(x*2)", "expression=(x*2)", "a11yStr=open parenthesis x times 2 close parenthesis"), + Iteration( + "(1/2)", "expression=(1/2)", "a11yStr=open parenthesis 1 divided by 2 close parenthesis" + ), + Iteration( + "(x/2)", "expression=(x/2)", "a11yStr=open parenthesis x divided by 2 close parenthesis" + ), + Iteration( + "(1^2)", + "expression=(1^2)", + "a11yStr=open parenthesis 1 raised to the power of 2 close parenthesis" + ), + Iteration( + "(x^2)", + "expression=(x^2)", + "a11yStr=open parenthesis x raised to the power of 2 close parenthesis" + ), + Iteration("(-2)", "expression=(-2)", "a11yStr=open parenthesis negative 2 close parenthesis"), + Iteration("(-x)", "expression=(-x)", "a11yStr=open parenthesis negative x close parenthesis"), + Iteration("(+2)", "expression=(+2)", "a11yStr=open parenthesis positive 2 close parenthesis"), + Iteration("(+x)", "expression=(+x)", "a11yStr=open parenthesis positive x close parenthesis") + ) + fun testConvertToString_eng_group_nestedOps_returnOpenParensOpCloseParensString() { + // Allow for the outer expression to have redundant parentheses to test cases when groups are + // announced (even though these exact cases would normally result in an error). + val exp = parseAlgebraicExpression(expression, errorCheckingMode = REQUIRED_ONLY) + + assertThat(exp).forHumanReadable(ENGLISH).convertsToStringThat().isEqualTo(a11yStr) + } + + @Test + @RunParameterized( + Iteration("√-2", "expression=√-2", "a11yStr=start square root negative 2 end square root"), + Iteration("√-x", "expression=√-x", "a11yStr=start square root negative x end square root"), + Iteration("√+2", "expression=√+2", "a11yStr=start square root positive 2 end square root"), + Iteration("√+x", "expression=√+x", "a11yStr=start square root positive x end square root"), + // Note that these cases compose with the group cases since √ only "attached" to the immediate + // next terms rather than being able to encapsulate a whole operation (like sqrt()). + Iteration( + "√(1+2)", + "expression=√(1+2)", + "a11yStr=start square root open parenthesis 1 plus 2 close parenthesis end square root" + ), + Iteration( + "√(1+x)", + "expression=√(1+x)", + "a11yStr=start square root open parenthesis 1 plus x close parenthesis end square root" + ), + Iteration( + "√(1-2)", + "expression=√(1-2)", + "a11yStr=start square root open parenthesis 1 minus 2 close parenthesis end square root" + ), + Iteration( + "√(1-x)", + "expression=√(1-x)", + "a11yStr=start square root open parenthesis 1 minus x close parenthesis end square root" + ), + Iteration( + "√(1*2)", + "expression=√(1*2)", + "a11yStr=start square root open parenthesis 1 times 2 close parenthesis end square root" + ), + Iteration( + "√(1*x)", + "expression=√(1*x)", + "a11yStr=start square root open parenthesis 1 times x close parenthesis end square root" + ), + Iteration( + "√(1/2)", + "expression=√(1/2)", + "a11yStr=start square root open parenthesis 1 divided by 2 close parenthesis end square root" + ), + Iteration( + "√(1/x)", + "expression=√(1/x)", + "a11yStr=start square root open parenthesis 1 divided by x close parenthesis end square root" + ), + Iteration( + "√(1^2)", + "expression=√(1^2)", + "a11yStr=start square root open parenthesis 1 raised to the power of 2 close parenthesis" + + " end square root" + ), + Iteration( + "√(1^x)", + "expression=√(1^x)", + "a11yStr=start square root open parenthesis 1 raised to the power of x close parenthesis" + + " end square root" + ), + Iteration( + "√(-2)", + "expression=√(-2)", + "a11yStr=start square root open parenthesis negative 2 close parenthesis end square root" + ), + Iteration( + "√(-x)", + "expression=√(-x)", + "a11yStr=start square root open parenthesis negative x close parenthesis end square root" + ), + Iteration( + "√(+2)", + "expression=√(+2)", + "a11yStr=start square root open parenthesis positive 2 close parenthesis end square root" + ), + Iteration( + "√(+x)", + "expression=√(+x)", + "a11yStr=start square root open parenthesis positive x close parenthesis end square root" + ) + ) + fun testConvertToString_eng_inlineSqrt_nestedOp_returnsStartSquareRootConstructString() { + // Allow for positive unary expressions. + val exp = parseAlgebraicExpression(expression, errorCheckingMode = REQUIRED_ONLY) + + assertThat(exp).forHumanReadable(ENGLISH).convertsToStringThat().isEqualTo(a11yStr) + } + + @Test + @RunParameterized( + Iteration( + "sqrt(1+2)", "expression=sqrt(1+2)", "a11yStr=start square root 1 plus 2 end square root" + ), + Iteration( + "sqrt(1+x)", "expression=sqrt(1+x)", "a11yStr=start square root 1 plus x end square root" + ), + Iteration( + "sqrt(1-2)", "expression=sqrt(1-2)", "a11yStr=start square root 1 minus 2 end square root" + ), + Iteration( + "sqrt(1-x)", "expression=sqrt(1-x)", "a11yStr=start square root 1 minus x end square root" + ), + Iteration( + "sqrt(1*2)", "expression=sqrt(1*2)", "a11yStr=start square root 1 times 2 end square root" + ), + Iteration( + "sqrt(1*x)", "expression=sqrt(1*x)", "a11yStr=start square root 1 times x end square root" + ), + Iteration( + "sqrt(1/2)", + "expression=sqrt(1/2)", + "a11yStr=start square root 1 divided by 2 end square root" + ), + Iteration( + "sqrt(1/x)", + "expression=sqrt(1/x)", + "a11yStr=start square root 1 divided by x end square root" + ), + Iteration( + "sqrt(1^2)", + "expression=sqrt(1^2)", + "a11yStr=start square root 1 raised to the power of 2 end square root" + ), + Iteration( + "sqrt(1^x)", + "expression=sqrt(1^x)", + "a11yStr=start square root 1 raised to the power of x end square root" + ), + Iteration( + "sqrt(-2)", "expression=sqrt(-2)", "a11yStr=start square root negative 2 end square root" + ), + Iteration( + "sqrt(-x)", "expression=sqrt(-x)", "a11yStr=start square root negative x end square root" + ), + Iteration( + "sqrt(+2)", "expression=sqrt(+2)", "a11yStr=start square root positive 2 end square root" + ), + Iteration( + "sqrt(+x)", "expression=sqrt(+x)", "a11yStr=start square root positive x end square root" + ) + ) + fun testConvertToString_eng_sqrt_nestedOp_returnsStartSquareRootConstructString() { + // Allow for positive unary expressions. + val exp = parseAlgebraicExpression(expression, errorCheckingMode = REQUIRED_ONLY) + + assertThat(exp).forHumanReadable(ENGLISH).convertsToStringThat().isEqualTo(a11yStr) + } + + @Test + @RunParameterized( + // Note that numeric exponentiations must be explicitly multiplied next to a constant. They + // otherwise result in a grammatical error that cannot be resolved. + Iteration("2x", "expression=2x", "a11yStr=2 x"), + Iteration("2z", "expression=2z", "a11yStr=2 zed"), + Iteration("2x^3", "expression=2x^3", "a11yStr=2 x raised to the power of 3"), + Iteration("2z^3", "expression=2z^3", "a11yStr=2 zed raised to the power of 3"), + Iteration("1234x^3.14", "expression=1234x^3.14", "a11yStr=1,234 x raised to the power of 3.14") + ) + fun testConvertToString_eng_implicitMult_leftConst_rightVarOrExp_returnsLeftRightString() { + val exp = parseAlgebraicExpression(expression) + + // Verify that the format [^ ] results in an implicit multiplication with + // no 'times' announced. + assertThat(exp).forHumanReadable(ENGLISH).convertsToStringThat().isEqualTo(a11yStr) + } + + @Test + @RunParameterized( + Iteration("xz", "expression=xz", "a11yStr=x times zed"), + Iteration("2xy", "expression=2yx", "a11yStr=2 y times x"), + Iteration("2√x", "expression=2√x", "a11yStr=2 times square root of x"), + Iteration("2sqrt(x)", "expression=2sqrt(x)", "a11yStr=2 times square root of x"), + Iteration("2(3)", "expression=2(3)", "a11yStr=2 times 3"), + Iteration("2(x)", "expression=2(x)", "a11yStr=2 times x"), + Iteration( + "2(x^3)", + "expression=2(x^3)", + "a11yStr=2 times open parenthesis x raised to the power of 3 close parenthesis" + ) + ) + fun testConvertToString_eng_impMult_nonLeftConst_orRightIsNotVarOrExp_returnsLeftTimesRightStr() { + // Allow for redundant single-term parentheses. + val exp = parseAlgebraicExpression(expression, errorCheckingMode = REQUIRED_ONLY) + + // If anything breaks up the format tested in the previous test (even if it's a group), then the + // multiplication is explicitly read out. + assertThat(exp).forHumanReadable(ENGLISH).convertsToStringThat().isEqualTo(a11yStr) + } + + @Test + fun testConvertToString_eng_divisionAsFractions_oneDivTwo_returnsOneHalfString() { + val exp = parseAlgebraicExpression("1/2") + + // 1/2 is a special case. + assertThat(exp) + .forHumanReadable(ENGLISH) + .convertsWithFractionsToStringThat().isEqualTo("one half") + } + + @Test + @RunParameterized( + Iteration("0/1", "expression=0/1", "a11yStr=0 over 1"), + Iteration("1/1", "expression=1/1", "a11yStr=1 over 1"), + Iteration("0/2", "expression=0/2", "a11yStr=0 over 2"), + Iteration("2/2", "expression=2/2", "a11yStr=2 over 2"), + Iteration("0/3", "expression=0/3", "a11yStr=0 over 3"), + Iteration("1/3", "expression=1/3", "a11yStr=1 over 3"), + Iteration("2/3", "expression=2/3", "a11yStr=2 over 3"), + Iteration("3/3", "expression=3/3", "a11yStr=3 over 3"), + Iteration("4/3", "expression=4/3", "a11yStr=4 over 3"), + Iteration("5/3", "expression=5/3", "a11yStr=5 over 3"), + Iteration("6/3", "expression=6/3", "a11yStr=6 over 3"), + Iteration("5/9", "expression=5/9", "a11yStr=5 over 9"), + Iteration("19/3", "expression=19/3", "a11yStr=19 over 3"), + Iteration("2/17", "expression=2/17", "a11yStr=2 over 17") + ) + fun testConvertToString_eng_divisionAsFractions_smallIntegerFracs_returnsNumOverDenomString() { + val exp = parseAlgebraicExpression(expression) + + assertThat(exp).forHumanReadable(ENGLISH).convertsWithFractionsToStringThat().isEqualTo(a11yStr) + } + + @Test + @RunParameterized( + Iteration("1/1234", "expression=1/1234", "a11yStr=1 over 1,234"), + Iteration("1234/1", "expression=1234/1", "a11yStr=1,234 over 1"), + Iteration("1234/987654", "expression=1234/987654", "a11yStr=1,234 over 987,654") + ) + fun testConvertToString_eng_divisionAsFractions_largeIntegerFracs_returnsNumOverDenomString() { + val exp = parseAlgebraicExpression(expression) + + // Large numbers are read as part of the fraction. + assertThat(exp).forHumanReadable(ENGLISH).convertsWithFractionsToStringThat().isEqualTo(a11yStr) + } + + @Test + @RunParameterized( + Iteration("1/x", "expression=1/x", "a11yStr=1 over x"), + Iteration("1/z", "expression=1/z", "a11yStr=1 over zed"), + Iteration("x/2", "expression=x/2", "a11yStr=x over 2"), + Iteration("z/3", "expression=z/3", "a11yStr=zed over 3"), + Iteration("x/z", "expression=x/z", "a11yStr=x over zed") + ) + fun testConvertToString_eng_divisionAsFractions_fracsWithVariables_returnsNumOverDenomString() { + val exp = parseAlgebraicExpression(expression) + + // Variables are read as part of the fraction. + assertThat(exp).forHumanReadable(ENGLISH).convertsWithFractionsToStringThat().isEqualTo(a11yStr) + } + + @Test + @RunParameterized( + Iteration( + "x/√2", + "expression=x/√2", + "a11yStr=the fraction with numerator x and denominator square root of 2" + ), + Iteration( + "x/-2", + "expression=x/-2", + "a11yStr=the fraction with numerator x and denominator negative 2" + ), + Iteration( + "2/(1+2)", + "expression=2/(1+2)", + "a11yStr=the fraction with numerator 2 and denominator open parenthesis 1 plus 2 close" + + " parenthesis" + ), + // Nested fractions still cause the outer fraction to be read out the long way. + Iteration( + "2/(1/2)", + "expression=2/(1/2)", + "a11yStr=the fraction with numerator 2 and denominator open parenthesis one half close" + + " parenthesis" + ), + Iteration( + "2/(1/3)", + "expression=2/(1/3)", + "a11yStr=the fraction with numerator 2 and denominator open parenthesis 1 over 3 close" + + " parenthesis" + ), + Iteration( + "x/sqrt(y/3)", + "expression=x/sqrt(y/3)", + "a11yStr=the fraction with numerator x and denominator start square root y over 3 end" + + " square root" + ), + Iteration( + "3.14/x", "expression=3.14/x", "a11yStr=the fraction with numerator 3.14 and denominator x" + ), + Iteration( + "x/3.14", "expression=x/3.14", "a11yStr=the fraction with numerator x and denominator 3.14" + ) + ) + fun testConvertToString_eng_divisionAsFractions_fracWithComplexParts_returnsFracConstructStr() { + val exp = parseAlgebraicExpression(expression) + + // Verify that complex fractions are read out with more specificity. + assertThat(exp).forHumanReadable(ENGLISH).convertsWithFractionsToStringThat().isEqualTo(a11yStr) + } + + @Test + @RunParameterized( + Iteration("1=2", "expression=1=2", "a11yStr=1 equals 2"), + Iteration("x=1", "expression=x=1", "a11yStr=x equals 1"), + Iteration("z=1", "expression=z=1", "a11yStr=zed equals 1"), + Iteration("2=x", "expression=2=x", "a11yStr=2 equals x"), + Iteration("2=z", "expression=2=z", "a11yStr=2 equals zed"), + Iteration("x=z", "expression=x=z", "a11yStr=x equals zed") + ) + fun testConvertToString_eng_simpleEquation_returnsLeftEqualsRightString() { + val eq = parseAlgebraicEquation(expression) + + assertThat(eq).forHumanReadable(ENGLISH).convertsToStringThat().isEqualTo(a11yStr) + } + + @Test + @RunParameterized( + Iteration("xyz", "expression=xyz", "a11yStr=x times y times zed"), + Iteration("1+x+x^2", "expression=1+x+x^2", "a11yStr=1 plus x plus x raised to the power of 2"), + Iteration( + "-3x^2+23x-14", + "expression=-3x^2+23x-14", + "a11yStr=negative 3 times x raised to the power of 2 plus 23 x minus 14" + ), + Iteration( + "y^2+xy+x^2", + "expression=y^2+xy+x^2", + "a11yStr=y raised to the power of 2 plus x times y plus x raised to the power of 2" + ) + ) + fun testConvertToString_eng_polynomialExpressions_returnsCorrectlyBuiltString() { + val exp = parseAlgebraicExpression(expression) + + // Polynomials should be read out correctly. + assertThat(exp).forHumanReadable(ENGLISH).convertsToStringThat().isEqualTo(a11yStr) + } + + @Test + @RunParameterized( + Iteration("z=xyz", "expression=z=xyz", "a11yStr=zed equals x times y times zed"), + Iteration( + "y=1+x+x^2", + "expression=y=1+x+x^2", + "a11yStr=y equals 1 plus x plus x raised to the power of 2" + ), + Iteration( + "-3x^2+23x-14=7y^3", + "expression=-3x^2+23x-14=7y^3", + "a11yStr=negative 3 times x raised to the power of 2 plus 23 x minus 14 equals 7 y raised" + + " to the power of 3" + ), + Iteration( + "sqrt(z)=y^2+xy+x^2", + "expression=sqrt(z)=y^2+xy+x^2", + "a11yStr=square root of zed equals y raised to the power of 2 plus x times y plus x raised" + + " to the power of 2" + ) + ) + fun testConvertToString_eng_polynomialEquations_returnsCorrectlyBuiltString() { + val eq = parseAlgebraicEquation(expression) + + // Polynomial equations should be read out correctly. + assertThat(eq).forHumanReadable(ENGLISH).convertsToStringThat().isEqualTo(a11yStr) + } + + @Test + @RunParameterized( + Iteration( + "(x^2+2x+1)/(x+1)", + "expression= ( x^2 + 2x + 1 ) /( x + 1)", + "a11yStr=open parenthesis x raised to the power of 2 plus 2 x plus 1 close parenthesis" + + " divided by open parenthesis x plus 1 close parenthesis" + ), + Iteration( + "(1/2)x", + "expression=(1/2) x", + "a11yStr=open parenthesis 1 divided by 2 close parenthesis times x" + ), + Iteration( + "(-27x^3)^(1/3)", + "expression=(\t-27x\n^3\r)^(1 / 3) ", + "a11yStr=open parenthesis negative 27 times x raised to the power of 3 close parenthesis" + + " raised to the power of open parenthesis 1 divided by 3 close parenthesis" + ), + Iteration( + "(4x^2)^(-1/2)", + "expression=( 4x ^ 2) ^ ( - 1 / 2 ) ", + "a11yStr=open parenthesis 4 x raised to the power of 2 close parenthesis raised to the" + + " power of open parenthesis negative 1 divided by 2 close parenthesis" + ), + Iteration( + "sqrt(sqrt(sqrt(x)+1))", + "expression=sqrt( sqrt( sqrt( x ) + 1 ) )", + "a11yStr=square root of start square root square root of x plus 1 end square root" + ), + Iteration( + "x-(1+(y-(2+z)))", + "expression= x - ( 1 + ( y - ( 2 + z )))", + "a11yStr=x minus open parenthesis 1 plus open parenthesis y minus open parenthesis 2 plus" + + " zed close parenthesis close parenthesis close parenthesis" + ), + Iteration( + "1/(2/(y+3/z))", + "expression=1 / ( 2 / ( y + 3/z ) )", + "a11yStr=1 divided by open parenthesis 2 divided by open parenthesis y plus 3 divided by" + + " zed close parenthesis close parenthesis" + ), + Iteration( + "x/y/z/2", "expression= x/ y/ z/ 2", "a11yStr=x divided by y divided by zed divided by 2" + ) + ) + fun testConvertToString_eng_complexNestedExpression_returnsCorrectlyBuiltString() { + val exp = parseAlgebraicExpression(expression) + + // The expression should correctly convert to a readable string, and all original whitespace + // should be ignored in the final rendered string. + assertThat(exp).forHumanReadable(ENGLISH).convertsToStringThat().isEqualTo(a11yStr) + } + + @Test + @RunParameterized( + Iteration( + "(x^2+2x+1)/(x+1)", + "expression= ( x^2 + 2x + 1 ) /( x + 1)", + "a11yStr=the fraction with numerator open parenthesis x raised to the power of 2 plus 2 x" + + " plus 1 close parenthesis and denominator open parenthesis x plus 1 close parenthesis" + ), + Iteration( + "(1/2)x", "expression=(1/2) x", "a11yStr=open parenthesis one half close parenthesis times x" + ), + Iteration( + "(-27x^3)^(1/3)", + "expression=(\t-27x\n^3\r)^(1 / 3) ", + "a11yStr=open parenthesis negative 27 times x raised to the power of 3 close parenthesis" + + " raised to the power of open parenthesis 1 over 3 close parenthesis" + ), + Iteration( + "(4x^2)^(-1/2)", + "expression=( 4x ^ 2) ^ ( - 1 / 2 ) ", + "a11yStr=open parenthesis 4 x raised to the power of 2 close parenthesis raised to the" + + " power of open parenthesis the fraction with numerator negative 1 and denominator 2" + + " close parenthesis" + ), + Iteration( + "sqrt(sqrt(sqrt(x)+1))", + "expression=sqrt( sqrt( sqrt( x ) + 1 ) )", + "a11yStr=square root of start square root square root of x plus 1 end square root" + ), + Iteration( + "x-(1+(y-(2+z)))", + "expression= x - ( 1 + ( y - ( 2 + z )))", + "a11yStr=x minus open parenthesis 1 plus open parenthesis y minus open parenthesis 2 plus" + + " zed close parenthesis close parenthesis close parenthesis" + ), + Iteration( + "1/(2/(y+3/z))", + "expression=1 / ( 2 / ( y + 3/z ) )", + "a11yStr=the fraction with numerator 1 and denominator open parenthesis the fraction with" + + " numerator 2 and denominator open parenthesis y plus 3 over zed close parenthesis" + + " close parenthesis" + ), + Iteration( + "x/y/z/2", + "expression= x/ y/ z/ 2", + "a11yStr=the fraction with numerator the fraction with numerator x over y and denominator" + + " zed and denominator 2" + ) + ) + fun testConvertToString_eng_complexNestedExpression_divAsFracs_returnsCorrectlyBuiltString() { + val exp = parseAlgebraicExpression(expression) + + // The expression should correctly convert to a readable string, and all original whitespace + // should be ignored in the final rendered string. + assertThat(exp).forHumanReadable(ENGLISH).convertsWithFractionsToStringThat().isEqualTo(a11yStr) + } + + @Test + @RunParameterized( + Iteration( + "y=(x^2+2x+1)/(x+1)", + "expression= y = ( x^2 + 2x + 1 ) /( x + 1)", + "a11yStr=y equals open parenthesis x raised to the power of 2 plus 2 x plus 1 close" + + " parenthesis divided by open parenthesis x plus 1 close parenthesis" + ), + Iteration( + "(1/2)x=sqrt(x)", + "expression=(1/2) x =sqrt (x)", + "a11yStr=open parenthesis 1 divided by 2 close parenthesis times x equals square root of x" + ), + Iteration( + "-3x=(-27x^3)^(1/3)", + "expression=\n-\n3\nx\n=\n(\t-27x\n^3\r)^(1 / 3) ", + "a11yStr=negative 3 times x equals open parenthesis negative 27 times x raised to the power" + + " of 3 close parenthesis raised to the power of open parenthesis 1 divided by 3 close" + + " parenthesis" + ), + Iteration( + "(4x^2)^(-1/2)=1+x", + "expression=( 4x ^ 2) ^ ( - 1 / 2 ) =1 + x ", + "a11yStr=open parenthesis 4 x raised to the power of 2 close parenthesis raised to the" + + " power of open parenthesis negative 1 divided by 2 close parenthesis equals 1 plus x" + ), + Iteration( + "sqrt(sqrt(sqrt(x)+1))=1/2", + "expression=sqrt( sqrt( sqrt( x ) + 1 ) ) = 1 / 2", + "a11yStr=square root of start square root square root of x plus 1 end square root equals 1" + + " divided by 2" + ), + Iteration( + "xy+x+y=x-(1+(y-(2+z)))", + "expression=xy+x+y=x - ( 1 + ( y - ( 2 + z )))", + "a11yStr=x times y plus x plus y equals x minus open parenthesis 1 plus open parenthesis y" + + " minus open parenthesis 2 plus zed close parenthesis close parenthesis close parenthesis" + ), + Iteration( + "x=1/(2/(y+3/z))", + "expression= x = 1 / ( 2 / ( y + 3/z ) )", + "a11yStr=x equals 1 divided by open parenthesis 2 divided by open parenthesis y plus 3" + + " divided by zed close parenthesis close parenthesis" + ), + Iteration( + "x/y/z/2=z", + "expression= x/ y/ z/ 2=z", + "a11yStr=x divided by y divided by zed divided by 2 equals zed" + ) + ) + fun testConvertToString_eng_complexNestedEquations_returnsCorrectlyBuiltString() { + val eq = parseAlgebraicEquation(expression) + + // The equation should correctly convert to a readable string, and all original whitespace + // should be ignored in the final rendered string. + assertThat(eq).forHumanReadable(ENGLISH).convertsToStringThat().isEqualTo(a11yStr) + } + + @Test + @RunParameterized( + Iteration( + "y=(x^2+2x+1)/(x+1)", + "expression= y = ( x^2 + 2x + 1 ) /( x + 1)", + "a11yStr=y equals the fraction with numerator open parenthesis x raised to the power of 2" + + " plus 2 x plus 1 close parenthesis and denominator open parenthesis x plus 1 close" + + " parenthesis" + ), + Iteration( + "(1/2)x=sqrt(x)", + "expression=(1/2) x =sqrt (x)", + "a11yStr=open parenthesis one half close parenthesis times x equals square root of x" + ), + Iteration( + "-3x=(-27x^3)^(1/3)", + "expression=\n-\n3\nx\n=\n(\t-27x\n^3\r)^(1 / 3) ", + "a11yStr=negative 3 times x equals open parenthesis negative 27 times x raised to the power" + + " of 3 close parenthesis raised to the power of open parenthesis 1 over 3 close parenthesis" + ), + Iteration( + "(4x^2)^(-1/2)=1+x", + "expression=( 4x ^ 2) ^ ( - 1 / 2 ) =1 + x ", + "a11yStr=open parenthesis 4 x raised to the power of 2 close parenthesis raised to the" + + " power of open parenthesis the fraction with numerator negative 1 and denominator 2" + + " close parenthesis equals 1 plus x" + ), + Iteration( + "sqrt(sqrt(sqrt(x)+1))=1/2", + "expression=sqrt( sqrt( sqrt( x ) + 1 ) ) = 1 / 2", + "a11yStr=square root of start square root square root of x plus 1 end square root equals" + + " one half" + ), + Iteration( + "xy+x+y=x-(1+(y-(2+z)))", + "expression=xy+x+y=x - ( 1 + ( y - ( 2 + z )))", + "a11yStr=x times y plus x plus y equals x minus open parenthesis 1 plus open parenthesis y" + + " minus open parenthesis 2 plus zed close parenthesis close parenthesis close parenthesis" + ), + Iteration( + "x=1/(2/(y+3/z))", + "expression= x = 1 / ( 2 / ( y + 3/z ) )", + "a11yStr=x equals the fraction with numerator 1 and denominator open parenthesis the" + + " fraction with numerator 2 and denominator open parenthesis y plus 3 over zed close" + + " parenthesis close parenthesis" + ), + Iteration( + "x/y/z/2=z", + "expression= x/ y/ z/ 2=z", + "a11yStr=the fraction with numerator the fraction with numerator x over y and denominator" + + " zed and denominator 2 equals zed" + ) + ) + fun testConvertToString_eng_complexNestedEquations_divAsFracs_returnsCorrectlyBuiltString() { + val eq = parseAlgebraicEquation(expression) + + // The equation should correctly convert to a readable string, and all original whitespace + // should be ignored in the final rendered string. + assertThat(eq).forHumanReadable(ENGLISH).convertsWithFractionsToStringThat().isEqualTo(a11yStr) + } + + // This & the next test are implementing cases defined in the doc: + // https://docs.google.com/document/d/1P-dldXQ08O-02ZRG978paiWOSz0dsvcKpDgiV_rKH_Y/edit#. + @Test + @RunParameterized( + Iteration( + "(x + 6)/(x - 4)", + "expression=(x + 6)/(x - 4)", + "a11yStr=the fraction with numerator open parenthesis x plus 6 close parenthesis and" + + " denominator open parenthesis x minus 4 close parenthesis" + ), + Iteration( + "4*(x)^(2)+20x", + "expression=4*(x)^(2)+20x", + "a11yStr=4 times x raised to the power of 2 plus 20 x" + ), + Iteration("3+x-5", "expression=3+x-5", "a11yStr=3 plus x minus 5"), + Iteration("Z+A-Z", "expression=Z+A-Z", "a11yStr=Zed plus A minus Zed"), + Iteration("6C - 5A -1", "expression=6C - 5A -1", "a11yStr=6 C minus 5 A minus 1"), + Iteration("5*Z-w", "expression=5*Z-w", "a11yStr=5 times Zed minus w"), + Iteration("L*S-3S+L", "expression=L*S-3S+L", "a11yStr=L times S minus 3 S plus L"), + Iteration( + "2*(2+6+3+4)", + "expression=2*(2+6+3+4)", + "a11yStr=2 times open parenthesis 2 plus 6 plus 3 plus 4 close parenthesis" + ), + Iteration("sqrt(64)", "expression=sqrt(64)", "a11yStr=square root of 64"), + Iteration( + "√(a+b)", + "expression=√(a+b)", + "a11yStr=start square root open parenthesis a plus b close parenthesis end square root" + ), + Iteration( + "3 * 10^-5", "expression=3 * 10^-5", "a11yStr=3 times 10 raised to the power of negative 5" + ), + Iteration( + "((x+2y) + 5*(a - 2b) + z)", + "expression=((x+2y) + 5*(a - 2b) + z)", + "a11yStr=open parenthesis open parenthesis x plus 2 y close parenthesis plus 5 times open" + + " parenthesis a minus 2 b close parenthesis plus zed close parenthesis" + ) + ) + fun testConvertToString_eng_assortedExpressionsFromPrd_returnsCorrectlyComputedString() { + // Some of the expressions include cases that would normally result in errors. + val exp = parseAlgebraicExpression(expression, errorCheckingMode = REQUIRED_ONLY) + + assertThat(exp).forHumanReadable(ENGLISH).convertsWithFractionsToStringThat().isEqualTo(a11yStr) + } + + @Test + @RunParameterized( + Iteration( + "3x^2 + 4y = 62", + "expression=3x^2 + 4y = 62", + "a11yStr=3 x raised to the power of 2 plus 4 y equals 62" + ) + ) + fun testConvertToString_eng_assortedEquationsFromPrd_returnsCorrectlyComputedString() { + val eq = parseAlgebraicEquation(expression) + + assertThat(eq).forHumanReadable(ENGLISH).convertsToStringThat().isEqualTo(a11yStr) + } + + @Test + fun testConvertToString_eng_rationalConstant_returnsNull() { + val exp = MathExpression.newBuilder().apply { + constant = ONE_HALF + }.build() + + // The conversion should fail since the expression includes a rational real (which aren't yet + // supported). + assertThat(exp).forHumanReadable(ENGLISH).doesNotConvertToString() + } + + @Test + fun testConvertToString_eng_invalidConstant_returnsNull() { + val exp = MathExpression.newBuilder().apply { + constant = Real.getDefaultInstance() + }.build() + + // The conversion should fail since the expression includes an invalid real constant. + assertThat(exp).forHumanReadable(ENGLISH).doesNotConvertToString() + } + + @Test + fun testConvertToString_eng_invalidBinaryOp_returnsNull() { + val exp = MathExpression.newBuilder().apply { + binaryOperation = MathBinaryOperation.getDefaultInstance() + }.build() + + // The conversion should fail since the expression includes an invalid binary operation. + assertThat(exp).forHumanReadable(ENGLISH).doesNotConvertToString() + } + + @Test + fun testConvertToString_eng_invalidUnaryOp_returnsNull() { + val exp = MathExpression.newBuilder().apply { + unaryOperation = MathUnaryOperation.getDefaultInstance() + }.build() + + // The conversion should fail since the expression includes an invalid unary operation. + assertThat(exp).forHumanReadable(ENGLISH).doesNotConvertToString() + } + + @Test + fun testConvertToString_eng_invalidFunctionType_returnsNull() { + val exp = MathExpression.newBuilder().apply { + functionCall = MathFunctionCall.getDefaultInstance() + }.build() + + // The conversion should fail since the expression includes an invalid function call. + assertThat(exp).forHumanReadable(ENGLISH).doesNotConvertToString() + } + + @Test + fun testConvertToString_eng_nestedDefaultExp_returnsNull() { + val exp = MathExpression.newBuilder().apply { + group = MathExpression.getDefaultInstance() + }.build() + + // The conversion should fail since the expression includes an invalid nested expression. + assertThat(exp).forHumanReadable(ENGLISH).doesNotConvertToString() + } + + @Test + fun testConvertToString_eng_nestedInvalidBinaryOp_returnsNull() { + val exp = MathExpression.newBuilder().apply { + group = MathExpression.newBuilder().apply { + binaryOperation = MathBinaryOperation.getDefaultInstance() + }.build() + }.build() + + // The conversion should fail since the expression includes an invalid nested expression. + assertThat(exp).forHumanReadable(ENGLISH).doesNotConvertToString() + } + + @Test + fun testConvertToString_eng_nestedInvalidUnaryOp_returnsNull() { + val exp = MathExpression.newBuilder().apply { + group = MathExpression.newBuilder().apply { + unaryOperation = MathUnaryOperation.getDefaultInstance() + }.build() + }.build() + + // The conversion should fail since the expression includes an invalid nested expression. + assertThat(exp).forHumanReadable(ENGLISH).doesNotConvertToString() + } + + @Test + fun testConvertToString_eng_nestedInvalidFunctionType_returnsNull() { + val exp = MathExpression.newBuilder().apply { + group = MathExpression.newBuilder().apply { + functionCall = MathFunctionCall.getDefaultInstance() + }.build() + }.build() + + // The conversion should fail since the expression includes an invalid nested expression. + assertThat(exp).forHumanReadable(ENGLISH).doesNotConvertToString() + } + + @Test + fun testConvertToString_eq_withLeftInvalidExp_returnsNull() { + val validExp = MathExpression.newBuilder().apply { + constant = ONE_HALF + }.build() + val invalidExp = MathExpression.getDefaultInstance() + val eq = MathEquation.newBuilder().apply { + leftSide = invalidExp + rightSide = validExp + }.build() + + // Both sides of the equation must be valid. + assertThat(eq).forHumanReadable(ENGLISH).doesNotConvertToString() + } + + @Test + fun testConvertToString_eq_withRightInvalidExp_returnsNull() { + val validExp = MathExpression.newBuilder().apply { + constant = ONE_HALF + }.build() + val invalidExp = MathExpression.getDefaultInstance() + val eq = MathEquation.newBuilder().apply { + leftSide = validExp + rightSide = invalidExp + }.build() + + // Both sides of the equation must be valid. + assertThat(eq).forHumanReadable(ENGLISH).doesNotConvertToString() + } + + private fun MathExpressionSubject.forHumanReadable( + language: OppiaLanguage + ): HumanReadableStringChecker { + return HumanReadableStringChecker(language) { divAsFraction -> + util.convertToHumanReadableString(actual, language, divAsFraction) + } + } + + private fun MathEquationSubject.forHumanReadable( + language: OppiaLanguage + ): HumanReadableStringChecker { + return HumanReadableStringChecker(language) { divAsFraction -> + util.convertToHumanReadableString(actual, language, divAsFraction) + } + } + + private fun setUpTestApplicationComponent() { + ApplicationProvider.getApplicationContext().inject(this) + } + + private class HumanReadableStringChecker( + private val language: OppiaLanguage, + private val maybeConvertToHumanReadableString: (Boolean) -> String? + ) { + fun convertsToStringThat(): StringSubject = + assertThat(convertToHumanReadableString(language, /* divAsFraction= */ false)) + + fun convertsWithFractionsToStringThat(): StringSubject = + assertThat(convertToHumanReadableString(language, /* divAsFraction= */ true)) + + fun doesNotConvertToString() { + assertWithMessage("Expected to not convert to: $language") + .that(maybeConvertToHumanReadableString(/* divAsFraction= */ false)) + .isNull() + } + + private fun convertToHumanReadableString( + language: OppiaLanguage, + divAsFraction: Boolean + ): String { + val readableString = maybeConvertToHumanReadableString(divAsFraction) + assertWithMessage("Expected to convert to: $language").that(readableString).isNotNull() + return checkNotNull(readableString) // Verified in the above assertion check. + } + } + + // TODO(#89): Move this to a common test application component. + @Singleton + @Component( + modules = [ + RobolectricModule::class, TestDispatcherModule::class, ApplicationModule::class, + PlatformParameterModule::class, LoggerModule::class, ContinueModule::class, + FractionInputModule::class, ItemSelectionInputModule::class, MultipleChoiceInputModule::class, + NumberWithUnitsRuleModule::class, NumericInputRuleModule::class, TextInputRuleModule::class, + DragDropSortInputModule::class, ImageClickInputModule::class, InteractionsModule::class, + GcsResourceModule::class, GlideImageLoaderModule::class, ImageParsingModule::class, + HtmlParserEntityTypeModule::class, QuestionModule::class, TestLogReportingModule::class, + AccessibilityTestModule::class, LogStorageModule::class, CachingTestModule::class, + PrimeTopicAssetsControllerModule::class, ExpirationMetaDataRetrieverTestModule::class, + ViewBindingShimModule::class, RatioInputModule::class, NetworkConfigProdModule::class, + ApplicationStartupListenerModule::class, HintsAndSolutionConfigModule::class, + LogUploadWorkerModule::class, WorkManagerConfigurationModule::class, + FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, + DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, + ExplorationStorageModule::class, NetworkModule::class, HintsAndSolutionProdModule::class, + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class, LocaleTestModule::class, ActivityRecreatorTestModule::class, + ActivityIntentFactoriesModule::class, PlatformParameterSingletonModule::class, + NumericExpressionInputModule::class, AlgebraicExpressionInputModule::class, + MathEquationInputModule::class + ] + ) + interface TestApplicationComponent : ApplicationComponent { + @Component.Builder + interface Builder { + @BindsInstance + fun setApplication(application: Application): Builder + + fun build(): TestApplicationComponent + } + + fun inject(test: MathExpressionAccessibilityUtilTest) + } + + class TestApplication : Application(), ActivityComponentFactory, ApplicationInjectorProvider { + private val component: TestApplicationComponent by lazy { + DaggerMathExpressionAccessibilityUtilTest_TestApplicationComponent.builder() + .setApplication(this) + .build() + } + + fun inject(test: MathExpressionAccessibilityUtilTest) { + component.inject(test) + } + + override fun createActivityComponent(activity: AppCompatActivity): ActivityComponent { + return component.getActivityComponentBuilderProvider().get().setActivity(activity).build() + } + + override fun getApplicationInjector(): ApplicationInjector = component + } + + private companion object { + private fun parseAlgebraicExpression( + expression: String, + allowedVariables: List = listOf("x", "y", "z"), + errorCheckingMode: ErrorCheckingMode = ALL_ERRORS + ): MathExpression { + return MathExpressionParser.parseAlgebraicExpression( + expression, allowedVariables, errorCheckingMode + ).getExpectedSuccess() + } + + private fun parseAlgebraicEquation( + expression: String, + ): MathEquation { + return MathExpressionParser.parseAlgebraicEquation( + expression, + allowedVariables = listOf("x", "y", "z"), + errorCheckingMode = ALL_ERRORS + ).getExpectedSuccess() + } + + private inline fun MathParsingResult.getExpectedSuccess(): T { + assertThat(this).isInstanceOf(MathParsingResult.Success::class.java) + return (this as MathParsingResult.Success).result + } + } +} diff --git a/domain/src/main/java/org/oppia/android/domain/locale/DisplayLocaleImpl.kt b/domain/src/main/java/org/oppia/android/domain/locale/DisplayLocaleImpl.kt index 39b231cf4ac..8d3a291b961 100644 --- a/domain/src/main/java/org/oppia/android/domain/locale/DisplayLocaleImpl.kt +++ b/domain/src/main/java/org/oppia/android/domain/locale/DisplayLocaleImpl.kt @@ -9,6 +9,7 @@ import org.oppia.android.app.model.OppiaLocaleContext import org.oppia.android.util.locale.OppiaBidiFormatter import org.oppia.android.util.locale.OppiaLocale import java.text.DateFormat +import java.text.NumberFormat import java.util.Date import java.util.Locale import java.util.Objects @@ -33,6 +34,7 @@ class DisplayLocaleImpl( private val dateTimeFormat by lazy { DateFormat.getDateTimeInstance(DATE_FORMAT_LENGTH, TIME_FORMAT_LENGTH, formattingLocale) } + private val numberFormat by lazy { NumberFormat.getNumberInstance(formattingLocale) } private val bidiFormatter by lazy { formatterFactory.createFormatter(formattingLocale) } // TODO(#3766): Restrict to be 'internal'. @@ -46,6 +48,10 @@ class DisplayLocaleImpl( configuration.setLocale(formattingLocale) } + override fun formatLong(value: Long): String = numberFormat.format(value) + + override fun formatDouble(value: Double): String = numberFormat.format(value) + override fun computeDateString(timestampMillis: Long): String = dateFormat.format(Date(timestampMillis)) diff --git a/domain/src/test/java/org/oppia/android/domain/locale/DisplayLocaleImplTest.kt b/domain/src/test/java/org/oppia/android/domain/locale/DisplayLocaleImplTest.kt index 0ff73fdb1b8..4a55dffc08d 100644 --- a/domain/src/test/java/org/oppia/android/domain/locale/DisplayLocaleImplTest.kt +++ b/domain/src/test/java/org/oppia/android/domain/locale/DisplayLocaleImplTest.kt @@ -133,6 +133,36 @@ class DisplayLocaleImplTest { assertThat(impl2).isEqualTo(impl1) } + @Test + fun testFormatLong_forLargeLong_returnsStringWithExactDigits() { + val impl = createDisplayLocaleImpl(US_ENGLISH_CONTEXT) + + val formattedString = impl.formatLong(123456789) + + assertThat(formattedString.filter { it.isDigit() }).isEqualTo("123456789") + } + + @Test + fun testFormatLong_forDouble_returnsStringWithExactDigits() { + val impl = createDisplayLocaleImpl(US_ENGLISH_CONTEXT) + + val formattedString = impl.formatDouble(454545456.123) + + val digitsOnly = formattedString.filter { it.isDigit() } + assertThat(digitsOnly).contains("454545456") + assertThat(digitsOnly).contains("123") + } + + @Test + fun testFormatLong_forDouble_returnsStringWithPeriodsOrCommas() { + val impl = createDisplayLocaleImpl(US_ENGLISH_CONTEXT) + + val formattedString = impl.formatDouble(123456789.123) + + // Depending on formatting, commas and/or periods are used for large doubles. + assertThat(formattedString).containsMatch("[,.]") + } + @Test fun testComputeDateString_forFixedTime_returnMonthDayYearParts() { val impl = createDisplayLocaleImpl(US_ENGLISH_CONTEXT) diff --git a/scripts/assets/file_content_validation_checks.textproto b/scripts/assets/file_content_validation_checks.textproto index 70a2d35df7a..f0d04f694f8 100644 --- a/scripts/assets/file_content_validation_checks.textproto +++ b/scripts/assets/file_content_validation_checks.textproto @@ -286,6 +286,7 @@ file_content_checks { file_path_regex: ".+?\\.kt" prohibited_content_regex: "OppiaParameterizedTestRunner" failure_message: "To use OppiaParameterizedTestRunner, please add an exemption to file_content_validation_checks.textproto and add an explanation for your use case in your PR description. Note that parameterized tests should only be used in special circumstances where a single behavior can be tested across multiple inputs, or for especially large test suites that can be trivially reduced." + exempted_file_name: "app/src/test/java/org/oppia/android/app/utility/math/MathExpressionAccessibilityUtilTest.kt" exempted_file_name: "domain/src/test/java/org/oppia/android/domain/classify/rules/algebraicexpressioninput/AlgebraicExpressionInputIsEquivalentToRuleClassifierProviderTest.kt" exempted_file_name: "domain/src/test/java/org/oppia/android/domain/classify/rules/algebraicexpressioninput/AlgebraicExpressionInputMatchesExactlyWithRuleClassifierProviderTest.kt" exempted_file_name: "domain/src/test/java/org/oppia/android/domain/classify/rules/algebraicexpressioninput/AlgebraicExpressionInputMatchesUpToTrivialManipulationsRuleClassifierProviderTest.kt" diff --git a/testing/src/main/java/org/oppia/android/testing/math/MathEquationSubject.kt b/testing/src/main/java/org/oppia/android/testing/math/MathEquationSubject.kt index 859ff36db29..a8c456a37e3 100644 --- a/testing/src/main/java/org/oppia/android/testing/math/MathEquationSubject.kt +++ b/testing/src/main/java/org/oppia/android/testing/math/MathEquationSubject.kt @@ -22,7 +22,7 @@ import org.oppia.android.util.math.toRawLatex */ class MathEquationSubject private constructor( metadata: FailureMetadata, - private val actual: MathEquation + val actual: MathEquation ) : LiteProtoSubject(metadata, actual) { /** * Returns a [MathExpressionSubject] to test [MathEquation.getLeftSide]. This method never fails diff --git a/testing/src/main/java/org/oppia/android/testing/math/MathExpressionSubject.kt b/testing/src/main/java/org/oppia/android/testing/math/MathExpressionSubject.kt index 1d4298ff3ac..e9241d67b58 100644 --- a/testing/src/main/java/org/oppia/android/testing/math/MathExpressionSubject.kt +++ b/testing/src/main/java/org/oppia/android/testing/math/MathExpressionSubject.kt @@ -84,7 +84,7 @@ import org.oppia.android.util.math.toRawLatex */ class MathExpressionSubject private constructor( metadata: FailureMetadata, - private val actual: MathExpression + val actual: MathExpression ) : LiteProtoSubject(metadata, actual) { /** * Begins the structure syntax matcher. diff --git a/utility/src/main/java/org/oppia/android/util/locale/OppiaLocale.kt b/utility/src/main/java/org/oppia/android/util/locale/OppiaLocale.kt index b4aa0fa3439..0011ca6872f 100644 --- a/utility/src/main/java/org/oppia/android/util/locale/OppiaLocale.kt +++ b/utility/src/main/java/org/oppia/android/util/locale/OppiaLocale.kt @@ -207,6 +207,25 @@ sealed class OppiaLocale { * [org.oppia.android.domain.locale.LocaleController.setAsDefault]). */ abstract class DisplayLocale(override val localeContext: OppiaLocaleContext) : OppiaLocale() { + /** + * Returns a locally formatted representation of the long integer [value]. + * + * No assumptions can be made regarding the formatting of the returned string except that: + * 1. The exact value will be represented (no rounding or truncation will occur). + * 2. The resulting value should be generally readable by screenreaders if they support the the + * current locale. + */ + abstract fun formatLong(value: Long): String + + /** + * Returns a locally formatted representation of the double [value]. + * + * No assumptions can be made regarding the formatting of the returned string except that it + * should generally be readable by screenreaders if they support the current locale. This + * function may round and/or truncate the double for formatting simplicity. + */ + abstract fun formatDouble(value: Double): String + /** * Returns a locally formatted date string representing the specified Unix timestamp. *