diff --git a/model/src/main/proto/interaction_object.proto b/model/src/main/proto/interaction_object.proto index d2f2c2008b8..c33912965d4 100644 --- a/model/src/main/proto/interaction_object.proto +++ b/model/src/main/proto/interaction_object.proto @@ -53,6 +53,162 @@ message NumberUnit { int32 exponent = 2; } +// Represents a number with explicitly-defined units. +message NumberWithUnitsExpression { + // The different types of numeric values that can have units. + oneof number_type { + // Indicates the value is a real number (decimal or integer). + double real = 1; + + // Indicates the value is a fraction. + Fraction fraction = 2; + } + + // Explicitly captures where units appear relative to the numeric value. + // This ensures parser output preserves format-specific semantics. + oneof expression_format { + // Units precede the value. + PrefixValueExpression prefix_value_expression = 3; + + // Units follow the value. + ValueSuffixExpression value_suffix_expression = 4; + + // Units appear on both sides of the value. + PrefixValueSuffixExpression prefix_value_suffix_expression = 5; + } +} + +// Represents expressions where units appear before the number. +message PrefixValueExpression { + repeated NumberUnitExpression prefix_units = 1; +} + +// Represents expressions where units appear after the number. +message ValueSuffixExpression { + repeated NumberUnitExpression suffix_units = 1; +} + +// Represents expressions where units appear both before and after the number. +message PrefixValueSuffixExpression { + repeated NumberUnitExpression prefix_units = 1; + repeated NumberUnitExpression suffix_units = 2; +} + +// Represents a specific unit with an exponent. +message NumberUnitExpression { + // Supported SI prefixes. + // These are combined with [unit]. + enum SiPrefix { + SI_PREFIX_UNSPECIFIED = 0; + DECA = 1; + HECTO = 2; + KILO = 3; + MEGA = 4; + GIGA = 5; + TERA = 6; + PETA = 7; + EXA = 8; + ZETTA = 9; + YOTTA = 10; + DECI = 11; + CENTI = 12; + MILLI = 13; + MICRO = 14; + NANO = 15; + PICO = 16; + FEMTO = 17; + ATTO = 18; + ZEPTO = 19; + YOCTO = 20; + } + + // The supported base unit types. Prefixed combinations are intentionally not enumerated here. + enum Unit { + UNIT_UNSPECIFIED = 0; + + // Length units + METER = 1; + INCH = 2; + FOOT = 3; + YARD = 4; + + // Mass units + GRAM = 5; + GRAIN = 6; + OUNCE = 7; + + // Area units + SQUARE_METER = 8; + SQUARE_INCH = 9; + SQUARE_FOOT = 10; + SQUARE_YARD = 11; + + // Volume unit + CUBIC_METER = 12; + LITER = 13; + CUBIC_CENTIMETER = 14; + CUBIC_INCH = 15; + CUBIC_FOOT = 16; + CUBIC_YARD = 17; + + // Temperature units + KELVIN = 18; + CELSIUS = 19; + + // Angle units + RADIAN = 20; + DEGREE = 21; + + // Time units + SECOND = 22; + MINUTE = 23; + HOUR = 24; + + // Frequency units + HERTZ = 25; + + // Amount of substance + MOLE = 26; + + // Luminous intensity + CANDELA = 27; + + // Force + NEWTON = 28; + + // Energy + JOULE = 29; + + // Power + WATT = 30; + + // Pressure + PASCAL = 31; + + // Electricity + AMPERE = 32; + VOLT = 33; + OHM = 34; + + // Currency units + DOLLAR = 35; + CENT = 36; + RUPEE = 37; + PAISA = 38; + } + + // The specific unit value. + Unit unit = 1; + + // The exponent (power) of this unit in the expression. + // Positive values indicate numerator units, negative values indicate denominator units. + int32 exponent = 2; + + // Optional SI prefix used with [unit]. + // Leave as SI_PREFIX_UNSPECIFIED for units that do not have a prefix. + SiPrefix si_prefix = 3; +} + // Structure for a ListOfString object. message ListOfSetsOfHtmlStrings { repeated StringList set_of_html_strings = 1; diff --git a/scripts/assets/file_content_validation_checks.textproto b/scripts/assets/file_content_validation_checks.textproto index 9695f4036d2..a1089876163 100644 --- a/scripts/assets/file_content_validation_checks.textproto +++ b/scripts/assets/file_content_validation_checks.textproto @@ -335,6 +335,7 @@ file_content_checks { exempted_file_name: "utility/src/test/java/org/oppia/android/util/math/MathExpressionExtensionsTest.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/NumberWithUnitsTokenizerTest.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_name: "utility/src/test/java/org/oppia/android/util/profile/ProfileNameValidatorTest.kt" diff --git a/scripts/assets/test_file_exemptions.textproto b/scripts/assets/test_file_exemptions.textproto index 16cb1cfba17..bb1d793ae19 100644 --- a/scripts/assets/test_file_exemptions.textproto +++ b/scripts/assets/test_file_exemptions.textproto @@ -4351,6 +4351,14 @@ test_file_exemption { exempted_file_path: "testing/src/main/java/org/oppia/android/testing/logging/SyncStatusTestModule.kt" override_min_coverage_percent_required: 0 } +test_file_exemption { + exempted_file_path: "testing/src/main/java/org/oppia/android/testing/math/NumberWithUnitsSubject.kt" + test_file_not_required: true +} +test_file_exemption { + exempted_file_path: "testing/src/main/java/org/oppia/android/testing/math/NumberWithUnitsTokenSubject.kt" + test_file_not_required: true +} test_file_exemption { exempted_file_path: "testing/src/main/java/org/oppia/android/testing/mockito/MockitoKotlinHelper.kt" test_file_not_required: true @@ -4647,6 +4655,10 @@ test_file_exemption { exempted_file_path: "utility/src/main/java/org/oppia/android/util/math/MathParsingError.kt" test_file_not_required: true } +test_file_exemption { + exempted_file_path: "utility/src/main/java/org/oppia/android/util/math/NumberWithUnitsParsingError.kt" + test_file_not_required: true +} test_file_exemption { exempted_file_path: "utility/src/main/java/org/oppia/android/util/networking/ConnectionStatus.kt" test_file_not_required: true diff --git a/testing/src/main/java/org/oppia/android/testing/math/BUILD.bazel b/testing/src/main/java/org/oppia/android/testing/math/BUILD.bazel index d7dc58e8d05..147eb929d09 100644 --- a/testing/src/main/java/org/oppia/android/testing/math/BUILD.bazel +++ b/testing/src/main/java/org/oppia/android/testing/math/BUILD.bazel @@ -140,3 +140,35 @@ kt_android_library( "//utility/src/main/java/org/oppia/android/util/math:tokenizer", ], ) + +kt_android_library( + name = "number_with_units_subject", + testonly = True, + srcs = [ + "NumberWithUnitsSubject.kt", + ], + visibility = [ + "//:oppia_testing_visibility", + ], + deps = [ + ":fraction_subject", + "//model/src/main/proto:interaction_object_java_proto_lite", + "//third_party:com_google_truth_extensions_truth-liteproto-extension", + "//third_party:com_google_truth_truth", + ], +) + +kt_android_library( + name = "number_with_units_token_subject", + testonly = True, + srcs = [ + "NumberWithUnitsTokenSubject.kt", + ], + visibility = [ + "//:oppia_testing_visibility", + ], + deps = [ + "//third_party:com_google_truth_truth", + "//utility/src/main/java/org/oppia/android/util/math:number_with_units_tokenizer", + ], +) diff --git a/testing/src/main/java/org/oppia/android/testing/math/NumberWithUnitsSubject.kt b/testing/src/main/java/org/oppia/android/testing/math/NumberWithUnitsSubject.kt new file mode 100644 index 00000000000..7380790e305 --- /dev/null +++ b/testing/src/main/java/org/oppia/android/testing/math/NumberWithUnitsSubject.kt @@ -0,0 +1,181 @@ +package org.oppia.android.testing.math + +import com.google.common.truth.ComparableSubject +import com.google.common.truth.DoubleSubject +import com.google.common.truth.FailureMetadata +import com.google.common.truth.IntegerSubject +import com.google.common.truth.Truth.assertAbout +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import com.google.common.truth.extensions.proto.LiteProtoSubject +import org.oppia.android.app.model.NumberUnitExpression +import org.oppia.android.app.model.NumberWithUnitsExpression +import org.oppia.android.testing.math.FractionSubject.Companion.assertThat + +// TODO(#6151): Add tests for this class. + +/** + * Truth subject for verifying properties of [NumberWithUnitsExpression]s. + * + * Note that this class is also a [LiteProtoSubject] so other aspects of the underlying + * [NumberWithUnitsExpression] proto can be verified through inherited methods. + * + * Call [assertThat] to create the subject. + */ +class NumberWithUnitsSubject private constructor( + metadata: FailureMetadata, + private val actual: NumberWithUnitsExpression +) : LiteProtoSubject(metadata, actual) { + + /** + * Verifies that the number type is [NumberWithUnitsExpression.NumberTypeCase.REAL] and returns + * a + * [DoubleSubject] to test the real value. + * + * This method will fail if the underlying [NumberWithUnitsExpression] does not have a real + * number type. + */ + fun hasRealValueThat(): DoubleSubject { + assertWithMessage( + "Expected number type to be REAL, not: ${actual.numberTypeCase}" + ) + .that(actual.numberTypeCase) + .isEqualTo(NumberWithUnitsExpression.NumberTypeCase.REAL) + return assertThat(actual.real) + } + + /** + * Verifies that the number type is [NumberWithUnitsExpression.NumberTypeCase.FRACTION] and + * returns a + * [FractionSubject] to test the fraction value. + * + * This method will fail if the underlying [NumberWithUnitsExpression] does not have a fraction + * number type. + */ + fun hasFractionValueThat(): FractionSubject { + assertWithMessage( + "Expected number type to be FRACTION, not: ${actual.numberTypeCase}" + ) + .that(actual.numberTypeCase) + .isEqualTo(NumberWithUnitsExpression.NumberTypeCase.FRACTION) + return assertThat(actual.fraction) + } + + /** + * Returns an [IntegerSubject] to test the total unit count across the expression format. + */ + fun hasUnitCountThat(): IntegerSubject = assertThat(allUnits().size) + + /** + * Verifies that the expression format has at least one prefix unit and returns a + * [NumberUnitSubject]. + */ + fun hasPrefixThat(): NumberUnitSubject { + val units = when (actual.expressionFormatCase) { + NumberWithUnitsExpression.ExpressionFormatCase.PREFIX_VALUE_EXPRESSION -> + actual.prefixValueExpression.prefixUnitsList + + NumberWithUnitsExpression.ExpressionFormatCase.PREFIX_VALUE_SUFFIX_EXPRESSION -> + actual.prefixValueSuffixExpression.prefixUnitsList + + else -> emptyList() + } + assertWithMessage("Expected to find at least one prefix unit, but found none") + .that(units) + .isNotEmpty() + return NumberUnitSubject.assertThat(units.first()) + } + + /** + * Verifies that the expression format has at least one suffix unit and returns a + * [NumberUnitSubject] for the suffix unit at the specified index. + */ + fun hasSuffixWithIndexThat(index: Int): NumberUnitSubject { + val units = when (actual.expressionFormatCase) { + NumberWithUnitsExpression.ExpressionFormatCase.VALUE_SUFFIX_EXPRESSION -> + actual.valueSuffixExpression.suffixUnitsList + + NumberWithUnitsExpression.ExpressionFormatCase.PREFIX_VALUE_SUFFIX_EXPRESSION -> + actual.prefixValueSuffixExpression.suffixUnitsList + + else -> emptyList() + } + assertWithMessage("Expected to find at least one suffix unit, but found none") + .that(units) + .isNotEmpty() + assertWithMessage("Expected suffix unit index to be non-negative, but was: $index") + .that(index) + .isAtLeast(0) + assertWithMessage( + "Expected suffix unit index $index to be valid for suffix unit count ${units.size}" + ).that(index).isLessThan(units.size) + return NumberUnitSubject.assertThat(units[index]) + } + + private fun allUnits(): List { + return when (actual.expressionFormatCase) { + NumberWithUnitsExpression.ExpressionFormatCase.PREFIX_VALUE_EXPRESSION -> + actual.prefixValueExpression.prefixUnitsList + + NumberWithUnitsExpression.ExpressionFormatCase.VALUE_SUFFIX_EXPRESSION -> + actual.valueSuffixExpression.suffixUnitsList + + NumberWithUnitsExpression.ExpressionFormatCase.PREFIX_VALUE_SUFFIX_EXPRESSION -> + actual.prefixValueSuffixExpression.prefixUnitsList + + actual.prefixValueSuffixExpression.suffixUnitsList + + else -> emptyList() + } + } + + /** + * Truth subject for verifying properties of [NumberUnitExpression]s. + * + * Note that this class is also a [LiteProtoSubject] so other aspects of the underlying + * [NumberUnitExpression] proto can be verified through inherited methods. + * + * Call [NumberUnitSubject.assertThat] to create the subject. + */ + class NumberUnitSubject private constructor( + metadata: FailureMetadata, + private val actual: NumberUnitExpression + ) : LiteProtoSubject(metadata, actual) { + + /** + * Returns a [ComparableSubject] to test [NumberUnitExpression.getUnit]. + */ + fun hasUnitThat(): ComparableSubject = assertThat(actual.unit) + + /** + * Returns a [ComparableSubject] to test [NumberUnitExpression.getSiPrefix]. + */ + fun hasSiPrefixThat(): ComparableSubject = + assertThat(actual.siPrefix) + + /** + * Returns an [IntegerSubject] to test [NumberUnitExpression.getExponent]. + * + * This method never fails since the underlying property defaults to 0 if it's not defined in + * the unit. + */ + fun hasExponentThat(): IntegerSubject = assertThat(actual.exponent) + + companion object { + /** + * Returns a new [NumberUnitSubject] to verify aspects of the specified + * [NumberUnitExpression] value. + */ + fun assertThat(actual: NumberUnitExpression): NumberUnitSubject = + assertAbout(::NumberUnitSubject).that(actual) + } + } + + companion object { + /** + * Returns a new [NumberWithUnitsSubject] to verify aspects of the specified + * [NumberWithUnitsExpression] value. + */ + fun assertThat(actual: NumberWithUnitsExpression): NumberWithUnitsSubject = + assertAbout(::NumberWithUnitsSubject).that(actual) + } +} diff --git a/testing/src/main/java/org/oppia/android/testing/math/NumberWithUnitsTokenSubject.kt b/testing/src/main/java/org/oppia/android/testing/math/NumberWithUnitsTokenSubject.kt new file mode 100644 index 00000000000..583f3f2272e --- /dev/null +++ b/testing/src/main/java/org/oppia/android/testing/math/NumberWithUnitsTokenSubject.kt @@ -0,0 +1,144 @@ +package org.oppia.android.testing.math + +import com.google.common.truth.DoubleSubject +import com.google.common.truth.FailureMetadata +import com.google.common.truth.IntegerSubject +import com.google.common.truth.StringSubject +import com.google.common.truth.Subject +import com.google.common.truth.Truth +import org.oppia.android.util.math.NumberWithUnitsTokenizer +import org.oppia.android.util.math.NumberWithUnitsTokenizer.Companion.Token + +// TODO(#6151): Add tests for this class. + +/** + * A Truth [Subject] for testing [Token] objects from [NumberWithUnitsTokenizer]. + */ +class NumberWithUnitsTokenSubject private constructor( + metadata: FailureMetadata, + private val actual: Token +) : Subject(metadata, actual) { + + /** + * Verifies that the token is a positive integer and returns an [IntegerSubject] for the value. + * + * @return an [IntegerSubject] to verify the integer value + * @throws AssertionError if the token is not a [Token.PositiveInteger] + */ + fun isPositiveIntegerWithValueThat(): IntegerSubject { + return Truth.assertThat(actual.asVerifiedType().parsedValue) + } + + /** + * Verifies that the token is a positive real number and returns a [DoubleSubject] for the value. + * + * @return a [DoubleSubject] to verify the real number value + * @throws AssertionError if the token is not a [Token.PositiveRealNumber] + */ + fun isPositiveRealNumberWithValueThat(): DoubleSubject { + return Truth.assertThat(actual.asVerifiedType().parsedValue) + } + + /** + * Verifies that the token is a minus symbol. + * + * @throws AssertionError if the token is not a [Token.MinusSymbol] + */ + fun isMinusSymbol() { + actual.asVerifiedType() + } + + /** + * Verifies that the token is a divide symbol. + * + * @throws AssertionError if the token is not a [Token.DivideSymbol] + */ + fun isDivideSymbol() { + actual.asVerifiedType() + } + + /** + * Verifies that the token is an exponentiation symbol. + * + * @throws AssertionError if the token is not a [Token.ExponentiationSymbol] + */ + fun isExponentiationSymbol() { + actual.asVerifiedType() + } + + /** + * Verifies that the token is a multiply symbol. + * + * @throws AssertionError if the token is not a [Token.MultiplySymbol] + */ + fun isMultiplySymbol() { + actual.asVerifiedType() + } + + /** + * Verifies that the token is a left parenthesis symbol. + * + * @throws AssertionError if the token is not a [Token.LeftParenthesisSymbol] + */ + fun isLeftParenthesisSymbol() { + actual.asVerifiedType() + } + + /** + * Verifies that the token is a right parenthesis symbol. + * + * @throws AssertionError if the token is not a [Token.RightParenthesisSymbol] + */ + fun isRightParenthesisSymbol() { + actual.asVerifiedType() + } + + /** + * Verifies that the token is an invalid token. + * + * @throws AssertionError if the token is not a [Token.InvalidToken] + */ + fun isInvalidToken() { + actual.asVerifiedType() + } + + /** + * Verifies that the token is a [Token.Unit] and returns a [StringSubject] for its raw value. + * + * @return a [StringSubject] to verify the raw unit text + * @throws AssertionError if the token is not a [Token.Unit] + */ + fun isUnitWithRawValueThat(): StringSubject { + return Truth.assertThat(actual.asVerifiedType().unit) + } + + companion object { + /** + * Creates a new [NumberWithUnitsTokenSubject] to verify aspects of the specified [Token] value. + * + * This is the main entry point for using this Truth subject. It follows the standard + * Truth pattern for creating subjects. + * + * @param actual the [Token] to create assertions for + * @return a new [NumberWithUnitsTokenSubject] for making assertions + */ + fun assertThat(actual: Token): NumberWithUnitsTokenSubject = + Truth.assertAbout(::NumberWithUnitsTokenSubject).that(actual) + + /** + * Extension function that safely casts a [Token] to a specific subtype. + * + * This method performs a type check before casting to ensure the token is of the + * expected type. If the type check fails, it throws an appropriate `AssertionError` + * through Truth's assertion mechanism. + * + * @param T the expected token subtype to cast to + * @return the token cast to the expected type + * @throws AssertionError if the token is not of the expected type + */ + private inline fun Token.asVerifiedType(): T { + assertThat(this).isInstanceOf(T::class.java) + return this as T + } + } +} 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 782cfdb7920..b98e46e0018 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 @@ -225,3 +225,43 @@ kt_android_library( "//model/src/main/proto:math_java_proto_lite", ], ) + +kt_android_library( + name = "number_with_units_parser", + srcs = ["NumberWithUnitsParser.kt"], + visibility = [ + "//:oppia_api_visibility", + ], + deps = [ + ":number_with_units_parsing_error", + ":number_with_units_tokenizer", + ":peekable_iterator", + "//model/src/main/proto:interaction_object_java_proto_lite", + "//utility/src/main/java/org/oppia/android/util/extensions:string_extensions", + ], +) + +kt_android_library( + name = "number_with_units_parsing_error", + srcs = [ + "NumberWithUnitsParsingError.kt", + ], + visibility = [ + "//:oppia_api_visibility", + ], + deps = [ + "//model/src/main/proto:interaction_object_java_proto_lite", + ], +) + +kt_android_library( + name = "number_with_units_tokenizer", + srcs = ["NumberWithUnitsTokenizer.kt"], + visibility = [ + "//:oppia_api_visibility", + ], + deps = [ + ":peekable_iterator", + "//utility/src/main/java/org/oppia/android/util/extensions:string_extensions", + ], +) diff --git a/utility/src/main/java/org/oppia/android/util/math/NumberWithUnitsParser.kt b/utility/src/main/java/org/oppia/android/util/math/NumberWithUnitsParser.kt new file mode 100644 index 00000000000..66c8eb260bc --- /dev/null +++ b/utility/src/main/java/org/oppia/android/util/math/NumberWithUnitsParser.kt @@ -0,0 +1,745 @@ +package org.oppia.android.util.math + +import org.oppia.android.app.model.Fraction +import org.oppia.android.app.model.NumberUnitExpression +import org.oppia.android.app.model.NumberWithUnitsExpression +import org.oppia.android.app.model.PrefixValueExpression +import org.oppia.android.app.model.PrefixValueSuffixExpression +import org.oppia.android.app.model.ValueSuffixExpression +import org.oppia.android.util.extensions.normalizeWhitespace +import org.oppia.android.util.math.NumberWithUnitsTokenizer.Companion.Token +import org.oppia.android.util.math.PeekableIterator.Companion.toPeekableIterator + +/** + * Parser for number-with-units expressions. + * + * For the formal grammar specification, refer to the doc: + * https://docs.google.com/document/d/1PF1LdzBUwNO3tSqucCoMdYpPOWUySI3yS4m_knfLdtE. + */ +class NumberWithUnitsParser private constructor( + private val rawExpression: String, + private val tokens: PeekableIterator +) { + /** + * Grammar: + * ``` + * number_with_units = prefix_formatted_value | suffix_formatted_value ; + * ``` + */ + private fun parseNumberWithUnits(): NumberWithUnitsParsingResult { + if (!tokens.hasNext()) { + return NumberWithUnitsParsingError.EmptyExpressionError.toFailure() + } + val nextToken = tokens.peek() + val result = when { + nextToken.isCurrencyPrefixToken() -> parsePrefixFormattedValue() + nextToken is Token.PositiveInteger || + nextToken is Token.PositiveRealNumber || + nextToken is Token.MinusSymbol -> parseSuffixFormattedValue() + + nextToken is Token.InvalidToken -> { + val token = tokens.next() + NumberWithUnitsParsingError.InvalidTokenError( + rawExpression.substring(token.startIndex, token.endIndex) + ).toFailure() + } + + else -> NumberWithUnitsParsingError.NumberExpectedError.toFailure() + } + + // Ensure all tokens were consumed. + return result.maybeFail { + if (tokens.hasNext()) { + NumberWithUnitsParsingError.TrailingTokensError + } else null + } + } + + /** + * Grammar: + * ``` + * prefix_formatted_value = currency_unit_prefix , number , [ compound_unit ] ; + * ``` + */ + private fun parsePrefixFormattedValue(): NumberWithUnitsParsingResult { + val prefixUnit = parseCurrencyPrefixUnit() + ?: return NumberWithUnitsParsingError.GenericError.toFailure() + + return parseNumber(hasCurrencyPrefix = true).flatMap { expressionBuilder -> + if (isAtSuffixUnit()) { + parseCompoundUnit().maybeFail { suffixUnits -> + // Check for duplicate currency: a currency prefix was already added, so if any suffix + // unit is also a currency unit the expression is ambiguous (e.g. "$5 dollars"). + if (suffixUnits.any { it.unit.isCurrencyUnit() }) { + NumberWithUnitsParsingError.DuplicateCurrencyError + } else null + }.map { suffixUnits -> + expressionBuilder.prefixValueSuffixExpression = + PrefixValueSuffixExpression.newBuilder().apply { + addPrefixUnits(prefixUnit) + addAllSuffixUnits(suffixUnits) + }.build() + expressionBuilder.build() + } + } else { + expressionBuilder.prefixValueExpression = PrefixValueExpression.newBuilder().apply { + addPrefixUnits(prefixUnit) + }.build() + NumberWithUnitsParsingResult.Success(expressionBuilder.build()) + } + } + } + + /** + * Grammar: + * ``` + * suffix_formatted_value = number , compound_unit ; + * ``` + */ + private fun parseSuffixFormattedValue(): NumberWithUnitsParsingResult { + return parseNumber().flatMap { expressionBuilder -> + // compound_unit is required + if (!isAtSuffixUnit()) { + NumberWithUnitsParsingError.UnitExpectedError.toFailure() + } else { + parseCompoundUnit().map { suffixUnits -> + expressionBuilder.apply { + valueSuffixExpression = ValueSuffixExpression.newBuilder().apply { + addAllSuffixUnits(suffixUnits) + }.build() + }.build() + } + } + } + } + + /** + * Grammar: + * ``` + * negatable_number = [ "-" ] , number ; + * number = positive_integer , [ ( "/" , positive_integer ) | ( "." , positive_integer ) ] ; + * ``` + * + * Notes that [Token.PositiveRealNumber] is already tokenized as a single token, so decimal + * parsing does not happen here. `integer/integer` is always interpreted as a fractional numeric + * literal at this stage. Unit division (for example `m/s`) is parsed later as part of + * `compound_unit`. + */ + private fun parseNumber( + hasCurrencyPrefix: Boolean = false + ): NumberWithUnitsParsingResult { + val isNegative = if (tokens.peek() is Token.MinusSymbol) { + true.also { tokens.next() } // consume '-' + } else false + + val expressionBuilder = NumberWithUnitsExpression.newBuilder() + + return when (val firstToken = tokens.peek()) { + is Token.PositiveRealNumber -> { + tokens.next() + expressionBuilder.real = if (isNegative) { + -firstToken.parsedValue + } else { + firstToken.parsedValue + } + NumberWithUnitsParsingResult.Success(expressionBuilder) + } + + is Token.PositiveInteger -> { + tokens.next() + // Check for fraction continuation. + if (tokens.peek() is Token.DivideSymbol) { + // Peek ahead: could be a fraction (integer/integer) if followed by PositiveInteger + // and then a unit or end-of-input. But we need to distinguish "1/2 kg" (fraction) + // from "5 m/s" (unit division). Since we are in parseNumber, the divide here always + // means a fractional number. + tokens.next() // consume '/' + val denomToken = tokens.peek() + if (denomToken is Token.PositiveInteger) { + tokens.next() + val fraction = Fraction.newBuilder().apply { + this.isNegative = isNegative + wholeNumber = 0 + numerator = firstToken.parsedValue + denominator = denomToken.parsedValue + }.build() + expressionBuilder.fraction = fraction + NumberWithUnitsParsingResult.Success(expressionBuilder) + } else { + NumberWithUnitsParsingError.MissingDenominatorError.toFailure() + } + } else { + expressionBuilder.real = if (isNegative) { + -firstToken.parsedValue.toDouble() + } else { + firstToken.parsedValue.toDouble() + } + NumberWithUnitsParsingResult.Success(expressionBuilder) + } + } + + else -> { + if (hasCurrencyPrefix) { + NumberWithUnitsParsingError.NumberExpectedAfterCurrencyPrefixError.toFailure() + } else { + NumberWithUnitsParsingError.NumberExpectedError.toFailure() + } + } + } + } + + /** + * Grammar: + * ``` + * compound_unit = units_multiplied , [ division_operator , denominator_expression ] ; + * ``` + */ + private fun parseCompoundUnit(): NumberWithUnitsParsingResult> { + return parseUnitsMultiplied().flatMap { numeratorUnits -> + val allUnits = numeratorUnits.toMutableList() + + // Optional: division_operator , denominator_expression + if (tokens.peek() is Token.DivideSymbol) { + tokens.next() // consume '/' + parseDenominatorExp().map { denomUnits -> + allUnits + denomUnits + } + } else { + NumberWithUnitsParsingResult.Success(allUnits) + } + } + } + + /** + * Grammar: + * ``` + * denominator_expression = + * ( left_paren , units_multiplied , right_paren ) | units_multiplied ; + * ``` + */ + private fun parseDenominatorExp(): NumberWithUnitsParsingResult> { + val hasParens = tokens.peek() is Token.LeftParenthesisSymbol + if (hasParens) tokens.next() // consume '(' + + if (!isAtSuffixUnit()) { + return NumberWithUnitsParsingError.UnitExpectedAfterDivisionError.toFailure() + } + + return parseUnitsMultiplied().flatMap { units -> + if (hasParens) { + if (tokens.peek() !is Token.RightParenthesisSymbol) { + return@flatMap NumberWithUnitsParsingError.UnbalancedParenthesesError.toFailure() + } + tokens.next() // consume ')' + } + + // Negate exponents for denominator units + val negated = units.map { unit -> + unit.toBuilder().setExponent(-unit.exponent).build() + } + NumberWithUnitsParsingResult.Success(negated) + } + } + + /** + * Grammar: + * ``` + * units_multiplied = unit_with_exponent , { unit_with_exponent } ; + * ``` + */ + private fun parseUnitsMultiplied(): NumberWithUnitsParsingResult> { + val units = mutableListOf() + return parseUnitsFromNextToken().flatMap { firstUnits -> + units.addAll(firstUnits) + + var currentResult: NumberWithUnitsParsingResult = + NumberWithUnitsParsingResult.Success(Unit) + // Continue consuming units while we see suffix units (but not '/', ')' or end) + while (isAtSuffixUnit() && !currentResult.isFailure()) { + currentResult = currentResult.flatMap { + parseUnitsFromNextToken().map { nextUnits -> + units.addAll(nextUnits) + Unit + } + } + } + currentResult.map { units } + } + } + + /** + * Parses a single unit token, returning one or more [NumberUnitExpression]s. + * + * First attempts standard single-unit parsing (with optional exponent). If that fails due to + * an unrecognized unit, attempts to decompose the token into multiple known units. + * + * When decomposition succeeds and a trailing exponent is present, the exponent + * is applied only to the last unit in the decomposed list. + */ + private fun parseUnitsFromNextToken(): NumberWithUnitsParsingResult> { + val initialRetrievalCount = tokens.getRetrievalCount() + val singleResult = parseUnitWithExponent() + if (singleResult !is NumberWithUnitsParsingResult.Failure) { + return singleResult.map { listOf(it) } + } + + // Single-unit parsing failed. Try decomposing compound unit tokens + // only if the single-unit parse didn't consume any tokens. + if (tokens.getRetrievalCount() == initialRetrievalCount) { + val token = tokens.peek() + if (token is Token.Unit) { + val decomposed = tryDecomposeCompoundUnit(token.unit, token) + if (decomposed != null && decomposed.size > 1) { + tokens.next() // Consume the compound token. + + // Look ahead for an exponent extension following the compound token. + // If present, apply the exponent only to the last unit in the decomposed list. + return parseExponentExtension().map { exponentMultiplier -> + if (exponentMultiplier != 1) { + val mutableDecomposed = decomposed.toMutableList() + val lastUnit = mutableDecomposed.last() + mutableDecomposed[mutableDecomposed.lastIndex] = lastUnit.toBuilder() + .setExponent(lastUnit.exponent * exponentMultiplier) + .build() + mutableDecomposed + } else { + decomposed + } + } + } + } + } + + // Return the original single-unit error. + return NumberWithUnitsParsingResult.Failure(singleResult.error) + } + + /** + * Grammar: + * ``` + * unit_with_exponent = suffix_unit , [ exponent_operator , negatable_number ] ; + * ``` + */ + private fun parseUnitWithExponent(): NumberWithUnitsParsingResult { + return parseSuffixUnit().flatMap { parsedUnit -> + parseExponentExtension().map { exponentMultiplier -> + parsedUnit.toBuilder() + .setExponent(parsedUnit.exponent * exponentMultiplier) + .build() + } + } + } + + /** + * Parses an exponent extension if one follows the current position in the token stream. + * + * @return a [NumberWithUnitsParsingResult] containing the exponent multiplier value (1 if no + * exponent is present) + */ + private fun parseExponentExtension(): NumberWithUnitsParsingResult { + if (tokens.peek() is Token.ExponentiationSymbol) { + tokens.next() // consume '^' + + val negativeExponent = if (tokens.peek() is Token.MinusSymbol) { + true.also { tokens.next() } // consume '-' + } else false + + val expToken = tokens.peek() + return if (expToken is Token.PositiveInteger) { + tokens.next() + val exponentMultiplier = if (negativeExponent) { + -expToken.parsedValue + } else { + expToken.parsedValue + } + NumberWithUnitsParsingResult.Success(exponentMultiplier) + } else { + NumberWithUnitsParsingError.MissingExponentError.toFailure() + } + } + return NumberWithUnitsParsingResult.Success(1) + } + + /** + * Attempts to decompose a compound unit string into a list of individual [NumberUnitExpression]s. + * + * Each substring is first tried as a plain unit, then as an SI-prefix and base unit combination. + * + * @param unitStr the unit string to decompose + * @param originalToken the original [Token.Unit] (used for index tracking) + * @return the list of resolved units, or null if decomposition is not possible + */ + private fun tryDecomposeCompoundUnit( + unitStr: String, + originalToken: Token.Unit + ): List? { + if (unitStr.isEmpty()) return emptyList() + + // Try longest prefix first to find the best match. + for (len in unitStr.length downTo 1) { + val candidate = unitStr.substring(0, len) + val resolved = resolveUnitSubstring(candidate, originalToken) + if (resolved != null) { + val remaining = unitStr.substring(len) + val remainingToken = Token.Unit( + remaining, originalToken.startIndex + len, originalToken.endIndex + ) + val rest = tryDecomposeCompoundUnit(remaining, remainingToken) + if (rest != null) return listOf(resolved) + rest + } + } + return null + } + + /** + * Tries to resolve a unit substring as either a plain unit or an SI-prefix and base unit. + * + * @param candidate the substring to resolve + * @param originalToken the original token (for index computation) + * @return the resolved [NumberUnitExpression], or null if not resolvable + */ + private fun resolveUnitSubstring( + candidate: String, + originalToken: Token.Unit + ): NumberUnitExpression? { + val syntheticToken = Token.Unit( + candidate, originalToken.startIndex, originalToken.startIndex + candidate.length + ) + + // Try as a plain unit first. + val plainUnit = parseAnyUnit(syntheticToken) + if (plainUnit != null) return createUnitExpression(plainUnit) + + // Try as SI-prefix and base unit. + val siPrefixIndex = syntheticToken.extractUnitStartIndex() + if (siPrefixIndex > 0 && siPrefixIndex < candidate.length) { + val siPrefixStr = candidate.substring(0, siPrefixIndex) + val siPrefix = parseSiPrefix(siPrefixStr) + val baseUnitStr = candidate.substring(siPrefixIndex) + val baseToken = Token.Unit( + baseUnitStr, syntheticToken.startIndex + siPrefixIndex, syntheticToken.endIndex + ) + val baseUnit = parseAnyUnit(baseToken) + if (baseUnit != null && baseUnit.isPrefixable() && siPrefix != null) { + return createUnitExpression(baseUnit, siPrefix = siPrefix) + } + } + return null + } + + /** + * Grammar: + * ``` + * suffix_unit = physical_unit | currency_unit_suffix ; + * physical_unit = length_unit | mass_unit | area_unit | volume_unit | ... ; + * ``` + * + * The token stream may deliver an [Token.SiPrefix] followed by a base unit token, + * or directly a unit token, or a currency suffix token. + * + * @return the canonical unit name string + */ + private fun parseSuffixUnit(): NumberWithUnitsParsingResult { + val token = tokens.peek() + if (token !is Token.Unit || token.unit.isEmpty()) { + return NumberWithUnitsParsingError.UnitExpectedError.toFailure() + } + + val unitStartIndex = token.extractUnitStartIndex() + if (unitStartIndex == 0) { + // No SI prefix, so attempt to parse the entire token as a unit. + val parsedUnit = parseAnyUnit(token) + return if (parsedUnit != null) { + NumberWithUnitsParsingResult.Success(parsedUnit) + } else { + NumberWithUnitsParsingError.InvalidUnitError(token.unit).toFailure() + }.map { unit -> + tokens.next() + createUnitExpression(unit) + } + } else { + // SI prefix present, so attempt to parse the base unit after the prefix. + val siPrefixStr = token.unit.substring(0, unitStartIndex) + val siPrefix = parseSiPrefix(siPrefixStr) + val baseUnitStr = token.unit.substring(unitStartIndex) + val baseUnit = parseAnyUnit( + Token.Unit(baseUnitStr, token.startIndex + unitStartIndex, token.endIndex) + ) + return if (baseUnit == null || !baseUnit.isPrefixable()) { + // Might not be SI prefix, but entirely unit. Attempt to parse entire token as unit + // before failing with UnitExpectedAfterSiPrefixError. + val parsedUnit = parseAnyUnit(token) + if (parsedUnit != null) { + NumberWithUnitsParsingResult.Success(parsedUnit) + } else { + NumberWithUnitsParsingError.UnitExpectedAfterSiPrefixError(siPrefixStr).toFailure() + }.map { unit -> + tokens.next() + createUnitExpression(unit) + } + } else { + NumberWithUnitsParsingResult.Success(baseUnit).map { unit -> + tokens.next() + createUnitExpression( + unit, + siPrefix = siPrefix ?: NumberUnitExpression.SiPrefix.SI_PREFIX_UNSPECIFIED + ).toBuilder().build() + } + } + } + } + + /** Parses a currency prefix token and returns it as a [NumberUnitExpression] with exponent 1. */ + private fun parseCurrencyPrefixUnit(): NumberUnitExpression? { + val token = tokens.peek() as? Token.Unit ?: return null + val currencyUnit = when (token.unit) { + "$" -> NumberUnitExpression.Unit.DOLLAR + "₹", "Rs" -> NumberUnitExpression.Unit.RUPEE + else -> return null + } + tokens.next() + return createUnitExpression(currencyUnit) + } + + private fun parseAnyUnit(token: Token.Unit): NumberUnitExpression.Unit? { + return when (token.unit) { + "m", "meter", "meters" -> NumberUnitExpression.Unit.METER + "m2" -> NumberUnitExpression.Unit.SQUARE_METER + "m3" -> NumberUnitExpression.Unit.CUBIC_METER + "in", "inch", "inches" -> NumberUnitExpression.Unit.INCH + "sqin", "sqinch" -> NumberUnitExpression.Unit.SQUARE_INCH + "cuin" -> NumberUnitExpression.Unit.CUBIC_INCH + "ft", "foot", "feet" -> NumberUnitExpression.Unit.FOOT + "sqft", "sqfeet" -> NumberUnitExpression.Unit.SQUARE_FOOT + "cuft" -> NumberUnitExpression.Unit.CUBIC_FOOT + "yd", "yard", "yards" -> NumberUnitExpression.Unit.YARD + "sqyd", "sqyard" -> NumberUnitExpression.Unit.SQUARE_YARD + "cuyd" -> NumberUnitExpression.Unit.CUBIC_YARD + "g", "gram", "grams" -> NumberUnitExpression.Unit.GRAM + "gr", "grain", "grains" -> NumberUnitExpression.Unit.GRAIN + "oz", "ounce", "ounces" -> NumberUnitExpression.Unit.OUNCE + "l", "L", "lt", "liter", "litre", "liters", "litres" -> NumberUnitExpression.Unit.LITER + "cc" -> NumberUnitExpression.Unit.CUBIC_CENTIMETER + "s", "sec", "secs", "second", "seconds" -> NumberUnitExpression.Unit.SECOND + "min", "mins", "minute", "minutes" -> NumberUnitExpression.Unit.MINUTE + "h", "hr", "hrs", "hour", "hours" -> NumberUnitExpression.Unit.HOUR + "K", "kelvin" -> NumberUnitExpression.Unit.KELVIN + "degC", "celsius" -> NumberUnitExpression.Unit.CELSIUS + "Hz", "hertz" -> NumberUnitExpression.Unit.HERTZ + "rad", "radian", "radians" -> NumberUnitExpression.Unit.RADIAN + "deg", "degree", "degrees" -> NumberUnitExpression.Unit.DEGREE + "mol", "mole", "moles" -> NumberUnitExpression.Unit.MOLE + "cd", "candela" -> NumberUnitExpression.Unit.CANDELA + "N", "newton", "newtons" -> NumberUnitExpression.Unit.NEWTON + "J", "joule", "joules" -> NumberUnitExpression.Unit.JOULE + "W", "watt", "watts" -> NumberUnitExpression.Unit.WATT + "Pa", "pascal", "pascals" -> NumberUnitExpression.Unit.PASCAL + "A", "ampere", "amperes" -> NumberUnitExpression.Unit.AMPERE + "V", "volt", "volts" -> NumberUnitExpression.Unit.VOLT + "ohm", "ohms" -> NumberUnitExpression.Unit.OHM + "USD", "dollar", "dollars", "Dollar", "Dollars" -> NumberUnitExpression.Unit.DOLLAR + "¢", "cent", "cents", "Cent", "Cents" -> NumberUnitExpression.Unit.CENT + "rupee", "rupees", "Rupee", "Rupees" -> NumberUnitExpression.Unit.RUPEE + "paisa", "paise", "Paisa", "Paise" -> NumberUnitExpression.Unit.PAISA + else -> null + } + } + + private fun NumberUnitExpression.Unit.isPrefixable(): Boolean { + return this == NumberUnitExpression.Unit.METER || + this == NumberUnitExpression.Unit.GRAM || + this == NumberUnitExpression.Unit.LITER || + this == NumberUnitExpression.Unit.RADIAN || + this == NumberUnitExpression.Unit.DEGREE || + this == NumberUnitExpression.Unit.SECOND || + this == NumberUnitExpression.Unit.HERTZ || + this == NumberUnitExpression.Unit.MOLE || + this == NumberUnitExpression.Unit.CANDELA || + this == NumberUnitExpression.Unit.NEWTON || + this == NumberUnitExpression.Unit.JOULE || + this == NumberUnitExpression.Unit.WATT || + this == NumberUnitExpression.Unit.PASCAL || + this == NumberUnitExpression.Unit.AMPERE || + this == NumberUnitExpression.Unit.VOLT || + this == NumberUnitExpression.Unit.OHM + } + + private fun Token.Unit.extractUnitStartIndex(): Int { + val match = SI_PREFIX_MAP.keys + .sortedByDescending { it.length } + .firstOrNull { prefix -> + this.unit.length > prefix.length && this.unit.startsWith(prefix) + } + + return match?.length ?: 0 + } + + private fun parseSiPrefix(symbol: String): NumberUnitExpression.SiPrefix? { + return Companion.SI_PREFIX_MAP[symbol] + } + + private fun isAtSuffixUnit(): Boolean { + return tokens.peek() is Token.Unit + } + + private fun NumberUnitExpression.Unit.isCurrencyUnit(): Boolean = + this == NumberUnitExpression.Unit.DOLLAR || + this == NumberUnitExpression.Unit.CENT || + this == NumberUnitExpression.Unit.RUPEE || + this == NumberUnitExpression.Unit.PAISA + + private fun Token?.isCurrencyPrefixToken(): Boolean { + if (this !is Token.Unit) return false + return unit == "$" || unit == "₹" || unit == "Rs" + } + + private fun createUnitExpression( + unit: NumberUnitExpression.Unit, + exponent: Int = 1, + siPrefix: NumberUnitExpression.SiPrefix = + NumberUnitExpression.SiPrefix.SI_PREFIX_UNSPECIFIED + ): NumberUnitExpression { + return NumberUnitExpression.newBuilder().apply { + this.unit = unit + this.exponent = exponent + this.siPrefix = siPrefix + }.build() + } + + companion object { + /** The result of attempting to parse a raw number-with-units expression. */ + sealed class NumberWithUnitsParsingResult { + /** Indicates that the parse was successful with a value of [result]. */ + data class Success(val result: T) : NumberWithUnitsParsingResult() + + /** Indicates that the parse failed with the specified [error]. */ + data class Failure( + val error: NumberWithUnitsParsingError + ) : NumberWithUnitsParsingResult() + } + + /** + * Parses a [rawExpression] as a number-with-units expression. + * + * The expression is first normalized (trimmed / whitespace collapsed) before tokenization. + * + * @return the result of attempting to parse the specified expression + */ + fun parseNumberWithUnits( + rawExpression: String + ): NumberWithUnitsParsingResult { + val normalized = rawExpression.normalizeWhitespace() + val tokens = NumberWithUnitsTokenizer.tokenize(normalized).toPeekableIterator() + return NumberWithUnitsParser(normalized, tokens).parseNumberWithUnits() + } + + /** Creates a failed parse result with [this] error. */ + private fun NumberWithUnitsParsingError.toFailure(): NumberWithUnitsParsingResult = + NumberWithUnitsParsingResult.Failure(this) + + /** Returns whether [this] result is a failure. */ + private fun NumberWithUnitsParsingResult.isFailure() = + this is NumberWithUnitsParsingResult.Failure + + /** + * Transforms the successful result of [this] using [operation], or returns the failure if + * [this] is a failure. + */ + private fun NumberWithUnitsParsingResult.map( + operation: (T1) -> T2 + ): NumberWithUnitsParsingResult = flatMap { result -> + NumberWithUnitsParsingResult.Success(operation(result)) + } + + /** + * Transforms the successful result of [this] using [operation], or returns the failure if + * [this] is a failure. The difference between this and [map] is that [operation] can also + * return a failure, which will be returned by this method if it occurs. + */ + private fun NumberWithUnitsParsingResult.flatMap( + operation: (T1) -> NumberWithUnitsParsingResult + ): NumberWithUnitsParsingResult { + return when (this) { + is NumberWithUnitsParsingResult.Success -> operation(result) + is NumberWithUnitsParsingResult.Failure -> error.toFailure() + } + } + + /** + * Potentially changes [this] result into a failure based on the provided [operation]. + * The operation is only called if [this] result is currently successful; the returned result + * will only be in a failing state if [operation] returns a non-null error. + */ + private fun NumberWithUnitsParsingResult.maybeFail( + operation: (T) -> NumberWithUnitsParsingError? + ): NumberWithUnitsParsingResult = flatMap { result -> + operation(result)?.toFailure() ?: this + } + + /** + * Map of metric/SI prefix abbreviated and full names to their corresponding + * [NumberUnitExpression.SiPrefix] enum values. + */ + val SI_PREFIX_MAP = mapOf( + "da" to NumberUnitExpression.SiPrefix.DECA, + "deca" to NumberUnitExpression.SiPrefix.DECA, + + "h" to NumberUnitExpression.SiPrefix.HECTO, + "hecto" to NumberUnitExpression.SiPrefix.HECTO, + + "k" to NumberUnitExpression.SiPrefix.KILO, + "kilo" to NumberUnitExpression.SiPrefix.KILO, + + "M" to NumberUnitExpression.SiPrefix.MEGA, + "mega" to NumberUnitExpression.SiPrefix.MEGA, + + "G" to NumberUnitExpression.SiPrefix.GIGA, + "giga" to NumberUnitExpression.SiPrefix.GIGA, + + "T" to NumberUnitExpression.SiPrefix.TERA, + "tera" to NumberUnitExpression.SiPrefix.TERA, + + "P" to NumberUnitExpression.SiPrefix.PETA, + "peta" to NumberUnitExpression.SiPrefix.PETA, + + "E" to NumberUnitExpression.SiPrefix.EXA, + "exa" to NumberUnitExpression.SiPrefix.EXA, + + "Z" to NumberUnitExpression.SiPrefix.ZETTA, + "zetta" to NumberUnitExpression.SiPrefix.ZETTA, + + "Y" to NumberUnitExpression.SiPrefix.YOTTA, + "yotta" to NumberUnitExpression.SiPrefix.YOTTA, + + "d" to NumberUnitExpression.SiPrefix.DECI, + "deci" to NumberUnitExpression.SiPrefix.DECI, + + "c" to NumberUnitExpression.SiPrefix.CENTI, + "centi" to NumberUnitExpression.SiPrefix.CENTI, + + "m" to NumberUnitExpression.SiPrefix.MILLI, + "milli" to NumberUnitExpression.SiPrefix.MILLI, + + "u" to NumberUnitExpression.SiPrefix.MICRO, + "micro" to NumberUnitExpression.SiPrefix.MICRO, + + "n" to NumberUnitExpression.SiPrefix.NANO, + "nano" to NumberUnitExpression.SiPrefix.NANO, + + "p" to NumberUnitExpression.SiPrefix.PICO, + "pico" to NumberUnitExpression.SiPrefix.PICO, + + "f" to NumberUnitExpression.SiPrefix.FEMTO, + "femto" to NumberUnitExpression.SiPrefix.FEMTO, + + "a" to NumberUnitExpression.SiPrefix.ATTO, + "atto" to NumberUnitExpression.SiPrefix.ATTO, + + "z" to NumberUnitExpression.SiPrefix.ZEPTO, + "zepto" to NumberUnitExpression.SiPrefix.ZEPTO, + + "y" to NumberUnitExpression.SiPrefix.YOCTO, + "yocto" to NumberUnitExpression.SiPrefix.YOCTO + ) + } +} diff --git a/utility/src/main/java/org/oppia/android/util/math/NumberWithUnitsParsingError.kt b/utility/src/main/java/org/oppia/android/util/math/NumberWithUnitsParsingError.kt new file mode 100644 index 00000000000..15cb46c888d --- /dev/null +++ b/utility/src/main/java/org/oppia/android/util/math/NumberWithUnitsParsingError.kt @@ -0,0 +1,129 @@ +package org.oppia.android.util.math + +/** + * An error that can be encountered while trying to parse a raw number with units expression. + * + * All possible errors are subclasses to this sealed class. This class is modeled after + * [MathParsingError] and represents errors specific to number-with-units parsing. + */ +sealed class NumberWithUnitsParsingError { + /** + * Indicates that an invalid symbol was encountered while parsing, e.g. '@'. + * + * This is an irrecoverable error. + * + * @property invalidSymbol the raw invalid symbol that was encountered during parsing + */ + data class InvalidTokenError(val invalidSymbol: String) : NumberWithUnitsParsingError() + + /** + * Indicates that an invalid unit was encountered while parsing. + * + * This is an irrecoverable error. + * + * @property invalidUnit the raw invalid unit that was encountered during parsing + */ + data class InvalidUnitError(val invalidUnit: String) : NumberWithUnitsParsingError() + + /** + * Indicates that the input expression is empty or contains only whitespace. + * + * This is an irrecoverable error. + */ + object EmptyExpressionError : NumberWithUnitsParsingError() + + /** + * Indicates that a number was expected but not found. + * + * This is an irrecoverable error. A number-with-units expression must begin with either + * a currency prefix unit followed by a number, or a number directly. + */ + object NumberExpectedError : NumberWithUnitsParsingError() + + /** + * Indicates that at least one unit is expected but none were found after the number. + * + * This is an irrecoverable error. A valid number-with-units expression must have at least + * one unit. + */ + object UnitExpectedError : NumberWithUnitsParsingError() + + /** + * Indicates that the user didn't finish a parenthetical group, e.g. '(m' or 'm)'. + * + * This is an irrecoverable error. + */ + object UnbalancedParenthesesError : NumberWithUnitsParsingError() + + /** + * Indicates that a number was expected after the division symbol '/' in a fraction. + * + * For example, '1/' is missing the denominator. + * + * This is an irrecoverable error. + */ + object MissingDenominatorError : NumberWithUnitsParsingError() + + /** + * Indicates that an exponent value was expected after the '^' operator but not found. + * + * For example, 'kg^' is missing the exponent. + * + * This is an irrecoverable error. + */ + object MissingExponentError : NumberWithUnitsParsingError() + + /** + * Indicates that a unit was expected after an SI prefix. + * + * For example, 'kilo' without a following unit like 'gram' or 'meter'. + * + * This is an irrecoverable error. + * + * @property prefix the SI prefix that was found without a following unit + */ + data class UnitExpectedAfterSiPrefixError( + val prefix: String + ) : NumberWithUnitsParsingError() + + /** + * Indicates that a currency prefix was used but no valid number followed it. + * + * For example, '$ kg' where a number is expected after '$'. + * + * This is an irrecoverable error. + */ + object NumberExpectedAfterCurrencyPrefixError : NumberWithUnitsParsingError() + + /** + * Indicates that a unit was expected after the division operator in a compound unit. + * + * For example, 'm /' where units are expected after the division. + * + * This is an irrecoverable error. + */ + object UnitExpectedAfterDivisionError : NumberWithUnitsParsingError() + + /** + * Indicates that both a currency prefix and a currency suffix were found in the same expression. + * + * For example, '$5 dollars' is ambiguous. + * + * This is an irrecoverable error. + */ + object DuplicateCurrencyError : NumberWithUnitsParsingError() + + /** + * Indicates that there are extra tokens remaining after the expression has been fully parsed. + * + * This is an irrecoverable error. + */ + object TrailingTokensError : NumberWithUnitsParsingError() + + /** + * Indicates a generic error that wasn't specifically recognized as any of the others. + * + * This is an irrecoverable error. + */ + object GenericError : NumberWithUnitsParsingError() +} diff --git a/utility/src/main/java/org/oppia/android/util/math/NumberWithUnitsTokenizer.kt b/utility/src/main/java/org/oppia/android/util/math/NumberWithUnitsTokenizer.kt new file mode 100644 index 00000000000..c79782615de --- /dev/null +++ b/utility/src/main/java/org/oppia/android/util/math/NumberWithUnitsTokenizer.kt @@ -0,0 +1,278 @@ +package org.oppia.android.util.math + +import org.oppia.android.util.math.PeekableIterator.Companion.toPeekableIterator + +/** + * A tokenizer for parsing mathematical expressions containing numbers and units. + * + * This class only lexes raw unit text by consuming contiguous valid unit characters and does + * not interpret singular/plural forms, abbreviations, or naming conventions. Unit normalization + * and interpretation are handled by [NumberWithUnitsParser]. + * + * See https://docs.google.com/document/d/1PF1LdzBUwNO3tSqucCoMdYpPOWUySI3yS4m_knfLdtE for the + * various units supported by the parser. + */ +class NumberWithUnitsTokenizer private constructor() { + companion object { + /** + * Tokenizes a string input into a sequence of [Token] objects. + * + * This method processes characters one by one, identifying tokens based on the first + * character encountered. It handles whitespace automatically and delegates to specific + * tokenization methods based on the character type. + * + * @param input the string to tokenize + * @return a [Sequence] of [Token] objects + */ + fun tokenize(input: String): Sequence { + val chars = input.toCharArray().asSequence().toPeekableIterator() + return generateSequence { + chars.consumeWhitespace() + + when (chars.peek()) { + in '0'..'9' -> tokenizeIntegerOrRealNumber(chars) + in 'a'..'z', in 'A'..'Z' -> tokenizeUnit(input, chars) + '-', '−', '–' -> tokenizeSymbol(chars) { startIndex, endIndex -> + Token.MinusSymbol(startIndex, endIndex) + } + + '/' -> tokenizeSymbol(chars) { startIndex, endIndex -> + Token.DivideSymbol(startIndex, endIndex) + } + + '₹', '$', '¢' -> tokenizeSymbol(chars) { startIndex, endIndex -> + rawUnitToken(input, startIndex, endIndex) + } + + '^' -> tokenizeSymbol(chars) { startIndex, endIndex -> + Token.ExponentiationSymbol(startIndex, endIndex) + } + + '*' -> tokenizeSymbol(chars) { startIndex, endIndex -> + Token.MultiplySymbol(startIndex, endIndex) + } + + '(' -> tokenizeSymbol(chars) { startIndex, endIndex -> + Token.LeftParenthesisSymbol(startIndex, endIndex) + } + + ')' -> tokenizeSymbol(chars) { startIndex, endIndex -> + Token.RightParenthesisSymbol(startIndex, endIndex) + } + + null -> null + else -> tokenizeSymbol(chars) { startIndex, endIndex -> + Token.InvalidToken(startIndex, endIndex) + } + } + } + } + + /** + * Tokenizes a number (either integer or real) starting from the current position. + * + * This method handles both integers (e.g., "123") and real numbers with decimal points (e.g., "123.45"). + * Whitespace is allowed around the decimal point, but not between digits. + * + * @param chars the peekable iterator positioned at the start of a number + * @return a [Token.PositiveInteger], [Token.PositiveRealNumber], or [Token.InvalidToken] + */ + private fun tokenizeIntegerOrRealNumber(chars: PeekableIterator): Token { + val startIndex = chars.getRetrievalCount() + val integerPart1 = + parseInteger(chars) + ?: return Token.InvalidToken(startIndex, endIndex = chars.getRetrievalCount()) + val integerEndIndex = chars.getRetrievalCount() + chars.consumeWhitespace() + + return if (chars.peek() == '.') { + chars.next() + chars.consumeWhitespace() + + val integerPart2 = parseInteger(chars) + ?: return Token.InvalidToken(startIndex, endIndex = chars.getRetrievalCount()) + + val doubleValue = "$integerPart1.$integerPart2".toValidDoubleOrNull() + ?: return Token.InvalidToken(startIndex, endIndex = chars.getRetrievalCount()) + Token.PositiveRealNumber(doubleValue, startIndex, endIndex = chars.getRetrievalCount()) + } else { + Token.PositiveInteger( + integerPart1.toIntOrNull() + ?: return Token.InvalidToken(startIndex, endIndex = chars.getRetrievalCount()), + startIndex, + integerEndIndex + ) + } + } + + /** + * Parses a contiguous sequence of digits into a string representation of an integer. + * + * This method stops parsing if it encounters whitespace or any non-digit character. + * + * @param chars the peekable iterator to parse from + * @return the parsed integer as a string, or null if no digits were found + */ + private fun parseInteger(chars: PeekableIterator): String? { + val integerBuilder = StringBuilder() + while (chars.peek() in '0'..'9') { + integerBuilder.append(chars.next()) + } + return if (integerBuilder.isNotEmpty()) { + integerBuilder.toString() + } else null + } + + /** + * Tokenizes a single-character symbol using the provided factory function. + * + * @param chars the peekable iterator positioned at the symbol + * @param factory function that creates the appropriate token given start and end indices + * @return the token created by the factory function + */ + private fun tokenizeSymbol(chars: PeekableIterator, factory: (Int, Int) -> Token): Token { + val startIndex = chars.getRetrievalCount() + chars.next() + val endIndex = chars.getRetrievalCount() + return factory(startIndex, endIndex) + } + + /** + * Tokenizes a raw unit string starting from the current position by consuming contiguous + * valid unit characters. + * + * This method does not interpret unit semantics. Unit normalization (such as handling + * singular or plural forms and abbreviations) is performed by [NumberWithUnitsParser]. + * + * @param input the original string input being tokenized + * @param chars the [PeekableIterator] positioned at the start of a unit + * @return the appropriate [Token] representing the raw unit text + */ + private fun tokenizeUnit(input: String, chars: PeekableIterator): Token { + val startIndex = chars.getRetrievalCount() + while (chars.peek() in VALID_UNIT_CHARACTERS) { + chars.next() + } + return rawUnitToken(input, startIndex, chars.getRetrievalCount()) + } + + /** Represents a token for parsing a number with units. */ + sealed class Token { + /** The (inclusive) index in the input stream at which point this token begins. */ + abstract val startIndex: Int + + /** The (exclusive) index in the input stream at which point this token ends. */ + abstract val endIndex: Int + + /** + * Represents a positive integer (i.e. no decimal point, and no negative sign). + * + * @property parsedValue the parsed value of the integer + */ + class PositiveInteger( + val parsedValue: Int, + override val startIndex: Int, + override val endIndex: Int + ) : Token() + + /** + * Represents a positive real number (i.e. contains a decimal point, but no negative sign). + * + * @property parsedValue the parsed value of the real number as a [Double] + */ + class PositiveRealNumber( + val parsedValue: Double, + override val startIndex: Int, + override val endIndex: Int + ) : Token() + + /** Represents a minus sign, e.g. '-'. */ + class MinusSymbol( + override val startIndex: Int, + override val endIndex: Int + ) : Token() + + /** Represents a multiply sign, e.g. '*'. */ + class MultiplySymbol( + override val startIndex: Int, + override val endIndex: Int + ) : Token() + + /** Represents a divide sign, e.g. '/'. */ + class DivideSymbol( + override val startIndex: Int, + override val endIndex: Int + ) : Token() + + /** Represents an exponent sign, i.e. '^'. */ + class ExponentiationSymbol( + override val startIndex: Int, + override val endIndex: Int + ) : Token() + + /** Represents a left parenthesis symbol, i.e. '('. */ + class LeftParenthesisSymbol( + override val startIndex: Int, + override val endIndex: Int + ) : Token() + + /** Represents a right parenthesis symbol, i.e. ')'. */ + class RightParenthesisSymbol( + override val startIndex: Int, + override val endIndex: Int + ) : Token() + + /** Represents any recognized unit using its original matched input substring. */ + class Unit( + val unit: String, + override val startIndex: Int, + override val endIndex: Int + ) : Token() + + /** Represents an invalid character that doesn't fit any of the other [Token] types. */ + class InvalidToken(override val startIndex: Int, override val endIndex: Int) : Token() + } + + /** Converts a string to a [Token.Unit]. */ + private fun rawUnitToken(input: String, startIndex: Int, endIndex: Int): Token.Unit = + Token.Unit(input.substring(startIndex, endIndex), startIndex, endIndex) + + /** + * Converts a string to a Double, ensuring the result is finite (not infinite or NaN). + * + * @return the parsed Double if valid and finite, null otherwise + */ + private fun String.toValidDoubleOrNull(): Double? { + return toDoubleOrNull()?.takeIf { it.isFinite() } + } + + /** + * Determines if a character is considered whitespace for tokenization purposes. + * + * @return true if the character is a space, tab, newline, or carriage return + */ + private fun Char.isWhitespace(): Boolean = when (this) { + ' ', '\t', '\n', '\r' -> true + else -> false + } + + /** + * Consumes all consecutive whitespace characters from the iterator. + * + * This method advances the iterator past any whitespace characters until + * a non-whitespace character is encountered or the end is reached. + */ + private fun PeekableIterator.consumeWhitespace() { + while (peek()?.isWhitespace() == true) next() + } + + /** + * The set of characters that are valid within a unit token. + * + * This includes all ASCII letters (both cases) and the digits '2' and '3' + * which are needed to tokenize units like m2 (square meter) and m3 (cubic meter). + */ + private val VALID_UNIT_CHARACTERS: Set = + ('a'..'z').toSet() + ('A'..'Z').toSet() + setOf('2', '3') + } +} 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 7a59fa5612c..b68443ecc9c 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 @@ -362,3 +362,41 @@ oppia_android_test( "//utility/src/main/java/org/oppia/android/util/math:fraction_parser", ], ) + +oppia_android_test( + name = "NumberWithUnitsParserTest", + srcs = ["NumberWithUnitsParserTest.kt"], + custom_package = "org.oppia.android.util.math", + test_class = "org.oppia.android.util.math.NumberWithUnitsParserTest", + test_manifest = "//utility:test_manifest", + deps = [ + "//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:number_with_units_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:number_with_units_parser", + ], +) + +oppia_android_test( + name = "NumberWithUnitsTokenizerTest", + srcs = ["NumberWithUnitsTokenizerTest.kt"], + custom_package = "org.oppia.android.util.math", + test_class = "org.oppia.android.util.math.NumberWithUnitsTokenizerTest", + test_manifest = "//utility:test_manifest", + deps = [ + "//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:number_with_units_token_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:number_with_units_tokenizer", + ], +) diff --git a/utility/src/test/java/org/oppia/android/util/math/NumberWithUnitsParserTest.kt b/utility/src/test/java/org/oppia/android/util/math/NumberWithUnitsParserTest.kt new file mode 100644 index 00000000000..a4ccc02d5d9 --- /dev/null +++ b/utility/src/test/java/org/oppia/android/util/math/NumberWithUnitsParserTest.kt @@ -0,0 +1,3742 @@ +package org.oppia.android.util.math + +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.extensions.proto.LiteProtoTruth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.oppia.android.app.model.NumberUnitExpression.SiPrefix +import org.oppia.android.app.model.NumberUnitExpression.Unit +import org.oppia.android.app.model.NumberWithUnitsExpression +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.SelectRunnerPlatform +import org.oppia.android.testing.junit.ParameterizedJunitTestRunner +import org.oppia.android.testing.math.NumberWithUnitsSubject.Companion.assertThat +import org.oppia.android.util.math.NumberWithUnitsParser.Companion.NumberWithUnitsParsingResult +import org.oppia.android.util.math.NumberWithUnitsParsingError.DuplicateCurrencyError +import org.oppia.android.util.math.NumberWithUnitsParsingError.EmptyExpressionError +import org.oppia.android.util.math.NumberWithUnitsParsingError.InvalidTokenError +import org.oppia.android.util.math.NumberWithUnitsParsingError.InvalidUnitError +import org.oppia.android.util.math.NumberWithUnitsParsingError.MissingDenominatorError +import org.oppia.android.util.math.NumberWithUnitsParsingError.MissingExponentError +import org.oppia.android.util.math.NumberWithUnitsParsingError.NumberExpectedAfterCurrencyPrefixError +import org.oppia.android.util.math.NumberWithUnitsParsingError.NumberExpectedError +import org.oppia.android.util.math.NumberWithUnitsParsingError.TrailingTokensError +import org.oppia.android.util.math.NumberWithUnitsParsingError.UnbalancedParenthesesError +import org.oppia.android.util.math.NumberWithUnitsParsingError.UnitExpectedAfterDivisionError +import org.oppia.android.util.math.NumberWithUnitsParsingError.UnitExpectedAfterSiPrefixError +import org.oppia.android.util.math.NumberWithUnitsParsingError.UnitExpectedError +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode + +/** Tests for [NumberWithUnitsParser]. */ +// FunctionName: test names are conventionally named with underscores. +@Suppress("FunctionName") +@RunWith(OppiaParameterizedTestRunner::class) +@SelectRunnerPlatform(ParameterizedJunitTestRunner::class) +@LooperMode(LooperMode.Mode.PAUSED) +@Config +class NumberWithUnitsParserTest { + @Parameter lateinit var input: String + @Parameter lateinit var expectedUnit: String + @Parameter lateinit var expectedSiPrefix: String + + @Test + fun testParser_emptyString_returnsEmptyExpressionError() { + val error = parseNumberWithUnitsExpectingFailure("") + assertThat(error).isInstanceOf(EmptyExpressionError::class.java) + } + + @Test + fun testParser_whitespaceOnly_returnsEmptyExpressionError() { + val error = parseNumberWithUnitsExpectingFailure(" ") + assertThat(error).isInstanceOf(EmptyExpressionError::class.java) + } + + @Test + fun testParser_integerOnly_noUnit_returnsUnitExpectedError() { + val error = parseNumberWithUnitsExpectingFailure("42") + assertThat(error).isInstanceOf(UnitExpectedError::class.java) + } + + @Test + fun testParser_realNumberOnly_noUnit_returnsUnitExpectedError() { + val error = parseNumberWithUnitsExpectingFailure("3.14") + assertThat(error).isInstanceOf(UnitExpectedError::class.java) + } + + @Test + fun testParser_negativeIntegerOnly_noUnit_returnsUnitExpectedError() { + val error = parseNumberWithUnitsExpectingFailure("-5") + assertThat(error).isInstanceOf(UnitExpectedError::class.java) + } + + @Test + fun testParser_negativeRealOnly_noUnit_returnsUnitExpectedError() { + val error = parseNumberWithUnitsExpectingFailure("-2.5") + assertThat(error).isInstanceOf(UnitExpectedError::class.java) + } + + @Test + fun testParser_invalidUnit_returnsInvalidUnitError() { + val error = parseNumberWithUnitsExpectingFailure("42 invalid") + assertThat(error).isInstanceOf(InvalidUnitError::class.java) + val invalidUnitError = error as InvalidUnitError + assertThat(invalidUnitError.invalidUnit).isEqualTo("invalid") + } + + @Test + fun testParser_dollarPrefix_integer_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("$100") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(100.0) + hasUnitCountThat().isEqualTo(1) + hasPrefixThat().apply { + hasUnitThat().isEqualTo(Unit.DOLLAR) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_dollarPrefix_realNumber_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("$99.99") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(99.99) + hasUnitCountThat().isEqualTo(1) + hasPrefixThat().apply { + hasUnitThat().isEqualTo(Unit.DOLLAR) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_dollarPrefix_negativeNumber_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("$-50") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(-50.0) + hasUnitCountThat().isEqualTo(1) + hasPrefixThat().apply { + hasUnitThat().isEqualTo(Unit.DOLLAR) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_dollarPrefix_fraction_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("$1/2") + assertThat(result).apply { + hasFractionValueThat().apply { + hasNegativePropertyThat().isFalse() + hasWholeNumberThat().isEqualTo(0) + hasNumeratorThat().isEqualTo(1) + hasDenominatorThat().isEqualTo(2) + } + hasUnitCountThat().isEqualTo(1) + hasPrefixThat().apply { + hasUnitThat().isEqualTo(Unit.DOLLAR) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_dollarPrefix_withAdditionalSuffixUnit_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("$100 kg") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(100.0) + hasUnitCountThat().isEqualTo(2) + hasPrefixThat().apply { + hasUnitThat().isEqualTo(Unit.DOLLAR) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_dollarPrefix_noNumber_returnsError() { + val error = parseNumberWithUnitsExpectingFailure("$ kg") + assertThat(error).isInstanceOf(NumberExpectedAfterCurrencyPrefixError::class.java) + } + + @Test + fun testParser_dollarPrefix_bareMinus_returnsError() { + val error = parseNumberWithUnitsExpectingFailure("$-") + assertThat(error).isInstanceOf(NumberExpectedAfterCurrencyPrefixError::class.java) + } + + @Test + fun testParser_rupeePrefix_integer_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("₹500") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(500.0) + hasUnitCountThat().isEqualTo(1) + hasPrefixThat().apply { + hasUnitThat().isEqualTo(Unit.RUPEE) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_rupeePrefix_realNumber_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("₹10.5") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(10.5) + hasUnitCountThat().isEqualTo(1) + hasPrefixThat().apply { + hasUnitThat().isEqualTo(Unit.RUPEE) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_rupeePrefix_noNumber_returnsError() { + val error = parseNumberWithUnitsExpectingFailure("₹ kg") + assertThat(error).isInstanceOf(NumberExpectedAfterCurrencyPrefixError::class.java) + } + + @Test + @Iteration("dollarSuffix", "input=100 dollars", "expectedUnit=DOLLAR") + @Iteration("centSuffix", "input=50 cents", "expectedUnit=CENT") + @Iteration("rupeeSuffix", "input=100 rupees", "expectedUnit=RUPEE") + @Iteration("paiseSuffix", "input=50 paise", "expectedUnit=PAISA") + @Iteration("meterSpelledOut", "input=10 meter", "expectedUnit=METER") + @Iteration("metersPlural", "input=10 meters", "expectedUnit=METER") + @Iteration("gramsPlural", "input=500 grams", "expectedUnit=GRAM") + @Iteration("feetSpelledOut", "input=6 feet", "expectedUnit=FOOT") + @Iteration("footSpelledOut", "input=1 foot", "expectedUnit=FOOT") + @Iteration("inchSpelledOut", "input=12 inch", "expectedUnit=INCH") + @Iteration("inchesPlural", "input=12 inches", "expectedUnit=INCH") + @Iteration("yardSpelledOut", "input=10 yard", "expectedUnit=YARD") + @Iteration("yardsPlural", "input=10 yards", "expectedUnit=YARD") + @Iteration("jouleSpelledOut", "input=100 joule", "expectedUnit=JOULE") + @Iteration("joulesPlural", "input=100 joules", "expectedUnit=JOULE") + @Iteration("wattSpelledOut", "input=60 watt", "expectedUnit=WATT") + @Iteration("wattsPlural", "input=60 watts", "expectedUnit=WATT") + @Iteration("ampereSpelledOut", "input=5 ampere", "expectedUnit=AMPERE") + @Iteration("voltSpelledOut", "input=220 volt", "expectedUnit=VOLT") + @Iteration("voltsPlural", "input=220 volts", "expectedUnit=VOLT") + @Iteration("ohmSpelledOut", "input=100 ohms", "expectedUnit=OHM") + @Iteration("newtonSpelledOut", "input=10 newton", "expectedUnit=NEWTON") + @Iteration("hertzSpelledOut", "input=50 hertz", "expectedUnit=HERTZ") + @Iteration("kelvinSpelledOut", "input=300 kelvin", "expectedUnit=KELVIN") + @Iteration("celsiusSpelledOut", "input=25 celsius", "expectedUnit=CELSIUS") + @Iteration("radianSpelledOut", "input=3 radian", "expectedUnit=RADIAN") + @Iteration("radiansPlural", "input=3 radians", "expectedUnit=RADIAN") + @Iteration("degreeSpelledOut", "input=90 degree", "expectedUnit=DEGREE") + @Iteration("degreesPlural", "input=90 degrees", "expectedUnit=DEGREE") + @Iteration("secondSpelledOut", "input=60 second", "expectedUnit=SECOND") + @Iteration("secondsPlural", "input=60 seconds", "expectedUnit=SECOND") + @Iteration("minuteSpelledOut", "input=30 minute", "expectedUnit=MINUTE") + @Iteration("minutesPlural", "input=30 minutes", "expectedUnit=MINUTE") + @Iteration("hourSpelledOut", "input=2 hour", "expectedUnit=HOUR") + @Iteration("hoursPlural", "input=2 hours", "expectedUnit=HOUR") + @Iteration("moleSpelledOut", "input=2 mole", "expectedUnit=MOLE") + @Iteration("molesPlural", "input=2 moles", "expectedUnit=MOLE") + @Iteration("candelaSpelledOut", "input=10 candela", "expectedUnit=CANDELA") + @Iteration("ounceSpelledOut", "input=8 ounce", "expectedUnit=OUNCE") + @Iteration("ouncesPlural", "input=8 ounces", "expectedUnit=OUNCE") + @Iteration("grainSpelledOut", "input=100 grain", "expectedUnit=GRAIN") + @Iteration("grainsPlural", "input=100 grains", "expectedUnit=GRAIN") + @Iteration("literSpelledOut", "input=5 liter", "expectedUnit=LITER") + @Iteration("litersPlural", "input=5 liters", "expectedUnit=LITER") + @Iteration("litreSpelling", "input=5 litre", "expectedUnit=LITER") + @Iteration("litresPlural", "input=5 litres", "expectedUnit=LITER") + @Iteration("pascalSpelledOut", "input=100 Pa", "expectedUnit=PASCAL") + @Iteration("sqfeet", "input=20 sqfeet", "expectedUnit=SQUARE_FOOT") + @Iteration("sqinch", "input=30 sqinch", "expectedUnit=SQUARE_INCH") + @Iteration("sqyard", "input=40 sqyard", "expectedUnit=SQUARE_YARD") + fun testParser_spelledOutOrPluralUnit_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess(input) + assertThat(result).apply { + hasUnitCountThat().isEqualTo(1) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.valueOf(expectedUnit)) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_newtonMeterSuffix_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("50 Nm") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(50.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.NEWTON) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_compoundUnit_jouleSecond_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("10 Js") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(10.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.JOULE) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_compoundUnit_wattSecond_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("5 Ws") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(5.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.WATT) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_compoundUnit_newtonMilliampere_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("7 NmA") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(7.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.NEWTON) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.AMPERE) + hasSiPrefixThat().isEqualTo(SiPrefix.MILLI) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_compoundUnit_withSiPrefix_kilogramMeter_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("3 kgm") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(3.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_compoundUnit_numberTouchingUnit_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("12Nm") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(12.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.NEWTON) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_compoundUnit_withExponent_appliesExponentToLastUnit() { + val result = parseNumberWithUnitsExpectingSuccess("5 Nm^2") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(5.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.NEWTON) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(2) + } + } + } + + @Test + fun testParser_compoundUnit_inDenominator_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("10 m/Ns") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(10.0) + hasUnitCountThat().isEqualTo(3) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.NEWTON) + hasExponentThat().isEqualTo(-1) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_compoundUnit_withDivision_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("10 Nm/s") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(10.0) + hasUnitCountThat().isEqualTo(3) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.NEWTON) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_compoundUnit_spacedEquivalent_matchesCompound() { + // "N m" (space-separated) should produce the same result as "Nm" (compound). + val spacedResult = parseNumberWithUnitsExpectingSuccess("50 N m") + val compoundResult = parseNumberWithUnitsExpectingSuccess("50 Nm") + assertThat(spacedResult).isEqualTo(compoundResult) + } + + @Test + fun testParser_compoundUnit_completelyInvalid_returnsInvalidUnitError() { + val error = parseNumberWithUnitsExpectingFailure("5 xyz") + assertThat(error).isInstanceOf(InvalidUnitError::class.java) + val invalidUnitError = error as InvalidUnitError + assertThat(invalidUnitError.invalidUnit).isEqualTo("xyz") + } + + @Test + fun testParser_compoundUnit_partiallyInvalid_returnsError() { + // "Nxyz" — "N" is valid (Newton), but "xyz" cannot be resolved. + // Decomposition requires size > 1, so single valid prefix with invalid rest fails. + val error = parseNumberWithUnitsExpectingFailure("5 Nxyz") + assertThat(error).isInstanceOf(InvalidUnitError::class.java) + } + + @Test + fun testParser_compoundUnit_singleCharUnit_noDecomposition_parsesCorrectly() { + // Single-char tokens like "N" should still parse normally (no decomposition needed). + val result = parseNumberWithUnitsExpectingSuccess("10 N") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(10.0) + hasUnitCountThat().isEqualTo(1) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.NEWTON) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_compoundUnit_mixedSpacedAndCompound_parsesCorrectly() { + // "Nm kg" — compound "Nm" followed by space-separated "kg". + val result = parseNumberWithUnitsExpectingSuccess("3 Nm kg") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(3.0) + hasUnitCountThat().isEqualTo(3) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.NEWTON) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_compoundUnit_pascalAbbrevWithSecond_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("100 Pas") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(100.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.PASCAL) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_compoundUnit_withCurrencyPrefix_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("$100 Nm") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(100.0) + hasUnitCountThat().isEqualTo(3) + hasPrefixThat().apply { + hasUnitThat().isEqualTo(Unit.DOLLAR) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.NEWTON) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_compoundUnit_voltAmpere_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("220 VA") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(220.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.VOLT) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.AMPERE) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_compoundUnit_negativeNumber_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("-5 Nm") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(-5.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.NEWTON) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_compoundUnit_realNumber_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("3.14 Nm") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(3.14) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.NEWTON) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_compoundUnit_fractionValue_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("1/2 Nm") + assertThat(result).apply { + hasFractionValueThat().apply { + hasNegativePropertyThat().isFalse() + hasWholeNumberThat().isEqualTo(0) + hasNumeratorThat().isEqualTo(1) + hasDenominatorThat().isEqualTo(2) + } + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.NEWTON) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_compoundUnit_jouleKelvin_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("8 JK") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(8.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.JOULE) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.KELVIN) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_compoundUnit_wattMeter_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("12 Wm") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(12.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.WATT) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_compoundUnit_voltSecond_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("6 Vs") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(6.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.VOLT) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_compoundUnit_pascalMeter_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("20 Pam") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(20.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.PASCAL) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_compoundUnit_ampereSecond_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("15 As") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(15.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.AMPERE) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_compoundUnit_hertzSecond_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("44 Hzs") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(44.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.HERTZ) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_compoundUnit_kelvinMeter_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("300 Km") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(300.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.KELVIN) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_compoundUnit_threeUnits_jouleKelvinSecond_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("2 JKs") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(2.0) + hasUnitCountThat().isEqualTo(3) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.JOULE) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.KELVIN) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_compoundUnit_wattAmpere_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("9 WA") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(9.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.WATT) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.AMPERE) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_compoundUnit_voltAmpereNoSpace_parsesCorrectly() { + // Number touching compound unit without any space. + val result = parseNumberWithUnitsExpectingSuccess("110VA") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(110.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.VOLT) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.AMPERE) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_compoundUnit_wattKilogram_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("50 Wkg") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(50.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.WATT) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_compoundUnit_jouleMillisecond_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("4 Jms") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(4.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.JOULE) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasSiPrefixThat().isEqualTo(SiPrefix.MILLI) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_compoundUnit_pascalSecond_withDivision_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("10 Pas/m") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(10.0) + hasUnitCountThat().isEqualTo(3) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.PASCAL) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_compoundUnit_voltMeter_inDenominator_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("5 A/Vm") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(5.0) + hasUnitCountThat().isEqualTo(3) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.AMPERE) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.VOLT) + hasExponentThat().isEqualTo(-1) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_compoundUnit_wattSecond_spacedEquivalent_matchesCompound() { + val spacedResult = parseNumberWithUnitsExpectingSuccess("5 W s") + val compoundResult = parseNumberWithUnitsExpectingSuccess("5 Ws") + assertThat(spacedResult).isEqualTo(compoundResult) + } + + @Test + fun testParser_compoundUnit_voltAmpere_spacedEquivalent_matchesCompound() { + val spacedResult = parseNumberWithUnitsExpectingSuccess("220 V A") + val compoundResult = parseNumberWithUnitsExpectingSuccess("220 VA") + assertThat(spacedResult).isEqualTo(compoundResult) + } + + @Test + fun testParser_compoundUnit_jouleSecond_withExponent_appliesExponentToLastUnit() { + val result = parseNumberWithUnitsExpectingSuccess("5 Js^2") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(5.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.JOULE) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(2) + } + } + } + + @Test + fun testParser_compoundUnit_withNegativeExponent_appliesExponentToLastUnit() { + val result = parseNumberWithUnitsExpectingSuccess("5 Nm^-2") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(5.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.NEWTON) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(-2) + } + } + } + + @Test + fun testParser_compoundUnit_withExponent_spacedEquivalent_matchesCompound() { + val spacedResult = parseNumberWithUnitsExpectingSuccess("5 N m^2") + val compoundResult = parseNumberWithUnitsExpectingSuccess("5 Nm^2") + assertThat(spacedResult).isEqualTo(compoundResult) + } + + @Test + fun testParser_compoundUnit_withExponentAndDivision_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("10 Nm^2/s") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(10.0) + hasUnitCountThat().isEqualTo(3) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.NEWTON) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(2) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_compoundUnit_withSiPrefix_andExponent_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("3 kgm^2") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(3.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(2) + } + } + } + + @Test + fun testParser_compoundUnit_wattPartiallyInvalid_returnsError() { + val error = parseNumberWithUnitsExpectingFailure("5 Wxyz") + assertThat(error).isInstanceOf(InvalidUnitError::class.java) + } + + @Test + @Iteration("integerWithMeter", "input=10 m", "expectedUnit=METER") + @Iteration("integerWithGram", "input=5 g", "expectedUnit=GRAM") + @Iteration("integerWithSecond", "input=60 s", "expectedUnit=SECOND") + @Iteration("integerWithKelvin", "input=300 K", "expectedUnit=KELVIN") + @Iteration("integerWithCelsius", "input=25 degC", "expectedUnit=CELSIUS") + @Iteration("integerWithNewton", "input=10 N", "expectedUnit=NEWTON") + @Iteration("integerWithJoule", "input=100 J", "expectedUnit=JOULE") + @Iteration("integerWithWatt", "input=60 W", "expectedUnit=WATT") + @Iteration("integerWithPascal", "input=101325 Pa", "expectedUnit=PASCAL") + @Iteration("integerWithAmpere", "input=5 A", "expectedUnit=AMPERE") + @Iteration("integerWithVolt", "input=220 V", "expectedUnit=VOLT") + @Iteration("integerWithOhm", "input=100 ohm", "expectedUnit=OHM") + @Iteration("integerWithHertz", "input=50 Hz", "expectedUnit=HERTZ") + @Iteration("integerWithMole", "input=2 mol", "expectedUnit=MOLE") + @Iteration("integerWithCandela", "input=10 cd", "expectedUnit=CANDELA") + @Iteration("integerWithRadian", "input=3 rad", "expectedUnit=RADIAN") + @Iteration("integerWithDegree", "input=90 deg", "expectedUnit=DEGREE") + @Iteration("integerWithLiter", "input=5 L", "expectedUnit=LITER") + @Iteration("integerWithInch", "input=12 in", "expectedUnit=INCH") + @Iteration("integerWithFoot", "input=6 ft", "expectedUnit=FOOT") + @Iteration("integerWithYard", "input=10 yd", "expectedUnit=YARD") + @Iteration("integerWithGrain", "input=100 gr", "expectedUnit=GRAIN") + @Iteration("integerWithOunce", "input=8 oz", "expectedUnit=OUNCE") + @Iteration("integerWithSquareInch", "input=25 sqinch", "expectedUnit=SQUARE_INCH") + @Iteration("integerWithSquareFoot", "input=100 sqft", "expectedUnit=SQUARE_FOOT") + @Iteration("integerWithSquareYard", "input=50 sqyd", "expectedUnit=SQUARE_YARD") + @Iteration("integerWithCc", "input=250 cc", "expectedUnit=CUBIC_CENTIMETER") + @Iteration("integerWithCubicInch", "input=10 cuin", "expectedUnit=CUBIC_INCH") + @Iteration("integerWithCubicFoot", "input=5 cuft", "expectedUnit=CUBIC_FOOT") + @Iteration("integerWithCubicYard", "input=3 cuyd", "expectedUnit=CUBIC_YARD") + @Iteration("integerWithSquareMeter", "input=20 m2", "expectedUnit=SQUARE_METER") + @Iteration("integerWithCubicMeter", "input=8 m3", "expectedUnit=CUBIC_METER") + @Iteration("integerWithMinute", "input=30 min", "expectedUnit=MINUTE") + @Iteration("integerWithHour", "input=2 hr", "expectedUnit=HOUR") + fun testParser_abbreviatedUnit_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess(input) + assertThat(result).apply { + hasUnitCountThat().isEqualTo(1) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.valueOf(expectedUnit)) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_realNumberWithMeter_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("3.14 m") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(3.14) + hasUnitCountThat().isEqualTo(1) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_realNumberWithKelvin_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("273.15 K") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(273.15) + hasUnitCountThat().isEqualTo(1) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.KELVIN) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_negativeIntegerWithUnit_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("-10 m") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(-10.0) + hasUnitCountThat().isEqualTo(1) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_negativeRealWithUnit_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("-3.5 kg") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(-3.5) + hasUnitCountThat().isEqualTo(1) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_fractionWithUnit_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("1/2 m") + assertThat(result).apply { + hasFractionValueThat().apply { + hasNegativePropertyThat().isFalse() + hasWholeNumberThat().isEqualTo(0) + hasNumeratorThat().isEqualTo(1) + hasDenominatorThat().isEqualTo(2) + } + hasUnitCountThat().isEqualTo(1) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_negativeFractionWithUnit_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("-3/4 kg") + assertThat(result).apply { + hasFractionValueThat().apply { + hasNegativePropertyThat().isTrue() + hasWholeNumberThat().isEqualTo(0) + hasNumeratorThat().isEqualTo(3) + hasDenominatorThat().isEqualTo(4) + } + hasUnitCountThat().isEqualTo(1) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_fractionMissingDenominator_returnsError() { + val error = parseNumberWithUnitsExpectingFailure("1/ m") + assertThat(error).isInstanceOf(MissingDenominatorError::class.java) + } + + @Test + @Iteration("kiloGram", "input=5 kg", "expectedUnit=GRAM", "expectedSiPrefix=KILO") + @Iteration("milliMeter", "input=100 mm", "expectedUnit=METER", "expectedSiPrefix=MILLI") + @Iteration("centiMeter", "input=50 cm", "expectedUnit=METER", "expectedSiPrefix=CENTI") + @Iteration("milliGram", "input=500 mg", "expectedUnit=GRAM", "expectedSiPrefix=MILLI") + @Iteration("megaWatt", "input=5 MW", "expectedUnit=WATT", "expectedSiPrefix=MEGA") + @Iteration("gigaHertz", "input=2 GHz", "expectedUnit=HERTZ", "expectedSiPrefix=GIGA") + @Iteration("nanoSecond", "input=100 ns", "expectedUnit=SECOND", "expectedSiPrefix=NANO") + @Iteration("microMeter", "input=10 um", "expectedUnit=METER", "expectedSiPrefix=MICRO") + @Iteration("picoSecond", "input=50 ps", "expectedUnit=SECOND", "expectedSiPrefix=PICO") + @Iteration("femtoMeter", "input=1 fm", "expectedUnit=METER", "expectedSiPrefix=FEMTO") + @Iteration("hectoPascal", "input=1013 hPa", "expectedUnit=PASCAL", "expectedSiPrefix=HECTO") + @Iteration("decaMeter", "input=10 dam", "expectedUnit=METER", "expectedSiPrefix=DECA") + @Iteration("deciLiter", "input=5 dL", "expectedUnit=LITER", "expectedSiPrefix=DECI") + @Iteration("teraWatt", "input=3 TW", "expectedUnit=WATT", "expectedSiPrefix=TERA") + @Iteration("petaJoule", "input=1 PJ", "expectedUnit=JOULE", "expectedSiPrefix=PETA") + @Iteration("exaJoule", "input=2 EJ", "expectedUnit=JOULE", "expectedSiPrefix=EXA") + @Iteration("zettaJoule", "input=1 ZJ", "expectedUnit=JOULE", "expectedSiPrefix=ZETTA") + @Iteration("yottaJoule", "input=1 YJ", "expectedUnit=JOULE", "expectedSiPrefix=YOTTA") + @Iteration("attoSecond", "input=500 as", "expectedUnit=SECOND", "expectedSiPrefix=ATTO") + @Iteration("zeptoSecond", "input=1 zs", "expectedUnit=SECOND", "expectedSiPrefix=ZEPTO") + @Iteration("yoctoSecond", "input=1 ys", "expectedUnit=SECOND", "expectedSiPrefix=YOCTO") + @Iteration("kiloLiter", "input=2 kL", "expectedUnit=LITER", "expectedSiPrefix=KILO") + @Iteration("milliMole", "input=10 mmol", "expectedUnit=MOLE", "expectedSiPrefix=MILLI") + fun testParser_siPrefixedUnit_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess(input) + assertThat(result).apply { + hasUnitCountThat().isEqualTo(1) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.valueOf(expectedUnit)) + hasSiPrefixThat().isEqualTo(SiPrefix.valueOf(expectedSiPrefix)) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_siPrefixAlone_noBaseUnit_returnsError() { + val error = parseNumberWithUnitsExpectingFailure("5 kilo") + assertThat(error).isInstanceOf(UnitExpectedAfterSiPrefixError::class.java) + } + + @Test + fun testParser_unitWithPositiveExponent_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("10 m^2") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(10.0) + hasUnitCountThat().isEqualTo(1) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(2) + } + } + } + + @Test + fun testParser_unitWithNegativeExponent_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("5 s^-1") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(5.0) + hasUnitCountThat().isEqualTo(1) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_unitWithExponent3_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("8 m^3") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(8.0) + hasUnitCountThat().isEqualTo(1) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(3) + } + } + } + + @Test + fun testParser_unitWithMissingExponent_returnsError() { + val error = parseNumberWithUnitsExpectingFailure("10 m^") + assertThat(error).isInstanceOf(MissingExponentError::class.java) + } + + @Test + fun testParser_unitWithExponentNegativeButNoValue_returnsError() { + val error = parseNumberWithUnitsExpectingFailure("10 m^-") + assertThat(error).isInstanceOf(MissingExponentError::class.java) + } + + @Test + fun testParser_unitWithMissingExponentFollowedByCompoundUnit_returnsError() { + val error = parseNumberWithUnitsExpectingFailure("10 kg^Nm") + assertThat(error).isInstanceOf(MissingExponentError::class.java) + } + + @Test + fun testParser_unitWithMissingExponentFollowedByUnit_returnsError() { + val error = parseNumberWithUnitsExpectingFailure("10 kg^N") + assertThat(error).isInstanceOf(MissingExponentError::class.java) + } + + @Test + fun testParser_twoUnitsMultiplied_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("10 kg m") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(10.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_threeUnitsMultiplied_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("1 kg m s") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(1.0) + hasUnitCountThat().isEqualTo(3) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_meterPerSecond_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("10 m/s") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(10.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_meterPerSecondSquared_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("9 m/s^2") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(9.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-2) + } + } + } + + @Test + fun testParser_kgPerCubicMeter_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("1000 kg/m^3") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(1000.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(-3) + } + } + } + + @Test + fun testParser_divisionWithNoUnitAfter_returnsError() { + val error = parseNumberWithUnitsExpectingFailure("10 m/") + assertThat(error).isInstanceOf(UnitExpectedAfterDivisionError::class.java) + } + + @Test + fun testParser_parenthesizedDenominator_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("5 kg m/(s^2)") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(5.0) + hasUnitCountThat().isEqualTo(3) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-2) + } + } + } + + @Test + fun testParser_parenthesizedDenominatorMultipleUnits_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("1 J/(kg K)") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(1.0) + hasUnitCountThat().isEqualTo(3) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.JOULE) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(-1) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.KELVIN) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_unbalancedParenthesis_missingClose_returnsError() { + val error = parseNumberWithUnitsExpectingFailure("5 m/(s") + assertThat(error).isInstanceOf(UnbalancedParenthesesError::class.java) + } + + @Test + fun testParser_emptyParenthesizedDenominator_returnsError() { + val error = parseNumberWithUnitsExpectingFailure("5 m/()") + assertThat(error).isInstanceOf(UnitExpectedAfterDivisionError::class.java) + } + + @Test + fun testParser_newtonMeterPerSecond_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("10 N m/s") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(10.0) + hasUnitCountThat().isEqualTo(3) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.NEWTON) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_kgMeterPerSecondSquared_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("1 kg m/s^2") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(1.0) + hasUnitCountThat().isEqualTo(3) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-2) + } + } + } + + @Test + fun testParser_wattPerSquareMeter_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("100 W/m^2") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(100.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.WATT) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(-2) + } + } + } + + @Test + fun testParser_extraWhitespace_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess(" 10 m ") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(10.0) + hasUnitCountThat().isEqualTo(1) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_dollarWithSpace_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("$ 100") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(100.0) + hasUnitCountThat().isEqualTo(1) + hasPrefixThat().apply { + hasUnitThat().isEqualTo(Unit.DOLLAR) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_rupeeWithSpace_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("₹ 200") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(200.0) + hasUnitCountThat().isEqualTo(1) + hasPrefixThat().apply { + hasUnitThat().isEqualTo(Unit.RUPEE) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_invalidTokenAtStart_returnsInvalidTokenError() { + val error = parseNumberWithUnitsExpectingFailure("@100 m") + assertThat(error).isInstanceOf(InvalidTokenError::class.java) + } + + @Test + fun testParser_unitAlone_noNumber_returnsNumberExpectedError() { + val error = parseNumberWithUnitsExpectingFailure("kg") + assertThat(error).isInstanceOf(NumberExpectedError::class.java) + } + + @Test + fun testParser_bareMinus_returnsNumberExpectedError() { + val error = parseNumberWithUnitsExpectingFailure("-") + assertThat(error).isInstanceOf(NumberExpectedError::class.java) + } + + @Test + fun testParser_parenthesisAtStart_returnsNumberExpectedError() { + val error = parseNumberWithUnitsExpectingFailure("(10 m)") + assertThat(error).isInstanceOf(NumberExpectedError::class.java) + } + + @Test + fun testParser_trailingNumber_returnsTrailingTokensError() { + val error = parseNumberWithUnitsExpectingFailure("10 m 5") + assertThat(error).isInstanceOf(TrailingTokensError::class.java) + } + + @Test + fun testParser_dollarPrefixWithTrailingInvalid_returnsTrailingTokensError() { + val error = parseNumberWithUnitsExpectingFailure("$100 @") + assertThat(error).isInstanceOf(TrailingTokensError::class.java) + } + + @Test + fun testParser_dollarPrefixWithCompoundSuffixUnits_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("$10 kg") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(10.0) + hasUnitCountThat().isEqualTo(2) + hasPrefixThat().apply { + hasUnitThat().isEqualTo(Unit.DOLLAR) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_dollarPrefixWithDivision_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("$10 kg/m") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(10.0) + hasUnitCountThat().isEqualTo(3) + hasPrefixThat().apply { + hasUnitThat().isEqualTo(Unit.DOLLAR) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_dollarPrefixWithDollarSuffix_returnsDuplicateCurrencyError() { + val error = parseNumberWithUnitsExpectingFailure("$100 dollars") + assertThat(error).isInstanceOf(DuplicateCurrencyError::class.java) + } + + @Test + fun testParser_rupeePrefixWithRupeeSuffix_returnsDuplicateCurrencyError() { + val error = parseNumberWithUnitsExpectingFailure("₹100 rupees") + assertThat(error).isInstanceOf(DuplicateCurrencyError::class.java) + } + + @Test + fun testParser_dollarPrefixWithCentSuffix_returnsDuplicateCurrencyError() { + val error = parseNumberWithUnitsExpectingFailure("$50 cents") + assertThat(error).isInstanceOf(DuplicateCurrencyError::class.java) + } + + @Test + fun testParser_rupeePrefixWithPaiseSuffix_returnsDuplicateCurrencyError() { + val error = parseNumberWithUnitsExpectingFailure("₹50 paise") + assertThat(error).isInstanceOf(DuplicateCurrencyError::class.java) + } + + @Test + fun testParser_dollarPrefixWithDollarSuffixAmongPhysicalUnits_returnsDuplicateCurrencyError() { + val error = parseNumberWithUnitsExpectingFailure("$10 kg dollars") + assertThat(error).isInstanceOf(DuplicateCurrencyError::class.java) + } + + @Test + fun testParser_fractionWithLargeNumbers_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("99/100 m") + assertThat(result).apply { + hasFractionValueThat().apply { + hasNegativePropertyThat().isFalse() + hasNumeratorThat().isEqualTo(99) + hasDenominatorThat().isEqualTo(100) + } + hasUnitCountThat().isEqualTo(1) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_fractionOnlyNoUnit_returnsUnitExpectedError() { + val error = parseNumberWithUnitsExpectingFailure("1/2") + assertThat(error).isInstanceOf(UnitExpectedError::class.java) + } + + @Test + fun testParser_dollarPrefix_negativeFraction_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("$-1/4") + assertThat(result).apply { + hasFractionValueThat().apply { + hasNegativePropertyThat().isTrue() + hasNumeratorThat().isEqualTo(1) + hasDenominatorThat().isEqualTo(4) + } + hasUnitCountThat().isEqualTo(1) + hasPrefixThat().apply { + hasUnitThat().isEqualTo(Unit.DOLLAR) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + @Iteration("centSymbol", "input=50 ¢", "expectedUnit=CENT") + @Iteration("dollarCapitalized", "input=100 Dollar", "expectedUnit=DOLLAR") + @Iteration("dollarsCapitalized", "input=100 Dollars", "expectedUnit=DOLLAR") + @Iteration("centCapitalized", "input=50 Cent", "expectedUnit=CENT") + @Iteration("centsCapitalized", "input=50 Cents", "expectedUnit=CENT") + @Iteration("rupeeCapitalized", "input=100 Rupee", "expectedUnit=RUPEE") + @Iteration("rupeesCapitalized", "input=100 Rupees", "expectedUnit=RUPEE") + @Iteration("paisaSpelledOut", "input=50 paisa", "expectedUnit=PAISA") + @Iteration("paisaCapitalized", "input=50 Paisa", "expectedUnit=PAISA") + @Iteration("paiseCapitalized", "input=50 Paise", "expectedUnit=PAISA") + @Iteration("usd", "input=100 USD", "expectedUnit=DOLLAR") + fun testParser_caseInsensitiveCurrencyAlias_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess(input) + assertThat(result).apply { + hasUnitCountThat().isEqualTo(1) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.valueOf(expectedUnit)) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_zeroWithUnit_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("0 m") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(0.0) + hasUnitCountThat().isEqualTo(1) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_zeroPointZeroWithUnit_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("0.0 m") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(0.0) + hasUnitCountThat().isEqualTo(1) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_negativeZeroWithUnit_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("-0 m") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(-0.0) + hasUnitCountThat().isEqualTo(1) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_largeNumberWithUnit_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("1000000 m") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(1000000.0) + hasUnitCountThat().isEqualTo(1) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_smallDecimalWithUnit_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("0.001 m") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-6).of(0.001) + hasUnitCountThat().isEqualTo(1) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_meterPerSecondWithExplicitExponent1_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("10 m^1/s^1") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(10.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_numeratorWithMultipleUnitsAndDivision_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("1 kg m^2/s^3") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(1.0) + hasUnitCountThat().isEqualTo(3) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(2) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-3) + } + } + } + + @Test + fun testParser_parenthesizedDenominatorWithExponents_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("1 kg m^2/(s^3 A)") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(1.0) + hasUnitCountThat().isEqualTo(4) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(2) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-3) + } + hasSuffixWithIndexThat(index = 3).apply { + hasUnitThat().isEqualTo(Unit.AMPERE) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_kiloNewtonMeter_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("5 kN m") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(5.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.NEWTON) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_kiloWattPerSquareMeter_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("100 kW/m^2") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(100.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.WATT) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(-2) + } + } + } + + @Test + fun testParser_megaJoulePerKilogram_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("10 MJ/kg") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(10.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.JOULE) + hasSiPrefixThat().isEqualTo(SiPrefix.MEGA) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_gigaWattHour_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("5 GW hr") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(5.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.WATT) + hasSiPrefixThat().isEqualTo(SiPrefix.GIGA) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.HOUR) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_siPrefixedUnitWithExponentInCompound_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("5 kN^2 m") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(5.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.NEWTON) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(2) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_siPrefixedUnitInDenominator_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("10 N/kN") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(10.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.NEWTON) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.NEWTON) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_siPrefixedUnitInParenthesizedDenominator_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("1 W/(cm^2 kN)") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(1.0) + hasUnitCountThat().isEqualTo(3) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.WATT) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasSiPrefixThat().isEqualTo(SiPrefix.CENTI) + hasExponentThat().isEqualTo(-2) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.NEWTON) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_feetPerSecond_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("88 ft/s") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(88.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.FOOT) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_feetPerSecondSquared_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("32 ft/s^2") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(32.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.FOOT) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-2) + } + } + } + + @Test + fun testParser_inchPerMinute_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("5 in/min") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(5.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.INCH) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.MINUTE) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_yardPerHour_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("100 yd/hr") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(100.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.YARD) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.HOUR) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_ouncePerCubicInch_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("5 oz/cuin") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(5.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.OUNCE) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.CUBIC_INCH) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_grainPerCc_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("10 gr/cc") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(10.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.GRAIN) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.CUBIC_CENTIMETER) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_ouncePerSquareFoot_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("3 oz/sqft") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(3.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.OUNCE) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.SQUARE_FOOT) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_squareMeterPerSecond_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("10 m2/s") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(10.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.SQUARE_METER) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_cubicMeterPerKilogram_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("1 m3/kg") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(1.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.CUBIC_METER) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_literPerSecond_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("20 L/s") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(20.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.LITER) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_ccPerMinute_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("100 cc/min") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(100.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.CUBIC_CENTIMETER) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.MINUTE) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_squareFootPerHour_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("50 sqft/hr") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(50.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.SQUARE_FOOT) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.HOUR) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_cubicFootPerSecond_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("15 cuft/s") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(15.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.CUBIC_FOOT) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_fourUnitsCompound_voltageInBaseUnits_parsesCorrectly() { + // 1 V = 1 kg⋅m²/s³⋅A⁻¹ + val result = parseNumberWithUnitsExpectingSuccess("1 kg m^2/(s^3 A)") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(1.0) + hasUnitCountThat().isEqualTo(4) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(2) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-3) + } + hasSuffixWithIndexThat(index = 3).apply { + hasUnitThat().isEqualTo(Unit.AMPERE) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_fourUnitsCompound_resistanceInBaseUnits_parsesCorrectly() { + // 1 Ω = 1 kg⋅m²/s³⋅A⁻² + val result = parseNumberWithUnitsExpectingSuccess("1 kg m^2/(s^3 A^2)") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(1.0) + hasUnitCountThat().isEqualTo(4) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(2) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-3) + } + hasSuffixWithIndexThat(index = 3).apply { + hasUnitThat().isEqualTo(Unit.AMPERE) + hasExponentThat().isEqualTo(-2) + } + } + } + + @Test + fun testParser_fiveUnitsCompound_specificHeatCapacity_parsesCorrectly() { + // Specific heat capacity: J/(kg·K) = m²⋅s⁻²⋅K⁻¹ → expressed as kg m^2/(s^2 mol K) + val result = parseNumberWithUnitsExpectingSuccess("8 kg m^2/(s^2 mol K)") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(8.0) + hasUnitCountThat().isEqualTo(5) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(2) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-2) + } + hasSuffixWithIndexThat(index = 3).apply { + hasUnitThat().isEqualTo(Unit.MOLE) + hasExponentThat().isEqualTo(-1) + } + hasSuffixWithIndexThat(index = 4).apply { + hasUnitThat().isEqualTo(Unit.KELVIN) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_fiveUnitsNumeratorOnly_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("1 kg m s A V") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(1.0) + hasUnitCountThat().isEqualTo(5) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 3).apply { + hasUnitThat().isEqualTo(Unit.AMPERE) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 4).apply { + hasUnitThat().isEqualTo(Unit.VOLT) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_sixUnitsCompound_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("1 kg m^2 cd/(s^3 A mol)") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(1.0) + hasUnitCountThat().isEqualTo(6) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(2) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.CANDELA) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 3).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-3) + } + hasSuffixWithIndexThat(index = 4).apply { + hasUnitThat().isEqualTo(Unit.AMPERE) + hasExponentThat().isEqualTo(-1) + } + hasSuffixWithIndexThat(index = 5).apply { + hasUnitThat().isEqualTo(Unit.MOLE) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_allUnitsWithExplicitExponents_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("1 kg^1 m^2 s^-3 A^-1") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(1.0) + hasUnitCountThat().isEqualTo(4) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(2) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-3) + } + hasSuffixWithIndexThat(index = 3).apply { + hasUnitThat().isEqualTo(Unit.AMPERE) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_multipleUnitsInParenthesizedDenominator_allWithExponents_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("1 kg/(m^2 s^3)") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(1.0) + hasUnitCountThat().isEqualTo(3) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(-2) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-3) + } + } + } + + @Test + fun testParser_threeUnitsInParenthesizedDenominator_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("1 N/(m^2 s K)") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(1.0) + hasUnitCountThat().isEqualTo(4) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.NEWTON) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(-2) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-1) + } + hasSuffixWithIndexThat(index = 3).apply { + hasUnitThat().isEqualTo(Unit.KELVIN) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_singleUnitInParenthesizedDenominator_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("10 m/(s)") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(10.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_realNumberWithFourUnitCompound_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("3.14 kg m^2/(s^2 K)") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(3.14) + hasUnitCountThat().isEqualTo(4) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(2) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-2) + } + hasSuffixWithIndexThat(index = 3).apply { + hasUnitThat().isEqualTo(Unit.KELVIN) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_negativeRealWithAcceleration_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("-9.81 m/s^2") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(-9.81) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-2) + } + } + } + + @Test + fun testParser_negativeRealWithThreeUnitCompound_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("-2.5 kg m/s^2") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(-2.5) + hasUnitCountThat().isEqualTo(3) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-2) + } + } + } + + @Test + fun testParser_smallDecimalWithCompoundUnit_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("0.001 kg m/s") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-6).of(0.001) + hasUnitCountThat().isEqualTo(3) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_largeRealWithCompoundUnit_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("999999.99 m^3/(kg s^2)") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-2).of(999999.99) + hasUnitCountThat().isEqualTo(3) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(3) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(-1) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-2) + } + } + } + + @Test + fun testParser_fractionWithThreeUnitCompound_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("1/2 kg m/s^2") + assertThat(result).apply { + hasFractionValueThat().apply { + hasNegativePropertyThat().isFalse() + hasNumeratorThat().isEqualTo(1) + hasDenominatorThat().isEqualTo(2) + } + hasUnitCountThat().isEqualTo(3) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-2) + } + } + } + + @Test + fun testParser_fractionWithParenthesizedDenominator_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("1/2 kg/(m^3)") + assertThat(result).apply { + hasFractionValueThat().apply { + hasNegativePropertyThat().isFalse() + hasWholeNumberThat().isEqualTo(0) + hasNumeratorThat().isEqualTo(1) + hasDenominatorThat().isEqualTo(2) + } + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(-3) + } + } + } + + @Test + fun testParser_negativeFractionWithFourUnitCompound_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("-1/4 N m/(s^2 kg)") + assertThat(result).apply { + hasFractionValueThat().apply { + hasNegativePropertyThat().isTrue() + hasNumeratorThat().isEqualTo(1) + hasDenominatorThat().isEqualTo(4) + } + hasUnitCountThat().isEqualTo(4) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.NEWTON) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-2) + } + hasSuffixWithIndexThat(index = 3).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_fractionWithFiveUnits_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("3/7 kg m^2/(s^2 mol K)") + assertThat(result).apply { + hasFractionValueThat().apply { + hasNegativePropertyThat().isFalse() + hasNumeratorThat().isEqualTo(3) + hasDenominatorThat().isEqualTo(7) + } + hasUnitCountThat().isEqualTo(5) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(2) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-2) + } + hasSuffixWithIndexThat(index = 3).apply { + hasUnitThat().isEqualTo(Unit.MOLE) + hasExponentThat().isEqualTo(-1) + } + hasSuffixWithIndexThat(index = 4).apply { + hasUnitThat().isEqualTo(Unit.KELVIN) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_dollarPrefixWithThreeUnitCompound_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("$10 kg m/s") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(10.0) + hasUnitCountThat().isEqualTo(4) + hasPrefixThat().apply { + hasUnitThat().isEqualTo(Unit.DOLLAR) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_rupeePrefixWithFourUnitCompound_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("₹100 kg m^2/(s^2 K)") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(100.0) + hasUnitCountThat().isEqualTo(5) + hasPrefixThat().apply { + hasUnitThat().isEqualTo(Unit.RUPEE) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(2) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-2) + } + hasSuffixWithIndexThat(index = 3).apply { + hasUnitThat().isEqualTo(Unit.KELVIN) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_dollarPrefixFractionWithThreeUnitCompound_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("$1/4 kg m/s^2") + assertThat(result).apply { + hasFractionValueThat().apply { + hasNumeratorThat().isEqualTo(1) + hasDenominatorThat().isEqualTo(4) + } + hasUnitCountThat().isEqualTo(4) + hasPrefixThat().apply { + hasUnitThat().isEqualTo(Unit.DOLLAR) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-2) + } + } + } + + @Test + fun testParser_unitWithExponent5_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("1 m^5/s") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(1.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(5) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_unitWithNegativeExponent4InDenominator_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("1 W/m^4") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(1.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.WATT) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(-4) + } + } + } + + @Test + fun testParser_highExponentInParenthesizedDenominator_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("1 kg/(m^4 s^5)") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(1.0) + hasUnitCountThat().isEqualTo(3) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(-4) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-5) + } + } + } + + @Test + fun testParser_exponentZeroInCompoundUnit_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("10 m^0 s") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(10.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(0) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_exponentZeroInDenominator_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("10 m/s^0") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(10.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(0) + } + } + } + + @Test + fun testParser_negativeExponentInParenthesizedDenominator_multipleUnits_parsesCorrectly() { + // s^-2 and A^-1 in denominator → negated → s^2 and A^1 + val result = parseNumberWithUnitsExpectingSuccess("1 kg m^2/(s^-2 A^-1)") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(1.0) + hasUnitCountThat().isEqualTo(4) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(2) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(2) + } + hasSuffixWithIndexThat(index = 3).apply { + hasUnitThat().isEqualTo(Unit.AMPERE) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_mixedPositiveAndNegativeExponentsInDenominator_parsesCorrectly() { + // In denominator: s^2 (→ -2), A^-1 (→ 1) + val result = parseNumberWithUnitsExpectingSuccess("1 kg/(s^2 A^-1)") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(1.0) + hasUnitCountThat().isEqualTo(3) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-2) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.AMPERE) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_compoundUnitOrdering_numeratorBeforeDenominator() { + val result = parseNumberWithUnitsExpectingSuccess("1 kg m^2/(s^3 A)") + assertThat(result).apply { + hasUnitCountThat().isEqualTo(4) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(2) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-3) + } + hasSuffixWithIndexThat(index = 3).apply { + hasUnitThat().isEqualTo(Unit.AMPERE) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_sixUnitOrdering_preservesInputOrder() { + val result = parseNumberWithUnitsExpectingSuccess("1 kg m^2 cd/(s^3 A mol)") + assertThat(result).apply { + hasUnitCountThat().isEqualTo(6) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.CANDELA) + } + hasSuffixWithIndexThat(index = 3).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + } + hasSuffixWithIndexThat(index = 4).apply { + hasUnitThat().isEqualTo(Unit.AMPERE) + } + hasSuffixWithIndexThat(index = 5).apply { + hasUnitThat().isEqualTo(Unit.MOLE) + } + } + } + + @Test + fun testParser_dollarPrefixCompoundOrdering_dollarFirstThenNumeratorThenDenominator() { + val result = parseNumberWithUnitsExpectingSuccess("$10 kg m/(s^2 K)") + assertThat(result).apply { + hasUnitCountThat().isEqualTo(5) + hasPrefixThat().apply { + hasUnitThat().isEqualTo(Unit.DOLLAR) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-2) + } + hasSuffixWithIndexThat(index = 3).apply { + hasUnitThat().isEqualTo(Unit.KELVIN) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_stefanBoltzmannConstant_parsesCorrectly() { + // σ = 5.67 W/(m^2 K^4) + val result = parseNumberWithUnitsExpectingSuccess("5 W/(m^2 K^4)") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(5.0) + hasUnitCountThat().isEqualTo(3) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.WATT) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(-2) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.KELVIN) + hasExponentThat().isEqualTo(-4) + } + } + } + + @Test + fun testParser_electricFieldStrength_parsesCorrectly() { + // E = V/m + val result = parseNumberWithUnitsExpectingSuccess("1000 V/m") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(1000.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.VOLT) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_magneticFieldStrength_parsesCorrectly() { + // Tesla = kg/(A⋅s²) + val result = parseNumberWithUnitsExpectingSuccess("1 kg/(A s^2)") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(1.0) + hasUnitCountThat().isEqualTo(3) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.AMPERE) + hasExponentThat().isEqualTo(-1) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-2) + } + } + } + + @Test + fun testParser_thermalConductivity_parsesCorrectly() { + // W/(m⋅K) + val result = parseNumberWithUnitsExpectingSuccess("200 W/(m K)") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(200.0) + hasUnitCountThat().isEqualTo(3) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.WATT) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(-1) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.KELVIN) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_viscosity_parsesCorrectly() { + // Pa⋅s = kg/(m⋅s) + val result = parseNumberWithUnitsExpectingSuccess("1 Pa s") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(1.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.PASCAL) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_kinematicViscosity_parsesCorrectly() { + // m²/s + val result = parseNumberWithUnitsExpectingSuccess("1 m^2/s") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(1.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(2) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_momentOfInertia_parsesCorrectly() { + // kg⋅m² + val result = parseNumberWithUnitsExpectingSuccess("10 kg m^2") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(10.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(2) + } + } + } + + @Test + fun testParser_angularVelocity_parsesCorrectly() { + // rad/s + val result = parseNumberWithUnitsExpectingSuccess("3 rad/s") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(3.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.RADIAN) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_angularAcceleration_parsesCorrectly() { + // rad/s² + val result = parseNumberWithUnitsExpectingSuccess("5 rad/s^2") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(5.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.RADIAN) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-2) + } + } + } + + @Test + fun testParser_torque_parsesCorrectly() { + // N⋅m + val result = parseNumberWithUnitsExpectingSuccess("50 N m") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(50.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.NEWTON) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_surfaceTension_parsesCorrectly() { + // N/m + val result = parseNumberWithUnitsExpectingSuccess("72 N/m") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(72.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.NEWTON) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_heatFlux_parsesCorrectly() { + // W/m² + val result = parseNumberWithUnitsExpectingSuccess("1360 W/m^2") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(1360.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.WATT) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(-2) + } + } + } + + @Test + fun testParser_specificEnergy_parsesCorrectly() { + // J/kg = m²/s² + val result = parseNumberWithUnitsExpectingSuccess("42000 J/kg") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(42000.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.JOULE) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_molarMass_parsesCorrectly() { + // kg/mol + val result = parseNumberWithUnitsExpectingSuccess("18 kg/mol") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(18.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.MOLE) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_luminousFlux_parsesCorrectly() { + // cd⋅rad² + val result = parseNumberWithUnitsExpectingSuccess("100 cd rad^2") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(100.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.CANDELA) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.RADIAN) + hasExponentThat().isEqualTo(2) + } + } + } + + @Test + fun testParser_divisionScopesMultipleUnitsWithExponents_parsesCorrectly() { + // 10 N m / s^2 kg → N and m in numerator, s^2 and kg in denominator + val result = parseNumberWithUnitsExpectingSuccess("10 N m/s^2 kg") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(10.0) + hasUnitCountThat().isEqualTo(4) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.NEWTON) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-2) + } + hasSuffixWithIndexThat(index = 3).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_threeNumeratorUnitsWithDivision_parsesCorrectly() { + // 1 kg m s / A → kg, m, s in numerator; A in denominator + val result = parseNumberWithUnitsExpectingSuccess("1 kg m s/A") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(1.0) + hasUnitCountThat().isEqualTo(4) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 3).apply { + hasUnitThat().isEqualTo(Unit.AMPERE) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_singleNumeratorDividedByThreeUnits_parsesCorrectly() { + // 1 J / kg m K → J in numerator; kg, m, K in denominator + val result = parseNumberWithUnitsExpectingSuccess("1 J/kg m K") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(1.0) + hasUnitCountThat().isEqualTo(4) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.JOULE) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(-1) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(-1) + } + hasSuffixWithIndexThat(index = 3).apply { + hasUnitThat().isEqualTo(Unit.KELVIN) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_spelledOutMeterPerSecond_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("10 meter/second") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(10.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_spelledOutKilogramMeterPerSecondSquared_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("1 kilogram meter/second^2") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(1.0) + hasUnitCountThat().isEqualTo(3) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-2) + } + } + } + + @Test + fun testParser_spelledOutNewtonMeter_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("50 newton meter") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(50.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.NEWTON) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_spelledOutWattPerSquareMeterKelvin_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("200 watt/(meter^2 kelvin)") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(200.0) + hasUnitCountThat().isEqualTo(3) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.WATT) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(-2) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.KELVIN) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_spelledOutJoulePerKilogramKelvin_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("4186 joule/(kilogram kelvin)") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(4186.0) + hasUnitCountThat().isEqualTo(3) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.JOULE) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(-1) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.KELVIN) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_spelledOutFeetPerSecond_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("88 feet/second") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(88.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.FOOT) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_spelledOutOuncePerCubicInch_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("5 ounce/cuin") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(5.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.OUNCE) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.CUBIC_INCH) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_spelledOutVoltPerMeter_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("100 volt/meter") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(100.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.VOLT) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_kilogramPerCubicMeterSpelledOut_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("1000 kilogram/meter^3") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(1000.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(-3) + } + } + } + + @Test + fun testParser_megawattPerSquareMeter_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("5 megawatt/meter^2") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(5.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.WATT) + hasSiPrefixThat().isEqualTo(SiPrefix.MEGA) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(-2) + } + } + } + + @Test + fun testParser_doubleDivision_returnsTrailingTokensError() { + // "10 m/s/kg" → after parsing m/s, the second '/' is a trailing token + val error = parseNumberWithUnitsExpectingFailure("10 m/s/kg") + assertThat(error).isInstanceOf(TrailingTokensError::class.java) + } + + @Test + fun testParser_divisionWithNoNumeratorUnit_returnsMissingDenominatorError() { + val error = parseNumberWithUnitsExpectingFailure("10 /s") + assertThat(error).isInstanceOf(MissingDenominatorError::class.java) + } + + @Test + fun testParser_emptyParenthesesInDenominator_compoundUnit_returnsError() { + val error = parseNumberWithUnitsExpectingFailure("10 kg m/()") + assertThat(error).isInstanceOf(UnitExpectedAfterDivisionError::class.java) + } + + @Test + fun testParser_unbalancedParenthesis_complexCompound_returnsError() { + val error = parseNumberWithUnitsExpectingFailure("10 kg m/(s^2 A") + assertThat(error).isInstanceOf(UnbalancedParenthesesError::class.java) + } + + @Test + fun testParser_missingExponentInCompound_returnsError() { + val error = parseNumberWithUnitsExpectingFailure("10 kg m^/s") + assertThat(error).isInstanceOf(MissingExponentError::class.java) + } + + @Test + fun testParser_missingExponentInDenominator_returnsError() { + val error = parseNumberWithUnitsExpectingFailure("10 m/s^") + assertThat(error).isInstanceOf(MissingExponentError::class.java) + } + + @Test + fun testParser_missingExponentNegativeSignInDenominator_returnsError() { + val error = parseNumberWithUnitsExpectingFailure("10 m/s^-") + assertThat(error).isInstanceOf(MissingExponentError::class.java) + } + + @Test + fun testParser_siPrefixWithoutBaseUnitInCompound_returnsError() { + // "10 m/kilo" → k recognized as SI prefix but "ilo" is not valid + val error = parseNumberWithUnitsExpectingFailure("5 m/kilo") + assertThat(error).isInstanceOf(UnitExpectedAfterSiPrefixError::class.java) + } + + @Test + fun testParser_divisionAfterParenthesizedDenominator_returnsTrailingTokensError() { + val error = parseNumberWithUnitsExpectingFailure("10 m/(s) /kg") + assertThat(error).isInstanceOf(TrailingTokensError::class.java) + } + + @Test + fun testParser_trailingNumberAfterCompound_returnsTrailingTokensError() { + val error = parseNumberWithUnitsExpectingFailure("10 m/s 5") + assertThat(error).isInstanceOf(TrailingTokensError::class.java) + } + + @Test + fun testParser_numberInDenominator_returnsUnitExpectedAfterDivisionError() { + // "10 m/3" → '3' is not a unit + val error = parseNumberWithUnitsExpectingFailure("10 m/3") + assertThat(error).isInstanceOf(UnitExpectedAfterDivisionError::class.java) + } + + @Test + fun testParser_compoundUnitWithExtraWhitespace_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess(" 10 kg m^2 / s^2 ") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(10.0) + hasUnitCountThat().isEqualTo(3) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(2) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-2) + } + } + } + + @Test + fun testParser_parenthesizedDenominatorWithExtraWhitespace_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("1 kg m^2 / ( s^3 A )") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(1.0) + hasUnitCountThat().isEqualTo(4) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(2) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-3) + } + hasSuffixWithIndexThat(index = 3).apply { + hasUnitThat().isEqualTo(Unit.AMPERE) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_tabsInCompoundUnit_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("5\tkg\tm/s^2") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(5.0) + hasUnitCountThat().isEqualTo(3) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-2) + } + } + } + + @Test + fun testParser_sameUnitInNumeratorAndDenominator_parsesCorrectly() { + // m in numerator and m^2 in denominator → results in m^1 and m^-2 separately + val result = parseNumberWithUnitsExpectingSuccess("1 m/m^2") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(1.0) + // Parser does not simplify, both m occurrences appear + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(-2) + } + } + } + + @Test + fun testParser_sameUnitRepeatedInNumerator_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("1 m m") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(1.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + } + } + + @Test + fun testParser_sameUnitRepeatedInParenthesizedDenominator_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("1 kg/(s s)") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(1.0) + hasUnitCountThat().isEqualTo(3) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-1) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_zeroWithCompoundUnit_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("0 kg m/s^2") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(0.0) + hasUnitCountThat().isEqualTo(3) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.GRAM) + hasSiPrefixThat().isEqualTo(SiPrefix.KILO) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-2) + } + } + } + + @Test + fun testParser_negativeZeroWithCompoundUnit_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("-0 m/s") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(-0.0) + hasUnitCountThat().isEqualTo(2) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.SECOND) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_zeroPointZeroWithCompoundUnit_parsesCorrectly() { + val result = parseNumberWithUnitsExpectingSuccess("0.0 W/(m^2 K)") + assertThat(result).apply { + hasRealValueThat().isWithin(1e-5).of(0.0) + hasUnitCountThat().isEqualTo(3) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.WATT) + hasExponentThat().isEqualTo(1) + } + hasSuffixWithIndexThat(index = 1).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(-2) + } + hasSuffixWithIndexThat(index = 2).apply { + hasUnitThat().isEqualTo(Unit.KELVIN) + hasExponentThat().isEqualTo(-1) + } + } + } + + @Test + fun testParser_fractionWithZeroDenominator_parsesAsFraction() { + // Parser doesn't validate zero denominator - that's a semantic concern. + val result = parseNumberWithUnitsExpectingSuccess("1/0 m") + assertThat(result).apply { + hasFractionValueThat().apply { + hasNumeratorThat().isEqualTo(1) + hasDenominatorThat().isEqualTo(0) + } + hasUnitCountThat().isEqualTo(1) + hasSuffixWithIndexThat(index = 0).apply { + hasUnitThat().isEqualTo(Unit.METER) + hasExponentThat().isEqualTo(1) + } + } + } + + private fun parseNumberWithUnitsExpectingSuccess( + expression: String + ): NumberWithUnitsExpression { + val parsingResult = parseNumberWithUnits(expression) + return expectSuccessfulParsingResult(parsingResult) + } + + private fun parseNumberWithUnitsExpectingFailure( + expression: String + ): NumberWithUnitsParsingError { + val parsingResult = parseNumberWithUnits(expression) + return expectFailingParsingResult(parsingResult) + } + + private fun parseNumberWithUnits( + expression: String, + ): NumberWithUnitsParsingResult { + return NumberWithUnitsParser.parseNumberWithUnits(expression) + } + + private fun expectSuccessfulParsingResult( + result: NumberWithUnitsParsingResult + ): NumberWithUnitsExpression { + assertThat(result).isInstanceOf( + NumberWithUnitsParsingResult.Success::class.java + ) + return (result as NumberWithUnitsParsingResult.Success).result + } + + private fun expectFailingParsingResult( + result: NumberWithUnitsParsingResult + ): NumberWithUnitsParsingError { + assertThat(result).isInstanceOf( + NumberWithUnitsParsingResult.Failure::class.java + ) + return (result as NumberWithUnitsParsingResult.Failure).error + } +} diff --git a/utility/src/test/java/org/oppia/android/util/math/NumberWithUnitsTokenizerTest.kt b/utility/src/test/java/org/oppia/android/util/math/NumberWithUnitsTokenizerTest.kt new file mode 100644 index 00000000000..39e5af1be82 --- /dev/null +++ b/utility/src/test/java/org/oppia/android/util/math/NumberWithUnitsTokenizerTest.kt @@ -0,0 +1,1922 @@ +package org.oppia.android.util.math + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +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.SelectRunnerPlatform +import org.oppia.android.testing.junit.ParameterizedJunitTestRunner +import org.oppia.android.testing.math.NumberWithUnitsTokenSubject.Companion.assertThat +import org.robolectric.annotation.Config + +/** Tests for [NumberWithUnitsTokenizer]. */ +// FunctionName: test names are conventionally named with underscores. +@Suppress("FunctionName") +@RunWith(OppiaParameterizedTestRunner::class) +@SelectRunnerPlatform(ParameterizedJunitTestRunner::class) +@Config +class NumberWithUnitsTokenizerTest { + @Parameter lateinit var input: String + + @Parameter lateinit var expected: String + + @Test + fun testTokenize_emptyString_producesNoTokens() { + val tokens = NumberWithUnitsTokenizer.tokenize("").toList() + assertThat(tokens).isEmpty() + } + + @Test + @Iteration(" ", "input= ") + @Iteration("\n", "input=\n") + @Iteration("\t", "input=\t") + @Iteration("\n\t", "input=\n\t") + fun testTokenize_onlyWhitespace_producesNoTokens() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).isEmpty() + } + + @Test + @Iteration(" 1", "input= 1", "expected=1") + @Iteration("42 ", "input=42 ", "expected=42") + fun testTokenize_positiveIntegerWithWhitespaces_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(1) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(expected.toInt()) + } + + @Test + @Iteration(" 3.14 ", "input= 3.14 ", "expected=3.14") + @Iteration(" 9.8 ", "input= 9.8 ", "expected=9.8") + fun testTokenize_realNumberWithWhitespaces_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(1) + assertThat(tokens[0]).isPositiveRealNumberWithValueThat().isWithin(1e-5).of(expected.toDouble()) + } + + @Test + fun testTokenize_negativeNumberWithWhitespaces_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(" - 2.5 ").toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isMinusSymbol() + assertThat(tokens[1]).isPositiveRealNumberWithValueThat().isWithin(1e-5).of(2.5) + } + + @Test + fun testTokenize_singleDigitInteger_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("7").toList() + + assertThat(tokens).hasSize(1) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(7) + } + + @Test + @Iteration("17", "input=17", "expected=17") + @Iteration("12345", "input=12345", "expected=12345") + fun testTokenize_multiDigitInteger_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(1) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(expected.toInt()) + } + + @Test + fun testTokenize_integerWithLeadingZeros_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("007").toList() + + assertThat(tokens).hasSize(1) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(7) + } + + @Test + fun testTokenize_veryLargeInteger_producesInvalidToken() { + val tokens = NumberWithUnitsTokenizer.tokenize("1111111111111111111111111").toList() + + assertThat(tokens).hasSize(1) + assertThat(tokens[0]).isInvalidToken() + } + + @Test + @Iteration("12.34", "input=12.34", "expected=12.34") + @Iteration("1.0", "input=1.0", "expected=1.0") + @Iteration("0.001", "input=0.001", "expected=0.001") + fun testTokenize_validDecimalNumber_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(1) + assertThat(tokens[0]).isPositiveRealNumberWithValueThat().isWithin(1e-5).of(expected.toDouble()) + } + + @Test + fun testTokenize_decimalWithoutLeadingDigit_isInvalid() { + val tokens = NumberWithUnitsTokenizer.tokenize(".5").toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isInvalidToken() + assertThat(tokens[1]).isPositiveIntegerWithValueThat().isEqualTo(5) + } + + @Test + fun testTokenize_decimalWithoutTrailingDigit_isInvalid() { + val tokens = NumberWithUnitsTokenizer.tokenize("12.").toList() + + assertThat(tokens).hasSize(1) + assertThat(tokens[0]).isInvalidToken() + } + + @Test + fun testTokenize_multipleDecimalPoints_isInvalid() { + val tokens = NumberWithUnitsTokenizer.tokenize("1.2.3").toList() + + assertThat(tokens).hasSize(3) + assertThat(tokens[0]).isPositiveRealNumberWithValueThat().isWithin(1e-5).of(1.2) + assertThat(tokens[1]).isInvalidToken() + assertThat(tokens[2]).isPositiveIntegerWithValueThat().isEqualTo(3) + } + + @Test + fun testTokenize_negativeInteger_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("-1").toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isMinusSymbol() + assertThat(tokens[1]).isPositiveIntegerWithValueThat().isEqualTo(1) + } + + @Test + fun testTokenize_negativeRealNumber_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("-42.84").toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isMinusSymbol() + assertThat(tokens[1]).isPositiveRealNumberWithValueThat().isWithin(1e-5).of(42.84) + } + + @Test + fun testTokenize_fractionsWithPositiveIntegers_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("1/2").toList() + + assertThat(tokens).hasSize(3) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(1) + assertThat(tokens[1]).isDivideSymbol() + assertThat(tokens[2]).isPositiveIntegerWithValueThat().isEqualTo(2) + } + + @Test + fun testTokenize_fractionsWithNegativeNumerator_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("-47/80").toList() + + assertThat(tokens).hasSize(4) + assertThat(tokens[0]).isMinusSymbol() + assertThat(tokens[1]).isPositiveIntegerWithValueThat().isEqualTo(47) + assertThat(tokens[2]).isDivideSymbol() + assertThat(tokens[3]).isPositiveIntegerWithValueThat().isEqualTo(80) + } + + @Test + fun testTokenize_fractionsWithNegativeNumeratorAndDenominator_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("-47/-80").toList() + + // Note the parser will be responsible for interpreting the double-negative as a positive, + // or throw an error but the tokenizer should still produce the correct tokens. + assertThat(tokens).hasSize(5) + assertThat(tokens[0]).isMinusSymbol() + assertThat(tokens[1]).isPositiveIntegerWithValueThat().isEqualTo(47) + assertThat(tokens[2]).isDivideSymbol() + assertThat(tokens[3]).isMinusSymbol() + assertThat(tokens[4]).isPositiveIntegerWithValueThat().isEqualTo(80) + } + + @Test + fun testTokenize_fractionsWithDecimal_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("1.5/3.7").toList() + + // Note the parser will be responsible for throwing an error since fractions with decimals + // aren't valid, but the tokenizer should still produce the correct tokens. + assertThat(tokens).hasSize(3) + assertThat(tokens[0]).isPositiveRealNumberWithValueThat().isWithin(1e-5).of(1.5) + assertThat(tokens[1]).isDivideSymbol() + assertThat(tokens[2]).isPositiveRealNumberWithValueThat().isWithin(1e-5).of(3.7) + } + + @Test + @Iteration("₹ 10", "input=₹ 10", "expected=₹") + @Iteration("Rs 10", "input=Rs 10", "expected=Rs") + fun testTokenize_rupeePrefixUnit_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isUnitWithRawValueThat().isEqualTo(expected) + assertThat(tokens[1]).isPositiveIntegerWithValueThat().isEqualTo(10) + } + + @Test + @Iteration("10 rupee", "input=10 rupee", "expected=rupee") + @Iteration("10 rupees", "input=10 rupees", "expected=rupees") + @Iteration("10 Rupee", "input=10 Rupee", "expected=Rupee") + @Iteration("10 Rupees", "input=10 Rupees", "expected=Rupees") + fun testTokenize_rupeeSuffixUnit_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("10 paise", "input=10 paise", "expected=paise") + @Iteration("10 paisa", "input=10 paisa", "expected=paisa") + @Iteration("10 Paise", "input=10 Paise", "expected=Paise") + @Iteration("10 Paisa", "input=10 Paisa", "expected=Paisa") + fun testTokenize_paisaSuffixUnit_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + fun testTokenize_dollarPrefixUnit_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("$12.5").toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isUnitWithRawValueThat().isEqualTo("$") + assertThat(tokens[1]).isPositiveRealNumberWithValueThat().isWithin(1e-5).of(12.5) + } + + @Test + @Iteration("10 USD", "input=10 USD", "expected=USD") + @Iteration("10 dollars", "input=10 dollars", "expected=dollars") + @Iteration("10 dollar", "input=10 dollar", "expected=dollar") + @Iteration("10 Dollars", "input=10 Dollars", "expected=Dollars") + @Iteration("10 Dollar", "input=10 Dollar", "expected=Dollar") + fun testTokenize_dollarSuffixUnit_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("10 ¢", "input=10 ¢", "expected=¢") + @Iteration("10 cents", "input=10 cents", "expected=cents") + @Iteration("10 cent", "input=10 cent", "expected=cent") + @Iteration("10 Cents", "input=10 Cents", "expected=Cents") + @Iteration("10 Cent", "input=10 Cent", "expected=Cent") + fun testTokenize_centSuffixUnit_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("20 m", "input=20 m", "expected=m") + @Iteration("20 meter", "input=20 meter", "expected=meter") + @Iteration("20 meters", "input=20 meters", "expected=meters") + fun testTokenize_correctMeterUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(20) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("20 Meter", "input=20 Meter", "expected=Meter") + @Iteration("20 Meters", "input=20 Meters", "expected=Meters") + fun testTokenize_incorrectMeterUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(20) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("20 in", "input=20 in", "expected=in") + @Iteration("20 inch", "input=20 inch", "expected=inch") + @Iteration("20 inches", "input=20 inches", "expected=inches") + fun testTokenize_correctInchUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(20) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("20 In", "input=20 In", "expected=In") + @Iteration("20 Inch", "input=20 Inch", "expected=Inch") + @Iteration("20 Inches", "input=20 Inches", "expected=Inches") + fun testTokenize_incorrectInchUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(20) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("20 ft", "input=20 ft", "expected=ft") + @Iteration("20 foot", "input=20 foot", "expected=foot") + @Iteration("20 feet", "input=20 feet", "expected=feet") + fun testTokenize_correctFootUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(20) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("20 Ft", "input=20 Ft", "expected=Ft") + @Iteration("20 Foot", "input=20 Foot", "expected=Foot") + @Iteration("20 Feet", "input=20 Feet", "expected=Feet") + fun testTokenize_incorrectFootUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(20) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("20 yd", "input=20 yd", "expected=yd") + @Iteration("20 yard", "input=20 yard", "expected=yard") + @Iteration("20 yards", "input=20 yards", "expected=yards") + fun testTokenize_correctYardUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(20) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("20 Yd", "input=20 Yd", "expected=Yd") + @Iteration("20 Yard", "input=20 Yard", "expected=Yard") + @Iteration("20 Yards", "input=20 Yards", "expected=Yards") + fun testTokenize_incorrectYardUnits_parsesYottaPrefixAndRemaining() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(20) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("20 g", "input=20 g", "expected=g") + @Iteration("20 gram", "input=20 gram", "expected=gram") + @Iteration("20 grams", "input=20 grams", "expected=grams") + fun testTokenize_correctGramUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(20) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("20 Gram", "input=20 Gram", "expected=Gram") + @Iteration("20 Grams", "input=20 Grams", "expected=Grams") + fun testTokenize_incorrectGramUnits_parsesGigaPrefixAndRemaining() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(20) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("20 gr", "input=20 gr", "expected=gr") + @Iteration("20 grain", "input=20 grain", "expected=grain") + @Iteration("20 grains", "input=20 grains", "expected=grains") + fun testTokenize_correctGrainUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(20) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("20 Gr", "input=20 Gr", "expected=Gr") + @Iteration("20 Grain", "input=20 Grain", "expected=Grain") + @Iteration("20 Grains", "input=20 Grains", "expected=Grains") + fun testTokenize_incorrectGrainUnits_parsesGigaPrefixAndRemaining() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(20) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("20 oz", "input=20 oz", "expected=oz") + @Iteration("20 ounce", "input=20 ounce", "expected=ounce") + @Iteration("20 ounces", "input=20 ounces", "expected=ounces") + fun testTokenize_correctOunceUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(20) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("20 Oz", "input=20 Oz", "expected=Oz") + @Iteration("20 Ounce", "input=20 Ounce", "expected=Ounce") + @Iteration("20 Ounces", "input=20 Ounces", "expected=Ounces") + fun testTokenize_incorrectOunceUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(20) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + fun testTokenize_correctSquareMeterUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("10 m2").toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("m2") + } + + @Test + fun testTokenize_incorrectSquareMeterUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("10 M2").toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("M2") + } + + @Test + @Iteration("10 sqft", "input=10 sqft", "expected=sqft") + @Iteration("10 sqfeet", "input=10 sqfeet", "expected=sqfeet") + fun testTokenize_correctSquareFootUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("10 Sqft", "input=10 Sqft", "expected=Sqft") + @Iteration("10 Sqfeet", "input=10 Sqfeet", "expected=Sqfeet") + fun testTokenize_incorrectSquareFootUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("10 sqyd", "input=10 sqyd", "expected=sqyd") + @Iteration("10 sqyard", "input=10 sqyard", "expected=sqyard") + fun testTokenize_correctSquareYardUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("10 Sqyd", "input=10 Sqyd", "expected=Sqyd") + @Iteration("10 Sqyard", "input=10 Sqyard", "expected=Sqyard") + fun testTokenize_incorrectSquareYardUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + fun testTokenize_correctCubicMeterUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("10 m3").toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("m3") + } + + @Test + fun testTokenize_incorrectCubicMeterUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("10 M3").toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("M3") + } + + @Test + @Iteration("10 litre", "input=10 litre", "expected=litre") + @Iteration("10 liter", "input=10 liter", "expected=liter") + @Iteration("10 litres", "input=10 litres", "expected=litres") + @Iteration("10 liters", "input=10 liters", "expected=liters") + @Iteration("10 L", "input=10 L", "expected=L") + @Iteration("10 l", "input=10 l", "expected=l") + @Iteration("10 lt", "input=10 lt", "expected=lt") + fun testTokenize_correctLiterUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("10 Litre", "input=10 Litre", "expected=Litre") + @Iteration("10 Litres", "input=10 Litres", "expected=Litres") + @Iteration("10 Liter", "input=10 Liter", "expected=Liter") + @Iteration("10 Liters", "input=10 Liters", "expected=Liters") + @Iteration("10 Lt", "input=10 Lt", "expected=Lt") + fun testTokenize_incorrectLiterUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + fun testTokenize_correctCubicCentimeterUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("10 cc").toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("cc") + } + + @Test + fun testTokenize_incorrectCubicCentimeterUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("10 CC").toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("CC") + } + + @Test + fun testTokenize_correctCubicInchUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("10 cuin").toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("cuin") + } + + @Test + fun testTokenize_incorrectCubicInchUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("10 Cuin").toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("Cuin") + } + + @Test + fun testTokenize_correctCubicFootUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("10 cuft").toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("cuft") + } + + @Test + fun testTokenize_incorrectCubicFootUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("10 Cuft").toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("Cuft") + } + + @Test + fun testTokenize_correctCubicYardUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("10 cuyd").toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("cuyd") + } + + @Test + fun testTokenize_incorrectCubicYardUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("10 Cuyd").toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("Cuyd") + } + + @Test + @Iteration("310.15 K", "input=310.15 K", "expected=K") + @Iteration("310.15 kelvin", "input=310.15 kelvin", "expected=kelvin") + fun testTokenize_correctKelvinUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveRealNumberWithValueThat().isWithin(1e-5).of(310.15) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + fun testTokenize_incorrectKelvinUnits_uppercaseKelvin_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("310.15 Kelvin").toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveRealNumberWithValueThat().isWithin(1e-5).of(310.15) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("Kelvin") + } + + @Test + @Iteration("37 degC", "input=37 degC", "expected=degC") + @Iteration("37 celsius", "input=37 celsius", "expected=celsius") + fun testTokenize_correctCelsiusUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(37) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("37 C", "input=37 C", "expected=C") + @Iteration("37 Celsius", "input=37 Celsius", "expected=Celsius") + fun testTokenize_incorrectCelsiusUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(37) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("10 rad", "input=10 rad", "expected=rad") + @Iteration("10 radian", "input=10 radian", "expected=radian") + @Iteration("10 radians", "input=10 radians", "expected=radians") + fun testTokenize_correctRadianUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("10 Rad", "input=10 Rad", "expected=Rad") + @Iteration("10 Radian", "input=10 Radian", "expected=Radian") + fun testTokenize_incorrectRadianUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("10 deg", "input=10 deg", "expected=deg") + @Iteration("10 degree", "input=10 degree", "expected=degree") + @Iteration("10 degrees", "input=10 degrees", "expected=degrees") + fun testTokenize_correctDegreeUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("10 Deg", "input=10 Deg", "expected=Deg") + @Iteration("10 Degree", "input=10 Degree", "expected=Degree") + @Iteration("10 Degrees", "input=10 Degrees", "expected=Degrees") + fun testTokenize_incorrectDegreeUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("10 s", "input=10 s", "expected=s") + @Iteration("10 second", "input=10 second", "expected=second") + @Iteration("10 seconds", "input=10 seconds", "expected=seconds") + @Iteration("10 sec", "input=10 sec", "expected=sec") + @Iteration("10 secs", "input=10 secs", "expected=secs") + fun testTokenize_correctSecondUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("10 S", "input=10 S", "expected=S") + @Iteration("10 Second", "input=10 Second", "expected=Second") + @Iteration("10 Seconds", "input=10 Seconds", "expected=Seconds") + fun testTokenize_incorrectSecondUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("10 min", "input=10 min", "expected=min") + @Iteration("10 mins", "input=10 mins", "expected=mins") + @Iteration("10 minute", "input=10 minute", "expected=minute") + @Iteration("10 minutes", "input=10 minutes", "expected=minutes") + fun testTokenize_correctMinuteUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("10 Min", "input=10 Min", "expected=Min") + @Iteration("10 Minute", "input=10 Minute", "expected=Minute") + @Iteration("10 Minutes", "input=10 Minutes", "expected=Minutes") + fun testTokenize_incorrectMinuteUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("10 h", "input=10 h", "expected=h") + @Iteration("10 hr", "input=10 hr", "expected=hr") + @Iteration("10 hrs", "input=10 hrs", "expected=hrs") + @Iteration("10 hour", "input=10 hour", "expected=hour") + @Iteration("10 hours", "input=10 hours", "expected=hours") + fun testTokenize_correctHourUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("10 H", "input=10 H", "expected=H") + @Iteration("10 Hr", "input=10 Hr", "expected=Hr") + @Iteration("10 Hour", "input=10 Hour", "expected=Hour") + @Iteration("10 Hours", "input=10 Hours", "expected=Hours") + fun testTokenize_incorrectHourUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("10 Hz", "input=10 Hz", "expected=Hz") + @Iteration("10 hertz", "input=10 hertz", "expected=hertz") + fun testTokenize_correctHertzUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + fun testTokenize_incorrectHertzUnits_hz_parsesHourUnitAndZeptoPrefix() { + val tokens = NumberWithUnitsTokenizer.tokenize("10 hz").toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("hz") + } + + @Test + fun testTokenize_incorrectHertzUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("10 Hertz").toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("Hertz") + } + + @Test + @Iteration("10 mol", "input=10 mol", "expected=mol") + @Iteration("10 mole", "input=10 mole", "expected=mole") + @Iteration("10 moles", "input=10 moles", "expected=moles") + fun testTokenize_correctMoleUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("10 Mol", "input=10 Mol", "expected=Mol") + @Iteration("10 Mole", "input=10 Mole", "expected=Mole") + @Iteration("10 Moles", "input=10 Moles", "expected=Moles") + fun testTokenize_incorrectMoleUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("10 cd", "input=10 cd", "expected=cd") + @Iteration("10 candela", "input=10 candela", "expected=candela") + fun testTokenize_correctCandelaUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("10 Cd", "input=10 Cd", "expected=Cd") + @Iteration("10 CD", "input=10 CD", "expected=CD") + @Iteration("10 Candela", "input=10 Candela", "expected=Candela") + fun testTokenize_incorrectCandelaUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("10 N", "input=10 N", "expected=N") + @Iteration("10 newton", "input=10 newton", "expected=newton") + fun testTokenize_correctNewtonUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + fun testTokenize_incorrectNewtonUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("10 Newton").toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("Newton") + } + + @Test + @Iteration("10 J", "input=10 J", "expected=J") + @Iteration("10 joule", "input=10 joule", "expected=joule") + @Iteration("10 joules", "input=10 joules", "expected=joules") + fun testTokenize_correctJouleUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("10 j", "input=10 j", "expected=j") + @Iteration("10 Joule", "input=10 Joule", "expected=Joule") + @Iteration("10 Joules", "input=10 Joules", "expected=Joules") + fun testTokenize_incorrectJouleUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("100 W", "input=100 W", "expected=W") + @Iteration("100 watt", "input=100 watt", "expected=watt") + @Iteration("100 watts", "input=100 watts", "expected=watts") + fun testTokenize_correctWattUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(100) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("100 Watt", "input=100 Watt", "expected=Watt") + @Iteration("100 Watts", "input=100 Watts", "expected=Watts") + fun testTokenize_incorrectWattUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(100) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("100 Pa", "input=100 Pa", "expected=Pa") + fun testTokenize_correctPascalUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(100) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("100 pa", "input=100 pa", "expected=pa") + @Iteration("100 pascal", "input=100 pascal", "expected=pascal") + fun testTokenize_incorrectPascalUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(100) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("10 A", "input=10 A", "expected=A") + @Iteration("10 ampere", "input=10 ampere", "expected=ampere") + @Iteration("10 amperes", "input=10 amperes", "expected=amperes") + fun testTokenize_correctAmpereUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("10 Ampere", "input=10 Ampere", "expected=Ampere") + @Iteration("10 Amperes", "input=10 Amperes", "expected=Amperes") + fun testTokenize_incorrectAmpereUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("10 V", "input=10 V", "expected=V") + @Iteration("10 volt", "input=10 volt", "expected=volt") + @Iteration("10 volts", "input=10 volts", "expected=volts") + fun testTokenize_correctVoltUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("10 v", "input=10 v", "expected=v") + @Iteration("10 Volt", "input=10 Volt", "expected=Volt") + @Iteration("10 Volts", "input=10 Volts", "expected=Volts") + fun testTokenize_incorrectVoltUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("10 ohm", "input=10 ohm", "expected=ohm") + @Iteration("10 ohms", "input=10 ohms", "expected=ohms") + fun testTokenize_correctOhmUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("10 Ohm", "input=10 Ohm", "expected=Ohm") + @Iteration("10 Ohms", "input=10 Ohms", "expected=Ohms") + fun testTokenize_incorrectOhmUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("10 deca", "input=10 deca", "expected=deca") + @Iteration("10 da", "input=10 da", "expected=da") + fun testTokenize_deca_parsesDecaSiPrefix() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("10 hecto", "input=10 hecto", "expected=hecto") + @Iteration("10 h", "input=10 h", "expected=h") + fun testTokenize_hecto_parsesHectoSiPrefix() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("10 kilo", "input=10 kilo", "expected=kilo") + @Iteration("10 k", "input=10 k", "expected=k") + fun testTokenize_kilo_parsesKiloSiPrefix() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("10 mega", "input=10 mega", "expected=mega") + @Iteration("10 M", "input=10 M", "expected=M") + fun testTokenize_mega_parsesMegaSiPrefix() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("10 giga", "input=10 giga", "expected=giga") + @Iteration("10 G", "input=10 G", "expected=G") + fun testTokenize_giga_parsesGigaSiPrefix() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("10 tera", "input=10 tera", "expected=tera") + @Iteration("10 T", "input=10 T", "expected=T") + fun testTokenize_tera_parsesTeraSiPrefix() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("10 peta", "input=10 peta", "expected=peta") + @Iteration("10 P", "input=10 P", "expected=P") + fun testTokenize_peta_parsesPetaSiPrefix() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("10 exa", "input=10 exa", "expected=exa") + @Iteration("10 E", "input=10 E", "expected=E") + fun testTokenize_exa_parsesExaSiPrefix() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("10 zetta", "input=10 zetta", "expected=zetta") + @Iteration("10 Z", "input=10 Z", "expected=Z") + fun testTokenize_zetta_parsesZettaSiPrefix() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("10 yotta", "input=10 yotta", "expected=yotta") + @Iteration("10 Y", "input=10 Y", "expected=Y") + fun testTokenize_yotta_parsesYottaSiPrefix() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("10 deci", "input=10 deci", "expected=deci") + @Iteration("10 d", "input=10 d", "expected=d") + fun testTokenize_deci_parsesDeciSiPrefix() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("10 centi", "input=10 centi", "expected=centi") + @Iteration("10 c", "input=10 c", "expected=c") + fun testTokenize_centi_parsesCentiSiPrefix() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("10 milli", "input=10 milli", "expected=milli") + @Iteration("10 m", "input=10 m", "expected=m") + fun testTokenize_milli_parsesMilliSiPrefix() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("10 micro", "input=10 micro", "expected=micro") + @Iteration("10 u", "input=10 u", "expected=u") + fun testTokenize_micro_parsesMicroSiPrefix() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("10 nano", "input=10 nano", "expected=nano") + @Iteration("10 n", "input=10 n", "expected=n") + fun testTokenize_nano_parsesNanoSiPrefix() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("10 pico", "input=10 pico", "expected=pico") + @Iteration("10 p", "input=10 p", "expected=p") + fun testTokenize_pico_parsesPicoSiPrefix() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("10 femto", "input=10 femto", "expected=femto") + @Iteration("10 f", "input=10 f", "expected=f") + fun testTokenize_femto_parsesFemtoSiPrefix() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("10 atto", "input=10 atto", "expected=atto") + @Iteration("10 a", "input=10 a", "expected=a") + fun testTokenize_atto_parsesAttoSiPrefix() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("10 zepto", "input=10 zepto", "expected=zepto") + @Iteration("10 z", "input=10 z", "expected=z") + fun testTokenize_zepto_parsesZeptoSiPrefix() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + @Iteration("10 yocto", "input=10 yocto", "expected=yocto") + @Iteration("10 y", "input=10 y", "expected=y") + fun testTokenize_yocto_parsesYoctoSiPrefix() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo(expected) + } + + @Test + fun testTokenize_accelerationWithSpacedUnits_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(" 9.8 m s ^ -2 ").toList() + + assertThat(tokens).hasSize(6) + assertThat(tokens[0]).isPositiveRealNumberWithValueThat().isWithin(1e-5).of(9.8) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("m") + assertThat(tokens[2]).isUnitWithRawValueThat().isEqualTo("s") + assertThat(tokens[3]).isExponentiationSymbol() + assertThat(tokens[4]).isMinusSymbol() + assertThat(tokens[5]).isPositiveIntegerWithValueThat().isEqualTo(2) + } + + @Test + fun testTokenize_velocityMeterPerSecond_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("10 m/s").toList() + + assertThat(tokens).hasSize(4) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("m") + assertThat(tokens[2]).isDivideSymbol() + assertThat(tokens[3]).isUnitWithRawValueThat().isEqualTo("s") + } + + @Test + fun testTokenize_velocityKilometerPerHour_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("100 km/h").toList() + + assertThat(tokens).hasSize(4) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(100) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("km") + assertThat(tokens[2]).isDivideSymbol() + assertThat(tokens[3]).isUnitWithRawValueThat().isEqualTo("h") + } + + @Test + fun testTokenize_accelerationMeterPerSecondSquared_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("9.8 m/s^2").toList() + + assertThat(tokens).hasSize(6) + assertThat(tokens[0]).isPositiveRealNumberWithValueThat().isWithin(1e-5).of(9.8) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("m") + assertThat(tokens[2]).isDivideSymbol() + assertThat(tokens[3]).isUnitWithRawValueThat().isEqualTo("s") + assertThat(tokens[4]).isExponentiationSymbol() + assertThat(tokens[5]).isPositiveIntegerWithValueThat().isEqualTo(2) + } + + @Test + fun testTokenize_dollarsPerSquareMeter_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("20 dollars / m^2").toList() + + assertThat(tokens).hasSize(6) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(20) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("dollars") + assertThat(tokens[2]).isDivideSymbol() + assertThat(tokens[3]).isUnitWithRawValueThat().isEqualTo("m") + assertThat(tokens[4]).isExponentiationSymbol() + assertThat(tokens[5]).isPositiveIntegerWithValueThat().isEqualTo(2) + } + + @Test + fun testTokenize_dollarPrefixPerSquareMeter_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("$ 20 / m^2").toList() + + assertThat(tokens).hasSize(6) + assertThat(tokens[0]).isUnitWithRawValueThat().isEqualTo("$") + assertThat(tokens[1]).isPositiveIntegerWithValueThat().isEqualTo(20) + assertThat(tokens[2]).isDivideSymbol() + assertThat(tokens[3]).isUnitWithRawValueThat().isEqualTo("m") + assertThat(tokens[4]).isExponentiationSymbol() + assertThat(tokens[5]).isPositiveIntegerWithValueThat().isEqualTo(2) + } + + @Test + fun testTokenize_rupeesPerSquareMeterWithDivide_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("₹ 100 /m^2").toList() + + assertThat(tokens).hasSize(6) + assertThat(tokens[0]).isUnitWithRawValueThat().isEqualTo("₹") + assertThat(tokens[1]).isPositiveIntegerWithValueThat().isEqualTo(100) + assertThat(tokens[2]).isDivideSymbol() + assertThat(tokens[3]).isUnitWithRawValueThat().isEqualTo("m") + assertThat(tokens[4]).isExponentiationSymbol() + assertThat(tokens[5]).isPositiveIntegerWithValueThat().isEqualTo(2) + } + + @Test + fun testTokenize_rupeesWithMeterNegativeExponent_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("₹ 100 m^-2").toList() + + assertThat(tokens).hasSize(6) + assertThat(tokens[0]).isUnitWithRawValueThat().isEqualTo("₹") + assertThat(tokens[1]).isPositiveIntegerWithValueThat().isEqualTo(100) + assertThat(tokens[2]).isUnitWithRawValueThat().isEqualTo("m") + assertThat(tokens[3]).isExponentiationSymbol() + assertThat(tokens[4]).isMinusSymbol() + assertThat(tokens[5]).isPositiveIntegerWithValueThat().isEqualTo(2) + } + + @Test + fun testTokenize_kilogramPerMeterPerSecond_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("5 kg / m / s").toList() + + assertThat(tokens).hasSize(6) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(5) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("kg") + assertThat(tokens[2]).isDivideSymbol() + assertThat(tokens[3]).isUnitWithRawValueThat().isEqualTo("m") + assertThat(tokens[4]).isDivideSymbol() + assertThat(tokens[5]).isUnitWithRawValueThat().isEqualTo("s") + } + + @Test + fun testTokenize_newtonPerMeterPerSecond_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("10 N / m / s").toList() + + assertThat(tokens).hasSize(6) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("N") + assertThat(tokens[2]).isDivideSymbol() + assertThat(tokens[3]).isUnitWithRawValueThat().isEqualTo("m") + assertThat(tokens[4]).isDivideSymbol() + assertThat(tokens[5]).isUnitWithRawValueThat().isEqualTo("s") + } + + @Test + fun testTokenize_kilogramPerMeterTimesSecondWithParentheses_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("5 kg / (m * s)").toList() + + assertThat(tokens).hasSize(8) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(5) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("kg") + assertThat(tokens[2]).isDivideSymbol() + assertThat(tokens[3]).isLeftParenthesisSymbol() + assertThat(tokens[4]).isUnitWithRawValueThat().isEqualTo("m") + assertThat(tokens[5]).isMultiplySymbol() + assertThat(tokens[6]).isUnitWithRawValueThat().isEqualTo("s") + assertThat(tokens[7]).isRightParenthesisSymbol() + } + + @Test + fun testTokenize_jouleWithUnitProductInParentheses_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("50 J / (kg * K)").toList() + + assertThat(tokens).hasSize(8) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(50) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("J") + assertThat(tokens[2]).isDivideSymbol() + assertThat(tokens[3]).isLeftParenthesisSymbol() + assertThat(tokens[4]).isUnitWithRawValueThat().isEqualTo("kg") + assertThat(tokens[5]).isMultiplySymbol() + assertThat(tokens[6]).isUnitWithRawValueThat().isEqualTo("K") + assertThat(tokens[7]).isRightParenthesisSymbol() + } + + @Test + fun testTokenize_wattPerSquareMeterKelvin_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("5 W / (m^2 * K)").toList() + + assertThat(tokens).hasSize(10) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(5) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("W") + assertThat(tokens[2]).isDivideSymbol() + assertThat(tokens[3]).isLeftParenthesisSymbol() + assertThat(tokens[4]).isUnitWithRawValueThat().isEqualTo("m") + assertThat(tokens[5]).isExponentiationSymbol() + assertThat(tokens[6]).isPositiveIntegerWithValueThat().isEqualTo(2) + assertThat(tokens[7]).isMultiplySymbol() + assertThat(tokens[8]).isUnitWithRawValueThat().isEqualTo("K") + assertThat(tokens[9]).isRightParenthesisSymbol() + } + + @Test + fun testTokenize_realNumberWithBracketedUnitsAndExponent_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("12.50 (kg * m)^-3").toList() + + assertThat(tokens).hasSize(9) + assertThat(tokens[0]).isPositiveRealNumberWithValueThat().isWithin(1e-5).of(12.50) + assertThat(tokens[1]).isLeftParenthesisSymbol() + assertThat(tokens[2]).isUnitWithRawValueThat().isEqualTo("kg") + assertThat(tokens[3]).isMultiplySymbol() + assertThat(tokens[4]).isUnitWithRawValueThat().isEqualTo("m") + assertThat(tokens[5]).isRightParenthesisSymbol() + assertThat(tokens[6]).isExponentiationSymbol() + assertThat(tokens[7]).isMinusSymbol() + assertThat(tokens[8]).isPositiveIntegerWithValueThat().isEqualTo(3) + } + + @Test + fun testTokenize_bracketedUnitsSquared_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("(m * s)^2").toList() + + assertThat(tokens).hasSize(7) + assertThat(tokens[0]).isLeftParenthesisSymbol() + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("m") + assertThat(tokens[2]).isMultiplySymbol() + assertThat(tokens[3]).isUnitWithRawValueThat().isEqualTo("s") + assertThat(tokens[4]).isRightParenthesisSymbol() + assertThat(tokens[5]).isExponentiationSymbol() + assertThat(tokens[6]).isPositiveIntegerWithValueThat().isEqualTo(2) + } + + @Test + fun testTokenize_nestedParentheses_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("((m))").toList() + + assertThat(tokens).hasSize(5) + assertThat(tokens[0]).isLeftParenthesisSymbol() + assertThat(tokens[1]).isLeftParenthesisSymbol() + assertThat(tokens[2]).isUnitWithRawValueThat().isEqualTo("m") + assertThat(tokens[3]).isRightParenthesisSymbol() + assertThat(tokens[4]).isRightParenthesisSymbol() + } + + @Test + fun testTokenize_complexNestedExpression_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("5 kg / ((m * s) * K)").toList() + + assertThat(tokens).hasSize(12) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(5) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("kg") + assertThat(tokens[2]).isDivideSymbol() + assertThat(tokens[3]).isLeftParenthesisSymbol() + assertThat(tokens[4]).isLeftParenthesisSymbol() + assertThat(tokens[5]).isUnitWithRawValueThat().isEqualTo("m") + assertThat(tokens[6]).isMultiplySymbol() + assertThat(tokens[7]).isUnitWithRawValueThat().isEqualTo("s") + assertThat(tokens[8]).isRightParenthesisSymbol() + assertThat(tokens[9]).isMultiplySymbol() + assertThat(tokens[10]).isUnitWithRawValueThat().isEqualTo("K") + assertThat(tokens[11]).isRightParenthesisSymbol() + } + + @Test + fun testTokenize_densityKilogramPerCubicMeter_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("1000 kg/m^3").toList() + + assertThat(tokens).hasSize(6) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(1000) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("kg") + assertThat(tokens[2]).isDivideSymbol() + assertThat(tokens[3]).isUnitWithRawValueThat().isEqualTo("m") + assertThat(tokens[4]).isExponentiationSymbol() + assertThat(tokens[5]).isPositiveIntegerWithValueThat().isEqualTo(3) + } + + @Test + fun testTokenize_densityGramPerCubicCentimeter_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("2.7 g/cc").toList() + + assertThat(tokens).hasSize(4) + assertThat(tokens[0]).isPositiveRealNumberWithValueThat().isWithin(1e-5).of(2.7) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("g") + assertThat(tokens[2]).isDivideSymbol() + assertThat(tokens[3]).isUnitWithRawValueThat().isEqualTo("cc") + } + + @Test + fun testTokenize_pressurePascal_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("101325 Pa").toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(101325) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("Pa") + } + + @Test + fun testTokenize_pressureNewtonPerSquareMeter_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("100 N/m^2").toList() + + assertThat(tokens).hasSize(6) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(100) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("N") + assertThat(tokens[2]).isDivideSymbol() + assertThat(tokens[3]).isUnitWithRawValueThat().isEqualTo("m") + assertThat(tokens[4]).isExponentiationSymbol() + assertThat(tokens[5]).isPositiveIntegerWithValueThat().isEqualTo(2) + } + + @Test + fun testTokenize_electricalResistanceOhmMeter_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("1.7 ohm * m").toList() + + assertThat(tokens).hasSize(4) + assertThat(tokens[0]).isPositiveRealNumberWithValueThat().isWithin(1e-5).of(1.7) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("ohm") + assertThat(tokens[2]).isMultiplySymbol() + assertThat(tokens[3]).isUnitWithRawValueThat().isEqualTo("m") + } + + @Test + fun testTokenize_electricFieldVoltPerMeter_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("500 V/m").toList() + + assertThat(tokens).hasSize(4) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(500) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("V") + assertThat(tokens[2]).isDivideSymbol() + assertThat(tokens[3]).isUnitWithRawValueThat().isEqualTo("m") + } + + @Test + fun testTokenize_powerWattPerSquareMeter_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("1000 W/m^2").toList() + + assertThat(tokens).hasSize(6) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(1000) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("W") + assertThat(tokens[2]).isDivideSymbol() + assertThat(tokens[3]).isUnitWithRawValueThat().isEqualTo("m") + assertThat(tokens[4]).isExponentiationSymbol() + assertThat(tokens[5]).isPositiveIntegerWithValueThat().isEqualTo(2) + } + + @Test + fun testTokenize_energyKilowattHour_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("500 kW * h").toList() + + assertThat(tokens).hasSize(4) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(500) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("kW") + assertThat(tokens[2]).isMultiplySymbol() + assertThat(tokens[3]).isUnitWithRawValueThat().isEqualTo("h") + } + + @Test + fun testTokenize_workNewtonMeter_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("100 N * m").toList() + + assertThat(tokens).hasSize(4) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(100) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("N") + assertThat(tokens[2]).isMultiplySymbol() + assertThat(tokens[3]).isUnitWithRawValueThat().isEqualTo("m") + } + + @Test + fun testTokenize_frequencyKilohertz_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("2.4 kHz").toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isPositiveRealNumberWithValueThat().isWithin(1e-5).of(2.4) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("kHz") + } + + @Test + fun testTokenize_angularVelocityRadPerSecond_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("3.14 rad/s").toList() + + assertThat(tokens).hasSize(4) + assertThat(tokens[0]).isPositiveRealNumberWithValueThat().isWithin(1e-5).of(3.14) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("rad") + assertThat(tokens[2]).isDivideSymbol() + assertThat(tokens[3]).isUnitWithRawValueThat().isEqualTo("s") + } + + @Test + fun testTokenize_angularAcceleration_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("2.5 rad/s^2").toList() + + assertThat(tokens).hasSize(6) + assertThat(tokens[0]).isPositiveRealNumberWithValueThat().isWithin(1e-5).of(2.5) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("rad") + assertThat(tokens[2]).isDivideSymbol() + assertThat(tokens[3]).isUnitWithRawValueThat().isEqualTo("s") + assertThat(tokens[4]).isExponentiationSymbol() + assertThat(tokens[5]).isPositiveIntegerWithValueThat().isEqualTo(2) + } + + @Test + fun testTokenize_molarConcentration_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("0.5 mol/L").toList() + + assertThat(tokens).hasSize(4) + assertThat(tokens[0]).isPositiveRealNumberWithValueThat().isWithin(1e-5).of(0.5) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("mol") + assertThat(tokens[2]).isDivideSymbol() + assertThat(tokens[3]).isUnitWithRawValueThat().isEqualTo("L") + } + + @Test + fun testTokenize_molarMass_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("18 g/mol").toList() + + assertThat(tokens).hasSize(4) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(18) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("g") + assertThat(tokens[2]).isDivideSymbol() + assertThat(tokens[3]).isUnitWithRawValueThat().isEqualTo("mol") + } + + @Test + fun testTokenize_luminousIntensityCandelaPerSquareMeter_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("1000 cd/m^2").toList() + + assertThat(tokens).hasSize(6) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(1000) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("cd") + assertThat(tokens[2]).isDivideSymbol() + assertThat(tokens[3]).isUnitWithRawValueThat().isEqualTo("m") + assertThat(tokens[4]).isExponentiationSymbol() + assertThat(tokens[5]).isPositiveIntegerWithValueThat().isEqualTo(2) + } + + @Test + fun testTokenize_milligramPerMilliliter_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("5 milligram/millilitre").toList() + + assertThat(tokens).hasSize(4) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(5) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("milligram") + assertThat(tokens[2]).isDivideSymbol() + assertThat(tokens[3]).isUnitWithRawValueThat().isEqualTo("millilitre") + } + + @Test + fun testTokenize_microgramPerLiter_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("100 ug/l").toList() + + assertThat(tokens).hasSize(4) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(100) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("ug") + assertThat(tokens[2]).isDivideSymbol() + assertThat(tokens[3]).isUnitWithRawValueThat().isEqualTo("l") + } + + @Test + fun testTokenize_nanometerPerSecond_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("50 nm/s").toList() + + assertThat(tokens).hasSize(4) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(50) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("nm") + assertThat(tokens[2]).isDivideSymbol() + assertThat(tokens[3]).isUnitWithRawValueThat().isEqualTo("s") + } + + @Test + fun testTokenize_ouncesPerSquareInch_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("14.7 oz/in^2").toList() + + assertThat(tokens).hasSize(6) + assertThat(tokens[0]).isPositiveRealNumberWithValueThat().isWithin(1e-5).of(14.7) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("oz") + assertThat(tokens[2]).isDivideSymbol() + assertThat(tokens[3]).isUnitWithRawValueThat().isEqualTo("in") + assertThat(tokens[4]).isExponentiationSymbol() + assertThat(tokens[5]).isPositiveIntegerWithValueThat().isEqualTo(2) + } + + @Test + fun testTokenize_yardPerHour_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("60 yd/h").toList() + + assertThat(tokens).hasSize(4) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(60) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("yd") + assertThat(tokens[2]).isDivideSymbol() + assertThat(tokens[3]).isUnitWithRawValueThat().isEqualTo("h") + } + + @Test + fun testTokenize_feetPerSecondSquared_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("32 ft/s^2").toList() + + assertThat(tokens).hasSize(6) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(32) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("ft") + assertThat(tokens[2]).isDivideSymbol() + assertThat(tokens[3]).isUnitWithRawValueThat().isEqualTo("s") + assertThat(tokens[4]).isExponentiationSymbol() + assertThat(tokens[5]).isPositiveIntegerWithValueThat().isEqualTo(2) + } + + @Test + fun testTokenize_compoundUnitWithExtraWhitespace_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(" 10 kg / m ^ 2 ").toList() + + assertThat(tokens).hasSize(6) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("kg") + assertThat(tokens[2]).isDivideSymbol() + assertThat(tokens[3]).isUnitWithRawValueThat().isEqualTo("m") + assertThat(tokens[4]).isExponentiationSymbol() + assertThat(tokens[5]).isPositiveIntegerWithValueThat().isEqualTo(2) + } + + @Test + fun testTokenize_compoundUnitWithNoWhitespace_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("10kg/m^2").toList() + + assertThat(tokens).hasSize(6) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("kg") + assertThat(tokens[2]).isDivideSymbol() + assertThat(tokens[3]).isUnitWithRawValueThat().isEqualTo("m") + assertThat(tokens[4]).isExponentiationSymbol() + assertThat(tokens[5]).isPositiveIntegerWithValueThat().isEqualTo(2) + } + + @Test + fun testTokenize_unitWithLargePositiveExponent_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("1 m^10").toList() + + assertThat(tokens).hasSize(4) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(1) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("m") + assertThat(tokens[2]).isExponentiationSymbol() + assertThat(tokens[3]).isPositiveIntegerWithValueThat().isEqualTo(10) + } + + @Test + fun testTokenize_unitWithLargeNegativeExponent_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("1 m^-10").toList() + + assertThat(tokens).hasSize(5) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(1) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("m") + assertThat(tokens[2]).isExponentiationSymbol() + assertThat(tokens[3]).isMinusSymbol() + assertThat(tokens[4]).isPositiveIntegerWithValueThat().isEqualTo(10) + } + + @Test + fun testTokenize_multipleUnitsWithExponents_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("1 m^2 * s^-1").toList() + + assertThat(tokens).hasSize(9) + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(1) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("m") + assertThat(tokens[2]).isExponentiationSymbol() + assertThat(tokens[3]).isPositiveIntegerWithValueThat().isEqualTo(2) + assertThat(tokens[4]).isMultiplySymbol() + assertThat(tokens[5]).isUnitWithRawValueThat().isEqualTo("s") + assertThat(tokens[6]).isExponentiationSymbol() + assertThat(tokens[7]).isMinusSymbol() + assertThat(tokens[8]).isPositiveIntegerWithValueThat().isEqualTo(1) + } + + @Test + fun testTokenize_scientificNotationStyleNumber_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("6.022 * 10^23 mol^-1").toList() + + assertThat(tokens).hasSize(9) + assertThat(tokens[0]).isPositiveRealNumberWithValueThat().isWithin(1e-5).of(6.022) + assertThat(tokens[1]).isMultiplySymbol() + assertThat(tokens[2]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[3]).isExponentiationSymbol() + assertThat(tokens[4]).isPositiveIntegerWithValueThat().isEqualTo(23) + assertThat(tokens[5]).isUnitWithRawValueThat().isEqualTo("mol") + assertThat(tokens[6]).isExponentiationSymbol() + assertThat(tokens[7]).isMinusSymbol() + assertThat(tokens[8]).isPositiveIntegerWithValueThat().isEqualTo(1) + } + + @Test + fun testTokenize_emptyParentheses_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("()").toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isLeftParenthesisSymbol() + assertThat(tokens[1]).isRightParenthesisSymbol() + } + + @Test + fun testTokenize_unmatchedLeftParenthesis_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("(m").toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isLeftParenthesisSymbol() + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("m") + } + + @Test + fun testTokenize_unmatchedRightParenthesis_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("m)").toList() + + assertThat(tokens).hasSize(2) + assertThat(tokens[0]).isUnitWithRawValueThat().isEqualTo("m") + assertThat(tokens[1]).isRightParenthesisSymbol() + } + + @Test + fun testTokenize_gravitationalConstant_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("6.674 N * m^2 / kg^2").toList() + + assertThat(tokens).hasSize(10) + assertThat(tokens[0]).isPositiveRealNumberWithValueThat().isWithin(1e-5).of(6.674) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("N") + assertThat(tokens[2]).isMultiplySymbol() + assertThat(tokens[3]).isUnitWithRawValueThat().isEqualTo("m") + assertThat(tokens[4]).isExponentiationSymbol() + assertThat(tokens[5]).isPositiveIntegerWithValueThat().isEqualTo(2) + assertThat(tokens[6]).isDivideSymbol() + assertThat(tokens[7]).isUnitWithRawValueThat().isEqualTo("kg") + assertThat(tokens[8]).isExponentiationSymbol() + assertThat(tokens[9]).isPositiveIntegerWithValueThat().isEqualTo(2) + } + + @Test + fun testTokenize_specificHeatCapacity_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("4.186 J / (g * degC)").toList() + + assertThat(tokens).hasSize(8) + assertThat(tokens[0]).isPositiveRealNumberWithValueThat().isWithin(1e-5).of(4.186) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("J") + assertThat(tokens[2]).isDivideSymbol() + assertThat(tokens[3]).isLeftParenthesisSymbol() + assertThat(tokens[4]).isUnitWithRawValueThat().isEqualTo("g") + assertThat(tokens[5]).isMultiplySymbol() + assertThat(tokens[6]).isUnitWithRawValueThat().isEqualTo("degC") + assertThat(tokens[7]).isRightParenthesisSymbol() + } + + @Test + fun testTokenize_thermalConductivity_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("0.6 W / (m * K)").toList() + + assertThat(tokens).hasSize(8) + assertThat(tokens[0]).isPositiveRealNumberWithValueThat().isWithin(1e-5).of(0.6) + assertThat(tokens[1]).isUnitWithRawValueThat().isEqualTo("W") + assertThat(tokens[2]).isDivideSymbol() + assertThat(tokens[3]).isLeftParenthesisSymbol() + assertThat(tokens[4]).isUnitWithRawValueThat().isEqualTo("m") + assertThat(tokens[5]).isMultiplySymbol() + assertThat(tokens[6]).isUnitWithRawValueThat().isEqualTo("K") + assertThat(tokens[7]).isRightParenthesisSymbol() + } + + @Test + fun testTokenize_onlyOperators_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("/ * ^").toList() + + assertThat(tokens).hasSize(3) + assertThat(tokens[0]).isDivideSymbol() + assertThat(tokens[1]).isMultiplySymbol() + assertThat(tokens[2]).isExponentiationSymbol() + } + + @Test + fun testTokenize_consecutiveExponents_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("m^^2").toList() + + assertThat(tokens).hasSize(4) + assertThat(tokens[0]).isUnitWithRawValueThat().isEqualTo("m") + assertThat(tokens[1]).isExponentiationSymbol() + assertThat(tokens[2]).isExponentiationSymbol() + assertThat(tokens[3]).isPositiveIntegerWithValueThat().isEqualTo(2) + } + + @Test + fun testTokenize_consecutiveDivisions_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize("m//s").toList() + + assertThat(tokens).hasSize(4) + assertThat(tokens[0]).isUnitWithRawValueThat().isEqualTo("m") + assertThat(tokens[1]).isDivideSymbol() + assertThat(tokens[2]).isDivideSymbol() + assertThat(tokens[3]).isUnitWithRawValueThat().isEqualTo("s") + } + + @Test + @Iteration("10 °C", "input=10 °C") + @Iteration("10 °", "input=10 °") + @Iteration("10 Ω", "input=10 Ω") + fun testTokenize_unknownSymbols_parsesCorrectly() { + val tokens = NumberWithUnitsTokenizer.tokenize(input).toList() + + assertThat(tokens[0]).isPositiveIntegerWithValueThat().isEqualTo(10) + assertThat(tokens[1]).isInvalidToken() + } +}