├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .spi.yml ├── LICENSE ├── Package.resolved ├── Package.swift ├── PeriodDuration.xcworkspace ├── contents.xcworkspacedata └── xcshareddata │ ├── IDEWorkspaceChecks.plist │ ├── swiftpm │ └── Package.resolved │ └── xcschemes │ ├── Benchmarks.xcscheme │ └── PeriodDuration.xcscheme ├── README.md ├── Sources ├── Benchmarks │ └── main.swift └── PeriodDuration │ ├── Duration │ ├── Duration+Codable.swift │ ├── Duration+Description.swift │ ├── Duration+ExpressibleByStringLiteral.swift │ ├── Duration+Formatting.swift │ ├── Duration+FoundationSupport.swift │ ├── Duration+ISO8601.swift │ ├── Duration+Signs.swift │ ├── Duration+Zero.swift │ └── Duration.swift │ ├── Period │ ├── Period+Codable.swift │ ├── Period+Description.swift │ ├── Period+ExpressibleByStringLiteral.swift │ ├── Period+Formatting.swift │ ├── Period+FoundationSupport.swift │ ├── Period+ISO8601.swift │ ├── Period+Signs.swift │ ├── Period+Zero.swift │ └── Period.swift │ ├── PeriodDuration │ ├── PeriodDuration+Codable.swift │ ├── PeriodDuration+Description.swift │ ├── PeriodDuration+ExpressibleByStringLiteral.swift │ ├── PeriodDuration+Formatting.swift │ ├── PeriodDuration+FoundationSupport.swift │ ├── PeriodDuration+ISO8601.swift │ ├── PeriodDuration+Signs.swift │ ├── PeriodDuration+Zero.swift │ └── PeriodDuration.swift │ └── Utils.swift └── Tests └── PeriodDurationTests ├── CodableTests.swift ├── DescriptionTests.swift ├── ExpressibleByStringLiteralTests.swift ├── FormattingTests.swift ├── FoundationSupportTests.swift ├── ISO8601Tests.swift ├── IsBlankTests.swift ├── Props.swift ├── Scenarios.swift └── SignsTests.swift /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - "**" 10 | schedule: 11 | - cron: '3 3 * * 2' # 3:03 AM, every Tuesday 12 | 13 | concurrency: 14 | group: ci-${{ github.ref }} 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | macOS: 19 | name: ${{ matrix.platform }} (Swift ${{ matrix.swift }}) 20 | runs-on: ${{ matrix.os }} 21 | strategy: 22 | fail-fast: false 23 | matrix: 24 | platform: 25 | - iOS 26 | - macOS 27 | - tvOS 28 | - watchOS 29 | swift: 30 | - 5.7 31 | - 5.8 32 | - 5.9 33 | include: 34 | - swift: 5.7 35 | os: macos-13 36 | - swift: 5.8 37 | os: macos-13 38 | - swift: 5.9 39 | os: macos-13 40 | - action: test 41 | - platform: tvOS 42 | action: build 43 | - platform: watchOS 44 | action: build 45 | steps: 46 | - uses: actions/checkout@v4 47 | - uses: mxcl/xcodebuild@v2 48 | with: 49 | action: ${{ matrix.action }} 50 | platform: ${{ matrix.platform }} 51 | swift: ~${{ matrix.swift }} 52 | scheme: PeriodDuration 53 | linux: 54 | name: Linux (Swift ${{ matrix.swift }}) 55 | runs-on: ubuntu-latest 56 | strategy: 57 | fail-fast: false 58 | matrix: 59 | swift: 60 | - 5.7 61 | - 5.8 62 | - 5.9 63 | container: 64 | image: swift:${{ matrix.swift }} 65 | steps: 66 | - uses: actions/checkout@v4 67 | - run: swift test --parallel --sanitize=thread -Xswiftc -warnings-as-errors 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /.swiftpm 4 | /Packages 5 | /*.xcodeproj 6 | xcuserdata/ 7 | DerivedData/ 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - platform: ios 5 | scheme: PeriodDuration 6 | - platform: macos-xcodebuild 7 | scheme: PeriodDuration 8 | - platform: tvos 9 | scheme: PeriodDuration 10 | - platform: watchos 11 | scheme: PeriodDuration 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "swift-argument-parser", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/apple/swift-argument-parser", 7 | "state" : { 8 | "revision" : "fee6933f37fde9a5e12a1e4aeaa93fe60116ff2a", 9 | "version" : "1.2.2" 10 | } 11 | }, 12 | { 13 | "identity" : "swift-benchmark", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/google/swift-benchmark", 16 | "state" : { 17 | "revision" : "8163295f6fe82356b0bcf8e1ab991645de17d096", 18 | "version" : "0.1.2" 19 | } 20 | }, 21 | { 22 | "identity" : "swift-case-paths", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/pointfreeco/swift-case-paths", 25 | "state" : { 26 | "revision" : "5da6989aae464f324eef5c5b52bdb7974725ab81", 27 | "version" : "1.0.0" 28 | } 29 | }, 30 | { 31 | "identity" : "swift-custom-dump", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/pointfreeco/swift-custom-dump", 34 | "state" : { 35 | "revision" : "edd66cace818e1b1c6f1b3349bb1d8e00d6f8b01", 36 | "version" : "1.0.0" 37 | } 38 | }, 39 | { 40 | "identity" : "swift-json-testing", 41 | "kind" : "remoteSourceControl", 42 | "location" : "https://github.com/davdroman/swift-json-testing", 43 | "state" : { 44 | "revision" : "de48704db4af7d448bc27aea305b39494fed6eec", 45 | "version" : "0.2.0" 46 | } 47 | }, 48 | { 49 | "identity" : "swift-parsing", 50 | "kind" : "remoteSourceControl", 51 | "location" : "https://github.com/pointfreeco/swift-parsing", 52 | "state" : { 53 | "revision" : "a0e7d73f462c1c38c59dc40a3969ac40cea42950", 54 | "version" : "0.13.0" 55 | } 56 | }, 57 | { 58 | "identity" : "xctest-dynamic-overlay", 59 | "kind" : "remoteSourceControl", 60 | "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", 61 | "state" : { 62 | "revision" : "23cbf2294e350076ea4dbd7d5d047c1e76b03631", 63 | "version" : "1.0.2" 64 | } 65 | } 66 | ], 67 | "version" : 2 68 | } 69 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.7 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "PeriodDuration", 7 | platforms: [ 8 | .iOS(.v13), 9 | .tvOS(.v13), 10 | .macOS(.v10_15), 11 | .watchOS(.v6), 12 | ], 13 | products: [ 14 | .library(name: "PeriodDuration", targets: ["PeriodDuration"]), 15 | ], 16 | targets: [ 17 | .target(name: "PeriodDuration", dependencies: [ 18 | .product(name: "Parsing", package: "swift-parsing"), 19 | ]), 20 | .testTarget(name: "PeriodDurationTests", dependencies: [ 21 | .target(name: "PeriodDuration"), 22 | .product(name: "JSONTesting", package: "swift-json-testing"), 23 | ]), 24 | 25 | .executableTarget(name: "Benchmarks", dependencies: [ 26 | .target(name: "PeriodDuration"), 27 | .product(name: "Benchmark", package: "swift-benchmark"), 28 | ]), 29 | ] 30 | ) 31 | 32 | package.dependencies = [ 33 | .package(url: "https://github.com/google/swift-benchmark", from: "0.1.2"), 34 | .package(url: "https://github.com/pointfreeco/swift-parsing", from: "0.13.0"), 35 | .package(url: "https://github.com/davdroman/swift-json-testing", from: "0.2.0"), 36 | ] 37 | -------------------------------------------------------------------------------- /PeriodDuration.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /PeriodDuration.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /PeriodDuration.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "swift-argument-parser", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/apple/swift-argument-parser", 7 | "state" : { 8 | "revision" : "fee6933f37fde9a5e12a1e4aeaa93fe60116ff2a", 9 | "version" : "1.2.2" 10 | } 11 | }, 12 | { 13 | "identity" : "swift-benchmark", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/google/swift-benchmark", 16 | "state" : { 17 | "revision" : "8163295f6fe82356b0bcf8e1ab991645de17d096", 18 | "version" : "0.1.2" 19 | } 20 | }, 21 | { 22 | "identity" : "swift-case-paths", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/pointfreeco/swift-case-paths", 25 | "state" : { 26 | "revision" : "5da6989aae464f324eef5c5b52bdb7974725ab81", 27 | "version" : "1.0.0" 28 | } 29 | }, 30 | { 31 | "identity" : "swift-custom-dump", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/pointfreeco/swift-custom-dump", 34 | "state" : { 35 | "revision" : "edd66cace818e1b1c6f1b3349bb1d8e00d6f8b01", 36 | "version" : "1.0.0" 37 | } 38 | }, 39 | { 40 | "identity" : "swift-json-testing", 41 | "kind" : "remoteSourceControl", 42 | "location" : "https://github.com/davdroman/swift-json-testing", 43 | "state" : { 44 | "revision" : "de48704db4af7d448bc27aea305b39494fed6eec", 45 | "version" : "0.2.0" 46 | } 47 | }, 48 | { 49 | "identity" : "swift-parsing", 50 | "kind" : "remoteSourceControl", 51 | "location" : "https://github.com/pointfreeco/swift-parsing", 52 | "state" : { 53 | "revision" : "a0e7d73f462c1c38c59dc40a3969ac40cea42950", 54 | "version" : "0.13.0" 55 | } 56 | }, 57 | { 58 | "identity" : "xctest-dynamic-overlay", 59 | "kind" : "remoteSourceControl", 60 | "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", 61 | "state" : { 62 | "revision" : "23cbf2294e350076ea4dbd7d5d047c1e76b03631", 63 | "version" : "1.0.2" 64 | } 65 | } 66 | ], 67 | "version" : 2 68 | } 69 | -------------------------------------------------------------------------------- /PeriodDuration.xcworkspace/xcshareddata/xcschemes/Benchmarks.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 | 69 | 75 | 76 | 77 | 78 | 84 | 86 | 92 | 93 | 94 | 95 | 97 | 98 | 101 | 102 | 103 | -------------------------------------------------------------------------------- /PeriodDuration.xcworkspace/xcshareddata/xcschemes/PeriodDuration.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 34 | 40 | 41 | 42 | 43 | 44 | 54 | 55 | 61 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PeriodDuration 2 | 3 | [![CI](https://github.com/davdroman/PeriodDuration/actions/workflows/ci.yml/badge.svg)](https://github.com/davdroman/PeriodDuration/actions/workflows/ci.yml) 4 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fdavdroman%2FPeriodDuration%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/davdroman/PeriodDuration) 5 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fdavdroman%2FPeriodDuration%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/davdroman/PeriodDuration) 6 | 7 | This library introduces a close equivalent to [Java's PeriodDuration](https://www.threeten.org/threeten-extra/apidocs/org.threeten.extra/org/threeten/extra/PeriodDuration.html), motivated by the lack of support for this standard in Foundation. 8 | 9 | `PeriodDuration` is based off of a [previous library](https://github.com/treatwell/ISO8601PeriodDuration) I worked on, however it goes beyond simple serialization by introducing dedicated types with full ISO 8601 compliant `Codable` support. 10 | 11 | ## Usage 12 | 13 | Available types: `Period`, `Duration` and `PeriodDuration`. 14 | 15 | ### Period 16 | 17 | [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601#Durations) defines a "Period" as a combination of _years_, _months_, and _days_ elapsed. Periods **do not** include _hours_, _minutes_ or _seconds_. 18 | 19 | ```swift 20 | Period(years: 3, months: 1, days: 5) // = "P3Y1M5D" 21 | ``` 22 | 23 | ### Duration 24 | 25 | [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601#Durations) defines a "Duration" as a combination of _hours_, _minutes_ or _seconds_ elapsed. Durations **do not** include _years_, _months_, and _days_. 26 | 27 | ```swift 28 | Duration(hours: 2, minutes: 5, seconds: 0) // = "PT2H5M0S" 29 | ``` 30 | 31 | ### PeriodDuration 32 | 33 | `PeriodDuration` is a combinations of _years_, _months_, _days_, _hours_, _minutes_ and _seconds_ elapsed. As a type, it holds both a `Period` and a `Duration` instance within it to represent all of these values. 34 | 35 | ```swift 36 | PeriodDuration(years: 3, months: 1, days: 5, hours: 2, minutes: 5, seconds: 0) // = "P3Y1M5DT2H5M0S" 37 | ``` 38 | 39 | ### Conversion to DateComponents 40 | 41 | All three types provided allow for easy conversion into the built-in `DateComponents` type in Foundation. 42 | 43 | ```swift 44 | let dateComponents: DateComponents = Period(years: 3, months: 1, days: 5).asDateComponents 45 | ``` 46 | 47 | This allows for a number of handy things. Namely: 48 | 49 | - Date manipulation: adding periods/durations to `Date` instances via [`Calendar.date(byAdding:to:wrappingComponents:)`](https://developer.apple.com/documentation/foundation/calendar/2293676-date/). 50 | - Human-readable formatting via [`DateComponentsFormatter`](https://developer.apple.com/documentation/foundation/datecomponentsformatter). 51 | 52 | ## Benchmarks 53 | 54 | ``` 55 | MacBook Pro (14-inch, 2021) 56 | Apple M1 Pro (10 cores, 8 performance and 2 efficiency) 57 | 32 GB Memory 58 | 59 | $ swift run -c release Benchmarks 60 | 61 | name time std iterations 62 | ------------------------------------------------------ 63 | parse PeriodDuration 1041.000 ns ± 26.34 % 1000000 64 | print PeriodDuration 1291.000 ns ± 12.34 % 1000000 65 | parse Period 1333.000 ns ± 13.65 % 1000000 66 | print Period 666.000 ns ± 38.67 % 1000000 67 | parse Duration 1041.000 ns ± 33.51 % 1000000 68 | print Duration 666.000 ns ± 16.86 % 1000000 69 | ``` 70 | -------------------------------------------------------------------------------- /Sources/Benchmarks/main.swift: -------------------------------------------------------------------------------- 1 | import PeriodDuration 2 | import Benchmark 3 | 4 | benchmark("parse PeriodDuration") { 5 | _ = PeriodDuration(iso8601: "P3Y3M3W3DT3H3M3S") 6 | } 7 | 8 | benchmark("print PeriodDuration") { 9 | _ = PeriodDuration(years: 3, months: 3, days: 3, hours: 3, minutes: 3, seconds: 3).formatted(style: .iso8601) 10 | } 11 | 12 | benchmark("parse Period") { 13 | _ = Period(iso8601: "P3Y3M3W3D") 14 | } 15 | 16 | benchmark("print Period") { 17 | _ = Period(years: 3, months: 3, days: 3).formatted(style: .iso8601) 18 | } 19 | 20 | benchmark("parse Duration") { 21 | _ = Duration(iso8601: "PT3H3M3S") 22 | } 23 | 24 | benchmark("print Duration") { 25 | _ = Duration(hours: 3, minutes: 3, seconds: 3).formatted(style: .iso8601) 26 | } 27 | 28 | Benchmark.main() 29 | -------------------------------------------------------------------------------- /Sources/PeriodDuration/Duration/Duration+Codable.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Duration: Codable { 4 | public init(from decoder: Decoder) throws { 5 | let container = try decoder.singleValueContainer() 6 | let rawValue = try container.decode(String.self) 7 | guard let _self = Self(iso8601: rawValue) else { 8 | throw DecodingError.dataCorruptedError( 9 | in: container, 10 | debugDescription: "Invalid Duration ISO 8601 value \"\(rawValue)\"" 11 | ) 12 | } 13 | self = _self 14 | } 15 | 16 | public func encode(to encoder: Encoder) throws { 17 | var container = encoder.singleValueContainer() 18 | try container.encode(self.formatted(style: .iso8601)) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/PeriodDuration/Duration/Duration+Description.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Duration: CustomStringConvertible { 4 | public var description: String { 5 | [ 6 | hours.nilIfZero.map { "\($0) hour\($0 == 1 ? "" : "s")" }, 7 | minutes.nilIfZero.map { "\($0) minute\($0 == 1 ? "" : "s")" }, 8 | seconds.nilIfZero.map { "\($0) second\($0 == 1 ? "" : "s")" }, 9 | ] 10 | .lazy 11 | .compactMap { $0 } 12 | .joined(separator: ", ") 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/PeriodDuration/Duration/Duration+ExpressibleByStringLiteral.swift: -------------------------------------------------------------------------------- 1 | #if DEBUG 2 | import Foundation 3 | 4 | extension Duration: ExpressibleByStringLiteral { 5 | public init(stringLiteral value: StringLiteralType) { 6 | guard let _self = Self(iso8601: value) else { 7 | preconditionFailure("Could not parse string literal '\(value)' into ISO 8601 Duration") 8 | } 9 | self = _self 10 | } 11 | } 12 | #endif 13 | -------------------------------------------------------------------------------- /Sources/PeriodDuration/Duration/Duration+Formatting.swift: -------------------------------------------------------------------------------- 1 | #if !os(Linux) 2 | import Foundation 3 | 4 | extension Duration { 5 | public func formatted( 6 | style: DateComponentsFormatter.UnitsStyle = .full, 7 | allowedUnits: NSCalendar.Unit = [.hour, .minute, .second], 8 | locale: Locale 9 | ) -> String { 10 | formatter.allowedUnits = allowedUnits 11 | formatter.unitsStyle = style 12 | formatter.calendar?.locale = locale 13 | return formatter.string(from: self) ?? "" 14 | } 15 | } 16 | #endif 17 | -------------------------------------------------------------------------------- /Sources/PeriodDuration/Duration/Duration+FoundationSupport.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Duration { 4 | public var asDateComponents: DateComponents { 5 | DateComponents( 6 | hour: hours, 7 | minute: minutes, 8 | second: seconds 9 | ) 10 | } 11 | } 12 | 13 | #if !os(Linux) 14 | extension DateComponentsFormatter { 15 | public func string(from duration: Duration) -> String? { 16 | string(from: duration.asDateComponents) 17 | } 18 | } 19 | #endif 20 | -------------------------------------------------------------------------------- /Sources/PeriodDuration/Duration/Duration+ISO8601.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Duration { 4 | public enum StandardFormatStyle { 5 | case iso8601 6 | } 7 | } 8 | 9 | extension Duration { 10 | public init?(iso8601 rawValue: String) { 11 | do { 12 | self = try Parsers.duration.parse(rawValue) 13 | } catch { 14 | #if DEBUG 15 | print("[\(Self.self)]", error) 16 | #endif 17 | return nil 18 | } 19 | } 20 | 21 | public func formatted(style: StandardFormatStyle) -> String { 22 | switch style { 23 | case .iso8601: 24 | var result = "" 25 | result += "PT" 26 | result += self.hours.withSuffix("H") 27 | result += self.minutes.withSuffix("M") 28 | result += self.seconds.withSuffix("S") 29 | return result 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/PeriodDuration/Duration/Duration+Signs.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Duration { 4 | public static prefix func + (rhs: Self) -> Self { 5 | rhs 6 | } 7 | 8 | public static prefix func - (rhs: Self) -> Self { 9 | Self( 10 | hours: -rhs.hours, 11 | minutes: -rhs.minutes, 12 | seconds: -rhs.seconds 13 | ) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/PeriodDuration/Duration/Duration+Zero.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Duration { 4 | public static var zero: Self { 5 | Self() 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Sources/PeriodDuration/Duration/Duration.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct Duration: Hashable { 4 | public var hours: Int 5 | public var minutes: Int 6 | public var seconds: Int 7 | 8 | public init(hours: Int = 0, minutes: Int = 0, seconds: Int = 0) { 9 | self.hours = hours 10 | self.minutes = minutes 11 | self.seconds = seconds 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/PeriodDuration/Period/Period+Codable.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Period: Codable { 4 | public init(from decoder: Decoder) throws { 5 | let container = try decoder.singleValueContainer() 6 | let rawValue = try container.decode(String.self) 7 | guard let _self = Self(iso8601: rawValue) else { 8 | throw DecodingError.dataCorruptedError( 9 | in: container, 10 | debugDescription: "Invalid Period ISO 8601 value \"\(rawValue)\"" 11 | ) 12 | } 13 | self = _self 14 | } 15 | 16 | public func encode(to encoder: Encoder) throws { 17 | var container = encoder.singleValueContainer() 18 | try container.encode(self.formatted(style: .iso8601)) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/PeriodDuration/Period/Period+Description.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Period: CustomStringConvertible { 4 | public var description: String { 5 | [ 6 | years.nilIfZero.map { "\($0) year\($0 == 1 ? "" : "s")" }, 7 | months.nilIfZero.map { "\($0) month\($0 == 1 ? "" : "s")" }, 8 | days.nilIfZero.map { "\($0) day\($0 == 1 ? "" : "s")" }, 9 | ] 10 | .lazy 11 | .compactMap { $0 } 12 | .joined(separator: ", ") 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/PeriodDuration/Period/Period+ExpressibleByStringLiteral.swift: -------------------------------------------------------------------------------- 1 | #if DEBUG 2 | import Foundation 3 | 4 | extension Period: ExpressibleByStringLiteral { 5 | public init(stringLiteral value: StringLiteralType) { 6 | guard let _self = Self(iso8601: value) else { 7 | preconditionFailure("Could not parse string literal '\(value)' into ISO 8601 Period") 8 | } 9 | self = _self 10 | } 11 | } 12 | #endif 13 | -------------------------------------------------------------------------------- /Sources/PeriodDuration/Period/Period+Formatting.swift: -------------------------------------------------------------------------------- 1 | #if !os(Linux) 2 | import Foundation 3 | 4 | extension Period { 5 | public func formatted( 6 | style: DateComponentsFormatter.UnitsStyle = .full, 7 | allowedUnits: NSCalendar.Unit = [.year, .month, .day], 8 | locale: Locale 9 | ) -> String { 10 | formatter.allowedUnits = allowedUnits 11 | formatter.unitsStyle = style 12 | formatter.calendar?.locale = locale 13 | return formatter.string(from: self) ?? "" 14 | } 15 | } 16 | #endif 17 | -------------------------------------------------------------------------------- /Sources/PeriodDuration/Period/Period+FoundationSupport.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Period { 4 | public var asDateComponents: DateComponents { 5 | DateComponents( 6 | year: years, 7 | month: months, 8 | day: days 9 | ) 10 | } 11 | } 12 | 13 | #if !os(Linux) 14 | extension DateComponentsFormatter { 15 | public func string(from period: Period) -> String? { 16 | string(from: period.asDateComponents) 17 | } 18 | } 19 | #endif 20 | -------------------------------------------------------------------------------- /Sources/PeriodDuration/Period/Period+ISO8601.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Period { 4 | public enum StandardFormatStyle { 5 | case iso8601 6 | } 7 | } 8 | 9 | extension Period { 10 | public init?(iso8601 rawValue: String) { 11 | do { 12 | self = try Parsers.period.parse(rawValue) 13 | } catch { 14 | #if DEBUG 15 | print("[\(Self.self)]", error) 16 | #endif 17 | return nil 18 | } 19 | } 20 | 21 | public func formatted(style: StandardFormatStyle) -> String { 22 | switch style { 23 | case .iso8601: 24 | var result = "" 25 | result += "P" 26 | result += self.years.withSuffix("Y") 27 | result += self.months.withSuffix("M") 28 | result += self.days.withSuffix("D") 29 | return result 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/PeriodDuration/Period/Period+Signs.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Period { 4 | public static prefix func + (rhs: Self) -> Self { 5 | rhs 6 | } 7 | 8 | public static prefix func - (rhs: Self) -> Self { 9 | Self( 10 | years: -rhs.years, 11 | months: -rhs.months, 12 | days: -rhs.days 13 | ) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/PeriodDuration/Period/Period+Zero.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Period { 4 | public static var zero: Self { 5 | Self() 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Sources/PeriodDuration/Period/Period.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct Period: Hashable { 4 | public var years: Int 5 | public var months: Int 6 | public var days: Int 7 | 8 | public init(years: Int = 0, months: Int = 0, days: Int = 0) { 9 | self.years = years 10 | self.months = months 11 | self.days = days 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/PeriodDuration/PeriodDuration/PeriodDuration+Codable.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension PeriodDuration: Codable { 4 | public init(from decoder: Decoder) throws { 5 | let container = try decoder.singleValueContainer() 6 | let rawValue = try container.decode(String.self) 7 | guard let _self = Self(iso8601: rawValue) else { 8 | throw DecodingError.dataCorruptedError( 9 | in: container, 10 | debugDescription: "Invalid PeriodDuration ISO 8601 value \"\(rawValue)\"" 11 | ) 12 | } 13 | self = _self 14 | } 15 | 16 | public func encode(to encoder: Encoder) throws { 17 | var container = encoder.singleValueContainer() 18 | try container.encode(self.formatted(style: .iso8601)) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/PeriodDuration/PeriodDuration/PeriodDuration+Description.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension PeriodDuration: CustomStringConvertible { 4 | public var description: String { 5 | [ 6 | self.years.nilIfZero.map { "\($0) year\($0 == 1 ? "" : "s")" }, 7 | self.months.nilIfZero.map { "\($0) month\($0 == 1 ? "" : "s")" }, 8 | self.days.nilIfZero.map { "\($0) day\($0 == 1 ? "" : "s")" }, 9 | self.hours.nilIfZero.map { "\($0) hour\($0 == 1 ? "" : "s")" }, 10 | self.minutes.nilIfZero.map { "\($0) minute\($0 == 1 ? "" : "s")" }, 11 | self.seconds.nilIfZero.map { "\($0) second\($0 == 1 ? "" : "s")" }, 12 | ] 13 | .lazy 14 | .compactMap { $0 } 15 | .joined(separator: ", ") 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/PeriodDuration/PeriodDuration/PeriodDuration+ExpressibleByStringLiteral.swift: -------------------------------------------------------------------------------- 1 | #if DEBUG 2 | import Foundation 3 | 4 | extension PeriodDuration: ExpressibleByStringLiteral { 5 | public init(stringLiteral value: StringLiteralType) { 6 | guard let _self = Self(iso8601: value) else { 7 | preconditionFailure("Could not parse string literal '\(value)' into ISO 8601 PeriodDuration") 8 | } 9 | self = _self 10 | } 11 | } 12 | #endif 13 | -------------------------------------------------------------------------------- /Sources/PeriodDuration/PeriodDuration/PeriodDuration+Formatting.swift: -------------------------------------------------------------------------------- 1 | #if !os(Linux) 2 | import Foundation 3 | 4 | extension PeriodDuration { 5 | public func formatted( 6 | style: DateComponentsFormatter.UnitsStyle = .full, 7 | allowedUnits: NSCalendar.Unit = [.year, .month, .day, .hour, .minute, .second], 8 | locale: Locale 9 | ) -> String { 10 | formatter.allowedUnits = allowedUnits 11 | formatter.unitsStyle = style 12 | formatter.calendar?.locale = locale 13 | return formatter.string(from: self) ?? "" 14 | } 15 | } 16 | #endif 17 | -------------------------------------------------------------------------------- /Sources/PeriodDuration/PeriodDuration/PeriodDuration+FoundationSupport.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension PeriodDuration { 4 | public var asDateComponents: DateComponents { 5 | DateComponents( 6 | year: self.years, 7 | month: self.months, 8 | day: self.days, 9 | hour: self.hours, 10 | minute: self.minutes, 11 | second: self.seconds 12 | ) 13 | } 14 | } 15 | 16 | #if !os(Linux) 17 | extension DateComponentsFormatter { 18 | public func string(from periodDuration: PeriodDuration) -> String? { 19 | string(from: periodDuration.asDateComponents) 20 | } 21 | } 22 | #endif 23 | -------------------------------------------------------------------------------- /Sources/PeriodDuration/PeriodDuration/PeriodDuration+ISO8601.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension PeriodDuration { 4 | public enum StandardFormatStyle { 5 | case iso8601 6 | } 7 | } 8 | 9 | extension PeriodDuration { 10 | public init?(iso8601 rawValue: String) { 11 | do { 12 | self = try Parsers.periodDuration.parse(rawValue) 13 | } catch { 14 | #if DEBUG 15 | print("[\(Self.self)]", error) 16 | #endif 17 | return nil 18 | } 19 | } 20 | 21 | public func formatted(style: StandardFormatStyle) -> String { 22 | switch style { 23 | case .iso8601: 24 | var result = "" 25 | result += "P" 26 | result += self.years.withSuffix("Y") 27 | result += self.months.withSuffix("M") 28 | result += self.days.withSuffix("D") 29 | 30 | guard duration != .zero else { return result } 31 | result += "T" 32 | result += self.hours.withSuffix("H") 33 | result += self.minutes.withSuffix("M") 34 | result += self.seconds.withSuffix("S") 35 | 36 | return result 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/PeriodDuration/PeriodDuration/PeriodDuration+Signs.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension PeriodDuration { 4 | public static prefix func + (rhs: Self) -> Self { 5 | rhs 6 | } 7 | 8 | public static prefix func - (rhs: Self) -> Self { 9 | Self( 10 | years: -rhs.years, 11 | months: -rhs.months, 12 | days: -rhs.days, 13 | hours: -rhs.hours, 14 | minutes: -rhs.minutes, 15 | seconds: -rhs.seconds 16 | ) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/PeriodDuration/PeriodDuration/PeriodDuration+Zero.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension PeriodDuration { 4 | public static var zero: Self { 5 | Self() 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Sources/PeriodDuration/PeriodDuration/PeriodDuration.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | @dynamicMemberLookup 4 | public struct PeriodDuration: Hashable { 5 | public var period: Period 6 | public var duration: Duration 7 | 8 | public init(period: Period, duration: Duration) { 9 | self.period = period 10 | self.duration = duration 11 | } 12 | 13 | public init(period: Period) { 14 | self.period = period 15 | self.duration = Duration() 16 | } 17 | 18 | public init(duration: Duration) { 19 | self.period = Period() 20 | self.duration = duration 21 | } 22 | 23 | public init( 24 | years: Int = 0, 25 | months: Int = 0, 26 | days: Int = 0, 27 | hours: Int = 0, 28 | minutes: Int = 0, 29 | seconds: Int = 0 30 | ) { 31 | self.init( 32 | period: Period(years: years, months: months, days: days), 33 | duration: Duration(hours: hours, minutes: minutes, seconds: seconds) 34 | ) 35 | } 36 | } 37 | 38 | extension PeriodDuration { 39 | public subscript(dynamicMember keyPath: WritableKeyPath) -> Int { 40 | get { period[keyPath: keyPath] } 41 | set { period[keyPath: keyPath] = newValue } 42 | } 43 | 44 | public subscript(dynamicMember keyPath: WritableKeyPath) -> Int { 45 | get { duration[keyPath: keyPath] } 46 | set { duration[keyPath: keyPath] = newValue } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Sources/PeriodDuration/Utils.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Parsing 3 | 4 | enum Parsers { 5 | static let pDesignator = Skip { 6 | "P".utf8 7 | Not { 8 | Digits(1...) 9 | Whitespace(0..., .horizontal) 10 | End() 11 | } 12 | } 13 | 14 | static let years = digitsAndUnit("Y".utf8) 15 | static let months = digitsAndUnit("M".utf8) 16 | static let weeks = digitsAndUnit("W".utf8) 17 | static let days = digitsAndUnit("D".utf8) 18 | static let hours = digitsAndUnit("H".utf8) 19 | static let minutes = digitsAndUnit("M".utf8) 20 | static let seconds = digitsAndUnit("S".utf8) 21 | 22 | static let periodValues = Parse { 23 | years 24 | months 25 | weeks 26 | days 27 | } 28 | .map { years, months, weeks, days in 29 | Period(years: years, months: months, days: weeks * 7 + days) 30 | } 31 | 32 | static let durationValues = Parse { 33 | hours 34 | minutes 35 | seconds 36 | } 37 | .map(Duration.init(hours:minutes:seconds:)) 38 | 39 | static let period = Parse { 40 | pDesignator 41 | periodValues 42 | Skip { 43 | Optionally { 44 | "T".utf8 45 | durationValues 46 | } 47 | } 48 | } 49 | 50 | static let duration = Parse { 51 | pDesignator 52 | OneOf { 53 | Skip { PrefixThrough("T".utf8) } 54 | Skip { Rest() }.replaceError(with: ()) 55 | } 56 | durationValues 57 | } 58 | 59 | static let periodDuration = Parse { 60 | pDesignator 61 | periodValues 62 | OneOf { 63 | "T".utf8 64 | Skip { Rest() }.replaceError(with: ()) 65 | } 66 | durationValues 67 | } 68 | .map(PeriodDuration.init(period:duration:)) 69 | } 70 | 71 | private extension Parsers { 72 | static func digitsAndUnit(_ unit: String.UTF8View) -> AnyParser { 73 | Parse { 74 | Optionally { Digits(1...) } 75 | unit 76 | } 77 | .map { $0 ?? 0 } 78 | .replaceError(with: 0) 79 | .eraseToAnyParser() 80 | } 81 | } 82 | 83 | extension Numeric { 84 | var nilIfZero: Self? { 85 | self == .zero ? nil : self 86 | } 87 | 88 | func withSuffix(_ c: Character) -> String { 89 | if self == .zero { 90 | return "" 91 | } else { 92 | return "\(self)\(c)" 93 | } 94 | } 95 | } 96 | 97 | #if !os(Linux) 98 | let formatter: DateComponentsFormatter = { 99 | var formatter = DateComponentsFormatter() 100 | var calendar = Calendar(identifier: .iso8601) 101 | calendar.timeZone = TimeZone(secondsFromGMT: 0)! 102 | formatter.calendar = calendar 103 | return formatter 104 | }() 105 | #endif 106 | -------------------------------------------------------------------------------- /Tests/PeriodDurationTests/CodableTests.swift: -------------------------------------------------------------------------------- 1 | import CustomDump 2 | import JSONTesting 3 | import PeriodDuration 4 | 5 | final class CodableTests: XCTestCase { 6 | func testPeriodDurationScenarios() throws { 7 | for s in scenarios { 8 | try assert(.string(s.input), s.output.map(PeriodDuration.init), identical: s.roundtrippingType == PeriodDuration.self) 9 | } 10 | } 11 | 12 | func testPeriodScenarios() throws { 13 | for s in scenarios { 14 | try assert(.string(s.input), s.output.map(Period.init), identical: s.roundtrippingType == Period.self) 15 | } 16 | } 17 | 18 | func testDurationScenarios() throws { 19 | for s in scenarios { 20 | try assert(.string(s.input), s.output.map(Duration.init), identical: s.roundtrippingType == Duration.self) 21 | } 22 | } 23 | } 24 | 25 | private extension CodableTests { 26 | func assert( 27 | _ json: JSON, 28 | _ codable: T?, 29 | identical: Bool, 30 | file: StaticString = #filePath, 31 | line: UInt = #line 32 | ) throws where T: Codable, T: Equatable { 33 | let message = "rawValue: \(json)" 34 | try XCTAssertJSONCoding(codable, message, file: file, line: line) 35 | 36 | if identical { 37 | try XCTAssertJSONEncoding(codable, json) 38 | } 39 | 40 | do { 41 | try XCTAssertJSONDecoding(json, codable, message, file: file, line: line) 42 | } catch let error as DecodingError where codable == nil { 43 | switch error { 44 | case .dataCorrupted(let context): 45 | let type = "\(type(of: T.self))".prefix(while: { $0 != "." }) 46 | XCTAssertEqual(context.debugDescription, "Invalid \(type) ISO 8601 value \(json)", file: file, line: line) 47 | default: 48 | XCTFail("Unexpected `DecodingError` received: '\(error)' - \(message)", file: file, line: line) 49 | } 50 | } catch let error { 51 | XCTFail("Unexpected error received: '\(error)' - \(message)", file: file, line: line) 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Tests/PeriodDurationTests/DescriptionTests.swift: -------------------------------------------------------------------------------- 1 | import PeriodDuration 2 | import XCTest 3 | 4 | final class DescriptionTests: XCTestCase { 5 | func testPeriodDurationDescription() { 6 | XCTAssertEqual( 7 | PeriodDuration.zero.description, 8 | "" 9 | ) 10 | XCTAssertEqual( 11 | PeriodDuration(years: 1, months: 1, days: 1, hours: 1, minutes: 1, seconds: 1).description, 12 | "1 year, 1 month, 1 day, 1 hour, 1 minute, 1 second" 13 | ) 14 | XCTAssertEqual( 15 | PeriodDuration(years: 3, months: 3, days: 3, hours: 3, minutes: 3, seconds: 3).description, 16 | "3 years, 3 months, 3 days, 3 hours, 3 minutes, 3 seconds" 17 | ) 18 | } 19 | 20 | func testPeriodDescription() { 21 | XCTAssertEqual( 22 | Period.zero.description, 23 | "" 24 | ) 25 | XCTAssertEqual( 26 | Period(years: 1, months: 1, days: 1).description, 27 | "1 year, 1 month, 1 day" 28 | ) 29 | XCTAssertEqual( 30 | Period(years: 3, months: 3, days: 3).description, 31 | "3 years, 3 months, 3 days" 32 | ) 33 | } 34 | 35 | func testDurationDescription() { 36 | XCTAssertEqual( 37 | Duration.zero.description, 38 | "" 39 | ) 40 | XCTAssertEqual( 41 | Duration(hours: 1, minutes: 1, seconds: 1).description, 42 | "1 hour, 1 minute, 1 second" 43 | ) 44 | XCTAssertEqual( 45 | Duration(hours: 3, minutes: 3, seconds: 3).description, 46 | "3 hours, 3 minutes, 3 seconds" 47 | ) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Tests/PeriodDurationTests/ExpressibleByStringLiteralTests.swift: -------------------------------------------------------------------------------- 1 | import PeriodDuration 2 | import XCTest 3 | 4 | final class ExpressibleByStringLiteralTests: XCTestCase { 5 | func testPeriodDuration() { 6 | XCTAssertEqual("PYMWDTHMS", PeriodDuration()) 7 | XCTAssertEqual("P3YMWDTHMS", PeriodDuration(years: 3)) // Y 8 | XCTAssertEqual("PY3MWDTHMS", PeriodDuration(months: 3)) // M 9 | XCTAssertEqual("PYM3WDTHMS", PeriodDuration(days: 21)) // W 10 | XCTAssertEqual("PYMW3DTHMS", PeriodDuration(days: 3)) // D 11 | XCTAssertEqual("PYMWDT3HMS", PeriodDuration(hours: 3)) // H 12 | XCTAssertEqual("PYMWDTH3MS", PeriodDuration(minutes: 3)) // m 13 | XCTAssertEqual("PYMWDTHM3S", PeriodDuration(seconds: 3)) // S 14 | XCTAssertEqual("P3Y3M3W3DT3H3M3S", PeriodDuration(years: 3, months: 3, days: 24, hours: 3, minutes: 3, seconds: 3)) // YMWDHMS 15 | XCTAssertEqual("P3Y3M3DT3H3M3S", PeriodDuration(years: 3, months: 3, days: 3, hours: 3, minutes: 3, seconds: 3)) // YMDHMS 16 | } 17 | 18 | func testPeriod() { 19 | XCTAssertEqual("PYMWD", Period()) 20 | XCTAssertEqual("P3YMWD", Period(years: 3)) // Y 21 | XCTAssertEqual("PY3MWD", Period(months: 3)) // M 22 | XCTAssertEqual("PYM3WD", Period(days: 21)) // W 23 | XCTAssertEqual("PYMW3D", Period(days: 3)) // D 24 | XCTAssertEqual("P3Y3MWD", Period(years: 3, months: 3)) // YM 25 | XCTAssertEqual("PYM3W3D", Period(days: 24)) // WD 26 | XCTAssertEqual("P3YM3WD", Period(years: 3, days: 21)) // YW 27 | XCTAssertEqual("P3YMW3D", Period(years: 3, days: 3)) // YD 28 | XCTAssertEqual("P3Y3M3WD", Period(years: 3, months: 3, days: 21)) // YMW 29 | XCTAssertEqual("PY3M3W3D", Period(months: 3, days: 24)) // MWD 30 | XCTAssertEqual("P3Y3M3W3D", Period(years: 3, months: 3, days: 24)) // YMWD 31 | XCTAssertEqual("P3Y3M3D", Period(years: 3, months: 3, days: 3)) // YMD 32 | 33 | XCTAssertEqual("PY", Period()) 34 | XCTAssertEqual("P3Y", Period(years: 3)) 35 | 36 | XCTAssertEqual("PM", Period()) 37 | XCTAssertEqual("P3M", Period(months: 3)) 38 | 39 | XCTAssertEqual("PW", Period()) 40 | XCTAssertEqual("P3W", Period(days: 21)) 41 | 42 | XCTAssertEqual("PD", Period()) 43 | XCTAssertEqual("P3D", Period(days: 3)) 44 | } 45 | 46 | func testDuration() { 47 | XCTAssertEqual("PTHMS", Duration()) 48 | XCTAssertEqual("PT3HMS", Duration(hours: 3)) // H 49 | XCTAssertEqual("PTH3MS", Duration(minutes: 3)) // M 50 | XCTAssertEqual("PTHM3S", Duration(seconds: 3)) // S 51 | XCTAssertEqual("PT3H3MS", Duration(hours: 3, minutes: 3)) // HM 52 | XCTAssertEqual("PTH3M3S", Duration(minutes: 3, seconds: 3)) // MS 53 | XCTAssertEqual("PT3HM3S", Duration(hours: 3, seconds: 3)) // HS 54 | XCTAssertEqual("PT3H3M3S", Duration(hours: 3, minutes: 3, seconds: 3)) // HMS 55 | 56 | XCTAssertEqual("PT", Duration()) 57 | XCTAssertEqual("PTH", Duration()) 58 | XCTAssertEqual("PT3H", Duration(hours: 3)) 59 | 60 | XCTAssertEqual("PTM", Duration()) 61 | XCTAssertEqual("PT3M", Duration(minutes: 3)) 62 | 63 | XCTAssertEqual("PTS", Duration()) 64 | XCTAssertEqual("PT3S", Duration(seconds: 3)) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Tests/PeriodDurationTests/FormattingTests.swift: -------------------------------------------------------------------------------- 1 | #if !os(Linux) 2 | import PeriodDuration 3 | import XCTest 4 | 5 | final class FormattingTests: XCTestCase { 6 | let enLocale = Locale(identifier: "en_GB") 7 | let esLocale = Locale(identifier: "es_ES") 8 | } 9 | 10 | extension FormattingTests { 11 | func testPeriodDurationFormatting_defaultStyle() { 12 | XCTAssertEqual( 13 | PeriodDuration.zero.formatted(locale: enLocale), 14 | "0 seconds" 15 | ) 16 | XCTAssertEqual( 17 | PeriodDuration(fullProps).formatted(locale: enLocale), 18 | "1 year, 2 months, 3 days, 4 hours, 5 minutes, 6 seconds" 19 | ) 20 | } 21 | 22 | func testPeriodFormatting_defaultStyle() { 23 | XCTAssertEqual( 24 | Period.zero.formatted(locale: enLocale), 25 | "0 days" 26 | ) 27 | XCTAssertEqual( 28 | Period(fullProps).formatted(locale: enLocale), 29 | "1 year, 2 months, 3 days" 30 | ) 31 | } 32 | 33 | func testDurationFormatting_defaultStyle() { 34 | XCTAssertEqual( 35 | Duration.zero.formatted(locale: enLocale), 36 | "0 seconds" 37 | ) 38 | XCTAssertEqual( 39 | Duration(fullProps).formatted(locale: enLocale), 40 | "4 hours, 5 minutes, 6 seconds" 41 | ) 42 | } 43 | } 44 | 45 | extension FormattingTests { 46 | func testPeriodDurationFormatting_explicitStyle() { 47 | XCTAssertEqual( 48 | PeriodDuration.zero.formatted(style: .short, locale: enLocale), 49 | "0 secs" 50 | ) 51 | XCTAssertEqual( 52 | PeriodDuration(fullProps).formatted(style: .short, locale: enLocale), 53 | "1 yr, 2 mths, 3 days, 4 hrs, 5 min, 6 secs" 54 | ) 55 | } 56 | 57 | func testPeriodFormatting_explicitStyle() { 58 | XCTAssertEqual( 59 | Period.zero.formatted(style: .short, locale: enLocale), 60 | "0 days" 61 | ) 62 | XCTAssertEqual( 63 | Period(fullProps).formatted(style: .short, locale: enLocale), 64 | "1 yr, 2 mths, 3 days" 65 | ) 66 | } 67 | 68 | func testDurationFormatting_explicitStyle() { 69 | XCTAssertEqual( 70 | Duration.zero.formatted(style: .short, locale: enLocale), 71 | "0 secs" 72 | ) 73 | XCTAssertEqual( 74 | Duration(fullProps).formatted(style: .short, locale: enLocale), 75 | "4 hrs, 5 min, 6 secs" 76 | ) 77 | } 78 | } 79 | 80 | extension FormattingTests { 81 | func testPeriodDurationFormatting_customAllowedUnits() { 82 | XCTAssertEqual( 83 | PeriodDuration.zero.formatted(allowedUnits: [.day, .hour, .minute, .second], locale: enLocale), 84 | "0 seconds" 85 | ) 86 | XCTAssertEqual( 87 | PeriodDuration(fullProps).formatted(allowedUnits: [.day, .hour, .minute, .second], locale: enLocale), 88 | "427 days, 4 hours, 5 minutes, 6 seconds" 89 | ) 90 | } 91 | 92 | func testPeriodFormatting_customAllowedUnits() { 93 | XCTAssertEqual( 94 | Period.zero.formatted(allowedUnits: [.day, .hour, .minute, .second], locale: enLocale), 95 | "0 seconds" 96 | ) 97 | XCTAssertEqual( 98 | Period(fullProps).formatted(allowedUnits: [.month, .minute], locale: enLocale), 99 | "14 months, 4,320 minutes" 100 | ) 101 | } 102 | 103 | func testDurationFormatting_customAllowedUnits() { 104 | XCTAssertEqual( 105 | Duration.zero.formatted(allowedUnits: [.hour, .minute], locale: enLocale), 106 | "0 minutes" 107 | ) 108 | XCTAssertEqual( 109 | Duration(fullProps).formatted(allowedUnits: [.hour, .minute], locale: enLocale), 110 | "4 hours, 5 minutes" 111 | ) 112 | } 113 | } 114 | 115 | extension FormattingTests { 116 | func testPeriodDurationFormatting_esLocale() { 117 | XCTAssertEqual( 118 | PeriodDuration.zero.formatted(locale: esLocale), 119 | "0 segundos" 120 | ) 121 | XCTAssertEqual( 122 | PeriodDuration(fullProps).formatted(locale: esLocale), 123 | "1 año, 2 meses, 3 días, 4 horas, 5 minutos y 6 segundos" 124 | ) 125 | } 126 | 127 | func testPeriodFormatting_esLocale() { 128 | XCTAssertEqual( 129 | Period.zero.formatted(locale: esLocale), 130 | "0 días" 131 | ) 132 | XCTAssertEqual( 133 | Period(fullProps).formatted(locale: esLocale), 134 | "1 año, 2 meses y 3 días" 135 | ) 136 | } 137 | 138 | func testDurationFormatting_esLocale() { 139 | XCTAssertEqual( 140 | Duration.zero.formatted(locale: esLocale), 141 | "0 segundos" 142 | ) 143 | XCTAssertEqual( 144 | Duration(fullProps).formatted(locale: esLocale), 145 | "4 horas, 5 minutos y 6 segundos" 146 | ) 147 | } 148 | } 149 | #endif 150 | -------------------------------------------------------------------------------- /Tests/PeriodDurationTests/FoundationSupportTests.swift: -------------------------------------------------------------------------------- 1 | import CustomDump 2 | import PeriodDuration 3 | import XCTest 4 | 5 | final class FoundationSupportTests: XCTestCase { 6 | func testPeriodDurationAsDateComponents() { 7 | XCTAssertNoDifference( 8 | PeriodDuration.zero.asDateComponents, 9 | DateComponents(zeroProps) 10 | ) 11 | XCTAssertNoDifference( 12 | PeriodDuration(fullProps).asDateComponents, 13 | DateComponents(fullProps) 14 | ) 15 | } 16 | 17 | func testPeriodAsDateComponents() { 18 | XCTAssertNoDifference( 19 | Period.zero.asDateComponents, 20 | DateComponents(year: 0, month: 0, day: 0) 21 | ) 22 | XCTAssertNoDifference( 23 | Period(fullProps).asDateComponents, 24 | DateComponents(year: 1, month: 2, day: 3) 25 | ) 26 | } 27 | 28 | func testDurationAsDateComponents() { 29 | XCTAssertNoDifference( 30 | Duration.zero.asDateComponents, 31 | DateComponents(hour: 0, minute: 0, second: 0) 32 | ) 33 | XCTAssertNoDifference( 34 | Duration(fullProps).asDateComponents, 35 | DateComponents(hour: 4, minute: 5, second: 6) 36 | ) 37 | } 38 | 39 | #if !os(Linux) 40 | func formatter(units: NSCalendar.Unit) -> DateComponentsFormatter { 41 | let formatter = DateComponentsFormatter() 42 | formatter.allowedUnits = units 43 | formatter.unitsStyle = .full 44 | var calendar = Calendar(identifier: .iso8601) 45 | calendar.locale = Locale(identifier: "en_US_POSIX") 46 | calendar.timeZone = TimeZone(secondsFromGMT: 0)! 47 | formatter.calendar = calendar 48 | return formatter 49 | } 50 | 51 | func testDateComponentsFormatter_stringFromPeriodDuration() { 52 | let formatter = formatter(units: [.year, .month, .day, .hour, .minute, .second]) 53 | XCTAssertEqual( 54 | formatter.string(from: PeriodDuration(fullProps)), 55 | "1 year, 2 months, 3 days, 4 hours, 5 minutes, 6 seconds" 56 | ) 57 | XCTAssertEqual( 58 | formatter.string(from: PeriodDuration.zero), 59 | "0 seconds" 60 | ) 61 | } 62 | 63 | func testDateComponentsFormatter_stringFromPeriod() { 64 | let formatter = formatter(units: [.year, .month, .day]) 65 | XCTAssertEqual( 66 | formatter.string(from: Period(fullProps)), 67 | "1 year, 2 months, 3 days" 68 | ) 69 | XCTAssertEqual( 70 | formatter.string(from: Period.zero), 71 | "0 days" 72 | ) 73 | } 74 | 75 | func testDateComponentsFormatter_stringFromDuration() { 76 | let formatter = formatter(units: [.hour, .minute, .second]) 77 | XCTAssertEqual( 78 | formatter.string(from: Duration(fullProps)), 79 | "4 hours, 5 minutes, 6 seconds" 80 | ) 81 | XCTAssertEqual( 82 | formatter.string(from: Duration.zero), 83 | "0 seconds" 84 | ) 85 | } 86 | #endif 87 | } 88 | -------------------------------------------------------------------------------- /Tests/PeriodDurationTests/ISO8601Tests.swift: -------------------------------------------------------------------------------- 1 | import PeriodDuration 2 | import XCTest 3 | 4 | final class ISO8601Tests: XCTestCase { 5 | func testPeriodDurationScenarios() throws { 6 | for s in scenarios { 7 | XCTAssertEqual(PeriodDuration(iso8601: s.input), s.output.map(PeriodDuration.init), "input: \(s.input)") 8 | if let output = s.output, s.roundtrippingType == PeriodDuration.self { 9 | XCTAssertEqual(PeriodDuration(output).formatted(style: .iso8601), s.input) 10 | } 11 | } 12 | } 13 | 14 | func testPeriodScenarios() throws { 15 | for s in scenarios { 16 | XCTAssertEqual(Period(iso8601: s.input), s.output.map(Period.init), "input: \(s.input)") 17 | if let output = s.output, s.roundtrippingType == Period.self { 18 | XCTAssertEqual(Period(output).formatted(style: .iso8601), s.input) 19 | } 20 | } 21 | } 22 | 23 | func testDurationScenarios() throws { 24 | for s in scenarios { 25 | XCTAssertEqual(Duration(iso8601: s.input), s.output.map(Duration.init), "input: \(s.input)") 26 | if let output = s.output, s.roundtrippingType == Duration.self { 27 | XCTAssertEqual(Duration(output).formatted(style: .iso8601), s.input) 28 | } 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Tests/PeriodDurationTests/IsBlankTests.swift: -------------------------------------------------------------------------------- 1 | import PeriodDuration 2 | import XCTest 3 | 4 | final class ZeroTests: XCTestCase { 5 | func testPeriodDurationZero() { 6 | let sut = PeriodDuration.zero 7 | XCTAssertEqual(sut.years, 0) 8 | XCTAssertEqual(sut.months, 0) 9 | XCTAssertEqual(sut.days, 0) 10 | XCTAssertEqual(sut.hours, 0) 11 | XCTAssertEqual(sut.minutes, 0) 12 | XCTAssertEqual(sut.seconds, 0) 13 | } 14 | 15 | func testPeriodZero() { 16 | let sut = Period.zero 17 | XCTAssertEqual(sut.years, 0) 18 | XCTAssertEqual(sut.months, 0) 19 | XCTAssertEqual(sut.days, 0) 20 | } 21 | 22 | func testDurationZero() { 23 | let sut = Duration.zero 24 | XCTAssertEqual(sut.hours, 0) 25 | XCTAssertEqual(sut.minutes, 0) 26 | XCTAssertEqual(sut.seconds, 0) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Tests/PeriodDurationTests/Props.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import PeriodDuration 3 | import XCTest 4 | 5 | struct Props { 6 | var years: Int = 0 7 | var months: Int = 0 8 | var days: Int = 0 9 | var hours: Int = 0 10 | var minutes: Int = 0 11 | var seconds: Int = 0 12 | } 13 | 14 | extension PeriodDuration { 15 | init(_ props: Props) { 16 | self.init( 17 | years: props.years, 18 | months: props.months, 19 | days: props.days, 20 | hours: props.hours, 21 | minutes: props.minutes, 22 | seconds: props.seconds 23 | ) 24 | } 25 | } 26 | 27 | extension Period { 28 | init(_ props: Props) { 29 | self.init( 30 | years: props.years, 31 | months: props.months, 32 | days: props.days 33 | ) 34 | } 35 | } 36 | 37 | extension Duration { 38 | init(_ props: Props) { 39 | self.init( 40 | hours: props.hours, 41 | minutes: props.minutes, 42 | seconds: props.seconds 43 | ) 44 | } 45 | } 46 | 47 | extension DateComponents { 48 | init(_ props: Props) { 49 | self.init( 50 | year: props.years, 51 | month: props.months, 52 | day: props.days, 53 | hour: props.hours, 54 | minute: props.minutes, 55 | second: props.seconds 56 | ) 57 | } 58 | } 59 | 60 | final class PropsTests: XCTestCase { 61 | func testInitPeriodDurationWithProps() { 62 | let sut = PeriodDuration(fullProps) 63 | XCTAssertEqual(sut.years, 1) 64 | XCTAssertEqual(sut.months, 2) 65 | XCTAssertEqual(sut.days, 3) 66 | XCTAssertEqual(sut.hours, 4) 67 | XCTAssertEqual(sut.minutes, 5) 68 | XCTAssertEqual(sut.seconds, 6) 69 | } 70 | 71 | func testInitPeriodWithProps() { 72 | let sut = Period(fullProps) 73 | XCTAssertEqual(sut.years, 1) 74 | XCTAssertEqual(sut.months, 2) 75 | XCTAssertEqual(sut.days, 3) 76 | } 77 | 78 | func testInitDurationWithProps() { 79 | let sut = Duration(fullProps) 80 | XCTAssertEqual(sut.hours, 4) 81 | XCTAssertEqual(sut.minutes, 5) 82 | XCTAssertEqual(sut.seconds, 6) 83 | } 84 | 85 | func testInitDateComponentsWithProps() { 86 | let sut = DateComponents(fullProps) 87 | XCTAssertEqual(sut.year, 1) 88 | XCTAssertEqual(sut.month, 2) 89 | XCTAssertEqual(sut.day, 3) 90 | XCTAssertEqual(sut.hour, 4) 91 | XCTAssertEqual(sut.minute, 5) 92 | XCTAssertEqual(sut.second, 6) 93 | } 94 | } 95 | 96 | let zeroProps = Props(years: 0, months: 0, days: 0, hours: 0, minutes: 0, seconds: 0) 97 | let fullProps = Props(years: 1, months: 2, days: 3, hours: 4, minutes: 5, seconds: 6) 98 | -------------------------------------------------------------------------------- /Tests/PeriodDurationTests/Scenarios.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import JSONTesting 3 | import PeriodDuration 4 | 5 | let scenarios: [Scenario] = [ 6 | // MARK: Full 7 | .init("YMWDTHMS", nil), 8 | .init("3YMWDTHMS", nil), // Y 9 | .init("Y3MWDTHMS", nil), // M 10 | .init("YM3WDTHMS", nil), // W 11 | .init("YMW3DTHMS", nil), // D 12 | .init("YMWDT3HMS", nil), // H 13 | .init("YMWDTH3MS", nil), // m 14 | .init("YMWDTHM3S", nil), // S 15 | .init("3Y3M3W3DT3H3M3S", nil), // YMWDHMS 16 | 17 | .init("PYMWDTHMS", .init()), 18 | .init("P3YMWDTHMS", .init(years: 3)), // Y 19 | .init("PY3MWDTHMS", .init(months: 3)), // M 20 | .init("PYM3WDTHMS", .init(days: 21)), // W 21 | .init("PYMW3DTHMS", .init(days: 3)), // D 22 | .init("PYMWDT3HMS", .init(hours: 3)), // H 23 | .init("PYMWDTH3MS", .init(minutes: 3)), // m 24 | .init("PYMWDTHM3S", .init(seconds: 3)), // S 25 | .init("P3Y3M3W3DT3H3M3S", .init(years: 3, months: 3, days: 24, hours: 3, minutes: 3, seconds: 3)), // YMWDHMS 26 | .init("P3Y3M3DT3H3M3S", .init(years: 3, months: 3, days: 3, hours: 3, minutes: 3, seconds: 3), roundtripWhen: PeriodDuration.self), // YMDHMS 27 | 28 | // MARK: Period Full 29 | .init("YMWD", nil), 30 | .init("3YMWD", nil), // Y 31 | .init("Y3MWD", nil), // M 32 | .init("YM3WD", nil), // W 33 | .init("YMW3D", nil), // D 34 | .init("3Y3MWD", nil), // YM 35 | .init("YM3W3D", nil), // WD 36 | .init("3YM3WD", nil), // YW 37 | .init("3YMW3D", nil), // YD 38 | .init("3Y3M3WD", nil), // YMW 39 | .init("Y3M3W3D", nil), // MWD 40 | .init("3Y3M3W3D", nil), // YMWD 41 | 42 | .init("PYMWD", .init()), 43 | .init("P3YMWD", .init(years: 3)), // Y 44 | .init("PY3MWD", .init(months: 3)), // M 45 | .init("PYM3WD", .init(days: 21)), // W 46 | .init("PYMW3D", .init(days: 3)), // D 47 | .init("P3Y3MWD", .init(years: 3, months: 3)), // YM 48 | .init("PYM3W3D", .init(days: 24)), // WD 49 | .init("P3YM3WD", .init(years: 3, days: 21)), // YW 50 | .init("P3YMW3D", .init(years: 3, days: 3)), // YD 51 | .init("P3Y3M3WD", .init(years: 3, months: 3, days: 21)), // YMW 52 | .init("PY3M3W3D", .init(months: 3, days: 24)), // MWD 53 | .init("P3Y3M3W3D", .init(years: 3, months: 3, days: 24)), // YMWD 54 | .init("P3Y3M3D", .init(years: 3, months: 3, days: 3), roundtripWhen: Period.self), // YMD 55 | 56 | // MARK: Period Individual 57 | .init("Y", nil), 58 | .init("3Y", nil), 59 | 60 | .init("PY", .init()), 61 | .init("P3Y", .init(years: 3)), 62 | 63 | .init("M", nil), 64 | .init("3M", nil), 65 | 66 | .init("PM", .init()), 67 | .init("P3M", .init(months: 3)), 68 | 69 | .init("W", nil), 70 | .init("3W", nil), 71 | 72 | .init("PW", .init()), 73 | .init("P3W", .init(days: 21)), 74 | 75 | .init("D", nil), 76 | .init("3D", nil), 77 | 78 | .init("PD", .init()), 79 | .init("P3D", .init(days: 3)), 80 | 81 | // MARK: Duration Full 82 | .init("THMS", nil), 83 | .init("T3HMS", nil), // H 84 | .init("TH3MS", nil), // M 85 | .init("THM3S", nil), // S 86 | .init("T3H3MS", nil), // HM 87 | .init("TH3M3S", nil), // MS 88 | .init("T3HM3S", nil), // HS 89 | .init("T3H3M3S", nil), // HMS 90 | 91 | .init("PTHMS", .init()), 92 | .init("PT3HMS", .init(hours: 3)), // H 93 | .init("PTH3MS", .init(minutes: 3)), // M 94 | .init("PTHM3S", .init(seconds: 3)), // S 95 | .init("PT3H3MS", .init(hours: 3, minutes: 3)), // HM 96 | .init("PTH3M3S", .init(minutes: 3, seconds: 3)), // MS 97 | .init("PT3HM3S", .init(hours: 3, seconds: 3)), // HS 98 | .init("PT3H3M3S", .init(hours: 3, minutes: 3, seconds: 3), roundtripWhen: Duration.self), // HMS 99 | 100 | // MARK: Duration Individual 101 | .init("H", nil), 102 | .init("TH", nil), 103 | .init("3H", nil), 104 | .init("T3H", nil), 105 | 106 | .init("PT", .init(), roundtripWhen: Duration.self), 107 | .init("PTH", .init()), 108 | .init("PT3H", .init(hours: 3), roundtripWhen: Duration.self), 109 | 110 | .init("M", nil), 111 | .init("TM", nil), 112 | .init("3M", nil), 113 | .init("T3M", nil), 114 | 115 | .init("PTM", .init()), 116 | .init("PT3M", .init(minutes: 3), roundtripWhen: Duration.self), 117 | 118 | .init("S", nil), 119 | .init("TS", nil), 120 | .init("3S", nil), 121 | .init("T3S", nil), 122 | 123 | .init("PTS", .init()), 124 | .init("PT3S", .init(seconds: 3), roundtripWhen: Duration.self), 125 | 126 | // MARK: Edge Cases 127 | .init("", nil), 128 | .init(" ", nil), 129 | .init("3", nil), 130 | .init("P3", nil), 131 | .init("P3 ", nil), 132 | .init("P3 ", nil), 133 | .init("T3", nil), 134 | .init("PT3", nil), 135 | .init("*", nil), 136 | .init("PT3H*", nil), 137 | .init("PT3.0H", nil), 138 | .init("PT3,2H", nil), 139 | .init("PT32_H", nil), 140 | .init("PT_32H", nil), 141 | .init("PT 32H", nil), 142 | .init("PT32 H", nil), 143 | .init(" PT32H", nil), 144 | .init("PT32H ", nil), 145 | .init(" PT32H ", nil), 146 | ] 147 | 148 | struct Scenario { 149 | typealias Input = String 150 | typealias Output = Props 151 | 152 | /// Raw ISO 8601 input 153 | let input: Input 154 | /// Expected props computed from input 155 | let output: Output? 156 | /// A type for which the input and output is exactly the same both ways (encoding & decoding) 157 | let roundtrippingType: Any.Type? 158 | 159 | init(_ input: Input, _ output: Output?, roundtripWhen roundtrippingType: Any.Type? = nil) { 160 | self.input = input 161 | self.output = output 162 | self.roundtrippingType = roundtrippingType 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /Tests/PeriodDurationTests/SignsTests.swift: -------------------------------------------------------------------------------- 1 | import PeriodDuration 2 | import XCTest 3 | 4 | final class SignsTests: XCTestCase { 5 | func testPeriodDurationSigns() { 6 | XCTAssertEqual(-PeriodDuration.zero, PeriodDuration.zero) 7 | XCTAssertEqual(-PeriodDuration(fullProps), PeriodDuration(years: -1, months: -2, days: -3, hours: -4, minutes: -5, seconds: -6)) 8 | XCTAssertEqual(-PeriodDuration(years: -1, months: -2, days: -3, hours: -4, minutes: -5, seconds: -6), PeriodDuration(fullProps)) 9 | 10 | XCTAssertEqual(+PeriodDuration.zero, PeriodDuration.zero) 11 | XCTAssertEqual(+PeriodDuration(fullProps), PeriodDuration(fullProps)) 12 | XCTAssertEqual(+PeriodDuration(years: -1, months: -2, days: -3, hours: -4, minutes: -5, seconds: -6), PeriodDuration(years: -1, months: -2, days: -3, hours: -4, minutes: -5, seconds: -6)) 13 | } 14 | 15 | func testPeriodSigns() { 16 | XCTAssertEqual(-Period.zero, Period.zero) 17 | XCTAssertEqual(-Period(fullProps), Period(years: -1, months: -2, days: -3)) 18 | XCTAssertEqual(-Period(years: -1, months: -2, days: -3), Period(fullProps)) 19 | 20 | XCTAssertEqual(+Period.zero, Period.zero) 21 | XCTAssertEqual(+Period(fullProps), Period(fullProps)) 22 | XCTAssertEqual(+Period(years: -1, months: -2, days: -3), Period(years: -1, months: -2, days: -3)) 23 | } 24 | 25 | func testDurationSigns() { 26 | XCTAssertEqual(-Duration.zero, Duration.zero) 27 | XCTAssertEqual(-Duration(fullProps), Duration(hours: -4, minutes: -5, seconds: -6)) 28 | XCTAssertEqual(-Duration(hours: -4, minutes: -5, seconds: -6), Duration(fullProps)) 29 | 30 | XCTAssertEqual(+Duration.zero, Duration.zero) 31 | XCTAssertEqual(+Duration(fullProps), Duration(fullProps)) 32 | XCTAssertEqual(+Duration(hours: -4, minutes: -5, seconds: -6), Duration(hours: -4, minutes: -5, seconds: -6)) 33 | } 34 | } 35 | --------------------------------------------------------------------------------