From 7e758306f983fec90c401aa2ebbb8750337a90b3 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Fri, 25 Mar 2022 20:42:12 -0700 Subject: [PATCH] Fix part of #4044: Add protos & testing library for polynomials (#4050) ## Explanation Fix part of #4044 Originally copied from #2173 when it was in proof-of-concept form This PR introduces the protos and test subject to represent polynomials. For the purposes of upcoming classifier work, a polynomial is defined as a sum of terms where each term has a coefficient and zero or more variables with positive integer powers. This representation provides support for all real polynomials, and keeps a nicely structured representation for coefficients that actually allows for retaining integers and rational values (this will become more evident when math expression -> polynomial conversion is added). Furthermore, various extensions files are added to help support some of the fundamental operations (mainly needed by test subjects). These operations are being thoroughly tested, and will be augmented with a lot more functionality in upcoming PRs. The new polynomial test subject doesn't have new tests to keep this PR focused on production code, and since it's relatively easy to verify as correct via review. #4100 is tracking adding tests. This structure will be utilized in a later PR in IsEquivalentTo classifier implementations for each numeric expression, algebraic expression, and math equation interaction. For specific details on this classifier, see [the PRD](https://docs.google.com/document/d/1x2vcSjocJUXkwwlce5Gjjq_Z83ykVIn2Fp0BcnrOrTg/edit#heading=h.1q3av9yssyi5). Polynomials are the ideal structure for verifying equivalence since they fully collapse expressions regardless of associativity, commutativity, and distributivity (to some extent--caveats will be noted in the future PR that converts expressions to this new polynomial structure). Slightly separate from polynomials, ``FloatExtensions`` was updated to include better epsilon values for comparing both floats and doubles (and a separate one is used for doubles). These are loosely based on the computed machine epsilon value listed here: https://en.wikipedia.org/wiki/Machine_epsilon, but it uses a higher order of magnitude and rounding for a bit more "wiggle room" for equality (so that it's not essentially replicating '==' for values close to 1). ## Essential Checklist - [x] The PR title and explanation each start with "Fix #bugnum: " (If this PR fixes part of an issue, prefix the title with "Fix part of #bugnum: ...".) - [x] Any changes to [scripts/assets](https://github.com/oppia/oppia-android/tree/develop/scripts/assets) files have their rationale included in the PR explanation. - [x] The PR follows the [style guide](https://github.com/oppia/oppia-android/wiki/Coding-style-guide). - [x] The PR does not contain any unnecessary code changes from Android Studio ([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#undo-unnecessary-changes)). - [x] The PR is made from a branch that's **not** called "develop" and is up-to-date with "develop". - [x] The PR is **assigned** to the appropriate reviewers ([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#clarification-regarding-assignees-and-reviewers-section)). ## For UI-specific PRs only N/A -- proto & testing library only change, and the proto changes are only additions. No UI functionality is yet affected by these changes. Commit history: * Copy proto-based changes from #2173. * Introduce math.proto & refactor math extensions. Much of this is copied from #2173. * Migrate tests & remove unneeded prefix. * Add needed newline. * Some needed Fraction changes. * Introduce math expression + equation protos. Also adds testing libraries for both + fractions & reals (new structure). Most of this is copied from #2173. * Add protos + testing lib for commutative exprs. * Add protos & test libs for polynomials. * Lint fix. * Lint fixes. * Fix broken test post-refactor. * Post-merge fix. * Add regex check, docs, and resolve TODOs. This also changes regex handling in the check to be more generic for better flexibility when matching files. * Lint fix. * Fix failing static checks. * Fix broken CI checks. Adds missing KDocs, test file exemptions, and fixes the Gradle build. * Lint fixes. * Add docs & exempted tests. * Remove blank line. * Add docs + tests. * Address reviewer comments + other stuff. This also fixes a typo and incorrectly ordered exemptions list I noticed during development of downstream PRs. * Move StringExtensions & fraction parsing. This splits fraction parsing between UI & utility components. * Address reviewer comments. * Alphabetize test exemptions. * Add missing KDocs. * Remove the ComparableOperationList wrapper. * Use more intentional epsilons for float comparing. * Remove failing test. * Fix broken build. * Fix broken build post-merge. * Post-merge fix. * More post-merge fixes. --- .../util/InteractionObjectExtensions.kt | 10 - ...icInputEqualsRuleClassifierProviderTest.kt | 25 +- model/src/main/proto/math.proto | 26 + scripts/assets/test_file_exemptions.textproto | 3 +- .../oppia/android/testing/math/BUILD.bazel | 18 + .../android/testing/math/PolynomialSubject.kt | 156 ++++++ .../org/oppia/android/util/math/BUILD.bazel | 2 + .../android/util/math/FloatExtensions.kt | 37 +- .../android/util/math/FractionExtensions.kt | 62 +- .../android/util/math/PolynomialExtensions.kt | 59 ++ .../android/util/math/RatioExtensions.kt | 5 + .../oppia/android/util/math/RealExtensions.kt | 88 +++ .../org/oppia/android/util/math/BUILD.bazel | 73 +++ .../android/util/math/FloatExtensionsTest.kt | 165 ++++++ .../util/math/FractionExtensionsTest.kt | 530 ++++++++++++++++++ .../util/math/PolynomialExtensionsTest.kt | 390 +++++++++++++ .../android/util/math/RatioExtensionsTest.kt | 4 +- .../android/util/math/RealExtensionsTest.kt | 414 ++++++++++++++ 18 files changed, 2027 insertions(+), 40 deletions(-) create mode 100644 testing/src/main/java/org/oppia/android/testing/math/PolynomialSubject.kt create mode 100644 utility/src/main/java/org/oppia/android/util/math/PolynomialExtensions.kt create mode 100644 utility/src/main/java/org/oppia/android/util/math/RealExtensions.kt create mode 100644 utility/src/test/java/org/oppia/android/util/math/FloatExtensionsTest.kt create mode 100644 utility/src/test/java/org/oppia/android/util/math/FractionExtensionsTest.kt create mode 100644 utility/src/test/java/org/oppia/android/util/math/PolynomialExtensionsTest.kt create mode 100644 utility/src/test/java/org/oppia/android/util/math/RealExtensionsTest.kt 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()