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