├── .editorconfig
├── .gitattributes
├── .github
└── workflows
│ └── main.yml
├── .gitignore
├── .swiftlint.yml
├── .swiftpm
└── xcode
│ ├── package.xcworkspace
│ └── contents.xcworkspacedata
│ └── xcshareddata
│ └── xcschemes
│ └── Percentage.xcscheme
├── Package.swift
├── Sources
└── Percentage
│ └── Percentage.swift
├── Tests
└── PercentageTests
│ └── PercentageTests.swift
├── license
└── readme.md
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = tab
5 | end_of_line = lf
6 | charset = utf-8
7 | trim_trailing_whitespace = true
8 | insert_final_newline = true
9 |
10 | [*.yml]
11 | indent_style = space
12 | indent_size = 2
13 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | * text=auto eol=lf
2 |
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 | on:
3 | - push
4 | - pull_request
5 | jobs:
6 | test:
7 | runs-on: macos-latest
8 | steps:
9 | - uses: actions/checkout@v5
10 | - run: swift test --parallel
11 | lint:
12 | runs-on: ubuntu-latest
13 | steps:
14 | - uses: actions/checkout@v5
15 | - uses: norio-nomura/action-swiftlint@3.2.1
16 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /.build
2 | /Packages
3 | /*.xcodeproj
4 | xcuserdata
5 | /.swiftpm
6 |
--------------------------------------------------------------------------------
/.swiftlint.yml:
--------------------------------------------------------------------------------
1 | only_rules:
2 | - anyobject_protocol
3 | - array_init
4 | - block_based_kvo
5 | - class_delegate_protocol
6 | - closing_brace
7 | - closure_end_indentation
8 | - closure_parameter_position
9 | - closure_spacing
10 | - collection_alignment
11 | - colon
12 | - comma
13 | - compiler_protocol_init
14 | - computed_accessors_order
15 | - conditional_returns_on_newline
16 | - contains_over_filter_count
17 | - contains_over_filter_is_empty
18 | - contains_over_first_not_nil
19 | - contains_over_range_nil_comparison
20 | - control_statement
21 | - custom_rules
22 | - discarded_notification_center_observer
23 | - discouraged_direct_init
24 | - discouraged_object_literal
25 | - discouraged_optional_boolean
26 | - discouraged_optional_collection
27 | - duplicate_enum_cases
28 | - duplicate_imports
29 | - dynamic_inline
30 | - empty_collection_literal
31 | - empty_count
32 | - empty_enum_arguments
33 | - empty_parameters
34 | - empty_parentheses_with_trailing_closure
35 | - empty_string
36 | - empty_xctest_method
37 | - enum_case_associated_values_count
38 | - explicit_init
39 | - fallthrough
40 | - fatal_error_message
41 | - first_where
42 | - flatmap_over_map_reduce
43 | - for_where
44 | - generic_type_name
45 | - ibinspectable_in_extension
46 | - identical_operands
47 | - identifier_name
48 | - implicit_getter
49 | - implicit_return
50 | - inclusive_language
51 | - inert_defer
52 | - is_disjoint
53 | - joined_default_parameter
54 | - last_where
55 | - leading_whitespace
56 | - legacy_cggeometry_functions
57 | - legacy_constant
58 | - legacy_constructor
59 | - legacy_hashing
60 | - legacy_multiple
61 | - legacy_nsgeometry_functions
62 | - legacy_random
63 | - literal_expression_end_indentation
64 | - lower_acl_than_parent
65 | - mark
66 | - modifier_order
67 | - multiline_arguments
68 | - multiline_function_chains
69 | - multiline_literal_brackets
70 | - multiline_parameters
71 | - multiline_parameters_brackets
72 | - nimble_operator
73 | - no_extension_access_modifier
74 | - no_fallthrough_only
75 | - no_space_in_method_call
76 | - notification_center_detachment
77 | - nsobject_prefer_isequal
78 | - number_separator
79 | - opening_brace
80 | - operator_usage_whitespace
81 | - operator_whitespace
82 | - orphaned_doc_comment
83 | - overridden_super_call
84 | - prefer_self_type_over_type_of_self
85 | - prefer_zero_over_explicit_init
86 | - private_action
87 | - private_outlet
88 | - private_unit_test
89 | - prohibited_super_call
90 | - protocol_property_accessors_order
91 | - reduce_boolean
92 | - reduce_into
93 | - redundant_discardable_let
94 | - redundant_nil_coalescing
95 | - redundant_objc_attribute
96 | - redundant_optional_initialization
97 | - redundant_set_access_control
98 | - redundant_string_enum_value
99 | - redundant_type_annotation
100 | - redundant_void_return
101 | - required_enum_case
102 | - return_arrow_whitespace
103 | - shorthand_operator
104 | - sorted_first_last
105 | - statement_position
106 | - static_operator
107 | - strong_iboutlet
108 | - superfluous_disable_command
109 | - switch_case_alignment
110 | - switch_case_on_newline
111 | - syntactic_sugar
112 | - test_case_accessibility
113 | - toggle_bool
114 | - trailing_closure
115 | - trailing_comma
116 | - trailing_newline
117 | - trailing_semicolon
118 | - trailing_whitespace
119 | - unavailable_function
120 | - unneeded_break_in_switch
121 | - unneeded_parentheses_in_closure_argument
122 | - unowned_variable_capture
123 | - untyped_error_in_catch
124 | - unused_capture_list
125 | - unused_closure_parameter
126 | - unused_control_flow_label
127 | - unused_enumerated
128 | - unused_optional_binding
129 | - unused_setter_value
130 | - valid_ibinspectable
131 | - vertical_parameter_alignment
132 | - vertical_parameter_alignment_on_call
133 | - vertical_whitespace_closing_braces
134 | - vertical_whitespace_opening_braces
135 | - void_return
136 | - weak_delegate
137 | - xct_specific_matcher
138 | - xctfail_message
139 | - yoda_condition
140 | analyzer_rules:
141 | - unused_declaration
142 | - unused_import
143 | number_separator:
144 | minimum_length: 5
145 | identifier_name:
146 | max_length:
147 | warning: 100
148 | error: 100
149 | min_length:
150 | warning: 2
151 | error: 2
152 | validates_start_with_lowercase: false
153 | allowed_symbols:
154 | - '_'
155 | excluded:
156 | - 'x'
157 | - 'y'
158 | - 'a'
159 | - 'b'
160 | - 'x1'
161 | - 'x2'
162 | - 'y1'
163 | - 'y2'
164 | custom_rules:
165 | no_nsrect:
166 | regex: '\bNSRect\b'
167 | match_kinds: typeidentifier
168 | message: 'Use CGRect instead of NSRect'
169 | no_nssize:
170 | regex: '\bNSSize\b'
171 | match_kinds: typeidentifier
172 | message: 'Use CGSize instead of NSSize'
173 | no_nspoint:
174 | regex: '\bNSPoint\b'
175 | match_kinds: typeidentifier
176 | message: 'Use CGPoint instead of NSPoint'
177 | swiftui_state_private:
178 | regex: '@(State|StateObject)\s+var'
179 | message: "SwiftUI @State/@StateObject properties should be private"
180 | final_class:
181 | regex: '^class [a-zA-Z\d]+[^{]+\{'
182 | message: "Classes should be marked as final whenever possible. If you actually need it to be subclassable, just add `// swiftlint:disable:next final_class`."
183 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/xcshareddata/xcschemes/Percentage.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
29 |
35 |
36 |
37 |
38 |
39 |
44 |
45 |
47 |
53 |
54 |
55 |
56 |
57 |
67 |
68 |
74 |
75 |
81 |
82 |
83 |
84 |
86 |
87 |
90 |
91 |
92 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:6.0
2 | import PackageDescription
3 |
4 | let package = Package(
5 | name: "Percentage",
6 | products: [
7 | .library(
8 | name: "Percentage",
9 | targets: [
10 | "Percentage"
11 | ]
12 | )
13 | ],
14 | targets: [
15 | .target(
16 | name: "Percentage"
17 | ),
18 | .testTarget(
19 | name: "PercentageTests",
20 | dependencies: [
21 | "Percentage"
22 | ]
23 | )
24 | ]
25 | )
26 |
--------------------------------------------------------------------------------
/Sources/Percentage/Percentage.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | /**
4 | ```
5 | import Percentage
6 |
7 | 10% + 5.5%
8 | //=> 15.5%
9 |
10 | -10% / 2
11 | //=> -5%
12 |
13 | (40% + 93%) * 3
14 | //=> 399%
15 |
16 | 50% * 50%
17 | //=> 25%
18 |
19 | 30% > 25%
20 | //=> true
21 |
22 | 50%.of(200)
23 | //=> 100
24 |
25 | Percentage(50)
26 | //=> 50%
27 |
28 | Percentage(fraction: 0.5)
29 | //=> 50%
30 |
31 | Percentage.from(100, of: 200)
32 | //=> 50%
33 |
34 | Percentage.change(from: 100, to: 150)
35 | //=> 50%
36 |
37 | 50%.fraction
38 | //=> 0.5
39 |
40 | 10%.rawValue
41 | //=> 10
42 |
43 | 50%.isWithinStandardRange
44 | //=> true
45 |
46 | 150%.clamped(to: 0%...100%)
47 | //=> 100%
48 |
49 | 110%.clampedZeroToHundred
50 | //=> 100%
51 |
52 | 100.increased(by: 20%)
53 | //=> 120
54 |
55 | 100.decreased(by: 20%)
56 | //=> 80
57 |
58 | 40%.originalValueBeforeIncrease(finalValue: 120)
59 | //=> 85.71428571428571
60 |
61 | 12%.originalValueBeforeDecrease(finalValue: 106)
62 | //=> 120.45454545454545
63 |
64 | 90%.isPercentOf(67)
65 | //=> 74.44444444444444
66 |
67 | 33.333%.formatted(decimalPlaces: 1)
68 | //=> "33.3%"
69 |
70 | // With locale (macOS 12.0+/iOS 15.0+)
71 | 50%.formatted(decimalPlaces: 1, locale: Locale(languageCode: .french))
72 | //=> "50,0 %"
73 |
74 | print("\(1%)")
75 | //=> "1%"
76 |
77 | Percent.random(in: 10%...20%)
78 | //=> "14.3%"
79 | ```
80 | */
81 | public struct Percentage: Hashable, Codable, Sendable {
82 | /**
83 | The raw percentage number.
84 |
85 | ```
86 | 10%.rawValue
87 | //=> 10
88 | ```
89 | */
90 | public let rawValue: Double
91 |
92 | /**
93 | Get the percentage as a fraction.
94 |
95 | ```
96 | 50%.fraction
97 | //=> 0.5
98 | ```
99 | */
100 | public var fraction: Double { rawValue / 100 }
101 |
102 | /**
103 | Clamp the percentage to a value between 0% and 100%.
104 |
105 | ```
106 | 110%.clampedZeroToHundred
107 | //=> 100%
108 |
109 | -1%.clampedZeroToHundred
110 | //=> 0%
111 |
112 | 60%.clampedZeroToHundred
113 | //=> 60%
114 | ```
115 | */
116 | public var clampedZeroToHundred: Self {
117 | if rawValue > 100 {
118 | 100%
119 | } else if rawValue < 0 {
120 | 0%
121 | } else {
122 | self
123 | }
124 | }
125 |
126 | /**
127 | Check if the percentage is within the standard 0-100% range.
128 |
129 | ```
130 | 50%.isWithinStandardRange
131 | //=> true
132 |
133 | 150%.isWithinStandardRange
134 | //=> false
135 |
136 | (-10%).isWithinStandardRange
137 | //=> false
138 | ```
139 | */
140 | public var isWithinStandardRange: Bool {
141 | rawValue >= 0 && rawValue <= 100
142 | }
143 |
144 | /**
145 | Create a `Percentage` from a `BinaryFloatingPoint`, for example, `Double` or `CGFloat`.
146 |
147 | ```
148 | let cgFloat: CGFloat = 50.5
149 | Percentage(cgFloat)
150 | //=> 50.5%
151 | ```
152 | */
153 | public init(_ percentage: some BinaryFloatingPoint) {
154 | self.init(rawValue: Double(percentage))
155 | }
156 |
157 | /**
158 | Create a `Percentage` from a `BinaryInteger`, for example, `Int`.
159 |
160 | ```
161 | let int = 50
162 | Percentage(int)
163 | //=> 50%
164 | ```
165 | */
166 | public init(_ percentage: some BinaryInteger) {
167 | self.init(rawValue: Double(percentage))
168 | }
169 |
170 | /**
171 | Create a `Percentage` from a fraction.
172 |
173 | ```
174 | Percentage(fraction: 0.5)
175 | //=> "50%"
176 | ```
177 | */
178 | public init(fraction: Double) {
179 | self.init(rawValue: fraction * 100)
180 | }
181 |
182 | /**
183 | Returns how much the percentage of the given integer value is.
184 |
185 | ```
186 | 50%.of(200)
187 | //=> 100
188 | ```
189 | */
190 | public func of(_ value: Value) -> Value {
191 | value * Value(rawValue.rounded()) / 100
192 | }
193 |
194 | /**
195 | Returns how much the percentage of the given integer value is exactly, represented as floating-point.
196 |
197 | ```
198 | 50%.of(201) as Double
199 | //=> 100.5
200 | ```
201 | */
202 | public func of(
203 | _ value: some BinaryInteger
204 | ) -> ReturnValue {
205 | ReturnValue(value) * ReturnValue(rawValue) / 100
206 | }
207 |
208 | /**
209 | Returns how much the percentage of the given floating-point value is.
210 |
211 | ```
212 | 50%.of(250.5)
213 | //=> 125.25
214 | ```
215 | */
216 | public func of(_ value: Value) -> Value {
217 | value * Value(rawValue) / 100
218 | }
219 | }
220 |
221 | extension Percentage {
222 | /**
223 | Returns a random value within the given range.
224 |
225 | ```
226 | Percent.random(in: 10%...20%)
227 | //=> Can be 10%, 11%, 12%, 19.98%, etc.
228 | ```
229 | */
230 | public static func random(in range: ClosedRange) -> Self {
231 | self.init(fraction: .random(in: range.lowerBound.fraction...range.upperBound.fraction))
232 | }
233 |
234 | /**
235 | Create a `Percentage` from a value and a total.
236 |
237 | ```
238 | Percentage.from(100, of: 200)
239 | //=> 50%
240 |
241 | Percentage.from(75, of: 300)
242 | //=> 25%
243 | ```
244 | */
245 | public static func from(
246 | _ value: some BinaryInteger,
247 | of total: some BinaryInteger
248 | ) -> Self {
249 | guard total != 0 else {
250 | return self.init(0)
251 | }
252 |
253 | return self.init(Double(value) / Double(total) * 100)
254 | }
255 |
256 | /**
257 | Create a `Percentage` from a value and a total.
258 |
259 | ```
260 | Percentage.from(50.5, of: 101)
261 | //=> 50%
262 |
263 | Percentage.from(12.5, of: 50.0)
264 | //=> 25%
265 | ```
266 | */
267 | public static func from(
268 | _ value: some BinaryFloatingPoint,
269 | of total: some BinaryFloatingPoint
270 | ) -> Self {
271 | guard total != 0 else {
272 | return self.init(0)
273 | }
274 |
275 | return self.init(Double(value) / Double(total) * 100)
276 | }
277 |
278 | /**
279 | Find the original value before a percentage increase.
280 |
281 | For example, if a value is 120 after a 40% increase, this returns the original value (85.714...).
282 |
283 | ```
284 | 40%.originalValueBeforeIncrease(finalValue: 120)
285 | //=> 85.714...
286 |
287 | 50%.originalValueBeforeIncrease(finalValue: 150)
288 | //=> 100
289 | ```
290 | */
291 | public func originalValueBeforeIncrease(
292 | finalValue: some BinaryFloatingPoint
293 | ) -> Double {
294 | Double(finalValue) / (1 + fraction)
295 | }
296 |
297 | /**
298 | Find the original value before a percentage increase.
299 |
300 | For example, if a value is 120 after a 40% increase, this returns the original value (85.714...).
301 |
302 | ```
303 | 40%.originalValueBeforeIncrease(finalValue: 120)
304 | //=> 85.714...
305 |
306 | 50%.originalValueBeforeIncrease(finalValue: 150)
307 | //=> 100
308 | ```
309 | */
310 | public func originalValueBeforeIncrease(
311 | finalValue: some BinaryInteger
312 | ) -> Double {
313 | Double(finalValue) / (1 + fraction)
314 | }
315 |
316 | /**
317 | Find the original value before a percentage decrease.
318 |
319 | For example, if a value is 106 after a 12% decrease, this returns the original value (120.454...).
320 |
321 | ```
322 | 12%.originalValueBeforeDecrease(finalValue: 106)
323 | //=> 120.454...
324 |
325 | 20%.originalValueBeforeDecrease(finalValue: 80)
326 | //=> 100
327 | ```
328 | */
329 | public func originalValueBeforeDecrease(
330 | finalValue: some BinaryFloatingPoint
331 | ) -> Double {
332 | guard fraction < 1 else {
333 | // Cannot have a decrease of 100% or more
334 | return .infinity
335 | }
336 |
337 | return Double(finalValue) / (1 - fraction)
338 | }
339 |
340 | /**
341 | Find the original value before a percentage decrease.
342 |
343 | For example, if a value is 106 after a 12% decrease, this returns the original value (120.454...).
344 |
345 | ```
346 | 12%.originalValueBeforeDecrease(finalValue: 106)
347 | //=> 120.454...
348 |
349 | 20%.originalValueBeforeDecrease(finalValue: 80)
350 | //=> 100
351 | ```
352 | */
353 | public func originalValueBeforeDecrease(
354 | finalValue: some BinaryInteger
355 | ) -> Double {
356 | guard fraction < 1 else {
357 | // Cannot have a decrease of 100% or more
358 | return .infinity
359 | }
360 |
361 | return Double(finalValue) / (1 - fraction)
362 | }
363 |
364 | /**
365 | Find what value this percentage is of.
366 |
367 | For example, "67 is 90% of what?" returns 74.444...
368 |
369 | ```
370 | 90%.isPercentOf(67)
371 | //=> 74.444...
372 |
373 | 50%.isPercentOf(50)
374 | //=> 100
375 | ```
376 | */
377 | public func isPercentOf(_ value: some BinaryFloatingPoint) -> Double {
378 | guard fraction != 0 else {
379 | return .infinity
380 | }
381 |
382 | return Double(value) / fraction
383 | }
384 |
385 | /**
386 | Find what value this percentage is of.
387 |
388 | For example, "67 is 90% of what?" returns 74.444...
389 |
390 | ```
391 | 90%.isPercentOf(67)
392 | //=> 74.444...
393 |
394 | 50%.isPercentOf(50)
395 | //=> 100
396 | ```
397 | */
398 | public func isPercentOf(_ value: some BinaryInteger) -> Double {
399 | guard fraction != 0 else {
400 | return .infinity
401 | }
402 |
403 | return Double(value) / fraction
404 | }
405 |
406 | /**
407 | Clamp the percentage to a specific range.
408 |
409 | ```
410 | 150%.clamped(to: 0%...100%)
411 | //=> 100%
412 |
413 | (-50%).clamped(to: 0%...100%)
414 | //=> 0%
415 |
416 | 50%.clamped(to: 25%...75%)
417 | //=> 50%
418 | ```
419 | */
420 | public func clamped(to range: ClosedRange) -> Self {
421 | if self < range.lowerBound {
422 | return range.lowerBound
423 | } else if self > range.upperBound {
424 | return range.upperBound
425 | } else {
426 | return self
427 | }
428 | }
429 |
430 | /**
431 | Calculate the percentage change between two values.
432 |
433 | ```
434 | Percentage.change(from: 100, to: 150)
435 | //=> 50%
436 |
437 | Percentage.change(from: 150, to: 100)
438 | //=> -33.33%
439 |
440 | Percentage.change(from: 100, to: 200)
441 | //=> 100%
442 | ```
443 | */
444 | public static func change(
445 | from originalValue: T,
446 | to newValue: T
447 | ) -> Self {
448 | guard originalValue != 0 else {
449 | if newValue == 0 {
450 | return self.init(0)
451 | }
452 |
453 | return self.init(Double.infinity)
454 | }
455 |
456 | let change = Double(Int(newValue) - Int(originalValue)) / Double(originalValue) * 100
457 | return self.init(change)
458 | }
459 |
460 | /**
461 | Calculate the percentage change between two values.
462 |
463 | ```
464 | Percentage.change(from: 100.0, to: 150.0)
465 | //=> 50%
466 |
467 | Percentage.change(from: 50.5, to: 75.75)
468 | //=> 50%
469 | ```
470 | */
471 | public static func change(
472 | from originalValue: some BinaryFloatingPoint,
473 | to newValue: some BinaryFloatingPoint
474 | ) -> Self {
475 | guard originalValue != 0 else {
476 | if newValue == 0 {
477 | return self.init(0)
478 | }
479 | return self.init(Double.infinity)
480 | }
481 |
482 | let change = (Double(newValue) - Double(originalValue)) / Double(originalValue) * 100
483 | return self.init(change)
484 | }
485 |
486 | /**
487 | Format the percentage with a specific number of decimal places.
488 |
489 | ```
490 | 33.333%.formatted(decimalPlaces: 1)
491 | //=> "33.3%"
492 |
493 | 33.333%.formatted(decimalPlaces: 0)
494 | //=> "33%"
495 |
496 | 50%.formatted(decimalPlaces: 2)
497 | //=> "50.00%"
498 |
499 | // With specific locale (macOS 12.0+/iOS 15.0+)
500 | 50.5%.formatted(decimalPlaces: 1, locale: Locale(languageCode: .french))
501 | //=> "50,5 %" (French formatting)
502 | ```
503 | */
504 | public func formatted(decimalPlaces: Int, locale: Locale = .current) -> String {
505 | if #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) {
506 | return fraction.formatted(
507 | .percent
508 | .precision(.fractionLength(decimalPlaces))
509 | .locale(locale)
510 | )
511 | } else {
512 | // Fallback for older OS versions
513 | let formatter = NumberFormatter()
514 | formatter.numberStyle = .percent
515 | formatter.minimumFractionDigits = decimalPlaces
516 | formatter.maximumFractionDigits = decimalPlaces
517 | formatter.locale = locale
518 | return formatter.string(for: fraction) ?? "\(String(format: "%g", rawValue))%"
519 | }
520 | }
521 | }
522 |
523 | @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *)
524 | extension Percentage {
525 | /**
526 | Format the percentage using a FormatStyle.
527 |
528 | ```
529 | 33.333%.formatted(.percent.precision(.fractionLength(1)))
530 | //=> "33.3%"
531 |
532 | 50%.formatted(.percent.precision(.fractionLength(0)))
533 | //=> "50%"
534 | ```
535 | */
536 | public func formatted(
537 | _ style: F
538 | ) -> F.FormatOutput where F.FormatInput == Double {
539 | style.format(fraction)
540 | }
541 | }
542 |
543 | extension BinaryInteger {
544 | /**
545 | Increase the value by a percentage.
546 |
547 | ```
548 | 100.increased(by: 20%)
549 | //=> 120
550 |
551 | 50.increased(by: 100%)
552 | //=> 100
553 | ```
554 | */
555 | public func increased(by percentage: Percentage) -> Self {
556 | self + percentage.of(self)
557 | }
558 |
559 | /**
560 | Decrease the value by a percentage.
561 |
562 | ```
563 | 100.decreased(by: 20%)
564 | //=> 80
565 |
566 | 50.decreased(by: 50%)
567 | //=> 25
568 | ```
569 | */
570 | public func decreased(by percentage: Percentage) -> Self {
571 | self - percentage.of(self)
572 | }
573 | }
574 |
575 | extension BinaryFloatingPoint {
576 | /**
577 | Increase the value by a percentage.
578 |
579 | ```
580 | 100.0.increased(by: 20%)
581 | //=> 120.0
582 |
583 | 50.5.increased(by: 100%)
584 | //=> 101.0
585 | ```
586 | */
587 | public func increased(by percentage: Percentage) -> Self {
588 | self + percentage.of(self)
589 | }
590 |
591 | /**
592 | Decrease the value by a percentage.
593 |
594 | ```
595 | 100.0.decreased(by: 20%)
596 | //=> 80.0
597 |
598 | 50.5.decreased(by: 50%)
599 | //=> 25.25
600 | ```
601 | */
602 | public func decreased(by percentage: Percentage) -> Self {
603 | self - percentage.of(self)
604 | }
605 | }
606 |
607 | extension Percentage: RawRepresentable {
608 | public init(rawValue: Double) {
609 | self.rawValue = rawValue
610 | }
611 | }
612 |
613 | extension Percentage: Comparable {
614 | public static func < (lhs: Self, rhs: Self) -> Bool {
615 | lhs.rawValue < rhs.rawValue
616 | }
617 | }
618 |
619 | extension Percentage: CustomStringConvertible {
620 | public var description: String {
621 | formatted(decimalPlaces: 2)
622 | }
623 | }
624 |
625 | // swiftlint:disable static_operator
626 | prefix operator -
627 |
628 | public prefix func - (percentage: Percentage) -> Percentage {
629 | Percentage(-percentage.rawValue)
630 | }
631 |
632 | postfix operator %
633 |
634 | public postfix func % (value: Double) -> Percentage {
635 | Percentage(value)
636 | }
637 |
638 | public postfix func % (value: Int) -> Percentage {
639 | Percentage(Double(value))
640 | }
641 | // swiftlint:enable static_operator
642 |
643 | extension Percentage: ExpressibleByFloatLiteral {
644 | public init(floatLiteral value: Double) {
645 | self.init(rawValue: value)
646 | }
647 | }
648 |
649 | extension Percentage: ExpressibleByIntegerLiteral {
650 | public init(integerLiteral value: Double) {
651 | self.init(rawValue: value)
652 | }
653 | }
654 |
655 | extension Percentage: Numeric {
656 | public typealias Magnitude = Double.Magnitude
657 |
658 | public static var zero: Self { 0 }
659 |
660 | public static func + (lhs: Self, rhs: Self) -> Self {
661 | self.init(lhs.rawValue + rhs.rawValue)
662 | }
663 |
664 | public static func += (lhs: inout Self, rhs: Self) {
665 | lhs = lhs + rhs
666 | }
667 |
668 | public static func - (lhs: Self, rhs: Self) -> Self {
669 | self.init(lhs.rawValue - rhs.rawValue)
670 | }
671 |
672 | public static func -= (lhs: inout Self, rhs: Self) {
673 | lhs = lhs - rhs
674 | }
675 |
676 | public static func * (lhs: Self, rhs: Self) -> Self {
677 | self.init(fraction: lhs.fraction * rhs.fraction)
678 | }
679 |
680 | public static func *= (lhs: inout Self, rhs: Self) {
681 | lhs = lhs * rhs
682 | }
683 |
684 | public var magnitude: Magnitude { rawValue.magnitude }
685 |
686 | public init?(exactly source: some BinaryInteger) {
687 | guard let value = Double(exactly: source) else {
688 | return nil
689 | }
690 |
691 | self.init(value)
692 | }
693 | }
694 |
695 | extension Percentage {
696 | public static func / (lhs: Self, rhs: Self) -> Self {
697 | self.init(fraction: lhs.fraction / rhs.fraction)
698 | }
699 |
700 | public static func /= (lhs: inout Self, rhs: Self) {
701 | lhs = lhs / rhs
702 | }
703 | }
704 |
--------------------------------------------------------------------------------
/Tests/PercentageTests/PercentageTests.swift:
--------------------------------------------------------------------------------
1 | import Testing
2 | import Foundation
3 | import CoreGraphics
4 | @testable import Percentage
5 |
6 | struct PercentageTests {
7 | @Test
8 | func percentage() {
9 | #expect(10% == 10%) // swiftlint:disable:this identical_operands
10 | #expect(1.1%.rawValue == 1.1)
11 | #expect(10% + 5.5% == 15.5%)
12 | #expect(50% * 50% == 25%)
13 | #expect(50% / 50% == 100%)
14 | #expect(50%.of(200) == 100)
15 | #expect(Percentage(50.5) == 50.5%)
16 | #expect(Percentage(rawValue: 50.5) == 50.5%)
17 |
18 | let int = 50
19 | #expect(Percentage(int) == 50%)
20 |
21 | let int8: Int8 = 50
22 | #expect(Percentage(int8) == 50%)
23 |
24 | let cgFloat: CGFloat = 50.5
25 | #expect(Percentage(cgFloat) == 50.5%)
26 |
27 | let float: Float = 50.5
28 | #expect(Percentage(float) == 50.5%)
29 |
30 | #expect(Percentage(fraction: 0.5) == 50%)
31 | #expect(50%.fraction == 0.5)
32 |
33 | #expect(30% > 25%)
34 | }
35 |
36 | @Test
37 | func clampedZeroToHundred() {
38 | #expect(101%.clampedZeroToHundred == 100%)
39 | #expect((-1%).clampedZeroToHundred == 0%)
40 | #expect(40%.clampedZeroToHundred == 40%)
41 | }
42 |
43 | @Test
44 | func percentageOf() {
45 | #expect(50%.of(200) == 100)
46 | #expect(50%.of(201) == 100)
47 | #expect(50%.of(201) as Double == 100.5)
48 | #expect(50%.of(250.5) == 125.25)
49 | #expect(25%.of(Float64(200)) == Float64(50))
50 | }
51 |
52 | @Test
53 | func arithmetics() {
54 | #expect(1% + 1% == 2%)
55 | #expect(1% - 1% == 0%)
56 | #expect(20% / 10% == 200%)
57 | }
58 |
59 | @Test
60 | func arithmeticsDouble() {
61 | #expect(1% + 1 == 2%)
62 | #expect(1% - 1 == 0%)
63 | }
64 |
65 | @Test
66 | func arithmeticsMutating() {
67 | var plus = 1%
68 | plus += 1%
69 | #expect(plus == 2%)
70 |
71 | var minus = 1%
72 | minus -= 1%
73 | #expect(minus == 0%)
74 |
75 | var divide = 20%
76 | divide /= 10%
77 | #expect(divide == 200%)
78 | }
79 |
80 | @Test
81 | func arithmeticsMutatingDouble() {
82 | var plus = 1%
83 | plus += 1
84 | #expect(plus == 2%)
85 |
86 | var minus = 1%
87 | minus -= 1
88 | #expect(minus == 0%)
89 |
90 | var divide = 20%
91 | divide /= 10
92 | #expect(divide == 200%)
93 | }
94 |
95 | @Test
96 | func codable() {
97 | struct Foo: Codable {
98 | let alpha: Percentage
99 | }
100 |
101 | let foo = Foo(alpha: 1%)
102 | let data = try! JSONEncoder().encode(foo)
103 | let string = String(data: data, encoding: .utf8)!
104 |
105 | #expect(string == "{\"alpha\":1}")
106 | }
107 |
108 | @Test
109 | func random() {
110 | let range = 10%...20%
111 | let random = Percentage.random(in: range)
112 | #expect(range.contains(random))
113 | }
114 |
115 | @Test
116 | func percentageFrom() {
117 | // Test: Percent.from(100, of: 200) //=> 50%
118 | #expect(Percentage.from(100, of: 200) == 50%)
119 | #expect(Percentage.from(50, of: 100) == 50%)
120 | #expect(Percentage.from(25, of: 50) == 50%)
121 | #expect(Percentage.from(75, of: 300) == 25%)
122 | #expect(Percentage.from(0, of: 100) == 0%)
123 | #expect(Percentage.from(100, of: 100) == 100%)
124 | #expect(Percentage.from(150, of: 100) == 150%)
125 |
126 | // Test with floating point values
127 | #expect(Percentage.from(50.5, of: 101) == 50%)
128 | #expect(Percentage.from(12.5, of: 50.0) == 25%)
129 | }
130 |
131 | @Test
132 | func originalValueBeforeIncrease() {
133 | // If a value is 120 after a 40% increase, what is the original value?
134 | // Formula: original = final / (1 + percentage)
135 | // 120 / 1.4 = 85.71428...
136 | #expect(abs(40%.originalValueBeforeIncrease(finalValue: 120) - 85.71428571428571) < 0.00001)
137 | #expect(abs(50%.originalValueBeforeIncrease(finalValue: 150) - 100.0) < 0.00001)
138 | #expect(abs(100%.originalValueBeforeIncrease(finalValue: 200) - 100.0) < 0.00001)
139 | #expect(abs(0%.originalValueBeforeIncrease(finalValue: 100) - 100.0) < 0.00001)
140 | #expect(abs(25%.originalValueBeforeIncrease(finalValue: 125) - 100.0) < 0.00001)
141 | }
142 |
143 | @Test
144 | func originalValueBeforeDecrease() {
145 | // If a value is 106 after a 12% decrease, what is the original value?
146 | // Formula: original = final / (1 - percentage)
147 | // 106 / 0.88 = 120.45454...
148 | #expect(abs(12%.originalValueBeforeDecrease(finalValue: 106) - 120.45454545454545) < 0.00001)
149 | #expect(abs(50%.originalValueBeforeDecrease(finalValue: 50) - 100.0) < 0.00001)
150 | #expect(abs(20%.originalValueBeforeDecrease(finalValue: 80) - 100.0) < 0.00001)
151 | #expect(abs(0%.originalValueBeforeDecrease(finalValue: 100) - 100.0) < 0.00001)
152 | #expect(abs(25%.originalValueBeforeDecrease(finalValue: 75) - 100.0) < 0.00001)
153 | }
154 |
155 | @Test
156 | func isPercentOf() {
157 | // "x IS y% OF what?" - 67 is 90% of what?
158 | // Formula: result = value / percentage
159 | // 67 / 0.9 = 74.44444...
160 | #expect(abs(90%.isPercentOf(67) - 74.44444444444444) < 0.00001)
161 | #expect(abs(50%.isPercentOf(50) - 100.0) < 0.00001)
162 | #expect(abs(100%.isPercentOf(100) - 100.0) < 0.00001)
163 | #expect(abs(25%.isPercentOf(25) - 100.0) < 0.00001)
164 | #expect(abs(200%.isPercentOf(200) - 100.0) < 0.00001)
165 | }
166 |
167 | @Test
168 | func isWithinStandardRange() {
169 | #expect(0%.isWithinStandardRange)
170 | #expect(50%.isWithinStandardRange)
171 | #expect(100%.isWithinStandardRange)
172 | #expect(!(-1%).isWithinStandardRange)
173 | #expect(!101%.isWithinStandardRange)
174 | #expect(!150%.isWithinStandardRange)
175 | #expect(!(-50%).isWithinStandardRange)
176 | }
177 |
178 | @Test
179 | func clampedToRange() {
180 | #expect(50%.clamped(to: 0%...100%) == 50%)
181 | #expect(150%.clamped(to: 0%...100%) == 100%)
182 | #expect((-50%).clamped(to: 0%...100%) == 0%)
183 | #expect(75%.clamped(to: 0%...100%) == 75%)
184 |
185 | // Custom ranges
186 | #expect(50%.clamped(to: 25%...75%) == 50%)
187 | #expect(20%.clamped(to: 25%...75%) == 25%)
188 | #expect(80%.clamped(to: 25%...75%) == 75%)
189 | #expect((-10%).clamped(to: (-20%)...20%) == -10%)
190 | }
191 |
192 | @Test
193 | func percentageChange() {
194 | // Percentage.change(from: 100, to: 150) //=> 50%
195 | #expect(Percentage.change(from: 100, to: 150) == 50%)
196 | #expect(abs(Percentage.change(from: 150, to: 100).rawValue - (-33.333333333333336)) < 0.00001)
197 | #expect(Percentage.change(from: 100, to: 100) == 0%)
198 | #expect(Percentage.change(from: 50, to: 75) == 50%)
199 | #expect(Percentage.change(from: 200, to: 100) == -50%)
200 | #expect(Percentage.change(from: 100, to: 0) == -100%)
201 | #expect(Percentage.change(from: 100, to: 200) == 100%)
202 |
203 | // Floating point values
204 | #expect(Percentage.change(from: 100.0, to: 150.0) == 50%)
205 | #expect(Percentage.change(from: 50.5, to: 75.75) == 50%)
206 |
207 | // Edge case: from 0
208 | #expect(Percentage.change(from: 0, to: 100).rawValue.isInfinite)
209 | #expect(Percentage.change(from: 0, to: 0) == 0%)
210 | }
211 |
212 | @Test(.disabled("Infinite recursion in Swift Testing framework"))
213 | func
214 | numericExtensionsInteger() {
215 | // Integer types - using explicit Int type to avoid ambiguity with Percentage literals
216 | let hundred: Int = 100
217 | let twentyPercent = Percentage(20)
218 | #expect(hundred.increased(by: twentyPercent) == 120)
219 | #expect(hundred.decreased(by: twentyPercent) == 80)
220 |
221 | let fifty: Int = 50
222 | let oneHundredPercent = Percentage(100)
223 | let fiftyPercent = Percentage(50)
224 | #expect(fifty.increased(by: oneHundredPercent) == 100)
225 | #expect(fifty.decreased(by: fiftyPercent) == 25)
226 |
227 | let twoHundred: Int = 200
228 | let zeroPercent = Percentage(0)
229 | #expect(twoHundred.increased(by: zeroPercent) == 200)
230 | #expect(twoHundred.decreased(by: zeroPercent) == 200)
231 |
232 | // Negative percentages
233 | #expect(hundred.increased(by: Percentage(-20)) == 80)
234 | #expect(hundred.decreased(by: Percentage(-20)) == 120)
235 |
236 | // Large percentages
237 | #expect(hundred.increased(by: Percentage(200)) == 300)
238 | #expect(hundred.decreased(by: Percentage(100)) == 0)
239 |
240 | // Test with Int8, Int16, etc.
241 | let int8: Int8 = 50
242 | #expect(int8.increased(by: Percentage(20)) == 60)
243 | #expect(int8.decreased(by: Percentage(20)) == 40)
244 |
245 | let int16: Int16 = 100
246 | #expect(int16.increased(by: Percentage(50)) == 150)
247 | #expect(int16.decreased(by: Percentage(25)) == 75)
248 | }
249 |
250 | @Test
251 | func numericExtensionsFloatingPoint() {
252 | // Floating point types
253 | #expect(abs(100.0.increased(by: 20%) - 120.0) < 0.00001)
254 | #expect(abs(100.0.decreased(by: 20%) - 80.0) < 0.00001)
255 | #expect(abs(50.5.increased(by: 100%) - 101.0) < 0.00001)
256 | #expect(abs(50.5.decreased(by: 50%) - 25.25) < 0.00001)
257 |
258 | // CGFloat
259 | let cgFloat: CGFloat = 100.0
260 | #expect(abs(cgFloat.increased(by: 25%) - 125.0) < 0.00001)
261 | #expect(abs(cgFloat.decreased(by: 10%) - 90.0) < 0.00001)
262 |
263 | // Float
264 | let float: Float = 200.0
265 | #expect(abs(Float(float.increased(by: 15%)) - 230.0) < 0.00001)
266 | #expect(abs(Float(float.decreased(by: 30%)) - 140.0) < 0.00001)
267 | }
268 |
269 | @Test
270 | func formatting() {
271 | if #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) {
272 | // Basic formatting - use US locale for consistent testing
273 | let usLocale = Locale(identifier: "en_US")
274 | #expect(33.333%.formatted(decimalPlaces: 1, locale: usLocale) == "33.3%")
275 | #expect(33.333%.formatted(decimalPlaces: 0, locale: usLocale) == "33%")
276 | #expect(33.333%.formatted(decimalPlaces: 2, locale: usLocale) == "33.33%")
277 | #expect(33.336%.formatted(decimalPlaces: 2, locale: usLocale) == "33.34%")
278 | #expect(50%.formatted(decimalPlaces: 0, locale: usLocale) == "50%")
279 | #expect(50%.formatted(decimalPlaces: 2, locale: usLocale) == "50.00%")
280 |
281 | // Negative percentages
282 | #expect((-33.333%).formatted(decimalPlaces: 1, locale: usLocale) == "-33.3%")
283 | #expect((-50%).formatted(decimalPlaces: 0, locale: usLocale) == "-50%")
284 |
285 | // Large percentages - note: modern formatter uses grouping separator
286 | #expect(150%.formatted(decimalPlaces: 0, locale: usLocale) == "150%")
287 | #expect(1234.567%.formatted(decimalPlaces: 1, locale: usLocale) == "1,234.6%")
288 |
289 | // Edge cases
290 | #expect(0%.formatted(decimalPlaces: 0, locale: usLocale) == "0%")
291 | #expect(0.004%.formatted(decimalPlaces: 2, locale: usLocale) == "0.00%")
292 | #expect(0.005%.formatted(decimalPlaces: 2, locale: usLocale) == "0.00%") // Rounding: 0.005% = 0.00005 as fraction, rounds down
293 |
294 | // Test that it respects locale
295 | let frLocale = Locale(identifier: "fr_FR")
296 | let formattedFr = 50.5%.formatted(decimalPlaces: 1, locale: frLocale)
297 | #expect(formattedFr.contains("50")) // French formatting may differ
298 |
299 | // Test default locale (current)
300 | let defaultFormatted = 50%.formatted(decimalPlaces: 0)
301 | #expect(!defaultFormatted.isEmpty)
302 | } else {
303 | // Test legacy formatting for older OS versions
304 | let usLocale = Locale(identifier: "en_US")
305 | #expect(33.333%.formatted(decimalPlaces: 1, locale: usLocale) == "33.3%")
306 | #expect(50%.formatted(decimalPlaces: 0, locale: usLocale) == "50%")
307 | }
308 | }
309 |
310 | @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *)
311 | @Test
312 | func formattedWithFormatStyle() {
313 | // Test with FormatStyle using modern locale API
314 | let usLocale = Locale(identifier: "en_US")
315 |
316 | let percent1 = 33.333%
317 | let formatted1 = percent1.formatted(.percent.precision(.fractionLength(1)).locale(usLocale))
318 | #expect(formatted1 == "33.3%")
319 |
320 | let percent2 = 50%
321 | let formatted2 = percent2.formatted(.percent.precision(.fractionLength(0)).locale(usLocale))
322 | #expect(formatted2 == "50%")
323 |
324 | let percent3 = 0.5%
325 | let formatted3 = percent3.formatted(.percent.precision(.fractionLength(2)).locale(usLocale))
326 | #expect(formatted3 == "0.50%")
327 |
328 | // Test locale-specific formatting
329 | let frLocale = Locale(identifier: "fr_FR")
330 | let percent4 = 50%
331 | let formatted4 = percent4.formatted(.percent.locale(frLocale))
332 | #expect(formatted4.contains("50")) // French uses different formatting
333 |
334 | // Test using the formatted extension method directly
335 | let percent5 = 75.5%
336 | let formatted5 = percent5.formatted(.percent.precision(.fractionLength(1)))
337 | #expect(!formatted5.isEmpty)
338 | }
339 |
340 | // MARK: - Additional comprehensive tests
341 |
342 | @Test
343 | func originalValueBeforeIncreaseWithIntegers() {
344 | // Test BinaryInteger overload
345 | #expect(abs(40%.originalValueBeforeIncrease(finalValue: 140) - 100.0) < 0.00001)
346 | #expect(abs(25%.originalValueBeforeIncrease(finalValue: 125) - 100.0) < 0.00001)
347 | #expect(abs(100%.originalValueBeforeIncrease(finalValue: 200) - 100.0) < 0.00001)
348 | }
349 |
350 | @Test
351 | func originalValueBeforeDecreaseWithIntegers() {
352 | // Test BinaryInteger overload
353 | #expect(abs(20%.originalValueBeforeDecrease(finalValue: 80) - 100.0) < 0.00001)
354 | #expect(abs(50%.originalValueBeforeDecrease(finalValue: 50) - 100.0) < 0.00001)
355 | #expect(abs(25%.originalValueBeforeDecrease(finalValue: 75) - 100.0) < 0.00001)
356 | }
357 |
358 | @Test
359 | func isPercentOfWithIntegers() {
360 | // Test BinaryInteger overload
361 | #expect(abs(50%.isPercentOf(50) - 100.0) < 0.00001)
362 | #expect(abs(25%.isPercentOf(25) - 100.0) < 0.00001)
363 | #expect(abs(90%.isPercentOf(90) - 100.0) < 0.00001)
364 | }
365 |
366 | @Test
367 | func percentageFromFloatingPoint() {
368 | // Test BinaryFloatingPoint overload
369 | #expect(Percentage.from(50.5, of: 101.0) == 50%)
370 | #expect(Percentage.from(25.25, of: 101.0) == 25%)
371 | #expect(abs(Percentage.from(33.333, of: 99.999).rawValue - 33.333333333333336) < 0.00001)
372 | }
373 |
374 | @Test
375 | func percentageChangeWithIntegers() {
376 | // Test BinaryInteger overload specifically
377 | #expect(Percentage.change(from: 100, to: 150) == 50%)
378 | #expect(Percentage.change(from: 50, to: 75) == 50%)
379 | #expect(Percentage.change(from: 200, to: 100) == -50%)
380 | }
381 |
382 | @Test
383 | func description() {
384 | // Test the description property
385 | let fifty = 50%
386 | let description = fifty.description
387 | #expect(description.contains("50"))
388 | #expect(description.contains("%"))
389 |
390 | let decimal = 33.5%
391 | let decimalDescription = decimal.description
392 | #expect(decimalDescription.contains("33"))
393 | #expect(decimalDescription.contains("%"))
394 | }
395 |
396 | @Test
397 | func comparableProtocol() {
398 | // Test Comparable conformance thoroughly
399 | #expect(10% < 20%)
400 | #expect(50% > 25%)
401 | #expect(100% >= 100%) // swiftlint:disable:this identical_operands
402 | #expect(0% <= 0%) // swiftlint:disable:this identical_operands
403 | #expect(!(50% < 25%))
404 | #expect(!(25% > 50%))
405 |
406 | // Test with negative percentages
407 | #expect((-10%) < 10%)
408 | #expect(10% > (-10%))
409 | #expect((-50%) < (-25%))
410 | }
411 |
412 | @Test
413 | func arithmeticOperators() {
414 | // Test all arithmetic operators comprehensively
415 | #expect(50% + 25% == 75%)
416 | #expect(75% - 25% == 50%)
417 | #expect(50% * 2% == 1%) // 50% * 2% = 1% (0.5 * 0.02 = 0.01)
418 | #expect(100% / 50% == 200%) // 100% / 50% = 2 (1.0 / 0.5 = 2.0)
419 |
420 | // Test with zero
421 | #expect(50% + 0% == 50%)
422 | #expect(50% - 0% == 50%)
423 | #expect(50% * 0% == 0%)
424 |
425 | // Test negative arithmetic
426 | #expect(50% + (-25%) == 25%)
427 | #expect(50% - (-25%) == 75%)
428 | }
429 |
430 | @Test
431 | func edgeCases() {
432 | // Test edge cases and boundary conditions
433 |
434 | // Very large percentages
435 | let large = Percentage(1_000_000)
436 | #expect(large.rawValue == 1_000_000)
437 |
438 | // Very small percentages
439 | let tiny = Percentage(0.0001)
440 | #expect(tiny.rawValue == 0.0001)
441 |
442 | // Negative percentages
443 | let negative = Percentage(-50)
444 | #expect(negative.rawValue == -50)
445 | #expect(!negative.isWithinStandardRange)
446 |
447 | // Test percentage of zero
448 | #expect(50%.of(0) == 0)
449 | #expect(100%.of(0) == 0)
450 |
451 | // Test zero percentage
452 | let zero = Percentage(0)
453 | #expect(zero.of(100) == 0)
454 | #expect(zero.of(250.5) == 0.0)
455 | }
456 |
457 | @Test
458 | func fractionProperty() {
459 | // Test fraction property specifically
460 | #expect(0%.fraction == 0.0)
461 | #expect(50%.fraction == 0.5)
462 | #expect(100%.fraction == 1.0)
463 | #expect(200%.fraction == 2.0)
464 | #expect((-50%).fraction == -0.5)
465 |
466 | // Test with decimal percentages
467 | #expect(25.5%.fraction == 0.255)
468 | #expect(abs(33.333%.fraction - 0.33333) < 0.00001)
469 | }
470 |
471 | @Test
472 | func protocolConformances() {
473 | // Test Hashable conformance
474 | let p1 = 50%
475 | let p2 = 50%
476 | let p3 = 75%
477 |
478 | #expect(p1.hashValue == p2.hashValue)
479 | #expect(p1.hashValue != p3.hashValue)
480 |
481 | // Test that equal percentages are hashable-equal
482 | let set: Set = [p1, p2, p3]
483 | #expect(set.count == 2) // p1 and p2 should be considered equal
484 | }
485 |
486 | @Test
487 | func literalConformances() {
488 | // Test ExpressibleByIntegerLiteral
489 | let intLiteral: Percentage = 50
490 | #expect(intLiteral.rawValue == 50)
491 |
492 | // Test ExpressibleByFloatLiteral
493 | let floatLiteral: Percentage = 50.5
494 | #expect(floatLiteral.rawValue == 50.5)
495 |
496 | // Test that percentage literals work
497 | let percentLiteral = 50%
498 | #expect(percentLiteral.rawValue == 50)
499 | }
500 |
501 | @Test
502 | func numericProtocolConformance() {
503 | // Test Numeric protocol conformance
504 | #expect(Percentage.zero == 0%)
505 |
506 | var mutable = 50%
507 | mutable += 25%
508 | #expect(mutable == 75%)
509 |
510 | mutable -= 25%
511 | #expect(mutable == 50%)
512 |
513 | mutable *= 2%
514 | #expect(mutable == 1%) // 50% * 2% = 1% (0.5 * 0.02 = 0.01)
515 |
516 | // Test magnitude
517 | #expect(50%.magnitude == 50.0)
518 | #expect((-50%).magnitude == 50.0)
519 | }
520 | }
521 |
--------------------------------------------------------------------------------
/license:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) Sindre Sorhus (https://sindresorhus.com)
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6 |
7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8 |
9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
10 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | # Percentage
2 |
3 | > A percentage type for Swift
4 |
5 | Makes percentages more readable and type-safe, for example, for APIs that currently accept a fraction `Double`.
6 |
7 | ```diff
8 | -.opacity(0.45)
9 | +.opacity(45%)
10 | ```
11 |
12 | ## Install
13 |
14 | Add the following to `Package.swift`:
15 |
16 | ```swift
17 | .package(url: "https://github.com/sindresorhus/Percentage", from: "2.0.0")
18 | ```
19 |
20 | [Or add the package in Xcode.](https://developer.apple.com/documentation/xcode/adding_package_dependencies_to_your_app)
21 |
22 | ## Usage
23 |
24 | See the [source](Sources/Percentage/Percentage.swift) for docs.
25 |
26 | ```swift
27 | import Percentage
28 |
29 | 10% + 5.5%
30 | //=> 15.5%
31 |
32 | -10% / 2
33 | //=> -5%
34 |
35 | (40% + 93%) * 3
36 | //=> 399%
37 |
38 | 50% * 50%
39 | //=> 25%
40 |
41 | 30% > 25%
42 | //=> true
43 |
44 | 50%.of(200)
45 | //=> 100
46 |
47 | Percentage(50)
48 | //=> 50%
49 |
50 | Percentage(fraction: 0.5)
51 | //=> 50%
52 |
53 | Percentage.from(100, of: 200)
54 | //=> 50%
55 |
56 | Percentage.change(from: 100, to: 150)
57 | //=> 50%
58 |
59 | 50%.fraction
60 | //=> 0.5
61 |
62 | 10%.rawValue
63 | //=> 10
64 |
65 | 50%.isWithinStandardRange
66 | //=> true
67 |
68 | 150%.clamped(to: 0%...100%)
69 | //=> 100%
70 |
71 | 110%.clampedZeroToHundred
72 | //=> 100%
73 |
74 | 100.increased(by: 20%)
75 | //=> 120
76 |
77 | 100.decreased(by: 20%)
78 | //=> 80
79 |
80 | 40%.originalValueBeforeIncrease(finalValue: 120)
81 | //=> 85.71428571428571
82 |
83 | 12%.originalValueBeforeDecrease(finalValue: 106)
84 | //=> 120.45454545454545
85 |
86 | 90%.isPercentOf(67)
87 | //=> 74.44444444444444
88 |
89 | 33.333%.formatted(decimalPlaces: 1)
90 | //=> "33.3%"
91 |
92 | // With locale (macOS 12.0+/iOS 15.0+)
93 | 50%.formatted(decimalPlaces: 1, locale: Locale(languageCode: .french))
94 | //=> "50,0 %"
95 |
96 | print("\(1%)")
97 | //=> "1%"
98 |
99 | Percent.random(in: 10%...20%)
100 | //=> "14.3%"
101 | ```
102 |
103 | The type conforms to `Hashable`, `Codable`, `RawRepresentable`, `Comparable`, `ExpressibleByFloatLiteral`, `ExpressibleByIntegerLiteral`, `Numeric`, `Sendable`, and supports all the arithmetic operators.
104 |
105 | ### Codable
106 |
107 | The percentage value is encoded as a single value:
108 |
109 | ```swift
110 | struct Foo: Codable {
111 | let alpha: Percentage
112 | }
113 |
114 | let foo = Foo(alpha: 1%)
115 | let data = try! JSONEncoder().encode(foo)
116 | let string = String(data: data, encoding: .utf8)!
117 |
118 | print(string)
119 | //=> "{\"alpha\":1}"
120 | ```
121 |
122 | ## FAQ
123 |
124 | #### Can you support Carthage and CocoaPods?
125 |
126 | No, but you can still use Swift Package Manager for this package even though you mainly use Carthage or CocoaPods.
127 |
128 | ## Related
129 |
130 | - [Defaults](https://github.com/sindresorhus/Defaults) - Swifty and modern UserDefaults
131 | - [KeyboardShortcuts](https://github.com/sindresorhus/KeyboardShortcuts) - Add user-customizable global keyboard shortcuts to your macOS app
132 | - [LaunchAtLogin](https://github.com/sindresorhus/LaunchAtLogin) - Add "Launch at Login" functionality to your macOS app
133 | - [More…](https://github.com/search?q=user%3Asindresorhus+language%3Aswift+archived%3Afalse&type=repositories)
134 |
--------------------------------------------------------------------------------