From 81124219608eb10833eedc0e91e9506b1a0446c0 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Sat, 26 Mar 2022 14:07:36 -0700 Subject: [PATCH] Fix part of #4044: Add support for computing polynomials from math expressions (#4056) ## Explanation Fix part of #4044 Originally copied from #2173 when it was in proof-of-concept form Introduces support for computing polynomials from algebraic expressions (represented using the ``MathExpression`` proto). At a high-level, most algebraic expression representations of polynomials should be convertible to the proto ``Polynomial`` structure by the converter, but there's a main limitation: cases when polynomial factoring is needed (such as a root power) isn't currently supported, though single term polynomials can be rooted if they result in a valid root. Beyond that, any case where a non-integer or negative power occurs (such as variables in denominators or remainders during polynomial division) will result in a failure. Otherwise, full polynomial arithmetic is supported including: negation, addition, subtraction, multiplication, division (via long division), and exponentiation. Like past PRs, the converter attempts to retain maximum precision by attempting to keep integers and fraction representation as long as possible. There are also a lot of edge cases which is why the tests are so long. Some general notes: - PolynomialExtensionsTest was exempted to utilize parameterization for a few test cases - ``RealExtensions`` was updated to return a nullable result for square roots & powers to replace exceptional cases with null results so that they can be ignored similar to other failure cases - A new reverse sort function was added for iterables in ComparatorExtensions (to ensure polynomial sorting is correct) ## 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 -- This does not have user-facing changes, though it will be used as the base logic for future domain classifiers that will be added. 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. * Add math tokenizer + utility & tests. This is mostly copied from #2173. * Add math expression/equation parsing support. This includes full error detection, and specific test suites for each parsing case. Much revisement is needed in the tests, and some additional issues may yet need to be fixed in the parser and/or error-detection logic. This is copied from #2173 with revisement & reduction since it's part of a multi-PR split. * Add exp evaluation & LaTeX conversion support. This is mainly copied from #2173. * Remove unneeded comment lines. * Add expr->comparable operation list conv support. This enables the ability to compare two expressions such that operation associativity and commutativity is considered (i.e. items can be rearranged using those rules without breaking expression equality). This is mostly copied from #2173. * Add support for expression->polynomial conversion. This is mostly copied from #2173. * Fix broken test post-refactor. * Add reasonable import for abs(). * Fix equals errors for equations. This splits the current error into two: one for no equals being present (& adds it), and one for too many equals. This better supports the UI errors that need to be displayed to the user in these cases. * Fix rational^rational powers. This generifies the sqrt algorithm to support n-roots so that rationals raised by rationals can actually work & retain the rational (in cases where the root can actually be taken). * Ensure rational terms reduce to ints. This ensures cases like 8/1 become just '8' coefficients rather than staying as an irrational (for simplification). * 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. * Add parameterized test runner. This commit introduces a new parameterized test runner that allows proper combinations of parameterized & non-parameterized tests in the same suite, and in a way that should work on both Robolectric & Espresso (though the latter isn't currently verified). Further, this commit also introduces a TokenSubject that will be used more explicitly by the follow-up commit for verifying MathTokenizer. * Add & update tests. This introduces tests for PeekableIterator, and reimplements all of MathTokenizer's tests to be more structured, thorough, and a bit more maintainable (i.e. by leveraging parameterized tests). * Lint fixes. This includes a fix for 'fun interface' not working with ktlint (see #4122). * Remove internals that broke things. * Add regex exemptions. * Post-merge fix. * Add error tests. These tests are more or less comprehensive based on existing tests and some new ideas. All errors are now covered by MathExpressionParserTest. Error ordering is not tested. A new Truth subject was added for easier testing, as well (for MathParsingError). * Finish algebraic equation tests. * Reimplement numeric expression tests. This is almost a full replacement. The new tests are more structured and intentional to cover key high-level concepts. More tests may be added in the future, but this is a sensible initial test offering. This also updates MathExpressionSubject to support checking specifically for implicit multiplication (and it's now required for such cases since explicit is otherwise assumed). * Finish algebraic expression tests. These largely rely on numeric expression tests (since they focus on verifying specific variable scenarios). * Add missing tests for better coverage. * Add KDocs & test exemptions. * Lint fixes. * Remove temporary TODOs. * Add tests. * Split StringToFractionParser. This is a temporary change that will be finished upstream (since there's an earlier PR that's a better fit for this change). * 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. * Fix typo & add regex check. The new regex check makes it so that all parameterized testing can be more easily tracked by the Android TL. * Add missing KDocs. * Post-merge cleanups. Also, fix text file exemption ordering. * Add new test for negation with math symbol. * Post-merge fixes. * Add KDocs. Also, add new regex exemption for new parameterized tests in this branch. * Refactor & simplify real ext impl. Also, fix/clarify some KDocs. * Lint fixes. * Simplify operation list converter a lot. This inlines three recursive operations to be done during the actual computation to simplify the overall converter complexity (and to make determining the test matrix easier). * Prepare for new tests. * Remove the ComparableOperationList wrapper. * Change parameterized method delimiter. * Use utility directly in test. * Post-merge fixes. This adjusts for the removal of ComparableOperationList (i.e. no wrapper proto). * Add first round of tests. This includes fixes to the converter itself as it wasn't distributing both product inversions and negation correctly in several cases. Tests should now be covering these cases. * Finish initial test suite. Still needs to be cleaned up, but after converter refactoring attempts. * Simplify operation sorting comparators. * Remove old tests. * Add remaining missing tests. * KDocs & test exemption. * Renames & lint fixes. * Post-merge fixes. * Add tests. * KDocs + exemptions. Also, clean up polynomial sorting. * Lint fixes. * Use more intentional epsilons for float comparing. * Treat en-dash as a subtraction symbol. * Add explicit platform selection for paramerized. This adds explicit platform selection support rather than it being automatic based on deps. While less flexible for shared tests, this offers better control for tests that don't want to to use Robolectric for local tests. This also adds a JUnit-only test runner, and updates MathTokenizerTest to use it (which led to an almost 40x decrease in runtime). * Exemption fixes. Also, fix name for the AndroidJUnit4 runner. * Remove failing test. * Fix unary expression precedence. Also, use ParameterizedJunitTestRunner for MathExpressionParserTest. * Fixes & add more test cases. * Post-merge fixes & test changes. Also, update RealExtensionsTest to use the faster JUnit runner. * Use utility directly in LaTeX tests. * Post-merge fixes. Also, update ExpressionToComparableOperationConverterTest to use the fast JUnit-only runner. * Post-merge fixes. Also, update PolynomialExtensionsTest to use fast JUnit-only runner. * Address reviewer comment. Clarifies the documentation in the test runner around parameter injection. * Fix broken build. * Fix broken build post-merge. * Post-merge fix. * More post-merge fixes. * Fix TODO comment. --- .../file_content_validation_checks.textproto | 1 + .../org/oppia/android/util/math/BUILD.bazel | 14 + .../android/util/math/ComparatorExtensions.kt | 29 +- .../math/ExpressionToPolynomialConverter.kt | 167 ++ .../android/util/math/FloatExtensions.kt | 4 +- .../android/util/math/FractionExtensions.kt | 6 +- .../util/math/MathExpressionExtensions.kt | 9 + .../android/util/math/PolynomialExtensions.kt | 434 +++ .../oppia/android/util/math/RealExtensions.kt | 122 +- .../org/oppia/android/util/math/BUILD.bazel | 22 +- .../util/math/ComparatorExtensionsTest.kt | 174 ++ .../ExpressionToPolynomialConverterTest.kt | 2326 +++++++++++++++ .../util/math/MathExpressionExtensionsTest.kt | 41 +- .../util/math/PolynomialExtensionsTest.kt | 2656 ++++++++++++++++- .../android/util/math/RealExtensionsTest.kt | 401 ++- 15 files changed, 6256 insertions(+), 150 deletions(-) create mode 100644 utility/src/main/java/org/oppia/android/util/math/ExpressionToPolynomialConverter.kt create mode 100644 utility/src/test/java/org/oppia/android/util/math/ExpressionToPolynomialConverterTest.kt diff --git a/scripts/assets/file_content_validation_checks.textproto b/scripts/assets/file_content_validation_checks.textproto index bcae6e30b40..0c9432585fb 100644 --- a/scripts/assets/file_content_validation_checks.textproto +++ b/scripts/assets/file_content_validation_checks.textproto @@ -290,6 +290,7 @@ file_content_checks { exempted_file_name: "utility/src/test/java/org/oppia/android/util/math/ExpressionToComparableOperationConverterTest.kt" exempted_file_name: "utility/src/test/java/org/oppia/android/util/math/MathExpressionParserTest.kt" exempted_file_name: "utility/src/test/java/org/oppia/android/util/math/MathTokenizerTest.kt" + exempted_file_name: "utility/src/test/java/org/oppia/android/util/math/PolynomialExtensionsTest.kt" exempted_file_name: "utility/src/test/java/org/oppia/android/util/math/RealExtensionsTest.kt" exempted_file_patterns: "testing/src/main/java/org/oppia/android/testing/junit/.+?\\.kt" } diff --git a/utility/src/main/java/org/oppia/android/util/math/BUILD.bazel b/utility/src/main/java/org/oppia/android/util/math/BUILD.bazel index ea0bf78d29e..e0d1169a095 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 @@ -124,6 +124,7 @@ kt_android_library( deps = [ ":expression_to_comparable_operation_converter", ":expression_to_latex_converter", + ":expression_to_polynomial_converter", ":numeric_expression_evaluator", "//model/src/main/proto:math_java_proto_lite", ], @@ -135,6 +136,7 @@ kt_android_library( "PolynomialExtensions.kt", ], deps = [ + ":comparator_extensions", ":real_extensions", "//model/src/main/proto:math_java_proto_lite", ], @@ -189,6 +191,18 @@ kt_android_library( ], ) +kt_android_library( + name = "expression_to_polynomial_converter", + srcs = [ + "ExpressionToPolynomialConverter.kt", + ], + deps = [ + ":polynomial_extensions", + ":real_extensions", + "//model/src/main/proto:math_java_proto_lite", + ], +) + kt_android_library( name = "numeric_expression_evaluator", srcs = [ diff --git a/utility/src/main/java/org/oppia/android/util/math/ComparatorExtensions.kt b/utility/src/main/java/org/oppia/android/util/math/ComparatorExtensions.kt index e853a03bfb9..c952e56686e 100644 --- a/utility/src/main/java/org/oppia/android/util/math/ComparatorExtensions.kt +++ b/utility/src/main/java/org/oppia/android/util/math/ComparatorExtensions.kt @@ -14,11 +14,34 @@ import com.google.protobuf.MessageLite * all of their items are equal per this [Comparator], including duplicates. */ fun Comparator.compareIterables(first: Iterable, second: Iterable): Int { + return compareIterablesInternal(first, second, reverseItemSort = false) +} + +/** + * Compares two [Iterable]s based on an item [Comparator] and returns the result, in much the same + * way as [compareIterables] except this reverses the result (that is, [first] will be considered + * less than [second] if it's larger). + * + * This should be used in place of a standard 'reversed()' since it will properly reverse (both the + * internal sorting and the comparison needs to be reversed in order for the reversal to be + * correct). + */ +fun Comparator.compareIterablesReversed(first: Iterable, second: Iterable): Int { + // Note that first & second are reversed here. + return compareIterablesInternal(second, first, reverseItemSort = true) +} + +private fun Comparator.compareIterablesInternal( + first: Iterable, + second: Iterable, + reverseItemSort: Boolean +): Int { // Reference: https://stackoverflow.com/a/30107086. - val firstIter = first.sortedWith(this).iterator() - val secondIter = second.sortedWith(this).iterator() + val itemComparator = if (reverseItemSort) reversed() else this + val firstIter = first.sortedWith(itemComparator).iterator() + val secondIter = second.sortedWith(itemComparator).iterator() while (firstIter.hasNext() && secondIter.hasNext()) { - val comparison = this.compare(firstIter.next(), secondIter.next()) + val comparison = this.compare(firstIter.next(), secondIter.next()).coerceIn(-1..1) if (comparison != 0) return comparison // Found a different item. } diff --git a/utility/src/main/java/org/oppia/android/util/math/ExpressionToPolynomialConverter.kt b/utility/src/main/java/org/oppia/android/util/math/ExpressionToPolynomialConverter.kt new file mode 100644 index 00000000000..3d487ff95b8 --- /dev/null +++ b/utility/src/main/java/org/oppia/android/util/math/ExpressionToPolynomialConverter.kt @@ -0,0 +1,167 @@ +package org.oppia.android.util.math + +import org.oppia.android.app.model.MathBinaryOperation +import org.oppia.android.app.model.MathBinaryOperation.Operator.ADD +import org.oppia.android.app.model.MathBinaryOperation.Operator.DIVIDE +import org.oppia.android.app.model.MathBinaryOperation.Operator.EXPONENTIATE +import org.oppia.android.app.model.MathBinaryOperation.Operator.MULTIPLY +import org.oppia.android.app.model.MathBinaryOperation.Operator.SUBTRACT +import org.oppia.android.app.model.MathExpression +import org.oppia.android.app.model.MathExpression.ExpressionTypeCase.BINARY_OPERATION +import org.oppia.android.app.model.MathExpression.ExpressionTypeCase.CONSTANT +import org.oppia.android.app.model.MathExpression.ExpressionTypeCase.EXPRESSIONTYPE_NOT_SET +import org.oppia.android.app.model.MathExpression.ExpressionTypeCase.FUNCTION_CALL +import org.oppia.android.app.model.MathExpression.ExpressionTypeCase.GROUP +import org.oppia.android.app.model.MathExpression.ExpressionTypeCase.UNARY_OPERATION +import org.oppia.android.app.model.MathExpression.ExpressionTypeCase.VARIABLE +import org.oppia.android.app.model.MathFunctionCall.FunctionType +import org.oppia.android.app.model.MathFunctionCall.FunctionType.FUNCTION_UNSPECIFIED +import org.oppia.android.app.model.MathFunctionCall.FunctionType.SQUARE_ROOT +import org.oppia.android.app.model.MathUnaryOperation +import org.oppia.android.app.model.MathUnaryOperation.Operator.NEGATE +import org.oppia.android.app.model.MathUnaryOperation.Operator.POSITIVE +import org.oppia.android.app.model.Polynomial +import org.oppia.android.app.model.Real +import org.oppia.android.app.model.MathBinaryOperation.Operator as BinaryOperator +import org.oppia.android.app.model.MathUnaryOperation.Operator as UnaryOperator + +/** + * Converter from [MathExpression] to [Polynomial]. + * + * See the separate protos for specifics on structure, and [reduceToPolynomial] for the actual + * conversion function. + */ +class ExpressionToPolynomialConverter private constructor() { + companion object { + /** + * Returns a new [Polynomial] that represents this [MathExpression], or null if it's not a valid + * polynomial. + * + * Polynomials are defined as a list of terms where each term has a coefficient and zero or more + * variables. There are a number of specific constraints that this function guarantees for all + * returned polynomials: + * - Terms will never have duplicate variable expressions (e.g. there will never be a returned + * polynomial with multiple 'x' terms, but there can be an 'x' and 'x^2' term). This is + * because effort is taken to combine like terms. + * - Terms are always sorted by lexicography of the variable names and variable powers which + * allows for comparison that operates independently of commutativity, associativity, and + * distributivity. + * - There will only ever be at most one constant term in the polynomial. + * - There will always be at least 1 term (even if it's the constant zero). + * - The polynomial will be mathematically equivalent to the original expression. + * - Coefficients will be kept to the highest possible precision (i.e. integers and fractions + * will be preferred over irrationals unless a rounding error occurs). + * - Most polynomial operations will be computed, including unary negation, addition, + * subtraction, multiplication (both implicit and explicit), division, and powers. + * + * Note that this will return null if a polynomial cannot be computed, such as in the cases: + * - The expression represents a division where the result has a remainder polynomial. + * - The expression results in a variable with a negative power or a division by an expression. + * - The expression results in a non-integer power (which includes a current limitation for + * expressions like 'sqrt(x)^2'; these cannot pass because internally the method cannot + * represent 'x^1/2'). + * - The expression results in a power variable (which can never represent a polynomial). + * - The expression is invalid (e.g. a default proto instance). + * + * This function is only expected to be used in conjunction with algebraic expressions. It's + * suggested to use evaluation when comparing for equivalence among numeric expressions as it + * should yield the same result and be more performant. + * + * The tests for this method provide very thorough and broad examples of different cases that + * this function supports. In particular, the equality tests are useful to see what sorts of + * expressions can be considered the same per [Polynomial] representation. + */ + fun MathExpression.reduceToPolynomial(): Polynomial? { + return replaceSquareRoots() + .reduceToPolynomialAux() + ?.removeUnnecessaryVariables() + ?.simplifyRationals() + ?.sort() + } + + private fun MathExpression.replaceSquareRoots(): MathExpression { + return when (expressionTypeCase) { + BINARY_OPERATION -> toBuilder().apply { + binaryOperation = binaryOperation.toBuilder().apply { + leftOperand = binaryOperation.leftOperand.replaceSquareRoots() + rightOperand = binaryOperation.rightOperand.replaceSquareRoots() + }.build() + }.build() + UNARY_OPERATION -> toBuilder().apply { + unaryOperation = unaryOperation.toBuilder().apply { + operand = unaryOperation.operand.replaceSquareRoots() + }.build() + }.build() + FUNCTION_CALL -> when (functionCall.functionType) { + SQUARE_ROOT -> toBuilder().apply { + // Replace the square root function call with the equivalent exponentiation. That is, + // sqrt(x)=x^(1/2). + binaryOperation = MathBinaryOperation.newBuilder().apply { + operator = EXPONENTIATE + leftOperand = functionCall.argument.replaceSquareRoots() + rightOperand = MathExpression.newBuilder().apply { + constant = ONE_HALF + }.build() + }.build() + }.build() + FUNCTION_UNSPECIFIED, FunctionType.UNRECOGNIZED, null -> this + } + // This also eliminates groups from the expression. + GROUP -> group.replaceSquareRoots() + CONSTANT, VARIABLE, EXPRESSIONTYPE_NOT_SET, null -> this + } + } + + private fun MathExpression.reduceToPolynomialAux(): Polynomial? { + return when (expressionTypeCase) { + CONSTANT -> createConstantPolynomial(constant) + VARIABLE -> createSingleVariablePolynomial(variable) + BINARY_OPERATION -> binaryOperation.reduceToPolynomial() + UNARY_OPERATION -> unaryOperation.reduceToPolynomial() + // Both functions & groups should be removed ahead of polynomial reduction. + FUNCTION_CALL, GROUP, EXPRESSIONTYPE_NOT_SET, null -> null + } + } + + private fun MathBinaryOperation.reduceToPolynomial(): Polynomial? { + val leftPolynomial = leftOperand.reduceToPolynomialAux() ?: return null + val rightPolynomial = rightOperand.reduceToPolynomialAux() ?: return null + return when (operator) { + ADD -> leftPolynomial + rightPolynomial + SUBTRACT -> leftPolynomial - rightPolynomial + MULTIPLY -> leftPolynomial * rightPolynomial + DIVIDE -> leftPolynomial / rightPolynomial + EXPONENTIATE -> leftPolynomial pow rightPolynomial + BinaryOperator.OPERATOR_UNSPECIFIED, BinaryOperator.UNRECOGNIZED, null -> null + } + } + + private fun MathUnaryOperation.reduceToPolynomial(): Polynomial? { + return when (operator) { + NEGATE -> -(operand.reduceToPolynomialAux() ?: return null) + POSITIVE -> operand.reduceToPolynomialAux() // Positive unary changes nothing. + UnaryOperator.OPERATOR_UNSPECIFIED, UnaryOperator.UNRECOGNIZED, null -> null + } + } + + private fun createSingleVariablePolynomial(variableName: String): Polynomial { + return createSingleTermPolynomial( + Polynomial.Term.newBuilder().apply { + coefficient = ONE + addVariable( + Polynomial.Term.Variable.newBuilder().apply { + name = variableName + power = 1 + }.build() + ) + }.build() + ) + } + + private fun createConstantPolynomial(constant: Real): Polynomial = + createSingleTermPolynomial(Polynomial.Term.newBuilder().setCoefficient(constant).build()) + + private fun createSingleTermPolynomial(term: Polynomial.Term): Polynomial = + Polynomial.newBuilder().apply { addTerm(term) }.build() + } +} 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 9062dfe6484..3e6553df1dd 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 @@ -3,7 +3,9 @@ package org.oppia.android.util.math import kotlin.math.abs /** - * The error margin used for approximately [Float] equality checking. + * The error margin used for approximately [Float] equality checking, that is, the largest distance + * from any particular number before a new value will be considered unequal (i.e. all values between + * a float and (float-interval, float+interval) will be considered equal to the float). * * 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 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 bd0c8093ede..8a762f4515a 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 @@ -10,8 +10,10 @@ fun Fraction.hasFractionalPart(): Boolean { } /** - * Returns whether this fraction only represents a whole number. Note that for the fraction '0' this - * will return true. + * Returns whether this fraction only represents a whole number. + * + * Note that for the fraction '0' this will return true. Furthermore, this will return false for + * whole number-like improper fractions such as '3/1'. */ fun Fraction.isOnlyWholeNumber(): Boolean { return !hasFractionalPart() diff --git a/utility/src/main/java/org/oppia/android/util/math/MathExpressionExtensions.kt b/utility/src/main/java/org/oppia/android/util/math/MathExpressionExtensions.kt index 6b89e83acdb..2924dc5418e 100644 --- a/utility/src/main/java/org/oppia/android/util/math/MathExpressionExtensions.kt +++ b/utility/src/main/java/org/oppia/android/util/math/MathExpressionExtensions.kt @@ -3,9 +3,11 @@ package org.oppia.android.util.math import org.oppia.android.app.model.ComparableOperation import org.oppia.android.app.model.MathEquation import org.oppia.android.app.model.MathExpression +import org.oppia.android.app.model.Polynomial import org.oppia.android.app.model.Real import org.oppia.android.util.math.ExpressionToComparableOperationConverter.Companion.convertToComparableOperation import org.oppia.android.util.math.ExpressionToLatexConverter.Companion.convertToLatex +import org.oppia.android.util.math.ExpressionToPolynomialConverter.Companion.reduceToPolynomial import org.oppia.android.util.math.NumericExpressionEvaluator.Companion.evaluate /** @@ -37,3 +39,10 @@ fun MathExpression.evaluateAsNumericExpression(): Real? = evaluate() * See [convertToComparableOperation] for details. */ fun MathExpression.toComparableOperation(): ComparableOperation = convertToComparableOperation() + +/** + * Returns the [Polynomial] representation of this [MathExpression]. + * + * See [reduceToPolynomial] for details. + */ +fun MathExpression.toPolynomial(): Polynomial? = reduceToPolynomial() 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 index 5983554ddb1..2b3dac6d421 100644 --- a/utility/src/main/java/org/oppia/android/util/math/PolynomialExtensions.kt +++ b/utility/src/main/java/org/oppia/android/util/math/PolynomialExtensions.kt @@ -1,10 +1,20 @@ package org.oppia.android.util.math +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 +/** Represents a single-term constant polynomial with the value of 0. */ +val ZERO_POLYNOMIAL: Polynomial = createConstantPolynomial(ZERO) + +/** Represents a single-term constant polynomial with the value of 1. */ +val ONE_POLYNOMIAL: Polynomial = createConstantPolynomial(ONE) + +private val POLYNOMIAL_VARIABLE_COMPARATOR by lazy { createVariableComparator() } +private val POLYNOMIAL_TERM_COMPARATOR by lazy { createTermComparator() } + /** Returns whether this polynomial is a constant-only polynomial (contains no variables). */ fun Polynomial.isConstant(): Boolean = termCount == 1 && getTerm(0).variableCount == 0 @@ -33,6 +43,192 @@ fun Polynomial.toPlainText(): String { } } +/** + * Returns a version of this [Polynomial] with all zero-coefficient terms removed. + * + * This function guarantees that the returned polynomial have at least 1 term (even if it's just the + * constant zero). + */ +fun Polynomial.removeUnnecessaryVariables(): Polynomial { + return Polynomial.newBuilder().apply { + addAllTerm( + this@removeUnnecessaryVariables.termList.filter { term -> + !term.coefficient.isApproximatelyZero() + } + ) + }.build().ensureAtLeastConstant() +} + +/** + * Returns a version of this [Polynomial] with all rational coefficients potentially simplified to + * integer terms. + * + * A rational coefficient can be simplified iff: + * - It has no fractional representation (which includes zero fraction cases). + * - It has a denominator of 1 (which represents a whole number, even for improper fractions). + */ +fun Polynomial.simplifyRationals(): Polynomial { + return Polynomial.newBuilder().apply { + addAllTerm( + this@simplifyRationals.termList.map { term -> + term.toBuilder().apply { + coefficient = term.coefficient.maybeSimplifyRationalToInteger() + }.build() + } + ) + }.build() +} + +/** + * Returns a sorted version of this [Polynomial]. + * + * The returned version guarantees a repeatable and deterministic order that prioritizes variables + * earlier in the alphabet (or have lower lexicographical order), and have higher powers. Some + * examples: + * - 'x' will appear before '1'. + * - 'x^2' will appear before 'x'. + * - 'x' will appear before 'y'. + * - 'xy' will appear before 'x' and 'y'. + * - 'x^2y' will appear before 'xy^2', but after 'x^2y^2'. + * - 'xy^2' will appear before 'xy'. + */ +fun Polynomial.sort(): Polynomial = Polynomial.newBuilder().apply { + // The double sorting here is less efficient, but it ensures both terms and variables are + // correctly kept sorted. Fortunately, most internal operations will keep variables sorted by + // default. + addAllTerm( + this@sort.termList.map { term -> + Term.newBuilder().apply { + coefficient = term.coefficient + addAllVariable(term.variableList.sortedWith(POLYNOMIAL_VARIABLE_COMPARATOR)) + }.build() + }.sortedWith(POLYNOMIAL_TERM_COMPARATOR) + ) +}.build() + +/** + * Returns the negated version of this [Polynomial] such that the original polynomial plus the + * negative version would yield zero. + */ +operator fun Polynomial.unaryMinus(): Polynomial { + // Negating a polynomial just requires flipping the signs on all coefficients. + return toBuilder() + .clearTerm() + .addAllTerm(termList.map { it.toBuilder().setCoefficient(-it.coefficient).build() }) + .build() +} + +/** + * Returns the sum of this [Polynomial] with [rhs]. + * + * The returned polynomial is guaranteed to: + * - Have all like terms combined. + * - Have simplified rational coefficients (per [simplifyRationals]. + * - Have no zero coefficients (unless the entire polynomial is zero, in which case just 1). + */ +operator fun Polynomial.plus(rhs: Polynomial): Polynomial { + // Adding two polynomials just requires combining their terms lists (taking into account combining + // common terms). + return Polynomial.newBuilder().apply { + addAllTerm(this@plus.termList + rhs.termList) + }.build().combineLikeTerms().simplifyRationals().removeUnnecessaryVariables() +} + +/** + * Returns the subtraction of [rhs] from this [Polynomial]. + * + * The returned polynomial, when added with [rhs], will always equal the original polynomial. + * + * The returned polynomial has the same guarantee as those returned from [Polynomial.plus]. + */ +operator fun Polynomial.minus(rhs: Polynomial): Polynomial { + // a - b = a + -b + return this + -rhs +} + +/** + * Returns the product of this [Polynomial] with [rhs]. + * + * This will correctly cross-multiply terms, for example: (1+x)*(1-x) will become 1-x^2. + * + * The returned polynomial has the same guarantee as those returned from [Polynomial.plus]. + */ +operator fun Polynomial.times(rhs: Polynomial): Polynomial { + // Polynomial multiplication is simply multiplying each term in one by each term in the other. + val crossMultipliedTerms = termList.flatMap { leftTerm -> + rhs.termList.map { rightTerm -> leftTerm * rightTerm } + } + + // Treat each multiplied term as a unique polynomial, then add them together (so that like terms + // can be properly combined). Finally, ensure unnecessary variables are eliminated (especially for + // cases where no addition takes place, such as 0*x). + return crossMultipliedTerms.map { + createSingleTermPolynomial(it) + }.reduce(Polynomial::plus).simplifyRationals().removeUnnecessaryVariables() +} + +/** + * Returns the division of [rhs] from this [Polynomial], or null if there's a remainder after + * attempting the division. + * + * If this function returns non-null, it's guaranteed that the quotient times the divisor will yield + * the dividend. + * + * The returned polynomial has the same guarantee as those returned from [Polynomial.plus]. + */ +operator fun Polynomial.div(rhs: Polynomial): Polynomial? { + // See https://en.wikipedia.org/wiki/Polynomial_long_division#Pseudocode for reference. + if (rhs.isApproximatelyZero()) { + return null // Dividing by zero is invalid and thus cannot yield a polynomial. + } + + var quotient = ZERO_POLYNOMIAL + var remainder = this + val leadingDivisorTerm = rhs.getLeadingTerm() ?: return null + val divisorVariable = leadingDivisorTerm.highestDegreeVariable() + val divisorVariableName = divisorVariable?.name + val divisorDegree = leadingDivisorTerm.highestDegree() + while (!remainder.isApproximatelyZero() && + (remainder.getDegree() ?: return null) >= divisorDegree + ) { + // Attempt to divide the leading terms (this may fail). Note that the leading term should always + // be based on the divisor variable being used (otherwise subsequent division steps will be + // inconsistent and potentially fail to resolve). + val remainingLeadingTerm = remainder.getLeadingTerm(matchedVariable = divisorVariableName) + val newTerm = remainingLeadingTerm?.div(leadingDivisorTerm) ?: return null + quotient += newTerm.toPolynomial() + remainder -= newTerm.toPolynomial() * rhs + } + // Either the division was exact, or the remainder is a polynomial (i.e. a failed division). + return quotient.takeIf { remainder.isApproximatelyZero() } +} + +/** + * Returns the [Polynomial] that represents this [Polynomial] raised to [exp], or null if the result + * is not a valid polynomial or if a proper polynomial could not be kept along the way. + * + * This function will fail in a number of cases, including: + * - If [exp] is not a constant polynomial. + * - If this polynomial has more than one term (since that requires factoring). + * - If the result would yield a polynomial with a negative power. + * + * The returned polynomial has the same guarantee as those returned from [Polynomial.plus]. + */ +infix fun Polynomial.pow(exp: Polynomial): Polynomial? { + // Polynomial exponentiation is only supported if the right side is a constant polynomial, + // otherwise the result cannot be a polynomial (though could still be compared to another + // expression by utilizing sampling techniques). + return if (exp.isConstant()) { + pow(exp.getConstant())?.simplifyRationals()?.removeUnnecessaryVariables() + } else null +} + +private fun createConstantPolynomial(constant: Real): Polynomial = + createSingleTermPolynomial(Term.newBuilder().setCoefficient(constant).build()) + +private fun createSingleTermPolynomial(term: Term): Polynomial = + Polynomial.newBuilder().apply { addTerm(term) }.build() + private fun Term.toPlainText(): String { val productValues = mutableListOf() @@ -57,3 +253,241 @@ private fun Term.toPlainText(): String { private fun Variable.toPlainText(): String { return if (power > 1) "$name^$power" else name } + +private fun Polynomial.combineLikeTerms(): Polynomial { + // The following algorithm is expected to grow in space by O(N*M) and in time by O(N*m*log(m)) + // where N is the total number of terms, M is the total number of variables, and m is the largest + // single count of variables among all terms (this is assuming constant-time insertion for the + // underlying hashtable). + val newTerms = termList.groupBy { + it.variableList.sortedWith(POLYNOMIAL_VARIABLE_COMPARATOR) + }.mapValues { (_, coefficientTerms) -> + coefficientTerms.map { it.coefficient } + }.mapNotNull { (variables, coefficients) -> + // Combine like terms by summing their coefficients. + val newCoefficient = coefficients.reduce(Real::plus) + return@mapNotNull if (!newCoefficient.isApproximatelyZero()) { + Term.newBuilder().apply { + coefficient = newCoefficient + + // Remove variables with zero powers (since they evaluate to '1'). + addAllVariable(variables.filter { variable -> variable.power != 0 }) + }.build() + } else null // Zero terms should be removed. + } + return Polynomial.newBuilder().apply { + addAllTerm(newTerms) + }.build().ensureAtLeastConstant() +} + +private fun Polynomial.pow(exp: Real): Polynomial? { + val shouldBeInverted = exp.isNegative() + val positivePower = if (shouldBeInverted) -exp else exp + val exponentiation = when { + // Constant polynomials can be raised by any constant. + isConstant() -> (getConstant() pow positivePower)?.let { createConstantPolynomial(it) } + + // Polynomials can only be raised to positive integers (or zero). + exp.isWholeNumber() -> exp.asWholeNumber()?.let { pow(it) } + + // Polynomials can potentially be raised by a fractional power. + exp.isRational() -> pow(exp.rational) + + // All other cases require factoring most likely will not compute to polynomials (such as + // irrational exponents). + else -> null + } + return if (shouldBeInverted) { + val onePolynomial = ONE_POLYNOMIAL + // Note that this division is guaranteed to fail if the exponentiation result is a polynomial. + // Future implementations may leverage root-finding algorithms to factor for integer inverse + // powers (such as square root, cubic root, etc.). Non-integer inverse powers will require + // sampling. + exponentiation?.let { onePolynomial / it } + } else exponentiation +} + +private fun Polynomial.pow(rational: Fraction): Polynomial? { + // Polynomials with addition require factoring. + return if (isSingleTerm()) { + termList.first().pow(rational)?.toPolynomial() + } else null +} + +private fun Polynomial.pow(exp: Int): Polynomial { + // Anything raised to the power of 0 is 1. + if (exp == 0) return ONE_POLYNOMIAL + if (exp == 1) return this + var newValue = this + for (i in 1 until exp) newValue *= this + return newValue +} + +private operator fun Term.times(rhs: Term): Term { + // The coefficients are always multiplied. + val combinedCoefficient = coefficient * rhs.coefficient + + // Next, create a combined list of new variables. + val combinedVariables = variableList + rhs.variableList + + // Simplify the variables by combining the exponents of like variables. Start with a map of 0 + // powers, then add in the powers of each variable and collect the final list of unique terms. + val variableNamesMap = mutableMapOf() + combinedVariables.forEach { + variableNamesMap.compute(it.name) { _, power -> + if (power != null) power + it.power else it.power + } + } + val newVariableList = variableNamesMap.map { (name, power) -> + Variable.newBuilder().setName(name).setPower(power).build() + } + + return Term.newBuilder() + .setCoefficient(combinedCoefficient) + .addAllVariable(newVariableList) + .build() +} + +private operator fun Term.div(rhs: Term): Term? { + val dividendPowerMap = variableList.toPowerMap() + val divisorPowerMap = rhs.variableList.toPowerMap() + + // If any variables are present in the divisor and not the dividend, this division won't work + // effectively. + if (!dividendPowerMap.keys.containsAll(divisorPowerMap.keys)) return null + + // Division is simply subtracting the powers of terms in the divisor from those in the dividend. + val quotientPowerMap = dividendPowerMap.mapValues { (name, power) -> + power - divisorPowerMap.getOrDefault(name, defaultValue = 0) + } + + // If there are any negative powers, the divisor can't effectively divide this value. + if (quotientPowerMap.values.any { it < 0 }) return null + + // Remove variables with powers of 0 since those have been fully divided. Also, divide the + // coefficients to finish the division. + return Term.newBuilder() + .setCoefficient(coefficient / rhs.coefficient) + .addAllVariable(quotientPowerMap.filter { (_, power) -> power > 0 }.toVariableList()) + .build() +} + +private fun Term.pow(rational: Fraction): Term? { + // Raising an exponent by an exponent just requires multiplying the two together. + val newVariablePowers = variableList.map { variable -> + variable.power.toWholeNumberFraction() * rational + } + + // If any powers are not whole numbers then the rational is likely representing a root and the + // term in question is not rootable to that degree. + if (newVariablePowers.any { !it.isOnlyWholeNumber() }) return null + + val newCoefficient = coefficient pow Real.newBuilder().apply { + this.rational = rational + }.build() ?: return null + + return Term.newBuilder().apply { + coefficient = newCoefficient + addAllVariable( + (this@pow.variableList zip newVariablePowers).map { (variable, newPower) -> + variable.toBuilder().apply { + power = newPower.toWholeNumber() + }.build() + } + ) + }.build() +} + +/** + * Returns either this [Polynomial] or [ZERO_POLYNOMIAL] if this polynomial has no terms (i.e. the + * returned polynomial is always guaranteed to have at least one term). + */ +private fun Polynomial.ensureAtLeastConstant(): Polynomial { + return if (termCount != 0) this else ZERO_POLYNOMIAL +} + +private fun Polynomial.isSingleTerm(): Boolean = termList.size == 1 + +private fun Polynomial.isApproximatelyZero(): Boolean = + termList.all { it.coefficient.isApproximatelyZero() } // Zero polynomials only have 0 coefs. + +// Return the highest power to represent the degree of the polynomial. Reference: +// https://www.varsitytutors.com/algebra_1-help/how-to-find-the-degree-of-a-polynomial. +private fun Polynomial.getDegree(): Int? = getLeadingTerm()?.highestDegree() + +private fun Polynomial.getLeadingTerm(matchedVariable: String? = null): Term? { + // Return the leading term. Reference: https://undergroundmathematics.org/glossary/leading-term. + return termList.filter { term -> + matchedVariable?.let { variableName -> + term.variableList.any { it.name == variableName } + } ?: true + }.takeIf { it.isNotEmpty() }?.reduce { maxTerm, term -> + val maxTermDegree = maxTerm.highestDegree() + val termDegree = term.highestDegree() + return@reduce if (termDegree > maxTermDegree) term else maxTerm + } +} + +private fun Term.highestDegreeVariable(): Variable? = variableList.maxByOrNull(Variable::getPower) + +private fun Term.highestDegree(): Int = highestDegreeVariable()?.power ?: 0 + +private fun Term.toPolynomial(): Polynomial { + return Polynomial.newBuilder().addTerm(this).build() +} + +private fun List.toPowerMap(): Map { + return associateBy({ it.name }, { it.power }) +} + +private fun Map.toVariableList(): List { + return map { (name, power) -> Variable.newBuilder().setName(name).setPower(power).build() } +} + +private fun Real.maybeSimplifyRationalToInteger(): Real = when (realTypeCase) { + Real.RealTypeCase.RATIONAL -> { + val improperRational = rational.toImproperForm() + when { + rational.isOnlyWholeNumber() -> { + Real.newBuilder().apply { + integer = this@maybeSimplifyRationalToInteger.rational.toWholeNumber() + }.build() + } + // Some fractions are effectively whole numbers. + improperRational.denominator == 1 -> { + Real.newBuilder().apply { + integer = if (improperRational.isNegative) { + -improperRational.numerator + } else improperRational.numerator + }.build() + } + else -> this + } + } + // Nothing to do in these cases. + Real.RealTypeCase.IRRATIONAL, Real.RealTypeCase.INTEGER, Real.RealTypeCase.REALTYPE_NOT_SET, + null -> this +} + +private fun createTermComparator(): Comparator { + // First, sort by all variable names to ensure xy is placed ahead of xz. Then, sort by variable + // powers in order of the variables (such that x^2y is ranked higher thank xy). Finally, sort by + // the coefficient to ensure equality through the comparator works correctly (though in practice + // like terms should always be combined). Note the specific reversing happening here. It's done in + // this way so that sorted set bigger/smaller list is reversed (which matches expectations since + // larger terms should appear earlier in the results). This is implementing an ordering similar to + // https://en.wikipedia.org/wiki/Polynomial#Definition, except for multi-variable functions (where + // variables of higher degree are preferred over lower degree by lexicographical order of variable + // names). + val reversedVariableComparator = POLYNOMIAL_VARIABLE_COMPARATOR.reversed() + return compareBy>( + reversedVariableComparator::compareIterablesReversed, Term::getVariableList + ).thenByDescending(REAL_COMPARATOR, Term::getCoefficient) +} + +private fun createVariableComparator(): Comparator { + // Note that power is reversed because larger powers should actually be sorted ahead of smaller + // powers for the same variable name (but variable name still takes precedence). This ensures + // cases like x^2y+y^2x are sorted in that order. + return compareBy(Variable::getName).thenByDescending(Variable::getPower) +} diff --git a/utility/src/main/java/org/oppia/android/util/math/RealExtensions.kt b/utility/src/main/java/org/oppia/android/util/math/RealExtensions.kt index 69f4ab19aa2..607277ed7ad 100644 --- a/utility/src/main/java/org/oppia/android/util/math/RealExtensions.kt +++ b/utility/src/main/java/org/oppia/android/util/math/RealExtensions.kt @@ -9,6 +9,26 @@ import org.oppia.android.app.model.Real.RealTypeCase.REALTYPE_NOT_SET import kotlin.math.absoluteValue import kotlin.math.pow +/** Represents an integer [Real] with value 0. */ +val ZERO: Real by lazy { + Real.newBuilder().apply { integer = 0 }.build() +} + +/** Represents an integer [Real] with value 1. */ +val ONE: Real by lazy { + Real.newBuilder().apply { integer = 1 }.build() +} + +/** Represents a rational fraction [Real] with value 1/2. */ +val ONE_HALF: Real by lazy { + Real.newBuilder().apply { + rational = Fraction.newBuilder().apply { + numerator = 1 + denominator = 2 + }.build() + }.build() +} + /** * [Comparator] for [Real]s that ensures two reals can be compared even if they are different types. * @@ -31,6 +51,20 @@ fun Real.isRational(): Boolean = realTypeCase == RATIONAL */ fun Real.isInteger(): Boolean = realTypeCase == INTEGER +/** + * Returns whether this [Real] is explicitly a whole number, that is, either an integer or a + * [Fraction] that's also a whole number. + * + * Note that this has the same limitations as [Fraction.isOnlyWholeNumber] for rational values. + */ +fun Real.isWholeNumber(): Boolean { + return when (realTypeCase) { + RATIONAL -> rational.isOnlyWholeNumber() + INTEGER -> true + IRRATIONAL, REALTYPE_NOT_SET, null -> false + } +} + /** Returns whether this [Real] is negative. */ fun Real.isNegative(): Boolean = when (realTypeCase) { RATIONAL -> rational.isNegative @@ -39,9 +73,22 @@ fun Real.isNegative(): Boolean = when (realTypeCase) { REALTYPE_NOT_SET, null -> throw IllegalStateException("Invalid real: $this.") } +/** + * 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 whether this [Real] is approximately zero per [Double.approximatelyEquals]. */ +fun Real.isApproximatelyZero(): Boolean = isApproximatelyEqualTo(0.0) + /** * Returns a [Double] representation of this [Real] that is approximately the same value (per * [isApproximatelyEqualTo]). + * + * This method throws an exception if this [Real] is invalid (such as a default proto instance). */ fun Real.toDouble(): Double { return when (realTypeCase) { @@ -52,6 +99,23 @@ fun Real.toDouble(): Double { } } +/** + * Returns the whole-number representation of this [Real], or null if there isn't one. + * + * This function should only be called if [isWholeNumber] returns true. The contract of that + * function guarantees that a non-null integer can be returned here for whole number reals. + * + * This method throws an exception if this [Real] is invalid (such as a default proto instance). + */ +fun Real.asWholeNumber(): Int? { + return when (realTypeCase) { + RATIONAL -> if (rational.isOnlyWholeNumber()) rational.toWholeNumber() else null + INTEGER -> integer + IRRATIONAL -> null + REALTYPE_NOT_SET, null -> throw IllegalStateException("Invalid real: $this.") + } +} + /** * Returns a human-readable, plaintext representation of this [Real]. * @@ -73,14 +137,6 @@ fun Real.toPlainText(): String = when (realTypeCase) { 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. @@ -260,8 +316,7 @@ operator fun Real.div(rhs: Real): Real { * This function can fail in a few circumstances: * - One of the [Real]s is malformed or incomplete (such as a default instance). * - In cases where a root is being taken (i.e. when |[rhs]| < 1), if the root cannot be taken - * either an exception will be thrown or NaN will be returned (such as trying to take the even - * root of a negative value). + * either null or NaN will be returned (such as trying to take the even root of a negative value). * * Further, note that this function represents the real value root rather than the principal root, * so negative bases are allowed so long as the root being used is odd. For non-integerlike powers, @@ -290,7 +345,7 @@ operator fun Real.div(rhs: Real): Real { * (Note that the left column represents the left-hand side and the top row represents the * right-hand side of the operation). */ -infix fun Real.pow(rhs: Real): Real { +infix fun Real.pow(rhs: Real): Real? { // Powers can really only be effectively done via floats or whole-number only fractions. return when (realTypeCase) { RATIONAL -> { @@ -341,8 +396,7 @@ infix fun Real.pow(rhs: Real): Real { * Failure cases: * - An invalid [Real] is passed in (such as a default instance), resulting in an exception being * thrown. - * - A negative value is passed in (this will either result in an exception or a NaN being - * returned). + * - A negative value is passed in (this will either result in null or a NaN being returned). * * Similar to [Real.plus] & other operations, this function attempts to retain as much precision as * possible by first performing perfect roots before needing to perform a numerical approximation. @@ -358,7 +412,7 @@ infix fun Real.pow(rhs: Real): Real { * | irrational | irrational | irrational | * |------------------------------------------------| */ -fun sqrt(real: Real): Real { +fun sqrt(real: Real): Real? { return when (real.realTypeCase) { RATIONAL -> real.rational.root(base = 2, invert = false) IRRATIONAL -> createIrrationalReal(kotlin.math.sqrt(real.irrational)) @@ -403,7 +457,7 @@ private fun Int.pow(exp: Int): Real { } } -private fun Fraction.root(base: Int, invert: Boolean): Real { +private fun Fraction.root(base: Int, invert: Boolean): Real? { check(base > 0) { "Expected base of 1 or higher, not: $base" } val adjustedFraction = toImproperForm() @@ -412,24 +466,28 @@ private fun Fraction.root(base: Int, invert: Boolean): Real { val adjustedDenom = adjustedFraction.denominator val rootedNumerator = if (invert) root(adjustedDenom, base) else root(adjustedNum, base) val rootedDenominator = if (invert) root(adjustedNum, base) else root(adjustedDenom, base) - return if (rootedNumerator.isInteger() && rootedDenominator.isInteger()) { - Real.newBuilder().apply { - rational = Fraction.newBuilder().apply { - isNegative = rootedNumerator.isNegative() || rootedDenominator.isNegative() - numerator = rootedNumerator.integer.absoluteValue - denominator = rootedDenominator.integer.absoluteValue - }.build().toProperForm() - }.build() - } else { - // One or both of the components of the fraction can't be rooted, so compute an irrational - // version. - Real.newBuilder().apply { - irrational = rootedNumerator.toDouble() / rootedDenominator.toDouble() - }.build() + return when { + rootedNumerator == null || rootedDenominator == null -> null + rootedNumerator.isInteger() && rootedDenominator.isInteger() -> { + Real.newBuilder().apply { + rational = Fraction.newBuilder().apply { + isNegative = rootedNumerator.isNegative() || rootedDenominator.isNegative() + numerator = rootedNumerator.integer.absoluteValue + denominator = rootedDenominator.integer.absoluteValue + }.build().toProperForm() + }.build() + } + else -> { + // One or both of the components of the fraction can't be rooted, so compute an irrational + // version. + Real.newBuilder().apply { + irrational = rootedNumerator.toDouble() / rootedDenominator.toDouble() + }.build() + } } } -private fun root(int: Int, base: Int): Real { +private fun root(int: Int, base: Int): Real? { // First, check if the integer is a root. Base reference for possible methods: // https://www.researchgate.net/post/How-to-decide-if-a-given-number-will-have-integer-square-root-or-not. if (int == 0 && base == 0) { @@ -442,7 +500,7 @@ private fun root(int: Int, base: Int): Real { } check(base > 0) { "Expected base of 1 or higher, not: $base" } - check((int < 0 && base.isOdd()) || int >= 0) { "Radicand results in imaginary number: $int" } + if (int < 0 && !base.isOdd()) return null when { int == 0 -> { @@ -466,7 +524,7 @@ private fun root(int: Int, base: Int): Real { } val radicand = int.absoluteValue - var potentialRoot = base + var potentialRoot = 1 while (potentialRoot.pow(base).integer < radicand) { potentialRoot++ } 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 4ba0812e29f..0f7c78a736c 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 @@ -98,6 +98,24 @@ oppia_android_test( ], ) +oppia_android_test( + name = "ExpressionToPolynomialConverterTest", + srcs = ["ExpressionToPolynomialConverterTest.kt"], + custom_package = "org.oppia.android.util.math", + test_class = "org.oppia.android.util.math.ExpressionToPolynomialConverterTest", + 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:math_expression_parser", + ], +) + oppia_android_test( name = "FloatExtensionsTest", srcs = ["FloatExtensionsTest.kt"], @@ -160,6 +178,7 @@ oppia_android_test( 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", "//testing/src/main/java/org/oppia/android/testing/math:real_subject", "//third_party:androidx_test_ext_junit", "//third_party:com_google_truth_truth", @@ -275,8 +294,9 @@ oppia_android_test( test_manifest = "//utility:test_manifest", deps = [ "//model/src/main/proto:math_java_proto_lite", + "//testing/src/main/java/org/oppia/android/testing/junit:oppia_parameterized_test_runner", + "//testing/src/main/java/org/oppia/android/testing/junit:parameterized_junit_test_runner", "//testing/src/main/java/org/oppia/android/testing/math:polynomial_subject", - "//third_party:androidx_test_ext_junit", "//third_party:com_google_truth_truth", "//third_party:junit_junit", "//third_party:org_robolectric_robolectric", diff --git a/utility/src/test/java/org/oppia/android/util/math/ComparatorExtensionsTest.kt b/utility/src/test/java/org/oppia/android/util/math/ComparatorExtensionsTest.kt index 5c44c17968e..a1c73b45d71 100644 --- a/utility/src/test/java/org/oppia/android/util/math/ComparatorExtensionsTest.kt +++ b/utility/src/test/java/org/oppia/android/util/math/ComparatorExtensionsTest.kt @@ -204,6 +204,180 @@ class ComparatorExtensionsTest { assertThat(compareResult).isEqualTo(0) } + @Test + fun testCompareIterablesReversed_emptyList_emptyList_returnsZero() { + val leftList = listOf() + val rightList = listOf() + + val compareResult = stringComparator.compareIterablesReversed(leftList, rightList) + + assertThat(compareResult).isEqualTo(0) + } + + @Test + fun testCompareIterablesReversed_singletonList_emptyList_returnsNegativeOne() { + val leftList = listOf("1") + val rightList = listOf() + + val compareResult = stringComparator.compareIterablesReversed(leftList, rightList) + + assertThat(compareResult).isEqualTo(-1) + } + + @Test + fun testCompareIterablesReversed_emptyList_singletonList_returnsOne() { + val leftList = listOf() + val rightList = listOf("1") + + val compareResult = stringComparator.compareIterablesReversed(leftList, rightList) + + assertThat(compareResult).isEqualTo(1) + } + + @Test + fun testCompareIterablesReversed_singletonList_singletonList_sameElems_returnsZero() { + val leftList = listOf("1") + val rightList = listOf("1") + + val compareResult = stringComparator.compareIterablesReversed(leftList, rightList) + + assertThat(compareResult).isEqualTo(0) + } + + @Test + fun testCompareIterablesReversed_twoItemList_singletonList_commonElem_returnsNegativeOne() { + val leftList = listOf("1", "2") + val rightList = listOf("1") + + val compareResult = stringComparator.compareIterablesReversed(leftList, rightList) + + assertThat(compareResult).isEqualTo(-1) + } + + @Test + fun testCompareIterablesReversed_singletonList_twoItemList_commonElem_returnsOne() { + val leftList = listOf("1") + val rightList = listOf("1", "2") + + val compareResult = stringComparator.compareIterablesReversed(leftList, rightList) + + assertThat(compareResult).isEqualTo(1) + } + + @Test + fun testCompareIterablesReversed_equalSizeLists_sameItems_sameOrder_returnsZero() { + val leftList = listOf("1", "2") + val rightList = listOf("1", "2") + + val compareResult = stringComparator.compareIterablesReversed(leftList, rightList) + + assertThat(compareResult).isEqualTo(0) + } + + @Test + fun testCompareIterablesReversed_equalSizeLists_sameItems_differentOrder_returnsZero() { + val leftList = listOf("1", "2") + val rightList = listOf("2", "1") + + val compareResult = stringComparator.compareIterablesReversed(leftList, rightList) + + // Order shouldn't matter. + assertThat(compareResult).isEqualTo(0) + } + + @Test + fun testCompareIterablesReversed_list223_list123_returnsNegativeOne() { + val leftList = listOf("2", "2", "3") + val rightList = listOf("1", "2", "3") + + val compareResult = stringComparator.compareIterablesReversed(leftList, rightList) + + assertThat(compareResult).isEqualTo(-1) + } + + @Test + fun testCompareIterablesReversed_list123_list223_returnsOne() { + val leftList = listOf("1", "2", "3") + val rightList = listOf("2", "2", "3") + + val compareResult = stringComparator.compareIterablesReversed(leftList, rightList) + + assertThat(compareResult).isEqualTo(1) + } + + @Test + fun testCompareIterablesReversed_list123_list11_returnsNegativeOne() { + val leftList = listOf("1", "2", "3") + val rightList = listOf("1", "1") + + val compareResult = stringComparator.compareIterablesReversed(leftList, rightList) + + assertThat(compareResult).isEqualTo(-1) + } + + @Test + fun testCompareIterablesReversed_list123_list13_returnsNegativeOne() { + val leftList = listOf("1", "2", "3") + val rightList = listOf("1", "3") + + val compareResult = stringComparator.compareIterablesReversed(leftList, rightList) + + assertThat(compareResult).isEqualTo(-1) + } + + @Test + fun testCompareIterablesReversed_list223_list1_returnsNegativeOne() { + val leftList = listOf("1", "2", "3") + val rightList = listOf("1") + + val compareResult = stringComparator.compareIterablesReversed(leftList, rightList) + + assertThat(compareResult).isEqualTo(-1) + } + + @Test + fun testCompareIterablesReversed_list123_list2_returnsNegativeNegativeOne() { + val leftList = listOf("1", "2", "3") + val rightList = listOf("2") + + val compareResult = stringComparator.compareIterablesReversed(leftList, rightList) + + assertThat(compareResult).isEqualTo(-1) + } + + @Test + fun testCompareIterablesReversed_list22_list2_returnsNegativeOne() { + val leftList = listOf("2", "2") + val rightList = listOf("2") + + val compareResult = stringComparator.compareIterablesReversed(leftList, rightList) + + // The first list has an extra element. This also verifies that duplicates are correctly + // considered during comparison. + assertThat(compareResult).isEqualTo(-1) + } + + @Test + fun testCompareIterablesReversed_list2_list22_returnsOne() { + val leftList = listOf("2") + val rightList = listOf("2", "2") + + val compareResult = stringComparator.compareIterablesReversed(leftList, rightList) + + // The second list has an extra element. + assertThat(compareResult).isEqualTo(1) + } + + @Test + fun testCompareIterablesReversed_list22_list22_returnsZero() { + val leftList = listOf("2", "2") + val rightList = listOf("2", "2") + + val compareResult = stringComparator.compareIterablesReversed(leftList, rightList) + + assertThat(compareResult).isEqualTo(0) + } + @Test fun testCompareProtos_defaultAndDefault_returnsZero() { val leftProto = TestMessage.newBuilder().build() diff --git a/utility/src/test/java/org/oppia/android/util/math/ExpressionToPolynomialConverterTest.kt b/utility/src/test/java/org/oppia/android/util/math/ExpressionToPolynomialConverterTest.kt new file mode 100644 index 00000000000..bcf64991b55 --- /dev/null +++ b/utility/src/test/java/org/oppia/android/util/math/ExpressionToPolynomialConverterTest.kt @@ -0,0 +1,2326 @@ +package org.oppia.android.util.math + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.oppia.android.app.model.MathExpression +import org.oppia.android.testing.math.PolynomialSubject.Companion.assertThat +import org.oppia.android.util.math.ExpressionToPolynomialConverter.Companion.reduceToPolynomial +import org.oppia.android.util.math.MathExpressionParser.Companion.ErrorCheckingMode +import org.oppia.android.util.math.MathExpressionParser.Companion.ErrorCheckingMode.ALL_ERRORS +import org.oppia.android.util.math.MathExpressionParser.Companion.ErrorCheckingMode.REQUIRED_ONLY +import org.oppia.android.util.math.MathExpressionParser.Companion.MathParsingResult +import org.robolectric.annotation.LooperMode + +/** + * Tests for [ExpressionToPolynomialConverter]. + * + * Note that this suite only tests with algebraic expressions since numeric expressions are never + * considered to be polynomials (despite numeric expression evaluation and the constant term of + * polynomials being expected to always result in the same value). + */ +// FunctionName: test names are conventionally named with underscores. +@Suppress("FunctionName") +@RunWith(AndroidJUnit4::class) +@LooperMode(LooperMode.Mode.PAUSED) +class ExpressionToPolynomialConverterTest { + @Test + fun testReduce_integerConstantExpression_returnsConstantPolynomial() { + val expression = parseAlgebraicExpression("2") + + val polynomial = expression.reduceToPolynomial() + + assertThat(polynomial).isConstantThat().isIntegerThat().isEqualTo(2) + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("2") + } + + @Test + fun testReduce_decimalConstantExpression_returnsConstantPolynomial() { + val expression = parseAlgebraicExpression("3.14") + + val polynomial = expression.reduceToPolynomial() + + assertThat(polynomial).isConstantThat().isIrrationalThat().isWithin(1e-5).of(3.14) + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("3.14") + } + + @Test + fun testReduce_variableConstantExpression_returnsSingleTermPolynomial() { + val expression = parseAlgebraicExpression("x") + + val polynomial = expression.reduceToPolynomial() + + assertThat(polynomial).hasTermCountThat().isEqualTo(1) + assertThat(polynomial).term(0).hasCoefficientThat().isIntegerThat().isEqualTo(1) + assertThat(polynomial).term(0).hasVariableCountThat().isEqualTo(1) + assertThat(polynomial).term(0).variable(0).hasNameThat().isEqualTo("x") + assertThat(polynomial).term(0).variable(0).hasPowerThat().isEqualTo(1) + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("x") + } + + @Test + fun testReduce_intTimesVariable_returnsPolynomialWithCoefficient() { + val expression = parseAlgebraicExpression("7*x") + + val polynomial = expression.reduceToPolynomial() + + assertThat(polynomial).hasTermCountThat().isEqualTo(1) + assertThat(polynomial).term(0).hasCoefficientThat().isIntegerThat().isEqualTo(7) + assertThat(polynomial).term(0).hasVariableCountThat().isEqualTo(1) + assertThat(polynomial).term(0).variable(0).hasNameThat().isEqualTo("x") + assertThat(polynomial).term(0).variable(0).hasPowerThat().isEqualTo(1) + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("7x") + } + + @Test + fun testReduce_negativeDecimalTimesVariable_returnsPolynomialWithNegativeCoefficient() { + val expression = parseAlgebraicExpression("-3.14*x") + + val polynomial = expression.reduceToPolynomial() + + assertThat(polynomial).hasTermCountThat().isEqualTo(1) + assertThat(polynomial).term(0).hasCoefficientThat().isIrrationalThat().isWithin(1e-5).of(-3.14) + assertThat(polynomial).term(0).hasVariableCountThat().isEqualTo(1) + assertThat(polynomial).term(0).variable(0).hasNameThat().isEqualTo("x") + assertThat(polynomial).term(0).variable(0).hasPowerThat().isEqualTo(1) + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("-3.14x") + } + + @Test + fun testReduce_twoTimesXImplicitly_returnsPolynomialWithOneTermAndCoefficient() { + val expression = parseAlgebraicExpression("2x") + + val polynomial = expression.reduceToPolynomial() + + assertThat(polynomial).hasTermCountThat().isEqualTo(1) + assertThat(polynomial).term(0).hasCoefficientThat().isIntegerThat().isEqualTo(2) + assertThat(polynomial).term(0).hasVariableCountThat().isEqualTo(1) + assertThat(polynomial).term(0).variable(0).hasNameThat().isEqualTo("x") + assertThat(polynomial).term(0).variable(0).hasPowerThat().isEqualTo(1) + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("2x") + } + + @Test + fun testReduce_zeroX_returnsZeroPolynomial() { + val expression = parseAlgebraicExpression("0x") + + val polynomial = expression.reduceToPolynomial() + + // 0x just becomes 0 (the 'x' is removed). + assertThat(polynomial).isConstantThat().isIntegerThat().isEqualTo(0) + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("0") + } + + @Test + fun testReduce_onePlusTwo_returnsConstantThreePolynomial() { + val expression = parseAlgebraicExpression("1+2") + + val polynomial = expression.reduceToPolynomial() + + // The '1+2' is reduced to a single '3' constant. + assertThat(polynomial).isConstantThat().isIntegerThat().isEqualTo(3) + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("3") + } + + @Test + fun testReduce_xPlusX_returnTwoXPolynomial() { + val expression = parseAlgebraicExpression("x+x") + + val polynomial = expression.reduceToPolynomial() + + // x+x is combined to 2x (like terms are combined). + assertThat(polynomial).hasTermCountThat().isEqualTo(1) + assertThat(polynomial).term(0).hasCoefficientThat().isIntegerThat().isEqualTo(2) + assertThat(polynomial).term(0).hasVariableCountThat().isEqualTo(1) + assertThat(polynomial).term(0).variable(0).hasNameThat().isEqualTo("x") + assertThat(polynomial).term(0).variable(0).hasPowerThat().isEqualTo(1) + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("2x") + } + + @Test + fun testReduce_xPlusOne_returnsXPlusOnePolynomial() { + val expression = parseAlgebraicExpression("x+1") + + val polynomial = expression.reduceToPolynomial() + + // x+1 leads to a two-term polynomial. + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(0) + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("x + 1") + } + + @Test + fun testReduce_onePlusX_returnsXPlusOnePolynomial() { + val expression = parseAlgebraicExpression("1+x") + + val polynomial = expression.reduceToPolynomial() + + // 1+x leads to a two-term polynomial (with 'x' sorted first). + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(0) + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("x + 1") + } + + @Test + fun testReduce_xMinusOne_returnsXMinusOnePolynomial() { + val expression = parseAlgebraicExpression("x-1") + + val polynomial = expression.reduceToPolynomial() + + // x-1 leads to a two-term polynomial. + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(-1) + hasVariableCountThat().isEqualTo(0) + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("x - 1") + } + + @Test + fun testReduce_oneMinusX_returnsNegativeXPlusOne() { + val expression = parseAlgebraicExpression("1-x") + + val polynomial = expression.reduceToPolynomial() + + // 1-x leads to a two-term polynomial (note that 'x' is listed first due to sort priority). + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(-1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(0) + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("-x + 1") + } + + @Test + fun testReduce_xPlusTwoX_returnsThreeXPolynomial() { + val expression = parseAlgebraicExpression("x+2x") + + val polynomial = expression.reduceToPolynomial() + + // x+2x combines to 3x (since like terms are combined). This also verifies that coefficients are + // correctly combined. + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(3) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("3x") + } + + @Test + fun testReduce_xYPlusYzMinusXzMinusYzPlusThreeXy_returnsFourXyMinusXzPolynomial() { + val expression = parseAlgebraicExpression("xy+yz-xz-yz+3xy") + + val polynomial = expression.reduceToPolynomial() + + // xy+yz-xz-yz+3xy combines to 4xy-xz (eliminated terms are removed). + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(4) + hasVariableCountThat().isEqualTo(2) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + variable(1).apply { + hasNameThat().isEqualTo("y") + hasPowerThat().isEqualTo(1) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(-1) + hasVariableCountThat().isEqualTo(2) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + variable(1).apply { + hasNameThat().isEqualTo("z") + hasPowerThat().isEqualTo(1) + } + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("4xy - xz") + } + + @Test + fun testReduce_xy_returnsXTimesYPolynomial() { + val expression = parseAlgebraicExpression("xy") + + val polynomial = expression.reduceToPolynomial() + + // xy is a single-term, two-variable polynomial. + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(2) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + variable(1).apply { + hasNameThat().isEqualTo("y") + hasPowerThat().isEqualTo(1) + } + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("xy") + } + + @Test + fun testReduce_four_timesXPlusTwo_returnsEightXPlusEightPolynomial() { + val expression = parseAlgebraicExpression("4*(x+2)") + + val polynomial = expression.reduceToPolynomial() + + // 4*(x+2) becomes 4x+8 (the constant distributes to each term's coefficient). + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(4) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(8) + hasVariableCountThat().isEqualTo(0) + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("4x + 8") + } + + @Test + fun testReduce_x_timesOnePlusX_returnsXSquaredPlusXPolynomial() { + val expression = parseAlgebraicExpression("x(1+x)") + + val polynomial = expression.reduceToPolynomial() + + // x(1+x) is expanded to x^2+x. + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(2) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("x^2 + x") + } + + @Test + fun testReduce_y_timesOnePlusX_returnsXyPlusYPolynomial() { + val expression = parseAlgebraicExpression("y(1+x)") + + val polynomial = expression.reduceToPolynomial() + + // y(1+x) is expanded to xy+y. + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(2) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + variable(1).apply { + hasNameThat().isEqualTo("y") + hasPowerThat().isEqualTo(1) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("y") + hasPowerThat().isEqualTo(1) + } + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("xy + y") + } + + @Test + fun testReduce_xPlusOne_timesXMinusOne_returnsXSquaredMinusOnePolynomial() { + val expression = parseAlgebraicExpression("(x+1)(x-1)") + + val polynomial = expression.reduceToPolynomial() + + // (x+1)(x-1) expands to x^2-1. + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(2) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(-1) + hasVariableCountThat().isEqualTo(0) + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("x^2 - 1") + } + + @Test + fun testReduce_xMinusOne_timesXPlusOne_returnsXSquaredMinusOnePolynomial() { + val expression = parseAlgebraicExpression("(x-1)(x+1)") + + val polynomial = expression.reduceToPolynomial() + + // (x-1)(x+1) expands to x^2-1 (demonstrating multiplication commutativity). + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(2) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(-1) + hasVariableCountThat().isEqualTo(0) + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("x^2 - 1") + } + + @Test + fun testReduce_xPlusOne_timesXPlusOne_returnsXSquaredPlusTwoXPlusOnePolynomial() { + val expression = parseAlgebraicExpression("(x+1)(x+1)") + + val polynomial = expression.reduceToPolynomial() + + // (x+1)(x+1) expands to x^2+2x+1 (binomial multiplication). + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(3) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(2) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(2) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + term(2).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(0) + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("x^2 + 2x + 1") + } + + @Test + fun testReduce_twoMinusX_timesThreeXPlusSeven_returnsMinusThreeXSqPlusXPlusFourteenPolynomial() { + val expression = parseAlgebraicExpression("(2-x)(3x+7)") + + val polynomial = expression.reduceToPolynomial() + + // (2-x)(3x+7) expands to -3x^2-x+14 (shows multiplication with x coefficients). + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(3) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(-3) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(2) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(-1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + term(2).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(14) + hasVariableCountThat().isEqualTo(0) + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("-3x^2 - x + 14") + } + + @Test + fun testReduce_xRaisedToTwo_returnsXSquaredPolynomial() { + val expression = parseAlgebraicExpression("x^2") + + val polynomial = expression.reduceToPolynomial() + + // x^2 is treated as the variable 'x' with power 2. + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(2) + } + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("x^2") + } + + @Test + fun testReduce_xSquaredPlusXPlusOne_returnsXSquaredPlusXPlusOnePolynomial() { + val expression = parseAlgebraicExpression("x^2+x+1") + + val polynomial = expression.reduceToPolynomial() + + // x^2+x+1 stays the same since no terms can be combined, eliminated, or reordered. + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(3) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(2) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + term(2).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(0) + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("x^2 + x + 1") + } + + @Test + fun testReduce_xSquaredPlusXPlusXY_returnsXSquaredPlusXyPlusXPolynomial() { + val expression = parseAlgebraicExpression("x^2+x+xy") + + val polynomial = expression.reduceToPolynomial() + + // x^2+x+xy is treated as the same polynomial, though 'xy' comes before 'x' per sorting rules. + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(3) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(2) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(2) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + variable(1).apply { + hasNameThat().isEqualTo("y") + hasPowerThat().isEqualTo(1) + } + } + term(2).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("x^2 + xy + x") + } + + @Test + fun testReduce_x_timesXSquared_returnsXCubedPolynomial() { + val expression = parseAlgebraicExpression("xx^2") + + val polynomial = expression.reduceToPolynomial() + + // xx^2 becomes x^3 since like terms are multiplied and simplified. + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(3) + } + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("x^3") + } + + @Test + fun testReduce_xSquared_plusXSquaredY_returnsXSquaredYPlusXSquared() { + val expression = parseAlgebraicExpression("x^2 + x^2y") + + val polynomial = expression.reduceToPolynomial() + + // x^2+x^2y becomes x^2y+x^2 (terms reordered, but nothing should be combined). + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(2) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(2) + } + variable(1).apply { + hasNameThat().isEqualTo("y") + hasPowerThat().isEqualTo(1) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(2) + } + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("x^2y + x^2") + } + + @Test + fun testReduce_constant_division_returnsFractionalPolynomial() { + val expression = parseAlgebraicExpression("1/2") + + val polynomial = expression.reduceToPolynomial() + + // Division of constants is actually computed. + assertThat(polynomial).isConstantThat().isEqualTo(ONE_HALF) + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("1/2") + } + + @Test + fun testReduce_decimalConstant_division_returnsIrrationalPolynomial() { + val expression = parseAlgebraicExpression("3.14/2") + + val polynomial = expression.reduceToPolynomial() + + // Division of constants is actually computed. + assertThat(polynomial).isConstantThat().isIrrationalThat().isWithin(1e-5).of(1.57) + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("1.57") + } + + @Test + fun testReduce_x_dividedByZero_returnsNullPolynomial() { + // Dividing by zero is an optional error that needs to be disabled for this check. + val expression = parseAlgebraicExpression("x/0", errorCheckingMode = REQUIRED_ONLY) + + val polynomial = expression.reduceToPolynomial() + + // Cannot divide by zero. + assertThat(polynomial).isNotValidPolynomial() + } + + @Test + fun testReduce_x_dividedByOneMinusOne_returnsNullPolynomial() { + val expression = parseAlgebraicExpression("x/(1-1)") + + val polynomial = expression.reduceToPolynomial() + + // Cannot divide by zero, even in cases when the denominator needs to be evaluated. + assertThat(polynomial).isNotValidPolynomial() + } + + @Test + fun testReduce_x_dividedByXMinusX_returnsNullPolynomial() { + val expression = parseAlgebraicExpression("x/(x-x)") + + val polynomial = expression.reduceToPolynomial() + + // Another division by zero, but more complex. + assertThat(polynomial).isNotValidPolynomial() + } + + @Test + fun testReduce_two_dividedByZero_returnsNullPolynomial() { + // Dividing by zero is an optional error that needs to be disabled for this check. + val expression = parseAlgebraicExpression("2/0", errorCheckingMode = REQUIRED_ONLY) + + val polynomial = expression.reduceToPolynomial() + + // Division by zero is not allowed for purely constant polynomials, either. + assertThat(polynomial).isNotValidPolynomial() + } + + @Test + fun testReduce_x_dividedByTwo_returnsOneHalfXPolynomial() { + val expression = parseAlgebraicExpression("x/2") + + val polynomial = expression.reduceToPolynomial() + + // x/2 is treated as (1/2)x (that is, the variable 'x' with coefficient '1/2'). + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isEqualTo(ONE_HALF) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("(1/2)x") + } + + @Test + fun testReduce_one_dividedByX_returnsNullPolynomial() { + val expression = parseAlgebraicExpression("1/x") + + val polynomial = expression.reduceToPolynomial() + + // Polynomials cannot have negative powers, so dividing by a polynomial isn't valid. + assertThat(polynomial).isNotValidPolynomial() + } + + @Test + fun testReduce_x_dividedByX_returnsOnePolynomial() { + val expression = parseAlgebraicExpression("x/x") + + val polynomial = expression.reduceToPolynomial() + + // x/x is just '1'. + assertThat(polynomial).isConstantThat().isIntegerThat().isEqualTo(1) + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("1") + } + + @Test + fun testReduce_x_dividedByNegativeTwo_returnsNegativeOneHalfXPolynomial() { + val expression = parseAlgebraicExpression("x/-2") + + val polynomial = expression.reduceToPolynomial() + + // x/-2 is treated as (-1/2)x (that is, the variable 'x' with coefficient '-1/2'). + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isEqualTo(-ONE_HALF) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("(-1/2)x") + } + + @Test + fun testReduce_xPlusOne_dividedByTwo_returnsOneHalfXPlusOneHalfPolynomial() { + val expression = parseAlgebraicExpression("(x+1)/2") + + val polynomial = expression.reduceToPolynomial() + + // (x+1)/2 expands to (1/2)x+(1/2), a two-term polynomial. + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasCoefficientThat().isEqualTo(ONE_HALF) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + term(1).apply { + hasCoefficientThat().isEqualTo(ONE_HALF) + hasVariableCountThat().isEqualTo(0) + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("(1/2)x + 1/2") + } + + @Test + fun testReduce_xSquaredPlusX_dividedByX_returnsXPlusOnePolynomial() { + val expression = parseAlgebraicExpression("(x^2+x)/x") + + val polynomial = expression.reduceToPolynomial() + + // (x^2+x)/x becomes x+1 ('x' is factored out). + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(0) + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("x + 1") + } + + @Test + fun testReduce_xyPlusY_dividedByX_returnsNullPolynomial() { + val expression = parseAlgebraicExpression("(xy+y)/x") + + val polynomial = expression.reduceToPolynomial() + + // 'x' cannot be fully factored out of 'xy+y'. + assertThat(polynomial).isNotValidPolynomial() + } + + @Test + fun testReduce_xyPlusY_dividedByY_returnsXPlusOnePolynomial() { + val expression = parseAlgebraicExpression("(xy+y)/y") + + val polynomial = expression.reduceToPolynomial() + + // (xy+y)/y becomes x+1 ('y' is factored out). + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(0) + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("x + 1") + } + + @Test + fun testReduce_xyPlusY_dividedByXy_returnsNullPolynomial() { + val expression = parseAlgebraicExpression("(xy+y)/(xy)") + + val polynomial = expression.reduceToPolynomial() + + // 'xy' cannot be cleanly factored out of 'xy+y'. + assertThat(polynomial).isNotValidPolynomial() + } + + @Test + fun testReduce_xYMinusFiveY_dividedByY_returnsXMinusFivePolynomial() { + val expression = parseAlgebraicExpression("(xy-5y)/y") + + val polynomial = expression.reduceToPolynomial() + + // (xy-5y)/y becomes x-5 (demonstrates that variables become coefficients in such cases). + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(-5) + hasVariableCountThat().isEqualTo(0) + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("x - 5") + } + + @Test + fun testReduce_xSquaredMinusOne_dividedByXPlusOne_returnsXMinusOnePolynomial() { + val expression = parseAlgebraicExpression("(x^2-1)/(x+1)") + + val polynomial = expression.reduceToPolynomial() + + // (x^2-1)/(x+1)=x-1. + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(-1) + hasVariableCountThat().isEqualTo(0) + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("x - 1") + } + + @Test + fun testReduce_xSquaredMinusOne_dividedByXMinusOne_returnsXPlusOnePolynomial() { + val expression = parseAlgebraicExpression("(x^2-1)/(x-1)") + + val polynomial = expression.reduceToPolynomial() + + // (x^2-1)/(x-1)=x+1. + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(0) + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("x + 1") + } + + @Test + fun testReduce_xSquaredPlusTwoXPlusOne_dividedByXPlusOne_returnsXPlusOnePolynomial() { + val expression = parseAlgebraicExpression("(x^2+2x+1)/(x+1)") + + val polynomial = expression.reduceToPolynomial() + + // (x^2+2x+1)/(x+1)=x+1. + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(0) + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("x + 1") + } + + @Test + fun testReduce_negThreeXSqAddTwentyThreeXSubFourteen_dividedBySevenSubX_retsThreeXSubTwoPoly() { + val expression = parseAlgebraicExpression("(-3x^2+23x-14)/(7-x)") + + val polynomial = expression.reduceToPolynomial() + + // (-3x^2+23x-14)/(7-x)=3x-2 (demonstrates both deriving a non-one coefficient in the quotient, + // and dividing with negative leading terms). + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(3) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(-2) + hasVariableCountThat().isEqualTo(0) + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("3x - 2") + } + + @Test + fun testReduce_xSquaredMinusTwoXyPlusYSquared_dividedByXMinusY_returnsXMinusYPolynomial() { + val expression = parseAlgebraicExpression("(x^2-2xy+y^2)/(x-y)") + + val polynomial = expression.reduceToPolynomial() + + // (x^2-2xy+y^2)/(x-y)=x-y (demonstrates factoring out both 'x' and 'y' terms). + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(-1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("y") + hasPowerThat().isEqualTo(1) + } + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("x - y") + } + + @Test + fun testReduce_xCubedMinusYCubed_dividedByXMinusY_returnsXSquaredPlusXyPlusYSquaredPolynomial() { + val expression = parseAlgebraicExpression("(x^3-y^3)/(x-y)") + + val polynomial = expression.reduceToPolynomial() + + // (x^3-y^3)/(x-y)=x^2+xy+y^2. This demonstrates a more complex case where a new term can appear + // due to the division. This example comes from: + // https://www.kristakingmath.com/blog/predator-prey-systems-ghtcp-5e2r4-427ab. + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(3) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(2) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(2) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + variable(1).apply { + hasNameThat().isEqualTo("y") + hasPowerThat().isEqualTo(1) + } + } + term(2).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("y") + hasPowerThat().isEqualTo(2) + } + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("x^2 + xy + y^2") + } + + @Test + fun testReduce_xCubedMinusThreeXSqYPlusXySqMinusYCubed_dividedByXMinusYSq_retsXMinusYPoly() { + val expression = parseAlgebraicExpression("(x^3-3x^2y+3xy^2-y^3)/(x-y)^2") + + val polynomial = expression.reduceToPolynomial() + + // (x^3-3x^2y+3xy^2-y^3)/(x-y)^2=x-y (demonstrates dividing a variable term with a power larger + // than 1). + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(-1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("y") + hasPowerThat().isEqualTo(1) + } + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("x - y") + } + + @Test + fun testReduce_zeroRaisedToZero_returnsOne() { + val expression = parseAlgebraicExpression("0^0") + + val polynomial = expression.reduceToPolynomial() + + // 0^0=1 (for consistency with other 'pow' functions). + assertThat(polynomial).isConstantThat().isIntegerThat().isEqualTo(1) + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("1") + } + + @Test + fun testReduce_zeroRaisedToOne_returnsZero() { + val expression = parseAlgebraicExpression("0^1") + + val polynomial = expression.reduceToPolynomial() + + // 0^1=0. + assertThat(polynomial).isConstantThat().isIntegerThat().isEqualTo(0) + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("0") + } + + @Test + fun testReduce_oneRaisedToZero_returnsOne() { + val expression = parseAlgebraicExpression("1^0") + + val polynomial = expression.reduceToPolynomial() + + // 1^0=1. + assertThat(polynomial).isConstantThat().isIntegerThat().isEqualTo(1) + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("1") + } + + @Test + fun testReduce_twoRaisedToZero_returnsOne() { + val expression = parseAlgebraicExpression("2^0") + + val polynomial = expression.reduceToPolynomial() + + // 2^0=1. + assertThat(polynomial).isConstantThat().isIntegerThat().isEqualTo(1) + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("1") + } + + @Test + fun testReduce_xRaisedToZero_returnsOnePolynomial() { + val expression = parseAlgebraicExpression("x^0") + + val polynomial = expression.reduceToPolynomial() + + // x^0 is just 1 since anything raised to '1' is 1. + assertThat(polynomial).isConstantThat().isIntegerThat().isEqualTo(1) + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("1") + } + + @Test + fun testReduce_xRaisedToOne_returnsXPolynomial() { + val expression = parseAlgebraicExpression("x^1") + + val polynomial = expression.reduceToPolynomial() + + // x^1 is just 'x' (i.e. a polynomial with a variable term 'x' with power '1'). + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("x") + } + + @Test + fun testReduce_xRaisedToNegativeOne_returnsNullPolynomial() { + val expression = parseAlgebraicExpression("x^-1") + + val polynomial = expression.reduceToPolynomial() + + // Polynomials cannot have negative powers. + assertThat(polynomial).isNotValidPolynomial() + } + + @Test + fun testReduce_twoRaisedToQuantityThreeMinusSix_returnsOneEighthPolynomial() { + val expression = parseAlgebraicExpression("2^(3-6)") + + val polynomial = expression.reduceToPolynomial() + + // 2^(3-6) evaluates to 1/8 (i.e. constants can be raised to negative powers). + assertThat(polynomial).isConstantThat().isRationalThat().apply { + hasNegativePropertyThat().isFalse() + hasWholeNumberThat().isEqualTo(0) + hasNumeratorThat().isEqualTo(1) + hasDenominatorThat().isEqualTo(8) + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("1/8") + } + + @Test + fun testReduce_xRaisedToQuantityThreeMinusSix_returnsNullPolynomial() { + val expression = parseAlgebraicExpression("x^(3-6)") + + val polynomial = expression.reduceToPolynomial() + + // Polynomials cannot have negative powers. + assertThat(polynomial).isNotValidPolynomial() + } + + @Test + fun testReduce_negativeTwoXQuantityRaisedToTwo_returnsFourXSquaredPolynomial() { + val expression = parseAlgebraicExpression("(-2x)^2") + + val polynomial = expression.reduceToPolynomial() + + // (-2x)^2=4x^2 (negative term goes away and coefficient is multiplied). + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(4) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(2) + } + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("4x^2") + } + + @Test + fun testReduce_negativeTwoXQuantityRaisedToThree_returnsNegativeEightXCubedPolynomial() { + val expression = parseAlgebraicExpression("(-2x)^3") + + val polynomial = expression.reduceToPolynomial() + + // (-2x)^3=-8x^3 (the negative is kept due to an odd power. + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(-8) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(3) + } + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("-8x^3") + } + + @Test + fun testReduce_xYRaisedToTwo_returnsXYSquaredPolynomial() { + val expression = parseAlgebraicExpression("xy^2") + + val polynomial = expression.reduceToPolynomial() + + // For 'xy^2' the 'y' will have power '2' and 'x' will have power '1'. This and related tests + // help to verify that exponentiation assigns the power to the correct variable when parsing + // polynomial syntax. + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(2) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + variable(1).apply { + hasNameThat().isEqualTo("y") + hasPowerThat().isEqualTo(2) + } + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("xy^2") + } + + @Test + fun testReduce_yXRaisedToTwo_returnsXSquaredYPolynomial() { + val expression = parseAlgebraicExpression("yx^2") + + val polynomial = expression.reduceToPolynomial() + + // For 'x^2y' the 'x' will have power '2' and 'y' will have power '1'. + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(2) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(2) + } + variable(1).apply { + hasNameThat().isEqualTo("y") + hasPowerThat().isEqualTo(1) + } + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("x^2y") + } + + @Test + fun testReduce_xRaisedToTwoYRaisedToTwo_returnsXSquaredYSquaredPolynomial() { + val expression = parseAlgebraicExpression("x^2y^2") + + val polynomial = expression.reduceToPolynomial() + + // For 'x^2y^2' both variables have power '2'. + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(2) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(2) + } + variable(1).apply { + hasNameThat().isEqualTo("y") + hasPowerThat().isEqualTo(2) + } + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("x^2y^2") + } + + @Test + fun testReduce_twoRaisedToX_returnsNullPolynomial() { + // Raising to a variable term is an optional error that needs to be disabled for this check. + val expression = parseAlgebraicExpression("2^x", errorCheckingMode = REQUIRED_ONLY) + + val polynomial = expression.reduceToPolynomial() + + // 2^x is not a polynomial since powers must be positive integers. + assertThat(polynomial).isNotValidPolynomial() + } + + @Test + fun testReduce_xRaisedToX_returnsNullPolynomial() { + // Raising to a variable term is an optional error that needs to be disabled for this check. + val expression = parseAlgebraicExpression("x^x", errorCheckingMode = REQUIRED_ONLY) + + val polynomial = expression.reduceToPolynomial() + + // x^x is not a polynomial since powers must be positive integers. + assertThat(polynomial).isNotValidPolynomial() + } + + @Test + fun testReduce_squareRootX_returnsNullPolynomial() { + val expression = parseAlgebraicExpression("sqrt(x)") + + val polynomial = expression.reduceToPolynomial() + + // sqrt(x) is not a polynomial. + assertThat(polynomial).isNotValidPolynomial() + } + + @Test + fun testReduce_squareRootXQuantitySquared_returnsXPolynomial() { + val expression = parseAlgebraicExpression("sqrt(x)^2") + + val polynomial = expression.reduceToPolynomial() + + // This doesn't currently evaluate correctly due to a limitation in the reduction algorithm (it + // can't represent sub-polynomials with fractional powers). + assertThat(polynomial).isNotValidPolynomial() + } + + @Test + fun testReduce_squareRootOfXSquared_returnsXPolynomial() { + val expression = parseAlgebraicExpression("sqrt(x^2)") + + val polynomial = expression.reduceToPolynomial() + + // sqrt(x^2) is simplified to 'x'. + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("x") + } + + @Test + fun testReduce_squareRootOfOnePlusX_returnsNullPolynomial() { + val expression = parseAlgebraicExpression("sqrt(1+x)") + + val polynomial = expression.reduceToPolynomial() + + // 1+x has no square root. + assertThat(polynomial).isNotValidPolynomial() + } + + @Test + fun testReduce_squareRootOfFourXSquared_returnsTwoXPolynomial() { + val expression = parseAlgebraicExpression("sqrt(4x^2)") + + val polynomial = expression.reduceToPolynomial() + + // sqrt(4x^2)=2x. + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(2) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("2x") + } + + @Test + fun testReduce_squareRootOfNegativeFourXSquared_returnsNullPolynomial() { + val expression = parseAlgebraicExpression("sqrt(-4x^2)") + + val polynomial = expression.reduceToPolynomial() + + // sqrt(-4x^2) is not valid since negative even roots result in imaginary results. + assertThat(polynomial).isNotValidPolynomial() + } + + @Test + fun testReduce_squareRootOfXSquaredYSquared_returnsXyPolynomial() { + val expression = parseAlgebraicExpression("√(x^2y^2)") + + val polynomial = expression.reduceToPolynomial() + + // √(x^2y^2) evaluates to xy (i.e. individual variable terms can be extracted and rooted). + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(2) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + variable(1).apply { + hasNameThat().isEqualTo("y") + hasPowerThat().isEqualTo(1) + } + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("xy") + } + + @Test + fun testReduce_squareTwoXSquared_returnsIrrationalCoefficientXPolynomial() { + val expression = parseAlgebraicExpression("√(2x^2)") + + val polynomial = expression.reduceToPolynomial() + + // √(2x^2) evaluates to a polynomial with a decimal coefficient. + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isIrrationalThat().isWithin(1e-5).of(1.414213562) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + } + assertThat(polynomial).evaluatesToPlainTextThat().matches("1.414\\d+x") + } + + @Test + fun testReduce_sixteenXToTheFourth_raisedToOneFourth_returnsTwoXPolynomial() { + val expression = parseAlgebraicExpression("((2x)^4)^(1/4)") + + val polynomial = expression.reduceToPolynomial() + + // ((2x)^4)^(1/4)=2x (demonstrates root-based operations with exponentiation). + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(2) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("2x") + } + + @Test + fun testReduce_negativeSixteenXToTheFourth_raisedToOneFourth_returnsNullPolynomial() { + val expression = parseAlgebraicExpression("(-16x^4)^(1/4)") + + val polynomial = expression.reduceToPolynomial() + + // (-16x^4)^(1/4) is not valid since negative even roots result in imaginary results. + assertThat(polynomial).isNotValidPolynomial() + } + + @Test + fun testReduce_negativeTwentySevenYCubed_raisedToOneThird_returnsNegativeThreeXPolynomial() { + val expression = parseAlgebraicExpression("(-27y^3)^(1/3)") + + val polynomial = expression.reduceToPolynomial() + + // (-27y^3)^(1/3)=-3y (shows that odd roots can accept real-valued negative radicands). + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(-3) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("y") + hasPowerThat().isEqualTo(1) + } + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("-3y") + } + + @Test + fun testReduce_xSquared_raisedToOneHalf_returnsXPolynomial() { + val expression = parseAlgebraicExpression("(x^2)^(1/2)") + + val polynomial = expression.reduceToPolynomial() + + // (x^2)^(1/2) simplifies to just 'x'. + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("x") + } + + @Test + fun testReduce_xToTheOneHalf_squared_returnsXPolynomial() { + val expression = parseAlgebraicExpression("(x^(1/2))^2") + + val polynomial = expression.reduceToPolynomial() + + // This doesn't currently evaluate correctly due to a limitation in the reduction algorithm (it + // can't represent sub-polynomials with fractional powers). + assertThat(polynomial).isNotValidPolynomial() + } + + @Test + fun testReduce_xCubed_raisedToOneThird_returnsXPolynomial() { + val expression = parseAlgebraicExpression("(x^3)^(1/3)") + + val polynomial = expression.reduceToPolynomial() + + // (x^3)^(1/3) simplifies to just 'x'. + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("x") + } + + @Test + fun testReduce_xCubed_raisedToTwoThirds_returnsXSquaredPolynomial() { + val expression = parseAlgebraicExpression("(x^3)^(2/3)") + + val polynomial = expression.reduceToPolynomial() + + // (x^3)^(2/3) simplifies to 'x^2'. + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(2) + } + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("x^2") + } + + @Test + fun testReduce_xToTheOneThird_cubed_returnsXPolynomial() { + val expression = parseAlgebraicExpression("(x^(1/3))^3") + + val polynomial = expression.reduceToPolynomial() + + // This doesn't currently evaluate correctly due to a limitation in the reduction algorithm (it + // can't represent sub-polynomials with fractional powers). + assertThat(polynomial).isNotValidPolynomial() + } + + @Test + fun testReduce_xPlusOne_squared_returnsXSquaredPlusTwoXPlusOnePolynomial() { + val expression = parseAlgebraicExpression("(x+1)^2") + + val polynomial = expression.reduceToPolynomial() + + // (x+1)^2=x^2+2x+1 (simple binomial multiplication). + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(3) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(2) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(2) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + term(2).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(0) + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("x^2 + 2x + 1") + } + + @Test + fun testReduce_xPlusOne_cubed_returnsXCubedPlusThreeXSquaredPlusThreeXPlusOnePolynomial() { + val expression = parseAlgebraicExpression("(x+1)^3") + + val polynomial = expression.reduceToPolynomial() + + // (x+1)^3=x^3+3x^2+3x+1 (simple binomial multiplication per Pascal's triangle). + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(4) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(3) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(3) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(2) + } + } + term(2).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(3) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + term(3).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(0) + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("x^3 + 3x^2 + 3x + 1") + } + + @Test + fun testReduce_xMinusYCubed_returnsXCubedMinusThreeXSqYPlusThreeXYSqMinusYCubedPolynomial() { + val expression = parseAlgebraicExpression("(x-y)^3") + + val polynomial = expression.reduceToPolynomial() + + // (x-y)^3=x^3-3x^2y+3xy^2-y^3 (show that exponentiation works with double variable terms, too). + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(4) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(3) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(-3) + hasVariableCountThat().isEqualTo(2) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(2) + } + variable(1).apply { + hasNameThat().isEqualTo("y") + hasPowerThat().isEqualTo(1) + } + } + term(2).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(3) + hasVariableCountThat().isEqualTo(2) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + variable(1).apply { + hasNameThat().isEqualTo("y") + hasPowerThat().isEqualTo(2) + } + } + term(3).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(-1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("y") + hasPowerThat().isEqualTo(3) + } + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("x^3 - 3x^2y + 3xy^2 - y^3") + } + + @Test + fun testReduce_xSquaredPlusTwoXPlusOne_raisedToOneHalf_returnsNullPolynomial() { + val expression = parseAlgebraicExpression("(x^2+2x+1)^(1/2)") + + val polynomial = expression.reduceToPolynomial() + + // While (x^2+2x+1)^(1/2) can technically be factored to (x+1), the system doesn't yet support + // factoring polynomials via roots. + assertThat(polynomial).isNotValidPolynomial() + } + + @Test + fun testReduce_xRaisedToTwoPlusTwo_returnsXToTheFourthPolynomial() { + val expression = parseAlgebraicExpression("x^(2+2)") + + val polynomial = expression.reduceToPolynomial() + + // x^(2+2)=x^4 (the exponent is evaluated). + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(4) + } + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("x^4") + } + + @Test + fun testReduce_xRaisedToTwoMinusTwo_returnsOnePolynomial() { + val expression = parseAlgebraicExpression("x^(2-2)") + + val polynomial = expression.reduceToPolynomial() + + // x^(2-2)=1 (since 2-2 evaluates to 0, and x^0 is 1). + assertThat(polynomial).isConstantThat().isIntegerThat().isEqualTo(1) + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("1") + } + + @Test + fun testReduce_moreComplexArithmeticExpression_returnsCorrectlyComputedCoefficientsPolynomial() { + val expression = parseAlgebraicExpression("133+3.14*x/(11-15)^2") + + val polynomial = expression.reduceToPolynomial() + + // 133+3.14*x/(11-15)^2 simplifies to 0.19625x+133. + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasCoefficientThat().isIrrationalThat().isWithin(1e-5).of(0.19625) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(133) + hasVariableCountThat().isEqualTo(0) + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("0.19625x + 133") + } + + /* + * Tests to verify that ordering matches https://en.wikipedia.org/wiki/Polynomial#Definition + * (where multiple variables are sorted lexicographically). + */ + + @Test + fun testReduce_xCubedPlusXSquaredPlusXPlusOne_returnsSameOrderPolynomial() { + val expression = parseAlgebraicExpression("x^3+x^2+x+1") + + val polynomial = expression.reduceToPolynomial() + + // x^3+x^2+x+1 retains its order since higher power terms are ordered first. + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(4) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(3) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(2) + } + } + term(2).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + term(3).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(0) + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("x^3 + x^2 + x + 1") + } + + @Test + fun testReduce_onePlusXPlusXSquaredPlusXCubed_returnsXCubedPlusXSquaredPlusXPlusOnePolynomial() { + val expression = parseAlgebraicExpression("1+x+x^2+x^3") + + val polynomial = expression.reduceToPolynomial() + + // 1+x+x^2+x^3 is reversed to x^3+x^2+x+1 since higher power terms are ordered first. + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(4) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(3) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(2) + } + } + term(2).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + term(3).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(0) + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("x^3 + x^2 + x + 1") + } + + @Test + fun testReduce_xyPlusXzPlusYz_returnsSameOrderPolynomial() { + val expression = parseAlgebraicExpression("xy+xz+yz") + + val polynomial = expression.reduceToPolynomial() + + // xy+xz+yz retains its order since multivariable terms are ordered lexicographically. + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(3) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(2) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + variable(1).apply { + hasNameThat().isEqualTo("y") + hasPowerThat().isEqualTo(1) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(2) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + variable(1).apply { + hasNameThat().isEqualTo("z") + hasPowerThat().isEqualTo(1) + } + } + term(2).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(2) + variable(0).apply { + hasNameThat().isEqualTo("y") + hasPowerThat().isEqualTo(1) + } + variable(1).apply { + hasNameThat().isEqualTo("z") + hasPowerThat().isEqualTo(1) + } + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("xy + xz + yz") + } + + @Test + fun testReduce_zYPlusZxPlusYX_returnsXyPlusXzPlusYzPolynomial() { + val expression = parseAlgebraicExpression("zy+zx+yx") + + val polynomial = expression.reduceToPolynomial() + + // zy+zx+yx is reversed in ordered and terms to be xy+xz+yz since multivariable terms are + // ordered lexicographically. + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(3) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(2) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + variable(1).apply { + hasNameThat().isEqualTo("y") + hasPowerThat().isEqualTo(1) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(2) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + variable(1).apply { + hasNameThat().isEqualTo("z") + hasPowerThat().isEqualTo(1) + } + } + term(2).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(2) + variable(0).apply { + hasNameThat().isEqualTo("y") + hasPowerThat().isEqualTo(1) + } + variable(1).apply { + hasNameThat().isEqualTo("z") + hasPowerThat().isEqualTo(1) + } + } + } + assertThat(polynomial).evaluatesToPlainTextThat().isEqualTo("xy + xz + yz") + } + + @Test + fun testReduce_complexMultiVariableOutOfOrderExpression_returnsCorrectlyOrderedPolynomial() { + val expression = parseAlgebraicExpression("3+y+x+yx+x^2y+x^2y^2+y^2x") + + val polynomial = expression.reduceToPolynomial() + + // 3+y+x+yx+x^2y+x^2y^2+y^2x is sorted to: x^2y^2+x^2y+xy^2+xy+x+y+3 per term sorting rules. + // ordered lexicographically. + assertThat(polynomial).apply { + hasTermCountThat().isEqualTo(7) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(2) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(2) + } + variable(1).apply { + hasNameThat().isEqualTo("y") + hasPowerThat().isEqualTo(2) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(2) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(2) + } + variable(1).apply { + hasNameThat().isEqualTo("y") + hasPowerThat().isEqualTo(1) + } + } + term(2).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(2) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + variable(1).apply { + hasNameThat().isEqualTo("y") + hasPowerThat().isEqualTo(2) + } + } + term(3).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(2) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + variable(1).apply { + hasNameThat().isEqualTo("y") + hasPowerThat().isEqualTo(1) + } + } + term(4).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + term(5).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("y") + hasPowerThat().isEqualTo(1) + } + } + term(6).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(3) + hasVariableCountThat().isEqualTo(0) + } + } + assertThat(polynomial) + .evaluatesToPlainTextThat() + .isEqualTo("x^2y^2 + x^2y + xy^2 + xy + x + y + 3") + } + + @Test + fun testEquals_twoPolynomial_twoPolynomial_areEqual() { + val polynomial1 = parsePolynomialFromAlgebraicExpression("2") + val polynomial2 = parsePolynomialFromAlgebraicExpression("2") + + assertThat(polynomial1).isEqualTo(polynomial2) + } + + @Test + fun testEquals_zeroPolynomial_negativeZeroPolynomial_areEqual() { + val polynomial1 = parsePolynomialFromAlgebraicExpression("0") + val polynomial2 = parsePolynomialFromAlgebraicExpression("-0") + + assertThat(polynomial1).isEqualTo(polynomial2) + } + + @Test + fun testEquals_twoPolynomial_negativeTwoPolynomial_areNotEqual() { + val polynomial1 = parsePolynomialFromAlgebraicExpression("2") + val polynomial2 = parsePolynomialFromAlgebraicExpression("-2") + + assertThat(polynomial1).isNotEqualTo(polynomial2) + } + + @Test + fun testEquals_onePlusTwoPolynomial_threePolynomial_areEqual() { + val polynomial1 = parsePolynomialFromAlgebraicExpression("1+2") + val polynomial2 = parsePolynomialFromAlgebraicExpression("3") + + assertThat(polynomial1).isEqualTo(polynomial2) + } + + @Test + fun testEquals_threePolynomial_onePlusTwoPolynomial_areEqual() { + val polynomial1 = parsePolynomialFromAlgebraicExpression("3") + val polynomial2 = parsePolynomialFromAlgebraicExpression("1+2") + + assertThat(polynomial1).isEqualTo(polynomial2) + } + + @Test + fun testEquals_oneMinusTwoPolynomial_negativeOnePolynomial_areEqual() { + val polynomial1 = parsePolynomialFromAlgebraicExpression("1-2") + val polynomial2 = parsePolynomialFromAlgebraicExpression("-1") + + assertThat(polynomial1).isEqualTo(polynomial2) + } + + @Test + fun testEquals_twoTimesSixPolynomial_sixPolynomial_areEqual() { + val polynomial1 = parsePolynomialFromAlgebraicExpression("2*3") + val polynomial2 = parsePolynomialFromAlgebraicExpression("6") + + assertThat(polynomial1).isEqualTo(polynomial2) + } + + @Test + fun testEquals_twoRaisedToThreePolynomial_eightPolynomial_areEqual() { + val polynomial1 = parsePolynomialFromAlgebraicExpression("2^3") + val polynomial2 = parsePolynomialFromAlgebraicExpression("8") + + assertThat(polynomial1).isEqualTo(polynomial2) + } + + @Test + fun testEquals_xPolynomial_xPolynomial_areEqual() { + val polynomial1 = parsePolynomialFromAlgebraicExpression("x") + val polynomial2 = parsePolynomialFromAlgebraicExpression("x") + + assertThat(polynomial1).isEqualTo(polynomial2) + } + + @Test + fun testEquals_xPolynomial_twoPolynomial_areNotEqual() { + val polynomial1 = parsePolynomialFromAlgebraicExpression("x") + val polynomial2 = parsePolynomialFromAlgebraicExpression("2") + + assertThat(polynomial1).isNotEqualTo(polynomial2) + } + + @Test + fun testEquals_onePlusXPolynomial_xPlusOnePolynomial_areEqual() { + val polynomial1 = parsePolynomialFromAlgebraicExpression("1+x") + val polynomial2 = parsePolynomialFromAlgebraicExpression("x+1") + + // Demonstrate that commutativity doesn't matter (for addition). + assertThat(polynomial1).isEqualTo(polynomial2) + } + + @Test + fun testEquals_xPlusYPolynomial_yPlusXPolynomial_areEqual() { + val polynomial1 = parsePolynomialFromAlgebraicExpression("x+y") + val polynomial2 = parsePolynomialFromAlgebraicExpression("y+x") + + // Commutativity doesn't change for variable ordering. + assertThat(polynomial1).isEqualTo(polynomial2) + } + + @Test + fun testEquals_oneMinusXPolynomial_xMinusOnePolynomial_areNotEqual() { + val polynomial1 = parsePolynomialFromAlgebraicExpression("1-x") + val polynomial2 = parsePolynomialFromAlgebraicExpression("x-1") + + // Subtraction is not commutative. + assertThat(polynomial1).isNotEqualTo(polynomial2) + } + + @Test + fun testEquals_twoXPolynomial_xTimesTwoPolynomial_areEqual() { + val polynomial1 = parsePolynomialFromAlgebraicExpression("2x") + val polynomial2 = parsePolynomialFromAlgebraicExpression("x*2") + + // Demonstrate that commutativity doesn't matter (for multiplication). + assertThat(polynomial1).isEqualTo(polynomial2) + } + + @Test + fun testEquals_twoDividedByXPolynomial_xDividedByTwoPolynomial_areNotEqual() { + val polynomial1 = parsePolynomialFromAlgebraicExpression("2/x") + val polynomial2 = parsePolynomialFromAlgebraicExpression("x/2") + + // Division is not commutative. + assertThat(polynomial1).isNotEqualTo(polynomial2) + } + + @Test + fun testEquals_xTimesQuantityXPlusOnePolynomial_xSquaredPlusXPolynomial_areEqual() { + val polynomial1 = parsePolynomialFromAlgebraicExpression("x(x+1)") + val polynomial2 = parsePolynomialFromAlgebraicExpression("x^2+x") + + // Multiplication is expanded. + assertThat(polynomial1).isEqualTo(polynomial2) + } + + @Test + fun testEquals_threeXSubTwoTimesSevenSubX_minusThreeXSqAddTwentyThreeXSubFourteen_areEqual() { + val polynomial1 = parsePolynomialFromAlgebraicExpression("(3x-2)(7-x)") + val polynomial2 = parsePolynomialFromAlgebraicExpression("-3x^2+23x-14") + + // Multiplication is expanded. + assertThat(polynomial1).isEqualTo(polynomial2) + } + + @Test + fun testEquals_quantityXPlusOneSquaredPolynomial_xSquaredPlusTwoXPlusOnePolynomial_areEqual() { + val polynomial1 = parsePolynomialFromAlgebraicExpression("(x+1)^2") + val polynomial2 = parsePolynomialFromAlgebraicExpression("x^2+2x+1") + + // Exponentiation is expanded. + assertThat(polynomial1).isEqualTo(polynomial2) + } + + @Test + fun testEquals_quantityXPlusOneDividedByTwoPolynomial_oneHalfXPlusOneHalfPolynomial_areEqual() { + val polynomial1 = parsePolynomialFromAlgebraicExpression("(x+1)/2") + val polynomial2 = parsePolynomialFromAlgebraicExpression("x/2+(1/2)") + + // Division distributes. + assertThat(polynomial1).isEqualTo(polynomial2) + } + + @Test + fun testEquals_squareRootOnePlusOnePolynomial_squareRootTwoPolynomial_areEqual() { + val polynomial1 = parsePolynomialFromAlgebraicExpression("sqrt(1+1)") + val polynomial2 = parsePolynomialFromAlgebraicExpression("sqrt(2)") + + // The two are equal after evaluation (to contrast with comparable operations). + assertThat(polynomial1).isEqualTo(polynomial2) + } + + @Test + fun testEquals_squareRootTwoPolynomial_squareRootThreePolynomial_areNotEqual() { + val polynomial1 = parsePolynomialFromAlgebraicExpression("sqrt(2)") + val polynomial2 = parsePolynomialFromAlgebraicExpression("sqrt(3)") + + // The evaluated constants are actually different. + assertThat(polynomial1).isNotEqualTo(polynomial2) + } + + @Test + fun testEquals_squareRootTwoXSquaredPolynomial_twoXSquaredToOneHalfPolynomial_areEqual() { + val polynomial1 = parsePolynomialFromAlgebraicExpression("sqrt(2x^2)") + val polynomial2 = parsePolynomialFromAlgebraicExpression("(2x^2)^(1/2)") + + // sqrt() is the same as raising to 1/2. + assertThat(polynomial1).isEqualTo(polynomial2) + } + + @Test + fun testEquals_complexPolynomial_samePolynomialInDifferentOrder_areEqual() { + val polynomial1 = parsePolynomialFromAlgebraicExpression("3+y+x+yx+x^2y+x^2y^2+y^2x") + val polynomial2 = parsePolynomialFromAlgebraicExpression("xy+xy^2+x^2y+y^2x^2+3+x+y") + + // Order doesn't matter. + assertThat(polynomial1).isEqualTo(polynomial2) + } + + private fun parsePolynomialFromAlgebraicExpression(expression: String) = + parseAlgebraicExpression(expression).reduceToPolynomial() + + private companion object { + private fun parseAlgebraicExpression( + expression: String, + errorCheckingMode: ErrorCheckingMode = ALL_ERRORS + ): MathExpression { + return MathExpressionParser.parseAlgebraicExpression( + expression, allowedVariables = listOf("x", "y", "z"), errorCheckingMode + ).getExpectedSuccess() + } + + private inline fun MathParsingResult.getExpectedSuccess(): T { + assertThat(this).isInstanceOf(MathParsingResult.Success::class.java) + return (this as MathParsingResult.Success).result + } + } +} diff --git a/utility/src/test/java/org/oppia/android/util/math/MathExpressionExtensionsTest.kt b/utility/src/test/java/org/oppia/android/util/math/MathExpressionExtensionsTest.kt index 85a734a7ac4..7a52039d973 100644 --- a/utility/src/test/java/org/oppia/android/util/math/MathExpressionExtensionsTest.kt +++ b/utility/src/test/java/org/oppia/android/util/math/MathExpressionExtensionsTest.kt @@ -6,6 +6,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.oppia.android.app.model.MathEquation import org.oppia.android.app.model.MathExpression +import org.oppia.android.testing.math.PolynomialSubject.Companion.assertThat import org.oppia.android.testing.math.RealSubject.Companion.assertThat import org.oppia.android.util.math.MathExpressionParser.Companion.ErrorCheckingMode.ALL_ERRORS import org.oppia.android.util.math.MathExpressionParser.Companion.MathParsingResult @@ -20,7 +21,8 @@ import org.robolectric.annotation.LooperMode * verifications for operations like LaTeX conversion and expression evaluation are part of more * targeted test suites such as [ExpressionToLatexConverterTest] and * [NumericExpressionEvaluatorTest]. For comparable operations, see - * [ExpressionToComparableOperationConverterTest]. + * [ExpressionToComparableOperationConverterTest]. For polynomials, see + * [ExpressionToPolynomialConverterTest]. */ // FunctionName: test names are conventionally named with underscores. // SameParameterValue: tests should have specific context included/excluded for readability. @@ -95,6 +97,43 @@ class MathExpressionExtensionsTest { assertThat(operation1).isNotEqualTo(operation2) } + @Test + fun testToPolynomial_algebraicExpression_returnsCorrectPolynomial() { + val expression = parseAlgebraicExpression("(x^3-y^3)/(x-y)") + + val polynomial = expression.toPolynomial() + + assertThat(polynomial).hasTermCountThat().isEqualTo(3) + assertThat(polynomial).term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(2) + } + } + assertThat(polynomial).term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(2) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + variable(1).apply { + hasNameThat().isEqualTo("y") + hasPowerThat().isEqualTo(1) + } + } + assertThat(polynomial).term(2).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("y") + hasPowerThat().isEqualTo(2) + } + } + } + private companion object { private fun parseNumericExpression(expression: String): MathExpression { return parseNumericExpression(expression, ALL_ERRORS).retrieveExpectedSuccessfulResult() 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 index 4fac2ae26b0..2f8813d8096 100644 --- a/utility/src/test/java/org/oppia/android/util/math/PolynomialExtensionsTest.kt +++ b/utility/src/test/java/org/oppia/android/util/math/PolynomialExtensionsTest.kt @@ -1,6 +1,5 @@ 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 @@ -9,21 +8,27 @@ 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.junit.OppiaParameterizedTestRunner +import org.oppia.android.testing.junit.OppiaParameterizedTestRunner.Iteration +import org.oppia.android.testing.junit.OppiaParameterizedTestRunner.Parameter +import org.oppia.android.testing.junit.OppiaParameterizedTestRunner.RunParameterized +import org.oppia.android.testing.junit.OppiaParameterizedTestRunner.SelectRunnerPlatform +import org.oppia.android.testing.junit.ParameterizedJunitTestRunner +import org.oppia.android.testing.math.PolynomialSubject.Companion.assertThat 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) +@RunWith(OppiaParameterizedTestRunner::class) +@SelectRunnerPlatform(ParameterizedJunitTestRunner::class) @LooperMode(LooperMode.Mode.PAUSED) class PolynomialExtensionsTest { private companion object { - private const val PI = 3.1415 - - private val ONE_HALF_FRACTION = Fraction.newBuilder().apply { + private val ONE_THIRD_FRACTION = Fraction.newBuilder().apply { numerator = 1 - denominator = 2 + denominator = 3 }.build() private val ONE_AND_ONE_HALF_FRACTION = Fraction.newBuilder().apply { @@ -32,40 +37,64 @@ class PolynomialExtensionsTest { wholeNumber = 1 }.build() - private val ZERO_REAL = Real.newBuilder().apply { - integer = 0 + private val THREE_ONES_FRACTION = Fraction.newBuilder().apply { + numerator = 3 + denominator = 1 }.build() - private val ONE_REAL = Real.newBuilder().apply { - integer = 1 + private val THREE_FRACTION = Fraction.newBuilder().apply { + wholeNumber = 3 + denominator = 1 }.build() private val TWO_REAL = Real.newBuilder().apply { integer = 2 }.build() - private val ONE_HALF_REAL = Real.newBuilder().apply { - rational = ONE_HALF_FRACTION + private val THREE_REAL = Real.newBuilder().apply { + integer = 3 + }.build() + + private val FOUR_REAL = Real.newBuilder().apply { + integer = 4 + }.build() + + private val FIVE_REAL = Real.newBuilder().apply { + integer = 5 + }.build() + + private val SEVEN_REAL = Real.newBuilder().apply { + integer = 7 + }.build() + + private val ONE_THIRD_REAL = Real.newBuilder().apply { + rational = ONE_THIRD_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 + private val THREE_ONES_REAL = Real.newBuilder().apply { + rational = THREE_ONES_FRACTION + }.build() + + private val THREE_FRACTION_REAL = Real.newBuilder().apply { + rational = THREE_FRACTION }.build() - private val ZERO_POLYNOMIAL = createPolynomial(createTerm(coefficient = ZERO_REAL)) + private val PI_REAL = Real.newBuilder().apply { + irrational = 3.14 + }.build() 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 ONE_HALF_POLYNOMIAL = createPolynomial(createTerm(coefficient = ONE_HALF)) private val NEGATIVE_ONE_HALF_POLYNOMIAL = - createPolynomial(createTerm(coefficient = -ONE_HALF_REAL)) + createPolynomial(createTerm(coefficient = -ONE_HALF)) private val ONE_AND_ONE_HALF_POLYNOMIAL = createPolynomial(createTerm(coefficient = ONE_AND_ONE_HALF_REAL)) @@ -78,21 +107,39 @@ class PolynomialExtensionsTest { 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))) + createPolynomial(createTerm(coefficient = ONE, createVariable(name = "x", power = 1))) private val NEGATIVE_ONE_X_POLYNOMIAL = - createPolynomial(createTerm(coefficient = -ONE_REAL, createVariable(name = "x", power = 1))) + createPolynomial(createTerm(coefficient = -ONE, 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)) + createTerm(coefficient = ONE), + createTerm(coefficient = ONE, createVariable(name = "x", power = 1)) ) } + @Parameter lateinit var var1: String + @Parameter lateinit var var2: String + @Parameter lateinit var var3: String + + @Test + fun testZeroPolynomial_isEqualToZero() { + val subject = ZERO_POLYNOMIAL + + assertThat(subject).isConstantThat().isIntegerThat().isEqualTo(0) + } + + @Test + fun testOnePolynomial_isEqualToOne() { + val subject = ONE_POLYNOMIAL + + assertThat(subject).isConstantThat().isIntegerThat().isEqualTo(1) + } + @Test fun testIsConstant_default_returnsFalse() { val defaultPolynomial = Polynomial.getDefaultInstance() @@ -175,7 +222,7 @@ class PolynomialExtensionsTest { @Test fun testIsConstant_one_and_two_returnsFalse() { val onePlusTwoPolynomial = - createPolynomial(createTerm(coefficient = ONE_REAL), createTerm(coefficient = TWO_REAL)) + createPolynomial(createTerm(coefficient = ONE), createTerm(coefficient = TWO_REAL)) val result = onePlusTwoPolynomial.isConstant() @@ -223,14 +270,14 @@ class PolynomialExtensionsTest { fun testGetConstant_pi_returnsPi() { val result = PI_POLYNOMIAL.getConstant() - assertThat(result).isIrrationalThat().isWithin(1e-5).of(PI) + assertThat(result).isIrrationalThat().isWithin(1e-5).of(3.14) } @Test fun testGetConstant_negativePi_returnsNegativePi() { val result = NEGATIVE_PI_POLYNOMIAL.getConstant() - assertThat(result).isIrrationalThat().isWithin(1e-5).of(-PI) + assertThat(result).isIrrationalThat().isWithin(1e-5).of(-3.14) } @Test @@ -272,14 +319,14 @@ class PolynomialExtensionsTest { fun testToPlainText_pi_returnsPiString() { val result = PI_POLYNOMIAL.toPlainText() - assertThat(result).isEqualTo("3.1415") + assertThat(result).isEqualTo("3.14") } @Test fun testToPlainText_negativePi_returnsMinusPiString() { val result = NEGATIVE_PI_POLYNOMIAL.toPlainText() - assertThat(result).isEqualTo("-3.1415") + assertThat(result).isEqualTo("-3.14") } @Test @@ -313,8 +360,8 @@ class PolynomialExtensionsTest { @Test fun testToPlainText_oneAndNegativeX_returnsOneMinusXString() { val oneMinusXPolynomial = createPolynomial( - createTerm(coefficient = ONE_REAL), - createTerm(coefficient = -ONE_REAL, createVariable(name = "x", power = 1)) + createTerm(coefficient = ONE), + createTerm(coefficient = -ONE, createVariable(name = "x", power = 1)) ) val result = oneMinusXPolynomial.toPlainText() @@ -326,7 +373,7 @@ class PolynomialExtensionsTest { 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)) + createTerm(coefficient = ONE, createVariable(name = "y", power = 1)) ) val result = oneMinusXPolynomial.toPlainText() @@ -337,9 +384,9 @@ class PolynomialExtensionsTest { @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)) + createTerm(coefficient = ONE), + createTerm(coefficient = ONE, createVariable(name = "x", power = 1)), + createTerm(coefficient = ONE, createVariable(name = "x", power = 2)) ) val result = oneMinusXPolynomial.toPlainText() @@ -350,9 +397,9 @@ class PolynomialExtensionsTest { @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) + createTerm(coefficient = ONE, createVariable(name = "x", power = 2)), + createTerm(coefficient = ONE, createVariable(name = "x", power = 1)), + createTerm(coefficient = ONE) ) val result = oneMinusXPolynomial.toPlainText() @@ -364,15 +411,2548 @@ class PolynomialExtensionsTest { @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) + createTerm(coefficient = ONE, createVariable(name = "x", power = 2)), + createTerm(coefficient = ONE, createVariable(name = "y", power = 3)), + createTerm(coefficient = ONE) ) val result = oneMinusXPolynomial.toPlainText() assertThat(result).isEqualTo("x^2 + y^3 + 1") } + + @Test + fun testRemoveUnnecessaryVariables_zeroX_returnsZero() { + val polynomial = createPolynomial( + createTerm(coefficient = ZERO, createVariable(name = "x", power = 1)) + ) + + val result = polynomial.removeUnnecessaryVariables() + + // 0x becomes just 0. + assertThat(result).isConstantThat().isIntegerThat().isEqualTo(0) + } + + @Test + fun testRemoveUnnecessaryVariables_xPlusZero_returnsX() { + val polynomial = createPolynomial( + createTerm(coefficient = ONE, createVariable(name = "x", power = 1)), + createTerm(coefficient = ZERO) + ) + + val result = polynomial.removeUnnecessaryVariables() + + // x+0 is just x. + assertThat(result).isEqualTo(ONE_X_POLYNOMIAL) + } + + @Test + fun testRemoveUnnecessaryVariables_zeroXPlusOne_returnsOne() { + val polynomial = createPolynomial( + createTerm(coefficient = ZERO, createVariable(name = "x", power = 1)), + createTerm(coefficient = ONE) + ) + + val result = polynomial.removeUnnecessaryVariables() + + // 0x+1 is just 1. + assertThat(result).isConstantThat().isIntegerThat().isEqualTo(1) + } + + @Test + fun testRemoveUnnecessaryVariables_zeroXPlusZero_returnsZero() { + val polynomial = createPolynomial( + createTerm(coefficient = ZERO, createVariable(name = "x", power = 1)), + createTerm(coefficient = ZERO) + ) + + val result = polynomial.removeUnnecessaryVariables() + + // 0x+0 is just 0. + assertThat(result).isConstantThat().isIntegerThat().isEqualTo(0) + } + + @Test + fun testRemoveUnnecessaryVariables_zeroXSquaredPlusZeroXPlusTwo_returnsTwo() { + val polynomial = createPolynomial( + createTerm(coefficient = ZERO, createVariable(name = "x", power = 2)), + createTerm(coefficient = ZERO, createVariable(name = "x", power = 1)), + createTerm(coefficient = TWO_REAL) + ) + + val result = polynomial.removeUnnecessaryVariables() + + // 0x^2+0x+2 is just 2. + assertThat(result).isConstantThat().isIntegerThat().isEqualTo(2) + } + + @Test + fun testRemoveUnnecessaryVariables_zeroPlusOnePlusZeroXPlusZero_returnsOne() { + val polynomial = createPolynomial( + createTerm(coefficient = ZERO), + createTerm(coefficient = ONE), + createTerm(coefficient = ZERO, createVariable(name = "x", power = 1)), + createTerm(coefficient = ZERO) + ) + + val result = polynomial.removeUnnecessaryVariables() + + // 0+1+0x+0 is just 1. + assertThat(result).isConstantThat().isIntegerThat().isEqualTo(1) + } + + @Test + fun testSimplifyRationals_oneX_returnsOneX() { + val polynomial = createPolynomial( + createTerm(coefficient = ONE, createVariable(name = "x", power = 1)) + ) + + val result = polynomial.simplifyRationals() + + // x stays as x. + assertThat(result).isEqualTo(ONE_X_POLYNOMIAL) + } + + @Test + fun testSimplifyRationals_oneHalfX_returnsOneHalfX() { + val polynomial = createPolynomial( + createTerm(coefficient = ONE_HALF, createVariable(name = "x", power = 1)) + ) + + val result = polynomial.simplifyRationals() + + // (1/2)x stays as (1/2)x. + assertThat(result).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasVariableCountThat().isEqualTo(1) + hasCoefficientThat().isEqualTo(ONE_HALF) + variable(0).hasNameThat().isEqualTo("x") + variable(0).hasPowerThat().isEqualTo(1) + } + } + } + + @Test + fun testSimplifyRationals_threeOnesX_returnsThreeOnesX() { + val polynomial = createPolynomial( + createTerm(coefficient = THREE_ONES_REAL, createVariable(name = "x", power = 1)) + ) + + val result = polynomial.simplifyRationals() + + // (3/1)x stays as 3x. + assertThat(result).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasVariableCountThat().isEqualTo(1) + hasCoefficientThat().isIntegerThat().isEqualTo(3) + variable(0).hasNameThat().isEqualTo("x") + variable(0).hasPowerThat().isEqualTo(1) + } + } + } + + @Test + fun testSimplifyRationals_negativeThreeXAsFraction_returnsNegativeThreeXWithInteger() { + val polynomial = createPolynomial( + createTerm(coefficient = -THREE_FRACTION_REAL, createVariable(name = "x", power = 1)) + ) + + val result = polynomial.simplifyRationals() + + // -3x (fraction) becomes -3x (integer). + assertThat(result).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasVariableCountThat().isEqualTo(1) + hasCoefficientThat().isIntegerThat().isEqualTo(-3) + variable(0).hasNameThat().isEqualTo("x") + variable(0).hasPowerThat().isEqualTo(1) + } + } + } + + @Test + fun testSimplifyRationals_xPlusThreeFractionXSquared_returnsXPlusThreeXSquared() { + val polynomial = createPolynomial( + createTerm(coefficient = ONE, createVariable(name = "x", power = 1)), + createTerm(coefficient = THREE_FRACTION_REAL, createVariable(name = "x", power = 2)) + ) + + val result = polynomial.simplifyRationals() + + // x+3x (fraction) becomes x+3x (integer). + assertThat(result).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasVariableCountThat().isEqualTo(1) + hasCoefficientThat().isIntegerThat().isEqualTo(1) + variable(0).hasNameThat().isEqualTo("x") + variable(0).hasPowerThat().isEqualTo(1) + } + term(1).apply { + hasVariableCountThat().isEqualTo(1) + hasCoefficientThat().isIntegerThat().isEqualTo(3) + variable(0).hasNameThat().isEqualTo("x") + variable(0).hasPowerThat().isEqualTo(2) + } + } + } + + @Test + fun testSort_one_returnsOne() { + val polynomial = createPolynomial(createTerm(coefficient = ONE)) + + val result = polynomial.sort() + + assertThat(result).isConstantThat().isIntegerThat().isEqualTo(1) + } + + @Test + fun testSort_x_returnsX() { + val polynomial = createPolynomial( + createTerm(coefficient = ONE, createVariable(name = "x", power = 1)) + ) + + val result = polynomial.sort() + + assertThat(result).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + } + } + + @Test + fun testSort_onePlusTwo_returnsTwoPlusOne() { + val polynomial = createPolynomial( + createTerm(coefficient = ONE), createTerm(coefficient = TWO_REAL) + ) + + val result = polynomial.sort() + + // 1+2 becomes 2+1 (larger number sorted first). + assertThat(result).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(2) + hasVariableCountThat().isEqualTo(0) + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(0) + } + } + } + + @Test + fun testSort_twoPlusOne_returnsTwoPlusOne() { + val polynomial = createPolynomial( + createTerm(coefficient = TWO_REAL), createTerm(coefficient = ONE) + ) + + val result = polynomial.sort() + + // 2+1 stays as 2+1 (larger number sorted first). + assertThat(result).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(2) + hasVariableCountThat().isEqualTo(0) + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(0) + } + } + } + + @Test + fun testSort_xPlusX_returnsXPlusX() { + val polynomial = createPolynomial( + createTerm(coefficient = ONE, createVariable(name = "x", power = 1)), + createTerm(coefficient = ONE, createVariable(name = "x", power = 1)) + ) + + val result = polynomial.sort() + + // x+x is symmetrical, so nothing changes. + assertThat(result).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + } + } + + @Test + fun testSort_xPlusOne_returnsXPlusOne() { + val polynomial = createPolynomial( + createTerm(coefficient = ONE, createVariable(name = "x", power = 1)), + createTerm(coefficient = ONE) + ) + + val result = polynomial.sort() + + // x+1 stays as x+1 (variables are sorted first). + assertThat(result).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(0) + } + } + } + + @Test + fun testSort_onePlusX_returnsXPlusOne() { + val polynomial = createPolynomial( + createTerm(coefficient = ONE), + createTerm(coefficient = ONE, createVariable(name = "x", power = 1)) + ) + + val result = polynomial.sort() + + // 1+x becomes x+1 (variables are sorted first). + assertThat(result).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(0) + } + } + } + + @Test + fun testSort_xPlusTwoX_returnsTwoXPlusX() { + val polynomial = createPolynomial( + createTerm(coefficient = ONE, createVariable(name = "x", power = 1)), + createTerm(coefficient = TWO_REAL, createVariable(name = "x", power = 1)) + ) + + val result = polynomial.sort() + + // x+2x becomes 2x+x (larger coefficients are sorted first). + assertThat(result).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(2) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + } + } + + @Test + fun testSort_xPlusXSquared_returnsXSquaredPlusX() { + val polynomial = createPolynomial( + createTerm(coefficient = ONE, createVariable(name = "x", power = 1)), + createTerm(coefficient = ONE, createVariable(name = "x", power = 2)) + ) + + val result = polynomial.sort() + + // x+x^2 becomes x^2+x (larger powers are sorted first). + assertThat(result).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(2) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + } + } + + @Test + fun testSort_xSquaredPlusX_returnsXSquaredPlusX() { + val polynomial = createPolynomial( + createTerm(coefficient = ONE, createVariable(name = "x", power = 2)), + createTerm(coefficient = ONE, createVariable(name = "x", power = 1)) + ) + + val result = polynomial.sort() + + // x^2+x stays as x^2+x (larger powers are sorted first). + assertThat(result).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(2) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + } + } + + @Test + fun testSort_xMinusXSquared_returnsNegativeXSquaredPlusX() { + val polynomial = createPolynomial( + createTerm(coefficient = ONE, createVariable(name = "x", power = 1)), + createTerm(coefficient = -ONE, createVariable(name = "x", power = 2)) + ) + + val result = polynomial.sort() + + // 1-x^2 becomes -x^2+1 (larger powers are sorted first). + assertThat(result).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(-1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(2) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + } + } + + @Test + fun testSort_negativeXSquaredPlusX_returnsNegativeXSquaredPlusX() { + val polynomial = createPolynomial( + createTerm(coefficient = -ONE, createVariable(name = "x", power = 2)), + createTerm(coefficient = ONE, createVariable(name = "x", power = 1)) + ) + + val result = polynomial.sort() + + // -x^2+1 stays as -x^2+1 (larger powers are sorted first). + assertThat(result).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(-1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(2) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + } + } + + @Test + fun testSort_yPlusXy_returnsXyPlusY() { + val polynomial = createPolynomial( + createTerm(coefficient = ONE, createVariable(name = "y", power = 1)), + createTerm( + coefficient = ONE, + createVariable(name = "x", power = 1), + createVariable(name = "y", power = 1) + ) + ) + + val result = polynomial.sort() + + // y+xy becomes xy+y (x variables are sorted first). + assertThat(result).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(2) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + variable(1).apply { + hasNameThat().isEqualTo("y") + hasPowerThat().isEqualTo(1) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("y") + hasPowerThat().isEqualTo(1) + } + } + } + } + + @Test + fun testSort_xPlusXy_returnsXyPlusX() { + val polynomial = createPolynomial( + createTerm(coefficient = ONE, createVariable(name = "x", power = 1)), + createTerm( + coefficient = ONE, + createVariable(name = "x", power = 1), + createVariable(name = "y", power = 1) + ) + ) + + val result = polynomial.sort() + + // x+xy becomes xy+x (more variables are sorted first) + assertThat(result).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(2) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + variable(1).apply { + hasNameThat().isEqualTo("y") + hasPowerThat().isEqualTo(1) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + } + } + + @Test + fun testSort_xyPlusZyx_returnsXyzPlusXy() { + val polynomial = createPolynomial( + createTerm( + coefficient = ONE, + createVariable(name = "x", power = 1), + createVariable(name = "y", power = 1) + ), + createTerm( + coefficient = ONE, + createVariable(name = "x", power = 1), + createVariable(name = "y", power = 1), + createVariable(name = "z", power = 1) + ) + ) + + val result = polynomial.sort() + + // xy+zyx becomes xyz+xy (again, more variables are sorted first). Also, variables are + // rearranged lexicographically. + assertThat(result).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(3) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + variable(1).apply { + hasNameThat().isEqualTo("y") + hasPowerThat().isEqualTo(1) + } + variable(2).apply { + hasNameThat().isEqualTo("z") + hasPowerThat().isEqualTo(1) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(2) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + variable(1).apply { + hasNameThat().isEqualTo("y") + hasPowerThat().isEqualTo(1) + } + } + } + } + + @Test + fun testSort_zyPlusYx_returnsXyPlusYz() { + val polynomial = createPolynomial( + createTerm( + coefficient = ONE, + createVariable(name = "z", power = 1), + createVariable(name = "y", power = 1) + ), + createTerm( + coefficient = ONE, + createVariable(name = "x", power = 1), + createVariable(name = "y", power = 1) + ) + ) + + val result = polynomial.sort() + + // zy+yx becomes xy+yz (sorted lexicographically). + assertThat(result).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(2) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + variable(1).apply { + hasNameThat().isEqualTo("y") + hasPowerThat().isEqualTo(1) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(2) + variable(0).apply { + hasNameThat().isEqualTo("y") + hasPowerThat().isEqualTo(1) + } + variable(1).apply { + hasNameThat().isEqualTo("z") + hasPowerThat().isEqualTo(1) + } + } + } + } + + @Test + fun testSort_xyzPlusYXSquared_returnsXSquaredYPlusXyz() { + val polynomial = createPolynomial( + createTerm( + coefficient = ONE, + createVariable(name = "x", power = 1), + createVariable(name = "y", power = 1), + createVariable(name = "z", power = 1) + ), + createTerm( + coefficient = ONE, + createVariable(name = "y", power = 1), + createVariable(name = "x", power = 2) + ) + ) + + val result = polynomial.sort() + + // xyz+yx^2 becomes x^2y+xyz (despite xyz having more variables, the higher power of x^2y + // prioritizes it). + assertThat(result).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(2) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(2) + } + variable(1).apply { + hasNameThat().isEqualTo("y") + hasPowerThat().isEqualTo(1) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(3) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + variable(1).apply { + hasNameThat().isEqualTo("y") + hasPowerThat().isEqualTo(1) + } + variable(2).apply { + hasNameThat().isEqualTo("z") + hasPowerThat().isEqualTo(1) + } + } + } + } + + @Test + fun testSort_xSquaredY_plusX_plusYCubed_plusXSquared_returnsYCubedPlusXSqYPlusXSqPlusX() { + val polynomial = createPolynomial( + createTerm( + coefficient = ONE, + createVariable(name = "x", power = 2), + createVariable(name = "y", power = 1) + ), + createTerm(coefficient = ONE, createVariable(name = "x", power = 1)), + createTerm(coefficient = ONE, createVariable(name = "y", power = 3)), + createTerm(coefficient = ONE, createVariable(name = "x", power = 2)) + ) + + val result = polynomial.sort() + + // x^2y+x+y^3+x^2 becomes x^2y+x^2+x+y^3 per rules demonstrated in earlier tests. This test + // brings more of them together in one example, plus note that x terms are always fully listed + // first. + assertThat(result).apply { + hasTermCountThat().isEqualTo(4) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(2) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(2) + } + variable(1).apply { + hasNameThat().isEqualTo("y") + hasPowerThat().isEqualTo(1) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(2) + } + } + term(2).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + term(3).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("y") + hasPowerThat().isEqualTo(3) + } + } + } + } + + @Test + @RunParameterized( + Iteration("x+y+z", "var1=x", "var2=y", "var3=z"), + Iteration("x+z+y", "var1=x", "var2=z", "var3=y"), + Iteration("y+x+z", "var1=y", "var2=x", "var3=z"), + Iteration("y+z+x", "var1=y", "var2=z", "var3=x"), + Iteration("z+x+y", "var1=z", "var2=x", "var3=y"), + Iteration("z+y+x", "var1=z", "var2=y", "var3=x") + ) + fun testSort_xPlusYPlusZ_inAnyOrder_returnsXPlusYPlusZ() { + val polynomial = createPolynomial( + createTerm(coefficient = ONE, createVariable(name = var1, power = 1)), + createTerm(coefficient = ONE, createVariable(name = var2, power = 1)), + createTerm(coefficient = ONE, createVariable(name = var3, power = 1)) + ) + + val result = polynomial.sort() + + // Regardless of what order x, y, and z are combined in a polynomial, the sorted result is + // always x+y+z (per lexicographical sorting of the variable names themselves). + assertThat(result).apply { + hasTermCountThat().isEqualTo(3) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("y") + hasPowerThat().isEqualTo(1) + } + } + term(2).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("z") + hasPowerThat().isEqualTo(1) + } + } + } + } + + /* Operator tests. */ + + @Test + fun testUnaryMinus_zero_returnsZero() { + val polynomial = ZERO_POLYNOMIAL + + val result = -polynomial + + // negate(0) stays as 0. + assertThat(result).isConstantThat().isIntegerThat().isEqualTo(0) + } + + @Test + fun testUnaryMinus_one_returnsNegativeOne() { + val polynomial = ONE_POLYNOMIAL + + val result = -polynomial + + // negate(1) becomes -1. + assertThat(result).isConstantThat().isIntegerThat().isEqualTo(-1) + } + + @Test + fun testUnaryMinus_x_returnsNegativeX() { + val polynomial = createPolynomial( + createTerm(coefficient = ONE, createVariable(name = "x", power = 1)) + ) + + val result = -polynomial + + // negate(x) becomes -x. + assertThat(result).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(-1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + } + } + + @Test + fun testUnaryMinus_negativeX_returnsX() { + val polynomial = createPolynomial( + createTerm(coefficient = -ONE, createVariable(name = "x", power = 1)) + ) + + val result = -polynomial + + // negate(-x) becomes x. + assertThat(result).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + } + } + + @Test + fun testUnaryMinus_xSquaredPlusX_returnsNegativeXSquaredMinusX() { + val polynomial = createPolynomial( + createTerm(coefficient = ONE, createVariable(name = "x", power = 2)), + createTerm(coefficient = ONE, createVariable(name = "x", power = 1)) + ) + + val result = -polynomial + + // negate(x^2+x) becomes -x^2-x. + assertThat(result).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(-1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(2) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(-1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + } + } + + @Test + fun testUnaryMinus_oneMinusX_returnsNegativeOnePlusX() { + val polynomial = createPolynomial( + createTerm(coefficient = ONE), + createTerm(coefficient = -ONE, createVariable(name = "x", power = 1)) + ) + + val result = -polynomial + + // negate(1-x) becomes -1+x. + assertThat(result).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(-1) + hasVariableCountThat().isEqualTo(0) + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + } + } + + @Test + fun testPlus_zeroAndZero_returnsZero() { + val polynomial1 = ZERO_POLYNOMIAL + val polynomial2 = ZERO_POLYNOMIAL + + val result = polynomial1 + polynomial2 + + // 0+0=0. + assertThat(result).isConstantThat().isIntegerThat().isEqualTo(0) + } + + @Test + fun testPlus_zeroAndOne_returnsOne() { + val polynomial1 = ZERO_POLYNOMIAL + val polynomial2 = ONE_POLYNOMIAL + + val result = polynomial1 + polynomial2 + + // 0+1=1. + assertThat(result).isConstantThat().isIntegerThat().isEqualTo(1) + } + + @Test + fun testPlus_zeroAndX_returnsX() { + val polynomial1 = ZERO_POLYNOMIAL + val polynomial2 = ONE_X_POLYNOMIAL + + val result = polynomial1 + polynomial2 + + // 0+x=x. + assertThat(result).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + } + } + + @Test + fun testPlus_oneAndX_returnsOnePlusX() { + val polynomial1 = ONE_POLYNOMIAL + val polynomial2 = ONE_X_POLYNOMIAL + + val result = polynomial1 + polynomial2 + + // poly(1)+poly(x)=poly(1+x). + assertThat(result).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(0) + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + } + } + + @Test + fun testPlus_xAndOne_returnsXPlusOne() { + val polynomial1 = ONE_X_POLYNOMIAL + val polynomial2 = ONE_POLYNOMIAL + + val result = polynomial1 + polynomial2 + + // poly(x)+poly(1)=poly(x+1). Per sorting, this shows commutativity with the above operation. + assertThat(result).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(0) + } + } + } + + @Test + fun testPlus_xAndX_returnsTwoX() { + val polynomial1 = ONE_X_POLYNOMIAL + val polynomial2 = ONE_X_POLYNOMIAL + + val result = polynomial1 + polynomial2 + + // x+x=2x (shows combining like terms). + assertThat(result).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(2) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + } + } + + @Test + fun testPlus_xAndNegativeX_returnsZero() { + val polynomial1 = ONE_X_POLYNOMIAL + val polynomial2 = NEGATIVE_ONE_X_POLYNOMIAL + + val result = polynomial1 + polynomial2 + + // x+-x=0 (term elimination). + assertThat(result).isConstantThat().isIntegerThat().isEqualTo(0) + } + + @Test + fun testPlus_xSquaredAndX_returnsXSquaredPlusX() { + val polynomial1 = createPolynomial( + createTerm(coefficient = ONE, createVariable(name = "x", power = 2)) + ) + val polynomial2 = ONE_X_POLYNOMIAL + + val result = polynomial1 + polynomial2 + + // poly(x^2)+poly(x)=poly(x^+x). + assertThat(result).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(2) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + } + } + + @Test + fun testMinus_zeroAndZero_returnsZero() { + val polynomial1 = ZERO_POLYNOMIAL + val polynomial2 = ZERO_POLYNOMIAL + + val result = polynomial1 - polynomial2 + + // 0-0=0 (term elimination). + assertThat(result).isConstantThat().isIntegerThat().isEqualTo(0) + } + + @Test + fun testMinus_oneAndZero_returnsOne() { + val polynomial1 = ONE_POLYNOMIAL + val polynomial2 = ZERO_POLYNOMIAL + + val result = polynomial1 - polynomial2 + + // 1-0=1. + assertThat(result).isConstantThat().isIntegerThat().isEqualTo(1) + } + + @Test + fun testMinus_xAndZero_returnsX() { + val polynomial1 = ONE_X_POLYNOMIAL + val polynomial2 = ZERO_POLYNOMIAL + + val result = polynomial1 - polynomial2 + + // x-0=x. + assertThat(result).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + } + } + + @Test + fun testMinus_xAndOne_returnsXMinusOne() { + val polynomial1 = ONE_X_POLYNOMIAL + val polynomial2 = ONE_POLYNOMIAL + + val result = polynomial1 - polynomial2 + + // poly(x)-poly(1)=poly(x-1). + assertThat(result).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(-1) + hasVariableCountThat().isEqualTo(0) + } + } + } + + @Test + fun testMinus_oneAndX_returnsOneMinusX() { + val polynomial1 = ONE_POLYNOMIAL + val polynomial2 = ONE_X_POLYNOMIAL + + val result = polynomial1 - polynomial2 + + // poly(1)-poly(x)=poly(1-x). Shows anticommutativity. + assertThat(result).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(0) + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(-1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + } + } + + @Test + fun testMinus_twoXAndX_returnsX() { + val polynomial1 = TWO_X_POLYNOMIAL + val polynomial2 = ONE_X_POLYNOMIAL + + val result = polynomial1 - polynomial2 + + // 2x-x=x. + assertThat(result).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + } + } + + @Test + fun testMinus_xAndX_returnsZero() { + val polynomial1 = ONE_X_POLYNOMIAL + val polynomial2 = ONE_X_POLYNOMIAL + + val result = polynomial1 - polynomial2 + + // x-x=0. + assertThat(result).isConstantThat().isIntegerThat().isEqualTo(0) + } + + @Test + fun testMinus_xAndNegativeX_returnsTwoX() { + val polynomial1 = ONE_X_POLYNOMIAL + val polynomial2 = NEGATIVE_ONE_X_POLYNOMIAL + + val result = polynomial1 - polynomial2 + + // x - -x=2x. + assertThat(result).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(2) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + } + } + + @Test + fun testMinus_negativeXAndX_returnsNegativeTwoX() { + val polynomial1 = NEGATIVE_ONE_X_POLYNOMIAL + val polynomial2 = ONE_X_POLYNOMIAL + + val result = polynomial1 - polynomial2 + + // -x - x=-2x. + assertThat(result).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(-2) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + } + } + + @Test + fun testMinus_negativeXAndNegativeX_returnsZero() { + val polynomial1 = NEGATIVE_ONE_X_POLYNOMIAL + val polynomial2 = NEGATIVE_ONE_X_POLYNOMIAL + + val result = polynomial1 - polynomial2 + + // -x - -x=0. + assertThat(result).isConstantThat().isIntegerThat().isEqualTo(0) + } + + @Test + fun testMinus_xSquaredAndX_returnsXSquaredMinusX() { + val polynomial1 = createPolynomial( + createTerm(coefficient = ONE, createVariable(name = "x", power = 2)) + ) + val polynomial2 = ONE_X_POLYNOMIAL + + val result = polynomial1 - polynomial2 + + // poly(x^2)-poly(x)=poly(x^2-x). + assertThat(result).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(2) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(-1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + } + } + + @Test + fun testTimes_zeroAndZero_returnsZero() { + val polynomial1 = ZERO_POLYNOMIAL + val polynomial2 = ZERO_POLYNOMIAL + + val result = polynomial1 * polynomial2 + + // 0*0=0. + assertThat(result).isConstantThat().isIntegerThat().isEqualTo(0) + } + + @Test + fun testTimes_zeroAndOne_returnsZero() { + val polynomial1 = ZERO_POLYNOMIAL + val polynomial2 = ONE_POLYNOMIAL + + val result = polynomial1 * polynomial2 + + // 0*1=0. + assertThat(result).isConstantThat().isIntegerThat().isEqualTo(0) + } + + @Test + fun testTimes_oneAndOne_returnsOne() { + val polynomial1 = ONE_POLYNOMIAL + val polynomial2 = ONE_POLYNOMIAL + + val result = polynomial1 * polynomial2 + + // 1*1=1. + assertThat(result).isConstantThat().isIntegerThat().isEqualTo(1) + } + + @Test + fun testTimes_twoAndThree_returnsSix() { + val polynomial1 = TWO_POLYNOMIAL + val polynomial2 = createPolynomial(createTerm(coefficient = THREE_REAL)) + + val result = polynomial1 * polynomial2 + + // 2*3=6. + assertThat(result).isConstantThat().isIntegerThat().isEqualTo(6) + } + + @Test + fun testTimes_xAndZero_returnsZero() { + val polynomial1 = ONE_X_POLYNOMIAL + val polynomial2 = ZERO_POLYNOMIAL + + val result = polynomial1 * polynomial2 + + // x*0=0. + assertThat(result).isConstantThat().isIntegerThat().isEqualTo(0) + } + + @Test + fun testTimes_xAndOne_returnsX() { + val polynomial1 = ONE_X_POLYNOMIAL + val polynomial2 = ONE_POLYNOMIAL + + val result = polynomial1 * polynomial2 + + // x*1=x. + assertThat(result).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + } + } + + @Test + fun testTimes_xAndX_returnsXSquared() { + val polynomial1 = ONE_X_POLYNOMIAL + val polynomial2 = ONE_X_POLYNOMIAL + + val result = polynomial1 * polynomial2 + + // x*x=x^2. + assertThat(result).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(2) + } + } + } + } + + @Test + fun testTimes_threeXSquaredAndTwoX_returnsSixXCubed() { + val polynomial1 = createPolynomial( + createTerm(coefficient = THREE_REAL, createVariable(name = "x", power = 2)) + ) + val polynomial2 = createPolynomial( + createTerm(coefficient = TWO_REAL, createVariable(name = "x", power = 1)) + ) + + val result = polynomial1 * polynomial2 + + // 3x^2*2x=6x^3. + assertThat(result).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(6) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(3) + } + } + } + } + + @Test + fun testTimes_twoXAndThreeXSquared_returnsSixXCubed() { + val polynomial1 = createPolynomial( + createTerm(coefficient = TWO_REAL, createVariable(name = "x", power = 1)) + ) + val polynomial2 = createPolynomial( + createTerm(coefficient = THREE_REAL, createVariable(name = "x", power = 2)) + ) + + val result = polynomial1 * polynomial2 + + // 2x*3x^2=6x^3. This demonstrates multiplication commutativity. + assertThat(result).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(6) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(3) + } + } + } + } + + @Test + fun testTimes_xAndNegativeX_returnsNegativeXSquared() { + val polynomial1 = ONE_X_POLYNOMIAL + val polynomial2 = NEGATIVE_ONE_X_POLYNOMIAL + + val result = polynomial1 * polynomial2 + + // x*(-x)=-x^2. + assertThat(result).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(-1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(2) + } + } + } + } + + @Test + fun testTimes_negativeXAndX_returnsNegativeXSquared() { + val polynomial1 = NEGATIVE_ONE_X_POLYNOMIAL + val polynomial2 = ONE_X_POLYNOMIAL + + val result = polynomial1 * polynomial2 + + // (-x)*x=-x^2. + assertThat(result).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(-1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(2) + } + } + } + } + + @Test + fun testTimes_negativeXAndNegativeX_returnsXSquared() { + val polynomial1 = NEGATIVE_ONE_X_POLYNOMIAL + val polynomial2 = NEGATIVE_ONE_X_POLYNOMIAL + + val result = polynomial1 * polynomial2 + + // (-x)*(-x)=x^2 (negatives cancel out). + assertThat(result).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(2) + } + } + } + } + + @Test + fun testTimes_negativeFiveX_sevenX_returnsNegativeThirtyFiveXSquared() { + val polynomial1 = createPolynomial( + createTerm(coefficient = -FIVE_REAL, createVariable(name = "x", power = 1)) + ) + val polynomial2 = createPolynomial( + createTerm(coefficient = SEVEN_REAL, createVariable(name = "x", power = 1)) + ) + + val result = polynomial1 * polynomial2 + + // -5x*7x=-35x^2. + assertThat(result).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(-35) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(2) + } + } + } + } + + @Test + fun testTimes_onePlusX_onePlusX_returnsOnePlus2XPlusXSquared() { + val polynomial1 = createPolynomial( + createTerm(coefficient = ONE), + createTerm(coefficient = ONE, createVariable(name = "x", power = 1)) + ) + val polynomial2 = createPolynomial( + createTerm(coefficient = ONE), + createTerm(coefficient = ONE, createVariable(name = "x", power = 1)) + ) + + val result = polynomial1 * polynomial2 + + // (1+x)*(1+x)=1+2x+x^2 (like terms are combined). + assertThat(result).apply { + hasTermCountThat().isEqualTo(3) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(0) + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(2) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + term(2).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(2) + } + } + } + } + + @Test + fun testTimes_xPlusOne_xMinusOne_returnsXSquaredMinusOne() { + val polynomial1 = createPolynomial( + createTerm(coefficient = ONE, createVariable(name = "x", power = 1)), + createTerm(coefficient = ONE) + ) + val polynomial2 = createPolynomial( + createTerm(coefficient = ONE, createVariable(name = "x", power = 1)), + createTerm(coefficient = -ONE) + ) + + val result = polynomial1 * polynomial2 + + // (x+1)*(x-1)=x^2-1 (negative terms are eliminated). + assertThat(result).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(2) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(-1) + hasVariableCountThat().isEqualTo(0) + } + } + } + + @Test + fun testTimes_xMinusOne_xPlusOne_returnsXSquaredMinusOne() { + val polynomial1 = createPolynomial( + createTerm(coefficient = ONE, createVariable(name = "x", power = 1)), + createTerm(coefficient = -ONE) + ) + val polynomial2 = createPolynomial( + createTerm(coefficient = ONE, createVariable(name = "x", power = 1)), + createTerm(coefficient = ONE) + ) + + val result = polynomial1 * polynomial2 + + // (x-1)*(x+1)=x^2-1 (commutativity works for combining terms, too). + assertThat(result).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(2) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(-1) + hasVariableCountThat().isEqualTo(0) + } + } + } + + @Test + fun testTimes_twoXy_threeXSquaredY_returnsSixXCubedYSquared() { + val polynomial1 = createPolynomial( + createTerm( + coefficient = TWO_REAL, + createVariable(name = "x", power = 1), + createVariable(name = "y", power = 1) + ) + ) + val polynomial2 = createPolynomial( + createTerm( + coefficient = THREE_REAL, + createVariable(name = "x", power = 2), + createVariable(name = "y", power = 1) + ) + ) + + val result = polynomial1 * polynomial2 + + // 2xy*3x^2y=6x^3y^2. + assertThat(result).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(6) + hasVariableCountThat().isEqualTo(2) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(3) + } + variable(1).apply { + hasNameThat().isEqualTo("y") + hasPowerThat().isEqualTo(2) + } + } + } + } + + @Test + fun testDiv_oneAndZero_returnsNull() { + val polynomial1 = ONE_POLYNOMIAL + val polynomial2 = ZERO_POLYNOMIAL + + val result = polynomial1 / polynomial2 + + // Cannot divide by zero. + assertThat(result).isNotValidPolynomial() + } + + @Test + fun testDiv_threeAndTwo_returnsOneAndOneHalf() { + val polynomial1 = createPolynomial(createTerm(coefficient = THREE_REAL)) + val polynomial2 = TWO_POLYNOMIAL + + val result = polynomial1 / polynomial2 + + // 3/2=1 1/2 (fraction) to demonstrate fully constant division. + assertThat(result).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isRationalThat().apply { + hasNegativePropertyThat().isFalse() + hasWholeNumberThat().isEqualTo(1) + hasNumeratorThat().isEqualTo(1) + hasDenominatorThat().isEqualTo(2) + } + hasVariableCountThat().isEqualTo(0) + } + } + } + + @Test + fun testDiv_piAndTwo_returnsHalfPi() { + val polynomial1 = PI_POLYNOMIAL + val polynomial2 = TWO_POLYNOMIAL + + val result = polynomial1 / polynomial2 + + // 3.14/2=1.57 (irrational) to demonstrate fully constant division. + assertThat(result).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isIrrationalThat().isWithin(1e-5).of(1.57) + hasVariableCountThat().isEqualTo(0) + } + } + } + + @Test + fun testDiv_xAndOne_returnsX() { + val polynomial1 = ONE_X_POLYNOMIAL + val polynomial2 = ONE_POLYNOMIAL + + val result = polynomial1 / polynomial2 + + // x/1=x. + assertThat(result).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + } + } + + @Test + fun testDiv_oneAndX_returnsNull() { + val polynomial1 = ONE_POLYNOMIAL + val polynomial2 = ONE_X_POLYNOMIAL + + val result = polynomial1 / polynomial2 + + // 1/x fails (cannot have negative power terms in polynomials, and this also shows that division + // is not commutative). + assertThat(result).isNotValidPolynomial() + } + + @Test + fun testDiv_xSquared_x_returnsX() { + val polynomial1 = createPolynomial( + createTerm(coefficient = ONE, createVariable(name = "x", power = 2)) + ) + val polynomial2 = ONE_X_POLYNOMIAL + + val result = polynomial1 / polynomial2 + + // x^2/x=x. + assertThat(result).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + } + } + + @Test + fun testDiv_onePlus2XPlusXSquared_onePlusX_returnsOnePlusX() { + val polynomial1 = createPolynomial( + createTerm(coefficient = ONE), + createTerm(coefficient = TWO_REAL, createVariable(name = "x", power = 1)), + createTerm(coefficient = ONE, createVariable(name = "x", power = 2)) + ) + val polynomial2 = createPolynomial( + createTerm(coefficient = ONE), + createTerm(coefficient = ONE, createVariable(name = "x", power = 1)) + ) + + val result = polynomial1 / polynomial2 + + // (1+2x+x^2)/(1+x)=x+1 (full polynomial division). + assertThat(result).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(0) + } + } + } + + @Test + fun testDiv_xSquaredPlus2XPlusOne_onePlusX_returnsOnePlusX() { + val polynomial1 = createPolynomial( + createTerm(coefficient = ONE, createVariable(name = "x", power = 2)), + createTerm(coefficient = TWO_REAL, createVariable(name = "x", power = 1)), + createTerm(coefficient = ONE) + ) + val polynomial2 = createPolynomial( + createTerm(coefficient = ONE), + createTerm(coefficient = ONE, createVariable(name = "x", power = 1)) + ) + + val result = polynomial1 / polynomial2 + + // (x^2+2x+1)/(1+x)=x+1 (order of terms for the dividend doesn't matter). + assertThat(result).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(0) + } + } + } + + @Test + fun testDiv_xSquaredPlus2XPlusOne_oneMinusX_returnsNull() { + val polynomial1 = createPolynomial( + createTerm(coefficient = ONE, createVariable(name = "x", power = 2)), + createTerm(coefficient = TWO_REAL, createVariable(name = "x", power = 1)), + createTerm(coefficient = ONE) + ) + val polynomial2 = createPolynomial( + createTerm(coefficient = ONE), + createTerm(coefficient = -ONE, createVariable(name = "x", power = 1)) + ) + + val result = polynomial1 / polynomial2 + + // (x^2+2x+1)/(1-x) fails (division doesn't result in a perfect polynomial). + assertThat(result).isNotValidPolynomial() + } + + @Test + fun testDiv_negativeXCubed_xSquared_returnsNegativeX() { + val polynomial1 = createPolynomial( + createTerm(coefficient = -ONE, createVariable(name = "x", power = 3)) + ) + val polynomial2 = createPolynomial( + createTerm(coefficient = ONE, createVariable(name = "x", power = 2)) + ) + + val result = polynomial1 / polynomial2 + + // -x^3/x^2=-x (negatives are retained). + assertThat(result).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(-1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + } + } + + @Test + fun testDiv_xSquaredMinusOne_xPlusOne_returnsXMinusOne() { + val polynomial1 = createPolynomial( + createTerm(coefficient = ONE, createVariable(name = "x", power = 2)), + createTerm(coefficient = -ONE) + ) + val polynomial2 = createPolynomial( + createTerm(coefficient = ONE, createVariable(name = "x", power = 1)), + createTerm(coefficient = ONE) + ) + + val result = polynomial1 / polynomial2 + + // (x^2-1)/(x+1)=x-1. + assertThat(result).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(-1) + hasVariableCountThat().isEqualTo(0) + } + } + } + + @Test + fun testDiv_xSquaredMinusOne_xMinusOne_returnsXPlusOne() { + val polynomial1 = createPolynomial( + createTerm(coefficient = ONE, createVariable(name = "x", power = 2)), + createTerm(coefficient = -ONE) + ) + val polynomial2 = createPolynomial( + createTerm(coefficient = ONE, createVariable(name = "x", power = 1)), + createTerm(coefficient = -ONE) + ) + + val result = polynomial1 / polynomial2 + + // (x^2-1)/(x-1)=x+1. + assertThat(result).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(0) + } + } + } + + @Test + fun testDiv_xSquaredMinusOne_x_returnsNull() { + val polynomial1 = createPolynomial( + createTerm(coefficient = ONE, createVariable(name = "x", power = 2)), + createTerm(coefficient = -ONE) + ) + val polynomial2 = ONE_X_POLYNOMIAL + + val result = polynomial1 / polynomial2 + + // (x^2-1)/x fails. + assertThat(result).isNotValidPolynomial() + } + + @Test + fun testDiv_xSquaredMinusOne_negativeOne_negativeXSquaredPlusOne() { + val polynomial1 = createPolynomial( + createTerm(coefficient = ONE, createVariable(name = "x", power = 2)), + createTerm(coefficient = -ONE) + ) + val polynomial2 = createPolynomial(createTerm(coefficient = -ONE)) + + val result = polynomial1 / polynomial2 + + // (x^2-1)/(-1)=-x^2+1 (reverses negative signs). + assertThat(result).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(-1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(2) + } + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(0) + } + } + } + + @Test + fun testDiv_xSquaredMinusOne_two_returnsNull() { + val polynomial1 = createPolynomial( + createTerm(coefficient = ONE, createVariable(name = "x", power = 2)), + createTerm(coefficient = -ONE) + ) + val polynomial2 = TWO_POLYNOMIAL + + val result = polynomial1 / polynomial2 + + // (x^2-1)/2=(1/2)x^2-1/2 (since non-zero constants can always be factored). + assertThat(result).apply { + hasTermCountThat().isEqualTo(2) + term(0).apply { + hasCoefficientThat().isEqualTo(ONE_HALF) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(2) + } + } + term(1).apply { + hasCoefficientThat().isEqualTo(-ONE_HALF) + hasVariableCountThat().isEqualTo(0) + } + } + } + + @Test + fun testDiv_negativeThreeXSquared_xSquared_returnsNegativeThree() { + val polynomial1 = createPolynomial( + createTerm(coefficient = -THREE_REAL, createVariable(name = "x", power = 2)) + ) + val polynomial2 = createPolynomial( + createTerm(coefficient = ONE, createVariable(name = "x", power = 2)) + ) + + val result = polynomial1 / polynomial2 + + // (-3x^2)/(x^2)=-3 (coefficient is retained). + assertThat(result).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(-3) + hasVariableCountThat().isEqualTo(0) + } + } + } + + @Test + fun testDiv_negativeThreeXSquared_negativeXSquared_returnsThree() { + val polynomial1 = createPolynomial( + createTerm(coefficient = -THREE_REAL, createVariable(name = "x", power = 2)) + ) + val polynomial2 = createPolynomial( + createTerm(coefficient = -ONE, createVariable(name = "x", power = 2)) + ) + + val result = polynomial1 / polynomial2 + + // (-3x^2)/(-x^2)=3 (negatives cancel during division). + assertThat(result).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(3) + hasVariableCountThat().isEqualTo(0) + } + } + } + + @Test + fun testDiv_xSquaredY_y_returnsXSquared() { + val polynomial1 = createPolynomial( + createTerm( + coefficient = ONE, + createVariable(name = "x", power = 2), + createVariable(name = "y", power = 1) + ) + ) + val polynomial2 = createPolynomial( + createTerm(coefficient = ONE, createVariable(name = "y", power = 1)) + ) + + val result = polynomial1 / polynomial2 + + // x^2y / y=x^2 (variable elimination). + assertThat(result).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(2) + } + } + } + } + + @Test + fun testDiv_xSquaredY_x_returnsXTimesY() { + val polynomial1 = createPolynomial( + createTerm( + coefficient = ONE, + createVariable(name = "x", power = 2), + createVariable(name = "y", power = 1) + ) + ) + val polynomial2 = createPolynomial( + createTerm(coefficient = ONE, createVariable(name = "x", power = 1)) + ) + + val result = polynomial1 / polynomial2 + + // x^2y / x=xy (variable power elimination). + assertThat(result).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(2) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + variable(1).apply { + hasNameThat().isEqualTo("y") + hasPowerThat().isEqualTo(1) + } + } + } + } + + @Test + fun testDiv_xSquaredY_xSquared_returnsY() { + val polynomial1 = createPolynomial( + createTerm( + coefficient = ONE, + createVariable(name = "x", power = 2), + createVariable(name = "y", power = 1) + ) + ) + val polynomial2 = createPolynomial( + createTerm(coefficient = ONE, createVariable(name = "x", power = 2)) + ) + + val result = polynomial1 / polynomial2 + + // x^2y / x^2=y (variable elimination). + assertThat(result).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("y") + hasPowerThat().isEqualTo(1) + } + } + } + } + + @Test + fun testDiv_xSquaredY_yXSquared_returnsOne() { + val polynomial1 = createPolynomial( + createTerm( + coefficient = ONE, + createVariable(name = "x", power = 2), + createVariable(name = "y", power = 1) + ) + ) + val polynomial2 = createPolynomial( + createTerm( + coefficient = ONE, + createVariable(name = "y", power = 1), + createVariable(name = "x", power = 2) + ) + ) + + val result = polynomial1 / polynomial2 + + // x^2y / yx^2=1 (multi-variable elimination). + assertThat(result).isConstantThat().isIntegerThat().isEqualTo(1) + } + + @Test + fun testDiv_xSquaredY_ySquared_returnsNull() { + val polynomial1 = createPolynomial( + createTerm( + coefficient = ONE, + createVariable(name = "x", power = 2), + createVariable(name = "y", power = 1) + ) + ) + val polynomial2 = createPolynomial( + createTerm(coefficient = ONE, createVariable(name = "y", power = 2)) + ) + + val result = polynomial1 / polynomial2 + + // x^2y / y^2 fails (no polynomial exists). + assertThat(result).isNotValidPolynomial() + } + + @Test + fun testPow_zeroAndZero_returnsOne() { + val polynomial1 = ZERO_POLYNOMIAL + val polynomial2 = ZERO_POLYNOMIAL + + val result = polynomial1 pow polynomial2 + + // 0^0=1 (conventionally despite this power not existing in mathematics). + assertThat(result).isConstantThat().isIntegerThat().isEqualTo(1) + } + + @Test + fun testPow_zeroAndOne_returnsZero() { + val polynomial1 = ZERO_POLYNOMIAL + val polynomial2 = ONE_POLYNOMIAL + + val result = polynomial1 pow polynomial2 + + // 0^1=0. + assertThat(result).isConstantThat().isIntegerThat().isEqualTo(0) + } + + @Test + fun testPow_oneAndZero_returnsOne() { + val polynomial1 = ONE_POLYNOMIAL + val polynomial2 = ZERO_POLYNOMIAL + + val result = polynomial1 pow polynomial2 + + // 1^0=1 (i.e. exponentiation is not commutative). + assertThat(result).isConstantThat().isIntegerThat().isEqualTo(1) + } + + @Test + fun testPow_oneAndOne_returnsOne() { + val polynomial1 = ONE_POLYNOMIAL + val polynomial2 = ONE_POLYNOMIAL + + val result = polynomial1 pow polynomial2 + + // 1^1=1. + assertThat(result).isConstantThat().isIntegerThat().isEqualTo(1) + } + + @Test + fun testPow_xAndOne_returnsX() { + val polynomial1 = ONE_X_POLYNOMIAL + val polynomial2 = ONE_POLYNOMIAL + + val result = polynomial1 pow polynomial2 + + // poly(x)^poly(1)=poly(x). + assertThat(result).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + } + } + + @Test + fun testPow_xAndX_returnsNull() { + val polynomial1 = ONE_X_POLYNOMIAL + val polynomial2 = ONE_X_POLYNOMIAL + + val result = polynomial1 pow polynomial2 + + // x^x fails since polynomials can't have variable exponents. + assertThat(result).isNotValidPolynomial() + } + + @Test + fun testPow_xAndTwo_returnsXSquared() { + val polynomial1 = ONE_X_POLYNOMIAL + val polynomial2 = TWO_POLYNOMIAL + + val result = polynomial1 pow polynomial2 + + // poly(x)^poly(2)=poly(x^2). + assertThat(result).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(2) + } + } + } + } + + @Test + fun testPow_onePlusX_two_onePlus2XPlusXSquared() { + val polynomial1 = createPolynomial( + createTerm(coefficient = ONE), + createTerm(coefficient = ONE, createVariable(name = "x", power = 1)) + ) + val polynomial2 = TWO_POLYNOMIAL + + val result = polynomial1 pow polynomial2 + + // (1+x)^2=1+2x+x^2 (binomial expansion). + assertThat(result).apply { + hasTermCountThat().isEqualTo(3) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(0) + } + term(1).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(2) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + term(2).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(2) + } + } + } + } + + @Test + fun testPow_x_negativeOne_returnsNull() { + val polynomial1 = ONE_X_POLYNOMIAL + val polynomial2 = createPolynomial(createTerm(coefficient = -ONE)) + + val result = polynomial1 pow polynomial2 + + // x^-1 fails since polynomials can't have negative powers. + assertThat(result).isNotValidPolynomial() + } + + @Test + fun testPow_two_negativeOne_returnsOneHalf() { + val polynomial1 = TWO_POLYNOMIAL + val polynomial2 = createPolynomial(createTerm(coefficient = -ONE)) + + val result = polynomial1 pow polynomial2 + + // 2^-1=1/2 (this demonstrates constant-only powers, and that negative powers sometimes work). + assertThat(result).isConstantThat().isEqualTo(ONE_HALF) + } + + @Test + fun testPow_four_negativeOneHalf_returnsOneHalf() { + val polynomial1 = createPolynomial(createTerm(coefficient = FOUR_REAL)) + val polynomial2 = NEGATIVE_ONE_HALF_POLYNOMIAL + + val result = polynomial1 pow polynomial2 + + // 4^(-1/2)=1/2 (this demonstrates constant-only powers, and that negative powers sometimes work). + assertThat(result).isConstantThat().isEqualTo(ONE_HALF) + } + + @Test + fun testPow_onePlusX_oneHalf_returnsNull() { + val polynomial1 = createPolynomial( + createTerm(coefficient = ONE), + createTerm(coefficient = ONE, createVariable(name = "x", power = 1)) + ) + val polynomial2 = ONE_HALF_POLYNOMIAL + + val result = polynomial1 pow polynomial2 + + // (1+x)^(1/2) fails since 1+x has no square root. + assertThat(result).isNotValidPolynomial() + } + + @Test + fun testPow_onePlus2XPlusXSquared_oneHalf_returnsNull() { + val polynomial1 = createPolynomial( + createTerm(coefficient = ONE), + createTerm(coefficient = TWO_REAL, createVariable(name = "x", power = 1)), + createTerm(coefficient = ONE, createVariable(name = "x", power = 2)) + ) + val polynomial2 = ONE_HALF_POLYNOMIAL + + val result = polynomial1 pow polynomial2 + + // (1+2x+x^2)^(1/2) fails since multi-term factoring is not currently supported. + assertThat(result).isNotValidPolynomial() + } + + @Test + fun testPow_xSquaredMinusOne_oneHalf_returnsNull() { + val polynomial1 = createPolynomial( + createTerm(coefficient = ONE, createVariable(name = "x", power = 2)), + createTerm(coefficient = -ONE) + ) + val polynomial2 = ONE_HALF_POLYNOMIAL + + val result = polynomial1 pow polynomial2 + + // (x^2-1)^(1/2) fails since multi-term factoring is not currently supported. + assertThat(result).isNotValidPolynomial() + } + + @Test + fun testPow_xSquared_oneHalf_returnsX() { + val polynomial1 = createPolynomial( + createTerm(coefficient = ONE, createVariable(name = "x", power = 2)) + ) + val polynomial2 = ONE_HALF_POLYNOMIAL + + val result = polynomial1 pow polynomial2 + + // (x^2)^(1/2)=x. + assertThat(result).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(1) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + } + } + + @Test + fun testPow_fourXSquared_oneHalf_returnsTwoX() { + val polynomial1 = createPolynomial( + createTerm(coefficient = FOUR_REAL, createVariable(name = "x", power = 2)) + ) + val polynomial2 = ONE_HALF_POLYNOMIAL + + val result = polynomial1 pow polynomial2 + + // (4x^2)^(1/2)=2x (demonstrates that coefficients can also be rooted). + assertThat(result).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(2) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + } + } + + @Test + fun testPow_xSquared_negativeOneHalf_returnsNull() { + val polynomial1 = createPolynomial( + createTerm(coefficient = -ONE, createVariable(name = "x", power = 2)) + ) + val polynomial2 = ONE_HALF_POLYNOMIAL + + val result = polynomial1 pow polynomial2 + + // (-x^2)^(1/2) fails since a negative coefficient can't be square rooted. + assertThat(result).isNotValidPolynomial() + } + + @Test + fun testPow_negativeTwentySevenXCubed_oneThird_returnsNegativeThreeX() { + val twentySevenReal = checkNotNull(THREE_REAL pow THREE_REAL) + val polynomial1 = createPolynomial( + createTerm(coefficient = -twentySevenReal, createVariable(name = "x", power = 3)) + ) + val polynomial2 = createPolynomial(createTerm(coefficient = ONE_THIRD_REAL)) + + val result = polynomial1 pow polynomial2 + + // (-9x^3)^(1/3)=-3x (demonstrates real number rooting, i.e. support for negative coefficients + // in certain cases). + assertThat(result).apply { + hasTermCountThat().isEqualTo(1) + term(0).apply { + hasCoefficientThat().isIntegerThat().isEqualTo(-3) + hasVariableCountThat().isEqualTo(1) + variable(0).apply { + hasNameThat().isEqualTo("x") + hasPowerThat().isEqualTo(1) + } + } + } + } + + @Test + fun testPow_xSquared_oneThird_returnsNull() { + val twentySevenReal = checkNotNull(THREE_REAL pow THREE_REAL) + val polynomial1 = createPolynomial( + createTerm(coefficient = twentySevenReal, createVariable(name = "x", power = 2)) + ) + val polynomial2 = createPolynomial(createTerm(coefficient = ONE_THIRD_REAL)) + + val result = polynomial1 pow polynomial2 + + // (27x^2)^(1/3) fails since the power '2' cannot be taken to the 1/3 (i.e. 2/3 is not a valid + // polynomial power). + assertThat(result).isNotValidPolynomial() + } + + @Test + fun testPow_xAndPi_returnsNull() { + val polynomial1 = ONE_X_POLYNOMIAL + val polynomial2 = PI_POLYNOMIAL + + val result = polynomial1 pow polynomial2 + + // Cannot raise polynomials to non-integer powers. + assertThat(result).isNotValidPolynomial() + } } private fun createVariable(name: String, power: Int) = Variable.newBuilder().apply { diff --git a/utility/src/test/java/org/oppia/android/util/math/RealExtensionsTest.kt b/utility/src/test/java/org/oppia/android/util/math/RealExtensionsTest.kt index 7f56613e858..b6a13238005 100644 --- a/utility/src/test/java/org/oppia/android/util/math/RealExtensionsTest.kt +++ b/utility/src/test/java/org/oppia/android/util/math/RealExtensionsTest.kt @@ -54,6 +54,16 @@ class RealExtensionsTest { wholeNumber = 1 }.build() + private val THREE_FRACTION = Fraction.newBuilder().apply { + wholeNumber = 3 + denominator = 1 + }.build() + + private val THREE_ONES_FRACTION = Fraction.newBuilder().apply { + numerator = 3 + denominator = 1 + }.build() + private val ZERO_REAL = createIntegerReal(0) private val TWO_REAL = createIntegerReal(2) private val NEGATIVE_TWO_REAL = createIntegerReal(-2) @@ -62,6 +72,9 @@ class RealExtensionsTest { 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 THREE_FRACTION_REAL = createRationalReal(THREE_FRACTION) + private val NEGATIVE_THREE_FRACTION_REAL = createRationalReal(-THREE_FRACTION) + private val THREE_ONES_REAL = createRationalReal(THREE_ONES_FRACTION) private val PI_REAL = createIrrationalReal(PI) private val NEGATIVE_PI_REAL = createIrrationalReal(-PI) @@ -79,6 +92,32 @@ class RealExtensionsTest { @Parameter lateinit var expFrac: String @Parameter var expDouble: Double = Double.MIN_VALUE + @Test + fun testZero_isZeroInteger() { + val subject = ZERO + + assertThat(subject).isIntegerThat().isEqualTo(0) + } + + @Test + fun testOne_isOneInteger() { + val subject = ONE + + assertThat(subject).isIntegerThat().isEqualTo(1) + } + + @Test + fun testOneHalf_isOneHalfRational() { + val subject = ONE_HALF + + assertThat(subject).isRationalThat().apply { + hasNegativePropertyThat().isFalse() + hasWholeNumberThat().isEqualTo(0) + hasNumeratorThat().isEqualTo(1) + hasDenominatorThat().isEqualTo(2) + } + } + @Test fun testIsRational_default_returnsFalse() { val defaultReal = Real.getDefaultInstance() @@ -139,6 +178,76 @@ class RealExtensionsTest { assertThat(result).isFalse() } + @Test + fun testIsWholeNumber_default_returnsFalse() { + val defaultReal = Real.getDefaultInstance() + + val result = defaultReal.isWholeNumber() + + assertThat(result).isFalse() + } + + @Test + fun testIsWholeNumber_twoInteger_returnsTrue() { + val result = TWO_REAL.isWholeNumber() + + assertThat(result).isTrue() + } + + @Test + fun testIsWholeNumber_negativeTwoInteger_returnsTrue() { + val result = NEGATIVE_TWO_REAL.isWholeNumber() + + assertThat(result).isTrue() + } + + @Test + fun testIsWholeNumber_oneHalfFraction_returnsFalse() { + val result = ONE_HALF_REAL.isWholeNumber() + + assertThat(result).isFalse() + } + + @Test + fun testIsWholeNumber_threeOnesFraction_returnsFalse() { + val result = THREE_ONES_REAL.isWholeNumber() + + // 3/1 is treated as a fraction despite being numerically equivalent to a whole number. + assertThat(result).isFalse() + } + + @Test + fun testIsWholeNumber_threeFraction_returnsTrue() { + val result = THREE_FRACTION_REAL.isWholeNumber() + + assertThat(result).isTrue() + } + + @Test + fun testIsWholeNumber_negativeThreeFraction_returnsTrue() { + val result = NEGATIVE_THREE_FRACTION_REAL.isWholeNumber() + + assertThat(result).isTrue() + } + + @Test + fun testIsWholeNumber_piIrrational_returnsFalse() { + val result = PI_REAL.isWholeNumber() + + assertThat(result).isFalse() + } + + @Test + fun testIsWholeNumber_threeIrrational_returnsFalse() { + val real = createIrrationalReal(3.0) + + val result = real.isWholeNumber() + + // Despite 3.0 being approximately a whole number, it isn't considered one since it's a double + // (and thus can have precision loss). + assertThat(result).isFalse() + } + @Test fun testIsNegative_default_throwsException() { val defaultReal = Real.getDefaultInstance() @@ -190,6 +299,139 @@ class RealExtensionsTest { 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 testIsApproximatelyZero_default_throwsException() { + val defaultReal = Real.getDefaultInstance() + + val exception = assertThrows(IllegalStateException::class) { defaultReal.isApproximatelyZero() } + + assertThat(exception).hasMessageThat().contains("Invalid real") + } + + @Test + fun testIsApproximatelyZero_zeroInteger_returnsTrue() { + val result = ZERO_REAL.isApproximatelyZero() + + assertThat(result).isTrue() + } + + @Test + fun testIsApproximatelyZero_twoInteger_returnsFalse() { + val result = TWO_REAL.isApproximatelyZero() + + assertThat(result).isFalse() + } + + @Test + fun testIsApproximatelyZero_zeroFraction_returnsTrue() { + val real = createRationalReal(ZERO_FRACTION) + + val result = real.isApproximatelyZero() + + assertThat(result).isTrue() + } + + @Test + fun testIsApproximatelyZero_negativeZeroFraction_returnsTrue() { + val real = createRationalReal(-ZERO_FRACTION) + + val result = real.isApproximatelyZero() + + assertThat(result).isTrue() + } + + @Test + fun testIsApproximatelyZero_oneHalfFraction_returnsFalse() { + val result = ONE_HALF_REAL.isApproximatelyZero() + + assertThat(result).isFalse() + } + + @Test + fun testIsApproximatelyZero_zeroIrrational_returnsTrue() { + val real = createIrrationalReal(0.0) + + val result = real.isApproximatelyZero() + + assertThat(result).isTrue() + } + + @Test + fun testIsApproximatelyZero_irrationalCloseToZero_returnsTrue() { + val real = createIrrationalReal(0.00000000000000001) + + val result = real.isApproximatelyZero() + + assertThat(result).isTrue() + } + + @Test + fun testIsApproximatelyZero_piIrrational_returnsFalse() { + val result = PI_REAL.isApproximatelyZero() + + assertThat(result).isFalse() + } + @Test fun testToDouble_default_returnsZeroDouble() { val defaultReal = Real.getDefaultInstance() @@ -242,135 +484,145 @@ class RealExtensionsTest { } @Test - fun testToPlainText_default_returnsEmptyString() { + fun testAsWholeNumber_default_throwsException() { val defaultReal = Real.getDefaultInstance() - val result = defaultReal.toPlainText() + val exception = assertThrows(IllegalStateException::class) { defaultReal.asWholeNumber() } - assertThat(result).isEmpty() + assertThat(exception).hasMessageThat().contains("Invalid real") } @Test - fun testToPlainText_twoInteger_returnsTwoString() { - val result = TWO_REAL.toPlainText() + fun testAsWholeNumber_twoInteger_returnsTwo() { + val result = TWO_REAL.asWholeNumber() - assertThat(result).isEqualTo("2") + assertThat(result).isEqualTo(2) } @Test - fun testToPlainText_negativeTwoInteger_returnsMinusTwoString() { - val result = NEGATIVE_TWO_REAL.toPlainText() + fun testAsWholeNumber_negativeTwoInteger_returnsNegativeTwo() { + val result = NEGATIVE_TWO_REAL.asWholeNumber() - assertThat(result).isEqualTo("-2") + assertThat(result).isEqualTo(-2) } @Test - fun testToPlainText_oneHalfFraction_returnsOneHalfString() { - val result = ONE_HALF_REAL.toPlainText() + fun testAsWholeNumber_oneHalfFraction_returnsNull() { + val result = ONE_HALF_REAL.asWholeNumber() - assertThat(result).isEqualTo("1/2") + assertThat(result).isNull() } @Test - fun testToPlainText_negativeOneHalfFraction_returnsMinusOneHalfString() { - val result = NEGATIVE_ONE_HALF_REAL.toPlainText() + fun testAsWholeNumber_threeOnesFraction_returnsNull() { + val result = THREE_ONES_REAL.asWholeNumber() - assertThat(result).isEqualTo("-1/2") + // 3/1 is treated as a fraction despite being numerically equivalent to a whole number. + assertThat(result).isNull() } @Test - fun testToPlainText_oneAndOneHalfFraction_returnsThreeHalvesString() { - val result = ONE_AND_ONE_HALF_REAL.toPlainText() + fun testAsWholeNumber_threeFraction_returnsThree() { + val result = THREE_FRACTION_REAL.asWholeNumber() - assertThat(result).isEqualTo("3/2") + assertThat(result).isEqualTo(3) } @Test - fun testToPlainText_negativeOneAndOneHalfFraction_returnsMinusThreeHalvesString() { - val result = NEGATIVE_ONE_AND_ONE_HALF_REAL.toPlainText() + fun testAsWholeNumber_negativeThreeFraction_returnsNegativeThree() { + val result = NEGATIVE_THREE_FRACTION_REAL.asWholeNumber() - assertThat(result).isEqualTo("-3/2") + assertThat(result).isEqualTo(-3) } @Test - fun testToPlainText_piIrrational_returnsPiString() { - val result = PI_REAL.toPlainText() + fun testAsWholeNumber_piIrrational_returnsNull() { + val result = PI_REAL.asWholeNumber() - assertThat(result).isEqualTo("3.1415") + assertThat(result).isNull() } @Test - fun testToPlainText_negativePiIrrational_returnsMinusPiString() { - val result = NEGATIVE_PI_REAL.toPlainText() + fun testAsWholeNumber_threeIrrational_returnsNull() { + val real = createIrrationalReal(3.0) - assertThat(result).isEqualTo("-3.1415") + val result = real.asWholeNumber() + + // Despite 3.0 being approximately a whole number, it isn't considered one since it's a double + // (and thus can have precision loss). + assertThat(result).isNull() } @Test - fun testIsApproximatelyEqualTo_zeroIntegerAndZero_returnsTrue() { - val result = ZERO_REAL.isApproximatelyEqualTo(0.0) + fun testToPlainText_default_returnsEmptyString() { + val defaultReal = Real.getDefaultInstance() - assertThat(result).isTrue() + val result = defaultReal.toPlainText() + + assertThat(result).isEmpty() } @Test - fun testIsApproximatelyEqualTo_zeroAndOne_returnsFalse() { - val result = ZERO_REAL.isApproximatelyEqualTo(1.0) + fun testToPlainText_twoInteger_returnsTwoString() { + val result = TWO_REAL.toPlainText() - assertThat(result).isFalse() + assertThat(result).isEqualTo("2") } @Test - fun testIsApproximatelyEqualTo_twoAndTwoWithinThreshold_returnsTrue() { - val result = TWO_REAL.isApproximatelyEqualTo(2.0 + DOUBLE_EQUALITY_EPSILON / 2.0) + fun testToPlainText_negativeTwoInteger_returnsMinusTwoString() { + val result = NEGATIVE_TWO_REAL.toPlainText() - assertThat(result).isTrue() + assertThat(result).isEqualTo("-2") } @Test - fun testIsApproximatelyEqualTo_twoAndTwoOutsideThreshold_returnsFalse() { - val result = TWO_REAL.isApproximatelyEqualTo(2.0 + DOUBLE_EQUALITY_EPSILON * 2.0) + fun testToPlainText_oneHalfFraction_returnsOneHalfString() { + val result = ONE_HALF_REAL.toPlainText() - assertThat(result).isFalse() + assertThat(result).isEqualTo("1/2") } @Test - fun testIsApproximatelyEqualTo_oneHalfAndOne_returnsFalse() { - val result = ONE_HALF_REAL.isApproximatelyEqualTo(1.0) + fun testToPlainText_negativeOneHalfFraction_returnsMinusOneHalfString() { + val result = NEGATIVE_ONE_HALF_REAL.toPlainText() - assertThat(result).isFalse() + assertThat(result).isEqualTo("-1/2") } @Test - fun testIsApproximatelyEqualTo_oneHalfAndPointFive_returnsTrue() { - val result = ONE_HALF_REAL.isApproximatelyEqualTo(0.5) + fun testToPlainText_oneAndOneHalfFraction_returnsThreeHalvesString() { + val result = ONE_AND_ONE_HALF_REAL.toPlainText() - assertThat(result).isTrue() + assertThat(result).isEqualTo("3/2") } @Test - fun testIsApproximatelyEqualTo_oneHalfAndPointSix_returnsFalse() { - val result = ONE_HALF_REAL.isApproximatelyEqualTo(0.6) + fun testToPlainText_negativeOneAndOneHalfFraction_returnsMinusThreeHalvesString() { + val result = NEGATIVE_ONE_AND_ONE_HALF_REAL.toPlainText() - assertThat(result).isFalse() + assertThat(result).isEqualTo("-3/2") } @Test - fun testIsApproximatelyEqualTo_pointFiveAndPointFive_returnsTrue() { - val pointFiveReal = createIrrationalReal(0.5) - - val result = pointFiveReal.isApproximatelyEqualTo(0.5) + fun testToPlainText_piIrrational_returnsPiString() { + val result = PI_REAL.toPlainText() - assertThat(result).isTrue() + assertThat(result).isEqualTo("3.1415") } @Test - fun testIsApproximatelyEqualTo_pointFiveAndPointSix_returnsFalse() { - val pointFiveReal = createIrrationalReal(0.5) + fun testToPlainText_negativePiIrrational_returnsMinusPiString() { + val result = NEGATIVE_PI_REAL.toPlainText() - val result = pointFiveReal.isApproximatelyEqualTo(0.6) + assertThat(result).isEqualTo("-3.1415") + } - assertThat(result).isFalse() + @Test + fun testIsApproximatelyEqualTo_zeroIntegerAndZero_returnsTrue() { + val result = ZERO_REAL.isApproximatelyEqualTo(0.0) + + assertThat(result).isTrue() } @Test @@ -1562,13 +1814,14 @@ class RealExtensionsTest { } @Test - fun testPow_negativeIntToOneHalfFraction_throwsException() { + fun testPow_negativeIntToOneHalfFraction_returnsNull() { val lhsReal = createIntegerReal(-3) val rhsReal = createRationalReal(ONE_HALF_FRACTION) - val exception = assertThrows(IllegalStateException::class) { lhsReal pow rhsReal } + val result = lhsReal pow rhsReal - assertThat(exception).hasMessageThat().contains("Radicand results in imaginary number") + // Cannot take the square root of a negative number. + assertThat(result).isNull() } @Test @@ -1582,23 +1835,25 @@ class RealExtensionsTest { } @Test - fun testPow_negativeFractionToOneHalfFraction_throwsException() { + fun testPow_negativeFractionToOneHalfFraction_returnsNull() { val lhsReal = NEGATIVE_ONE_AND_ONE_HALF_REAL val rhsReal = createRationalReal(ONE_HALF_FRACTION) - val exception = assertThrows(IllegalStateException::class) { lhsReal pow rhsReal } + val result = lhsReal pow rhsReal - assertThat(exception).hasMessageThat().contains("Radicand results in imaginary number") + // Cannot take the square root of a negative number. + assertThat(result).isNull() } @Test - fun testPow_negativeFractionToNegativeFractionWithOddNumerator_throwsException() { + fun testPow_negativeFractionToNegativeFractionWithOddNumerator_returnsNull() { val lhsReal = createRationalReal((-4).toWholeNumberFraction()) val rhsReal = createRationalReal(-ONE_AND_ONE_HALF_FRACTION) - val exception = assertThrows(IllegalStateException::class) { lhsReal pow rhsReal } + val result = lhsReal pow rhsReal - assertThat(exception).hasMessageThat().contains("Radicand results in imaginary number") + // Cannot take an even root of a negative number. + assertThat(result).isNull() } @Test @@ -1643,12 +1898,13 @@ class RealExtensionsTest { } @Test - fun testSqrt_negativeInteger_throwsException() { + fun testSqrt_negativeInteger_returnsNull() { val real = createIntegerReal(-2) - val exception = assertThrows(IllegalStateException::class) { sqrt(real) } + val result = sqrt(real) - assertThat(exception).hasMessageThat().contains("Radicand results in imaginary number") + // Cannot take square root of a negative number. + assertThat(result).isNull() } @Test @@ -1679,12 +1935,13 @@ class RealExtensionsTest { } @Test - fun testSqrt_negativeFraction_throwsException() { + fun testSqrt_negativeFraction_returnsNull() { val real = createRationalReal((-2).toWholeNumberFraction()) - val exception = assertThrows(IllegalStateException::class) { sqrt(real) } + val result = sqrt(real) - assertThat(exception).hasMessageThat().contains("Radicand results in imaginary number") + // Cannot take the square root of a negative number. + assertThat(result).isNull() } @Test