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.
*