-
Notifications
You must be signed in to change notification settings - Fork 2
Measurement FormatStyle (#1) #22
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,157 @@ | ||
| // | ||
| // Inventory.swift | ||
| // Units | ||
| // | ||
| // Created by Jason Jobe on 1/28/26. | ||
| // | ||
| import Foundation | ||
|
|
||
| // MARK: SKU for Inventory | ||
| public typealias SKU = URL | ||
| public typealias Stock = Quantum | ||
|
|
||
| public extension QuantumType { | ||
| var sku: SKU { URL(sku: "\(self)") } | ||
| } | ||
|
|
||
| // MARK: Inventory - a collection of Quamtum | ||
| public struct Inventory { | ||
| public private(set) var items: [Stock] | ||
| public var count: Int { items.count } | ||
| } | ||
|
|
||
| public extension Inventory { | ||
|
|
||
| func adding(_ q: Quantum) -> Self { | ||
| var newSelf = self | ||
| newSelf.add(q) | ||
| return newSelf | ||
| } | ||
|
|
||
| func subtracting(_ q: Quantum) -> Self { | ||
| var newSelf = self | ||
| newSelf.subtract(q) | ||
| return newSelf | ||
| } | ||
|
|
||
| mutating func subtract(_ q: Quantum) { | ||
| if let ndx = items.firstIndex(where: { $0.qtype == q.qtype }) { | ||
| items[ndx].magnitude -= q.magnitude | ||
| if items[ndx].magnitude == 0 { | ||
| items.remove(at: ndx) | ||
| } | ||
| } else { | ||
| items.append(q) | ||
| } | ||
| } | ||
|
|
||
| mutating func add(_ q: Quantum) { | ||
| if let ndx = items.firstIndex(where: { $0.qtype == q.qtype }) { | ||
| items[ndx].magnitude += q.magnitude | ||
| if items[ndx].magnitude == 0 { | ||
| items.remove(at: ndx) | ||
| } | ||
| } else { | ||
| items.append(q) | ||
| } | ||
| } | ||
|
|
||
| func amount(of qt: QuantumType) -> Double { | ||
| items.reduce(0) { partial, elem in partial + (elem.qtype == qt ? elem.magnitude : 0) } | ||
| } | ||
| } | ||
|
|
||
| public extension Inventory { | ||
| static func += (lhs: inout Self, rhs: Quantum) { | ||
| lhs.add(rhs) | ||
| } | ||
| static func -= (lhs: inout Self, rhs: Quantum) { | ||
| lhs.subtract(rhs) | ||
| } | ||
| } | ||
|
|
||
| public extension Inventory { | ||
| static func += (lhs: inout Self, rhs: Inventory) { | ||
| rhs.items.forEach { lhs.add($0) } | ||
| } | ||
|
|
||
| static func -= (lhs: inout Self, rhs: Inventory) { | ||
| rhs.items.forEach { lhs.subtract($0) } | ||
| } | ||
|
|
||
| static func *= (lhs: inout Self, rhs: Double) { | ||
| for i in lhs.items.indices { | ||
| lhs.items[i].magnitude *= rhs | ||
| } | ||
| } | ||
|
|
||
| static func /= (lhs: inout Self, rhs: Double) { | ||
| for i in lhs.items.indices { | ||
| lhs.items[i].magnitude /= rhs | ||
| } | ||
| } | ||
|
|
||
| static func + (lhs: Self, rhs: Inventory) -> Inventory { | ||
| var result = lhs | ||
| for element in rhs.items { | ||
| result.add(element) | ||
| } | ||
| return result | ||
| } | ||
| } | ||
|
|
||
| // MARK: SKU Extenstion to URL | ||
| extension URL { | ||
| /** | ||
| A SKU URL has the scheme 'sku' and has the general format of | ||
| sku:<host>/<id>:=material[25units] | ||
| Guarenteed to create something | ||
| */ | ||
| public init(sku: String) { | ||
| self = Self.parse(sku: sku) | ||
| } | ||
|
|
||
| public static func parse(sku: String) -> URL { | ||
| if sku.lowercased().hasPrefix("sku:") { | ||
| if let url = URL(string: sku) { | ||
| return url | ||
| } | ||
| } | ||
|
|
||
| // Ensure we always create a valid URL with the `sku` scheme. | ||
| // We build components and percent-encode the path so arbitrary SKU tokens are safe. | ||
| var comps = URLComponents() | ||
| comps.scheme = "sku" | ||
|
|
||
| // Treat the whole string as the URL path | ||
| // Valid URL MUST have single leading slash | ||
| let rawPath = sku.hasPrefix("/") ? sku : "/" + sku | ||
| // URLComponents expects path to be unescaped | ||
| // it will handle encoding when producing .url | ||
| comps.path = rawPath | ||
|
|
||
| if let url = comps.url { | ||
| return url | ||
| } | ||
|
|
||
| // Absolute fallback: construct from a minimally encoded string. | ||
| // Replace spaces with %20 and remove illegal characters conservatively. | ||
| let allowed = CharacterSet.urlPathAllowed.union(CharacterSet(charactersIn: "/")) | ||
| let encodedPath = rawPath.unicodeScalars.map { allowed.contains($0) ? String($0) : String(format: "%%%02X", $0.value) }.joined() | ||
| return URL(string: "sku:\\" + encodedPath) | ||
| ?? URL(string: "sku:/UNKOWN")! | ||
| } | ||
| } | ||
|
|
||
| #if PLAY_TIME | ||
| import Playgrounds | ||
| #Playground { | ||
| if let sku = URL(string: "sku:joe@wildthink.com/role[3]:=12345") | ||
| { | ||
| print("SKU:", sku) | ||
| print("SKU.path:", sku.path) | ||
| } else { | ||
| print("BAD") | ||
| } | ||
| } | ||
| #endif |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,105 @@ | ||
| public extension Measurement { | ||
| struct Formatter<Output> { | ||
| let format: (Measurement) -> Output | ||
| } | ||
|
|
||
| func formatted<Output>(_ formatter: Formatter<Output>) -> Output { | ||
| formatter.format(self) | ||
| } | ||
|
|
||
| func formatted(_ formatter: Formatter<String> = .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 | ||
| // Implementation | ||
| import Foundation | ||
| extension NumberFormatter { | ||
| func string(from measurement: Measurement) -> String { | ||
| return "\(self.string(from: .init(value: measurement.value)) ?? "BAD") \(measurement.unit.symbol)" | ||
| } | ||
| } | ||
|
|
||
| // Usage | ||
| //let measurement = 28.123.measured(in: .meter) | ||
| //let formatter = NumberFormatter() | ||
| //formatter.maximumFractionDigits = 2 | ||
| //print(formatter.string(from: measurement)) // Prints `28.12 m` | ||
|
|
||
| public extension Percent { | ||
| struct Formatter<Output> { | ||
| let format: (Percent) -> Output | ||
| } | ||
|
|
||
| func formatted<Output>(_ formatter: Formatter<Output>) -> 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 | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
| } | ||
| } | ||
|
Comment on lines
+68
to
+74
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why change Equatable implementation to nearly equal? Is this related to the tests that were added?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The significant digits of a "percent" is usually small so representing them with a Double in the arithmetic can lead to values that might result in something like 37.989488484% leading to comparisons that break our intuition (like == 38%). This felt like a reasonable compromise. |
||
|
|
||
| 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 { | ||
| self.init(magnitude: .random(in: range.lowerBound.magnitude...range.upperBound.magnitude)) | ||
| } | ||
| } | ||
|
Comment on lines
+219
to
+231
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: Is
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In one sense, it is just syntactic sugar but it makes the intent clearer and more succinct and encapsulates the internals reducing developer cognitive load. |
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What do you think about using Swift's built-in
NumberFormatterinstead of building our own format system? Example:This seems like this approach would inherit very configurable options, while also simplifying the implementation and usage. Thoughts?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I was leaning into the newer Format API. The custom implementation is limiting. I'll work on that.