diff --git a/domain/src/main/java/org/oppia/android/domain/util/InteractionObjectExtensions.kt b/domain/src/main/java/org/oppia/android/domain/util/InteractionObjectExtensions.kt index 2e37e34e0f7..32f9123e852 100644 --- a/domain/src/main/java/org/oppia/android/domain/util/InteractionObjectExtensions.kt +++ b/domain/src/main/java/org/oppia/android/domain/util/InteractionObjectExtensions.kt @@ -1,7 +1,6 @@ package org.oppia.android.domain.util import org.oppia.android.app.model.ClickOnImage -import org.oppia.android.app.model.Fraction import org.oppia.android.app.model.ImageWithRegions import org.oppia.android.app.model.InteractionObject import org.oppia.android.app.model.InteractionObject.ObjectTypeCase.BOOL_VALUE @@ -107,15 +106,6 @@ private fun ImageWithRegions.toAnswerString(): String = private fun ClickOnImage.toAnswerString(): String = "[(${clickedRegionsList.joinToString()}), (${clickPosition.x}, ${clickPosition.y})]" -// https://github.com/oppia/oppia/blob/37285a/core/templates/dev/head/domain/objects/FractionObjectFactory.ts#L47 -private fun Fraction.toAnswerString(): String { - val fractionString = if (numerator != 0) "$numerator/$denominator" else "" - val mixedString = if (wholeNumber != 0) "$wholeNumber $fractionString" else "" - val positiveFractionString = if (mixedString.isNotEmpty()) mixedString else fractionString - val negativeString = if (isNegative) "-" else "" - return if (positiveFractionString.isNotEmpty()) "$negativeString$positiveFractionString" else "0" -} - private fun TranslatableHtmlContentId.toAnswerString(): String { return "content_id=$contentId" } diff --git a/domain/src/test/java/org/oppia/android/domain/classify/rules/numericinput/NumericInputEqualsRuleClassifierProviderTest.kt b/domain/src/test/java/org/oppia/android/domain/classify/rules/numericinput/NumericInputEqualsRuleClassifierProviderTest.kt index f7485b13545..ae70c8badc0 100644 --- a/domain/src/test/java/org/oppia/android/domain/classify/rules/numericinput/NumericInputEqualsRuleClassifierProviderTest.kt +++ b/domain/src/test/java/org/oppia/android/domain/classify/rules/numericinput/NumericInputEqualsRuleClassifierProviderTest.kt @@ -12,7 +12,7 @@ import org.junit.runner.RunWith import org.oppia.android.app.model.WrittenTranslationContext import org.oppia.android.domain.classify.InteractionObjectTestBuilder import org.oppia.android.testing.assertThrows -import org.oppia.android.util.math.FLOAT_EQUALITY_INTERVAL +import org.oppia.android.util.math.DOUBLE_EQUALITY_EPSILON import org.robolectric.annotation.Config import org.robolectric.annotation.LooperMode import javax.inject.Inject @@ -33,13 +33,11 @@ class NumericInputEqualsRuleClassifierProviderTest { private val NEGATIVE_REAL_VALUE_3_5 = InteractionObjectTestBuilder.createReal(value = -3.5) private val FIVE_TIMES_FLOAT_EQUALITY_INTERVAL = - InteractionObjectTestBuilder.createReal(value = 5 * FLOAT_EQUALITY_INTERVAL) - private val SIX_TIMES_FLOAT_EQUALITY_INTERVAL = - InteractionObjectTestBuilder.createReal(value = 6 * FLOAT_EQUALITY_INTERVAL) + InteractionObjectTestBuilder.createReal(value = 5 * DOUBLE_EQUALITY_EPSILON) private val FIVE_POINT_ONE_TIMES_FLOAT_EQUALITY_INTERVAL = InteractionObjectTestBuilder.createReal( - value = 5 * FLOAT_EQUALITY_INTERVAL + - FLOAT_EQUALITY_INTERVAL / 10 + value = 5 * DOUBLE_EQUALITY_EPSILON + + DOUBLE_EQUALITY_EPSILON / 10 ) private val STRING_VALUE = InteractionObjectTestBuilder.createString(value = "test") @@ -142,21 +140,6 @@ class NumericInputEqualsRuleClassifierProviderTest { assertThat(matches).isFalse() } - @Test - fun testPositiveRealAnswer_positiveRealInput_valueAtRange_valuesDoNotMatch() { - val inputs = mapOf( - "x" to FIVE_TIMES_FLOAT_EQUALITY_INTERVAL - ) - - val matches = inputEqualsRuleClassifier.matches( - answer = SIX_TIMES_FLOAT_EQUALITY_INTERVAL, - inputs = inputs, - writtenTranslationContext = WrittenTranslationContext.getDefaultInstance() - ) - - assertThat(matches).isFalse() - } - @Test fun testRealAnswer_missingInput_throwsException() { val inputs = mapOf("y" to POSITIVE_REAL_VALUE_1_5) diff --git a/model/src/main/proto/math.proto b/model/src/main/proto/math.proto index 4e3989ec3e3..ac6d25ef6ce 100644 --- a/model/src/main/proto/math.proto +++ b/model/src/main/proto/math.proto @@ -283,3 +283,29 @@ message ComparableOperation { } } } + +// Represents a polynomial, e.g.: 2x^3+3x-y+7. +message Polynomial { + // The list of terms in this polynomial, e.g. the '2x^3', '3x', '-y', and '-7' in 2x^3+3x-y+7. + repeated Term term = 1; + + // Represents a polynomial term, i.e. a real coefficient multiplied by one or more variables, each + // of which may have a power >= 1. + message Term { + // The coefficient of this term (which may be zero or negative), e.g. '2' in '2x^3'. + Real coefficient = 1; + + // The variables of this term. This list can be zero or more variables long (where zero + // variables indicate a constant polynomial term). + repeated Variable variable = 2; + + // A variable within the term, that is, a variable with a positive power. + message Variable { + // The name of the variable, e.g. 'x' in 'x^3'. + string name = 1; + + // The power of the variable, e.g. '3' in 'x^3'. + uint32 power = 2; + } + } +} diff --git a/scripts/assets/test_file_exemptions.textproto b/scripts/assets/test_file_exemptions.textproto index e0f5f71c948..28cfe9cca16 100644 --- a/scripts/assets/test_file_exemptions.textproto +++ b/scripts/assets/test_file_exemptions.textproto @@ -650,8 +650,9 @@ exempted_file_path: "testing/src/main/java/org/oppia/android/testing/espresso/Ko exempted_file_path: "testing/src/main/java/org/oppia/android/testing/junit/DefineAppLanguageLocaleContext.kt" exempted_file_path: "testing/src/main/java/org/oppia/android/testing/math/ComparableOperationSubject.kt" exempted_file_path: "testing/src/main/java/org/oppia/android/testing/math/FractionSubject.kt" -exempted_file_path: "testing/src/main/java/org/oppia/android/testing/math/MathEquationSubject.kt" exempted_file_path: "testing/src/main/java/org/oppia/android/testing/math/MathExpressionSubject.kt" +exempted_file_path: "testing/src/main/java/org/oppia/android/testing/math/MathEquationSubject.kt" +exempted_file_path: "testing/src/main/java/org/oppia/android/testing/math/PolynomialSubject.kt" exempted_file_path: "testing/src/main/java/org/oppia/android/testing/math/RealSubject.kt" exempted_file_path: "testing/src/main/java/org/oppia/android/testing/mockito/MockitoKotlinHelper.kt" exempted_file_path: "testing/src/main/java/org/oppia/android/testing/network/ApiMockLoader.kt" diff --git a/testing/src/main/java/org/oppia/android/testing/math/BUILD.bazel b/testing/src/main/java/org/oppia/android/testing/math/BUILD.bazel index 0e6b8bf7989..b8994e46c31 100644 --- a/testing/src/main/java/org/oppia/android/testing/math/BUILD.bazel +++ b/testing/src/main/java/org/oppia/android/testing/math/BUILD.bazel @@ -74,6 +74,24 @@ kt_android_library( ], ) +kt_android_library( + name = "polynomial_subject", + testonly = True, + srcs = [ + "PolynomialSubject.kt", + ], + visibility = [ + "//:oppia_testing_visibility", + ], + deps = [ + ":real_subject", + "//model/src/main/proto:math_java_proto_lite", + "//third_party:com_google_truth_extensions_truth-liteproto-extension", + "//third_party:com_google_truth_truth", + "//utility/src/main/java/org/oppia/android/util/math:extensions", + ], +) + kt_android_library( name = "real_subject", testonly = True, diff --git a/testing/src/main/java/org/oppia/android/testing/math/PolynomialSubject.kt b/testing/src/main/java/org/oppia/android/testing/math/PolynomialSubject.kt new file mode 100644 index 00000000000..23dc2b479a7 --- /dev/null +++ b/testing/src/main/java/org/oppia/android/testing/math/PolynomialSubject.kt @@ -0,0 +1,156 @@ +package org.oppia.android.testing.math + +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 +import com.google.common.truth.Truth.assertWithMessage +import com.google.common.truth.extensions.proto.LiteProtoSubject +import org.oppia.android.app.model.Polynomial +import org.oppia.android.testing.math.PolynomialSubject.Companion.assertThat +import org.oppia.android.testing.math.RealSubject.Companion.assertThat +import org.oppia.android.util.math.getConstant +import org.oppia.android.util.math.isConstant +import org.oppia.android.util.math.toPlainText + +// TODO(#4100): Add tests for this class. + +/** + * Truth subject for verifying properties of [Polynomial]s. + * + * Note that this class is also a [LiteProtoSubject] so other aspects of the underlying [Polynomial] + * proto can be verified through inherited methods. + * + * Call [assertThat] to create the subject. + */ +class PolynomialSubject( + metadata: FailureMetadata, + private val actual: Polynomial? +) : LiteProtoSubject(metadata, actual) { + private val nonNullActual by lazy { + checkNotNull(actual) { + "Expected polynomial to be defined, not null (is the expression/equation not a valid" + + " polynomial?)" + } + } + + /** Verifies that the represented [Polynomial] is null (i.e. not a valid polynomial). */ + fun isNotValidPolynomial() { + assertWithMessage( + "Expected polynomial to be undefined, but was: ${actual?.toPlainText()}" + ).that(actual).isNull() + } + + /** + * Verifies that the represented [Polynomial] is a constant (i.e. [Polynomial.isConstant] and + * returns a [RealSubject] to verify the value of the constant polynomial. + */ + fun isConstantThat(): RealSubject { + assertWithMessage("Expected polynomial to be constant, but was: ${nonNullActual.toPlainText()}") + .that(nonNullActual.isConstant()) + .isTrue() + return assertThat(nonNullActual.getConstant()) + } + + /** + * Returns an [IntegerSubject] to test [Polynomial.getTermCount]. + * + * This method never fails since the underlying property defaults to 0 if there are no terms + * defined in the polynomial (unless the polynomial is null). + */ + fun hasTermCountThat(): IntegerSubject = assertThat(nonNullActual.termCount) + + /** + * Returns a [PolynomialTermSubject] to test [Polynomial.getTerm] for the specified index. + * + * This method throws if the index doesn't correspond to a valid term. Callers should first verify + * the term count using [hasTermCountThat]. + */ + fun term(index: Int): PolynomialTermSubject = assertThat(nonNullActual.termList[index]) + + /** + * Returns a [StringSubject] to test the plain-text representation of the [Polynomial] (i.e. via + * [Polynomial.toPlainText]). + */ + fun evaluatesToPlainTextThat(): StringSubject = assertThat(nonNullActual.toPlainText()) + + companion object { + /** Returns a new [PolynomialSubject] to verify aspects of the specified [Polynomial] value. */ + fun assertThat(actual: Polynomial?): PolynomialSubject = + assertAbout(::PolynomialSubject).that(actual) + + private fun assertThat(actual: Polynomial.Term): PolynomialTermSubject = + assertAbout(::PolynomialTermSubject).that(actual) + + private fun assertThat(actual: Polynomial.Term.Variable): PolynomialTermVariableSubject = + assertAbout(::PolynomialTermVariableSubject).that(actual) + } + + /** + * Truth subject for verifying properties of [Polynomial.Term]s. + * + * Note that this class is also a [LiteProtoSubject] so other aspects of the underlying + * [Polynomial.Term] proto can be verified through inherited methods. + */ + class PolynomialTermSubject( + metadata: FailureMetadata, + private val actual: Polynomial.Term + ) : LiteProtoSubject(metadata, actual) { + /** + * Returns a [RealSubject] to test [Polynomial.Term.getCoefficient] for the represented term. + * + * This method never fails since the underlying property defaults to a default instance if it's + * not defined in the term. + */ + fun hasCoefficientThat(): RealSubject = assertThat(actual.coefficient) + + /** + * Returns an [IntegerSubject] to test [Polynomial.Term.getVariableCount] for the represented + * term. + * + * This method never fails since the underlying property defaults to 0 if there are no variables + * in the represented term. + */ + fun hasVariableCountThat(): IntegerSubject = assertThat(actual.variableCount) + + /** + * Returns a [PolynomialTermVariableSubject] to test [Polynomial.Term.getVariable] for the + * specified index. + * + * This method throws if the index doesn't correspond to a valid variable. Callers should first + * verify the variable count using [hasVariableCountThat]. + */ + fun variable(index: Int): PolynomialTermVariableSubject = + assertThat(actual.variableList[index]) + } + + /** + * Truth subject for verifying properties of [Polynomial.Term.Variable]s. + * + * Note that this class is also a [LiteProtoSubject] so other aspects of the underlying + * [Polynomial.Term.Variable] proto can be verified through inherited methods. + */ + class PolynomialTermVariableSubject( + metadata: FailureMetadata, + private val actual: Polynomial.Term.Variable + ) : LiteProtoSubject(metadata, actual) { + /** + * Returns a [StringSubject] to test [Polynomial.Term.Variable.getName] for the represented + * variable. + * + * This method never fails since the underlying property defaults to empty string if it's not + * defined in the variable. + */ + fun hasNameThat(): StringSubject = assertThat(actual.name) + + /** + * Returns an [IntegerSubject] to test [Polynomial.Term.Variable.getPower] for the represented + * variable. + * + * This method never fails since the underlying property defaults to 0 if it's not defined in + * the variable. + */ + fun hasPowerThat(): IntegerSubject = assertThat(actual.power) + } +} 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 cae9779bc08..d728bc434ee 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 @@ -9,7 +9,9 @@ kt_android_library( srcs = [ "FloatExtensions.kt", "FractionExtensions.kt", + "PolynomialExtensions.kt", "RatioExtensions.kt", + "RealExtensions.kt", ], visibility = [ "//:oppia_api_visibility", diff --git a/utility/src/main/java/org/oppia/android/util/math/FloatExtensions.kt b/utility/src/main/java/org/oppia/android/util/math/FloatExtensions.kt index 62504046c78..9062dfe6484 100644 --- a/utility/src/main/java/org/oppia/android/util/math/FloatExtensions.kt +++ b/utility/src/main/java/org/oppia/android/util/math/FloatExtensions.kt @@ -2,15 +2,40 @@ package org.oppia.android.util.math import kotlin.math.abs -/** The error margin used for float equality by [Float.approximatelyEquals]. */ -const val FLOAT_EQUALITY_INTERVAL = 1e-5 +/** + * The error margin used for approximately [Float] equality checking. + * + * Note that the machine epsilon value from https://en.wikipedia.org/wiki/Machine_epsilon is defined + * defined as the smallest value that, when added to, or subtract from, 1, will result in a value + * that is exactly equal to 1. A slightly larger value is picked here for some allowance in + * variance. + */ +const val FLOAT_EQUALITY_EPSILON: Float = 1e-6f -/** Returns whether this float approximately equals another based on a consistent epsilon value. */ +/** + * The error margin used for approximately [Double] equality checking. + * + * See [FLOAT_EQUALITY_EPSILON] for an explanation of this value. + */ +const val DOUBLE_EQUALITY_EPSILON: Double = 1e-15 + +/** + * Returns whether this float approximately equals another based on a consistent epsilon value + * ([FLOAT_EQUALITY_EPSILON]). + */ fun Float.approximatelyEquals(other: Float): Boolean { - return abs(this - other) < FLOAT_EQUALITY_INTERVAL + return abs(this - other) < FLOAT_EQUALITY_EPSILON } -/** Returns whether this double approximately equals another based on a consistent epsilon value. */ +/** Returns whether this double approximately equals another based on a consistent epsilon value + * ([DOUBLE_EQUALITY_EPSILON]). + */ fun Double.approximatelyEquals(other: Double): Boolean { - return abs(this - other) < FLOAT_EQUALITY_INTERVAL + return abs(this - other) < DOUBLE_EQUALITY_EPSILON } + +/** + * Returns a string representation of this [Double] that keeps the double in pure decimal and never + * relies on scientific notation (unlike [Double.toString]). + */ +fun Double.toPlainString(): String = toBigDecimal().toPlainString() 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 d4a57faf9be..d4881613346 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 @@ -2,6 +2,19 @@ package org.oppia.android.util.math import org.oppia.android.app.model.Fraction +/** Returns whether this fraction has a fractional component. */ +fun Fraction.hasFractionalPart(): Boolean { + return numerator != 0 +} + +/** + * Returns whether this fraction only represents a whole number. Note that for the fraction '0' this + * will return true. + */ +fun Fraction.isOnlyWholeNumber(): Boolean { + return !hasFractionalPart() +} + /** * Returns a [Double] version of this fraction. * @@ -13,6 +26,36 @@ fun Fraction.toDouble(): Double { return if (isNegative) -doubleVal else doubleVal } +/** + * Returns a submittable answer string representation of this fraction (note that this may not be + * the verbatim string originally submitted by the user, if any. + */ +fun Fraction.toAnswerString(): String { + return when { + // Fraction is only a whole number. + isOnlyWholeNumber() -> when (wholeNumber) { + 0 -> "0" // 0 is always 0 regardless of its negative sign. + else -> if (isNegative) "-$wholeNumber" else "$wholeNumber" + } + wholeNumber == 0 -> { + // Fraction contains just a fraction (no whole number). + when (denominator) { + 1 -> if (isNegative) "-$numerator" else "$numerator" + else -> if (isNegative) "-$numerator/$denominator" else "$numerator/$denominator" + } + } + else -> { + // Otherwise it's a mixed number. Note that the denominator is always shown here to account + // for strange cases that would require evaluation to resolve, such as: "2 2/1". + if (isNegative) { + "-$wholeNumber $numerator/$denominator" + } else { + "$wholeNumber $numerator/$denominator" + } + } + } +} + /** * Returns this fraction in its most simplified form. * @@ -26,7 +69,24 @@ fun Fraction.toSimplestForm(): Fraction { }.build() } +/** + * Returns this fraction in an improper form (that is, with a 0 whole number and only fractional + * parts). + */ +fun Fraction.toImproperForm(): Fraction { + val newNumerator = numerator + (denominator * wholeNumber) + return toBuilder().apply { + numerator = newNumerator + wholeNumber = 0 + }.build() +} + +/** Returns the negated form of this fraction. */ +operator fun Fraction.unaryMinus(): Fraction { + return toBuilder().apply { isNegative = !this@unaryMinus.isNegative }.build() +} + /** Returns the greatest common divisor between two integers. */ -fun gcd(x: Int, y: Int): Int { +private fun gcd(x: Int, y: Int): Int { return if (y == 0) x else gcd(y, x % y) } diff --git a/utility/src/main/java/org/oppia/android/util/math/PolynomialExtensions.kt b/utility/src/main/java/org/oppia/android/util/math/PolynomialExtensions.kt new file mode 100644 index 00000000000..5983554ddb1 --- /dev/null +++ b/utility/src/main/java/org/oppia/android/util/math/PolynomialExtensions.kt @@ -0,0 +1,59 @@ +package org.oppia.android.util.math + +import org.oppia.android.app.model.Polynomial +import org.oppia.android.app.model.Polynomial.Term +import org.oppia.android.app.model.Polynomial.Term.Variable +import org.oppia.android.app.model.Real + +/** Returns whether this polynomial is a constant-only polynomial (contains no variables). */ +fun Polynomial.isConstant(): Boolean = termCount == 1 && getTerm(0).variableCount == 0 + +/** + * Returns the first term coefficient from this polynomial. This corresponds to the whole value of + * the polynomial iff isConstant() returns true, otherwise this value isn't useful. + * + * Note that this function can throw if the polynomial is empty (so isConstant() should always be + * checked first). + */ +fun Polynomial.getConstant(): Real = getTerm(0).coefficient + +/** + * Returns a human-readable, plaintext representation of this [Polynomial]. + * + * The returned string is guaranteed to be a syntactically correct algebraic expression representing + * the polynomial, e.g. "1+x-7x^2"). + */ +fun Polynomial.toPlainText(): String { + return termList.map { + it.toPlainText() + }.reduce { ongoingPolynomialStr, termAnswerStr -> + if (termAnswerStr.startsWith("-")) { + "$ongoingPolynomialStr - ${termAnswerStr.drop(1)}" + } else "$ongoingPolynomialStr + $termAnswerStr" + } +} + +private fun Term.toPlainText(): String { + val productValues = mutableListOf() + + // Include the coefficient if there is one (coefficients of 1 are ignored only if there are + // variables present). + productValues += when { + variableList.isEmpty() || !abs(coefficient).isApproximatelyEqualTo(1.0) -> when { + coefficient.isRational() && variableList.isNotEmpty() -> "(${coefficient.toPlainText()})" + else -> coefficient.toPlainText() + } + coefficient.isNegative() -> "-" + else -> "" + } + + // Include any present variables. + productValues += variableList.map(Variable::toPlainText) + + // Take the product of all relevant values of the term. + return productValues.joinToString(separator = "") +} + +private fun Variable.toPlainText(): String { + return if (power > 1) "$name^$power" else name +} diff --git a/utility/src/main/java/org/oppia/android/util/math/RatioExtensions.kt b/utility/src/main/java/org/oppia/android/util/math/RatioExtensions.kt index 83d85e9098c..4b2b559d579 100644 --- a/utility/src/main/java/org/oppia/android/util/math/RatioExtensions.kt +++ b/utility/src/main/java/org/oppia/android/util/math/RatioExtensions.kt @@ -21,3 +21,8 @@ fun RatioExpression.toSimplestForm(): List { fun RatioExpression.toAnswerString(): String { return ratioComponentList.joinToString(separator = ":") } + +/** Returns the greatest common divisor between two integers. */ +private fun gcd(x: Int, y: Int): Int { + return if (y == 0) x else gcd(y, x % y) +} diff --git a/utility/src/main/java/org/oppia/android/util/math/RealExtensions.kt b/utility/src/main/java/org/oppia/android/util/math/RealExtensions.kt new file mode 100644 index 00000000000..7f8eabb7901 --- /dev/null +++ b/utility/src/main/java/org/oppia/android/util/math/RealExtensions.kt @@ -0,0 +1,88 @@ +package org.oppia.android.util.math + +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 + +/** + * Returns whether this [Real] is explicitly a rational type (i.e. a fraction). + * + * This returns false if the real is an integer despite that being mathematically rational. + */ +fun Real.isRational(): Boolean = realTypeCase == RATIONAL + +/** Returns whether this [Real] is negative. */ +fun Real.isNegative(): Boolean = when (realTypeCase) { + RATIONAL -> rational.isNegative + IRRATIONAL -> irrational < 0 + INTEGER -> integer < 0 + REALTYPE_NOT_SET, null -> throw IllegalStateException("Invalid real: $this.") +} + +/** + * Returns a [Double] representation of this [Real] that is approximately the same value (per + * [isApproximatelyEqualTo]). + */ +fun Real.toDouble(): Double { + return when (realTypeCase) { + RATIONAL -> rational.toDouble() + INTEGER -> integer.toDouble() + IRRATIONAL -> irrational + REALTYPE_NOT_SET, null -> throw IllegalStateException("Invalid real: $this.") + } +} + +/** + * Returns a human-readable, plaintext representation of this [Real]. + * + * Note that the returned value is guaranteed to be a self-contained numeric expression representing + * the real (which means proper fractions are converted to improper answer strings since fractions + * like '1 1/2' can't be written as a numeric expression without converting them to an improper + * form: '3/2'). + * + * Note that this will return an empty string if this [Real] doesn't represent an actual real value + * (e.g. a default instance). + */ +fun Real.toPlainText(): String = when (realTypeCase) { + // Note that the rational part is first converted to an improper fraction since mixed fractions + // can't be expressed as a single coefficient in typical polynomial syntax). + RATIONAL -> rational.toImproperForm().toAnswerString() + IRRATIONAL -> irrational.toPlainString() + INTEGER -> integer.toString() + // The Real type isn't valid, so rather than failing just return an empty string. + REALTYPE_NOT_SET, null -> "" +} + +/** + * Returns whether this [Real] is approximately equal to the specified [Double] per + * [Double.approximatelyEquals]. + */ +fun Real.isApproximatelyEqualTo(value: Double): Boolean { + return toDouble().approximatelyEquals(value) +} + +/** + * Returns a negative version of this [Real] such that the original real plus the negative version + * would result in zero. + */ +operator fun Real.unaryMinus(): Real { + return when (realTypeCase) { + RATIONAL -> recompute { it.setRational(-rational) } + IRRATIONAL -> recompute { it.setIrrational(-irrational) } + INTEGER -> recompute { it.setInteger(-integer) } + REALTYPE_NOT_SET, null -> throw IllegalStateException("Invalid real: $this.") + } +} + +/** + * Returns an absolute value of this [Real] (that is, a non-negative [Real]). + * + * [isNegative] is guaranteed to return false for the returned value. + */ +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() +} 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 6fb6fb73482..5e674f0bf9f 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 @@ -4,6 +4,42 @@ Tests for general-purpose mathematics utilities. load("//:oppia_android_test.bzl", "oppia_android_test") +oppia_android_test( + name = "FloatExtensionsTest", + srcs = ["FloatExtensionsTest.kt"], + custom_package = "org.oppia.android.util.math", + test_class = "org.oppia.android.util.math.FloatExtensionsTest", + 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_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", + ], +) + +oppia_android_test( + name = "FractionExtensionsTest", + srcs = ["FractionExtensionsTest.kt"], + custom_package = "org.oppia.android.util.math", + test_class = "org.oppia.android.util.math.FractionExtensionsTest", + test_manifest = "//utility:test_manifest", + deps = [ + "//model/src/main/proto:math_java_proto_lite", + "//testing", + "//testing/src/main/java/org/oppia/android/testing/math:fraction_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", + ], +) + oppia_android_test( name = "FractionParserTest", srcs = ["FractionParserTest.kt"], @@ -22,6 +58,24 @@ oppia_android_test( ], ) +oppia_android_test( + name = "PolynomialExtensionsTest", + srcs = ["PolynomialExtensionsTest.kt"], + custom_package = "org.oppia.android.util.math", + test_class = "org.oppia.android.util.math.PolynomialExtensionsTest", + test_manifest = "//utility:test_manifest", + deps = [ + "//model/src/main/proto:math_java_proto_lite", + "//testing/src/main/java/org/oppia/android/testing/math:polynomial_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", + ], +) + oppia_android_test( name = "RatioExtensionsTest", srcs = ["RatioExtensionsTest.kt"], @@ -38,3 +92,22 @@ oppia_android_test( "//utility/src/main/java/org/oppia/android/util/math:extensions", ], ) + +oppia_android_test( + name = "RealExtensionsTest", + srcs = ["RealExtensionsTest.kt"], + custom_package = "org.oppia.android.util.math", + test_class = "org.oppia.android.util.math.RealExtensionsTest", + test_manifest = "//utility:test_manifest", + deps = [ + "//model/src/main/proto:math_java_proto_lite", + "//testing", + "//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", + ], +) diff --git a/utility/src/test/java/org/oppia/android/util/math/FloatExtensionsTest.kt b/utility/src/test/java/org/oppia/android/util/math/FloatExtensionsTest.kt new file mode 100644 index 00000000000..9f83b544a6d --- /dev/null +++ b/utility/src/test/java/org/oppia/android/util/math/FloatExtensionsTest.kt @@ -0,0 +1,165 @@ +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.robolectric.annotation.LooperMode + +/** Tests for [Float] and [Double] extensions. */ +// FunctionName: test names are conventionally named with underscores. +@Suppress("FunctionName") +@RunWith(AndroidJUnit4::class) +@LooperMode(LooperMode.Mode.PAUSED) +class FloatExtensionsTest { + + @Test + fun testFloat_approximatelyEquals_bothZero_returnsTrue() { + val leftFloat = 0f + val rightFloat = 0f + + val result = leftFloat.approximatelyEquals(rightFloat) + + assertThat(result).isTrue() + } + + @Test + fun testFloat_approximatelyEquals_sameNonZeroValue_returnsTrue() { + val leftFloat = 1.2f + val rightFloat = 1.2f + + val result = leftFloat.approximatelyEquals(rightFloat) + + assertThat(result).isTrue() + } + + @Test + fun testFloat_approximatelyEquals_nonZeroValues_withinInterval_returnsTrue() { + val leftFloat = 1.2f + val rightFloat = leftFloat + FLOAT_EQUALITY_EPSILON / 10f + + val result = leftFloat.approximatelyEquals(rightFloat) + + // Verify that they are approximately equal, but not actually the same float. + assertThat(result).isTrue() + assertThat(leftFloat).isNotEqualTo(rightFloat) + } + + @Test + fun testFloat_approximatelyEquals_zeroAndNonZeroValue_veryDifferent_returnsFalse() { + val leftFloat = 0f + val rightFloat = 7.3f + + val result = leftFloat.approximatelyEquals(rightFloat) + + assertThat(result).isFalse() + } + + @Test + fun testFloat_approximatelyEquals_nonZeroValues_outsideInterval_returnsFalse() { + val leftFloat = 1.2f + val rightFloat = leftFloat + FLOAT_EQUALITY_EPSILON * 2f + + val result = leftFloat.approximatelyEquals(rightFloat) + + assertThat(result).isFalse() + } + + @Test + fun testFloat_approximatelyEquals_nonZeroValues_veryDifferent_returnsFalse() { + val leftFloat = 1.2f + val rightFloat = 7.3f + + val result = leftFloat.approximatelyEquals(rightFloat) + + assertThat(result).isFalse() + } + + @Test + fun testDouble_approximatelyEquals_bothZero_returnsTrue() { + val leftDouble = 0.0 + val rightDouble = 0.0 + + val result = leftDouble.approximatelyEquals(rightDouble) + + assertThat(result).isTrue() + } + + @Test + fun testDouble_approximatelyEquals_sameNonZeroValue_returnsTrue() { + val leftDouble = 1.2 + val rightDouble = 1.2 + + val result = leftDouble.approximatelyEquals(rightDouble) + + assertThat(result).isTrue() + } + + @Test + fun testDouble_approximatelyEquals_nonZeroValues_withinInterval_returnsTrue() { + val leftDouble = 0.2 + val rightDouble = leftDouble + DOUBLE_EQUALITY_EPSILON / 10.0 + + val result = leftDouble.approximatelyEquals(rightDouble) + + // Verify that they are approximately equal, but not actually the same double. + assertThat(result).isTrue() + assertThat(leftDouble).isNotEqualTo(rightDouble) + } + + @Test + fun testDouble_approximatelyEquals_nonZeroValues_outsideInterval_returnsFalse() { + val leftDouble = 1.2 + val rightDouble = leftDouble + DOUBLE_EQUALITY_EPSILON * 2 + + val result = leftDouble.approximatelyEquals(rightDouble) + + assertThat(result).isFalse() + } + + @Test + fun testDouble_approximatelyEquals_nonZeroValues_veryDifferent_returnsFalse() { + val leftDouble = 1.2 + val rightDouble = 7.3 + + val result = leftDouble.approximatelyEquals(rightDouble) + + assertThat(result).isFalse() + } + + @Test + fun testDouble_toPlainText_zero_returnsStringWithZero() { + val testDouble = 0.0 + + val plainText = testDouble.toPlainString() + + assertThat(plainText).isEqualTo("0.0") + } + + @Test + fun testDouble_toPlainText_nonZero_returnsStringForNonZero() { + val testDouble = 4.0 + + val plainText = testDouble.toPlainString() + + assertThat(plainText).isEqualTo("4.0") + } + + @Test + fun testDouble_toPlainText_negativeMultiDigitNumber_returnsCorrectString() { + val testDouble = -1.73 + + val plainText = testDouble.toPlainString() + + assertThat(plainText).isEqualTo("-1.73") + } + + @Test + fun testDouble_toPlainText_largeNumber_returnsNumberWithoutScientificNotation() { + val testDouble = 84758123.3213989 + + val plainText = testDouble.toPlainString() + + assertThat(plainText).isEqualTo("84758123.3213989") + } +} 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 new file mode 100644 index 00000000000..48121da69d7 --- /dev/null +++ b/utility/src/test/java/org/oppia/android/util/math/FractionExtensionsTest.kt @@ -0,0 +1,530 @@ +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.testing.assertThrows +import org.oppia.android.testing.math.FractionSubject.Companion.assertThat +import org.robolectric.annotation.LooperMode +import java.lang.ArithmeticException + +/** Tests for [Fraction] extensions. */ +// FunctionName: test names are conventionally named with underscores. +@Suppress("FunctionName") +@RunWith(AndroidJUnit4::class) +@LooperMode(LooperMode.Mode.PAUSED) +class FractionExtensionsTest { + private companion object { + private val ZERO_FRACTION = Fraction.newBuilder().apply { + denominator = 1 + }.build() + + private val NEGATIVE_ZERO_FRACTION = Fraction.newBuilder().apply { + isNegative = true + denominator = 1 + }.build() + + private val ONE_HALF_FRACTION = Fraction.newBuilder().apply { + numerator = 1 + denominator = 2 + }.build() + + private val NEGATIVE_ONE_HALF_FRACTION = Fraction.newBuilder().apply { + isNegative = true + numerator = 1 + denominator = 2 + }.build() + + private val ONE_AND_ONE_HALF_FRACTION = Fraction.newBuilder().apply { + wholeNumber = 1 + numerator = 1 + denominator = 2 + }.build() + + private val NEGATIVE_ONE_AND_ONE_HALF_FRACTION = Fraction.newBuilder().apply { + isNegative = true + wholeNumber = 1 + numerator = 1 + denominator = 2 + }.build() + + private val THREE_HALVES_FRACTION = Fraction.newBuilder().apply { + numerator = 3 + denominator = 2 + }.build() + + private val NEGATIVE_THREE_HALVES_FRACTION = Fraction.newBuilder().apply { + isNegative = true + numerator = 3 + denominator = 2 + }.build() + + private val THREE_ONES_FRACTION = Fraction.newBuilder().apply { + numerator = 3 + denominator = 1 + }.build() + + private val NEGATIVE_THREE_ONES_FRACTION = Fraction.newBuilder().apply { + isNegative = true + numerator = 3 + denominator = 1 + }.build() + + private val TWO_FRACTION = Fraction.newBuilder().apply { + wholeNumber = 2 + denominator = 1 + }.build() + + private val NEGATIVE_TWO_FRACTION = Fraction.newBuilder().apply { + isNegative = true + wholeNumber = 2 + denominator = 1 + }.build() + } + + @Test + fun testHasFractionalPart_zeroFraction_returnsFalse() { + val result = ZERO_FRACTION.hasFractionalPart() + + assertThat(result).isFalse() + } + + @Test + fun testHasFractionalPart_oneHalf_returnsTrue() { + val result = ONE_HALF_FRACTION.hasFractionalPart() + + assertThat(result).isTrue() + } + + @Test + fun testHasFractionalPart_negativeOneHalf_returnsTrue() { + val result = NEGATIVE_ONE_HALF_FRACTION.hasFractionalPart() + + assertThat(result).isTrue() + } + + @Test + fun testHasFractionalPart_mixedFraction_returnsTrue() { + val result = ONE_AND_ONE_HALF_FRACTION.hasFractionalPart() + + assertThat(result).isTrue() + } + + @Test + fun testHasFractionalPart_improperFraction_returnsTrue() { + val result = THREE_HALVES_FRACTION.hasFractionalPart() + + assertThat(result).isTrue() + } + + @Test + fun testHasFractionalPart_threeOverOne_returnsTrue() { + val result = THREE_ONES_FRACTION.hasFractionalPart() + + assertThat(result).isTrue() + } + + @Test + fun testHasFractionalPart_onlyWholeNumber_returnsFalse() { + val result = TWO_FRACTION.hasFractionalPart() + + assertThat(result).isFalse() + } + + @Test + fun testIsOnlyWholeNumber_zeroFraction_returnsTrue() { + val result = ZERO_FRACTION.isOnlyWholeNumber() + + assertThat(result).isTrue() + } + + @Test + fun testIsOnlyWholeNumber_oneHalf_returnsFalse() { + val result = ONE_HALF_FRACTION.isOnlyWholeNumber() + + assertThat(result).isFalse() + } + + @Test + fun testIsOnlyWholeNumber_negativeOneHalf_returnsFalse() { + val result = NEGATIVE_ONE_HALF_FRACTION.isOnlyWholeNumber() + + assertThat(result).isFalse() + } + + @Test + fun testIsOnlyWholeNumber_mixedFraction_returnsFalse() { + val result = ONE_AND_ONE_HALF_FRACTION.isOnlyWholeNumber() + + assertThat(result).isFalse() + } + + @Test + fun testIsOnlyWholeNumber_improperFraction_returnsFalse() { + val result = THREE_HALVES_FRACTION.isOnlyWholeNumber() + + assertThat(result).isFalse() + } + + @Test + fun testIsOnlyWholeNumber_threeOverOne_returnsFalse() { + val result = THREE_ONES_FRACTION.isOnlyWholeNumber() + + // 3/1 is technically not a whole number since it's still in fractional form. + assertThat(result).isFalse() + } + + @Test + fun testIsOnlyWholeNumber_onlyWholeNumber_returnsTrue() { + val result = TWO_FRACTION.isOnlyWholeNumber() + + assertThat(result).isTrue() + } + + @Test + fun testToDouble_zeroFraction_returnsZero() { + val result = ZERO_FRACTION.toDouble() + + assertThat(result).isWithin(1e-5).of(0.0) + } + + @Test + fun testToDouble_oneHalf_returnsPointFive() { + val result = ONE_HALF_FRACTION.toDouble() + + assertThat(result).isWithin(1e-5).of(0.5) + } + + @Test + fun testToDouble_negativeOneHalf_returnsNegativePointFive() { + val result = NEGATIVE_ONE_HALF_FRACTION.toDouble() + + assertThat(result).isWithin(1e-5).of(-0.5) + } + + @Test + fun testToDouble_one_and_one_half_returnsOnePointFive() { + val result = ONE_AND_ONE_HALF_FRACTION.toDouble() + + assertThat(result).isWithin(1e-5).of(1.5) + } + + @Test + fun testToDouble_threeHalves_returnsOnePointFive() { + val result = THREE_HALVES_FRACTION.toDouble() + + assertThat(result).isWithin(1e-5).of(1.5) + } + + @Test + fun testToDouble_two_returnsTwo() { + val result = TWO_FRACTION.toDouble() + + assertThat(result).isWithin(1e-5).of(2.0) + } + + @Test + fun testToAnswerString_zero_returnsZeroString() { + val result = ZERO_FRACTION.toAnswerString() + + assertThat(result).isEqualTo("0") + } + + @Test + fun testToAnswerString_negativeZero_returnsZeroString() { + val result = NEGATIVE_ZERO_FRACTION.toAnswerString() + + assertThat(result).isEqualTo("0") + } + + @Test + fun testToAnswerString_two_returnsTwoString() { + val result = TWO_FRACTION.toAnswerString() + + assertThat(result).isEqualTo("2") + } + + @Test + fun testToAnswerString_negativeTwo_returnsMinusTwoString() { + val result = NEGATIVE_TWO_FRACTION.toAnswerString() + + assertThat(result).isEqualTo("-2") + } + + @Test + fun testToAnswerString_threeOverOne_returnsThreeString() { + val result = THREE_ONES_FRACTION.toAnswerString() + + assertThat(result).isEqualTo("3") + } + + @Test + fun testToAnswerString_negativeThreeOverOne_returnsMinusThreeString() { + val result = NEGATIVE_THREE_ONES_FRACTION.toAnswerString() + + assertThat(result).isEqualTo("-3") + } + + @Test + fun testToAnswerString_threeOverTwo_returnsThreeHalvesString() { + val result = THREE_HALVES_FRACTION.toAnswerString() + + assertThat(result).isEqualTo("3/2") + } + + @Test + fun testToAnswerString_negativeThreeOverTwo_returnsMinusThreeHalvesString() { + val result = NEGATIVE_THREE_HALVES_FRACTION.toAnswerString() + + assertThat(result).isEqualTo("-3/2") + } + + @Test + fun testToAnswerString_oneAndOneHalf_returnsMixedFractionString() { + val result = ONE_AND_ONE_HALF_FRACTION.toAnswerString() + + assertThat(result).isEqualTo("1 1/2") + } + + @Test + fun testToAnswerString_negativeOneAndOneHalf_returnsMinusMixedFractionString() { + val result = NEGATIVE_ONE_AND_ONE_HALF_FRACTION.toAnswerString() + + assertThat(result).isEqualTo("-1 1/2") + } + + @Test + fun testToSimplestForm_zero_returnsZeroFraction() { + val result = ZERO_FRACTION.toSimplestForm() + + assertThat(result).hasNegativePropertyThat().isFalse() + assertThat(result).hasNumeratorThat().isEqualTo(0) + assertThat(result).hasDenominatorThat().isEqualTo(1) + assertThat(result).hasWholeNumberThat().isEqualTo(0) + } + + @Test + fun testToSimplestForm_two_returnsTwoFraction() { + val result = TWO_FRACTION.toSimplestForm() + + assertThat(result).hasNegativePropertyThat().isFalse() + assertThat(result).hasNumeratorThat().isEqualTo(0) + assertThat(result).hasDenominatorThat().isEqualTo(1) + assertThat(result).hasWholeNumberThat().isEqualTo(2) + } + + @Test + fun testToSimplestForm_oneHalf_returnsOneHalfFraction() { + val result = ONE_HALF_FRACTION.toSimplestForm() + + assertThat(result).hasNegativePropertyThat().isFalse() + assertThat(result).hasNumeratorThat().isEqualTo(1) + assertThat(result).hasDenominatorThat().isEqualTo(2) + assertThat(result).hasWholeNumberThat().isEqualTo(0) + } + + @Test + fun testToSimplestForm_oneAndOneHalf_returnsOneAndOneHalfFraction() { + val result = ONE_AND_ONE_HALF_FRACTION.toSimplestForm() + + assertThat(result).hasNegativePropertyThat().isFalse() + assertThat(result).hasNumeratorThat().isEqualTo(1) + assertThat(result).hasDenominatorThat().isEqualTo(2) + assertThat(result).hasWholeNumberThat().isEqualTo(1) + } + + @Test + fun testToSimplestForm_sixFourths_returnsThreeHalvesFraction() { + val sixHalvesFraction = Fraction.newBuilder().apply { + numerator = 6 + denominator = 4 + }.build() + + val result = sixHalvesFraction.toSimplestForm() + + assertThat(result).hasNegativePropertyThat().isFalse() + assertThat(result).hasNumeratorThat().isEqualTo(3) + assertThat(result).hasDenominatorThat().isEqualTo(2) + assertThat(result).hasWholeNumberThat().isEqualTo(0) + } + + @Test + fun testToSimplestForm_largeNegativeImproperFraction_reducesToSimplestImproperFraction() { + val largeImproperFraction = Fraction.newBuilder().apply { + isNegative = true + numerator = 1650 + denominator = 209 + }.build() + + val result = largeImproperFraction.toSimplestForm() + + assertThat(result).hasNegativePropertyThat().isTrue() + assertThat(result).hasNumeratorThat().isEqualTo(150) + assertThat(result).hasDenominatorThat().isEqualTo(19) + assertThat(result).hasWholeNumberThat().isEqualTo(0) + } + + @Test + fun testToSimplestForm_zeroDenominator_throwsException() { + val zeroDenominatorFraction = Fraction.getDefaultInstance() + + // Converting to simplest form results in a divide by zero in this case. + assertThrows(ArithmeticException::class) { zeroDenominatorFraction.toSimplestForm() } + } + + @Test + fun testToImproperForm_zero_returnsZeroFraction() { + val result = ZERO_FRACTION.toImproperForm() + + assertThat(result).hasNegativePropertyThat().isFalse() + assertThat(result).hasNumeratorThat().isEqualTo(0) + assertThat(result).hasDenominatorThat().isEqualTo(1) + assertThat(result).hasWholeNumberThat().isEqualTo(0) + } + + @Test + fun testToImproperForm_two_returnsTwoOnesFraction() { + val result = TWO_FRACTION.toImproperForm() + + assertThat(result).hasNegativePropertyThat().isFalse() + assertThat(result).hasNumeratorThat().isEqualTo(2) + assertThat(result).hasDenominatorThat().isEqualTo(1) + assertThat(result).hasWholeNumberThat().isEqualTo(0) + } + + @Test + fun testToImproperForm_oneHalf_returnsOneHalfFraction() { + val result = ONE_HALF_FRACTION.toImproperForm() + + assertThat(result).hasNegativePropertyThat().isFalse() + assertThat(result).hasNumeratorThat().isEqualTo(1) + assertThat(result).hasDenominatorThat().isEqualTo(2) + assertThat(result).hasWholeNumberThat().isEqualTo(0) + } + + @Test + fun testToImproperForm_oneAndOneHalf_returnsThreeHalvesFraction() { + val result = ONE_AND_ONE_HALF_FRACTION.toImproperForm() + + assertThat(result).hasNegativePropertyThat().isFalse() + assertThat(result).hasNumeratorThat().isEqualTo(3) + assertThat(result).hasDenominatorThat().isEqualTo(2) + assertThat(result).hasWholeNumberThat().isEqualTo(0) + } + + @Test + fun testToImproperForm_threeHalves_returnsThreeHalvesFraction() { + val result = THREE_HALVES_FRACTION.toImproperForm() + + assertThat(result).hasNegativePropertyThat().isFalse() + assertThat(result).hasNumeratorThat().isEqualTo(3) + assertThat(result).hasDenominatorThat().isEqualTo(2) + assertThat(result).hasWholeNumberThat().isEqualTo(0) + } + + @Test + fun testToImproperForm_negativeOneAndTwoThirds_returnsNegativeFiveThirdsFraction() { + val negativeOneAndTwoThirds = Fraction.newBuilder().apply { + isNegative = true + numerator = 2 + denominator = 3 + wholeNumber = 1 + }.build() + + val result = negativeOneAndTwoThirds.toImproperForm() + + assertThat(result).hasNegativePropertyThat().isTrue() + assertThat(result).hasNumeratorThat().isEqualTo(5) + assertThat(result).hasDenominatorThat().isEqualTo(3) + assertThat(result).hasWholeNumberThat().isEqualTo(0) + } + + @Test + fun testToImproperForm_largeSimpleFormFraction_returnsLargeImproperFraction() { + val negativeOneAndTwoThirds = Fraction.newBuilder().apply { + isNegative = true + numerator = 17 + denominator = 19 + wholeNumber = 7 + }.build() + + val result = negativeOneAndTwoThirds.toImproperForm() + + assertThat(result).hasNegativePropertyThat().isTrue() + assertThat(result).hasNumeratorThat().isEqualTo(150) + assertThat(result).hasDenominatorThat().isEqualTo(19) + assertThat(result).hasWholeNumberThat().isEqualTo(0) + } + + @Test + fun testUnaryMinus_zero_returnsNegativeZeroFraction() { + val result = -ZERO_FRACTION + + assertThat(result).hasNegativePropertyThat().isTrue() + assertThat(result).hasNumeratorThat().isEqualTo(0) + assertThat(result).hasDenominatorThat().isEqualTo(1) + assertThat(result).hasWholeNumberThat().isEqualTo(0) + } + + @Test + fun testUnaryMinus_two_returnsNegativeTwoFraction() { + val result = -TWO_FRACTION + + assertThat(result).hasNegativePropertyThat().isTrue() + assertThat(result).hasNumeratorThat().isEqualTo(0) + assertThat(result).hasDenominatorThat().isEqualTo(1) + assertThat(result).hasWholeNumberThat().isEqualTo(2) + } + + @Test + fun testUnaryMinus_negativeTwo_returnsTwoFraction() { + val result = -NEGATIVE_TWO_FRACTION + + assertThat(result).hasNegativePropertyThat().isFalse() + assertThat(result).hasNumeratorThat().isEqualTo(0) + assertThat(result).hasDenominatorThat().isEqualTo(1) + assertThat(result).hasWholeNumberThat().isEqualTo(2) + } + + @Test + fun testUnaryMinus_oneHalf_returnsNegativeOneHalfFraction() { + val result = -ONE_HALF_FRACTION + + assertThat(result).hasNegativePropertyThat().isTrue() + assertThat(result).hasNumeratorThat().isEqualTo(1) + assertThat(result).hasDenominatorThat().isEqualTo(2) + assertThat(result).hasWholeNumberThat().isEqualTo(0) + } + + @Test + fun testUnaryMinus_negativeOneHalf_returnsOneHalfFraction() { + val result = -NEGATIVE_ONE_HALF_FRACTION + + assertThat(result).hasNegativePropertyThat().isFalse() + assertThat(result).hasNumeratorThat().isEqualTo(1) + assertThat(result).hasDenominatorThat().isEqualTo(2) + assertThat(result).hasWholeNumberThat().isEqualTo(0) + } + + @Test + fun testUnaryMinus_oneAndOneHalf_returnsNegativeOneAndOneHalfFraction() { + val result = -ONE_AND_ONE_HALF_FRACTION + + assertThat(result).hasNegativePropertyThat().isTrue() + assertThat(result).hasNumeratorThat().isEqualTo(1) + assertThat(result).hasDenominatorThat().isEqualTo(2) + assertThat(result).hasWholeNumberThat().isEqualTo(1) + } + + @Test + fun testUnaryMinus_negativeOneAndOneHalf_returnsOneAndOneHalfFraction() { + val result = -NEGATIVE_ONE_AND_ONE_HALF_FRACTION + + assertThat(result).hasNegativePropertyThat().isFalse() + assertThat(result).hasNumeratorThat().isEqualTo(1) + assertThat(result).hasDenominatorThat().isEqualTo(2) + assertThat(result).hasWholeNumberThat().isEqualTo(1) + } +} diff --git a/utility/src/test/java/org/oppia/android/util/math/PolynomialExtensionsTest.kt b/utility/src/test/java/org/oppia/android/util/math/PolynomialExtensionsTest.kt new file mode 100644 index 00000000000..4fac2ae26b0 --- /dev/null +++ b/utility/src/test/java/org/oppia/android/util/math/PolynomialExtensionsTest.kt @@ -0,0 +1,390 @@ +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.Polynomial +import org.oppia.android.app.model.Polynomial.Term +import org.oppia.android.app.model.Polynomial.Term.Variable +import org.oppia.android.app.model.Real +import org.oppia.android.testing.math.RealSubject.Companion.assertThat +import org.robolectric.annotation.LooperMode + +/** Tests for [Polynomial] extensions. */ +// FunctionName: test names are conventionally named with underscores. +@Suppress("FunctionName") +@RunWith(AndroidJUnit4::class) +@LooperMode(LooperMode.Mode.PAUSED) +class PolynomialExtensionsTest { + private companion object { + private const val PI = 3.1415 + + private val ONE_HALF_FRACTION = Fraction.newBuilder().apply { + numerator = 1 + denominator = 2 + }.build() + + private val ONE_AND_ONE_HALF_FRACTION = Fraction.newBuilder().apply { + numerator = 1 + denominator = 2 + wholeNumber = 1 + }.build() + + private val ZERO_REAL = Real.newBuilder().apply { + integer = 0 + }.build() + + private val ONE_REAL = Real.newBuilder().apply { + integer = 1 + }.build() + + private val TWO_REAL = Real.newBuilder().apply { + integer = 2 + }.build() + + private val ONE_HALF_REAL = Real.newBuilder().apply { + rational = ONE_HALF_FRACTION + }.build() + + private val ONE_AND_ONE_HALF_REAL = Real.newBuilder().apply { + rational = ONE_AND_ONE_HALF_FRACTION + }.build() + + private val PI_REAL = Real.newBuilder().apply { + irrational = PI + }.build() + + private val ZERO_POLYNOMIAL = createPolynomial(createTerm(coefficient = ZERO_REAL)) + + private val TWO_POLYNOMIAL = createPolynomial(createTerm(coefficient = TWO_REAL)) + + private val NEGATIVE_TWO_POLYNOMIAL = createPolynomial(createTerm(coefficient = -TWO_REAL)) + + private val ONE_HALF_POLYNOMIAL = createPolynomial(createTerm(coefficient = ONE_HALF_REAL)) + + private val NEGATIVE_ONE_HALF_POLYNOMIAL = + createPolynomial(createTerm(coefficient = -ONE_HALF_REAL)) + + private val ONE_AND_ONE_HALF_POLYNOMIAL = + createPolynomial(createTerm(coefficient = ONE_AND_ONE_HALF_REAL)) + + private val NEGATIVE_ONE_AND_ONE_HALF_POLYNOMIAL = + createPolynomial(createTerm(coefficient = -ONE_AND_ONE_HALF_REAL)) + + private val PI_POLYNOMIAL = createPolynomial(createTerm(coefficient = PI_REAL)) + + private val NEGATIVE_PI_POLYNOMIAL = createPolynomial(createTerm(coefficient = -PI_REAL)) + + private val ONE_X_POLYNOMIAL = + createPolynomial(createTerm(coefficient = ONE_REAL, createVariable(name = "x", power = 1))) + + private val NEGATIVE_ONE_X_POLYNOMIAL = + createPolynomial(createTerm(coefficient = -ONE_REAL, createVariable(name = "x", power = 1))) + + private val TWO_X_POLYNOMIAL = + createPolynomial(createTerm(coefficient = TWO_REAL, createVariable(name = "x", power = 1))) + + private val ONE_PLUS_X_POLYNOMIAL = + createPolynomial( + createTerm(coefficient = ONE_REAL), + createTerm(coefficient = ONE_REAL, createVariable(name = "x", power = 1)) + ) + } + + @Test + fun testIsConstant_default_returnsFalse() { + val defaultPolynomial = Polynomial.getDefaultInstance() + + val result = defaultPolynomial.isConstant() + + assertThat(result).isFalse() + } + + @Test + fun testIsConstant_zero_returnsTrue() { + val result = ZERO_POLYNOMIAL.isConstant() + + assertThat(result).isTrue() + } + + @Test + fun testIsConstant_two_returnsTrue() { + val result = TWO_POLYNOMIAL.isConstant() + + assertThat(result).isTrue() + } + + @Test + fun testIsConstant_negativeTwo_returnsTrue() { + val result = NEGATIVE_TWO_POLYNOMIAL.isConstant() + + assertThat(result).isTrue() + } + + @Test + fun testIsConstant_oneHalf_returnsTrue() { + val result = ONE_HALF_POLYNOMIAL.isConstant() + + assertThat(result).isTrue() + } + + @Test + fun testIsConstant_negativeOneHalf_returnsTrue() { + val result = NEGATIVE_ONE_HALF_POLYNOMIAL.isConstant() + + assertThat(result).isTrue() + } + + @Test + fun testIsConstant_pi_returnsTrue() { + val result = PI_POLYNOMIAL.isConstant() + + assertThat(result).isTrue() + } + + @Test + fun testIsConstant_negativePi_returnsTrue() { + val result = NEGATIVE_PI_POLYNOMIAL.isConstant() + + assertThat(result).isTrue() + } + + @Test + fun testIsConstant_x_returnsFalse() { + val result = ONE_X_POLYNOMIAL.isConstant() + + assertThat(result).isFalse() + } + + @Test + fun testIsConstant_2x_returnsFalse() { + val result = TWO_X_POLYNOMIAL.isConstant() + + assertThat(result).isFalse() + } + + @Test + fun testIsConstant_one_and_x_returnsFalse() { + val result = ONE_PLUS_X_POLYNOMIAL.isConstant() + + assertThat(result).isFalse() + } + + @Test + fun testIsConstant_one_and_two_returnsFalse() { + val onePlusTwoPolynomial = + createPolynomial(createTerm(coefficient = ONE_REAL), createTerm(coefficient = TWO_REAL)) + + val result = onePlusTwoPolynomial.isConstant() + + // While 1+2 is effectively a constant polynomial, it's not actually simplified and thus isn't + // considered a constant polynomial. + assertThat(result).isFalse() + } + + @Test + fun testGetConstant_zero_returnsZero() { + val result = ZERO_POLYNOMIAL.getConstant() + + assertThat(result).isIntegerThat().isEqualTo(0) + } + + @Test + fun testGetConstant_two_returnsTwo() { + val result = TWO_POLYNOMIAL.getConstant() + + assertThat(result).isIntegerThat().isEqualTo(2) + } + + @Test + fun testGetConstant_negativeTwo_returnsNegativeTwo() { + val result = NEGATIVE_TWO_POLYNOMIAL.getConstant() + + assertThat(result).isIntegerThat().isEqualTo(-2) + } + + @Test + fun testGetConstant_oneHalf_returnsOneHalf() { + val result = ONE_HALF_POLYNOMIAL.getConstant() + + assertThat(result).isRationalThat().evaluatesToDoubleThat().isWithin(1e-5).of(0.5) + } + + @Test + fun testGetConstant_negativeOneHalf_returnsNegativeOneHalf() { + val result = NEGATIVE_ONE_HALF_POLYNOMIAL.getConstant() + + assertThat(result).isRationalThat().evaluatesToDoubleThat().isWithin(1e-5).of(-0.5) + } + + @Test + fun testGetConstant_pi_returnsPi() { + val result = PI_POLYNOMIAL.getConstant() + + assertThat(result).isIrrationalThat().isWithin(1e-5).of(PI) + } + + @Test + fun testGetConstant_negativePi_returnsNegativePi() { + val result = NEGATIVE_PI_POLYNOMIAL.getConstant() + + assertThat(result).isIrrationalThat().isWithin(1e-5).of(-PI) + } + + @Test + fun testToPlainText_zero_returnsZeroString() { + val result = ZERO_POLYNOMIAL.toPlainText() + + assertThat(result).isEqualTo("0") + } + + @Test + fun testToPlainText_two_returnsTwoString() { + val result = TWO_POLYNOMIAL.toPlainText() + + assertThat(result).isEqualTo("2") + } + + @Test + fun testToPlainText_negativeTwo_returnsMinusTwoString() { + val result = NEGATIVE_TWO_POLYNOMIAL.toPlainText() + + assertThat(result).isEqualTo("-2") + } + + @Test + fun testToPlainText_oneAndOneHalf_returnsThreeHalvesString() { + val result = ONE_AND_ONE_HALF_POLYNOMIAL.toPlainText() + + assertThat(result).isEqualTo("3/2") + } + + @Test + fun testToPlainText_negativeOneAndOneHalf_returnsMinusThreeHalvesString() { + val result = NEGATIVE_ONE_AND_ONE_HALF_POLYNOMIAL.toPlainText() + + assertThat(result).isEqualTo("-3/2") + } + + @Test + fun testToPlainText_pi_returnsPiString() { + val result = PI_POLYNOMIAL.toPlainText() + + assertThat(result).isEqualTo("3.1415") + } + + @Test + fun testToPlainText_negativePi_returnsMinusPiString() { + val result = NEGATIVE_PI_POLYNOMIAL.toPlainText() + + assertThat(result).isEqualTo("-3.1415") + } + + @Test + fun testToPlainText_2x_returns2XString() { + val result = TWO_X_POLYNOMIAL.toPlainText() + + assertThat(result).isEqualTo("2x") + } + + @Test + fun testToPlainText_1x_returnsXString() { + val result = ONE_X_POLYNOMIAL.toPlainText() + + assertThat(result).isEqualTo("x") + } + + @Test + fun testToPlainText_negativeX_returnsMinusXString() { + val result = NEGATIVE_ONE_X_POLYNOMIAL.toPlainText() + + assertThat(result).isEqualTo("-x") + } + + @Test + fun testToPlainText_oneAndX_returnsOnePlusXString() { + val result = ONE_PLUS_X_POLYNOMIAL.toPlainText() + + assertThat(result).isEqualTo("1 + x") + } + + @Test + fun testToPlainText_oneAndNegativeX_returnsOneMinusXString() { + val oneMinusXPolynomial = createPolynomial( + createTerm(coefficient = ONE_REAL), + createTerm(coefficient = -ONE_REAL, createVariable(name = "x", power = 1)) + ) + + val result = oneMinusXPolynomial.toPlainText() + + assertThat(result).isEqualTo("1 - x") + } + + @Test + fun testToPlainText_oneAndOneHalfXAndY_returnsThreeHalvesXPlusYString() { + val oneMinusXPolynomial = createPolynomial( + createTerm(coefficient = ONE_AND_ONE_HALF_REAL, createVariable(name = "x", power = 1)), + createTerm(coefficient = ONE_REAL, createVariable(name = "y", power = 1)) + ) + + val result = oneMinusXPolynomial.toPlainText() + + assertThat(result).isEqualTo("(3/2)x + y") + } + + @Test + fun testToPlainText_oneAndXAndXSquared_returnsOnePlusXPlusXSquaredString() { + val oneMinusXPolynomial = createPolynomial( + createTerm(coefficient = ONE_REAL), + createTerm(coefficient = ONE_REAL, createVariable(name = "x", power = 1)), + createTerm(coefficient = ONE_REAL, createVariable(name = "x", power = 2)) + ) + + val result = oneMinusXPolynomial.toPlainText() + + assertThat(result).isEqualTo("1 + x + x^2") + } + + @Test + fun testToPlainText_xSquaredAndXAndOne_returnsXSquaredPlusXPlusOneString() { + val oneMinusXPolynomial = createPolynomial( + createTerm(coefficient = ONE_REAL, createVariable(name = "x", power = 2)), + createTerm(coefficient = ONE_REAL, createVariable(name = "x", power = 1)), + createTerm(coefficient = ONE_REAL) + ) + + val result = oneMinusXPolynomial.toPlainText() + + // Compared with the test above, this shows that term order matters for string conversion. + assertThat(result).isEqualTo("x^2 + x + 1") + } + + @Test + fun testToPlainText_xSquaredYCubedAndOne_returnsXSquaredYCubedPlusOneString() { + val oneMinusXPolynomial = createPolynomial( + createTerm(coefficient = ONE_REAL, createVariable(name = "x", power = 2)), + createTerm(coefficient = ONE_REAL, createVariable(name = "y", power = 3)), + createTerm(coefficient = ONE_REAL) + ) + + val result = oneMinusXPolynomial.toPlainText() + + assertThat(result).isEqualTo("x^2 + y^3 + 1") + } +} + +private fun createVariable(name: String, power: Int) = Variable.newBuilder().apply { + this.name = name + this.power = power +}.build() + +private fun createTerm(coefficient: Real, vararg variables: Variable) = Term.newBuilder().apply { + this.coefficient = coefficient + addAllVariable(variables.toList()) +}.build() + +private fun createPolynomial(vararg terms: Term) = Polynomial.newBuilder().apply { + addAllTerm(terms.toList()) +}.build() diff --git a/utility/src/test/java/org/oppia/android/util/math/RatioExtensionsTest.kt b/utility/src/test/java/org/oppia/android/util/math/RatioExtensionsTest.kt index ca380220b0b..2b9bea5df06 100644 --- a/utility/src/test/java/org/oppia/android/util/math/RatioExtensionsTest.kt +++ b/utility/src/test/java/org/oppia/android/util/math/RatioExtensionsTest.kt @@ -7,7 +7,9 @@ import org.junit.runner.RunWith import org.oppia.android.app.model.RatioExpression import org.robolectric.annotation.LooperMode -/** Tests for [RatioExtensions]. */ +/** Tests for [RatioExpression] extensions. */ +// FunctionName: test names are conventionally named with underscores. +@Suppress("FunctionName") @RunWith(AndroidJUnit4::class) @LooperMode(LooperMode.Mode.PAUSED) class RatioExtensionsTest { 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 new file mode 100644 index 00000000000..6efbac11ed0 --- /dev/null +++ b/utility/src/test/java/org/oppia/android/util/math/RealExtensionsTest.kt @@ -0,0 +1,414 @@ +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.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) +@LooperMode(LooperMode.Mode.PAUSED) +class RealExtensionsTest { + private companion object { + private const val PI = 3.1415 + + private val ONE_HALF_FRACTION = Fraction.newBuilder().apply { + numerator = 1 + denominator = 2 + }.build() + + private val ONE_AND_ONE_HALF_FRACTION = Fraction.newBuilder().apply { + numerator = 1 + denominator = 2 + wholeNumber = 1 + }.build() + + private val ZERO_REAL = createIntegerReal(0) + private val TWO_REAL = createIntegerReal(2) + private val NEGATIVE_TWO_REAL = createIntegerReal(-2) + + private val ONE_HALF_REAL = createRationalReal(ONE_HALF_FRACTION) + private val NEGATIVE_ONE_HALF_REAL = createRationalReal(-ONE_HALF_FRACTION) + private val ONE_AND_ONE_HALF_REAL = createRationalReal(ONE_AND_ONE_HALF_FRACTION) + private val NEGATIVE_ONE_AND_ONE_HALF_REAL = createRationalReal(-ONE_AND_ONE_HALF_FRACTION) + + private val PI_REAL = createIrrationalReal(PI) + private val NEGATIVE_PI_REAL = createIrrationalReal(-PI) + } + + @Test + fun testIsRational_default_returnsFalse() { + val defaultReal = Real.getDefaultInstance() + + val result = defaultReal.isRational() + + assertThat(result).isFalse() + } + + @Test + fun testIsRational_twoInteger_returnsFalse() { + val result = TWO_REAL.isRational() + + assertThat(result).isFalse() + } + + @Test + fun testIsRational_oneHalfFraction_returnsTrue() { + val result = ONE_HALF_REAL.isRational() + + assertThat(result).isTrue() + } + + @Test + fun testIsRational_piIrrational_returnsFalse() { + val result = PI_REAL.isRational() + + assertThat(result).isFalse() + } + + @Test + fun testIsNegative_default_throwsException() { + val defaultReal = Real.getDefaultInstance() + + val exception = assertThrows(IllegalStateException::class) { defaultReal.isNegative() } + + assertThat(exception).hasMessageThat().contains("Invalid real") + } + + @Test + fun testIsNegative_twoInteger_returnsFalse() { + val result = TWO_REAL.isNegative() + + assertThat(result).isFalse() + } + + @Test + fun testIsNegative_negativeTwoInteger_returnsTrue() { + val result = NEGATIVE_TWO_REAL.isNegative() + + assertThat(result).isTrue() + } + + @Test + fun testIsNegative_oneHalfFraction_returnsFalse() { + val result = ONE_HALF_REAL.isNegative() + + assertThat(result).isFalse() + } + + @Test + fun testIsNegative_negativeOneHalfFraction_returnsTrue() { + val result = NEGATIVE_ONE_HALF_REAL.isNegative() + + assertThat(result).isTrue() + } + + @Test + fun testIsNegative_piIrrational_returnsFalse() { + val result = PI_REAL.isNegative() + + assertThat(result).isFalse() + } + + @Test + fun testIsNegative_negativePiIrrational_returnsTrue() { + val result = NEGATIVE_PI_REAL.isNegative() + + assertThat(result).isTrue() + } + + @Test + fun testToDouble_default_returnsZeroDouble() { + val defaultReal = Real.getDefaultInstance() + + val exception = assertThrows(IllegalStateException::class) { defaultReal.toDouble() } + + assertThat(exception).hasMessageThat().contains("Invalid real") + } + + @Test + fun testToDouble_twoInteger_returnsTwoDouble() { + val result = TWO_REAL.toDouble() + + assertThat(result).isWithin(1e-5).of(2.0) + } + + @Test + fun testToDouble_negativeTwoInteger_returnsNegativeTwoDouble() { + val result = NEGATIVE_TWO_REAL.toDouble() + + assertThat(result).isWithin(1e-5).of(-2.0) + } + + @Test + fun testToDouble_oneHalfFraction_returnsPointFive() { + val result = ONE_HALF_REAL.toDouble() + + assertThat(result).isWithin(1e-5).of(0.5) + } + + @Test + fun testToDouble_negativeOneHalfFraction_returnsNegativePointFive() { + val result = NEGATIVE_ONE_HALF_REAL.toDouble() + + assertThat(result).isWithin(1e-5).of(-0.5) + } + + @Test + fun testToDouble_piIrrational_returnsPi() { + val result = PI_REAL.toDouble() + + assertThat(result).isWithin(1e-5).of(PI) + } + + @Test + fun testToDouble_negativePiIrrational_returnsNegativePi() { + val result = NEGATIVE_PI_REAL.toDouble() + + assertThat(result).isWithin(1e-5).of(-PI) + } + + @Test + fun testToPlainText_default_returnsEmptyString() { + val defaultReal = Real.getDefaultInstance() + + val result = defaultReal.toPlainText() + + assertThat(result).isEmpty() + } + + @Test + fun testToPlainText_twoInteger_returnsTwoString() { + val result = TWO_REAL.toPlainText() + + assertThat(result).isEqualTo("2") + } + + @Test + fun testToPlainText_negativeTwoInteger_returnsMinusTwoString() { + val result = NEGATIVE_TWO_REAL.toPlainText() + + assertThat(result).isEqualTo("-2") + } + + @Test + fun testToPlainText_oneHalfFraction_returnsOneHalfString() { + val result = ONE_HALF_REAL.toPlainText() + + assertThat(result).isEqualTo("1/2") + } + + @Test + fun testToPlainText_negativeOneHalfFraction_returnsMinusOneHalfString() { + val result = NEGATIVE_ONE_HALF_REAL.toPlainText() + + assertThat(result).isEqualTo("-1/2") + } + + @Test + fun testToPlainText_oneAndOneHalfFraction_returnsThreeHalvesString() { + val result = ONE_AND_ONE_HALF_REAL.toPlainText() + + assertThat(result).isEqualTo("3/2") + } + + @Test + fun testToPlainText_negativeOneAndOneHalfFraction_returnsMinusThreeHalvesString() { + val result = NEGATIVE_ONE_AND_ONE_HALF_REAL.toPlainText() + + assertThat(result).isEqualTo("-3/2") + } + + @Test + fun testToPlainText_piIrrational_returnsPiString() { + val result = PI_REAL.toPlainText() + + assertThat(result).isEqualTo("3.1415") + } + + @Test + fun testToPlainText_negativePiIrrational_returnsMinusPiString() { + val result = NEGATIVE_PI_REAL.toPlainText() + + assertThat(result).isEqualTo("-3.1415") + } + + @Test + fun testIsApproximatelyEqualTo_zeroIntegerAndZero_returnsTrue() { + val result = ZERO_REAL.isApproximatelyEqualTo(0.0) + + assertThat(result).isTrue() + } + + @Test + fun testIsApproximatelyEqualTo_zeroAndOne_returnsFalse() { + val result = ZERO_REAL.isApproximatelyEqualTo(1.0) + + assertThat(result).isFalse() + } + + @Test + fun testIsApproximatelyEqualTo_twoAndTwoWithinThreshold_returnsTrue() { + val result = TWO_REAL.isApproximatelyEqualTo(2.0 + DOUBLE_EQUALITY_EPSILON / 2.0) + + assertThat(result).isTrue() + } + + @Test + fun testIsApproximatelyEqualTo_twoAndTwoOutsideThreshold_returnsFalse() { + val result = TWO_REAL.isApproximatelyEqualTo(2.0 + DOUBLE_EQUALITY_EPSILON * 2.0) + + assertThat(result).isFalse() + } + + @Test + fun testIsApproximatelyEqualTo_oneHalfAndOne_returnsFalse() { + val result = ONE_HALF_REAL.isApproximatelyEqualTo(1.0) + + assertThat(result).isFalse() + } + + @Test + fun testIsApproximatelyEqualTo_oneHalfAndPointFive_returnsTrue() { + val result = ONE_HALF_REAL.isApproximatelyEqualTo(0.5) + + assertThat(result).isTrue() + } + + @Test + fun testIsApproximatelyEqualTo_oneHalfAndPointSix_returnsFalse() { + val result = ONE_HALF_REAL.isApproximatelyEqualTo(0.6) + + assertThat(result).isFalse() + } + + @Test + fun testIsApproximatelyEqualTo_pointFiveAndPointFive_returnsTrue() { + val pointFiveReal = createIrrationalReal(0.5) + + val result = pointFiveReal.isApproximatelyEqualTo(0.5) + + assertThat(result).isTrue() + } + + @Test + fun testIsApproximatelyEqualTo_pointFiveAndPointSix_returnsFalse() { + val pointFiveReal = createIrrationalReal(0.5) + + val result = pointFiveReal.isApproximatelyEqualTo(0.6) + + assertThat(result).isFalse() + } + + @Test + fun testUnaryMinus_default_throwsException() { + val defaultReal = Real.getDefaultInstance() + + val exception = assertThrows(IllegalStateException::class) { -defaultReal } + + assertThat(exception).hasMessageThat().contains("Invalid real") + } + + @Test + fun testUnaryMinus_twoInteger_returnsNegativeTwoInteger() { + val result = -TWO_REAL + + assertThat(result).isIntegerThat().isEqualTo(-2) + } + + @Test + fun testUnaryMinus_negativeTwoInteger_returnsTwoInteger() { + val result = -NEGATIVE_TWO_REAL + + assertThat(result).isIntegerThat().isEqualTo(2) + } + + @Test + fun testUnaryMinus_twoOneHalf_returnsNegativeOneHalf() { + val result = -ONE_HALF_REAL + + assertThat(result).isRationalThat().evaluatesToDoubleThat().isWithin(1e-5).of(-0.5) + } + + @Test + fun testUnaryMinus_negativeOneHalf_returnsOneHalf() { + val result = -NEGATIVE_ONE_HALF_REAL + + assertThat(result).isRationalThat().evaluatesToDoubleThat().isWithin(1e-5).of(0.5) + } + + @Test + fun testUnaryMinus_pi_returnsNegativePi() { + val result = -PI_REAL + + assertThat(result).isIrrationalThat().isWithin(1e-5).of(-PI) + } + + @Test + fun testUnaryMinus_negativePi_returnsPi() { + val result = -NEGATIVE_PI_REAL + + assertThat(result).isIrrationalThat().isWithin(1e-5).of(PI) + } + + @Test + fun testAbs_twoInteger_returnsTwoInteger() { + val result = abs(TWO_REAL) + + assertThat(result).isIntegerThat().isEqualTo(2) + } + + @Test + fun testAbs_negativeTwoInteger_returnsTwoInteger() { + val result = abs(NEGATIVE_TWO_REAL) + + assertThat(result).isIntegerThat().isEqualTo(2) + } + + @Test + fun testAbs_oneHalf_returnsOneHalf() { + val result = abs(ONE_HALF_REAL) + + assertThat(result).isRationalThat().evaluatesToDoubleThat().isWithin(1e-5).of(0.5) + } + + @Test + fun testAbs_negativeOneHalf_returnsOneHalf() { + val result = abs(NEGATIVE_ONE_HALF_REAL) + + assertThat(result).isRationalThat().evaluatesToDoubleThat().isWithin(1e-5).of(0.5) + } + + @Test + fun testAbs_pi_returnsPi() { + val result = abs(PI_REAL) + + assertThat(result).isIrrationalThat().isWithin(1e-5).of(PI) + } + + @Test + fun testAbs_negativePi_returnsPi() { + val result = abs(NEGATIVE_PI_REAL) + + assertThat(result).isIrrationalThat().isWithin(1e-5).of(PI) + } +} + +private fun createIntegerReal(value: Int) = Real.newBuilder().apply { + integer = value +}.build() + +private fun createRationalReal(value: Fraction) = Real.newBuilder().apply { + rational = value +}.build() + +private fun createIrrationalReal(value: Double) = Real.newBuilder().apply { + irrational = value +}.build()