diff --git a/README.md b/README.md index 42ce9cd..dd64c86 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,55 @@ let weeklyCartons = try (workforce * personPickRate).convert(to: carton / .week) print(weeklyCartons) // Prints '350.0 carton/week' ``` +### Percent + +While technically not a `Measurement`, the use of the percent symbol ('%') is still useful +in conveying the "semanantics" of a scaler value so we include it in this package. + +Here’s how math operators work with percentages in typical calculations: + +Math operators with percentages treat the percent as its decimal equivalent +(e.g., 25% = 0.25) but in the case of `+` and `-` the calculation is less direct. + +#### Multiplication (100 * 25%) + + When you multiply a number by a percentage, you’re finding that percent of the number. + • 25% is the same as 0.25. + • So, 100 * 25% = 100 * 0.25 = 25. + +#### Division (100 / 30%) + + Dividing by a percentage means dividing by its decimal form. + • 30% is 0.3. + • So, 100 / 30% = 100 / 0.3 ≈ 333.33. + +#### Addition (100 + 10%) + + Adding a percentage to a number is less direct, but usually means increasing the number by that percent. + • 10% of 100 is 10. + • So, 100 + 10% = 100 + (100 * 0.10) = 110. + +#### General Rule + • Percent means “per hundred,” so 25% = 25/100 = 0.25. + • Replace the percent with its decimal equivalent before performing the operation. + + +### FormatStyle + +The `Measurement.Formatter` provides the `formatted` method to enable the +setting of the `NumberFormatStyleConfiguration.Precision` for String output. + +Example Use: + +``` + let measure = 28.123.measured(in: .meter) + + measure.formatted() // -> "28.123 m" + measure.formatted(minimumFractionDigits: 4) // -> "28.1230 m" + measure.formatted(maximumFractionDigits: 0) // -> "28 m" + measure.formatted(maximumFractionDigits: 1) // -> "28.1 m" +``` + ## CLI The easiest way to install the CLI is with brew: diff --git a/Sources/Units/Measurement/Formatter.swift b/Sources/Units/Measurement/Formatter.swift new file mode 100644 index 0000000..f2dc401 --- /dev/null +++ b/Sources/Units/Measurement/Formatter.swift @@ -0,0 +1,100 @@ +// +// Formatter.swift +// Units +// (aka Fountation.FormatStyle) +// +// Created by Jason Jobe on 10/24/25. +// + +public extension Measurement { + struct Formatter { + let format: (Measurement) -> Output + } + + func formatted(_ formatter: Formatter) -> Output { + formatter.format(self) + } + + func formatted(_ formatter: Formatter = .measurement()) -> String { + formatter.format(self) + } + + func formatted( + minimumFractionDigits: Int = 0, + maximumFractionDigits: Int = 4 + ) -> String { + Formatter + .measurement( + minimumFractionDigits: minimumFractionDigits, + maximumFractionDigits: maximumFractionDigits) + .format(self) + } +} + +extension Measurement.Formatter where Output == String { + public static func measurement( + minimumFractionDigits: Int = 0, + maximumFractionDigits: Int = 4 + ) -> Self { + self.init { value in + value.value + .formatted( + minimumFractionDigits: minimumFractionDigits, + maximumFractionDigits: maximumFractionDigits) + + " \(value.unit.symbol)" + } + } +} + + +// MARK: Percent Formatter + +public extension Percent { + struct Formatter { + let format: (Percent) -> Output + } + + func formatted(_ formatter: Formatter) -> Output { + formatter.format(self) + } +} + +public extension Percent { + func formatted(fractionDigits: Int = 2) -> String { + Formatter(fractionDigits: fractionDigits).format(self) + } +} + +public extension Percent.Formatter where Output == String { + + init (fractionDigits: Int) { + self.init { value in + (value.magnitude * 100) + .formatted( + minimumFractionDigits: 0, + maximumFractionDigits: fractionDigits) + + value.unit.symbol + } + } +} + +public extension BinaryFloatingPoint { + func formatted(minimumFractionDigits: Int = 0, maximumFractionDigits: Int = 4) -> String { + let minDigits = max(0, minimumFractionDigits) + let maxDigits = max(minDigits, maximumFractionDigits) + let s = String(format: "%.\(maxDigits)f", Double(self)) + if maxDigits > minDigits, s.contains(".") { + var trimmed = s + while trimmed.last == "0" { trimmed.removeLast() } + if trimmed.last == "." { trimmed.removeLast() } + if let dotIndex = trimmed.firstIndex(of: ".") { + let fractionalCount = trimmed.distance(from: trimmed.index(after: dotIndex), to: trimmed.endIndex) + if fractionalCount < minDigits { + return String(format: "%.\(minDigits)f", Double(self)) + } + } + return trimmed + } + return s + } +} diff --git a/Sources/Units/Measurement/Percent+Measurement.swift b/Sources/Units/Measurement/Percent+Measurement.swift index 154bee8..f18782d 100644 --- a/Sources/Units/Measurement/Percent+Measurement.swift +++ b/Sources/Units/Measurement/Percent+Measurement.swift @@ -43,7 +43,7 @@ import Foundation If you see a percent sign in a calculation, just convert it to a decimal and proceed as usual. If you want to know how subtraction works with percentages, or how to handle more complex expressions, let me know! */ -public struct Percent: Numeric, Equatable, Codable { +public struct Percent: Numeric, Codable, Sendable { public private(set) var magnitude: Double @@ -65,6 +65,14 @@ public struct Percent: Numeric, Equatable, Codable { } } +extension Percent: Equatable { + /// Implemented as "nearly" equal + public static func ==(lhs: Percent, rhs: Percent) -> Bool { + lhs.magnitude >= rhs.magnitude.nextDown + && lhs.magnitude <= lhs.magnitude.nextUp + } +} + extension Measurement { public var isPercent: Bool { self.unit == Percent.unit @@ -201,3 +209,23 @@ public extension Measurement { ) } } + +extension Percent: Comparable { + public static func < (lhs: Self, rhs: Self) -> Bool { + lhs.magnitude < rhs.magnitude + } +} + +extension Percent { + /** + Returns a random value within the given range. + + ``` + Percent.random(in: 10%...20%) + // 10%, 11%, 12%, 19.98%, etc. + ``` + */ + public static func random(in range: ClosedRange) -> Self { + self.init(magnitude: .random(in: range.lowerBound.magnitude...range.upperBound.magnitude)) + } +} diff --git a/Tests/UnitsTests/MeasurementTests.swift b/Tests/UnitsTests/MeasurementTests.swift index cbb59d8..1e0a746 100644 --- a/Tests/UnitsTests/MeasurementTests.swift +++ b/Tests/UnitsTests/MeasurementTests.swift @@ -600,4 +600,12 @@ final class MeasurementTests: XCTestCase { accuracy: accuracy ) } + + func testFormatStyle() { + let measure = 28.123.measured(in: .meter) + XCTAssertEqual(measure.formatted(), "28.123 m") + XCTAssertEqual(measure.formatted(minimumFractionDigits: 4), "28.1230 m") + XCTAssertEqual(measure.formatted(maximumFractionDigits: 0), "28 m") + XCTAssertEqual(measure.formatted(maximumFractionDigits: 1), "28.1 m") + } } diff --git a/Tests/UnitsTests/PercentTests.swift b/Tests/UnitsTests/PercentTests.swift index 2cd673f..f72a679 100644 --- a/Tests/UnitsTests/PercentTests.swift +++ b/Tests/UnitsTests/PercentTests.swift @@ -9,6 +9,7 @@ import XCTest final class PercentTests: XCTestCase { + func testParse() throws { XCTAssertEqual( try Expression("10m + 25%"), @@ -38,7 +39,20 @@ final class PercentTests: XCTestCase { try Expression("10m / 25%").solve(), 40.measured(in: .meter) ) - + } + + func testPercentCalculation() { + XCTAssertEqual(50% * 50%, 25%) + XCTAssertEqual(50% + 5.8%, 55.8%) + XCTAssertEqual(50% - 50%, 0%) + XCTAssertEqual(50% - 5.8%, 44.2%) + } + + func testPercentFormat() { + XCTAssertEqual(30%.formatted(), "30%") + XCTAssertEqual(28.5%.formatted(), "28.5%") + XCTAssertEqual(28.33%.formatted(), "28.33%") + XCTAssertEqual(28.33%.formatted(fractionDigits: 1), "28.3%") + XCTAssertEqual(28.33%.formatted(fractionDigits: 0), "28%") } } -