diff --git a/scripts/assets/file_content_validation_checks.textproto b/scripts/assets/file_content_validation_checks.textproto index a41099287d7..8b8fe90e4cd 100644 --- a/scripts/assets/file_content_validation_checks.textproto +++ b/scripts/assets/file_content_validation_checks.textproto @@ -289,5 +289,6 @@ file_content_checks { exempted_file_name: "scripts/src/javatests/org/oppia/android/scripts/regex/RegexPatternValidationCheckTest.kt" exempted_file_name: "utility/src/test/java/org/oppia/android/util/math/MathExpressionParserTest.kt" exempted_file_name: "utility/src/test/java/org/oppia/android/util/math/MathTokenizerTest.kt" + exempted_file_name: "utility/src/test/java/org/oppia/android/util/math/RealExtensionsTest.kt" exempted_file_patterns: "testing/src/main/java/org/oppia/android/testing/junit/.+?\\.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 b050a776c89..859ff36db29 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 @@ -1,12 +1,14 @@ package org.oppia.android.testing.math import com.google.common.truth.FailureMetadata +import com.google.common.truth.StringSubject import com.google.common.truth.Truth.assertAbout import com.google.common.truth.Truth.assertThat import com.google.common.truth.extensions.proto.LiteProtoSubject import org.oppia.android.app.model.MathEquation import org.oppia.android.testing.math.MathEquationSubject.Companion.assertThat import org.oppia.android.testing.math.MathExpressionSubject.Companion.assertThat +import org.oppia.android.util.math.toRawLatex // TODO(#4097): Add tests for this class. @@ -34,6 +36,26 @@ class MathEquationSubject private constructor( */ fun hasRightHandSideThat(): MathExpressionSubject = assertThat(actual.rightSide) + /** + * Returns a [StringSubject] to verify the LaTeX conversion of the tested [MathEquation]. + * + * For more details on LaTeX conversion, see [toRawLatex]. Note that this method, in contrast to + * [convertsWithFractionsToLatexStringThat], retains division operations as-is. + */ + fun convertsToLatexStringThat(): StringSubject = + assertThat(convertToLatex(divAsFraction = false)) + + /** + * Returns a [StringSubject] to verify the LaTeX conversion of the tested [MathEquation]. + * + * For more details on LaTeX conversion, see [toRawLatex]. Note that this method, in contrast to + * [convertsToLatexStringThat], treats divisions as fractions. + */ + fun convertsWithFractionsToLatexStringThat(): StringSubject = + assertThat(convertToLatex(divAsFraction = true)) + + private fun convertToLatex(divAsFraction: Boolean): String = actual.toRawLatex(divAsFraction) + companion object { /** * Returns a new [MathEquationSubject] to verify aspects of the specified [MathEquation] value. 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 674d110af7e..1d4298ff3ac 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 @@ -1,6 +1,8 @@ package org.oppia.android.testing.math +import com.google.common.truth.DoubleSubject import com.google.common.truth.FailureMetadata +import com.google.common.truth.IntegerSubject import com.google.common.truth.StringSubject import com.google.common.truth.Truth.assertAbout import com.google.common.truth.Truth.assertThat @@ -18,6 +20,8 @@ import org.oppia.android.app.model.MathUnaryOperation import org.oppia.android.app.model.Real import org.oppia.android.testing.math.MathExpressionSubject.Companion.assertThat import org.oppia.android.testing.math.RealSubject.Companion.assertThat +import org.oppia.android.util.math.evaluateAsNumericExpression +import org.oppia.android.util.math.toRawLatex // TODO(#4097): Add tests for this class. @@ -91,6 +95,65 @@ class MathExpressionSubject private constructor( ExpressionComparator.createFromExpression(actual).also(init) } + /** + * Assumes that this expression evaluates to a fraction (i.e. [Real.getRational]) and returns a + * [FractionSubject] to verify the computed value. + * + * Note that this should only be used for numeric expressions as variable expressions cannot be + * evaluated. For more context on expression evaluation, see [evaluateAsNumericExpression]. + */ + fun evaluatesToRationalThat(): FractionSubject = + FractionSubject.assertThat(evaluateAsReal(expectedType = Real.RealTypeCase.RATIONAL).rational) + + /** + * Assumes that this expression evaluates to an irrational (i.e. [Real.getIrrational]) and returns + * a [DoubleSubject] to verify the computed value. + * + * Note that this should only be used for numeric expressions as variable expressions cannot be + * evaluated. For more context on expression evaluation, see [evaluateAsNumericExpression]. + */ + fun evaluatesToIrrationalThat(): DoubleSubject = + assertThat(evaluateAsReal(expectedType = Real.RealTypeCase.IRRATIONAL).irrational) + + /** + * Assumes that this expression evaluates to an integer (i.e. [Real.getInteger]) and returns an + * [IntegerSubject] to verify the computed value. + * + * Note that this should only be used for numeric expressions as variable expressions cannot be + * evaluated. For more context on expression evaluation, see [evaluateAsNumericExpression]. + */ + fun evaluatesToIntegerThat(): IntegerSubject = + assertThat(evaluateAsReal(expectedType = Real.RealTypeCase.INTEGER).integer) + + /** + * Returns a [StringSubject] to verify the LaTeX conversion of the tested [MathExpression]. + * + * For more details on LaTeX conversion, see [toRawLatex]. Note that this method, in contrast to + * [convertsWithFractionsToLatexStringThat], retains division operations as-is. + */ + fun convertsToLatexStringThat(): StringSubject = + assertThat(convertToLatex(divAsFraction = false)) + + /** + * Returns a [StringSubject] to verify the LaTeX conversion of the tested [MathExpression]. + * + * For more details on LaTeX conversion, see [toRawLatex]. Note that this method, in contrast to + * [convertsToLatexStringThat], treats divisions as fractions. + */ + fun convertsWithFractionsToLatexStringThat(): StringSubject = + assertThat(convertToLatex(divAsFraction = true)) + + private fun evaluateAsReal(expectedType: Real.RealTypeCase): Real { + val real = actual.evaluateAsNumericExpression() + assertWithMessage("Failed to evaluate numeric expression").that(real).isNotNull() + assertWithMessage("Expected constant to evaluate to $expectedType") + .that(real?.realTypeCase) + .isEqualTo(expectedType) + return checkNotNull(real) // Just to remove the nullable operator; the actual check is above. + } + + private fun convertToLatex(divAsFraction: Boolean): String = actual.toRawLatex(divAsFraction) + /** * DSL syntax provider for verifying the structure of a [MathExpression]. * diff --git a/testing/src/main/java/org/oppia/android/testing/math/RealSubject.kt b/testing/src/main/java/org/oppia/android/testing/math/RealSubject.kt index bb8a180b306..908bc7be6e2 100644 --- a/testing/src/main/java/org/oppia/android/testing/math/RealSubject.kt +++ b/testing/src/main/java/org/oppia/android/testing/math/RealSubject.kt @@ -22,15 +22,17 @@ import org.oppia.android.testing.math.FractionSubject.Companion.assertThat */ class RealSubject private constructor( metadata: FailureMetadata, - private val actual: Real + private val actual: Real? ) : LiteProtoSubject(metadata, actual) { + private val nonNullActual by lazy { checkNotNull(actual) { "Expected real to be non-null" } } + /** * Returns a [FractionSubject] to test [Real.getRational]. This will fail if the [Real] pertaining * to this subject is not of type rational. */ fun isRationalThat(): FractionSubject { verifyTypeToBe(Real.RealTypeCase.RATIONAL) - return assertThat(actual.rational) + return assertThat(nonNullActual.rational) } /** @@ -39,7 +41,7 @@ class RealSubject private constructor( */ fun isIrrationalThat(): DoubleSubject { verifyTypeToBe(Real.RealTypeCase.IRRATIONAL) - return assertThat(actual.irrational) + return assertThat(nonNullActual.irrational) } /** @@ -48,17 +50,17 @@ class RealSubject private constructor( */ fun isIntegerThat(): IntegerSubject { verifyTypeToBe(Real.RealTypeCase.INTEGER) - return assertThat(actual.integer) + return assertThat(nonNullActual.integer) } private fun verifyTypeToBe(expected: Real.RealTypeCase) { - assertWithMessage("Expected real type to be $expected, not: ${actual.realTypeCase}") - .that(actual.realTypeCase) + assertWithMessage("Expected real type to be $expected, not: ${nonNullActual.realTypeCase}") + .that(nonNullActual.realTypeCase) .isEqualTo(expected) } companion object { /** Returns a new [RealSubject] to verify aspects of the specified [Real] value. */ - fun assertThat(actual: Real): RealSubject = assertAbout(::RealSubject).that(actual) + fun assertThat(actual: Real?): RealSubject = assertAbout(::RealSubject).that(actual) } } diff --git a/utility/src/main/java/org/oppia/android/util/math/BUILD.bazel b/utility/src/main/java/org/oppia/android/util/math/BUILD.bazel index 91b1f83ddf6..4ae833ce0b7 100644 --- a/utility/src/main/java/org/oppia/android/util/math/BUILD.bazel +++ b/utility/src/main/java/org/oppia/android/util/math/BUILD.bazel @@ -4,20 +4,18 @@ General-purpose mathematics utilities, especially for supporting math-based inte load("@io_bazel_rules_kotlin//kotlin:kotlin.bzl", "kt_android_library") -kt_android_library( +android_library( name = "extensions", - srcs = [ - "FloatExtensions.kt", - "FractionExtensions.kt", - "PolynomialExtensions.kt", - "RatioExtensions.kt", - "RealExtensions.kt", - ], visibility = [ "//:oppia_api_visibility", ], - deps = [ - "//model/src/main/proto:math_java_proto_lite", + exports = [ + ":float_extensions", + ":fraction_extensions", + ":math_expression_extensions", + ":polynomial_extensions", + ":ratio_extensions", + ":real_extensions", ], ) @@ -39,12 +37,12 @@ kt_android_library( "MathExpressionParser.kt", ], visibility = [ - "//:oppia_testing_visibility", + "//:oppia_api_visibility", ], deps = [ - ":extensions", ":math_parsing_error", ":peekable_iterator", + ":real_extensions", ":tokenizer", "//model/src/main/proto:math_java_proto_lite", ], @@ -86,3 +84,94 @@ kt_android_library( "//:oppia_testing_visibility", ], ) + +kt_android_library( + name = "float_extensions", + srcs = [ + "FloatExtensions.kt", + ], + deps = [ + "//model/src/main/proto:math_java_proto_lite", + ], +) + +kt_android_library( + name = "fraction_extensions", + srcs = [ + "FractionExtensions.kt", + ], + deps = [ + "//model/src/main/proto:math_java_proto_lite", + ], +) + +kt_android_library( + name = "math_expression_extensions", + srcs = [ + "MathExpressionExtensions.kt", + ], + deps = [ + ":expression_to_latex_converter", + ":numeric_expression_evaluator", + "//model/src/main/proto:math_java_proto_lite", + ], +) + +kt_android_library( + name = "polynomial_extensions", + srcs = [ + "PolynomialExtensions.kt", + ], + deps = [ + ":real_extensions", + "//model/src/main/proto:math_java_proto_lite", + ], +) + +kt_android_library( + name = "ratio_extensions", + srcs = [ + "RatioExtensions.kt", + ], + deps = [ + ":fraction_extensions", + "//model/src/main/proto:math_java_proto_lite", + ], +) + +kt_android_library( + name = "real_extensions", + srcs = [ + "RealExtensions.kt", + ], + deps = [ + ":float_extensions", + ":fraction_extensions", + "//model/src/main/proto:math_java_proto_lite", + ], +) + +kt_android_library( + name = "expression_to_latex_converter", + srcs = [ + "ExpressionToLatexConverter.kt", + ], + visibility = [ + "//:oppia_testing_visibility", + ], + deps = [ + ":real_extensions", + "//model/src/main/proto:math_java_proto_lite", + ], +) + +kt_android_library( + name = "numeric_expression_evaluator", + srcs = [ + "NumericExpressionEvaluator.kt", + ], + deps = [ + ":real_extensions", + "//model/src/main/proto:math_java_proto_lite", + ], +) diff --git a/utility/src/main/java/org/oppia/android/util/math/ExpressionToLatexConverter.kt b/utility/src/main/java/org/oppia/android/util/math/ExpressionToLatexConverter.kt new file mode 100644 index 00000000000..05cf8a25e90 --- /dev/null +++ b/utility/src/main/java/org/oppia/android/util/math/ExpressionToLatexConverter.kt @@ -0,0 +1,108 @@ +package org.oppia.android.util.math + +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.MathBinaryOperation.Operator as BinaryOperator +import org.oppia.android.app.model.MathUnaryOperation.Operator as UnaryOperator + +/** + * Converter between math equations/expressions and renderable LaTeX strings. + * + * In order to use this converter, directly import [convertToLatex] and call it for any + * [MathExpression]s or [MathEquation]s that should be converted to a renderable LaTeX + * representation. + */ +class ExpressionToLatexConverter private constructor() { + companion object { + /** + * Returns the LaTeX conversion of this [MathExpression]. + * + * Note that this routine attempts to retain the exact structure of the original expression, but + * not the actual original style. For example, parenthetical groups will be retained but spacing + * between operators will be normalized regardless of the original raw expression. + * + * Note that the returned LaTeX is primarily intended to be render-ready, and may not be as + * nicely human-readable. While some effort is taken to add spacing for better human + * readability, there may be extra curly braces or LaTeX structures to generally ensure + * correct rendering. + * + * Finally, the returned LaTeX should generally be portable/compatible with most LaTeX rendering + * systems as it only relies on basic LaTeX language structures. + * + * @param divAsFraction determines whether divisions within the math structure should be + * rendered instead as fractions rather than division operations + */ + fun MathExpression.convertToLatex(divAsFraction: Boolean): String { + return when (expressionTypeCase) { + CONSTANT -> constant.toPlainText() + VARIABLE -> variable + BINARY_OPERATION -> { + val lhsLatex = binaryOperation.leftOperand.convertToLatex(divAsFraction) + val rhsLatex = binaryOperation.rightOperand.convertToLatex(divAsFraction) + when (binaryOperation.operator) { + ADD -> "$lhsLatex + $rhsLatex" + SUBTRACT -> "$lhsLatex - $rhsLatex" + MULTIPLY -> if (binaryOperation.isImplicit) { + "$lhsLatex$rhsLatex" + } else "$lhsLatex \\times $rhsLatex" + DIVIDE -> if (divAsFraction) { + "\\frac{$lhsLatex}{$rhsLatex}" + } else "$lhsLatex \\div $rhsLatex" + EXPONENTIATE -> "$lhsLatex ^ {$rhsLatex}" + // There's no operator, so try and "recover" by outputting the raw operands. + BinaryOperator.OPERATOR_UNSPECIFIED, BinaryOperator.UNRECOGNIZED, null -> + "$lhsLatex $rhsLatex" + } + } + UNARY_OPERATION -> { + val operandLatex = unaryOperation.operand.convertToLatex(divAsFraction) + when (unaryOperation.operator) { + NEGATE -> "-$operandLatex" + POSITIVE -> "+$operandLatex" + // There's no known operator, so just output the original operand. + UnaryOperator.OPERATOR_UNSPECIFIED, UnaryOperator.UNRECOGNIZED, null -> operandLatex + } + } + FUNCTION_CALL -> { + val argumentLatex = functionCall.argument.convertToLatex(divAsFraction) + when (functionCall.functionType) { + SQUARE_ROOT -> "\\sqrt{$argumentLatex}" + // There's no recognized function, so try to "recover" by outputting the raw argument. + FUNCTION_UNSPECIFIED, FunctionType.UNRECOGNIZED, null -> argumentLatex + } + } + GROUP -> "(${group.convertToLatex(divAsFraction)})" + EXPRESSIONTYPE_NOT_SET, null -> "" // No corresponding LaTeX, so just go with empty string. + } + } + + /** + * Returns the LaTeX conversion of this [MathEquation]. + * + * See [convertToLatex] (for [MathExpression]s) for the specific behaviors and expectations of + * this function. + */ + fun MathEquation.convertToLatex(divAsFraction: Boolean): String { + val lhs = leftSide + val rhs = rightSide + return "${lhs.convertToLatex(divAsFraction)} = ${rhs.convertToLatex(divAsFraction)}" + } + } +} diff --git a/utility/src/main/java/org/oppia/android/util/math/FractionExtensions.kt b/utility/src/main/java/org/oppia/android/util/math/FractionExtensions.kt index d4881613346..bd0c8093ede 100644 --- a/utility/src/main/java/org/oppia/android/util/math/FractionExtensions.kt +++ b/utility/src/main/java/org/oppia/android/util/math/FractionExtensions.kt @@ -1,6 +1,8 @@ package org.oppia.android.util.math import org.oppia.android.app.model.Fraction +import kotlin.math.abs +import kotlin.math.absoluteValue /** Returns whether this fraction has a fractional component. */ fun Fraction.hasFractionalPart(): Boolean { @@ -15,6 +17,12 @@ fun Fraction.isOnlyWholeNumber(): Boolean { return !hasFractionalPart() } +/** + * Returns this fraction as a whole number. Note that this will not return a value that is + * mathematically equivalent to this fraction unless [isOnlyWholeNumber] returns true. + */ +fun Fraction.toWholeNumber(): Int = if (isNegative) -wholeNumber else wholeNumber + /** * Returns a [Double] version of this fraction. * @@ -69,6 +77,22 @@ fun Fraction.toSimplestForm(): Fraction { }.build() } +/** + * Returns this fraction in its proper form by first converting to simplest denominator, then + * extracting a whole number component. + * + * This function will properly convert a fraction whose denominator is 1 into a whole number-only + * fraction. + */ +fun Fraction.toProperForm(): Fraction { + return toSimplestForm().let { + it.toBuilder().apply { + wholeNumber = it.wholeNumber + (it.numerator / it.denominator) + numerator = it.numerator % it.denominator + }.build() + } +} + /** * Returns this fraction in an improper form (that is, with a 0 whole number and only fractional * parts). @@ -81,12 +105,144 @@ fun Fraction.toImproperForm(): Fraction { }.build() } +/** Returns the inverse improper fraction representation of this fraction. */ +private fun Fraction.toInvertedImproperForm(): Fraction { + return toImproperForm().let { improper -> + improper.toBuilder().apply { + numerator = improper.denominator + denominator = improper.numerator + }.build() + } +} + /** Returns the negated form of this fraction. */ operator fun Fraction.unaryMinus(): Fraction { return toBuilder().apply { isNegative = !this@unaryMinus.isNegative }.build() } +/** Adds two fractions together and returns a new one in its proper form. */ +operator fun Fraction.plus(rhs: Fraction): Fraction { + // First, eliminate the whole number by computing improper fractions. + val leftFraction = toImproperForm() + val rightFraction = rhs.toImproperForm() + + // Second, find a common denominator and compute the new numerators. + val commonDenominator = lcm(leftFraction.denominator, rightFraction.denominator) + val leftFactor = commonDenominator / leftFraction.denominator + val rightFactor = commonDenominator / rightFraction.denominator + val leftNumerator = leftFraction.numerator * leftFactor + val rightNumerator = rightFraction.numerator * rightFactor + + // Third, determine how the numerators are combined (based on negatives) and whether the result is + // negative. + val leftNeg = leftFraction.isNegative + val rightNeg = rightFraction.isNegative + val (newNumerator, isNegative) = when { + leftNeg && rightNeg -> leftNumerator + rightNumerator to true + !leftNeg && !rightNeg -> leftNumerator + rightNumerator to false + leftNeg && !rightNeg -> + (-leftNumerator + rightNumerator).absoluteValue to (leftNumerator > rightNumerator) + !leftNeg && rightNeg -> + (leftNumerator - rightNumerator).absoluteValue to (rightNumerator > leftNumerator) + else -> throw Exception("Impossible case") + } + + // Finally, compute the new fraction and convert it to proper form to compute its whole number. + return Fraction.newBuilder().apply { + this.isNegative = isNegative + numerator = newNumerator + denominator = commonDenominator + }.build().toProperForm() +} + +/** + * Subtracts the specified fraction from this fraction and returns the result in its proper form. + */ +operator fun Fraction.minus(rhs: Fraction): Fraction { + // a - b = a + -b + return this + -rhs +} + +/** Multiples this fraction by the specified and returns the result in its proper form. */ +operator fun Fraction.times(rhs: Fraction): Fraction { + // First, convert both fractions into their improper forms. + val leftFraction = toImproperForm() + val rightFraction = rhs.toImproperForm() + + // Second, multiple the numerators and denominators piece-wise. + val newNumerator = leftFraction.numerator * rightFraction.numerator + val newDenominator = leftFraction.denominator * rightFraction.denominator + + // Third, determine negative (negative is retained if only one is negative). + val isNegative = leftFraction.isNegative xor rightFraction.isNegative + return Fraction.newBuilder().apply { + this.isNegative = isNegative + numerator = newNumerator + denominator = newDenominator + }.build().toProperForm() +} + +/** Returns the proper form of the division from this fraction by the specified fraction. */ +operator fun Fraction.div(rhs: Fraction): Fraction { + // a / b = a * b^-1 (b's inverse). + return this * rhs.toInvertedImproperForm() +} + +/** + * Raises this [Fraction] to the specified [exp] power and returns the result. + * + * Note that since this is an infix operation it should be used as follows (as an example): + * ```kotlin + * val result = fraction pow integerPower + * ``` + * + * This function can only fail when (exceptions are thrown in all cases): + * - This [Fraction] is malformed or incomplete (e.g. a default instance). + * - The resulting [Fraction] would result in a zero denominator. + * + * Some specific details about the returned value: + * - A proper-form fraction is always returned (per [toProperForm]). + * - Negative powers are supported (they will invert the resulting fraction). + * - 0^0 is special-cased to return a 1-valued fraction for consistency with the power function for + * reals (see that KDoc and/or https://stackoverflow.com/a/19955996 for context). + */ +infix fun Fraction.pow(exp: Int): Fraction { + return when { + exp == 0 -> { + Fraction.newBuilder().apply { + wholeNumber = 1 + denominator = 1 + }.build() + } + exp == 1 -> this + // x^-2 == 1/(x^2). + exp < 1 -> (this pow -exp).toInvertedImproperForm().toProperForm() + else -> { // i > 1 + var newValue = this + for (i in 1 until exp) newValue *= this + return newValue.toProperForm() + } + } +} + +/** Returns the [Fraction] representation of this integer (as a whole number fraction). */ +fun Int.toWholeNumberFraction(): Fraction { + val intValue = this + return Fraction.newBuilder().apply { + isNegative = intValue < 0 + wholeNumber = abs(intValue) + numerator = 0 + denominator = 1 + }.build() +} + /** Returns the greatest common divisor between two integers. */ private fun gcd(x: Int, y: Int): Int { return if (y == 0) x else gcd(y, x % y) } + +/** Returns the least common multiple between two integers. */ +private fun lcm(x: Int, y: Int): Int { + // Reference: https://en.wikipedia.org/wiki/Least_common_multiple#Calculation. + return (x * y).absoluteValue / gcd(x, y) +} diff --git a/utility/src/main/java/org/oppia/android/util/math/MathExpressionExtensions.kt b/utility/src/main/java/org/oppia/android/util/math/MathExpressionExtensions.kt new file mode 100644 index 00000000000..9da1082f21e --- /dev/null +++ b/utility/src/main/java/org/oppia/android/util/math/MathExpressionExtensions.kt @@ -0,0 +1,30 @@ +package org.oppia.android.util.math + +import org.oppia.android.app.model.MathEquation +import org.oppia.android.app.model.MathExpression +import org.oppia.android.app.model.Real +import org.oppia.android.util.math.ExpressionToLatexConverter.Companion.convertToLatex +import org.oppia.android.util.math.NumericExpressionEvaluator.Companion.evaluate + +/** + * Returns the LaTeX conversion of this [MathExpression], with the style configuration determined by + * [divAsFraction]. + * + * See [convertToLatex] for specifics. + */ +fun MathExpression.toRawLatex(divAsFraction: Boolean): String = convertToLatex(divAsFraction) + +/** + * Returns the LaTeX conversion of this [MathEquation], with the style configuration determined by + * [divAsFraction]. + * + * See [convertToLatex] for specifics. + */ +fun MathEquation.toRawLatex(divAsFraction: Boolean): String = convertToLatex(divAsFraction) + +/** + * Returns the [Real] evaluation of this [MathExpression]. + * + * See [evaluate] for specifics. + */ +fun MathExpression.evaluateAsNumericExpression(): Real? = evaluate() diff --git a/utility/src/main/java/org/oppia/android/util/math/NumericExpressionEvaluator.kt b/utility/src/main/java/org/oppia/android/util/math/NumericExpressionEvaluator.kt new file mode 100644 index 00000000000..1b314675ea7 --- /dev/null +++ b/utility/src/main/java/org/oppia/android/util/math/NumericExpressionEvaluator.kt @@ -0,0 +1,116 @@ +package org.oppia.android.util.math + +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.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 +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 +import org.oppia.android.app.model.MathUnaryOperation.Operator.NEGATE +import org.oppia.android.app.model.MathUnaryOperation.Operator.POSITIVE +import org.oppia.android.app.model.Real +import org.oppia.android.app.model.MathBinaryOperation.Operator as BinaryOperator +import org.oppia.android.app.model.MathUnaryOperation.Operator as UnaryOperator + +/** + * Numeric evaluator for numeric [MathExpression]s. + * + * In order to use this evaluator, directly import [evaluate] and call it for any numeric + * [MathExpression]s that should be evaluated. + */ +class NumericExpressionEvaluator private constructor() { + companion object { + /** + * Evaluates a math expression. + * + * This function only works with numeric expressions since variable expressions have no means + * for evaluation (so they'll always result in a ``null`` return value). + * + * The function generally attempts to retain the most precise representation of a value in the + * following order (from highest priority to lowest): + * 1. Integers + * 2. Fractions (rational values) + * 3. Doubles (irrational values) + * + * Doubles will only be used if there's no other choice as they do not have perfect precision + * unlike the other two structures. Further, it's possible for doubles to be used in cases where + * an integer could work, or fractions to represent whole integers (due to quirks in underlying + * routines). That being said, within a certain precision threshold values returned by this + * function should be deterministic across multiple calls (for the same [MathExpression]). + * + * There are a number of cases where this function will fail: + * - When trying to evaluate a variable expression. + * - When trying to evaluate an invalid [MathExpression] (i.e. one of the substructures within + * the expression is not actually initialized per the proto structures). + * - When trying to perform an impossible math operation (such as divide by zero). Note that + * this will sometimes result in a [Real] being returned with a value like NaN or infinity, + * and other times may result in an exception being thrown. + * + * Note that there's no guard against overflowing values during computation, so care should be + * taken by the caller that this is possible for certain expressions. + * + * For more specifics on the constituent operations that "power" this function, see: + * - [Real.plus] + * - [Real.minus] + * - [Real.times] + * - [Real.div] + * - [Real.pow] + * - [Real.unaryMinus] + * - [sqrt] + * + * @return the [Real] representing the evaluated expression, or ``null`` if something went wrong + */ + fun MathExpression.evaluate(): Real? { + return when (expressionTypeCase) { + CONSTANT -> constant + VARIABLE -> null // Variables not supported in numeric expressions. + BINARY_OPERATION -> binaryOperation.evaluate() + UNARY_OPERATION -> unaryOperation.evaluate() + FUNCTION_CALL -> functionCall.evaluate() + GROUP -> group.evaluate() + EXPRESSIONTYPE_NOT_SET, null -> null + } + } + + private fun MathBinaryOperation.evaluate(): Real? { + return when (operator) { + ADD -> rightOperand.evaluate()?.let { leftOperand.evaluate()?.plus(it) } + SUBTRACT -> rightOperand.evaluate()?.let { leftOperand.evaluate()?.minus(it) } + MULTIPLY -> rightOperand.evaluate()?.let { leftOperand.evaluate()?.times(it) } + DIVIDE -> rightOperand.evaluate()?.let { leftOperand.evaluate()?.div(it) } + EXPONENTIATE -> rightOperand.evaluate()?.let { leftOperand.evaluate()?.pow(it) } + BinaryOperator.OPERATOR_UNSPECIFIED, BinaryOperator.UNRECOGNIZED, null -> null + } + } + + private fun MathUnaryOperation.evaluate(): Real? { + return when (operator) { + NEGATE -> operand.evaluate()?.let { -it } + POSITIVE -> operand.evaluate() // '+2' is the same as just '2'. + UnaryOperator.OPERATOR_UNSPECIFIED, UnaryOperator.UNRECOGNIZED, null -> null + } + } + + private fun MathFunctionCall.evaluate(): Real? { + return when (functionType) { + SQUARE_ROOT -> argument.evaluate()?.let { sqrt(it) } + FUNCTION_UNSPECIFIED, + FunctionType.UNRECOGNIZED, + null -> null + } + } + } +} diff --git a/utility/src/main/java/org/oppia/android/util/math/RealExtensions.kt b/utility/src/main/java/org/oppia/android/util/math/RealExtensions.kt index 7f8eabb7901..cc3f3687e91 100644 --- a/utility/src/main/java/org/oppia/android/util/math/RealExtensions.kt +++ b/utility/src/main/java/org/oppia/android/util/math/RealExtensions.kt @@ -1,10 +1,13 @@ package org.oppia.android.util.math +import org.oppia.android.app.model.Fraction import org.oppia.android.app.model.Real 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 kotlin.math.absoluteValue +import kotlin.math.pow /** * Returns whether this [Real] is explicitly a rational type (i.e. a fraction). @@ -13,6 +16,14 @@ import org.oppia.android.app.model.Real.RealTypeCase.REALTYPE_NOT_SET */ fun Real.isRational(): Boolean = realTypeCase == RATIONAL +/** + * Returns whether this [Real] is explicitly an integer. + * + * This returns false if the real is a rational despite that being mathematically an integer (e.g. a + * whole number fraction). + */ +fun Real.isInteger(): Boolean = realTypeCase == INTEGER + /** Returns whether this [Real] is negative. */ fun Real.isNegative(): Boolean = when (realTypeCase) { RATIONAL -> rational.isNegative @@ -69,13 +80,286 @@ fun Real.isApproximatelyEqualTo(value: Double): Boolean { */ operator fun Real.unaryMinus(): Real { return when (realTypeCase) { - RATIONAL -> recompute { it.setRational(-rational) } - IRRATIONAL -> recompute { it.setIrrational(-irrational) } - INTEGER -> recompute { it.setInteger(-integer) } + RATIONAL -> createRationalReal(-rational) + IRRATIONAL -> createIrrationalReal(-irrational) + INTEGER -> createIntegerReal(-integer) + REALTYPE_NOT_SET, null -> throw IllegalStateException("Invalid real: $this.") + } +} + +/** + * Adds this [Real] with another and returns the result. + * + * Neither [Real] being added are changed during the operation. + * + * Note that this function will always succeed (unless one of the [Real]s is malformed or + * incomplete), but the type of [Real] that's returned depends on the constituent [Real]s being + * added. For reference, here's how the conversion behaves: + * + * |---------------------------------------------------| + * | + | integer | rational | irrational | + * |------------|------------|------------|------------| + * | integer | integer | rational | irrational | + * | rational | rational | rational | irrational | + * | irrational | irrational | irrational | irrational | + * |---------------------------------------------------| + * + * As indicated by the above table, this function attempts to maintain as much precision as possible + * during operations (but will fall back to [Double]s if the calculation would otherwise result in a + * high level of error). While [Double]s don't perfectly capture precision, their error levels are + * generally better than the rounding errors encountered from integer arithmetic. + */ +operator fun Real.plus(rhs: Real): Real { + return when (realTypeCase) { + RATIONAL -> when (rhs.realTypeCase) { + RATIONAL -> createRationalReal(rational + rhs.rational) + IRRATIONAL -> createIrrationalReal(rational.toDouble() + rhs.irrational) + INTEGER -> createRationalReal(rational + rhs.integer.toWholeNumberFraction()) + REALTYPE_NOT_SET, null -> throw IllegalStateException("Invalid real: $rhs.") + } + IRRATIONAL -> when (rhs.realTypeCase) { + RATIONAL -> createIrrationalReal(irrational + rhs.rational.toDouble()) + IRRATIONAL -> createIrrationalReal(irrational + rhs.irrational) + INTEGER -> createIrrationalReal(irrational + rhs.integer) + REALTYPE_NOT_SET, null -> throw IllegalStateException("Invalid real: $rhs.") + } + INTEGER -> when (rhs.realTypeCase) { + RATIONAL -> createRationalReal(integer.toWholeNumberFraction() + rhs.rational) + IRRATIONAL -> createIrrationalReal(integer + rhs.irrational) + INTEGER -> createIntegerReal(integer + rhs.integer) + REALTYPE_NOT_SET, null -> throw IllegalStateException("Invalid real: $rhs.") + } + REALTYPE_NOT_SET, null -> throw IllegalStateException("Invalid real: $this.") + } +} + +/** + * Subtracts this [Real] from another and returns the result. + * + * Neither [Real] being subtracted are changed during the operation. + * + * Note that this function will always succeed (unless one of the [Real]s is malformed or + * incomplete), but the type of [Real] that's returned depends on the constituent [Real]s being + * subtracted. For reference, see [Real.plus] (the same type conversion is used). + */ +operator fun Real.minus(rhs: Real): Real { + return when (realTypeCase) { + RATIONAL -> when (rhs.realTypeCase) { + RATIONAL -> createRationalReal(rational - rhs.rational) + IRRATIONAL -> createIrrationalReal(rational.toDouble() - rhs.irrational) + INTEGER -> createRationalReal(rational - rhs.integer.toWholeNumberFraction()) + REALTYPE_NOT_SET, null -> throw IllegalStateException("Invalid real: $rhs.") + } + IRRATIONAL -> when (rhs.realTypeCase) { + RATIONAL -> createIrrationalReal(irrational - rhs.rational.toDouble()) + IRRATIONAL -> createIrrationalReal(irrational - rhs.irrational) + INTEGER -> createIrrationalReal(irrational - rhs.integer) + REALTYPE_NOT_SET, null -> throw IllegalStateException("Invalid real: $rhs.") + } + INTEGER -> when (rhs.realTypeCase) { + RATIONAL -> createRationalReal(integer.toWholeNumberFraction() - rhs.rational) + IRRATIONAL -> createIrrationalReal(integer - rhs.irrational) + INTEGER -> createIntegerReal(integer - rhs.integer) + REALTYPE_NOT_SET, null -> throw IllegalStateException("Invalid real: $rhs.") + } + REALTYPE_NOT_SET, null -> throw IllegalStateException("Invalid real: $this.") + } +} + +/** + * Multiplies this [Real] with another and returns the result. + * + * Neither [Real] being multiplied are changed during the operation. + * + * Note that this function will always succeed (unless one of the [Real]s is malformed or + * incomplete), but the type of [Real] that's returned depends on the constituent [Real]s being + * multiplied. For reference, see [Real.plus] (the same type conversion is used). + * + * Note that effective divisions by zero (i.e. fractions with zero denominators) may result in + * either an infinity being returned or an exception being thrown. + */ +operator fun Real.times(rhs: Real): Real { + return when (realTypeCase) { + RATIONAL -> when (rhs.realTypeCase) { + RATIONAL -> createRationalReal(rational * rhs.rational) + IRRATIONAL -> createIrrationalReal(rational.toDouble() * rhs.irrational) + INTEGER -> createRationalReal(rational * rhs.integer.toWholeNumberFraction()) + REALTYPE_NOT_SET, null -> throw IllegalStateException("Invalid real: $rhs.") + } + IRRATIONAL -> when (rhs.realTypeCase) { + RATIONAL -> createIrrationalReal(irrational * rhs.rational.toDouble()) + IRRATIONAL -> createIrrationalReal(irrational * rhs.irrational) + INTEGER -> createIrrationalReal(irrational * rhs.integer) + REALTYPE_NOT_SET, null -> throw IllegalStateException("Invalid real: $rhs.") + } + INTEGER -> when (rhs.realTypeCase) { + RATIONAL -> createRationalReal(integer.toWholeNumberFraction() * rhs.rational) + IRRATIONAL -> createIrrationalReal(integer * rhs.irrational) + INTEGER -> createIntegerReal(integer * rhs.integer) + REALTYPE_NOT_SET, null -> throw IllegalStateException("Invalid real: $rhs.") + } + REALTYPE_NOT_SET, null -> throw IllegalStateException("Invalid real: $this.") + } +} + +/** + * Divides this [Real] by another and returns the result. + * + * Neither [Real] being divided are changed during the operation. + * + * Note that this function will always succeed (unless one of the [Real]s is malformed or + * incomplete), but the type of [Real] that's returned depends on the constituent [Real]s being + * divided. For reference, see [Real.plus] for type conversion. It's the same for this method except + * one case: integer divided by integers. If the division is perfect (e.g. 4/2), an integer will be + * returned. Otherwise, a rational [Fraction] will be returned. + * + * Note also that divisions by zero may result in either an exception being thrown, or an infinity + * being returned. + */ +operator fun Real.div(rhs: Real): Real { + return when (realTypeCase) { + RATIONAL -> when (rhs.realTypeCase) { + RATIONAL -> createRationalReal(rational / rhs.rational) + IRRATIONAL -> createIrrationalReal(rational.toDouble() / rhs.irrational) + INTEGER -> createRationalReal(rational / rhs.integer.toWholeNumberFraction()) + REALTYPE_NOT_SET, null -> throw IllegalStateException("Invalid real: $rhs.") + } + IRRATIONAL -> when (rhs.realTypeCase) { + RATIONAL -> createIrrationalReal(irrational / rhs.rational.toDouble()) + IRRATIONAL -> createIrrationalReal(irrational / rhs.irrational) + INTEGER -> createIrrationalReal(irrational / rhs.integer) + REALTYPE_NOT_SET, null -> throw IllegalStateException("Invalid real: $rhs.") + } + INTEGER -> when (rhs.realTypeCase) { + RATIONAL -> createRationalReal(integer.toWholeNumberFraction() / rhs.rational) + IRRATIONAL -> createIrrationalReal(integer / rhs.irrational) + INTEGER -> integer.divide(rhs.integer) + REALTYPE_NOT_SET, null -> throw IllegalStateException("Invalid real: $rhs.") + } + REALTYPE_NOT_SET, null -> throw IllegalStateException("Invalid real: $this.") + } +} + +/** + * Computes the power of this [Real] raised to [rhs] and returns the result. + * + * Neither [Real] being combined are changed during the operation. + * + * As this is an infix function, it should be called as so (example): + * ```kotlin + * val result = baseReal pow powerReal + * ``` + * + * This function can fail in a few circumstances: + * - One of the [Real]s is malformed or incomplete (such as a default instance). + * - In cases where a root is being taken (i.e. when |[rhs]| < 1), if the root cannot be taken + * either an exception will be thrown or NaN will be returned (such as trying to take the even + * root of a negative value). + * + * Further, note that this function represents the real value root rather than the principal root, + * so negative bases are allowed so long as the root being used is odd. For non-integerlike powers, + * the base should never be negative except for fractions that could result in a positive base after + * exponentiation. + * + * This function special cases 0^0 to return 1 in all cases for consistency with the system ``pow`` + * function and other languages, per: https://stackoverflow.com/a/19955996. + * + * Finally, this function also attempts to retain maximum precision in much the same way as [sqrt] + * and [Real.plus] except there are more cases when a value may change types. See the following + * table for reference: + * + * |----------------------------------------------------------------------------------------------| + * | pow | positive int | negative int | rootable rational* | other rationals | irrational | + * |------------|--------------|--------------|--------------------|-----------------|------------| + * | integer | integer | rational | rational | irrational | irrational | + * | rational | rational | rational | rational | irrational | irrational | + * | irrational | irrational | irrational | irrational | irrational | irrational | + * |----------------------------------------------------------------------------------------------| + * + * *This corresponds to fraction powers whose denominator (which are treated as roots) can perform a + * perfect square root of either the integer base (for integer [Real]s) or both the numerator and + * denominator integers (for rational [Real]s). + * + * (Note that the left column represents the left-hand side and the top row represents the + * right-hand side of the operation). + */ +infix fun Real.pow(rhs: Real): Real { + // Powers can really only be effectively done via floats or whole-number only fractions. + return when (realTypeCase) { + RATIONAL -> { + // Left-hand side is Fraction. + when (rhs.realTypeCase) { + // Anything raised by a fraction is pow'd by the numerator and rooted by the denominator. + RATIONAL -> rhs.rational.toImproperForm().let { power -> + (rational pow power.numerator).root(power.denominator, power.isNegative) + } + IRRATIONAL -> createIrrationalReal(rational.toDouble().pow(rhs.irrational)) + INTEGER -> createRationalReal(rational pow rhs.integer) + REALTYPE_NOT_SET, null -> throw IllegalStateException("Invalid real: $rhs.") + } + } + IRRATIONAL -> { + // Left-hand side is a double. + when (rhs.realTypeCase) { + RATIONAL -> createIrrationalReal(irrational.pow(rhs.rational.toDouble())) + IRRATIONAL -> createIrrationalReal(irrational.pow(rhs.irrational)) + INTEGER -> createIrrationalReal(irrational.pow(rhs.integer)) + REALTYPE_NOT_SET, null -> throw IllegalStateException("Invalid real: $rhs.") + } + } + INTEGER -> { + // Left-hand side is an integer. + when (rhs.realTypeCase) { + // An integer raised to a fraction can use the same approach as above (fraction raised to + // fraction) by treating the integer as a whole number fraction. + RATIONAL -> rhs.rational.toImproperForm().let { power -> + (integer.toWholeNumberFraction() pow power.numerator).root( + power.denominator, power.isNegative + ) + } + IRRATIONAL -> createIrrationalReal(integer.toDouble().pow(rhs.irrational)) + INTEGER -> integer.pow(rhs.integer) + REALTYPE_NOT_SET, null -> throw IllegalStateException("Invalid real: $rhs.") + } + } REALTYPE_NOT_SET, null -> throw IllegalStateException("Invalid real: $this.") } } +/** + * Returns the square root of the specified [Real]. + * + * [real] is not changed as a result of this operation (a new [Real] value is returned). + * + * Failure cases: + * - An invalid [Real] is passed in (such as a default instance), resulting in an exception being + * thrown. + * - A negative value is passed in (this will either result in an exception or a NaN being + * returned). + * + * Similar to [Real.plus] & other operations, this function attempts to retain as much precision as + * possible by first performing perfect roots before needing to perform a numerical approximation. + * This is achieved by attempting to take perfect integer roots for integer and rational types and, + * if that's not possible, then converting to a double. See the following conversion table for + * reference: + * + * |------------------------------------------------| + * | sqrt | perfect square | all other values | + * |------------|----------------|------------------| + * | integer | integer | irrational | + * | rational | rational | irrational | + * | irrational | irrational | irrational | + * |------------------------------------------------| + */ +fun sqrt(real: Real): Real { + return when (real.realTypeCase) { + RATIONAL -> real.rational.root(base = 2, invert = false) + IRRATIONAL -> createIrrationalReal(kotlin.math.sqrt(real.irrational)) + INTEGER -> root(real.integer, base = 2) + REALTYPE_NOT_SET, null -> throw IllegalStateException("Invalid real: $real.") + } +} + /** * Returns an absolute value of this [Real] (that is, a non-negative [Real]). * @@ -83,6 +367,131 @@ operator fun Real.unaryMinus(): Real { */ fun abs(real: Real): Real = if (real.isNegative()) -real else real -private fun Real.recompute(transform: (Real.Builder) -> Real.Builder): Real { - return transform(newBuilderForType()).build() +private fun Int.divide(rhs: Int): Real = Real.newBuilder().apply { + // If rhs divides this integer, retain the integer. + val lhs = this@divide + if ((lhs % rhs) == 0) { + integer = lhs / rhs + } else { + // Otherwise, keep precision by turning the division into a fraction. + rational = Fraction.newBuilder().apply { + isNegative = (lhs < 0) xor (rhs < 0) + numerator = kotlin.math.abs(lhs) + denominator = kotlin.math.abs(rhs) + }.build().toProperForm() + } +}.build() + +private fun Int.pow(exp: Int): Real { + return when { + exp == 0 -> Real.newBuilder().apply { integer = 1 }.build() + exp == 1 -> Real.newBuilder().apply { integer = this@pow }.build() + exp < 0 -> Real.newBuilder().apply { rational = toWholeNumberFraction() pow exp }.build() + else -> { + // exp > 1 + var computed = this + for (i in 0 until exp - 1) computed *= this + Real.newBuilder().apply { integer = computed }.build() + } + } +} + +private fun Fraction.root(base: Int, invert: Boolean): Real { + check(base > 0) { "Expected base of 1 or higher, not: $base" } + + val adjustedFraction = toImproperForm() + val adjustedNum = + if (adjustedFraction.isNegative) -adjustedFraction.numerator else adjustedFraction.numerator + val adjustedDenom = adjustedFraction.denominator + val rootedNumerator = if (invert) root(adjustedDenom, base) else root(adjustedNum, base) + val rootedDenominator = if (invert) root(adjustedNum, base) else root(adjustedDenom, base) + return if (rootedNumerator.isInteger() && rootedDenominator.isInteger()) { + Real.newBuilder().apply { + rational = Fraction.newBuilder().apply { + isNegative = rootedNumerator.isNegative() || rootedDenominator.isNegative() + numerator = rootedNumerator.integer.absoluteValue + denominator = rootedDenominator.integer.absoluteValue + }.build().toProperForm() + }.build() + } else { + // One or both of the components of the fraction can't be rooted, so compute an irrational + // version. + Real.newBuilder().apply { + irrational = rootedNumerator.toDouble() / rootedDenominator.toDouble() + }.build() + } +} + +private fun root(int: Int, base: Int): Real { + // First, check if the integer is a root. Base reference for possible methods: + // https://www.researchgate.net/post/How-to-decide-if-a-given-number-will-have-integer-square-root-or-not. + if (int == 0 && base == 0) { + // This is considered a conventional identity per https://stackoverflow.com/a/19955996 that + // doesn't match mathematics definitions (but it does bring parity with the system's pow() + // function). + return Real.newBuilder().apply { + integer = 1 + }.build() + } + + check(base > 0) { "Expected base of 1 or higher, not: $base" } + check((int < 0 && base.isOdd()) || int >= 0) { "Radicand results in imaginary number: $int" } + + when { + int == 0 -> { + // 0^x is always zero. + return Real.newBuilder().apply { + integer = 0 + }.build() + } + int == 1 || int == 0 || base == 0 -> { + // 1^x and x^0 are always 1. + return Real.newBuilder().apply { + integer = 1 + }.build() + } + base == 1 -> { + // x^1 is always x. + return Real.newBuilder().apply { + integer = int + }.build() + } + } + + val radicand = int.absoluteValue + var potentialRoot = base + while (potentialRoot.pow(base).integer < radicand) { + potentialRoot++ + } + if (potentialRoot.pow(base).integer == radicand) { + // There's an exact integer representation of the root. + if (int < 0 && base.isOdd()) { + // Odd roots of negative numbers retain the negative. + potentialRoot = -potentialRoot + } + return Real.newBuilder().apply { + integer = potentialRoot + }.build() + } + + // Otherwise, compute the irrational square root. + return Real.newBuilder().apply { + irrational = if (base == 2) { + kotlin.math.sqrt(int.toDouble()) + } else int.toDouble().pow(1.0 / base.toDouble()) + }.build() } + +private fun Int.isOdd() = this % 2 == 1 + +private fun createRationalReal(value: Fraction): Real = Real.newBuilder().apply { + rational = value +}.build() + +private fun createIrrationalReal(value: Double): Real = Real.newBuilder().apply { + irrational = value +}.build() + +private fun createIntegerReal(value: Int): Real = Real.newBuilder().apply { + integer = value +}.build() diff --git a/utility/src/test/java/org/oppia/android/util/math/BUILD.bazel b/utility/src/test/java/org/oppia/android/util/math/BUILD.bazel index 217f16638ab..7021569bf26 100644 --- a/utility/src/test/java/org/oppia/android/util/math/BUILD.bazel +++ b/utility/src/test/java/org/oppia/android/util/math/BUILD.bazel @@ -42,6 +42,25 @@ oppia_android_test( ], ) +oppia_android_test( + name = "ExpressionToLatexConverterTest", + srcs = ["ExpressionToLatexConverterTest.kt"], + custom_package = "org.oppia.android.util.math", + test_class = "org.oppia.android.util.math.ExpressionToLatexConverterTest", + test_manifest = "//utility:test_manifest", + deps = [ + "//model/src/main/proto:math_java_proto_lite", + "//third_party:androidx_test_ext_junit", + "//third_party:com_google_truth_extensions_truth-liteproto-extension", + "//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/math:expression_to_latex_converter", + "//utility/src/main/java/org/oppia/android/util/math:math_expression_parser", + ], +) + oppia_android_test( name = "FloatExtensionsTest", srcs = ["FloatExtensionsTest.kt"], @@ -96,6 +115,25 @@ oppia_android_test( ], ) +oppia_android_test( + name = "MathExpressionExtensionsTest", + srcs = ["MathExpressionExtensionsTest.kt"], + custom_package = "org.oppia.android.util.math", + test_class = "org.oppia.android.util.math.MathExpressionExtensionsTest", + test_manifest = "//utility:test_manifest", + deps = [ + "//model/src/main/proto:math_java_proto_lite", + "//testing/src/main/java/org/oppia/android/testing/math:real_subject", + "//third_party:androidx_test_ext_junit", + "//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/math:extensions", + "//utility/src/main/java/org/oppia/android/util/math:math_expression_parser", + ], +) + oppia_android_test( name = "MathExpressionParserTest", srcs = ["MathExpressionParserTest.kt"], @@ -136,6 +174,25 @@ oppia_android_test( ], ) +oppia_android_test( + name = "NumericExpressionEvaluatorTest", + srcs = ["NumericExpressionEvaluatorTest.kt"], + custom_package = "org.oppia.android.util.math", + test_class = "org.oppia.android.util.math.NumericExpressionEvaluatorTest", + test_manifest = "//utility:test_manifest", + deps = [ + "//model/src/main/proto:math_java_proto_lite", + "//testing:assertion_helpers", + "//testing/src/main/java/org/oppia/android/testing/math:real_subject", + "//third_party:androidx_test_ext_junit", + "//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/math:math_expression_parser", + ], +) + oppia_android_test( name = "NumericExpressionParserTest", srcs = ["NumericExpressionParserTest.kt"], @@ -217,12 +274,14 @@ oppia_android_test( deps = [ "//model/src/main/proto:math_java_proto_lite", "//testing", + "//testing/src/main/java/org/oppia/android/testing/junit:oppia_parameterized_test_runner", + "//testing/src/main/java/org/oppia/android/testing/junit:parameterized_junit_test_runner", "//testing/src/main/java/org/oppia/android/testing/math:real_subject", - "//third_party:androidx_test_ext_junit", "//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/math:extensions", + "//utility/src/main/java/org/oppia/android/util/math:fraction_parser", ], ) diff --git a/utility/src/test/java/org/oppia/android/util/math/ExpressionToLatexConverterTest.kt b/utility/src/test/java/org/oppia/android/util/math/ExpressionToLatexConverterTest.kt new file mode 100644 index 00000000000..e351f9fc6d9 --- /dev/null +++ b/utility/src/test/java/org/oppia/android/util/math/ExpressionToLatexConverterTest.kt @@ -0,0 +1,253 @@ +package org.oppia.android.util.math + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.oppia.android.app.model.MathEquation +import org.oppia.android.app.model.MathExpression +import org.oppia.android.util.math.ExpressionToLatexConverter.Companion.convertToLatex +import org.oppia.android.util.math.MathExpressionParser.Companion.ErrorCheckingMode +import org.oppia.android.util.math.MathExpressionParser.Companion.MathParsingResult +import org.robolectric.annotation.LooperMode + +/** Tests for [ExpressionToLatexConverter]. */ +// FunctionName: test names are conventionally named with underscores. +// SameParameterValue: tests should have specific context included/excluded for readability. +@Suppress("FunctionName", "SameParameterValue") +@RunWith(AndroidJUnit4::class) +@LooperMode(LooperMode.Mode.PAUSED) +class ExpressionToLatexConverterTest { + @Test + fun testConvert_numericExp_number_returnsConstantLatex() { + val exp = parseNumericExpressionWithAllErrors("1") + + val latex = exp.convertToLatex(divAsFraction = false) + + assertThat(latex).isEqualTo("1") + } + + @Test + fun testConvert_numericExp_unaryPlus_withoutOptionalErrors_returnLatexWithUnaryPlus() { + val exp = parseNumericExpressionWithoutOptionalErrors("+1") + + val latex = exp.convertToLatex(divAsFraction = false) + + assertThat(latex).isEqualTo("+1") + } + + @Test + fun testConvert_numericExp_unaryMinus_returnLatexWithUnaryMinus() { + val exp = parseNumericExpressionWithAllErrors("-1") + + val latex = exp.convertToLatex(divAsFraction = false) + + assertThat(latex).isEqualTo("-1") + } + + @Test + fun testConvert_numericExp_addition_returnsLatexWithAddition() { + val exp = parseNumericExpressionWithAllErrors("1+2") + + val latex = exp.convertToLatex(divAsFraction = false) + + assertThat(latex).isEqualTo("1 + 2") + } + + @Test + fun testConvert_numericExp_subtraction_returnsLatexWithSubtract() { + val exp = parseNumericExpressionWithAllErrors("1-2") + + val latex = exp.convertToLatex(divAsFraction = false) + + assertThat(latex).isEqualTo("1 - 2") + } + + @Test + fun testConvert_numericExp_multiplication_returnsLatexWithMultiplication() { + val exp = parseNumericExpressionWithAllErrors("2*3") + + val latex = exp.convertToLatex(divAsFraction = false) + + assertThat(latex).isEqualTo("2 \\times 3") + } + + @Test + fun testConvert_numericExp_division_returnsLatexWithDivision() { + val exp = parseNumericExpressionWithAllErrors("2/3") + + val latex = exp.convertToLatex(divAsFraction = false) + + assertThat(latex).isEqualTo("2 \\div 3") + } + + @Test + fun testConvert_numericExp_division_divAsFraction_returnsLatexWithFraction() { + val exp = parseNumericExpressionWithAllErrors("2/3") + + val latex = exp.convertToLatex(divAsFraction = true) + + assertThat(latex).isEqualTo("\\frac{2}{3}") + } + + @Test + fun testConvert_numericExp_multipleDivisions_divAsFraction_returnsLatexWithFractions() { + val exp = parseNumericExpressionWithAllErrors("2/3/4") + + val latex = exp.convertToLatex(divAsFraction = true) + + assertThat(latex).isEqualTo("\\frac{\\frac{2}{3}}{4}") + } + + @Test + fun testConvert_numericExp_exponent_returnsLatexWithExponent() { + val exp = parseNumericExpressionWithAllErrors("2^3") + + val latex = exp.convertToLatex(divAsFraction = false) + + assertThat(latex).isEqualTo("2 ^ {3}") + } + + @Test + fun testConvert_numericExp_inlineSquareRoot_returnsLatexWithSquareRoot() { + val exp = parseNumericExpressionWithAllErrors("√2") + + val latex = exp.convertToLatex(divAsFraction = false) + + assertThat(latex).isEqualTo("\\sqrt{2}") + } + + @Test + fun testConvert_numericExp_inlineSquareRoot_operationArg_returnsLatexWithSquareRoot() { + val exp = parseNumericExpressionWithAllErrors("√(1+2)") + + val latex = exp.convertToLatex(divAsFraction = false) + + assertThat(latex).isEqualTo("\\sqrt{(1 + 2)}") + } + + @Test + fun testConvert_numericExp_squareRoot_returnsLatexWithSquareRoot() { + val exp = parseNumericExpressionWithAllErrors("sqrt(2)") + + val latex = exp.convertToLatex(divAsFraction = false) + + assertThat(latex).isEqualTo("\\sqrt{2}") + } + + @Test + fun testConvert_numericExp_squareRoot_operationArg_returnsLatexWithSquareRoot() { + val exp = parseNumericExpressionWithAllErrors("sqrt(1+2)") + + val latex = exp.convertToLatex(divAsFraction = false) + + assertThat(latex).isEqualTo("\\sqrt{1 + 2}") + } + + @Test + fun testConvert_numericExp_parentheses_returnsLatexWithGroup() { + val exp = parseNumericExpressionWithAllErrors("2/(3+4)") + + val latex = exp.convertToLatex(divAsFraction = false) + + assertThat(latex).isEqualTo("2 \\div (3 + 4)") + } + + @Test + fun testConvert_numericExp_exponentToGroup_returnsCorrectlyWrappedLatex() { + val exp = parseNumericExpressionWithAllErrors("2^(7-3)") + + val latex = exp.convertToLatex(divAsFraction = false) + + assertThat(latex).isEqualTo("2 ^ {(7 - 3)}") + } + + @Test + fun testConvert_algebraicExp_variable_returnsVariableLatex() { + val exp = parseAlgebraicExpressionWithAllErrors("x") + + val latex = exp.convertToLatex(divAsFraction = false) + + assertThat(latex).isEqualTo("x") + } + + @Test + fun testConvert_algebraicExp_twoX_returnsLatexWithImplicitMultiplication() { + val exp = parseAlgebraicExpressionWithAllErrors("2x") + + val latex = exp.convertToLatex(divAsFraction = false) + + assertThat(latex).isEqualTo("2x") + } + + @Test + fun testConvert_algebraicEq_xEqualsOne_returnsLatexWithEquals() { + val exp = parseAlgebraicEquationWithAllErrors("x=1") + + val latex = exp.convertToLatex(divAsFraction = false) + + assertThat(latex).isEqualTo("x = 1") + } + + @Test + fun testConvert_algebraicEq_complexExpression_returnsCorrectLatex() { + val exp = parseAlgebraicEquationWithAllErrors("(x+1)(x-2)=(x^3+2x^2-5x-6)/(x+3)") + + val latex = exp.convertToLatex(divAsFraction = false) + + assertThat(latex).isEqualTo("(x + 1)(x - 2) = (x ^ {3} + 2x ^ {2} - 5x - 6) \\div (x + 3)") + } + + @Test + fun testConvert_algebraicEq_complexExpression_divAsFraction_returnsCorrectLatex() { + val exp = parseAlgebraicEquationWithAllErrors("(x+1)(x-2)=(x^3+2x^2-5x-6)/(x+3)") + + val latex = exp.convertToLatex(divAsFraction = true) + + assertThat(latex).isEqualTo("(x + 1)(x - 2) = \\frac{(x ^ {3} + 2x ^ {2} - 5x - 6)}{(x + 3)}") + } + + private companion object { + private fun parseNumericExpressionWithoutOptionalErrors(expression: String): MathExpression { + return parseNumericExpressionInternal(expression, ErrorCheckingMode.REQUIRED_ONLY) + } + + private fun parseNumericExpressionWithAllErrors(expression: String): MathExpression { + return parseNumericExpressionInternal(expression, ErrorCheckingMode.ALL_ERRORS) + } + + private fun parseAlgebraicExpressionWithAllErrors( + expression: String, + allowedVariables: List = listOf("x", "y", "z") + ): MathExpression { + return MathExpressionParser.parseAlgebraicExpression( + expression, allowedVariables, + ErrorCheckingMode.ALL_ERRORS + ).getExpectedSuccess() + } + + private fun parseNumericExpressionInternal( + expression: String, + errorCheckingMode: ErrorCheckingMode + ): MathExpression { + return MathExpressionParser.parseNumericExpression( + expression, errorCheckingMode + ).getExpectedSuccess() + } + + private fun parseAlgebraicEquationWithAllErrors( + expression: String, + allowedVariables: List = listOf("x", "y", "z") + ): MathEquation { + return MathExpressionParser.parseAlgebraicEquation( + expression, allowedVariables, + 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/utility/src/test/java/org/oppia/android/util/math/FractionExtensionsTest.kt b/utility/src/test/java/org/oppia/android/util/math/FractionExtensionsTest.kt index 48121da69d7..3913e7b6dda 100644 --- a/utility/src/test/java/org/oppia/android/util/math/FractionExtensionsTest.kt +++ b/utility/src/test/java/org/oppia/android/util/math/FractionExtensionsTest.kt @@ -26,6 +26,17 @@ class FractionExtensionsTest { denominator = 1 }.build() + private val ONE_FRACTION = Fraction.newBuilder().apply { + wholeNumber = 1 + denominator = 1 + }.build() + + private val NEGATIVE_ONE_FRACTION = Fraction.newBuilder().apply { + isNegative = true + wholeNumber = 1 + denominator = 1 + }.build() + private val ONE_HALF_FRACTION = Fraction.newBuilder().apply { numerator = 1 denominator = 2 @@ -37,6 +48,17 @@ class FractionExtensionsTest { denominator = 2 }.build() + private val ONE_THIRD_FRACTION = Fraction.newBuilder().apply { + numerator = 1 + denominator = 3 + }.build() + + private val NEGATIVE_ONE_THIRD_FRACTION = Fraction.newBuilder().apply { + isNegative = true + numerator = 1 + denominator = 3 + }.build() + private val ONE_AND_ONE_HALF_FRACTION = Fraction.newBuilder().apply { wholeNumber = 1 numerator = 1 @@ -183,6 +205,57 @@ class FractionExtensionsTest { assertThat(result).isTrue() } + @Test + fun testToWholeNumber_zeroFraction_returnsZero() { + val result = ZERO_FRACTION.toWholeNumber() + + assertThat(result).isEqualTo(0) + } + + @Test + fun testToWholeNumber_negativeZeroFraction_returnsZero() { + val result = NEGATIVE_ZERO_FRACTION.toWholeNumber() + + assertThat(result).isEqualTo(0) + } + + @Test + fun testToWholeNumber_two_returnsTwo() { + val result = TWO_FRACTION.toWholeNumber() + + assertThat(result).isEqualTo(2) + } + + @Test + fun testToWholeNumber_negativeTwo_returnsNegativeTwo() { + val result = NEGATIVE_TWO_FRACTION.toWholeNumber() + + assertThat(result).isEqualTo(-2) + } + + @Test + fun testToWholeNumber_oneHalf_returnsZero() { + val result = ONE_HALF_FRACTION.toWholeNumber() + + assertThat(result).isEqualTo(0) + } + + @Test + fun testToWholeNumber_oneAndOneHalf_returnsOne() { + val result = ONE_AND_ONE_HALF_FRACTION.toWholeNumber() + + assertThat(result).isEqualTo(1) + } + + @Test + fun testToWholeNumber_threeOnes_returnsZero() { + val result = THREE_ONES_FRACTION.toWholeNumber() + + // Even though the fraction is technically equivalent to '3', it being in improper form results + // in there not technically being a whole number component. + assertThat(result).isEqualTo(0) + } + @Test fun testToDouble_zeroFraction_returnsZero() { val result = ZERO_FRACTION.toDouble() @@ -374,6 +447,80 @@ class FractionExtensionsTest { assertThrows(ArithmeticException::class) { zeroDenominatorFraction.toSimplestForm() } } + @Test + fun testToProperForm_zeroFraction_returnsZero() { + val result = ZERO_FRACTION.toProperForm() + + assertThat(result).isEqualTo(ZERO_FRACTION) + } + + @Test + fun testToProperForm_two_returnsTwo() { + val result = TWO_FRACTION.toProperForm() + + assertThat(result).isEqualTo(TWO_FRACTION) + } + + @Test + fun testToProperForm_threeOnes_returnsThree() { + val result = THREE_ONES_FRACTION.toProperForm() + + // Correctly extract the '3' numerator to being a whole number. + assertThat(result).hasNegativePropertyThat().isFalse() + assertThat(result).hasNumeratorThat().isEqualTo(0) + assertThat(result).hasDenominatorThat().isEqualTo(1) + assertThat(result).hasWholeNumberThat().isEqualTo(3) + } + + @Test + fun testToProperForm_oneHalf_returnsOneHalf() { + val result = ONE_HALF_FRACTION.toProperForm() + + assertThat(result).isEqualTo(ONE_HALF_FRACTION) + } + + @Test + fun testToProperForm_oneAndOneHalf_returnsOneAndOneHalf() { + val result = ONE_AND_ONE_HALF_FRACTION.toProperForm() + + // 1 1/2 is already in proper form. + assertThat(result).isEqualTo(ONE_AND_ONE_HALF_FRACTION) + } + + @Test + fun testToProperForm_threeHalves_returnsOneAndOneHalf() { + val result = THREE_HALVES_FRACTION.toProperForm() + + // 3/2 -> 1 1/2. + assertThat(result).isEqualTo(ONE_AND_ONE_HALF_FRACTION) + } + + @Test + fun testToProperForm_largeNegativeImproperFraction_reducesToSimplestProperFraction() { + val largeImproperFraction = Fraction.newBuilder().apply { + isNegative = true + numerator = 1650 + denominator = 209 + }.build() + + val result = largeImproperFraction.toProperForm() + + // Unlike toSimplestForm, toProperForm also extracts a whole number after reducing to the + // simplest denominator. + assertThat(result).hasNegativePropertyThat().isTrue() + assertThat(result).hasNumeratorThat().isEqualTo(17) + assertThat(result).hasDenominatorThat().isEqualTo(19) + assertThat(result).hasWholeNumberThat().isEqualTo(7) + } + + @Test + fun testToProperForm_zeroDenominator_throwsException() { + val zeroDenominatorFraction = Fraction.getDefaultInstance() + + // Converting to simplest form results in a divide by zero in this case. + assertThrows(ArithmeticException::class) { zeroDenominatorFraction.toProperForm() } + } + @Test fun testToImproperForm_zero_returnsZeroFraction() { val result = ZERO_FRACTION.toImproperForm() @@ -527,4 +674,744 @@ class FractionExtensionsTest { assertThat(result).hasDenominatorThat().isEqualTo(2) assertThat(result).hasWholeNumberThat().isEqualTo(1) } + + @Test + fun testPlus_zeroAndZero_returnsZero() { + val lhsFraction = ZERO_FRACTION + val rhsFraction = ZERO_FRACTION + + val result = lhsFraction + rhsFraction + + assertThat(result).isEqualTo(ZERO_FRACTION) + } + + @Test + fun testPlus_oneAndZero_returnsOne() { + val lhsFraction = ZERO_FRACTION + val rhsFraction = ONE_FRACTION + + val result = lhsFraction + rhsFraction + + assertThat(result).isEqualTo(ONE_FRACTION) + } + + @Test + fun testPlus_oneHalfAndOneHalf_returnsOne() { + val lhsFraction = ONE_HALF_FRACTION + val rhsFraction = ONE_HALF_FRACTION + + val result = lhsFraction + rhsFraction + + assertThat(result).isEqualTo(ONE_FRACTION) + } + + @Test + fun testPlus_oneHalfAndNegativeOneHalf_returnsZero() { + val lhsFraction = ONE_HALF_FRACTION + val rhsFraction = NEGATIVE_ONE_HALF_FRACTION + + val result = lhsFraction + rhsFraction + + assertThat(result).isEqualTo(ZERO_FRACTION) + } + + @Test + fun testPlus_oneThirdAndOneHalf_returnsFiveSixths() { + val lhsFraction = ONE_THIRD_FRACTION + val rhsFraction = ONE_HALF_FRACTION + + val result = lhsFraction + rhsFraction + + assertThat(result).hasNegativePropertyThat().isFalse() + assertThat(result).hasNumeratorThat().isEqualTo(5) + assertThat(result).hasDenominatorThat().isEqualTo(6) + assertThat(result).hasWholeNumberThat().isEqualTo(0) + } + + @Test + fun testPlus_oneHalfAndOneThird_returnsFiveSixths() { + val lhsFraction = ONE_HALF_FRACTION + val rhsFraction = ONE_THIRD_FRACTION + + val result = lhsFraction + rhsFraction + + // Demonstrate commutativity, i.e.: a+b=b+a. + assertThat(result).hasNegativePropertyThat().isFalse() + assertThat(result).hasNumeratorThat().isEqualTo(5) + assertThat(result).hasDenominatorThat().isEqualTo(6) + assertThat(result).hasWholeNumberThat().isEqualTo(0) + } + + @Test + fun testPlus_twentyFiveThirtiethsAndFiveSevenths_returnsOneAndTwentyThreeFortyTwos() { + val lhsFraction = Fraction.newBuilder().apply { + numerator = 25 + denominator = 30 + }.build() + val rhsFraction = Fraction.newBuilder().apply { + numerator = 5 + denominator = 7 + }.build() + + val result = lhsFraction + rhsFraction + + assertThat(result).hasNegativePropertyThat().isFalse() + assertThat(result).hasNumeratorThat().isEqualTo(23) + assertThat(result).hasDenominatorThat().isEqualTo(42) + assertThat(result).hasWholeNumberThat().isEqualTo(1) + } + + @Test + fun testPlus_negativeOneAndOneThird_returnsNegativeTwoThirds() { + val lhsFraction = NEGATIVE_ONE_FRACTION + val rhsFraction = ONE_THIRD_FRACTION + + val result = lhsFraction + rhsFraction + + // Effectively subtracting fractions via addition should work as expected. + assertThat(result).hasNegativePropertyThat().isTrue() + assertThat(result).hasNumeratorThat().isEqualTo(2) + assertThat(result).hasDenominatorThat().isEqualTo(3) + assertThat(result).hasWholeNumberThat().isEqualTo(0) + } + + @Test + fun testPlus_oneAndNegativeOneThird_returnsTwoThirds() { + val lhsFraction = ONE_FRACTION + val rhsFraction = NEGATIVE_ONE_THIRD_FRACTION + + val result = lhsFraction + rhsFraction + + // Effectively subtracting fractions via addition should work as expected. + assertThat(result).hasNegativePropertyThat().isFalse() + assertThat(result).hasNumeratorThat().isEqualTo(2) + assertThat(result).hasDenominatorThat().isEqualTo(3) + assertThat(result).hasWholeNumberThat().isEqualTo(0) + } + + @Test + fun testPlus_negativeOneAndNegativeOneThird_returnsNegativeOneAndOneThird() { + val lhsFraction = NEGATIVE_ONE_FRACTION + val rhsFraction = NEGATIVE_ONE_THIRD_FRACTION + + val result = lhsFraction + rhsFraction + + // Negative addition should work as expected. + assertThat(result).hasNegativePropertyThat().isTrue() + assertThat(result).hasNumeratorThat().isEqualTo(1) + assertThat(result).hasDenominatorThat().isEqualTo(3) + assertThat(result).hasWholeNumberThat().isEqualTo(1) + } + + @Test + fun testMinus_zeroAndZero_returnsZero() { + val lhsFraction = ZERO_FRACTION + val rhsFraction = ZERO_FRACTION + + val result = lhsFraction - rhsFraction + + assertThat(result).isEqualTo(ZERO_FRACTION) + } + + @Test + fun testMinus_oneAndZero_returnsOne() { + val lhsFraction = ONE_FRACTION + val rhsFraction = ZERO_FRACTION + + val result = lhsFraction - rhsFraction + + assertThat(result).isEqualTo(ONE_FRACTION) + } + + @Test + fun testMinus_oneHalfAndOneHalf_returnsZero() { + val lhsFraction = ONE_HALF_FRACTION + val rhsFraction = ONE_HALF_FRACTION + + val result = lhsFraction - rhsFraction + + assertThat(result).isEqualTo(ZERO_FRACTION) + } + + @Test + fun testMinus_oneHalfAndNegativeOneHalf_returnsOne() { + val lhsFraction = ONE_HALF_FRACTION + val rhsFraction = NEGATIVE_ONE_HALF_FRACTION + + val result = lhsFraction - rhsFraction + + // Minus a negative fraction should turn into regular addition. + assertThat(result).isEqualTo(ONE_FRACTION) + } + + @Test + fun testMinus_oneThirdAndOneHalf_returnsNegativeOneSixth() { + val lhsFraction = ONE_THIRD_FRACTION + val rhsFraction = ONE_HALF_FRACTION + + val result = lhsFraction - rhsFraction + + assertThat(result).hasNegativePropertyThat().isTrue() + assertThat(result).hasNumeratorThat().isEqualTo(1) + assertThat(result).hasDenominatorThat().isEqualTo(6) + assertThat(result).hasWholeNumberThat().isEqualTo(0) + } + + @Test + fun testMinus_oneHalfAndOneThird_returnsOneSixth() { + val lhsFraction = ONE_HALF_FRACTION + val rhsFraction = ONE_THIRD_FRACTION + + val result = lhsFraction - rhsFraction + + // Demonstrate anticommutativity, i.e.: a-b=-(b-a). + assertThat(result).hasNegativePropertyThat().isFalse() + assertThat(result).hasNumeratorThat().isEqualTo(1) + assertThat(result).hasDenominatorThat().isEqualTo(6) + assertThat(result).hasWholeNumberThat().isEqualTo(0) + } + + @Test + fun testMinus_twentyFiveThirtiethsAndTwentyThreeSevenths_returnsNegTwoAndNineteenFortyTwos() { + val lhsFraction = Fraction.newBuilder().apply { + numerator = 25 + denominator = 30 + }.build() + val rhsFraction = Fraction.newBuilder().apply { + numerator = 23 + denominator = 7 + }.build() + + val result = lhsFraction - rhsFraction + + // Verify that the result of subtraction results in a properly formed fraction. + assertThat(result).hasNegativePropertyThat().isTrue() + assertThat(result).hasNumeratorThat().isEqualTo(19) + assertThat(result).hasDenominatorThat().isEqualTo(42) + assertThat(result).hasWholeNumberThat().isEqualTo(2) + } + + @Test + fun testMinus_negativeOneAndOneThird_returnsNegativeOneAndOneThird() { + val lhsFraction = NEGATIVE_ONE_FRACTION + val rhsFraction = ONE_THIRD_FRACTION + + val result = lhsFraction - rhsFraction + + assertThat(result).hasNegativePropertyThat().isTrue() + assertThat(result).hasNumeratorThat().isEqualTo(1) + assertThat(result).hasDenominatorThat().isEqualTo(3) + assertThat(result).hasWholeNumberThat().isEqualTo(1) + } + + @Test + fun testMinus_oneAndNegativeOneThird_returnsOneAndOneThird() { + val lhsFraction = ONE_FRACTION + val rhsFraction = NEGATIVE_ONE_THIRD_FRACTION + + val result = lhsFraction - rhsFraction + + assertThat(result).hasNegativePropertyThat().isFalse() + assertThat(result).hasNumeratorThat().isEqualTo(1) + assertThat(result).hasDenominatorThat().isEqualTo(3) + assertThat(result).hasWholeNumberThat().isEqualTo(1) + } + + @Test + fun testMinus_negativeOneAndNegativeOneThird_returnsNegativeTwoThirds() { + val lhsFraction = NEGATIVE_ONE_FRACTION + val rhsFraction = NEGATIVE_ONE_THIRD_FRACTION + + val result = lhsFraction - rhsFraction + + assertThat(result).hasNegativePropertyThat().isTrue() + assertThat(result).hasNumeratorThat().isEqualTo(2) + assertThat(result).hasDenominatorThat().isEqualTo(3) + assertThat(result).hasWholeNumberThat().isEqualTo(0) + } + + @Test + fun testTimes_zeroAndZero_returnsZero() { + val lhsFraction = ZERO_FRACTION + val rhsFraction = ZERO_FRACTION + + val result = lhsFraction * rhsFraction + + assertThat(result).isEqualTo(ZERO_FRACTION) + } + + @Test + fun testTimes_oneAndZero_returnsZero() { + val lhsFraction = ONE_FRACTION + val rhsFraction = ZERO_FRACTION + + val result = lhsFraction * rhsFraction + + assertThat(result).isEqualTo(ZERO_FRACTION) + } + + @Test + fun testTimes_oneAndOne_returnsOne() { + val lhsFraction = ONE_FRACTION + val rhsFraction = ONE_FRACTION + + val result = lhsFraction * rhsFraction + + assertThat(result).isEqualTo(ONE_FRACTION) + } + + @Test + fun testTimes_twoAndOne_returnsTwo() { + val lhsFraction = TWO_FRACTION + val rhsFraction = ONE_FRACTION + + val result = lhsFraction * rhsFraction + + assertThat(result).isEqualTo(TWO_FRACTION) + } + + @Test + fun testTimes_oneHalfAndOneThird_returnsOneSixth() { + val lhsFraction = ONE_HALF_FRACTION + val rhsFraction = ONE_THIRD_FRACTION + + val result = lhsFraction * rhsFraction + + assertThat(result).hasNegativePropertyThat().isFalse() + assertThat(result).hasNumeratorThat().isEqualTo(1) + assertThat(result).hasDenominatorThat().isEqualTo(6) + assertThat(result).hasWholeNumberThat().isEqualTo(0) + } + + @Test + fun testTimes_oneThirdAndOneHalf_returnsOneSixth() { + val lhsFraction = ONE_THIRD_FRACTION + val rhsFraction = ONE_HALF_FRACTION + + val result = lhsFraction * rhsFraction + + // Demonstrate commutativity, i.e.: a*b=b*a. + assertThat(result).hasNegativePropertyThat().isFalse() + assertThat(result).hasNumeratorThat().isEqualTo(1) + assertThat(result).hasDenominatorThat().isEqualTo(6) + assertThat(result).hasWholeNumberThat().isEqualTo(0) + } + + @Test + fun testTimes_sevenHalvesAndTwentyFifteenths_returnsFourAndTwoThirds() { + val lhsFraction = Fraction.newBuilder().apply { + numerator = 7 + denominator = 2 + }.build() + val rhsFraction = Fraction.newBuilder().apply { + numerator = 20 + denominator = 15 + }.build() + + val result = lhsFraction * rhsFraction + + // Demonstrate that the multiplied result is a fully properly form fraction. + assertThat(result).hasNegativePropertyThat().isFalse() + assertThat(result).hasNumeratorThat().isEqualTo(2) + assertThat(result).hasDenominatorThat().isEqualTo(3) + assertThat(result).hasWholeNumberThat().isEqualTo(4) + } + + @Test + fun testTimes_negativeTwoAndOneThird_returnsNegativeTwoThirds() { + val lhsFraction = NEGATIVE_TWO_FRACTION + val rhsFraction = ONE_THIRD_FRACTION + + val result = lhsFraction * rhsFraction + + assertThat(result).hasNegativePropertyThat().isTrue() + assertThat(result).hasNumeratorThat().isEqualTo(2) + assertThat(result).hasDenominatorThat().isEqualTo(3) + assertThat(result).hasWholeNumberThat().isEqualTo(0) + } + + @Test + fun testTimes_twoAndNegativeOneThird_returnsNegativeTwoThirds() { + val lhsFraction = TWO_FRACTION + val rhsFraction = NEGATIVE_ONE_THIRD_FRACTION + + val result = lhsFraction * rhsFraction + + assertThat(result).hasNegativePropertyThat().isTrue() + assertThat(result).hasNumeratorThat().isEqualTo(2) + assertThat(result).hasDenominatorThat().isEqualTo(3) + assertThat(result).hasWholeNumberThat().isEqualTo(0) + } + + @Test + fun testTimes_negativeTwoAndNegativeOneThird_returnsTwoThirds() { + val lhsFraction = NEGATIVE_TWO_FRACTION + val rhsFraction = NEGATIVE_ONE_THIRD_FRACTION + + val result = lhsFraction * rhsFraction + + // The negatives cancel out during multiplication. + assertThat(result).hasNegativePropertyThat().isFalse() + assertThat(result).hasNumeratorThat().isEqualTo(2) + assertThat(result).hasDenominatorThat().isEqualTo(3) + assertThat(result).hasWholeNumberThat().isEqualTo(0) + } + + @Test + fun testDivides_zeroAndZero_throwsException() { + val lhsFraction = ZERO_FRACTION + val rhsFraction = ZERO_FRACTION + + assertThrows(Exception::class) { lhsFraction / rhsFraction } + } + + @Test + fun testDivides_oneAndZero_throwsException() { + val lhsFraction = ONE_FRACTION + val rhsFraction = ZERO_FRACTION + + assertThrows(Exception::class) { lhsFraction / rhsFraction } + } + + @Test + fun testDivides_twoAndZero_throwsException() { + val lhsFraction = TWO_FRACTION + val rhsFraction = ZERO_FRACTION + + assertThrows(Exception::class) { lhsFraction / rhsFraction } + } + + @Test + fun testDivides_twoAndOne_returnsTwo() { + val lhsFraction = TWO_FRACTION + val rhsFraction = ONE_FRACTION + + val result = lhsFraction / rhsFraction + + assertThat(result).isEqualTo(TWO_FRACTION) + } + + @Test + fun testDivides_oneHalfAndOneThird_returnsOneAndOneHalf() { + val lhsFraction = ONE_HALF_FRACTION + val rhsFraction = ONE_THIRD_FRACTION + + val result = lhsFraction / rhsFraction + + // (1/2)/(1/3)=3/2=1 1/2. + assertThat(result).isEqualTo(ONE_AND_ONE_HALF_FRACTION) + } + + @Test + fun testDivides_oneThirdAndOneHalf_returnsTwoThirds() { + val lhsFraction = ONE_THIRD_FRACTION + val rhsFraction = ONE_HALF_FRACTION + + val result = lhsFraction / rhsFraction + + // Demonstrate anticommutativity, i.e.: a/b=1/(b/a). + assertThat(result).hasNegativePropertyThat().isFalse() + assertThat(result).hasNumeratorThat().isEqualTo(2) + assertThat(result).hasDenominatorThat().isEqualTo(3) + assertThat(result).hasWholeNumberThat().isEqualTo(0) + } + + @Test + fun testDivides_fourThirdsAndTenThirtyFifths_returnsFourAndTwoThirds() { + val lhsFraction = Fraction.newBuilder().apply { + numerator = 4 + denominator = 3 + }.build() + val rhsFraction = Fraction.newBuilder().apply { + numerator = 10 + denominator = 35 + }.build() + + val result = lhsFraction / rhsFraction + + // Demonstrate that the divided result is a fully properly form fraction. + assertThat(result).hasNegativePropertyThat().isFalse() + assertThat(result).hasNumeratorThat().isEqualTo(2) + assertThat(result).hasDenominatorThat().isEqualTo(3) + assertThat(result).hasWholeNumberThat().isEqualTo(4) + } + + @Test + fun testDivides_negativeTwoAndOneThird_returnsNegativeSix() { + val lhsFraction = NEGATIVE_TWO_FRACTION + val rhsFraction = ONE_THIRD_FRACTION + + val result = lhsFraction / rhsFraction + + assertThat(result).hasNegativePropertyThat().isTrue() + assertThat(result).hasNumeratorThat().isEqualTo(0) + assertThat(result).hasDenominatorThat().isEqualTo(1) + assertThat(result).hasWholeNumberThat().isEqualTo(6) + } + + @Test + fun testDivides_twoAndNegativeOneThird_returnsNegativeSix() { + val lhsFraction = TWO_FRACTION + val rhsFraction = NEGATIVE_ONE_THIRD_FRACTION + + val result = lhsFraction / rhsFraction + + assertThat(result).hasNegativePropertyThat().isTrue() + assertThat(result).hasNumeratorThat().isEqualTo(0) + assertThat(result).hasDenominatorThat().isEqualTo(1) + assertThat(result).hasWholeNumberThat().isEqualTo(6) + } + + @Test + fun testDivides_negativeTwoAndNegativeOneThird_returnsSix() { + val lhsFraction = NEGATIVE_TWO_FRACTION + val rhsFraction = NEGATIVE_ONE_THIRD_FRACTION + + val result = lhsFraction / rhsFraction + + // The negatives cancel out during division. + assertThat(result).hasNegativePropertyThat().isFalse() + assertThat(result).hasNumeratorThat().isEqualTo(0) + assertThat(result).hasDenominatorThat().isEqualTo(1) + assertThat(result).hasWholeNumberThat().isEqualTo(6) + } + + @Test + fun testPow_zeroToZero_returnsOne() { + val fraction = ZERO_FRACTION + + val result = fraction pow 0 + + // See pow's documentation for specifics. + assertThat(result).isEqualTo(ONE_FRACTION) + } + + @Test + fun testPow_oneToZero_returnsOne() { + val fraction = ONE_FRACTION + + val result = fraction pow 0 + + assertThat(result).isEqualTo(ONE_FRACTION) + } + + @Test + fun testPow_oneToOne_returnsOne() { + val fraction = ONE_FRACTION + + val result = fraction pow 1 + + assertThat(result).isEqualTo(ONE_FRACTION) + } + + @Test + fun testPow_twoToZero_returnsOne() { + val fraction = TWO_FRACTION + + val result = fraction pow 0 + + assertThat(result).isEqualTo(ONE_FRACTION) + } + + @Test + fun testPow_twoToOne_returnsTwo() { + val fraction = TWO_FRACTION + + val result = fraction pow 1 + + assertThat(result).isEqualTo(TWO_FRACTION) + } + + @Test + fun testPow_twoToTwo_returnsFour() { + val fraction = TWO_FRACTION + + val result = fraction pow 2 + + assertThat(result).hasNegativePropertyThat().isFalse() + assertThat(result).hasNumeratorThat().isEqualTo(0) + assertThat(result).hasDenominatorThat().isEqualTo(1) + assertThat(result).hasWholeNumberThat().isEqualTo(4) + } + + @Test + fun testPow_oneThirdToTwo_returnsOneNinth() { + val fraction = ONE_THIRD_FRACTION + + val result = fraction pow 2 + + assertThat(result).hasNegativePropertyThat().isFalse() + assertThat(result).hasNumeratorThat().isEqualTo(1) + assertThat(result).hasDenominatorThat().isEqualTo(9) + assertThat(result).hasWholeNumberThat().isEqualTo(0) + } + + @Test + fun testPow_negativeOneThirdToTwo_returnsOneNinth() { + val fraction = NEGATIVE_ONE_THIRD_FRACTION + + val result = fraction pow 2 + + // The negative sign is lost since the power is even. + assertThat(result).hasNegativePropertyThat().isFalse() + assertThat(result).hasNumeratorThat().isEqualTo(1) + assertThat(result).hasDenominatorThat().isEqualTo(9) + assertThat(result).hasWholeNumberThat().isEqualTo(0) + } + + @Test + fun testPow_negativeOneThirdToThree_returnsNegativeOneTwentySeventh() { + val fraction = NEGATIVE_ONE_THIRD_FRACTION + + val result = fraction pow 3 + + // The negative sign is preserved since the power is odd. + assertThat(result).hasNegativePropertyThat().isTrue() + assertThat(result).hasNumeratorThat().isEqualTo(1) + assertThat(result).hasDenominatorThat().isEqualTo(27) + assertThat(result).hasWholeNumberThat().isEqualTo(0) + } + + @Test + fun testPow_twoToNegativeTwo_returnsOneFourth() { + val fraction = TWO_FRACTION + + val result = fraction pow -2 + + assertThat(result).hasNegativePropertyThat().isFalse() + assertThat(result).hasNumeratorThat().isEqualTo(1) + assertThat(result).hasDenominatorThat().isEqualTo(4) + assertThat(result).hasWholeNumberThat().isEqualTo(0) + } + + @Test + fun testPow_oneThirdToNegativeThree_returnsTwentySeven() { + val fraction = ONE_THIRD_FRACTION + + val result = fraction pow -3 + + // The negative sign is preserved since the power is odd. + assertThat(result).hasNegativePropertyThat().isFalse() + assertThat(result).hasNumeratorThat().isEqualTo(0) + assertThat(result).hasDenominatorThat().isEqualTo(1) + assertThat(result).hasWholeNumberThat().isEqualTo(27) + } + + @Test + fun testPow_negativeOneThirdToNegativeTwo_returnsNine() { + val fraction = NEGATIVE_ONE_THIRD_FRACTION + + val result = fraction pow -2 + + // The negative sign is lost since the power is even. + assertThat(result).hasNegativePropertyThat().isFalse() + assertThat(result).hasNumeratorThat().isEqualTo(0) + assertThat(result).hasDenominatorThat().isEqualTo(1) + assertThat(result).hasWholeNumberThat().isEqualTo(9) + } + + @Test + fun testPow_negativeOneThirdToNegativeThree_returnsNegativeTwentySeven() { + val fraction = NEGATIVE_ONE_THIRD_FRACTION + + val result = fraction pow -3 + + // The negative sign is preserved since the power is odd. + assertThat(result).hasNegativePropertyThat().isTrue() + assertThat(result).hasNumeratorThat().isEqualTo(0) + assertThat(result).hasDenominatorThat().isEqualTo(1) + assertThat(result).hasWholeNumberThat().isEqualTo(27) + } + + @Test + fun testPow_fourSeventhsCubed_returnsSixtyFourThreeHundredFortyThirds() { + val fraction = Fraction.newBuilder().apply { + numerator = 4 + denominator = 7 + }.build() + + val result = fraction pow 3 + + // Verify that the numerator is also correctly multiplied. + assertThat(result).hasNegativePropertyThat().isFalse() + assertThat(result).hasNumeratorThat().isEqualTo(64) + assertThat(result).hasDenominatorThat().isEqualTo(343) + assertThat(result).hasWholeNumberThat().isEqualTo(0) + } + + @Test + fun testPow_twentyOneTwelfthsToNegativeThree_returnsFiveAndTwentyThreeSixtyFourths() { + val fraction = Fraction.newBuilder().apply { + numerator = 12 + denominator = 21 + }.build() + + val result = fraction pow -3 + + // Verify that the resulting value is in fully proper form. + assertThat(result).hasNegativePropertyThat().isFalse() + assertThat(result).hasNumeratorThat().isEqualTo(23) + assertThat(result).hasDenominatorThat().isEqualTo(64) + assertThat(result).hasWholeNumberThat().isEqualTo(5) + } + + @Test + fun testToWholeNumberFraction_zero_returnsZeroFraction() { + val wholeNumber = 0 + + val fraction = wholeNumber.toWholeNumberFraction() + + assertThat(fraction).isEqualTo(ZERO_FRACTION) + } + + @Test + fun testToWholeNumberFraction_one_returnsOneFraction() { + val wholeNumber = 1 + + val fraction = wholeNumber.toWholeNumberFraction() + + assertThat(fraction).isEqualTo(ONE_FRACTION) + } + + @Test + fun testToWholeNumberFraction_twentyThree_returnsTwentyThreeFraction() { + val wholeNumber = 23 + + val fraction = wholeNumber.toWholeNumberFraction() + + assertThat(fraction).hasNegativePropertyThat().isFalse() + assertThat(fraction).hasNumeratorThat().isEqualTo(0) + assertThat(fraction).hasDenominatorThat().isEqualTo(1) + assertThat(fraction).hasWholeNumberThat().isEqualTo(23) + } + + @Test + fun testToWholeNumberFraction_negativeZero_returnsZeroFraction() { + val wholeNumber = -0 + + val fraction = wholeNumber.toWholeNumberFraction() + + assertThat(fraction).isEqualTo(ZERO_FRACTION) + } + + @Test + fun testToWholeNumberFraction_negativeOne_returnsNegativeOneFraction() { + val wholeNumber = -1 + + val fraction = wholeNumber.toWholeNumberFraction() + + assertThat(fraction).isEqualTo(NEGATIVE_ONE_FRACTION) + } + + @Test + fun testToWholeNumberFraction_negativeTwentyThree_returnsNegativeTwentyThreeFraction() { + val wholeNumber = -23 + + val fraction = wholeNumber.toWholeNumberFraction() + + assertThat(fraction).hasNegativePropertyThat().isTrue() + assertThat(fraction).hasNumeratorThat().isEqualTo(0) + assertThat(fraction).hasDenominatorThat().isEqualTo(1) + assertThat(fraction).hasWholeNumberThat().isEqualTo(23) + } } diff --git a/utility/src/test/java/org/oppia/android/util/math/MathExpressionExtensionsTest.kt b/utility/src/test/java/org/oppia/android/util/math/MathExpressionExtensionsTest.kt new file mode 100644 index 00000000000..0a81df14c54 --- /dev/null +++ b/utility/src/test/java/org/oppia/android/util/math/MathExpressionExtensionsTest.kt @@ -0,0 +1,97 @@ +package org.oppia.android.util.math + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.oppia.android.app.model.MathEquation +import org.oppia.android.app.model.MathExpression +import org.oppia.android.testing.math.RealSubject.Companion.assertThat +import org.oppia.android.util.math.MathExpressionParser.Companion.ErrorCheckingMode.ALL_ERRORS +import org.oppia.android.util.math.MathExpressionParser.Companion.MathParsingResult +import org.oppia.android.util.math.MathExpressionParser.Companion.parseAlgebraicExpression +import org.oppia.android.util.math.MathExpressionParser.Companion.parseNumericExpression +import org.robolectric.annotation.LooperMode + +/** + * Tests for [MathExpression] and [MathEquation] extensions. + * + * Note that this suite only verifies that the extensions work at a high-level. More specific + * verifications for operations like LaTeX conversion and expression evaluation are part of more + * targeted test suites such as [ExpressionToLatexConverterTest] and + * [NumericExpressionEvaluatorTest]. + */ +// FunctionName: test names are conventionally named with underscores. +// SameParameterValue: tests should have specific context included/excluded for readability. +@Suppress("FunctionName", "SameParameterValue") +@RunWith(AndroidJUnit4::class) +@LooperMode(LooperMode.Mode.PAUSED) +class MathExpressionExtensionsTest { + @Test + fun testToRawLatex_algebraicExpression_divNotAsFraction_returnsLatexStringWithDivision() { + val expression = parseAlgebraicExpression("(x^2+7x-y)/2") + + val latex = expression.toRawLatex(divAsFraction = false) + + assertThat(latex).isEqualTo("(x ^ {2} + 7x - y) \\div 2") + } + + @Test + fun testToRawLatex_algebraicExpression_divAsFraction_returnsLatexStringWithFraction() { + val expression = parseAlgebraicExpression("(x^2+7x-y)/2") + + val latex = expression.toRawLatex(divAsFraction = true) + + assertThat(latex).isEqualTo("\\frac{(x ^ {2} + 7x - y)}{2}") + } + + @Test + fun testToRawLatex_algebraicEquation_divNotAsFraction_returnsLatexStringWithDivisions() { + val equation = parseAlgebraicEquation("y/2=(x^2+x-7)/(2x)") + + val latex = equation.toRawLatex(divAsFraction = false) + + assertThat(latex).isEqualTo("y \\div 2 = (x ^ {2} + x - 7) \\div (2x)") + } + + @Test + fun testToRawLatex_algebraicEquation_divAsFraction_returnsLatexStringWithFractions() { + val equation = parseAlgebraicEquation("y/2=(x^2+x-7)/(2x)") + + val latex = equation.toRawLatex(divAsFraction = true) + + assertThat(latex).isEqualTo("\\frac{y}{2} = \\frac{(x ^ {2} + x - 7)}{(2x)}") + } + + @Test + fun testEvaluateAsNumericExpression_numericExpression_returnsCorrectValue() { + val expression = parseNumericExpression("7*(3.14/0.76+8.4)^(3.8+1/(2+2/(7.4+1)))") + + val result = expression.evaluateAsNumericExpression() + + assertThat(result).isIrrationalThat().isWithin(1e-5).of(322194.700361352) + } + + private companion object { + private fun parseNumericExpression(expression: String): MathExpression { + return parseNumericExpression(expression, ALL_ERRORS).retrieveExpectedSuccessfulResult() + } + + private fun parseAlgebraicExpression(expression: String): MathExpression { + return parseAlgebraicExpression( + expression, allowedVariables = listOf("x", "y", "z"), ALL_ERRORS + ).retrieveExpectedSuccessfulResult() + } + + private fun parseAlgebraicEquation(expression: String): MathEquation { + return MathExpressionParser.parseAlgebraicEquation( + expression, allowedVariables = listOf("x", "y", "z"), ALL_ERRORS + ).retrieveExpectedSuccessfulResult() + } + + private fun MathParsingResult.retrieveExpectedSuccessfulResult(): T { + assertThat(this).isInstanceOf(MathParsingResult.Success::class.java) + return (this as MathParsingResult.Success).result + } + } +} diff --git a/utility/src/test/java/org/oppia/android/util/math/NumericExpressionEvaluatorTest.kt b/utility/src/test/java/org/oppia/android/util/math/NumericExpressionEvaluatorTest.kt new file mode 100644 index 00000000000..1f4786bde4b --- /dev/null +++ b/utility/src/test/java/org/oppia/android/util/math/NumericExpressionEvaluatorTest.kt @@ -0,0 +1,240 @@ +package org.oppia.android.util.math + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.oppia.android.app.model.MathExpression +import org.oppia.android.testing.math.RealSubject.Companion.assertThat +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.MathExpressionParser.Companion.parseAlgebraicExpression +import org.oppia.android.util.math.NumericExpressionEvaluator.Companion.evaluate +import org.robolectric.annotation.LooperMode + +/** + * Tests for [NumericExpressionEvaluator]. + * + * This test suite is primarily focused on verifying high-level behaviors of the evaluator. More + * specific tests exist for the sub-implementation pieces of the evaluator in [RealExtensionsTest] + * and [FractionExtensionsTest], and more complicated expression evaluation can be seen in + * [NumericExpressionParserTest]. + */ +// FunctionName: test names are conventionally named with underscores. +// SameParameterValue: tests should have specific context included/excluded for readability. +@Suppress("FunctionName", "SameParameterValue") +@RunWith(AndroidJUnit4::class) +@LooperMode(LooperMode.Mode.PAUSED) +class NumericExpressionEvaluatorTest { + @Test + fun testEvaluate_defaultExpression_returnsNull() { + val expression = MathExpression.getDefaultInstance() + + val result = expression.evaluate() + + // Default expressions have nothing to evaluate. + assertThat(result).isNull() + } + + @Test + fun testEvaluate_constantExpression_returnsConstant() { + val expression = parseNumericExpression("2") + + val result = expression.evaluate() + + assertThat(result).isIntegerThat().isEqualTo(2) + } + + @Test + fun testEvaluate_variableExpression_returnsNull() { + val expression = parseAlgebraicExpression("2x") + + val result = expression.evaluate() + + // Cannot evaluate variables. + assertThat(result).isNull() + } + + @Test + fun testEvaluate_onePlusTwo_returnsThree() { + val expression = parseNumericExpression("1+2") + + val result = expression.evaluate() + + assertThat(result).isIntegerThat().isEqualTo(3) + } + + @Test + fun testEvaluate_oneMinusTwo_returnsNegativeOne() { + val expression = parseNumericExpression("1-2") + + val result = expression.evaluate() + + assertThat(result).isIntegerThat().isEqualTo(-1) + } + + @Test + fun testEvaluate_twoTimesSeven_returnsFourteen() { + val expression = parseNumericExpression("2*7") + + val result = expression.evaluate() + + assertThat(result).isIntegerThat().isEqualTo(14) + } + + @Test + fun testEvaluate_fourDividedByTwo_returnsTwo() { + val expression = parseNumericExpression("4/2") + + val result = expression.evaluate() + + assertThat(result).isIntegerThat().isEqualTo(2) + } + + @Test + fun testEvaluate_oneDividedByTwo_returnsOneHalfFraction() { + val expression = parseNumericExpression("1/2") + + val result = expression.evaluate() + + assertThat(result).isRationalThat().apply { + hasNegativePropertyThat().isFalse() + hasWholeNumberThat().isEqualTo(0) + hasNumeratorThat().isEqualTo(1) + hasDenominatorThat().isEqualTo(2) + } + } + + @Test + fun testEvaluate_minusOne_returnsMinusOne() { + val expression = parseNumericExpression("-2") + + val result = expression.evaluate() + + assertThat(result).isIntegerThat().isEqualTo(-2) + } + + @Test + fun testEvaluate_plusTwo_returnsTwo() { + val expression = parseNumericExpression("+2", errorCheckingMode = REQUIRED_ONLY) + + val result = expression.evaluate() + + assertThat(result).isIntegerThat().isEqualTo(2) + } + + @Test + fun testEvaluate_minusGroupOneMinusTwo_returnsOne() { + val expression = parseNumericExpression("-(1-2)") + + val result = expression.evaluate() + + assertThat(result).isIntegerThat().isEqualTo(1) + } + + @Test + fun testEvaluate_plusGroupOneMinusTwo_returnsMinusOne() { + val expression = parseNumericExpression("+(1-2)", errorCheckingMode = REQUIRED_ONLY) + + val result = expression.evaluate() + + assertThat(result).isIntegerThat().isEqualTo(-1) + } + + @Test + fun testEvaluate_twoTimesNegativeSeven_returnsNegativeFourteen() { + val expression = parseNumericExpression("2*-7") + + val result = expression.evaluate() + + assertThat(result).isIntegerThat().isEqualTo(-14) + } + + @Test + fun testEvaluate_oneDividedByGroupOfOnePlusTwo_returnsOneThirdFraction() { + val expression = parseNumericExpression("1/(1+2)") + + val result = expression.evaluate() + + assertThat(result).isRationalThat().apply { + hasNegativePropertyThat().isFalse() + hasWholeNumberThat().isEqualTo(0) + hasNumeratorThat().isEqualTo(1) + hasDenominatorThat().isEqualTo(3) + } + } + + @Test + fun testEvaluate_twoRaisedToThree_returnsEight() { + val expression = parseNumericExpression("2^3") + + val result = expression.evaluate() + + assertThat(result).isIntegerThat().isEqualTo(8) + } + + @Test + fun testEvaluate_groupOneDividedByTwoRaisedToNegativeThree_returnsEightFraction() { + val expression = parseNumericExpression("1/(2^-3)") + + val result = expression.evaluate() + + assertThat(result).isRationalThat().apply { + hasNegativePropertyThat().isFalse() + hasWholeNumberThat().isEqualTo(8) + hasNumeratorThat().isEqualTo(0) + hasDenominatorThat().isEqualTo(1) + } + } + + @Test + fun testEvaluate_rootOfTwo_returnsSquareRootOfTwoDecimal() { + val expression = parseNumericExpression("√2") + + val result = expression.evaluate() + + assertThat(result).isIrrationalThat().isWithin(1e-5).of(1.414213562) + } + + @Test + fun testEvaluate_rootOfGroupTwoRaisedToTwo_returnsTwoInteger() { + val expression = parseNumericExpression("√(2^2)") + + val result = expression.evaluate() + + assertThat(result).isIntegerThat().isEqualTo(2) + } + + @Test + fun testEvaluate_threeRaisedToOneDividedByTwo_returnsSquareRootOfThreeDecimal() { + val expression = parseNumericExpression("3^(1/2)") + + val result = expression.evaluate() + + assertThat(result).isIrrationalThat().isWithin(1e-5).of(1.732050808) + } + + private companion object { + private fun parseNumericExpression( + expression: String, + errorCheckingMode: ErrorCheckingMode = ALL_ERRORS + ): MathExpression { + return MathExpressionParser.parseNumericExpression( + expression, errorCheckingMode + ).retrieveExpectedSuccessfulResult() + } + + private fun parseAlgebraicExpression(expression: String): MathExpression { + return parseAlgebraicExpression( + expression, allowedVariables = listOf("x", "y", "z"), ALL_ERRORS + ).retrieveExpectedSuccessfulResult() + } + + private fun MathParsingResult.retrieveExpectedSuccessfulResult(): T { + assertThat(this).isInstanceOf(MathParsingResult.Success::class.java) + return (this as MathParsingResult.Success).result + } + } +} diff --git a/utility/src/test/java/org/oppia/android/util/math/NumericExpressionParserTest.kt b/utility/src/test/java/org/oppia/android/util/math/NumericExpressionParserTest.kt index 16933425c25..37e89515be3 100644 --- a/utility/src/test/java/org/oppia/android/util/math/NumericExpressionParserTest.kt +++ b/utility/src/test/java/org/oppia/android/util/math/NumericExpressionParserTest.kt @@ -19,12 +19,18 @@ import org.robolectric.annotation.LooperMode * both operator associativity and precedence). This suite does not cover errors (see * [MathExpressionParserTest] for those tests), nor algebraic expressions (see * [AlgebraicExpressionParserTest]). + * + * Further, many of the tests also verify that the expression evaluates to the correct value. This + * suite's goal is not to test that the evaluator works functionally but, rather, that it works + * practically. There are targeted tests designed to fail for the evaluator if issues are + * introduced (see [NumericExpressionEvaluatorTest]). */ // FunctionName: test names are conventionally named with underscores. @Suppress("FunctionName") @RunWith(AndroidJUnit4::class) @LooperMode(LooperMode.Mode.PAUSED) class NumericExpressionParserTest { + @Test fun testParse_singleInteger_returnsExpressionWithConstant() { val expression = parseNumericExpressionWithAllErrors("1") @@ -34,6 +40,7 @@ class NumericExpressionParserTest { withValueThat().isIntegerThat().isEqualTo(1) } } + assertThat(expression).evaluatesToIntegerThat().isEqualTo(1) } @Test @@ -45,6 +52,7 @@ class NumericExpressionParserTest { withValueThat().isIntegerThat().isEqualTo(2) } } + assertThat(expression).evaluatesToIntegerThat().isEqualTo(2) } @Test @@ -56,6 +64,7 @@ class NumericExpressionParserTest { withValueThat().isIntegerThat().isEqualTo(732) } } + assertThat(expression).evaluatesToIntegerThat().isEqualTo(732) } @Test @@ -67,6 +76,7 @@ class NumericExpressionParserTest { withValueThat().isIrrationalThat().isWithin(1e-5).of(2.5) } } + assertThat(expression).evaluatesToIrrationalThat().isWithin(1e-5).of(2.5) } @Test @@ -87,6 +97,7 @@ class NumericExpressionParserTest { } } } + assertThat(expression).evaluatesToIntegerThat().isEqualTo(3) } @Test @@ -107,6 +118,7 @@ class NumericExpressionParserTest { } } } + assertThat(expression).evaluatesToIntegerThat().isEqualTo(-1) } @Test @@ -127,6 +139,7 @@ class NumericExpressionParserTest { } } } + assertThat(expression).evaluatesToIntegerThat().isEqualTo(-1) } @Test @@ -147,6 +160,7 @@ class NumericExpressionParserTest { } } } + assertThat(expression).evaluatesToIntegerThat().isEqualTo(-1) } @Test @@ -167,6 +181,7 @@ class NumericExpressionParserTest { } } } + assertThat(expression).evaluatesToIntegerThat().isEqualTo(2) } @Test @@ -187,6 +202,7 @@ class NumericExpressionParserTest { } } } + assertThat(expression).evaluatesToIntegerThat().isEqualTo(2) } @Test @@ -207,6 +223,12 @@ class NumericExpressionParserTest { } } } + assertThat(expression).evaluatesToRationalThat().apply { + hasNegativePropertyThat().isFalse() + hasWholeNumberThat().isEqualTo(0) + hasNumeratorThat().isEqualTo(1) + hasDenominatorThat().isEqualTo(2) + } } @Test @@ -227,6 +249,12 @@ class NumericExpressionParserTest { } } } + assertThat(expression).evaluatesToRationalThat().apply { + hasNegativePropertyThat().isFalse() + hasWholeNumberThat().isEqualTo(0) + hasNumeratorThat().isEqualTo(1) + hasDenominatorThat().isEqualTo(2) + } } @Test @@ -247,6 +275,7 @@ class NumericExpressionParserTest { } } } + assertThat(expression).evaluatesToIntegerThat().isEqualTo(1) } @Test @@ -262,6 +291,7 @@ class NumericExpressionParserTest { } } } + assertThat(expression).evaluatesToIntegerThat().isEqualTo(-2) } @Test @@ -277,6 +307,7 @@ class NumericExpressionParserTest { } } } + assertThat(expression).evaluatesToIntegerThat().isEqualTo(-2) } @Test @@ -292,6 +323,7 @@ class NumericExpressionParserTest { } } } + assertThat(expression).evaluatesToIntegerThat().isEqualTo(2) } @Test @@ -305,6 +337,7 @@ class NumericExpressionParserTest { } } } + assertThat(expression).evaluatesToIntegerThat().isEqualTo(2) } @Test @@ -320,6 +353,7 @@ class NumericExpressionParserTest { } } } + assertThat(expression).evaluatesToIrrationalThat().isWithin(1e-5).of(1.414213562) } @Test @@ -335,6 +369,7 @@ class NumericExpressionParserTest { } } } + assertThat(expression).evaluatesToIrrationalThat().isWithin(1e-5).of(1.414213562) } @Test @@ -365,6 +400,7 @@ class NumericExpressionParserTest { } } } + assertThat(expression).evaluatesToIntegerThat().isEqualTo(7) } @Test @@ -395,6 +431,7 @@ class NumericExpressionParserTest { } } } + assertThat(expression).evaluatesToIntegerThat().isEqualTo(162) } @Test @@ -427,6 +464,7 @@ class NumericExpressionParserTest { } } } + assertThat(expression).evaluatesToIntegerThat().isEqualTo(1296) } @Test @@ -452,6 +490,9 @@ class NumericExpressionParserTest { } } } + // Note that this may differ from other calculators since the negation is applied last (others + // may interpret it as (-3)^4). + assertThat(expression).evaluatesToIntegerThat().isEqualTo(-81) } @Test @@ -486,6 +527,12 @@ class NumericExpressionParserTest { } } } + assertThat(expression).evaluatesToRationalThat().apply { + hasNegativePropertyThat().isFalse() + hasWholeNumberThat().isEqualTo(0) + hasNumeratorThat().isEqualTo(3) + hasDenominatorThat().isEqualTo(100000) + } } @Test @@ -520,6 +567,7 @@ class NumericExpressionParserTest { } } } + assertThat(expression).evaluatesToIntegerThat().isEqualTo(300000) } @Test @@ -545,6 +593,7 @@ class NumericExpressionParserTest { } } } + assertThat(expression).evaluatesToIrrationalThat().isWithin(1e-5).of(9.0) } @Test @@ -595,6 +644,7 @@ class NumericExpressionParserTest { } } } + assertThat(expression).evaluatesToIntegerThat().isEqualTo(-1) } @Test @@ -645,14 +695,20 @@ class NumericExpressionParserTest { } } } + assertThat(expression).evaluatesToRationalThat().apply { + hasNegativePropertyThat().isFalse() + hasWholeNumberThat().isEqualTo(1) + hasNumeratorThat().isEqualTo(1) + hasDenominatorThat().isEqualTo(4) + } } @Test fun testParse_nestedExponents_returnsExpWithExponentsAsRightAssociative() { - val expression = parseNumericExpressionWithoutOptionalErrors("2^3^4") + val expression = parseNumericExpressionWithoutOptionalErrors("2^3^1.5") // Exponentiation is resolved with right associativity, that is, from right to left. This is - // made clearer by grouping: 2^(3^4). Note that this is a specific choice made by the + // made clearer by grouping: 2^(3^1.5). Note that this is a specific choice made by the // implementation as there's no broad consensus around exponentiation associativity for infix // exponentiation. Right associativity is ideal since it more closely matches written-out // exponentiation (where the nested exponent is resolved first). @@ -672,18 +728,19 @@ class NumericExpressionParserTest { } rightOperand { constant { - withValueThat().isIntegerThat().isEqualTo(4) + withValueThat().isIrrationalThat().isWithin(1e-5).of(1.5) } } } } } } + assertThat(expression).evaluatesToIrrationalThat().isWithin(1e-5).of(36.660445757) } @Test fun testParse_nestedExponents_withGroups_returnsExpWithForcedLeftAssociativeExponent() { - val expression = parseNumericExpressionWithAllErrors("(2^3)^4") + val expression = parseNumericExpressionWithAllErrors("(2^3)^1.5") // Nested exponentiation can be "forced" to be left-associative by using a group to explicitly // change the order (since groups have higher precedence than exponents). @@ -707,11 +764,12 @@ class NumericExpressionParserTest { } rightOperand { constant { - withValueThat().isIntegerThat().isEqualTo(4) + withValueThat().isIrrationalThat().isWithin(1e-5).of(1.5) } } } } + assertThat(expression).evaluatesToIrrationalThat().isWithin(1e-5).of(22.627416998) } @Test @@ -752,6 +810,7 @@ class NumericExpressionParserTest { } } } + // Cannot evaluate this expression in real numbers. } @Test @@ -789,6 +848,7 @@ class NumericExpressionParserTest { } } } + assertThat(expression).evaluatesToIntegerThat().isEqualTo(-3) } @Test @@ -809,6 +869,7 @@ class NumericExpressionParserTest { } } } + assertThat(expression).evaluatesToIntegerThat().isEqualTo(6) } @Test @@ -833,6 +894,7 @@ class NumericExpressionParserTest { } } } + assertThat(expression).evaluatesToIntegerThat().isEqualTo(6) } @Test @@ -865,6 +927,7 @@ class NumericExpressionParserTest { } } } + assertThat(expression).evaluatesToIntegerThat().isEqualTo(6) } @Test @@ -908,6 +971,7 @@ class NumericExpressionParserTest { } } } + assertThat(expression).evaluatesToIntegerThat().isEqualTo(21) } @Test @@ -933,6 +997,7 @@ class NumericExpressionParserTest { } } } + assertThat(expression).evaluatesToIrrationalThat().isWithin(1e-5).of(3.464101615) } @Test @@ -962,6 +1027,7 @@ class NumericExpressionParserTest { } } } + assertThat(expression).evaluatesToIrrationalThat().isWithin(1e-5).of(2.449489743) } @Test @@ -987,6 +1053,7 @@ class NumericExpressionParserTest { } } } + assertThat(expression).evaluatesToIrrationalThat().isWithin(1e-5).of(2.828427125) } @Test @@ -1016,6 +1083,7 @@ class NumericExpressionParserTest { } } } + assertThat(expression).evaluatesToIrrationalThat().isWithin(1e-5).of(2.0) } @Test @@ -1044,6 +1112,7 @@ class NumericExpressionParserTest { } } } + assertThat(expression).evaluatesToIrrationalThat().isWithin(1e-5).of(4.242640687) } @Test @@ -1086,6 +1155,7 @@ class NumericExpressionParserTest { } } } + assertThat(expression).evaluatesToIrrationalThat().isWithin(1e-5).of(4.898979486) } @Test @@ -1120,6 +1190,7 @@ class NumericExpressionParserTest { } } } + assertThat(expression).evaluatesToIrrationalThat().isWithin(1e-5).of(5.242640687) } @Test @@ -1179,6 +1250,12 @@ class NumericExpressionParserTest { } } } + assertThat(expression).evaluatesToRationalThat().apply { + hasNegativePropertyThat().isFalse() + hasWholeNumberThat().isEqualTo(73) + hasNumeratorThat().isEqualTo(1) + hasDenominatorThat().isEqualTo(2) + } } @Test @@ -1215,6 +1292,7 @@ class NumericExpressionParserTest { } } } + assertThat(expression).evaluatesToIntegerThat().isEqualTo(32) } @Test @@ -1256,6 +1334,7 @@ class NumericExpressionParserTest { } } } + assertThat(expression).evaluatesToIntegerThat().isEqualTo(8192) } @Test @@ -1357,6 +1436,12 @@ class NumericExpressionParserTest { } } } + assertThat(expression).evaluatesToRationalThat().apply { + hasNegativePropertyThat().isFalse() + hasWholeNumberThat().isEqualTo(351) + hasNumeratorThat().isEqualTo(1) + hasDenominatorThat().isEqualTo(3) + } } @Test @@ -1446,6 +1531,7 @@ class NumericExpressionParserTest { } } } + assertThat(expression).evaluatesToIrrationalThat().isWithin(1e-5).of(956.092045778) } @Test @@ -1476,6 +1562,7 @@ class NumericExpressionParserTest { } } } + assertThat(expression).evaluatesToIntegerThat().isEqualTo(6) } @Test @@ -1537,6 +1624,7 @@ class NumericExpressionParserTest { } } } + assertThat(expression).evaluatesToIntegerThat().isEqualTo(13) } @Test @@ -1656,6 +1744,7 @@ class NumericExpressionParserTest { } } } + assertThat(expression).evaluatesToIrrationalThat().isWithin(1e-5).of(322194.700361352) } @Test diff --git a/utility/src/test/java/org/oppia/android/util/math/RealExtensionsTest.kt b/utility/src/test/java/org/oppia/android/util/math/RealExtensionsTest.kt index 6efbac11ed0..744784c355b 100644 --- a/utility/src/test/java/org/oppia/android/util/math/RealExtensionsTest.kt +++ b/utility/src/test/java/org/oppia/android/util/math/RealExtensionsTest.kt @@ -1,29 +1,45 @@ package org.oppia.android.util.math -import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import org.junit.Test import org.junit.runner.RunWith import org.oppia.android.app.model.Fraction import org.oppia.android.app.model.Real import org.oppia.android.testing.assertThrows +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.ParameterizedJunitTestRunner import org.oppia.android.testing.math.RealSubject.Companion.assertThat import org.robolectric.annotation.LooperMode /** Tests for [Real] extensions. */ // FunctionName: test names are conventionally named with underscores. @Suppress("FunctionName") -@RunWith(AndroidJUnit4::class) +@RunWith(OppiaParameterizedTestRunner::class) +@SelectRunnerPlatform(ParameterizedJunitTestRunner::class) @LooperMode(LooperMode.Mode.PAUSED) class RealExtensionsTest { private companion object { private const val PI = 3.1415 + private val ZERO_FRACTION = Fraction.newBuilder().apply { + numerator = 0 + denominator = 1 + }.build() + private val ONE_HALF_FRACTION = Fraction.newBuilder().apply { numerator = 1 denominator = 2 }.build() + private val ONE_FOURTH_FRACTION = Fraction.newBuilder().apply { + numerator = 1 + denominator = 4 + }.build() + private val ONE_AND_ONE_HALF_FRACTION = Fraction.newBuilder().apply { numerator = 1 denominator = 2 @@ -43,6 +59,18 @@ class RealExtensionsTest { private val NEGATIVE_PI_REAL = createIrrationalReal(-PI) } + private val fractionParser by lazy { FractionParser() } + + @Parameter var lhsInt: Int = Int.MIN_VALUE + @Parameter lateinit var lhsFrac: String + @Parameter var lhsDouble: Double = Double.MIN_VALUE + @Parameter var rhsInt: Int = Int.MIN_VALUE + @Parameter lateinit var rhsFrac: String + @Parameter var rhsDouble: Double = Double.MIN_VALUE + @Parameter var expInt: Int = Int.MIN_VALUE + @Parameter lateinit var expFrac: String + @Parameter var expDouble: Double = Double.MIN_VALUE + @Test fun testIsRational_default_returnsFalse() { val defaultReal = Real.getDefaultInstance() @@ -73,6 +101,36 @@ class RealExtensionsTest { assertThat(result).isFalse() } + @Test + fun testIsInteger_default_returnsFalse() { + val defaultReal = Real.getDefaultInstance() + + val result = defaultReal.isInteger() + + assertThat(result).isFalse() + } + + @Test + fun testIsInteger_twoInteger_returnsTrue() { + val result = TWO_REAL.isInteger() + + assertThat(result).isTrue() + } + + @Test + fun testIsInteger_oneHalfFraction_returnsFalse() { + val result = ONE_HALF_REAL.isInteger() + + assertThat(result).isFalse() + } + + @Test + fun testIsInteger_piIrrational_returnsFalse() { + val result = PI_REAL.isInteger() + + assertThat(result).isFalse() + } + @Test fun testIsNegative_default_throwsException() { val defaultReal = Real.getDefaultInstance() @@ -399,6 +457,1328 @@ class RealExtensionsTest { assertThat(result).isIrrationalThat().isWithin(1e-5).of(PI) } + + /* + * Begin operator tests. + * + * Note that parameterized tests are used here to reduce the length of the overall test despite it + * being not best practice (since each parameterized test is actually verifying multiple + * behaviors). + * + * For a reference on the iteration names: + * - 'identity' refers to an operator identity (i.e. a value which doesn't result in a change to + * the other operand of the operation) + * - commutativity refers to verifying commutativity, e.g.: a+b=b+a or a*b=b*a + * - noncommutativity refers to verifying that commutativity doesn't hold, e.g.: 2^3 != 3^2 + * - anticommutativity refers to verifying that commutativity is operationally reversed, e.g.: + * a-b=-(b-a) and a/b=1/(b/a). + */ + + // Addition tests. + + @Test + @RunParameterized( + Iteration("identity+identity", "lhsInt=0", "rhsInt=0", "expInt=0"), + Iteration("int+identity", "lhsInt=1", "rhsInt=0", "expInt=1"), + Iteration("int+int", "lhsInt=1", "rhsInt=2", "expInt=3"), + Iteration("commutativity", "lhsInt=2", "rhsInt=1", "expInt=3"), + Iteration("int+-int", "lhsInt=1", "rhsInt=-2", "expInt=-1"), + Iteration("-int+int", "lhsInt=-1", "rhsInt=2", "expInt=1"), + Iteration("-int+-int", "lhsInt=-1", "rhsInt=-2", "expInt=-3") + ) + fun testPlus_intAndInt_returnsInt() { + val lhsReal = createIntegerReal(lhsInt) + val rhsReal = createIntegerReal(rhsInt) + + val result = lhsReal + rhsReal + + assertThat(result).isIntegerThat().isEqualTo(expInt) + } + + @Test + @RunParameterized( + Iteration("identity+identity", "lhsInt=0", "rhsFrac=0/1", "expFrac=0/1"), + Iteration("int+identity", "lhsInt=1", "rhsFrac=0/1", "expFrac=1"), + Iteration("int+fraction", "lhsInt=2", "rhsFrac=1/3", "expFrac=2 1/3"), + Iteration("int+wholeNumberFraction", "lhsInt=2", "rhsFrac=3/1", "expFrac=5"), + Iteration("commutativity", "lhsInt=3", "rhsFrac=2/1", "expFrac=5"), + Iteration("int+-fraction", "lhsInt=2", "rhsFrac=-1/3", "expFrac=1 2/3"), + Iteration("-int+fraction", "lhsInt=-2", "rhsFrac=1/3", "expFrac=-1 2/3"), + Iteration("-int+-fraction", "lhsInt=-2", "rhsFrac=-1/3", "expFrac=-2 1/3") + ) + fun testPlus_intAndFraction_returnsFraction() { + val lhsReal = createIntegerReal(lhsInt) + val rhsReal = createRationalReal(rhsFrac) + + val result = lhsReal + rhsReal + + val expectedFraction = fractionParser.parseFraction(expFrac) + assertThat(result).isRationalThat().isEqualTo(expectedFraction) + } + + @Test + @RunParameterized( + Iteration("identity+identity", "lhsInt=0", "rhsDouble=0.0", "expDouble=0.0"), + Iteration("int+identity", "lhsInt=1", "rhsDouble=0.0", "expDouble=1.0"), + Iteration("int+double", "lhsInt=1", "rhsDouble=3.14", "expDouble=4.14"), + Iteration("int+wholeNumberDouble", "lhsInt=1", "rhsDouble=3.0", "expDouble=4.0"), + Iteration("commutativity", "lhsInt=3", "rhsDouble=1.0", "expDouble=4.0"), + Iteration("int+-double", "lhsInt=1", "rhsDouble=-3.14", "expDouble=-2.14"), + Iteration("-int+double", "lhsInt=-1", "rhsDouble=3.14", "expDouble=2.14"), + Iteration("-int+-double", "lhsInt=-1", "rhsDouble=-3.14", "expDouble=-4.14") + ) + fun testPlus_intAndDouble_returnsDouble() { + val lhsReal = createIntegerReal(lhsInt) + val rhsReal = createIrrationalReal(rhsDouble) + + val result = lhsReal + rhsReal + + assertThat(result).isIrrationalThat().isWithin(1e-5).of(expDouble) + } + + @Test + @RunParameterized( + Iteration("identity+identity", "lhsFrac=0/1", "rhsInt=0", "expFrac=0/1"), + Iteration("fraction+identity", "lhsFrac=1/1", "rhsInt=0", "expFrac=1"), + Iteration("fraction+int", "lhsFrac=1/3", "rhsInt=2", "expFrac=2 1/3"), + Iteration("wholeNumberFraction+int", "lhsFrac=3/1", "rhsInt=2", "expFrac=5"), + Iteration("commutativity", "lhsFrac=2/1", "rhsInt=3", "expFrac=5"), + Iteration("fraction+-int", "lhsFrac=1/3", "rhsInt=-2", "expFrac=-1 2/3"), + Iteration("-fraction+int", "lhsFrac=-1/3", "rhsInt=2", "expFrac=1 2/3"), + Iteration("-fraction+-int", "lhsFrac=-1/3", "rhsInt=-2", "expFrac=-2 1/3") + ) + fun testPlus_fractionAndInt_returnsFraction() { + val lhsReal = createRationalReal(lhsFrac) + val rhsReal = createIntegerReal(rhsInt) + + val result = lhsReal + rhsReal + + val expectedFraction = fractionParser.parseFraction(expFrac) + assertThat(result).isRationalThat().isEqualTo(expectedFraction) + } + + @Test + @RunParameterized( + Iteration("identity+identity", "lhsFrac=0/1", "rhsFrac=0/1", "expFrac=0/1"), + Iteration("fraction+identity", "lhsFrac=3/2", "rhsFrac=0/1", "expFrac=1 1/2"), + Iteration("fraction+fraction", "lhsFrac=3/2", "rhsFrac=1/3", "expFrac=1 5/6"), + Iteration("commutativity", "lhsFrac=1/3", "rhsFrac=3/2", "expFrac=1 5/6"), + Iteration("fraction+-fraction", "lhsFrac=1/2", "rhsFrac=-1/3", "expFrac=1/6"), + Iteration("-fraction+fraction", "lhsFrac=-1/2", "rhsFrac=1/3", "expFrac=-1/6"), + Iteration("-fraction+-fraction", "lhsFrac=-1/2", "rhsFrac=-1/3", "expFrac=-5/6") + ) + fun testPlus_fractionAndFraction_returnsFraction() { + val lhsReal = createRationalReal(lhsFrac) + val rhsReal = createRationalReal(rhsFrac) + + val result = lhsReal + rhsReal + + val expectedFraction = fractionParser.parseFraction(expFrac) + assertThat(result).isRationalThat().isEqualTo(expectedFraction) + } + + @Test + @RunParameterized( + Iteration("identity+identity", "lhsFrac=0/1", "rhsDouble=0.0", "expDouble=0.0"), + Iteration("fraction+identity", "lhsFrac=3/2", "rhsDouble=0.0", "expDouble=1.5"), + Iteration("fraction+double", "lhsFrac=3/2", "rhsDouble=3.14", "expDouble=4.64"), + Iteration("wholeNumberFraction+double", "lhsFrac=3/1", "rhsDouble=2.0", "expDouble=5.0"), + Iteration("commutativity", "lhsFrac=2/1", "rhsDouble=3.0", "expDouble=5.0"), + Iteration("fraction+-double", "lhsFrac=3/2", "rhsDouble=-3.14", "expDouble=-1.64"), + Iteration("-fraction+double", "lhsFrac=-3/2", "rhsDouble=3.14", "expDouble=1.64"), + Iteration("-fraction+-double", "lhsFrac=-3/2", "rhsDouble=-3.14", "expDouble=-4.64") + ) + fun testPlus_fractionAndDouble_returnsDouble() { + val lhsReal = createRationalReal(lhsFrac) + val rhsReal = createIrrationalReal(rhsDouble) + + val result = lhsReal + rhsReal + + assertThat(result).isIrrationalThat().isWithin(1e-5).of(expDouble) + } + + @Test + @RunParameterized( + Iteration("identity+identity", "lhsDouble=0.0", "rhsInt=0", "expDouble=0.0"), + Iteration("double+identity", "lhsDouble=1.0", "rhsInt=0", "expDouble=1.0"), + Iteration("double+int", "lhsDouble=3.14", "rhsInt=1", "expDouble=4.14"), + Iteration("wholeNumberDouble+int", "lhsDouble=3.0", "rhsInt=1", "expDouble=4.0"), + Iteration("commutativity", "lhsDouble=1.0", "rhsInt=3", "expDouble=4.0"), + Iteration("double+-int", "lhsDouble=3.14", "rhsInt=-1", "expDouble=2.14"), + Iteration("-double+int", "lhsDouble=-3.14", "rhsInt=1", "expDouble=-2.14"), + Iteration("-double+-int", "lhsDouble=-3.14", "rhsInt=-1", "expDouble=-4.14") + ) + fun testPlus_doubleAndInt_returnsDouble() { + val lhsReal = createIrrationalReal(lhsDouble) + val rhsReal = createIntegerReal(rhsInt) + + val result = lhsReal + rhsReal + + assertThat(result).isIrrationalThat().isWithin(1e-5).of(expDouble) + } + + @Test + @RunParameterized( + Iteration("identity+identity", "lhsDouble=0.0", "rhsFrac=0/1", "expDouble=0.0"), + Iteration("double+identity", "lhsDouble=3.14", "rhsFrac=0/1", "expDouble=3.14"), + Iteration("double+fraction", "lhsDouble=3.14", "rhsFrac=3/2", "expDouble=4.64"), + Iteration("double+wholeNumberFraction", "lhsDouble=2.0", "rhsFrac=3/1", "expDouble=5.0"), + Iteration("commutativity", "lhsDouble=3.0", "rhsFrac=2/1", "expDouble=5.0"), + Iteration("double+-fraction", "lhsDouble=3.14", "rhsFrac=-3/2", "expDouble=1.64"), + Iteration("-double+fraction", "lhsDouble=-3.14", "rhsFrac=3/2", "expDouble=-1.64"), + Iteration("-double+-fraction", "lhsDouble=-3.14", "rhsFrac=-3/2", "expDouble=-4.64") + ) + fun testPlus_doubleAndFraction_returnsDouble() { + val lhsReal = createIrrationalReal(lhsDouble) + val rhsReal = createRationalReal(rhsFrac) + + val result = lhsReal + rhsReal + + assertThat(result).isIrrationalThat().isWithin(1e-5).of(expDouble) + } + + @Test + @RunParameterized( + Iteration("identity+identity", "lhsDouble=0.0", "rhsDouble=0.0", "expDouble=0.0"), + Iteration("double+identity", "lhsDouble=1.0", "rhsDouble=0.0", "expDouble=1.0"), + Iteration("double+double", "lhsDouble=3.14", "rhsDouble=2.7", "expDouble=5.84"), + Iteration("commutativity", "lhsDouble=2.7", "rhsDouble=3.14", "expDouble=5.84"), + Iteration("double+-double", "lhsDouble=3.14", "rhsDouble=-2.7", "expDouble=0.44"), + Iteration("-double+double", "lhsDouble=-3.14", "rhsDouble=2.7", "expDouble=-0.44"), + Iteration("-double+-double", "lhsDouble=-3.14", "rhsDouble=-2.7", "expDouble=-5.84") + ) + fun testPlus_doubleAndDouble_returnsDouble() { + val lhsReal = createIrrationalReal(lhsDouble) + val rhsReal = createIrrationalReal(rhsDouble) + + val result = lhsReal + rhsReal + + assertThat(result).isIrrationalThat().isWithin(1e-5).of(expDouble) + } + + // Subtraction tests. + + @Test + @RunParameterized( + Iteration("identity-identity", "lhsInt=0", "rhsInt=0", "expInt=0"), + Iteration("int-identity", "lhsInt=1", "rhsInt=0", "expInt=1"), + Iteration("int-int", "lhsInt=1", "rhsInt=2", "expInt=-1"), + Iteration("anticommutativity", "lhsInt=2", "rhsInt=1", "expInt=1"), + Iteration("int--int", "lhsInt=1", "rhsInt=-2", "expInt=3"), + Iteration("-int-int", "lhsInt=-1", "rhsInt=2", "expInt=-3"), + Iteration("-int--int", "lhsInt=-1", "rhsInt=-2", "expInt=1") + ) + fun testMinus_intAndInt_returnsInt() { + val lhsReal = createIntegerReal(lhsInt) + val rhsReal = createIntegerReal(rhsInt) + + val result = lhsReal - rhsReal + + assertThat(result).isIntegerThat().isEqualTo(expInt) + } + + @Test + @RunParameterized( + Iteration("identity-identity", "lhsInt=0", "rhsFrac=0/1", "expFrac=0/1"), + Iteration("int-identity", "lhsInt=1", "rhsFrac=0/1", "expFrac=1"), + Iteration("int-fraction", "lhsInt=2", "rhsFrac=1/3", "expFrac=1 2/3"), + Iteration("int-wholeNumberFraction", "lhsInt=2", "rhsFrac=3/1", "expFrac=-1"), + Iteration("anticommutativity", "lhsInt=3", "rhsFrac=2/1", "expFrac=1"), + Iteration("int--fraction", "lhsInt=2", "rhsFrac=-1/3", "expFrac=2 1/3"), + Iteration("-int-fraction", "lhsInt=-2", "rhsFrac=1/3", "expFrac=-2 1/3"), + Iteration("-int--fraction", "lhsInt=-2", "rhsFrac=-1/3", "expFrac=-1 2/3") + ) + fun testMinus_intAndFraction_returnsFraction() { + val lhsReal = createIntegerReal(lhsInt) + val rhsReal = createRationalReal(rhsFrac) + + val result = lhsReal - rhsReal + + val expectedFraction = fractionParser.parseFraction(expFrac) + assertThat(result).isRationalThat().isEqualTo(expectedFraction) + } + + @Test + @RunParameterized( + Iteration("identity-identity", "lhsInt=0", "rhsDouble=0.0", "expDouble=0.0"), + Iteration("int-identity", "lhsInt=1", "rhsDouble=0.0", "expDouble=1.0"), + Iteration("int-double", "lhsInt=1", "rhsDouble=3.14", "expDouble=-2.14"), + Iteration("int-wholeNumberDouble", "lhsInt=1", "rhsDouble=3.0", "expDouble=-2.0"), + Iteration("anticommutativity", "lhsInt=3", "rhsDouble=1.0", "expDouble=2.0"), + Iteration("int--double", "lhsInt=1", "rhsDouble=-3.14", "expDouble=4.14"), + Iteration("-int-double", "lhsInt=-1", "rhsDouble=3.14", "expDouble=-4.14"), + Iteration("-int--double", "lhsInt=-1", "rhsDouble=-3.14", "expDouble=2.14") + ) + fun testMinus_intAndDouble_returnsDouble() { + val lhsReal = createIntegerReal(lhsInt) + val rhsReal = createIrrationalReal(rhsDouble) + + val result = lhsReal - rhsReal + + assertThat(result).isIrrationalThat().isWithin(1e-5).of(expDouble) + } + + @Test + @RunParameterized( + Iteration("identity-identity", "lhsFrac=0/1", "rhsInt=0", "expFrac=0/1"), + Iteration("fraction-identity", "lhsFrac=1/1", "rhsInt=0", "expFrac=1"), + Iteration("fraction-int", "lhsFrac=1/3", "rhsInt=2", "expFrac=-1 2/3"), + Iteration("wholeNumberFraction-int", "lhsFrac=3/1", "rhsInt=2", "expFrac=1"), + Iteration("anticommutativity", "lhsFrac=2/1", "rhsInt=3", "expFrac=-1"), + Iteration("fraction--int", "lhsFrac=1/3", "rhsInt=-2", "expFrac=2 1/3"), + Iteration("-fraction-int", "lhsFrac=-1/3", "rhsInt=2", "expFrac=-2 1/3"), + Iteration("-fraction--int", "lhsFrac=-1/3", "rhsInt=-2", "expFrac=1 2/3") + ) + fun testMinus_fractionAndInt_returnsFraction() { + val lhsReal = createRationalReal(lhsFrac) + val rhsReal = createIntegerReal(rhsInt) + + val result = lhsReal - rhsReal + + val expectedFraction = fractionParser.parseFraction(expFrac) + assertThat(result).isRationalThat().isEqualTo(expectedFraction) + } + + @Test + @RunParameterized( + Iteration("identity-identity", "lhsFrac=0/1", "rhsFrac=0/1", "expFrac=0/1"), + Iteration("fraction-identity", "lhsFrac=3/2", "rhsFrac=0/1", "expFrac=1 1/2"), + Iteration("fraction-fraction", "lhsFrac=3/2", "rhsFrac=1/3", "expFrac=1 1/6"), + Iteration("anticommutativity", "lhsFrac=1/3", "rhsFrac=3/2", "expFrac=-1 1/6"), + Iteration("fraction--fraction", "lhsFrac=1/2", "rhsFrac=-1/3", "expFrac=5/6"), + Iteration("-fraction-fraction", "lhsFrac=-1/2", "rhsFrac=1/3", "expFrac=-5/6"), + Iteration("-fraction--fraction", "lhsFrac=-1/2", "rhsFrac=-1/3", "expFrac=-1/6") + ) + fun testMinus_fractionAndFraction_returnsFraction() { + val lhsReal = createRationalReal(lhsFrac) + val rhsReal = createRationalReal(rhsFrac) + + val result = lhsReal - rhsReal + + val expectedFraction = fractionParser.parseFraction(expFrac) + assertThat(result).isRationalThat().isEqualTo(expectedFraction) + } + + @Test + @RunParameterized( + Iteration("identity-identity", "lhsFrac=0/1", "rhsDouble=0.0", "expDouble=0.0"), + Iteration("fraction-identity", "lhsFrac=3/2", "rhsDouble=0.0", "expDouble=1.5"), + Iteration("fraction-double", "lhsFrac=3/2", "rhsDouble=3.14", "expDouble=-1.64"), + Iteration("wholeNumberFraction-double", "lhsFrac=3/1", "rhsDouble=2.0", "expDouble=1.0"), + Iteration("anticommutativity", "lhsFrac=2/1", "rhsDouble=3.0", "expDouble=-1.0"), + Iteration("fraction--double", "lhsFrac=3/2", "rhsDouble=-3.14", "expDouble=4.64"), + Iteration("-fraction-double", "lhsFrac=-3/2", "rhsDouble=3.14", "expDouble=-4.64"), + Iteration("-fraction--double", "lhsFrac=-3/2", "rhsDouble=-3.14", "expDouble=1.64") + ) + fun testMinus_fractionAndDouble_returnsDouble() { + val lhsReal = createRationalReal(lhsFrac) + val rhsReal = createIrrationalReal(rhsDouble) + + val result = lhsReal - rhsReal + + assertThat(result).isIrrationalThat().isWithin(1e-5).of(expDouble) + } + + @Test + @RunParameterized( + Iteration("identity-identity", "lhsDouble=0.0", "rhsInt=0", "expDouble=0.0"), + Iteration("double-identity", "lhsDouble=1.0", "rhsInt=0", "expDouble=1.0"), + Iteration("double-int", "lhsDouble=3.14", "rhsInt=1", "expDouble=2.14"), + Iteration("wholeNumberDouble-int", "lhsDouble=3.0", "rhsInt=1", "expDouble=2.0"), + Iteration("anticommutativity", "lhsDouble=1.0", "rhsInt=3", "expDouble=-2.0"), + Iteration("double--int", "lhsDouble=3.14", "rhsInt=-1", "expDouble=4.14"), + Iteration("-double-int", "lhsDouble=-3.14", "rhsInt=1", "expDouble=-4.14"), + Iteration("-double--int", "lhsDouble=-3.14", "rhsInt=-1", "expDouble=-2.14") + ) + fun testMinus_doubleAndInt_returnsDouble() { + val lhsReal = createIrrationalReal(lhsDouble) + val rhsReal = createIntegerReal(rhsInt) + + val result = lhsReal - rhsReal + + assertThat(result).isIrrationalThat().isWithin(1e-5).of(expDouble) + } + + @Test + @RunParameterized( + Iteration("identity-identity", "lhsDouble=0.0", "rhsFrac=0/1", "expDouble=0.0"), + Iteration("double-identity", "lhsDouble=3.14", "rhsFrac=0/1", "expDouble=3.14"), + Iteration("double-fraction", "lhsDouble=3.14", "rhsFrac=3/2", "expDouble=1.64"), + Iteration("double-wholeNumberFraction", "lhsDouble=2.0", "rhsFrac=3/1", "expDouble=-1.0"), + Iteration("anticommutativity", "lhsDouble=3.0", "rhsFrac=2/1", "expDouble=1.0"), + Iteration("double--fraction", "lhsDouble=3.14", "rhsFrac=-3/2", "expDouble=4.64"), + Iteration("-double-fraction", "lhsDouble=-3.14", "rhsFrac=3/2", "expDouble=-4.64"), + Iteration("-double--fraction", "lhsDouble=-3.14", "rhsFrac=-3/2", "expDouble=-1.64") + ) + fun testMinus_doubleAndFraction_returnsDouble() { + val lhsReal = createIrrationalReal(lhsDouble) + val rhsReal = createRationalReal(rhsFrac) + + val result = lhsReal - rhsReal + + assertThat(result).isIrrationalThat().isWithin(1e-5).of(expDouble) + } + + @Test + @RunParameterized( + Iteration("identity-identity", "lhsDouble=0.0", "rhsDouble=0.0", "expDouble=0.0"), + Iteration("double-identity", "lhsDouble=1.0", "rhsDouble=0.0", "expDouble=1.0"), + Iteration("double-double", "lhsDouble=3.14", "rhsDouble=2.7", "expDouble=0.44"), + Iteration("anticommutativity", "lhsDouble=2.7", "rhsDouble=3.14", "expDouble=-0.44"), + Iteration("double--double", "lhsDouble=3.14", "rhsDouble=-2.7", "expDouble=5.84"), + Iteration("-double-double", "lhsDouble=-3.14", "rhsDouble=2.7", "expDouble=-5.84"), + Iteration("-double--double", "lhsDouble=-3.14", "rhsDouble=-2.7", "expDouble=-0.44") + ) + fun testMinus_doubleAndDouble_returnsDouble() { + val lhsReal = createIrrationalReal(lhsDouble) + val rhsReal = createIrrationalReal(rhsDouble) + + val result = lhsReal - rhsReal + + assertThat(result).isIrrationalThat().isWithin(1e-5).of(expDouble) + } + + // Multiplication tests. + + @Test + @RunParameterized( + Iteration("identity*identity", "lhsInt=1", "rhsInt=1", "expInt=1"), + Iteration("int*identity", "lhsInt=2", "rhsInt=1", "expInt=2"), + Iteration("int*int", "lhsInt=3", "rhsInt=2", "expInt=6"), + Iteration("commutativity", "lhsInt=2", "rhsInt=3", "expInt=6"), + Iteration("int*-int", "lhsInt=3", "rhsInt=-2", "expInt=-6"), + Iteration("-int*int", "lhsInt=-3", "rhsInt=2", "expInt=-6"), + Iteration("-int*-int", "lhsInt=-3", "rhsInt=-2", "expInt=6") + ) + fun testTimes_intAndInt_returnsInt() { + val lhsReal = createIntegerReal(lhsInt) + val rhsReal = createIntegerReal(rhsInt) + + val result = lhsReal * rhsReal + + assertThat(result).isIntegerThat().isEqualTo(expInt) + } + + @Test + @RunParameterized( + Iteration("identity*identity", "lhsInt=1", "rhsFrac=1", "expFrac=1"), + Iteration("int*identity", "lhsInt=2", "rhsFrac=1", "expFrac=2"), + Iteration("int*fraction", "lhsInt=2", "rhsFrac=1/3", "expFrac=2/3"), + Iteration("int*wholeNumberFraction", "lhsInt=2", "rhsFrac=3/1", "expFrac=6"), + Iteration("commutativity", "lhsInt=3", "rhsFrac=2/1", "expFrac=6"), + Iteration("int*-fraction", "lhsInt=2", "rhsFrac=-1/3", "expFrac=-2/3"), + Iteration("-int*fraction", "lhsInt=-2", "rhsFrac=1/3", "expFrac=-2/3"), + Iteration("-int*-fraction", "lhsInt=-2", "rhsFrac=-1/3", "expFrac=2/3") + ) + fun testTimes_intAndFraction_returnsFraction() { + val lhsReal = createIntegerReal(lhsInt) + val rhsReal = createRationalReal(rhsFrac) + + val result = lhsReal * rhsReal + + val expectedFraction = fractionParser.parseFraction(expFrac) + assertThat(result).isRationalThat().isEqualTo(expectedFraction) + } + + @Test + @RunParameterized( + Iteration("identity*identity", "lhsInt=1", "rhsDouble=1.0", "expDouble=1.0"), + Iteration("int*identity", "lhsInt=2", "rhsDouble=1.0", "expDouble=2.0"), + Iteration("int*double", "lhsInt=2", "rhsDouble=3.14", "expDouble=6.28"), + Iteration("int*wholeNumberDouble", "lhsInt=2", "rhsDouble=3.0", "expDouble=6.0"), + Iteration("commutativity", "lhsInt=3", "rhsDouble=2.0", "expDouble=6.0"), + Iteration("int*-double", "lhsInt=2", "rhsDouble=-3.14", "expDouble=-6.28"), + Iteration("-int*double", "lhsInt=-2", "rhsDouble=3.14", "expDouble=-6.28"), + Iteration("-int*-double", "lhsInt=-2", "rhsDouble=-3.14", "expDouble=6.28") + ) + fun testTimes_intAndDouble_returnsDouble() { + val lhsReal = createIntegerReal(lhsInt) + val rhsReal = createIrrationalReal(rhsDouble) + + val result = lhsReal * rhsReal + + assertThat(result).isIrrationalThat().isWithin(1e-5).of(expDouble) + } + + @Test + @RunParameterized( + Iteration("identity*identity", "lhsFrac=1/1", "rhsInt=1", "expFrac=1"), + Iteration("fraction*identity", "lhsFrac=2/1", "rhsInt=1", "expFrac=2"), + Iteration("fraction*int", "lhsFrac=1/3", "rhsInt=2", "expFrac=2/3"), + Iteration("wholeNumberFraction*int", "lhsFrac=3/1", "rhsInt=2", "expFrac=6"), + Iteration("commutativity", "lhsFrac=2/1", "rhsInt=3", "expFrac=6"), + Iteration("fraction*-int", "lhsFrac=1/3", "rhsInt=-2", "expFrac=-2/3"), + Iteration("-fraction*int", "lhsFrac=-1/3", "rhsInt=2", "expFrac=-2/3"), + Iteration("-fraction*-int", "lhsFrac=-1/3", "rhsInt=-2", "expFrac=2/3") + ) + fun testTimes_fractionAndInt_returnsFraction() { + val lhsReal = createRationalReal(lhsFrac) + val rhsReal = createIntegerReal(rhsInt) + + val result = lhsReal * rhsReal + + val expectedFraction = fractionParser.parseFraction(expFrac) + assertThat(result).isRationalThat().isEqualTo(expectedFraction) + } + + @Test + @RunParameterized( + Iteration("identity*identity", "lhsFrac=1/1", "rhsFrac=1/1", "expFrac=1"), + Iteration("fraction*identity", "lhsFrac=3/2", "rhsFrac=1/1", "expFrac=1 1/2"), + Iteration("fraction*fraction", "lhsFrac=3/2", "rhsFrac=4/7", "expFrac=6/7"), + Iteration("commutativity", "lhsFrac=4/7", "rhsFrac=3/2", "expFrac=6/7"), + Iteration("fraction*-fraction", "lhsFrac=1 3/9", "rhsFrac=-8/11", "expFrac=-32/33"), + Iteration("-fraction*fraction", "lhsFrac=-1 3/9", "rhsFrac=8/11", "expFrac=-32/33"), + Iteration("-fraction*-fraction", "lhsFrac=-1 3/9", "rhsFrac=-8/11", "expFrac=32/33") + ) + fun testTimes_fractionAndFraction_returnsFraction() { + val lhsReal = createRationalReal(lhsFrac) + val rhsReal = createRationalReal(rhsFrac) + + val result = lhsReal * rhsReal + + val expectedFraction = fractionParser.parseFraction(expFrac) + assertThat(result).isRationalThat().isEqualTo(expectedFraction) + } + + @Test + @RunParameterized( + Iteration("identity*identity", "lhsFrac=1/1", "rhsDouble=1.0", "expDouble=1.0"), + Iteration("fraction*identity", "lhsFrac=3/2", "rhsDouble=1.0", "expDouble=1.5"), + Iteration("fraction*double", "lhsFrac=3/2", "rhsDouble=3.14", "expDouble=4.71"), + Iteration("wholeNumberFraction*double", "lhsFrac=3/1", "rhsDouble=2.0", "expDouble=6.0"), + Iteration("commutativity", "lhsFrac=2/1", "rhsDouble=3.0", "expDouble=6.0"), + Iteration("fraction*-double", "lhsFrac=1 3/2", "rhsDouble=-3.14", "expDouble=-7.85"), + Iteration("-fraction*double", "lhsFrac=-1 3/2", "rhsDouble=3.14", "expDouble=-7.85"), + Iteration("-fraction*-double", "lhsFrac=-1 3/2", "rhsDouble=-3.14", "expDouble=7.85") + ) + fun testTimes_fractionAndDouble_returnsDouble() { + val lhsReal = createRationalReal(lhsFrac) + val rhsReal = createIrrationalReal(rhsDouble) + + val result = lhsReal * rhsReal + + assertThat(result).isIrrationalThat().isWithin(1e-5).of(expDouble) + } + + @Test + @RunParameterized( + Iteration("identity*identity", "lhsDouble=1.0", "rhsInt=1", "expDouble=1.0"), + Iteration("double*identity", "lhsDouble=2.0", "rhsInt=1", "expDouble=2.0"), + Iteration("double*int", "lhsDouble=3.14", "rhsInt=2", "expDouble=6.28"), + Iteration("wholeNumberDouble*int", "lhsDouble=3.0", "rhsInt=2", "expDouble=6"), + Iteration("commutativity", "lhsDouble=2.0", "rhsInt=3", "expDouble=6.0"), + Iteration("double*-int", "lhsDouble=3.14", "rhsInt=-2", "expDouble=-6.28"), + Iteration("-double*int", "lhsDouble=-3.14", "rhsInt=2", "expDouble=-6.28"), + Iteration("-double*-int", "lhsDouble=-3.14", "rhsInt=-2", "expDouble=6.28") + ) + fun testTimes_doubleAndInt_returnsDouble() { + val lhsReal = createIrrationalReal(lhsDouble) + val rhsReal = createIntegerReal(rhsInt) + + val result = lhsReal * rhsReal + + assertThat(result).isIrrationalThat().isWithin(1e-5).of(expDouble) + } + + @Test + @RunParameterized( + Iteration("identity*identity", "lhsDouble=1.0", "rhsFrac=1/1", "expDouble=1.0"), + Iteration("double*identity", "lhsDouble=2.0", "rhsFrac=1/1", "expDouble=2.0"), + Iteration("double*fraction", "lhsDouble=3.14", "rhsFrac=3/2", "expDouble=4.71"), + Iteration("double*wholeNumberFraction", "lhsDouble=2.0", "rhsFrac=3/1", "expDouble=6.0"), + Iteration("commutativity", "lhsDouble=3.0", "rhsFrac=2/1", "expDouble=6.0"), + Iteration("double*-fraction", "lhsDouble=3.14", "rhsFrac=-1 3/2", "expDouble=-7.85"), + Iteration("-double*fraction", "lhsDouble=-3.14", "rhsFrac=1 3/2", "expDouble=-7.85"), + Iteration("-double*-fraction", "lhsDouble=-3.14", "rhsFrac=-1 3/2", "expDouble=7.85") + ) + fun testTimes_doubleAndFraction_returnsDouble() { + val lhsReal = createIrrationalReal(lhsDouble) + val rhsReal = createRationalReal(rhsFrac) + + val result = lhsReal * rhsReal + + assertThat(result).isIrrationalThat().isWithin(1e-5).of(expDouble) + } + + @Test + @RunParameterized( + Iteration("identity*identity", "lhsDouble=1.0", "rhsDouble=1.0", "expDouble=1.0"), + Iteration("double*identity", "lhsDouble=2.0", "rhsDouble=1.0", "expDouble=2.0"), + Iteration("double*double", "lhsDouble=3.14", "rhsDouble=2.7", "expDouble=8.478"), + Iteration("commutativity", "lhsDouble=2.7", "rhsDouble=3.14", "expDouble=8.478"), + Iteration("double*-double", "lhsDouble=3.14", "rhsDouble=-2.7", "expDouble=-8.478"), + Iteration("-double*double", "lhsDouble=-3.14", "rhsDouble=2.7", "expDouble=-8.478"), + Iteration("-double*-double", "lhsDouble=-3.14", "rhsDouble=-2.7", "expDouble=8.478") + ) + fun testTimes_doubleAndDouble_returnsDouble() { + val lhsReal = createIrrationalReal(lhsDouble) + val rhsReal = createIrrationalReal(rhsDouble) + + val result = lhsReal * rhsReal + + assertThat(result).isIrrationalThat().isWithin(1e-5).of(expDouble) + } + + // Division tests. + + @Test + @RunParameterized( + Iteration("identity/identity", "lhsInt=1", "rhsInt=1", "expInt=1"), + Iteration("int/identity", "lhsInt=2", "rhsInt=1", "expInt=2"), + Iteration("int/int", "lhsInt=8", "rhsInt=2", "expInt=4"), + Iteration("int/-int", "lhsInt=8", "rhsInt=-2", "expInt=-4"), + Iteration("-int/int", "lhsInt=-8", "rhsInt=2", "expInt=-4"), + Iteration("-int/-int", "lhsInt=-8", "rhsInt=-2", "expInt=4") + ) + fun testDiv_intAndInt_divides_returnsInt() { + val lhsReal = createIntegerReal(lhsInt) + val rhsReal = createIntegerReal(rhsInt) + + val result = lhsReal / rhsReal + + // If the divisor divides the dividend, the result is an integer. + assertThat(result).isIntegerThat().isEqualTo(expInt) + } + + @Test + @RunParameterized( + Iteration("int/int", "lhsInt=7", "rhsInt=2", "expFrac=3 1/2"), + Iteration("anticommutativity", "lhsInt=2", "rhsInt=7", "expFrac=2/7"), + Iteration("int/-int", "lhsInt=7", "rhsInt=-2", "expFrac=-3 1/2"), + Iteration("-int/int", "lhsInt=-7", "rhsInt=2", "expFrac=-3 1/2"), + Iteration("-int/-int", "lhsInt=-7", "rhsInt=-2", "expFrac=3 1/2") + ) + fun testDiv_intAndInt_doesNotDivide_returnsFraction() { + val lhsReal = createIntegerReal(lhsInt) + val rhsReal = createIntegerReal(rhsInt) + + val result = lhsReal / rhsReal + + // If the divisor doesn't divide the dividend, the result is a fraction. + val expectedFraction = fractionParser.parseFraction(expFrac) + assertThat(result).isRationalThat().isEqualTo(expectedFraction) + } + + @Test + @RunParameterized( + Iteration("identity/identity", "lhsInt=1", "rhsFrac=1", "expFrac=1"), + Iteration("int/identity", "lhsInt=2", "rhsFrac=1", "expFrac=2"), + Iteration("int/fraction", "lhsInt=4", "rhsFrac=1/3", "expFrac=12"), + Iteration("int/wholeNumberFraction", "lhsInt=2", "rhsFrac=3/1", "expFrac=2/3"), + Iteration("anticommutativity", "lhsInt=3", "rhsFrac=2/1", "expFrac=1 1/2"), + Iteration("int/-fraction", "lhsInt=5", "rhsFrac=-2/3", "expFrac=-7 1/2"), + Iteration("-int/fraction", "lhsInt=-5", "rhsFrac=2/3", "expFrac=-7 1/2"), + Iteration("-int/-fraction", "lhsInt=-5", "rhsFrac=-2/3", "expFrac=7 1/2") + ) + fun testDiv_intAndFraction_returnsFraction() { + val lhsReal = createIntegerReal(lhsInt) + val rhsReal = createRationalReal(rhsFrac) + + val result = lhsReal / rhsReal + + val expectedFraction = fractionParser.parseFraction(expFrac) + assertThat(result).isRationalThat().isEqualTo(expectedFraction) + } + + @Test + @RunParameterized( + Iteration("identity/identity", "lhsInt=1", "rhsDouble=1.0", "expDouble=1.0"), + Iteration("int/identity", "lhsInt=2", "rhsDouble=1.0", "expDouble=2.0"), + Iteration("int/double", "lhsInt=2", "rhsDouble=3.14", "expDouble=0.636942675"), + Iteration("int/wholeNumberDouble", "lhsInt=2", "rhsDouble=3.0", "expDouble=0.666666667"), + Iteration("anticommutativity", "lhsInt=3", "rhsDouble=2.0", "expDouble=1.5"), + Iteration("int/-double", "lhsInt=2", "rhsDouble=-3.14", "expDouble=-0.636942675"), + Iteration("-int/double", "lhsInt=-2", "rhsDouble=3.14", "expDouble=-0.636942675"), + Iteration("-int/-double", "lhsInt=-2", "rhsDouble=-3.14", "expDouble=0.636942675") + ) + fun testDiv_intAndDouble_returnsDouble() { + val lhsReal = createIntegerReal(lhsInt) + val rhsReal = createIrrationalReal(rhsDouble) + + val result = lhsReal / rhsReal + + assertThat(result).isIrrationalThat().isWithin(1e-5).of(expDouble) + } + + @Test + @RunParameterized( + Iteration("identity/identity", "lhsFrac=1/1", "rhsInt=1", "expFrac=1"), + Iteration("fraction/identity", "lhsFrac=2/1", "rhsInt=1", "expFrac=2"), + Iteration("fraction/int", "lhsFrac=1/3", "rhsInt=2", "expFrac=1/6"), + Iteration("wholeNumberFraction/int", "lhsFrac=3/1", "rhsInt=2", "expFrac=1 1/2"), + Iteration("anticommutativity", "lhsFrac=2/1", "rhsInt=3", "expFrac=2/3"), + Iteration("fraction/-int", "lhsFrac=-1 1/3", "rhsInt=2", "expFrac=-2/3"), + Iteration("-fraction/int", "lhsFrac=1 1/3", "rhsInt=-2", "expFrac=-2/3"), + Iteration("-fraction/-int", "lhsFrac=-1 1/3", "rhsInt=-2", "expFrac=2/3") + ) + fun testDiv_fractionAndInt_returnsFraction() { + val lhsReal = createRationalReal(lhsFrac) + val rhsReal = createIntegerReal(rhsInt) + + val result = lhsReal / rhsReal + + val expectedFraction = fractionParser.parseFraction(expFrac) + assertThat(result).isRationalThat().isEqualTo(expectedFraction) + } + + @Test + @RunParameterized( + Iteration("identity/identity", "lhsFrac=1/1", "rhsFrac=1/1", "expFrac=1"), + Iteration("fraction/identity", "lhsFrac=3/2", "rhsFrac=1/1", "expFrac=1 1/2"), + Iteration("fraction/fraction", "lhsFrac=3/2", "rhsFrac=4/7", "expFrac=2 5/8"), + Iteration("anticommutativity", "lhsFrac=4/7", "rhsFrac=3/2", "expFrac=8/21"), + Iteration("fraction/-fraction", "lhsFrac=1 3/9", "rhsFrac=-8/11", "expFrac=-1 5/6"), + Iteration("-fraction/fraction", "lhsFrac=-1 3/9", "rhsFrac=8/11", "expFrac=-1 5/6"), + Iteration("-fraction/-fraction", "lhsFrac=-1 3/9", "rhsFrac=-8/11", "expFrac=1 5/6") + ) + fun testDiv_fractionAndFraction_returnsFraction() { + val lhsReal = createRationalReal(lhsFrac) + val rhsReal = createRationalReal(rhsFrac) + + val result = lhsReal / rhsReal + + val expectedFraction = fractionParser.parseFraction(expFrac) + assertThat(result).isRationalThat().isEqualTo(expectedFraction) + } + + @Test + @RunParameterized( + Iteration("identity/identity", "lhsFrac=1/1", "rhsDouble=1.0", "expDouble=1.0"), + Iteration("fraction/identity", "lhsFrac=3/2", "rhsDouble=1.0", "expDouble=1.5"), + Iteration("fraction/double", "lhsFrac=3/2", "rhsDouble=3.14", "expDouble=0.477707006"), + Iteration("wholeNumberFraction/double", "lhsFrac=3/1", "rhsDouble=2.0", "expDouble=1.5"), + Iteration("anticommutativity", "lhsFrac=2/1", "rhsDouble=3.0", "expDouble=0.666666667"), + Iteration("fraction/-double", "lhsFrac=1 3/2", "rhsDouble=-3.14", "expDouble=-0.796178344"), + Iteration("-fraction/double", "lhsFrac=-1 3/2", "rhsDouble=3.14", "expDouble=-0.796178344"), + Iteration("-fraction/-double", "lhsFrac=-1 3/2", "rhsDouble=-3.14", "expDouble=0.796178344") + ) + fun testDiv_fractionAndDouble_returnsDouble() { + val lhsReal = createRationalReal(lhsFrac) + val rhsReal = createIrrationalReal(rhsDouble) + + val result = lhsReal / rhsReal + + assertThat(result).isIrrationalThat().isWithin(1e-5).of(expDouble) + } + + @Test + @RunParameterized( + Iteration("identity/identity", "lhsDouble=1.0", "rhsInt=1", "expDouble=1.0"), + Iteration("double/identity", "lhsDouble=2.0", "rhsInt=1", "expDouble=2.0"), + Iteration("double/int", "lhsDouble=3.14", "rhsInt=2", "expDouble=1.57"), + Iteration("wholeNumberDouble/int", "lhsDouble=3.0", "rhsInt=2", "expDouble=1.5"), + Iteration("anticommutativity", "lhsDouble=2.0", "rhsInt=3", "expDouble=0.666666667"), + Iteration("double/-int", "lhsDouble=3.14", "rhsInt=-2", "expDouble=-1.57"), + Iteration("-double/int", "lhsDouble=-3.14", "rhsInt=2", "expDouble=-1.57"), + Iteration("-double/-int", "lhsDouble=-3.14", "rhsInt=-2", "expDouble=1.57") + ) + fun testDiv_doubleAndInt_returnsDouble() { + val lhsReal = createIrrationalReal(lhsDouble) + val rhsReal = createIntegerReal(rhsInt) + + val result = lhsReal / rhsReal + + assertThat(result).isIrrationalThat().isWithin(1e-5).of(expDouble) + } + + @Test + @RunParameterized( + Iteration("identity/identity", "lhsDouble=1.0", "rhsFrac=1/1", "expDouble=1.0"), + Iteration("double/identity", "lhsDouble=2.0", "rhsFrac=1/1", "expDouble=2.0"), + Iteration("double/fraction", "lhsDouble=3.14", "rhsFrac=3/2", "expDouble=2.093333333"), + Iteration("double/wholeNumberFraction", "lhsDouble=2.0", "rhsFrac=3/1", "expDouble=0.66666667"), + Iteration("anticommutativity", "lhsDouble=3.0", "rhsFrac=2/1", "expDouble=1.5"), + Iteration("double/-fraction", "lhsDouble=3.14", "rhsFrac=-1 3/2", "expDouble=-1.256"), + Iteration("-double/fraction", "lhsDouble=-3.14", "rhsFrac=1 3/2", "expDouble=-1.256"), + Iteration("-double/-fraction", "lhsDouble=-3.14", "rhsFrac=-1 3/2", "expDouble=1.256") + ) + fun testDiv_doubleAndFraction_returnsDouble() { + val lhsReal = createIrrationalReal(lhsDouble) + val rhsReal = createRationalReal(rhsFrac) + + val result = lhsReal / rhsReal + + assertThat(result).isIrrationalThat().isWithin(1e-5).of(expDouble) + } + + @Test + @RunParameterized( + Iteration("identity/identity", "lhsDouble=1.0", "rhsDouble=1.0", "expDouble=1.0"), + Iteration("double/identity", "lhsDouble=2.0", "rhsDouble=1.0", "expDouble=2.0"), + Iteration("double/double", "lhsDouble=3.14", "rhsDouble=2.7", "expDouble=1.162962963"), + Iteration("anticommutativity", "lhsDouble=2.7", "rhsDouble=3.14", "expDouble=0.859872611"), + Iteration("double/-double", "lhsDouble=3.14", "rhsDouble=-2.7", "expDouble=-1.162962963"), + Iteration("-double/double", "lhsDouble=-3.14", "rhsDouble=2.7", "expDouble=-1.162962963"), + Iteration("-double/-double", "lhsDouble=-3.14", "rhsDouble=-2.7", "expDouble=1.162962963") + ) + fun testDiv_doubleAndDouble_returnsDouble() { + val lhsReal = createIrrationalReal(lhsDouble) + val rhsReal = createIrrationalReal(rhsDouble) + + val result = lhsReal / rhsReal + + assertThat(result).isIrrationalThat().isWithin(1e-5).of(expDouble) + } + + @Test + fun testDiv_intDividedByZeroInt_throwsException() { + val lhsReal = createIntegerReal(2) + val rhsReal = createIntegerReal(0) + + assertThrows(ArithmeticException::class) { lhsReal / rhsReal } + } + + @Test + fun testDiv_intDividedByZeroFraction_throwsException() { + val lhsReal = createIntegerReal(2) + val rhsReal = createRationalReal(ZERO_FRACTION) + + assertThrows(ArithmeticException::class) { lhsReal / rhsReal } + } + + @Test + fun testDiv_intDividedByZeroDouble_returnsInfinityDouble() { + val lhsReal = createIntegerReal(2) + val rhsReal = createIrrationalReal(0.0) + + val result = lhsReal / rhsReal + + assertThat(result).isEqualTo(createIrrationalReal(Double.POSITIVE_INFINITY)) + } + + @Test + fun testDiv_fractionDividedByZeroInt_throwsException() { + val lhsReal = ONE_AND_ONE_HALF_REAL + val rhsReal = createIntegerReal(0) + + assertThrows(ArithmeticException::class) { lhsReal / rhsReal } + } + + @Test + fun testDiv_fractionDividedByZeroFraction_throwsException() { + val lhsReal = ONE_AND_ONE_HALF_REAL + val rhsReal = createRationalReal(ZERO_FRACTION) + + assertThrows(ArithmeticException::class) { lhsReal / rhsReal } + } + + @Test + fun testDiv_fractionDividedByZeroDouble_returnsInfinityDouble() { + val lhsReal = ONE_AND_ONE_HALF_REAL + val rhsReal = createIrrationalReal(0.0) + + val result = lhsReal / rhsReal + + assertThat(result).isEqualTo(createIrrationalReal(Double.POSITIVE_INFINITY)) + } + + @Test + fun testDiv_doubleDividedByZeroInt_returnsInfinityDouble() { + val lhsReal = createIrrationalReal(3.14) + val rhsReal = createIntegerReal(0) + + val result = lhsReal / rhsReal + + assertThat(result).isEqualTo(createIrrationalReal(Double.POSITIVE_INFINITY)) + } + + @Test + fun testDiv_doubleDividedByZeroFraction_returnsInfinityDouble() { + val lhsReal = createIrrationalReal(3.14) + val rhsReal = createRationalReal(ZERO_FRACTION) + + val result = lhsReal / rhsReal + + assertThat(result).isEqualTo(createIrrationalReal(Double.POSITIVE_INFINITY)) + } + + @Test + fun testDiv_doubleDividedByZeroDouble_returnsInfinityDouble() { + val lhsReal = createIrrationalReal(3.14) + val rhsReal = createIrrationalReal(0.0) + + val result = lhsReal / rhsReal + + assertThat(result).isEqualTo(createIrrationalReal(Double.POSITIVE_INFINITY)) + } + + // Exponentiation tests. + + @Test + @RunParameterized( + Iteration("0^0", "lhsInt=0", "rhsInt=0", "expInt=1"), + Iteration("identity^0", "lhsInt=1", "rhsInt=0", "expInt=1"), + Iteration("identity^identity", "lhsInt=1", "rhsInt=1", "expInt=1"), + Iteration("int^0", "lhsInt=2", "rhsInt=0", "expInt=1"), + Iteration("int^identity", "lhsInt=2", "rhsInt=1", "expInt=2"), + Iteration("int^int", "lhsInt=2", "rhsInt=3", "expInt=8"), + Iteration("noncommutativity", "lhsInt=3", "rhsInt=2", "expInt=9"), + Iteration("-int^even int", "lhsInt=-2", "rhsInt=4", "expInt=16"), + Iteration("-int^odd int", "lhsInt=-2", "rhsInt=3", "expInt=-8") + ) + fun testPow_intAndInt_positivePower_returnsInt() { + val lhsReal = createIntegerReal(lhsInt) + val rhsReal = createIntegerReal(rhsInt) + + val result = lhsReal pow rhsReal + + // Integer raised to positive (or zero) integers always results in another integer. + assertThat(result).isIntegerThat().isEqualTo(expInt) + } + + @Test + @RunParameterized( + Iteration("int^-int", "lhsInt=2", "rhsInt=-3", "expFrac=1/8"), + Iteration("-int^-even int", "lhsInt=-2", "rhsInt=-4", "expFrac=1/16"), + Iteration("-int^-odd int", "lhsInt=-2", "rhsInt=-3", "expFrac=-1/8") + ) + fun testPow_intAndInt_negativePower_returnsFraction() { + val lhsReal = createIntegerReal(lhsInt) + val rhsReal = createIntegerReal(rhsInt) + + val result = lhsReal pow rhsReal + + // Integers raised to a negative integer yields a fraction since x^-y=1/(x^y). + val expectedFraction = fractionParser.parseFraction(expFrac) + assertThat(result).isRationalThat().isEqualTo(expectedFraction) + } + + @Test + @RunParameterized( + Iteration("0^0", "lhsInt=0", "rhsFrac=0/1", "expFrac=1"), + Iteration("identity^0", "lhsInt=1", "rhsFrac=0/1", "expFrac=1"), + Iteration("identity^identity", "lhsInt=1", "rhsFrac=1", "expFrac=1"), + Iteration("int^0", "lhsInt=2", "rhsFrac=0/1", "expFrac=1"), + Iteration("int^identity", "lhsInt=2", "rhsFrac=1", "expFrac=2"), + Iteration("int^fraction", "lhsInt=16", "rhsFrac=3/2", "expFrac=64"), + Iteration("int^wholeNumberFraction", "lhsInt=2", "rhsFrac=3/1", "expFrac=8"), + Iteration("noncommutativity", "lhsInt=3", "rhsFrac=2/1", "expFrac=9"), + Iteration("int^odd fraction", "lhsInt=8", "rhsFrac=5/3", "expFrac=32"), + Iteration("int^-fraction", "lhsInt=8", "rhsFrac=-4/2", "expFrac=1/64"), + Iteration("-int^odd fraction", "lhsInt=-8", "rhsFrac=5/3", "expFrac=-32"), + Iteration("-int^-fraction", "lhsInt=-4", "rhsFrac=-4/2", "expFrac=1/16"), + Iteration("-int^-odd fraction", "lhsInt=-8", "rhsFrac=-5/3", "expFrac=-1/32") + ) + fun testPow_intAndFraction_denominatorCanRootInt_returnsFraction() { + val lhsReal = createIntegerReal(lhsInt) + val rhsReal = createRationalReal(rhsFrac) + + val result = lhsReal pow rhsReal + + val expectedFraction = fractionParser.parseFraction(expFrac) + assertThat(result).isRationalThat().isEqualTo(expectedFraction) + } + + @Test + @RunParameterized( + Iteration("int^fraction", "lhsInt=3", "rhsFrac=2/3", "expDouble=2.080083823"), + Iteration("-int^fraction", "lhsInt=-4", "rhsFrac=2/3", "expDouble=2.5198421"), + Iteration("int^-fraction", "lhsInt=2", "rhsFrac=-2/3", "expDouble=0.629960525"), + Iteration("-int^-fraction", "lhsInt=-4", "rhsFrac=-2/3", "expDouble=0.396850263") + ) + fun testPow_intAndFraction_denominatorCannotRootInt_returnsDouble() { + val lhsReal = createIntegerReal(lhsInt) + val rhsReal = createRationalReal(rhsFrac) + + val result = lhsReal pow rhsReal + + assertThat(result).isIrrationalThat().isWithin(1e-5).of(expDouble) + } + + @Test + @RunParameterized( + Iteration("0^0", "lhsInt=0", "rhsDouble=0.0", "expDouble=1.0"), + Iteration("identity^0", "lhsInt=1", "rhsDouble=0.0", "expDouble=1.0"), + Iteration("identity^identity", "lhsInt=1", "rhsDouble=1.0", "expDouble=1.0"), + Iteration("int^0", "lhsInt=2", "rhsDouble=0.0", "expDouble=1.0"), + Iteration("int^identity", "lhsInt=2", "rhsDouble=1.0", "expDouble=2.0"), + Iteration("int^double", "lhsInt=2", "rhsDouble=3.14", "expDouble=8.815240927"), + Iteration("int^wholeNumberDouble", "lhsInt=2", "rhsDouble=3.0", "expDouble=8.0"), + Iteration("noncommutativity", "lhsInt=3", "rhsDouble=2.0", "expDouble=9.0"), + Iteration("int^-double", "lhsInt=2", "rhsDouble=-3.14", "expDouble=0.113439894") + ) + fun testPow_intAndDouble_returnsDouble() { + val lhsReal = createIntegerReal(lhsInt) + val rhsReal = createIrrationalReal(rhsDouble) + + val result = lhsReal pow rhsReal + + assertThat(result).isIrrationalThat().isWithin(1e-5).of(expDouble) + } + + @Test + @RunParameterized( + Iteration("0^0", "lhsFrac=0", "rhsInt=0", "expFrac=1"), + Iteration("identity^0", "lhsFrac=1", "rhsInt=0", "expFrac=1"), + Iteration("identity^identity", "lhsFrac=1", "rhsInt=1", "expFrac=1"), + Iteration("fraction^0", "lhsFrac=1/3", "rhsInt=0", "expFrac=1"), + Iteration("fraction^identity", "lhsFrac=1/3", "rhsInt=1", "expFrac=1/3"), + Iteration("fraction^int", "lhsFrac=2/3", "rhsInt=3", "expFrac=8/27"), + Iteration("wholeNumberFraction^int", "lhsFrac=3", "rhsInt=2", "expFrac=9"), + Iteration("noncommutativity", "lhsFrac=2", "rhsInt=3", "expFrac=8"), + Iteration("fraction^-int", "lhsFrac=4/3", "rhsInt=-2", "expFrac=9/16"), + Iteration("-fraction^int", "lhsFrac=-4/3", "rhsInt=2", "expFrac=1 7/9"), + Iteration("-fraction^-int", "lhsFrac=-4/3", "rhsInt=-2", "expFrac=9/16") + ) + fun testPow_fractionAndInt_returnsFraction() { + val lhsReal = createRationalReal(lhsFrac) + val rhsReal = createIntegerReal(rhsInt) + + val result = lhsReal pow rhsReal + + val expectedFraction = fractionParser.parseFraction(expFrac) + assertThat(result).isRationalThat().isEqualTo(expectedFraction) + } + + @Test + @RunParameterized( + Iteration("0^0", "lhsFrac=0", "rhsFrac=0", "expFrac=1"), + Iteration("identity^0", "lhsFrac=1", "rhsFrac=0", "expFrac=1"), + Iteration("identity^identity", "lhsFrac=1", "rhsFrac=1", "expFrac=1"), + Iteration("fraction^0", "lhsFrac=3/2", "rhsFrac=0", "expFrac=1"), + Iteration("fraction^identity", "lhsFrac=3/2", "rhsFrac=1", "expFrac=1 1/2"), + Iteration("fraction^fraction", "lhsFrac=32/243", "rhsFrac=3/5", "expFrac=8/27"), + Iteration("fraction^wholeNumberFraction", "lhsFrac=3", "rhsFrac=2", "expFrac=9"), + Iteration("noncommutativity", "lhsFrac=2", "rhsFrac=3", "expFrac=8"), + Iteration("fraction^-fraction", "lhsFrac=32/243", "rhsFrac=-3/5", "expFrac=3 3/8") + ) + fun testPow_fractionAndFraction_denominatorCanRootFraction_returnsFraction() { + val lhsReal = createRationalReal(lhsFrac) + val rhsReal = createRationalReal(rhsFrac) + + val result = lhsReal pow rhsReal + + val expectedFraction = fractionParser.parseFraction(expFrac) + assertThat(result).isRationalThat().isEqualTo(expectedFraction) + } + + @Test + @RunParameterized( + Iteration("fraction^fraction", "lhsFrac=3/2", "rhsFrac=2/3", "expDouble=1.310370697"), + Iteration("noncommutativity", "lhsFrac=2/3", "rhsFrac=3/2", "expDouble=0.544331054"), + Iteration("fraction^-fraction", "lhsFrac=3/2", "rhsFrac=-2/3", "expDouble=0.763142828") + ) + fun testPow_fractionAndFraction_denominatorCannotRootFraction_returnsDouble() { + val lhsReal = createRationalReal(lhsFrac) + val rhsReal = createRationalReal(rhsFrac) + + val result = lhsReal pow rhsReal + + assertThat(result).isIrrationalThat().isWithin(1e-5).of(expDouble) + } + + @Test + @RunParameterized( + Iteration("0^0", "lhsFrac=0", "rhsDouble=0.0", "expDouble=1.0"), + Iteration("identity^0", "lhsFrac=1", "rhsDouble=0.0", "expDouble=1.0"), + Iteration("identity^identity", "lhsFrac=1", "rhsDouble=1.0", "expDouble=1.0"), + Iteration("fraction^0", "lhsFrac=3/2", "rhsDouble=0.0", "expDouble=1.0"), + Iteration("fraction^identity", "lhsFrac=3/2", "rhsDouble=1.0", "expDouble=1.5"), + Iteration("fraction^double", "lhsFrac=3/2", "rhsDouble=3.14", "expDouble=3.572124224"), + Iteration("wholeNumberFraction^double", "lhsFrac=3", "rhsDouble=2.0", "expDouble=9.0"), + Iteration("noncommutativity", "lhsFrac=2", "rhsDouble=3.0", "expDouble=8.0"), + Iteration("fraction^-double", "lhsFrac=1 3/2", "rhsDouble=-3.14", "expDouble=0.056294812") + ) + fun testPow_fractionAndDouble_returnsDouble() { + val lhsReal = createRationalReal(lhsFrac) + val rhsReal = createIrrationalReal(rhsDouble) + + val result = lhsReal pow rhsReal + + assertThat(result).isIrrationalThat().isWithin(1e-5).of(expDouble) + } + + @Test + @RunParameterized( + Iteration("0^0", "lhsDouble=0.0", "rhsInt=0", "expDouble=1.0"), + Iteration("identity^0", "lhsDouble=1.0", "rhsInt=0", "expDouble=1.0"), + Iteration("identity^identity", "lhsDouble=1.0", "rhsInt=1", "expDouble=1.0"), + Iteration("double^0", "lhsDouble=3.14", "rhsInt=0", "expDouble=1.0"), + Iteration("double^identity", "lhsDouble=3.14", "rhsInt=1", "expDouble=3.14"), + Iteration("double^int", "lhsDouble=3.14", "rhsInt=2", "expDouble=9.8596"), + Iteration("wholeNumberDouble^int", "lhsDouble=3.0", "rhsInt=2", "expDouble=9.0"), + Iteration("noncommutativity", "lhsDouble=2.0", "rhsInt=3", "expDouble=8.0"), + Iteration("double^-int", "lhsDouble=3.14", "rhsInt=-3", "expDouble=0.032300635"), + Iteration("-double^int", "lhsDouble=-3.14", "rhsInt=3", "expDouble=-30.959144"), + Iteration("-double^-int", "lhsDouble=-3.14", "rhsInt=-3", "expDouble=-0.032300635") + ) + fun testPow_doubleAndInt_returnsDouble() { + val lhsReal = createIrrationalReal(lhsDouble) + val rhsReal = createIntegerReal(rhsInt) + + val result = lhsReal pow rhsReal + + assertThat(result).isIrrationalThat().isWithin(1e-5).of(expDouble) + } + + @Test + @RunParameterized( + Iteration("0^0", "lhsDouble=0.0", "rhsFrac=0/1", "expDouble=1.0"), + Iteration("identity^0", "lhsDouble=1.0", "rhsFrac=0/1", "expDouble=1.0"), + Iteration("identity^identity", "lhsDouble=1.0", "rhsFrac=1", "expDouble=1.0"), + Iteration("double^0", "lhsDouble=3.14", "rhsFrac=0/1", "expDouble=1.0"), + Iteration("double^identity", "lhsDouble=3.14", "rhsFrac=1", "expDouble=3.14"), + Iteration("double^fraction", "lhsDouble=3.14", "rhsFrac=3/2", "expDouble=5.564094176"), + Iteration("double^wholeNumberFraction", "lhsDouble=2.0", "rhsFrac=3/1", "expDouble=8.0"), + Iteration("noncommutativity", "lhsDouble=3.0", "rhsFrac=2/1", "expDouble=9.0"), + Iteration("double^-fraction", "lhsDouble=3.14", "rhsFrac=-3/2", "expDouble=0.179723773") + ) + fun testPow_doubleAndFraction_returnsDouble() { + val lhsReal = createIrrationalReal(lhsDouble) + val rhsReal = createRationalReal(rhsFrac) + + val result = lhsReal pow rhsReal + + assertThat(result).isIrrationalThat().isWithin(1e-5).of(expDouble) + } + + @Test + @RunParameterized( + Iteration("0^0", "lhsDouble=0.0", "rhsDouble=0.0", "expDouble=1.0"), + Iteration("identity^0", "lhsDouble=1.0", "rhsDouble=0.0", "expDouble=1.0"), + Iteration("identity^identity", "lhsDouble=1.0", "rhsDouble=1.0", "expDouble=1.0"), + Iteration("double^0", "lhsDouble=3.14", "rhsDouble=0.0", "expDouble=1.0"), + Iteration("double^identity", "lhsDouble=3.14", "rhsDouble=1.0", "expDouble=3.14"), + Iteration("double^double", "lhsDouble=3.14", "rhsDouble=2.7", "expDouble=21.963929943"), + Iteration("noncommutativity", "lhsDouble=2.7", "rhsDouble=3.14", "expDouble=22.619459311"), + Iteration("double^-double", "lhsDouble=3.14", "rhsDouble=-2.7", "expDouble=0.045529193") + ) + fun testPow_doubleAndDouble_returnsDouble() { + val lhsReal = createIrrationalReal(lhsDouble) + val rhsReal = createIrrationalReal(rhsDouble) + + val result = lhsReal pow rhsReal + + assertThat(result).isIrrationalThat().isWithin(1e-5).of(expDouble) + } + + @Test + fun testPow_negativeIntToOneHalfFraction_throwsException() { + val lhsReal = createIntegerReal(-3) + val rhsReal = createRationalReal(ONE_HALF_FRACTION) + + val exception = assertThrows(IllegalStateException::class) { lhsReal pow rhsReal } + + assertThat(exception).hasMessageThat().contains("Radicand results in imaginary number") + } + + @Test + fun testPow_negativeIntToNonzeroDouble_returnsNotANumber() { + val lhsReal = createIntegerReal(-3) + val rhsReal = createIrrationalReal(3.14) + + val result = lhsReal pow rhsReal + + assertThat(result).isIrrationalThat().isNaN() + } + + @Test + fun testPow_negativeFractionToOneHalfFraction_throwsException() { + val lhsReal = NEGATIVE_ONE_AND_ONE_HALF_REAL + val rhsReal = createRationalReal(ONE_HALF_FRACTION) + + val exception = assertThrows(IllegalStateException::class) { lhsReal pow rhsReal } + + assertThat(exception).hasMessageThat().contains("Radicand results in imaginary number") + } + + @Test + fun testPow_negativeFractionToNegativeFractionWithOddNumerator_throwsException() { + val lhsReal = createRationalReal((-4).toWholeNumberFraction()) + val rhsReal = createRationalReal(-ONE_AND_ONE_HALF_FRACTION) + + val exception = assertThrows(IllegalStateException::class) { lhsReal pow rhsReal } + + assertThat(exception).hasMessageThat().contains("Radicand results in imaginary number") + } + + @Test + fun testPow_negativeFractionToNonzeroDouble_returnsNotANumber() { + val lhsReal = NEGATIVE_ONE_AND_ONE_HALF_REAL + val rhsReal = createIrrationalReal(3.14) + + val result = lhsReal pow rhsReal + + assertThat(result).isIrrationalThat().isNaN() + } + + @Test + fun testPow_negativeDoubleToOneHalfFraction_returnsNotANumber() { + val lhsReal = createIrrationalReal(-2.7) + val rhsReal = createRationalReal(ONE_HALF_FRACTION) + + val result = lhsReal pow rhsReal + + assertThat(result).isIrrationalThat().isNaN() + } + + @Test + fun testPow_negativeDoubleToNonzeroDouble_returnsNotANumber() { + val lhsReal = createIrrationalReal(-2.7) + val rhsReal = createIrrationalReal(3.14) + + val result = lhsReal pow rhsReal + + assertThat(result).isIrrationalThat().isNaN() + } + + /* End operator tests. */ + + @Test + fun testSqrt_defaultReal_throwsException() { + val real = Real.getDefaultInstance() + + val exception = assertThrows(IllegalStateException::class) { sqrt(real) } + + assertThat(exception).hasMessageThat().contains("Invalid real") + } + + @Test + fun testSqrt_negativeInteger_throwsException() { + val real = createIntegerReal(-2) + + val exception = assertThrows(IllegalStateException::class) { sqrt(real) } + + assertThat(exception).hasMessageThat().contains("Radicand results in imaginary number") + } + + @Test + fun testSqrt_zeroInteger_returnsZeroInteger() { + val real = createIntegerReal(0) + + val result = sqrt(real) + + assertThat(result).isIntegerThat().isEqualTo(0) + } + + @Test + fun testSqrt_fourInteger_returnsTwoInteger() { + val real = createIntegerReal(4) + + val result = sqrt(real) + + assertThat(result).isIntegerThat().isEqualTo(2) + } + + @Test + fun testSqrt_fourTwo_returnsSqrtTwoDouble() { + val real = createIntegerReal(2) + + val result = sqrt(real) + + assertThat(result).isIrrationalThat().isWithin(1e-5).of(1.414213562) + } + + @Test + fun testSqrt_negativeFraction_throwsException() { + val real = createRationalReal((-2).toWholeNumberFraction()) + + val exception = assertThrows(IllegalStateException::class) { sqrt(real) } + + assertThat(exception).hasMessageThat().contains("Radicand results in imaginary number") + } + + @Test + fun testSqrt_zeroFraction_returnZeroFraction() { + val real = createRationalReal(ZERO_FRACTION) + + val result = sqrt(real) + + assertThat(result).isRationalThat().isEqualTo(ZERO_FRACTION) + } + + @Test + fun testSqrt_fourFraction_returnsTwoFraction() { + val real = createRationalReal(4.toWholeNumberFraction()) + + val result = sqrt(real) + + assertThat(result).isRationalThat().isEqualTo(2.toWholeNumberFraction()) + } + + @Test + fun testSqrt_oneFourthFraction_returnsOneHalfFraction() { + val real = createRationalReal(ONE_FOURTH_FRACTION) + + val result = sqrt(real) + + assertThat(result).isRationalThat().isEqualTo(ONE_HALF_FRACTION) + } + + @Test + fun testSqrt_sixteenthNinthsFraction_returnsOneAndOneThirdFraction() { + val real = createRationalReal(createFraction(numerator = 16, denominator = 9)) + + val result = sqrt(real) + + // Verify that both the numerator and denominator are properly rooted, and that a proper + // fraction is returned. + assertThat(result).isRationalThat().apply { + hasNegativePropertyThat().isFalse() + hasWholeNumberThat().isEqualTo(1) + hasNumeratorThat().isEqualTo(1) + hasDenominatorThat().isEqualTo(3) + } + } + + @Test + fun testSqrt_twoThirdsFraction_returnsComputedDouble() { + val real = createRationalReal(createFraction(numerator = 2, denominator = 3)) + + val result = sqrt(real) + + // sqrt(2/3) can't be computed perfectly, so a double must be computed, instead. + assertThat(result).isIrrationalThat().isWithin(1e-5).of(0.816496581) + } + + @Test + fun testSqrt_negativeDouble_returnsNotANumber() { + val real = createIrrationalReal(-2.7) + + val result = sqrt(real) + + assertThat(result).isIrrationalThat().isNaN() + } + + @Test + fun testSqrt_zeroDouble_returnsZeroDouble() { + val real = createIrrationalReal(0.0) + + val result = sqrt(real) + + assertThat(result).isIrrationalThat().isWithin(1e-5).of(0.0) + } + + @Test + fun testSqrt_fourDouble_returnsTwoDouble() { + val real = createIrrationalReal(4.0) + + val result = sqrt(real) + + assertThat(result).isIrrationalThat().isWithin(1e-5).of(2.0) + } + + @Test + fun testSqrt_twoDouble_returnsRootTwoDouble() { + val real = createIrrationalReal(2.0) + + val result = sqrt(real) + + assertThat(result).isIrrationalThat().isWithin(1e-5).of(1.414213562) + } + + @Test + fun testSqrt_nonWholeDouble_returnsCorrectSquareRootDouble() { + val real = createIrrationalReal(3.14) + + val result = sqrt(real) + + assertThat(result).isIrrationalThat().isWithin(1e-5).of(1.772004515) + } + + private fun createRationalReal(rawFractionExpression: String) = + createRationalReal(fractionParser.parseFractionFromString(rawFractionExpression)) } private fun createIntegerReal(value: Int) = Real.newBuilder().apply { @@ -412,3 +1792,8 @@ private fun createRationalReal(value: Fraction) = Real.newBuilder().apply { private fun createIrrationalReal(value: Double) = Real.newBuilder().apply { irrational = value }.build() + +private fun createFraction(numerator: Int, denominator: Int) = Fraction.newBuilder().apply { + this.numerator = numerator + this.denominator = denominator +}.build()