diff --git a/README.md b/README.md index 8c2d106..a65f078 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,8 @@ # Units 📏 -Units is a Swift package to manipulate, compare, and convert between physical quantities. This package models measurements, -which are a numerical value with a unit of measure. It has been designed so that users don't need to worry whether they are -using a defined unit (like `Newton`) or a complex composite unit (like `kg*m/s^2`). Both should be easy to convert to and from -different units, perform arithmetic operations, check dimensionality, or serialize to and from a string format. +Units is a Swift package to manipulate, compare, and convert between physical quantities. This package models measurements, which are a numerical value with a unit of measure. It has been designed so that users don't need to worry whether they are using a defined unit (like `Newton`) or a complex composite unit (like `kg*m/s^2`). Both should be easy to convert to and from different units, perform arithmetic operations, check dimensionality, or serialize to and from a string format. -This approach allows us to easily handle any permutation of units. You want to convert `12 km³/hr/N` to -`ft²*s/lb`? We've got you covered! +This approach allows us to easily handle any permutation of units. You want to convert `12 km³/hr/N` to `ft²*s/lb`? We've got you covered! Included is a convenient command-line tool for performing quick unit conversions. See the [CLI section](#cli) for details. @@ -23,7 +19,7 @@ This package has no other dependencies. ## Usage -Users should interact primarily with the `Measurement` struct. Here are a few usage examples: +Users should interact primarily with the `Measurement` struct. Here are a examples of arithmetic: ```swift let drivingSpeed = 60.measured(in: .mile / .hour) @@ -34,9 +30,9 @@ let drivingDistance = drivingSpeed * drivingTime print(drivingDistance.convert(to: .mile)) // Prints 30 mi ``` -The type names in this package align closely with the unit system provided by `Foundation`. This was intentional to provide a -familiar nomenclature for Swift developers. The APIs have been designed to avoid namespace ambiguity in files where both `Units` -and `Foundation` are imported as much as possible. However, if an issue arises, just qualify the desired package like so: +Note that a measurement may be multiplied or divided by another measurement with any unit, resulting in a measurement that has a new-dimensioned unit (5 meters / 10 seconds ✅). However, addition and subtraction requires that both measurements have the same dimensionality (5 meters - 10 seconds ❌), otherwise a runtime error is thrown. If adding or subtracting two measurements with different units but the same dimensionality, the result retains the first measurement's unit (5 meters - 5 millimeters = 4.995 meters). + +The type names in this package align closely with the unit system provided by `Foundation`. This was intentional to provide a familiar nomenclature for Swift developers. The APIs have been designed to avoid namespace ambiguity in files where both `Units` and `Foundation` are imported as much as possible. However, if an issue arises, just qualify the desired package like so: ```swift let measurement = Units.Measurement(value: 5, unit: .mile) @@ -72,41 +68,25 @@ print(distance) // Prints '15 m/s' ## Conversion -Only linear conversions are supported. The vast majority of unit conversions are simply changes in scale, represented by a single -conversion coefficient, sometimes with a constant shift. Units that don't match this format (like currency conversions, which are -typically time-based functions) are not supported. +Only linear conversions are supported. The vast majority of unit conversions are simply changes in scale, represented by a single conversion coefficient, sometimes with a constant shift. Units that don't match this format (like currency conversions, which are typically time-based functions) are not supported. -Composite units are those that represent complex quantities and dimensions. A good example is `horsepower`, whose quantity is -`mass * length^2 / time^2`. +Composite units are those that represent complex quantities and dimensions. A good example is `horsepower`, whose quantity is `mass * length^2 / time^2`. ### Coefficients -Each quantity has a single "base unit", through which the units of that quantity may be converted. SI units have been -chosen to be these base units for all quantities. +Each quantity has a single "base unit", through which the units of that quantity may be converted. SI units have been chosen to be these base units for all quantities. -Non-base units require a conversion coefficient to convert between them and other units of the same dimension. This coefficient -is the number of base units there are in one of the defined unit. For example, `kilometer` has a coefficient of `1000` -because there are 1000 meters in 1 kilometer. +Non-base units require a conversion coefficient to convert between them and other units of the same dimension. This coefficient is the number of base units there are in one of the defined unit. For example, `kilometer` has a coefficient of `1000` because there are 1000 meters in 1 kilometer. -Composite units must have a coefficient that converts to the composte SI units of those dimensions. That is, `horsepower` should -have a conversion to `kilogram * meter^2 / second^2` (otherwise known as `watt`). This is natural for SI quantities and units, but -care should be taken that a single, absolute base unit is chosen for all non-SI quantities since they will impact all composite -conversions. +Composite units must have a coefficient that converts to the composte SI units of those dimensions. That is, `horsepower` should have a conversion to `kilogram * meter^2 / second^2` (otherwise known as `watt`). This is natural for SI quantities and units, but care should be taken that a single, absolute base unit is chosen for all non-SI quantities since they will impact all composite conversions. ### Constants -Units that include a constant value, such as Fahrenheit, cannot be used within composite unit conversions. For example, -you may not convert `5m/°F` to `m/°C` because its unclear how to handle their shifted scale. Instead use the -non-shifted Kelvin and Rankine temperature units to refer to temperature differentials. +Units that include a constant value, such as Fahrenheit, cannot be used within composite unit conversions. For example, you may not convert `5m/°F` to `m/°C` because its unclear how to handle their shifted scale. Instead use the non-shifted Kelvin and Rankine temperature units to refer to temperature differentials. ## Serialization -Each defined unit must have a unique symbol, which is used to identify and serialize/deserialize it. Defined unit symbols are not -allowed to contain the `*`, `/`, `^`, or ` ` characters because those are used in the symbol representation of complex units. -Complex units are represented by their arithmetic combination of simple units using `*` for multiplication, `/` for division, -and `^` for exponentiation. Order of operations treats exponentiation first, and multiplication and division equally, from left- -to-right. This means that, unless negatively exponentiated, units following a `*` can always be considered to be "in the numerator", -and those following `/` can always be considered to be "in the denominator". +Each defined unit must have a unique symbol, which is used to identify and serialize/deserialize it. Defined unit symbols are not allowed to contain the `*`, `/`, `^`, or ` ` characters because those are used in the symbol representation of complex units. Complex units are represented by their arithmetic combination of simple units using `*` for multiplication, `/` for division, and `^` for exponentiation. Order of operations treats exponentiation first, and multiplication and division equally, from left-to-right. This means that, unless negatively exponentiated, units following a `*` can always be considered to be "in the numerator", and those following `/` can always be considered to be "in the denominator". Here are a few examples: @@ -125,7 +105,7 @@ Expressions are a mathematical combination of measurements. Arithemetic operator - `5.3 m + 3.8 m` - `5m^2/s + (1m + 2m)^2 / 5s` -There are few expression parsing rules to keep in mind: +There are few expression parsing rules to keep in mind: - All parentheses must be matched - All measurement operators must have a leading and following space. i.e. ` * ` @@ -134,8 +114,7 @@ There are few expression parsing rules to keep in mind: ## Default Units -For a list of the default units and their conversion factors, see the -[`DefaultUnits.swift file`](https://github.com/NeedleInAJayStack/Units/blob/main/Sources/Units/Unit/DefaultUnits.swift) +For a list of the default units and their conversion factors, see the [`DefaultUnits.swift file`](https://github.com/NeedleInAJayStack/Units/blob/main/Sources/Units/Unit/DefaultUnits.swift) ## Custom Units @@ -157,8 +136,7 @@ This returns a Unit object that can be used in arithmetic, conversions, and seri ### Non-scientific Units -For "non-scientific" units, it is typically appropriate to use the `Amount` quantity. Through this -approach, you can easily build up an impromptu conversion system on the fly. For example: +For "non-scientific" units, it is typically appropriate to use the `Amount` quantity. Through this approach, you can easily build up an impromptu conversion system on the fly. For example: ```swift let apple = try Unit.define( @@ -197,13 +175,9 @@ print(weeklyCartons) // Prints '350.0 carton/week' ### Adding custom units to the Registry -To support deserialization and runtime querying of available units, this package keeps a global -registry of the default units. The `Unit.define` method does not insert new definitions into this -registry. While this avoids conflicts and prevents race conditions, it also means that units created -using `Unit.define` cannot be deserialized correctly or looked up using `Unit(fromSymbol:)` +To support deserialization and runtime querying of available units, this package keeps a global registry of the default units. The `Unit.define` method does not insert new definitions into this registry. While this avoids conflicts and prevents race conditions, it also means that units created using `Unit.define` cannot be deserialized correctly or looked up using `Unit(fromSymbol:)` -If these features are absolutely needed, and the implications are understood, custom units can be -added to the registry using `Unit.register`: +If these features are absolutely needed, and the implications are understood, custom units can be added to the registry using `Unit.register`: ```swift let centifoot = try Unit.register( @@ -214,8 +188,7 @@ let centifoot = try Unit.register( ) ``` -Note that you may only register the unit once globally, and afterwards it should be accessed -either by the assigned variable or using `Unit(fromSymbol: String)`. +Note that you may only register the unit once globally, and afterwards it should be accessed either by the assigned variable or using `Unit(fromSymbol: String)`. To simplify access, `Unit` may be extended with a static property: @@ -231,8 +204,14 @@ Again, unless strictly necessary, `Unit.define` is preferred over `Unit.register ## CLI -The command-line interface can be built and installed by running the command below. Note that -[swift](https://www.swift.org/download/) must be installed. +The easiest way to install the CLI is with brew: + +```sh +brew tap NeedleInAJayStack/tap +brew install units +``` + +Alternatively, you can build it from source and install it to `/usr/local/bin/` using the install script. Note that [swift](https://www.swift.org/download/) must be installed, and you need write permissions to `/usr/local/bin/`. ```bash ./install.sh @@ -252,9 +231,7 @@ You can then perform unit conversions using the `unit convert` command: unit convert 5m/s mi/hr # Returns 11.184681460272012 mi/hr ``` -This command uses the unit and expression [serialization format](#serialization). Note that for -convenience, you may use an underscore `_` to represent the normally serialized space. Also, -`*` characters may need to be escaped. +This command uses the unit and expression [serialization format](#serialization). Note that for convenience, you may use an underscore `_` to represent the normally serialized space. Also, `*` characters may need to be escaped. You can also evaulate math in the first argument. For example: @@ -272,6 +249,4 @@ unit list ## Contributing -Contributions are absolutely welcome! If you find yourself using a custom unit a lot, feel free -to stick it in an MR, and we can add it to the default list! - +Contributions are absolutely welcome! If you find yourself using a custom unit a lot, feel free to stick it in an MR, and we can add it to the default list! diff --git a/Sources/Units/Expression.swift b/Sources/Units/Expression.swift index a72f911..391c8df 100644 --- a/Sources/Units/Expression.swift +++ b/Sources/Units/Expression.swift @@ -2,17 +2,17 @@ public final class Expression { // Implemented as a linked list of ExpressionNodes. This allows us to indicate operators, // and iteratively solve by reducing the list according to the order of operations. - + var first: ExpressionNode var last: ExpressionNode var count: Int - + init(node: ExpressionNode) { - self.first = node - self.last = node + first = node + last = node count = 1 } - + /// Initializes an expression from a string. /// /// Parsing rules: @@ -29,17 +29,17 @@ public final class Expression { /// - Parameter expr: The string expression to parse. public init(_ expr: String) throws { let parsed = try Parser(expr).parseExpression() - self.first = parsed.first - self.last = parsed.last - self.count = parsed.count + first = parsed.first + last = parsed.last + count = parsed.count } - + /// Reduces the expression to a single measurement, respecting the [order of operations](https://en.wikipedia.org/wiki/Order_of_operations) public func solve() throws -> Measurement { let copy = self.copy() return try copy.computeAndDestroy() } - + @discardableResult func append(op: Operator, node: ExpressionNode) -> Self { last.next = .init(op: op, node: node) @@ -47,7 +47,7 @@ public final class Expression { count = count + 1 return self } - + func copy() -> Expression { // Copy the expression list so the original is not destroyed let copy = Expression(node: first.copy()) @@ -58,12 +58,11 @@ public final class Expression { } return copy } - + /// Reduces the expression to a single measurement, respecting the [order of operations](https://en.wikipedia.org/wiki/Order_of_operations) /// /// NOTE: This flattens the list, destroying it. Use `solve` for non-destructive behavior. private func computeAndDestroy() throws -> Measurement { - // SubExpressions func computeSubExpression(node: ExpressionNode) throws { switch node.value { @@ -81,7 +80,7 @@ public final class Expression { } try computeSubExpression(node: left) // At this point, there should be no more sub expressions - + // Exponentals func exponentiate(node: ExpressionNode) throws { guard let exponent = node.exponent else { @@ -102,7 +101,7 @@ public final class Expression { left = next.node } try exponentiate(node: left) - + // Multiplication left = first while let next = left.next { @@ -123,7 +122,7 @@ public final class Expression { fatalError("Parentheses still present during multiplication phase") } } - + // Addition left = first while let next = left.next { @@ -131,7 +130,7 @@ public final class Expression { switch (left.value, right.value) { case let (.measurement(leftMeasurement), .measurement(rightMeasurement)): switch next.op { - case .add: // Compute and absorb right node into left + case .add: // Compute and absorb right node into left left.value = try .measurement(leftMeasurement + rightMeasurement) left.next = right.next case .subtract: // Compute and absorb right node into left @@ -144,7 +143,7 @@ public final class Expression { fatalError("Parentheses still present during addition phase") } } - + if first.next != nil { fatalError("Expression list reduction not complete") } @@ -194,15 +193,15 @@ class ExpressionNode { var value: ExpressionNodeValue var exponent: Int? var next: ExpressionLink? - + init(_ value: ExpressionNodeValue, exponent: Int? = nil, next: ExpressionLink? = nil) { self.value = value self.exponent = exponent self.next = next } - + func copy() -> ExpressionNode { - return .init(value.copy(), exponent: self.exponent) + return .init(value.copy(), exponent: exponent) } } @@ -216,7 +215,7 @@ extension ExpressionNode: Equatable { enum ExpressionNodeValue { case measurement(Measurement) case subExpression(Expression) - + func copy() -> ExpressionNodeValue { switch self { case let .measurement(measurement): @@ -254,7 +253,7 @@ extension ExpressionNodeValue: Equatable { class ExpressionLink { let op: Operator let node: ExpressionNode - + init(op: Operator, node: ExpressionNode) { self.op = op self.node = node diff --git a/Sources/Units/Measurement/Measurement.swift b/Sources/Units/Measurement/Measurement.swift index 3e37213..e42add3 100644 --- a/Sources/Units/Measurement/Measurement.swift +++ b/Sources/Units/Measurement/Measurement.swift @@ -38,6 +38,10 @@ public struct Measurement: Equatable, Codable { /// - Parameter newUnit: The unit to convert this measurement to /// - Returns: A new measurement with the converted scalar value and provided unit of measure public func convert(to newUnit: Unit) throws -> Measurement { + if unit == newUnit { + // No conversion needed + return self + } guard unit.isDimensionallyEquivalent(to: newUnit) else { throw UnitError.incompatibleUnits(message: "Cannot convert \(unit) to \(newUnit)") } @@ -52,9 +56,9 @@ public struct Measurement: Equatable, Codable { /// - rhs: The right-hand-side measurement /// - Returns: A new measurement with the summed scalar values and the same unit of measure public static func + (lhs: Measurement, rhs: Measurement) throws -> Measurement { - try checkSameUnit(lhs, rhs) + let rhsValue = try rhs.convert(to: lhs.unit).value return Measurement( - value: lhs.value + rhs.value, + value: lhs.value + rhsValue, unit: lhs.unit ) } @@ -64,8 +68,8 @@ public struct Measurement: Equatable, Codable { /// - lhs: The left-hand-side measurement /// - rhs: The right-hand-side measurement public static func += (lhs: inout Measurement, rhs: Measurement) throws { - try checkSameUnit(lhs, rhs) - lhs.value = lhs.value + rhs.value + let rhsValue = try rhs.convert(to: lhs.unit).value + lhs.value = lhs.value + rhsValue } /// Subtract one measurement from another. The measurements must have the same unit. @@ -75,9 +79,9 @@ public struct Measurement: Equatable, Codable { /// - Returns: A new measurement with the subtracted scalar values and the same unit of measure /// and the same unit of measure public static func - (lhs: Measurement, rhs: Measurement) throws -> Measurement { - try checkSameUnit(lhs, rhs) + let rhsValue = try rhs.convert(to: lhs.unit).value return Measurement( - value: lhs.value - rhs.value, + value: lhs.value - rhsValue, unit: lhs.unit ) } @@ -87,8 +91,8 @@ public struct Measurement: Equatable, Codable { /// - lhs: The left-hand-side measurement /// - rhs: The right-hand-side measurement public static func -= (lhs: inout Measurement, rhs: Measurement) throws { - try checkSameUnit(lhs, rhs) - lhs.value = lhs.value - rhs.value + let rhsValue = try rhs.convert(to: lhs.unit).value + lhs.value = lhs.value - rhsValue } /// Multiply the measurements. The measurements may have different units. @@ -166,8 +170,8 @@ extension Measurement: LosslessStringConvertible { guard let parsed = try? Parser(description).parseMeasurement() else { return nil } - self.value = parsed.value - self.unit = parsed.unit + value = parsed.value + unit = parsed.unit } } diff --git a/Sources/Units/Parser.swift b/Sources/Units/Parser.swift index 7ccc482..4f64d97 100644 --- a/Sources/Units/Parser.swift +++ b/Sources/Units/Parser.swift @@ -3,25 +3,25 @@ import Foundation class Parser { var data: [UnicodeScalar] var position = 0 - + private var cur: Character? { guard position < data.count else { return nil } return Character(UnicodeScalar(data[position])) } - + private var peek: Character? { guard position < data.count - 1 else { return nil } - return Character(UnicodeScalar(data[position+1])) + return Character(UnicodeScalar(data[position + 1])) } - + init(_ string: String) { - self.data = Array(string.unicodeScalars) + data = Array(string.unicodeScalars) } - + func parseMeasurement() throws -> Measurement { let value: Double switch try next() { @@ -30,7 +30,7 @@ class Parser { default: throw ParserError.invalidMeasurement } - + let unit: Unit switch try next() { case let .unit(parsed): @@ -40,17 +40,17 @@ class Parser { default: throw ParserError.invalidMeasurement } - + return Measurement(value: value, unit: unit) } - + func parseExpression() throws -> Expression { return try parseExpression(isSubExpression: false) } - + private func parseExpression(isSubExpression: Bool) throws -> Expression { var expression: Expression? = nil - + var token = try next() var op: Operator? = nil // We do while/true because we can exit on either eof or rParen, depending on isSubExpression @@ -63,7 +63,7 @@ class Parser { break parseLoop case let .number(value): let unit: Unit - + // Check next token to see if it is a unit. Continue loop to avoid calling next again below. let nextToken = try next() switch nextToken { @@ -89,7 +89,7 @@ class Parser { case .lParen: let subExpression = try parseExpression(isSubExpression: true) let node = ExpressionNode(.subExpression(subExpression)) - + if let expression = expression { guard let op = op else { throw ParserError.invalidExpression(reason: "No operator preceeding left parentheses") @@ -127,7 +127,7 @@ class Parser { } token = try next() } - + if let op = op { throw ParserError.invalidExpression(reason: "Expression ended with operator `\(op)`") } @@ -136,16 +136,16 @@ class Parser { } return expression } - + private func next() throws -> Token { guard let char = cur else { return .eof } - + if char.isNumber { let startPosition = position var numberString = "" - while let cur = cur, (cur.isNumber || cur == ".") { + while let cur = cur, cur.isNumber || cur == "." { numberString.append(cur) consume() } @@ -153,16 +153,13 @@ class Parser { throw ParserError.unableToParseNumber(numberString, position: startPosition) } return .number(number) - } - else if char == "(" { + } else if char == "(" { consume() return .lParen - } - else if char == ")" { + } else if char == ")" { consume() return .rParen - } - else if char == "^" { + } else if char == "^" { let startPosition = position try consume("^") var intString = "" @@ -174,8 +171,7 @@ class Parser { throw ParserError.unableToParseExponent("^\(intString)", position: startPosition) } return .exp(int) - } - else if char.isWhitespace { + } else if char.isWhitespace { if peek == "+" { try consume(" ") try consume("+") @@ -200,26 +196,25 @@ class Parser { try consume(" ") return .div } - + // consume and try again consume() return try next() - } - else { + } else { var unitString = "" while let cur = cur, cur != "(" && cur != ")" && !cur.isWhitespace { unitString.append(cur) consume() } - let unit = try Unit.init(fromSymbol: unitString) + let unit = try Unit(fromSymbol: unitString) return .unit(unit) } } - + private func consume() { position = position + 1 } - + private func consume(_ expected: Character) throws { guard let character = cur else { return @@ -248,7 +243,7 @@ enum ParserError: Error { case unexpectedCharacter(Character, position: Int) case unableToParseNumber(String, position: Int) case unableToParseExponent(String, position: Int) - + case invalidMeasurement case invalidExpression(reason: String) } diff --git a/Sources/Units/Registry.swift b/Sources/Units/Registry.swift index aefad2a..34d44a8 100644 --- a/Sources/Units/Registry.swift +++ b/Sources/Units/Registry.swift @@ -253,11 +253,11 @@ class Registry { // MARK: Luminous Intensity DefaultUnits.candela, - + // MARK: Luminous Flux DefaultUnits.lumen, - + // MARK: Magnetic Flux DefaultUnits.weber, diff --git a/Sources/Units/Unit/DefaultUnits.swift b/Sources/Units/Unit/DefaultUnits.swift index e8d35e4..fde7e7e 100644 --- a/Sources/Units/Unit/DefaultUnits.swift +++ b/Sources/Units/Unit/DefaultUnits.swift @@ -3,6 +3,7 @@ import Foundation /// Static type containing this package's pre-defined units enum DefaultUnits { // MARK: If adding units to this list, add corresponding entries to the following files: + // - Unit+DefaultUnits.swift // - Registry.swift // - DefinitionTests.swift diff --git a/Tests/UnitsTests/DefinitionTests.swift b/Tests/UnitsTests/DefinitionTests.swift index 6bc7e7f..e0bdd44 100644 --- a/Tests/UnitsTests/DefinitionTests.swift +++ b/Tests/UnitsTests/DefinitionTests.swift @@ -64,7 +64,7 @@ class DefinitionTests: XCTestCase { try XCTAssertEqual(Measurement("1Ebit"), 1e18.measured(in: .bit).convert(to: .exabit)) try XCTAssertEqual(Measurement("1Zbit"), 1e21.measured(in: .bit).convert(to: .zetabit)) try XCTAssertEqual(Measurement("1Ybit"), 1e24.measured(in: .bit).convert(to: .yottabit)) - + try XCTAssertEqual(Measurement("1Kibit"), 1024.measured(in: .bit).convert(to: .kibibit)) try XCTAssertEqual(Measurement("1Mibit"), pow(1024, 2).measured(in: .bit).convert(to: .mebibit)) try XCTAssertEqual(Measurement("1Gibit"), pow(1024, 3).measured(in: .bit).convert(to: .gibibit)) @@ -73,7 +73,7 @@ class DefinitionTests: XCTestCase { try XCTAssertEqual(Measurement("1Eibit"), pow(1024, 6).measured(in: .bit).convert(to: .exbibit)) try XCTAssertEqual(Measurement("1Zibit"), pow(1024, 7).measured(in: .bit).convert(to: .zebibit)) try XCTAssertEqual(Measurement("1Yibit"), pow(1024, 8).measured(in: .bit).convert(to: .yobibit)) - + try XCTAssertEqual(Measurement("1byte"), 8.measured(in: .bit).convert(to: .byte)) try XCTAssertEqual(Measurement("1kB"), 8000.measured(in: .bit).convert(to: .kilobyte)) try XCTAssertEqual(Measurement("1MB"), 8e6.measured(in: .bit).convert(to: .megabyte)) @@ -83,7 +83,7 @@ class DefinitionTests: XCTestCase { try XCTAssertEqual(Measurement("1EB"), 8e18.measured(in: .bit).convert(to: .exabyte)) try XCTAssertEqual(Measurement("1ZB"), 8e21.measured(in: .bit).convert(to: .zetabyte)) try XCTAssertEqual(Measurement("1YB"), 8e24.measured(in: .bit).convert(to: .yottabyte)) - + try XCTAssertEqual(Measurement("1KiB"), (8 * 1024).measured(in: .bit).convert(to: .kibibyte)) try XCTAssertEqual(Measurement("1MiB"), (8 * pow(1024, 2)).measured(in: .bit).convert(to: .mebibyte)) try XCTAssertEqual(Measurement("1GiB"), (8 * pow(1024, 3)).measured(in: .bit).convert(to: .gibibyte)) diff --git a/Tests/UnitsTests/ExpressionTests.swift b/Tests/UnitsTests/ExpressionTests.swift index 08ef8e8..a0f8cda 100644 --- a/Tests/UnitsTests/ExpressionTests.swift +++ b/Tests/UnitsTests/ExpressionTests.swift @@ -8,203 +8,203 @@ final class ExpressionTests: XCTestCase { Expression(node: .init(.measurement(5.measured(in: .meter)))) .append(op: .add, node: .init(.measurement(3.measured(in: .meter)))) ) - + XCTAssertEqual( try Expression("5.3 m + 3.8 m"), Expression(node: .init(.measurement(5.3.measured(in: .meter)))) .append(op: .add, node: .init(.measurement(3.8.measured(in: .meter)))) ) - + XCTAssertEqual( try Expression("5m^2/s + (1m + 2m)^2 / 5s"), Expression(node: .init(.measurement(5.measured(in: .meter * .meter / .second)))) .append(op: .add, node: .init( .subExpression( .init(node: .init(.measurement(1.measured(in: .meter)))) - .append(op: .add, node: .init(.measurement(2.measured(in: .meter)))) + .append(op: .add, node: .init(.measurement(2.measured(in: .meter)))) ), exponent: 2 )) .append(op: .divide, node: .init(.measurement(5.measured(in: .second)))) ) } - + func testSingleMeasurement() throws { try XCTAssertEqual( Expression("5kW").solve(), 5.measured(in: .kilowatt) ) } - + func testAddition() throws { try XCTAssertEqual( Expression("5kW + 2kW").solve(), 7.measured(in: .kilowatt) ) } - + func testSubtraction() throws { try XCTAssertEqual( Expression("5kW - 2kW").solve(), 3.measured(in: .kilowatt) ) } - + func testMultiplication() throws { try XCTAssertEqual( Expression("5kW * 2hr").solve(), 10.measured(in: .kilowatt * .hour) ) } - + func testDivision() throws { try XCTAssertEqual( Expression("6m / 2s").solve(), 3.measured(in: .meter / .second) ) } - + func testExponent() throws { try XCTAssertEqual( Expression("(3m)^2").solve(), 9.measured(in: .meter * .meter) ) - + try XCTAssertEqual( Expression("(3m + 2m)^2").solve(), 25.measured(in: .meter * .meter) ) - + try XCTAssertEqual( Expression("3m^2 + (2m)^2").solve(), 7.measured(in: .meter * .meter) ) } - + func testParentheses() throws { try XCTAssertEqual( Expression("(5kW) * 2hr").solve(), 10.measured(in: .kilowatt * .hour) ) - + try XCTAssertEqual( Expression("5kW * (2hr)").solve(), 10.measured(in: .kilowatt * .hour) ) - + try XCTAssertEqual( Expression("5kW * (2hr + 1hr)").solve(), 15.measured(in: .kilowatt * .hour) ) } - + func testOrderOfOperations() throws { try XCTAssertEqual( Expression("5kW * 2hr + 3kW*hr").solve(), 13.measured(in: .kilowatt * .hour) ) - + try XCTAssertEqual( Expression("5kW*hr + 3kW * 2hr").solve(), 11.measured(in: .kilowatt * .hour) ) - + try XCTAssertEqual( Expression("5kW*hr + 3kW * 2hr + 2kW*hr").solve(), 13.measured(in: .kilowatt * .hour) ) - + try XCTAssertEqual( Expression("5kW * 3hr + 2kW * 2hr").solve(), 19.measured(in: .kilowatt * .hour) ) - + try XCTAssertEqual( Expression("5kW * (3hr + 2hr) * 2hr").solve(), 50.measured(in: .kilowatt * .hour * .hour) ) } - + func testDescription() throws { try XCTAssertEqual( Expression("5kW * 3hr + 2kW * 2hr").description, "5.0 kW * 3.0 hr + 2.0 kW * 2.0 hr" ) } - + func testPrintParseCycle() throws { try XCTAssertEqual( Expression(Expression("5kW * 3hr + 2kW * 2hr").description), Expression("5kW * 3hr + 2kW * 2hr") ) - + try XCTAssertEqual( Expression(Expression("5kW * (3hr + 2hr) * 2hr").description), Expression("5kW * (3hr + 2hr) * 2hr") ) } - + func testEquatable() throws { try XCTAssertEqual( Expression("5kW + 3kW + 2kW"), Expression("5kW + 3kW + 2kW") ) } - + func testNotEqualWhenCountsDontMatch() throws { try XCTAssertNotEqual( Expression("5kW + 3kW"), Expression("5kW + 3kW + 2kW") ) } - + func testNotEqualWhenOperatorsDontMatch() throws { try XCTAssertNotEqual( Expression("5kW + 3kW"), Expression("5kW * 3kW") ) } - + func testNotEqualWhenUnitsDontMatch() throws { try XCTAssertNotEqual( Expression("5kW + 3kW"), Expression("5W + 3W") ) } - + func testNotEqualWhenScalarsDontMatch() throws { try XCTAssertNotEqual( Expression("5kW + 3kW"), Expression("5kW + 4kW") ) } - + func testNotEqualWhenExponentsDontMatch() throws { try XCTAssertNotEqual( Expression("(5kW)^2"), Expression("(5kW)^3") ) } - + func testNotEqualWhenSubExpressionsDontMatch() throws { try XCTAssertNotEqual( Expression("5kW * (2hr + 1hr)"), Expression("5kW * (1hr + 1hr)") ) } - + func testSolveIsNotDestructive() throws { let expression = try Expression("5kW + 2kW") - + XCTAssertEqual( expression.description, "5.0 kW + 2.0 kW" ) - + try XCTAssertNoThrow(expression.solve()) - + expression.append(op: .add, node: .init(.measurement(3.measured(in: .kilowatt)))) - + XCTAssertEqual( expression.description, "5.0 kW + 2.0 kW + 3.0 kW" diff --git a/Tests/UnitsTests/MeasurementTests.swift b/Tests/UnitsTests/MeasurementTests.swift index b54f3e0..c26f943 100644 --- a/Tests/UnitsTests/MeasurementTests.swift +++ b/Tests/UnitsTests/MeasurementTests.swift @@ -36,6 +36,13 @@ final class MeasurementTests: XCTestCase { accuracy: accuracy ) + // Test that adding different units of the same dimension works + XCTAssertEqual( + try 5.measured(in: .meter) + 5.measured(in: .millimeter), + 5.005.measured(in: .meter), + ) + + // Test that adding different dimensions throws an error XCTAssertThrowsError( try 5.measured(in: .meter) + 5.measured(in: .second) ) @@ -62,6 +69,13 @@ final class MeasurementTests: XCTestCase { accuracy: accuracy ) + // Test that subtracting different units of the same dimension works + XCTAssertEqual( + try 5.measured(in: .meter) - 5.measured(in: .millimeter), + 4.995.measured(in: .meter), + ) + + // Test that subtracting different dimensions throws an error XCTAssertThrowsError( try 5.measured(in: .meter) - 5.measured(in: .second) ) diff --git a/Tests/UnitsTests/ParserTests.swift b/Tests/UnitsTests/ParserTests.swift index 356ac1e..76a2c43 100644 --- a/Tests/UnitsTests/ParserTests.swift +++ b/Tests/UnitsTests/ParserTests.swift @@ -8,64 +8,64 @@ final class ParseMeasurementTests: XCTestCase { 5.1.measured(in: .none) ) } - + func testSimpleUnit() throws { XCTAssertEqual( try Parser("5.1 kW").parseMeasurement(), 5.1.measured(in: .kilowatt) ) } - + func testUnitWithSymbol() throws { XCTAssertEqual( try Parser("5.1 °F").parseMeasurement(), 5.1.measured(in: .fahrenheit) ) } - + func testComplexUnit() throws { XCTAssertEqual( try Parser("5.1 m^2*kg/s^3").parseMeasurement(), 5.1.measured(in: .meter * .meter * .kilogram / .second / .second / .second) ) } - + func testHandlesWhitespace() throws { XCTAssertEqual( try Parser(" 5.1 ").parseMeasurement(), 5.1.measured(in: .none) ) - + XCTAssertEqual( try Parser("5.1 kW").parseMeasurement(), 5.1.measured(in: .kilowatt) ) - + XCTAssertEqual( try Parser("5.1kW").parseMeasurement(), 5.1.measured(in: .kilowatt) ) } - + func testHandlesNoDecimal() throws { XCTAssertEqual( try Parser("5 kW").parseMeasurement(), 5.measured(in: .kilowatt) ) } - + func testFailsOnBadUnit() throws { XCTAssertThrowsError( try Parser("5 +").parseMeasurement() ) } - + func testFailsOnUnknownUnit() throws { XCTAssertThrowsError( try Parser("5 flippers").parseMeasurement() ) } - + func testFailsOnBadValue() throws { XCTAssertThrowsError( try Parser("orange kW").parseMeasurement() @@ -81,7 +81,7 @@ final class ParseExpressionTests: XCTestCase { .append(op: .add, node: .init(.measurement(3.measured(in: .meter)))) ) } - + func testComplex() throws { XCTAssertEqual( try Parser("5 m^2/s + (1 m + 2 m)^2 / 5 s").parseExpression(), @@ -89,32 +89,32 @@ final class ParseExpressionTests: XCTestCase { .append(op: .add, node: .init( .subExpression( .init(node: .init(.measurement(1.measured(in: .meter)))) - .append(op: .add, node: .init(.measurement(2.measured(in: .meter)))) + .append(op: .add, node: .init(.measurement(2.measured(in: .meter)))) ), exponent: 2 )) .append(op: .divide, node: .init(.measurement(5.measured(in: .second)))) ) } - + func testNestedExpressions() throws { XCTAssertEqual( try Parser("5 m * (1 m * (1 m + 2 m))").parseExpression(), Expression(node: .init(.measurement(5.measured(in: .meter)))) - .append(op: .multiply, node: .init( - .subExpression( - .init(node: .init(.measurement(1.measured(in: .meter)))) - .append(op: .multiply, node: .init( - .subExpression( - .init(node: .init(.measurement(1.measured(in: .meter)))) - .append(op: .add, node: .init(.measurement(2.measured(in: .meter)))) - ) - )) - ) - )) + .append(op: .multiply, node: .init( + .subExpression( + .init(node: .init(.measurement(1.measured(in: .meter)))) + .append(op: .multiply, node: .init( + .subExpression( + .init(node: .init(.measurement(1.measured(in: .meter)))) + .append(op: .add, node: .init(.measurement(2.measured(in: .meter)))) + ) + )) + ) + )) ) } - + func testNoUnit() throws { XCTAssertEqual( try Parser("5 + 2 * 3").parseExpression(), @@ -123,21 +123,21 @@ final class ParseExpressionTests: XCTestCase { .append(op: .multiply, node: .init(.measurement(3.measured(in: .none)))) ) } - + func testHandlesWhitespace() throws { XCTAssertEqual( try Parser("5 m + 3 m").parseExpression(), Expression(node: .init(.measurement(5.measured(in: .meter)))) .append(op: .add, node: .init(.measurement(3.measured(in: .meter)))) ) - + XCTAssertEqual( try Parser("5m + 3m").parseExpression(), Expression(node: .init(.measurement(5.measured(in: .meter)))) .append(op: .add, node: .init(.measurement(3.measured(in: .meter)))) ) } - + func testFailsOnUnspacedOperators() throws { XCTAssertThrowsError( try Parser("5m+3m").parseExpression() @@ -152,24 +152,24 @@ final class ParseExpressionTests: XCTestCase { try Parser("5m/3m").parseExpression() ) } - + func testFailsOnIncompleteExpression() throws { XCTAssertThrowsError( try Parser("5m + ").parseExpression() ) - + XCTAssertThrowsError( try Parser("(5m + 2m) - (3m").parseExpression() ) - + XCTAssertThrowsError( try Parser("(5m)^").parseExpression() ) - + XCTAssertThrowsError( try Parser("(5m + 2m) (3m)").parseExpression() ) - + XCTAssertThrowsError( try Parser(") + (5m + 2m)").parseExpression() )