Skip to content

Commit 0342495

Browse files
Measurement FormatStyle (#1)
* FormatStyle WIP * added FormatStyle tests * updated README and @available --------- Co-authored-by: Jason Jobe <box2019@jasonjobe.com>
1 parent 78ffb04 commit 0342495

5 files changed

Lines changed: 207 additions & 3 deletions

File tree

README.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,56 @@ let weeklyCartons = try (workforce * personPickRate).convert(to: carton / .week)
194194
print(weeklyCartons) // Prints '350.0 carton/week'
195195
```
196196

197+
### Percent
198+
199+
While technically not a `Measurement`, the use of the percent symbol ('%') is still useful
200+
in conveying the "semanantics" of a scaler value so we include it in this package.
201+
202+
Here’s how math operators work with percentages in typical calculations:
203+
204+
Math operators with percentages treat the percent as its decimal equivalent
205+
(e.g., 25% = 0.25) but in the case of `+` and `-` the calculation is less direct.
206+
207+
#### Multiplication (100 * 25%)
208+
209+
When you multiply a number by a percentage, you’re finding that percent of the number.
210+
• 25% is the same as 0.25.
211+
• So, 100 * 25% = 100 * 0.25 = 25.
212+
213+
#### Division (100 / 30%)
214+
215+
Dividing by a percentage means dividing by its decimal form.
216+
• 30% is 0.3.
217+
• So, 100 / 30% = 100 / 0.3 ≈ 333.33.
218+
219+
#### Addition (100 + 10%)
220+
221+
Adding a percentage to a number is less direct, but usually means increasing the number by that percent.
222+
• 10% of 100 is 10.
223+
• So, 100 + 10% = 100 + (100 * 0.10) = 110.
224+
225+
#### General Rule
226+
• Percent means “per hundred,” so 25% = 25/100 = 0.25.
227+
• Replace the percent with its decimal equivalent before performing the operation.
228+
229+
230+
### FormatStyle
231+
232+
The `Measurement.Formatter` provides the `formatted` method to enable the
233+
setting of the `NumberFormatStyleConfiguration.Precision` for String output.
234+
235+
Example Use:
236+
237+
```
238+
let measure = 28.123.measured(in: .meter)
239+
240+
measure.formatted() // -> "28.123 m"
241+
measure.formatted(precision: .significantDigits(1) // -> "30 m"
242+
measure.formatted(precision: .significantDigits(3)) // -> "28.1 m"
243+
measure.formatted(precision:
244+
.integerAndFractionLength(integer: 2, fraction: 0)) // -> "28 m"
245+
```
246+
197247
## CLI
198248

199249
The easiest way to install the CLI is with brew:

Sources/Units/Measurement/Measurement.swift

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,3 +194,58 @@ extension Measurement: ExpressibleByFloatLiteral {
194194
}
195195

196196
extension Measurement: Sendable {}
197+
198+
// MARK: FormatStyle
199+
@available(macOS 12.0, iOS 15.0, *)
200+
public extension Measurement {
201+
202+
func formatted<Style: FormatStyle>(
203+
_ style: Style
204+
) -> Style.FormatOutput where Style.FormatInput == Self {
205+
style.format(self)
206+
}
207+
}
208+
209+
@available(macOS 12.0, iOS 15.0, *)
210+
public extension Measurement {
211+
typealias Precision = NumberFormatStyleConfiguration.Precision
212+
213+
struct Formatter<Output> {
214+
let format: (Measurement) -> Output
215+
}
216+
217+
func formatted<Output>(_ formatter: Formatter<Output>) -> Output {
218+
formatter.format(self)
219+
}
220+
221+
func formatted(_ formatter: Formatter<String> = .measurement()) -> String {
222+
formatter.format(self)
223+
}
224+
225+
@_disfavoredOverload
226+
func formatted(precision: Precision? = nil) -> String {
227+
formatted(.measurement(precision: precision))
228+
}
229+
}
230+
231+
@available(macOS 12.0, iOS 15.0, *)
232+
extension Measurement.Formatter where Output == String {
233+
public typealias Precision = NumberFormatStyleConfiguration.Precision
234+
235+
public static func measurement(precision: Precision? = nil) -> Self {
236+
.init { value in
237+
switch (value.unit, precision) {
238+
case (.none, .none):
239+
"\(value.value.formatted(.number))"
240+
case (.none, .some(let p)):
241+
"\(value.value.formatted(.number.precision(p)))"
242+
case (let u, .none):
243+
"\(value.value.formatted(.number)) \(u)"
244+
245+
case (let u, .some(let p)):
246+
"\(value.value.formatted(.number.precision(p))) \(u)"
247+
}
248+
}
249+
}
250+
}
251+

Sources/Units/Measurement/Percent+Measurement.swift

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ import Foundation
4343

4444
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!
4545
*/
46-
public struct Percent: Numeric, Equatable, Codable {
46+
public struct Percent: Numeric, Codable, Sendable {
4747

4848
public private(set) var magnitude: Double
4949

@@ -65,6 +65,14 @@ public struct Percent: Numeric, Equatable, Codable {
6565
}
6666
}
6767

68+
extension Percent: Equatable {
69+
/// Implemented as "nearly" equal
70+
public static func ==(lhs: Percent, rhs: Percent) -> Bool {
71+
lhs.magnitude >= rhs.magnitude.nextDown
72+
&& lhs.magnitude <= lhs.magnitude.nextUp
73+
}
74+
}
75+
6876
extension Measurement {
6977
public var isPercent: Bool {
7078
self.unit == Percent.unit
@@ -201,3 +209,72 @@ public extension Measurement {
201209
)
202210
}
203211
}
212+
213+
extension Percent: Comparable {
214+
public static func < (lhs: Self, rhs: Self) -> Bool {
215+
lhs.magnitude < rhs.magnitude
216+
}
217+
}
218+
219+
extension Percent {
220+
/**
221+
Returns a random value within the given range.
222+
223+
```
224+
Percent.random(in: 10%...20%)
225+
// 10%, 11%, 12%, 19.98%, etc.
226+
```
227+
*/
228+
public static func random(in range: ClosedRange<Self>) -> Self {
229+
self.init(magnitude: .random(in: range.lowerBound.magnitude...range.upperBound.magnitude))
230+
}
231+
}
232+
233+
// MARK: FormatStyle
234+
@available(macOS 12.0, iOS 15.0, *)
235+
public extension Percent {
236+
237+
func formatted<Style: FormatStyle>(
238+
_ style: Style
239+
) -> Style.FormatOutput where Style.FormatInput == Self {
240+
style.format(self)
241+
}
242+
}
243+
244+
public extension Percent {
245+
struct Formatter<Output> {
246+
let format: (Percent) -> Output
247+
}
248+
249+
func formatted<Output>(_ formatter: Formatter<Output>) -> Output {
250+
formatter.format(self)
251+
}
252+
}
253+
254+
@available(macOS 12.0, iOS 15.0, *)
255+
public extension Percent {
256+
func formatted(_ formatter: Formatter<String> = .percent) -> String {
257+
formatter.format(self)
258+
}
259+
260+
func formatted(fractionDigits: Int) -> String {
261+
Formatter(fractionDigits: fractionDigits).format(self)
262+
}
263+
}
264+
265+
@available(macOS 12.0, iOS 15.0, *)
266+
public extension Percent.Formatter where Output == String {
267+
268+
init (fractionDigits: Int) {
269+
self.init { value in
270+
value.magnitude
271+
.formatted(.percent.precision(.fractionLength(fractionDigits)))
272+
}
273+
}
274+
275+
static var percent: Self {
276+
.init { value in
277+
return value.magnitude.formatted(.percent)
278+
}
279+
}
280+
}

Tests/UnitsTests/MeasurementTests.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,4 +600,12 @@ final class MeasurementTests: XCTestCase {
600600
accuracy: accuracy
601601
)
602602
}
603+
604+
func testFormatStyle() {
605+
let measure = 28.123.measured(in: .meter)
606+
XCTAssertEqual(measure.formatted(), "28.123 m")
607+
XCTAssertEqual(measure.formatted(precision: .significantDigits(1)), "30 m")
608+
XCTAssertEqual(measure.formatted(precision: .significantDigits(3)), "28.1 m")
609+
XCTAssertEqual(measure.formatted(precision: .integerAndFractionLength(integer: 2, fraction: 0)), "28 m")
610+
}
603611
}

Tests/UnitsTests/PercentTests.swift

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import XCTest
1010

1111
final class PercentTests: XCTestCase {
12+
1213
func testParse() throws {
1314
XCTAssertEqual(
1415
try Expression("10m + 25%"),
@@ -38,7 +39,20 @@ final class PercentTests: XCTestCase {
3839
try Expression("10m / 25%").solve(),
3940
40.measured(in: .meter)
4041
)
41-
42+
}
43+
44+
func testPercentCalculation() {
45+
XCTAssertEqual(50% * 50%, 25%)
46+
XCTAssertEqual(50% + 5.8%, 55.8%)
47+
XCTAssertEqual(50% - 50%, 0%)
48+
XCTAssertEqual(50% - 5.8%, 44.2%)
49+
}
50+
51+
func testPercentFormat() {
52+
XCTAssertEqual(30%.formatted(), "30%")
53+
XCTAssertEqual(28.5%.formatted(), "28.5%")
54+
XCTAssertEqual(28.33%.formatted(), "28.33%")
55+
XCTAssertEqual(28.33%.formatted(fractionDigits: 1), "28.3%")
56+
XCTAssertEqual(28.33%.formatted(fractionDigits: 0), "28%")
4257
}
4358
}
44-

0 commit comments

Comments
 (0)