diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index b398e53..aac2f02 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -24,4 +24,5 @@ jobs: - run: swift test --enable-code-coverage - uses: drekka/swift-coverage-action@v1 with: + coverage: 90 show-all-files: true diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/DayType-Package.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/DayType-Package.xcscheme new file mode 100644 index 0000000..e738e2a --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/DayType-Package.xcscheme @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/DayTypeMacros.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/DayTypeMacros.xcscheme new file mode 100644 index 0000000..38c35f3 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/DayTypeMacros.xcscheme @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DayType-Package.xctestplan b/DayType-Package.xctestplan new file mode 100644 index 0000000..4d86245 --- /dev/null +++ b/DayType-Package.xctestplan @@ -0,0 +1,24 @@ +{ + "configurations" : [ + { + "id" : "F28B9C65-0AA9-4599-8E55-40AEEF5A8B41", + "name" : "Test Scheme Action", + "options" : { + + } + } + ], + "defaultOptions" : { + + }, + "testTargets" : [ + { + "target" : { + "containerPath" : "container:", + "identifier" : "DayTypeTests", + "name" : "DayTypeTests" + } + } + ], + "version" : 1 +} diff --git a/Macros/Implementations/EpochPropertyWrapperMacro.swift b/Macros/Implementations/EpochPropertyWrapperMacro.swift new file mode 100644 index 0000000..6ff5f69 --- /dev/null +++ b/Macros/Implementations/EpochPropertyWrapperMacro.swift @@ -0,0 +1,56 @@ +import SwiftDiagnostics +import SwiftSyntax +import SwiftSyntaxMacros + +struct EpochPropertyWrapperMacro: MemberMacro { + + static func expansion( + of node: AttributeSyntax, + providingMembersOf _: some DeclGroupSyntax, + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + + guard let typeName = node.arguments?.stringArgumentValue("typeName"), + let milliseconds = node.arguments?.argumentValue("milliseconds") else { + context.diagnose(Diagnostic( + node: node, message: MacroExpansionErrorMessage("typeName or milliseconds argument not supplied. Passed arguments \(node.arguments?.description ?? "")") + )) + return [] + } + + let withNullableImplementation = (node.arguments?.argumentValue("withNullableImplementation") ?? "true") == "true" + + return [ + DeclSyntax(stringLiteral: propertyWrapper(name: typeName, milliseconds: milliseconds, withNullableImplementation: withNullableImplementation)), + ] + } + + private static func propertyWrapper(name: String, milliseconds: String, withNullableImplementation: Bool) -> String { + let writeNulls = withNullableImplementation ? "false" : "true" + return """ + @propertyWrapper + public struct \(name): Codable { + + \(withNullableImplementation ? propertyWrapper(name: "Nullable", milliseconds: milliseconds, withNullableImplementation: false) : "") + + public var wrappedValue: DayType + + // We need to expose these values so keyed containers can access them. + let writeNulls = \(writeNulls) + let milliseconds = \(milliseconds) + + public init(wrappedValue: DayType) { + self.wrappedValue = wrappedValue + } + + public init(from decoder: Decoder) throws { + wrappedValue = try DayType.decode(using: decoder, milliseconds: \(milliseconds)) + } + + public func encode(to encoder: Encoder) throws { + try wrappedValue.encode(using: encoder, milliseconds: \(milliseconds), writeNulls: writeNulls) + } + } + """ + } +} diff --git a/Macros/Implementations/FormattedStringPropertyWrapperMacro.swift b/Macros/Implementations/FormattedStringPropertyWrapperMacro.swift new file mode 100644 index 0000000..c01ccaf --- /dev/null +++ b/Macros/Implementations/FormattedStringPropertyWrapperMacro.swift @@ -0,0 +1,59 @@ +import SwiftDiagnostics +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros + +/// Adds a static embedeed type for encoding and decoding using a passed formatter. +struct FormattedStringPropertyWrapperMacro: MemberMacro { + + static func expansion( + of node: AttributeSyntax, + providingMembersOf _: some DeclGroupSyntax, + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + + guard let typeName = node.arguments?.stringArgumentValue("name"), + let formatter = node.arguments?.argumentValue("formatter") + else { + context.diagnose(Diagnostic( + node: node, message: MacroExpansionErrorMessage("typeName, formatter or writeNulls argument not supplied. Passed arguments: \(node.arguments?.description ?? "")") + )) + return [] + } + + let withNullableImplementation = (node.arguments?.argumentValue("withNullableImplementation") ?? "true") == "true" + + return [ + DeclSyntax(stringLiteral: propertyWrapper(name: typeName, formatter: formatter, withNullableImplementation: withNullableImplementation)), + ] + } + + private static func propertyWrapper(name: String, formatter: String, withNullableImplementation: Bool) -> String { + let writeNulls = withNullableImplementation ? "false" : "true" + return """ + @propertyWrapper + public struct \(name): Codable { + + \(withNullableImplementation ? propertyWrapper(name: "Nullable", formatter: formatter, withNullableImplementation: false) : "") + + public var wrappedValue: DayType + + // We need to expose these values so keyed containers can access them. + let formatter = \(formatter) + let writeNulls = \(writeNulls) + + public init(wrappedValue: DayType) { + self.wrappedValue = wrappedValue + } + + public init(from decoder: Decoder) throws { + wrappedValue = try DayType.decode(from: decoder, formatter: \(formatter)) + } + + public func encode(to encoder: Encoder) throws { + try wrappedValue.encode(into: encoder, formatter: \(formatter), writeNulls: \(writeNulls)) + } + } + """ + } +} diff --git a/Macros/Implementations/KeyedContainerDecodeMissingDayStringMacro.swift b/Macros/Implementations/KeyedContainerDecodeMissingDayStringMacro.swift new file mode 100644 index 0000000..19410b7 --- /dev/null +++ b/Macros/Implementations/KeyedContainerDecodeMissingDayStringMacro.swift @@ -0,0 +1,35 @@ +import SwiftDiagnostics +import SwiftSyntax +import SwiftSyntaxMacros + +struct KeyedContainerDecodeMissingDayStringMacro: MemberMacro { + + static func expansion( + of node: AttributeSyntax, + providingMembersOf owningType: some DeclGroupSyntax, + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + + guard owningType.as(ExtensionDeclSyntax.self)?.extendedType.trimmedDescription == "KeyedDecodingContainer" else { + context.diagnose(Diagnostic( + node: node, message: MacroExpansionErrorMessage("Can only be used on a KeyedDecodingContainer: \(owningType.as(ExtensionDeclSyntax.self)?.extendedType.trimmedDescription ?? "")") + )) + return [] + } + + guard let decodableType = node.arguments?.typeArgumentValue("type") else { + context.diagnose(Diagnostic( + node: node, message: MacroExpansionErrorMessage("Unable to determine passed type argument from: \(node.arguments?.description ?? "")") + )) + return [] + } + + return [ + """ + public func decode(_ type: \(raw: decodableType).Type, forKey key: Key) throws -> \(raw: decodableType) { + try decodeIfPresent(type, forKey: key) ?? .init(wrappedValue: nil) + } + """, + ] + } +} diff --git a/Macros/Implementations/KeyedContainerDecodeMissingEpochMacro.swift b/Macros/Implementations/KeyedContainerDecodeMissingEpochMacro.swift new file mode 100644 index 0000000..7428f32 --- /dev/null +++ b/Macros/Implementations/KeyedContainerDecodeMissingEpochMacro.swift @@ -0,0 +1,35 @@ +import SwiftDiagnostics +import SwiftSyntax +import SwiftSyntaxMacros + +struct KeyedContainerDecodeMissingEpochMacro: MemberMacro { + + static func expansion( + of node: AttributeSyntax, + providingMembersOf owningType: some DeclGroupSyntax, + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + + guard owningType.as(ExtensionDeclSyntax.self)?.extendedType.trimmedDescription == "KeyedDecodingContainer" else { + context.diagnose(Diagnostic( + node: node, message: MacroExpansionErrorMessage("Can only be used on a KeyedDecodingContainer: \(owningType.as(ExtensionDeclSyntax.self)?.extendedType.trimmedDescription ?? "")") + )) + return [] + } + + guard let decodableType = node.arguments?.typeArgumentValue("type") else { + context.diagnose(Diagnostic( + node: node, message: MacroExpansionErrorMessage("Unable to determine passed type argument from: \(node.arguments?.description ?? "")") + )) + return [] + } + + return [ + """ + public func decode(_ type: \(raw: decodableType).Type, forKey key: Key) throws -> \(raw: decodableType) { + try decodeIfPresent(type, forKey: key) ?? .init(wrappedValue: nil) + } + """, + ] + } +} diff --git a/Macros/Implementations/KeyedContainerEncodeMissingDayStringMacro.swift b/Macros/Implementations/KeyedContainerEncodeMissingDayStringMacro.swift new file mode 100644 index 0000000..6b67a57 --- /dev/null +++ b/Macros/Implementations/KeyedContainerEncodeMissingDayStringMacro.swift @@ -0,0 +1,41 @@ +import SwiftDiagnostics +import SwiftSyntax +import SwiftSyntaxMacros + +struct KeyedContainerEncodeMissingDayStringMacro: MemberMacro { + + static func expansion( + of node: AttributeSyntax, + providingMembersOf owningType: some DeclGroupSyntax, + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + + guard owningType.as(ExtensionDeclSyntax.self)?.extendedType.trimmedDescription == "KeyedEncodingContainer" else { + context.diagnose(Diagnostic( + node: node, message: MacroExpansionErrorMessage("Can only be used on a KeyedEncodingContainer: \(owningType.as(ExtensionDeclSyntax.self)?.extendedType.trimmedDescription ?? "")") + )) + return [] + } + + guard let encodableType = node.arguments?.typeArgumentValue("type") else { + context.diagnose(Diagnostic( + node: node, message: MacroExpansionErrorMessage("Unable to determine passed type argument from: \(node.arguments?.description ?? "")") + )) + return [] + } + + return [ + """ + /// Encoding must be handled by the keyed container or we end up writing keys with missing values. + public mutating func encode(_ propertyWrapper: \(raw: encodableType), forKey key: Key) throws { + // If there is a value then use the property wrappers to convert it to a string and write that. + if let value = propertyWrapper.wrappedValue { + try self.encode(propertyWrapper.formatter.string(from: value.date()), forKey: key) + } else if propertyWrapper.writeNulls { + try self.encodeNil(forKey: key) + } + } + """, + ] + } +} diff --git a/Macros/Implementations/KeyedContainerEncodeMissingEpochMacro.swift b/Macros/Implementations/KeyedContainerEncodeMissingEpochMacro.swift new file mode 100644 index 0000000..30d8247 --- /dev/null +++ b/Macros/Implementations/KeyedContainerEncodeMissingEpochMacro.swift @@ -0,0 +1,40 @@ +import SwiftDiagnostics +import SwiftSyntax +import SwiftSyntaxMacros + +struct KeyedContainerEncodeMissingEpochMacro: MemberMacro { + + static func expansion( + of node: AttributeSyntax, + providingMembersOf owningType: some DeclGroupSyntax, + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + + guard owningType.as(ExtensionDeclSyntax.self)?.extendedType.trimmedDescription == "KeyedEncodingContainer" else { + context.diagnose(Diagnostic( + node: node, message: MacroExpansionErrorMessage("Can only be used on a KeyedEncodingContainer: \(owningType.as(ExtensionDeclSyntax.self)?.extendedType.trimmedDescription ?? "")") + )) + return [] + } + + guard let encodableType = node.arguments?.typeArgumentValue("type") else { + context.diagnose(Diagnostic( + node: node, message: MacroExpansionErrorMessage("Unable to determine passed type argument from: \(node.arguments?.description ?? "")") + )) + return [] + } + + return [ + """ + /// Encoding must be handled by the keyed container or we end up writing keys with missing values. + public mutating func encode(_ propertyWrapper: \(raw: encodableType), forKey key: Key) throws { + if let value = propertyWrapper.wrappedValue { + try self.encode(value.date().timeIntervalSince1970 * (propertyWrapper.milliseconds ? 1000 : 1), forKey: key) + } else if propertyWrapper.writeNulls { + try self.encodeNil(forKey: key) + } + } + """, + ] + } +} diff --git a/Macros/Implementations/Plugins.swift b/Macros/Implementations/Plugins.swift new file mode 100644 index 0000000..e6d5d32 --- /dev/null +++ b/Macros/Implementations/Plugins.swift @@ -0,0 +1,14 @@ +import SwiftCompilerPlugin +import SwiftSyntaxMacros + +@main +struct MyMacroPlugin: CompilerPlugin { + let providingMacros: [Macro.Type] = [ + FormattedStringPropertyWrapperMacro.self, + EpochPropertyWrapperMacro.self, + KeyedContainerDecodeMissingDayStringMacro.self, + KeyedContainerEncodeMissingDayStringMacro.self, + KeyedContainerDecodeMissingEpochMacro.self, + KeyedContainerEncodeMissingEpochMacro.self, + ] +} diff --git a/Macros/Implementations/Syntax+Extensions.swift b/Macros/Implementations/Syntax+Extensions.swift new file mode 100644 index 0000000..da224cc --- /dev/null +++ b/Macros/Implementations/Syntax+Extensions.swift @@ -0,0 +1,41 @@ +import SwiftDiagnostics +import SwiftSyntax +import SwiftSyntaxMacros + +extension AttributeSyntax.Arguments { + + func argumentValue(_ key: String) -> String? { + asLabeledExpressionList?.argumentValue(key) + } + + func stringArgumentValue(_ key: String) -> String? { + asLabeledExpressionList?.stringArgumentValue(key) + } + + func typeArgumentValue(_ key: String) -> String? { + asLabeledExpressionList?.typeArgumentValue(key) + } + + private var asLabeledExpressionList: LabeledExprListSyntax? { + self.as(LabeledExprListSyntax.self) + } +} + +extension LabeledExprListSyntax { + + func argumentValue(_ key: String) -> String? { + expression(key)?.trimmedDescription + } + + func stringArgumentValue(_ key: String) -> String? { + expression(key)?.as(StringLiteralExprSyntax.self)?.segments.trimmedDescription + } + + func typeArgumentValue(_ key: String) -> String? { + expression(key)?.as(MemberAccessExprSyntax.self)?.base?.trimmedDescription + } + + private func expression(_ key: String) -> ExprSyntax? { + first(where: { $0.label?.text == key })?.expression + } +} diff --git a/Macros/Module/Macros.swift b/Macros/Module/Macros.swift new file mode 100644 index 0000000..4e73e21 --- /dev/null +++ b/Macros/Module/Macros.swift @@ -0,0 +1,34 @@ +import Foundation + +// MARK: - Type macros + +@attached(member, names: arbitrary) +public macro DayStringPropertyWrapper(name: String, formatter: DateFormatter, withNullableImplementation: Bool = true) = #externalMacro(module: "DayTypeMacroImplementations", type: "FormattedStringPropertyWrapperMacro") + +@attached(member, names: arbitrary) +public macro ISO8601PropertyWrapper(name: String, formatter: ISO8601DateFormatter, withNullableImplementation: Bool = true) = #externalMacro(module: "DayTypeMacroImplementations", type: "FormattedStringPropertyWrapperMacro") + +// Refactor these +@attached(member, names: arbitrary) +public macro EpochPropertyWrapper(typeName: String, milliseconds: Bool, withNullableImplementation: Bool = true) = #externalMacro(module: "DayTypeMacroImplementations", type: "EpochPropertyWrapperMacro") + +// MARK: - Common macros + +@attached(member, names: named(decode), named(encode)) +public macro OptionalDayCodableImplementation(argumentName: String, argumentType: Any.Type) = #externalMacro(module: "DayTypeMacroImplementations", type: "OptionalDayCodableImplementationMacro") + +// MARK: - Keyed containers + +@attached(member, names: named(decode)) +public macro DecodeMissingString(type: Any.Type) = #externalMacro(module: "DayTypeMacroImplementations", type: "KeyedContainerDecodeMissingDayStringMacro") + +@attached(member, names: named(encode)) +public macro EncodeMissingString(type: Any.Type) = #externalMacro(module: "DayTypeMacroImplementations", type: "KeyedContainerEncodeMissingDayStringMacro") + +@attached(member, names: named(decode)) +public macro DecodeMissingEpoch(type: Any.Type) = #externalMacro(module: "DayTypeMacroImplementations", type: "KeyedContainerDecodeMissingEpochMacro") + +@attached(member, names: named(encode)) +public macro EncodeMissingEpoch(type: Any.Type) = #externalMacro(module: "DayTypeMacroImplementations", type: "KeyedContainerEncodeMissingEpochMacro") + + diff --git a/Package.resolved b/Package.resolved old mode 100755 new mode 100644 index 143534f..a3b73cc --- a/Package.resolved +++ b/Package.resolved @@ -1,32 +1,15 @@ { + "originHash" : "99bab288a62c38d106a0d481d77a387440627f8599eb73490986122e3076ff0c", "pins" : [ { - "identity" : "cwlcatchexception", + "identity" : "swift-syntax", "kind" : "remoteSourceControl", - "location" : "https://github.com/mattgallagher/CwlCatchException.git", + "location" : "https://github.com/swiftlang/swift-syntax.git", "state" : { - "revision" : "3b123999de19bf04905bc1dfdb76f817b0f2cc00", - "version" : "2.1.2" - } - }, - { - "identity" : "cwlpreconditiontesting", - "kind" : "remoteSourceControl", - "location" : "https://github.com/mattgallagher/CwlPreconditionTesting.git", - "state" : { - "revision" : "dc9af4781f2afdd1e68e90f80b8603be73ea7abc", - "version" : "2.2.0" - } - }, - { - "identity" : "nimble", - "kind" : "remoteSourceControl", - "location" : "https://github.com/quick/nimble", - "state" : { - "revision" : "eb5e3d717224fa0d1f6aff3fc2c5e8e81fa1f728", - "version" : "11.2.2" + "revision" : "0687f71944021d616d34d922343dcef086855920", + "version" : "600.0.1" } } ], - "version" : 2 + "version" : 3 } diff --git a/Package.swift b/Package.swift index 5989e86..d20c8fd 100755 --- a/Package.swift +++ b/Package.swift @@ -1,5 +1,6 @@ -// swift-tools-version:5.7 +// swift-tools-version:5.10 +import CompilerPluginSupport import PackageDescription let package = Package( @@ -10,20 +11,41 @@ let package = Package( ], products: [ .library(name: "DayType", targets: ["DayType"]), + .library(name: "DayTypeMacros", targets: ["DayTypeMacros"]), ], dependencies: [ - .package(url: "https://github.com/quick/nimble", from: "11.0.0"), + .package(url: "https://github.com/swiftlang/swift-syntax.git", from: "600.0.0-latest"), ], targets: [ .target( name: "DayType", + dependencies: [ + "DayTypeMacros", + ], path: "Sources" ), + .target( + name: "DayTypeMacros", + dependencies: [ + "DayTypeMacroImplementations", + ], + path: "Macros/Module" + ), + .macro( + name: "DayTypeMacroImplementations", + dependencies: [ + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + .product(name: "SwiftDiagnostics", package: "swift-syntax"), + .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), + ], + path: "Macros/Implementations" + ), .testTarget( name: "DayTypeTests", dependencies: [ "DayType", - .product(name: "Nimble", package: "nimble"), + .product(name: "SwiftSyntax", package: "swift-syntax"), + .product(name: "SwiftParser", package: "swift-syntax"), ], path: "Tests" ), diff --git a/README.md b/README.md index f93e3e7..b89d4a8 100755 --- a/README.md +++ b/README.md @@ -1,274 +1,205 @@ ![Calendar](media/Calendar.png) # DayType -_An API for dates that doesn't involve hours, minutes, seconds and timezones._ +_An API for dates and nothing else. No Calendars, no timezones, no hours, minutes or seconds. **Just dates!**._ -Swift provides excellent date support through it's `Date`, `Calendar`, `TimeZone` and other types. However there's a catch, they're all designed to with with specific points in time rather than the generalisations people often use. +Sure swift provides excellent date support with it's `Date`, `Calendar`, `TimeZone` and related types. But there's a catch - they're all designed to work with a specific point in time. And that's not always how people think, and sometimes not even what we get from a server. -For example, the APIS cannot refer to a person's birthday without anchoring it to a specific hour, minute, second and even partial second within a specific timezone. Yet people when discussion a person's birthday only think of the date in whatever timezone they are in. Not the exact moment of a person's birth which sometime's even the person being discussed doesn't know.The same goes for other dates people often work with, an employee leave, religious holidays, retail sales, festivals, etc all typically have a date associated, but not a time. +For example we never refer to a person's birthday as being a specific point in time. We don't say "Hey, it's Dave's birthday on the 29th of August at 2:14 am AEST". We simply say the 29th of August and everyone knows what we mean. But Apple's time APIs don't have that generalisation. And that means a whole lot of coding for developers whenever we need to deal in just days'. All sorts of code stripping times, adjusting for time zones, and comparing sanitized dates. All of which is easy to get wrong if you haven't done a lot of it. -As a result developers often find themselves writing code to strip time from Swift's `Date` in order to trick it into acting like a date. Often with mixed results as there are many technical issues to consider when coercing a point in time to such a generalisation. Especially with time zones and sometime questionable input from external sources. - -`DayType` provides simplify date handling through it's `Day` type. A `Day` is a representation of a 24 hours period instead of a specific point in time. ie. it doesn't have any hours, minutes, timezones, etc. This allows date code to be simpler because the developer no longer needs to sanitise time components, and that removes the angst of accidental bugs as well as making date based code considerably simpler. +So this is where `DayType` steps it. Basically it provides simplify date handling through a `Day` type which is a representation of a 24 hours period independant of any timezone and without hours, minutes, seconds or milliseconds. In other word a date as people think of it, and that can make your code considerably simpler. ## Installation -`DayType` is a SPM package only. +`DayType` is a SPM package only. So install it as you would install any other package. -# Day +# Introducing Day -The common type you'll use is `Day`. +DayType's common type is `Day` and the DayType package has all sorts of code to read, create and manipulate these `Day` types. Most of which is modelled off Apple's APIs so that as much as possible it will seem familiar. ## Initialisers -`Day` has a number of convenience initialisers which are pretty self explanatory and similar to Swift's `Date` initialisers: +A `Day` has a number of convenience initialisers. Most of which are self explanatory and similar to Swift's `Date`: ```swift -init() -init(daysSince1970: DayInterval) -init(timeIntervalSince1970: TimeInterval) -init(date: Date, usingCalendar calendar: Calendar = .current) -init(components: DayComponents) -init(_ year: Int, _ month: Int, _ day: Int) -init(year: Int, month: Int, day: Int) +init() // Creates a `Day` based on the current time. +init(daysSince1970: DayInterval) // Creates a `Day` using the number of days since 1970. +init(timeIntervalSince1970: TimeInterval) // Creates a `Day` from a `TimeInterval`. +init(date: Date, usingCalendar calendar: Calendar = .current) // Creates a `Day` from a `Date` with an optional calendar. +init(components: DayComponents) // Creates a `Day` from `DayComponents`. +init(_ year: Int, _ month: Int, _ day: Int) // Creates a `Day` from individual year, month and day values. Short form. +init(year: Int, month: Int, day: Int) // Creates a `Day` from individual year, month and day values. ``` ## Properties -### .daysSince1970 - -Literally the number of days since Swift's base date of 00:00:00 UTC on 1 January 1970. - -_Note that matches the number of days produced by this Apple API based code:_ - -```swift -let fromDate = Calendar.current.startOfDay(for: Date(timeIntervalSince1970: 0)) -let toDate = Calendar.current.startOfDay(for: Date()) -let numberOfDays = Calendar.current.dateComponents([.day], from: fromDate, to: toDate).day! -``` +### var daysSince1970: Int { get } -# Property wrappers +Literally the number of days since Swift's base date of 00:00:00 UTC on 1 January 1970. Note this is the number of whole days, dropping any spare seconds. -DayType's property wrappers are designed to address the mostly commonly seen issues when coding and decoding data from external sources. +> Note this effective matches the number of days produced by this API code: +> ```swift +> let fromDate = Calendar.current.startOfDay(for: Date(timeIntervalSince1970: 0)) +> let toDate = Calendar.current.startOfDay(for: Date()) +> let numberOfDays = Calendar.current.dateComponents([.day], from: fromDate, to: toDate).day! +> ``` -_Note: Whilst all of these wrappers support both `Day` and `Day?` properties through the use of the `DayCodable` protocol, it's also technically possible to apply this protocol to other types to make them convertible to a `Day`._ +## Mathematical operators -## @EpochSeconds +`Day` has a number of mathematical operators for adding and subtracting days from a `Day`: -Converts [epoch timestamps](https://www.epochconverter.com) to `Day`. For example the JSON data structure: +```swift -```json -{ - "dob":856616400 -} -``` +// Adding days +let day = Day(2000,1,1) + 5 // -> 2000-01-06 +day += 5 // -> 2000-01-11 -Can be read by: +// Subtracting days +let day = Day(2000,1,1) - 10 // -> 1999-12-21 +day -= 5 // -> 1999-12-16 -```swift -struct MyType: Codable { - @EpochSeconds var dob: Day // or Day? -} +// Obtaining a duration in days +Day(2000,1,10) - Day(2000,1,5) // -> 5 days duration. ``` -## @EpochMilliseconds +## Functions -Essentially the same as `@EpochSeconds` but expects the epoch time to be in millisecond [epoch timestamps](https://www.epochconverter.com). +### func date(inCalendar calendar: Calendar = .current, timeZone: TimeZone? = nil) -> Date -```json -{ - "dob":856616400123 -} -``` +Using the passed `Calendar` and `TimeZone` this function coverts a `Day` to a Swift `Date` with the time components being set to `00:00` (midnight). -Can be read by: +### func day(byAdding component: Day.Component, value: Int) -> Day -```swift -struct MyType: Codable { - @EpochMilliseconds var dob: Day // or Day? -} -``` +Adds any number of years, months or days to a `Day` and returns a new `day`. This is convenient for doing things like producing a sequence of dates for the same day on each month. -## @ISO8601 +### func formatted(_ day: Date.FormatStyle.DateStyle = .abbreviated) -> String -Converts [ISO8601](https://en.wikipedia.org/wiki/ISO_8601) date strings to `Day`. +Uses Apple's `Date.formatted(date:time:)` function to format the day into a `String` using the formatting specified in `Date.FormatStyle.DateStyle`. -```json -{ - "dob": "1997-02-22T13:00:00+11:00" -} -``` +# DayComponents -Can be read by: +Similar to how `Date` has `DateComponents` representing the individual parts of a date and time, `Day` has `DayComponents` which contain the day's year, month and day. -```swift -struct MyType: Codable { - @ISO8601 var dob: Day // or Day? -} -``` +# Protocol conformance -## @CustomISO8601 +## Codable -Where `T: DayCodable` and `Configurator: ISO8601Configurator`. +`Day` is fully `Codable`. -Internally `DayType` uses an `ISO8601DateFormatter` to read and write [ISO8601](https://en.wikipedia.org/wiki/ISO_8601) strings. As there are a variety of ISO8601 formats, this property wrapper allows you to pre-configure the formatter before it processes the string. +When encoded or decoded it uses an `Int` representing the number of days since 1 January 1970. This value can also be accessed via the `.daysSince1970` property. -```json -{ - "dob": "20120202 133323" -} -``` +## Equatable -Can be read by: +`Day` is `Equatable` so days can be compared. Ie. ```swift -enum MinimalFormat: ISO8601Configurator { - static func configure(formatter: ISO8601DateFormatter) { - formatter.timeZone = TimeZone(secondsFromGMT: 11 * 60 * 60) - formatter.formatOptions.insert(.withSpaceBetweenDateAndTime) - formatter.formatOptions.subtract([.withTimeZone, .withColonSeparatorInTime, .withDashSeparatorInDate]) } -} - -struct MyType: Codable { - @CustomISO8601 var dob: Day - // or ... - @CustomISO8601 var dob: Day? -} +Day(2001,2,3) == Day(2001,2,3) // true ``` -The property wrapper is configured trough a `ISO8601Configurator` protocol instance. There's only one function so implementing the protocol is pretty easy. - -_Note that because Swift does not current support specifying a default type for a generic argument, `@CustomISO8601` requires you to specify the `DayCodable` type (`Day` or `Day?`) which must match the type of the property._ - -### Supplied ISO8601 configurators - -#### ISO8601Config.Default - -This configurator does not change the formatter. It's main purpose is to support the `@ISO8601` property wrapper. +## Comparable -#### ISO8601Config.SansTimeZone +`Day` is `Comparable` which enables the comparable operators: `>`, `<`, `>=` and `<=` for comparing days. -This configurator is for the common situation where the ISO8601 string does not have the time zone specified. For example `"1997-02-22T13:00:00"`. +## Hashable -## @DateString +`Day` is also `Hashable` which allows it to be used as dictionary key in in a set. -Where `T: DayCodable` and `Configurator: DateStringConfigurator`. +## Stridable -This property wrapper handles dates stored as strings. It makes use of a custom configurator to specify the format of the date string with a number of common formats supplied. +`Day` is `Stridable` which means you can use it in for loops as well as with the `stride(from:to:by:)` function. For example: -```json -{ - "dob": "2012-12-02" +```swift +for day in Day(2000,1,1)...Day(2000,1,5) { + /// do something with the 1st, 2nd, 3rd, 4th and 5th. } -``` -Can be read by: +for day in Day(2000,1,1).. var dob: Day - // or ... - @DateString var dob: Day? +for day in stride(from: Day(2000,1,1), to: Day(2000,1,5), by: 2) { + /// do something with the 1st and 3rd. } ``` -The `DateStringConfigurator` protocol specifies only a single function which is `static`. That function is used to configure the formatter used to read and write the date strings. - - -_Note: Because Swift does not current support specifying a default type for a generic argument, `@DateString` requires you to specify the `DayCodable` type (`Day` or `Day?`)._ - -### Supplied date string configurators - -#### DateStringConfig.ISO +# Property wrappers -Reads date strings that follow the ISO8601 format but don't have any time components. ie. `2012-12-01' +DayType also provides a number of property wrappers which implement `Codable`, the intent being to allow easy conversions from all sorts of date formats that are often returned from servers. -#### DateStringConfig.DMY +All of the supplied property wrappers can read and write both `Day` and optional `Day?` properties and are grouped by the format of the data they expect to encode and decode. -Reads date strings using the `dd/MM/yyyy` date format. ie. `01/12/2012' +## `@DayString.DMY`, `@DayString.MDY` & `@DayString.YMD` -#### DateStringConfig.MDY - -Reads date strings using the `MM/dd/yyyy` date format. ie. `12/01/2012' - -# Manipulating Day types - -`Day` has also been extended to support a variety of functions and operators. it has `+`, `-`, `+=` and `-=` operators which can be used to add or subtract a number of days from a day. +These property wrappers are designed to encode and decode dates in the `dd/mm/yyyy`, `mm/dd/yyyy` and `yyyy/mm/dd` formats. For example: ```swift -let day = Day(2000,1,1) + 5 // -> 2000-01-06 -let day = Day(2000,1,1) - 10 // -> 1999-12-21 - -let day = Day(2000,1,1) -day += 5 // -> 2000-01-06 - -let day = Day(2000,1,1) -day -= 5 // -> 1999-12-21 +struct MyData { + @DayString.DMY var dmyDay: Day // "31/04/2025" + @DayString.MDY var mdyDay: Day // "04/31/2025" + @DayString.YMD var ymdOptionalDay: Day? // "2025/04/31" +} ``` -And you can subtract one day from another to get the duration between them. +## `@Epoch.seconds` & `@Epoch.milliseconds` + +Encodes and decodes days as [epoch timestamps](https://www.epochconverter.com). For example: ```swift -Day(2000,1,10) - Day(2000,1,5) // -> 5 days duration. +struct MyData { + @Epoch.Seconds var optionalSeconds: Day? // 1746059246 + @Epoch.Milliseconds var milliseconds: Day // 1746059246123 +} ``` -## Functions - -### .date(inCalendar:timeZone:) -> Date - -Using a passed `Calendar` and `TimeZone`, this function coverts a `Day` to a Swift `Date` with the `Day`'s year, month and day, and a time of `00:00` (midnight). With no arguments this function uses the current calendar and time zone. - -### .day(byAdding:, value:) -> Day +## `@ISO8601.Default` and `@ISO8601.SansTimezone` -Lets you add any number of years, months or days to a `Day` and get a new `day` back. This is convenient for doing things like producing a sequence of dates for the same day on each month. - -### .formatted(_:) -> String - -Wrapping `Date.formatted(date:time:)` this function formats a day using the standard formatting specified by the `Date.FormatStyle.DateStyle` styles. The time component of `Date.formatted(date:time:)` is omitted. - -# DayComponents - -Similar to the way `Date` has a matching `DateComponents`, `Day` has a matching `DayComponents`. In this case mostly as a convenient wrapper for passing the individual values for a year, month and day. - -# Protocol conformance +Encodes and decodes standard ISO8061 date strings. The only difference is that `@ISO8601.SansTimezone` is as it's name suggests, intended for reading strings written without a timezone value. For example -## Codable - -`Day` is fully `Codable`. - -It's base value is an `Int` representing the number of days since 1 January 1970 which can accessed via the `.daysSince1970` property. +```swift +struct MyData { + @ISO8601.Default var iso8601: Day // "2025-04-31T12:01:00Z+12:00" + @ISO8601.SansTimezone var OptionalSansTimezone: Day? // "2025-04-31T12:01:00" +} +``` +## Encoding and decoding nulls -## Equatable - -`Day` is `Equatable` so +By default all of DayType's property wrappers can handle decoding where the passed value is a `null` or if there is no value at all. For example: ```swift -Day(2001,2,3) == Day(2001,2,3) // true +struct MyData { + @DayString.DMY var dmy: Day? +} ``` -## Comparable - -`Day` is `Comparable` which lets you use all the comparable operators to compare dates. ie. `>`, `<`, `>=` and `<=`. - -## Hashable +Will read both of these JSONs, setting `dmy` to `nil`: -`Day` is `Hashable` so it can be used as dictionary keys and in sets. +```json +// Null value. +{ + "dmy": null +} -## Stridable +// Missing value. +{} +``` -`Day` is `Stridable` which means you can use it in for loops as well as with the `stride(from:to:by:)` function. +When encoding DayType will skip encoding `nil` values (so producing `{}`), however some API require `null` values. In order to handle these APIs DayType provides some nested property wrappers which will write `null` values instead of skipping the keys all together. For example: ```swift -for day in Day(2000,1,1)...Day(2000,1,5) { - /// do something with the 1st, 2nd, 3rd, 4th and 5th. +struct MyData { + @DayString.DMY.Nullable var dmy: Day? + @Epoch.Seconds.Nullable var seconds: Day? + @ISO8601.Default.Nullable var iso8601: Day? } +``` -for day in Day(2000,1,1).. Bool { + lhs.daysSince1970 < rhs.daysSince1970 + } +} diff --git a/Sources/Conformance/Day+CustomStringConvertable.swift b/Sources/Conformance/Day+CustomStringConvertable.swift new file mode 100644 index 0000000..232e297 --- /dev/null +++ b/Sources/Conformance/Day+CustomStringConvertable.swift @@ -0,0 +1,7 @@ +import Foundation + +extension Day: CustomStringConvertible { + public var description: String { + formatted() + } +} diff --git a/Sources/Conformance/Day+Equatable.swift b/Sources/Conformance/Day+Equatable.swift new file mode 100644 index 0000000..8000622 --- /dev/null +++ b/Sources/Conformance/Day+Equatable.swift @@ -0,0 +1,7 @@ +import Foundation + +extension Day: Equatable { + public static func == (lhs: Day, rhs: Day) -> Bool { + lhs.daysSince1970 == rhs.daysSince1970 + } +} diff --git a/Sources/Conformance/Day+Hashable.swift b/Sources/Conformance/Day+Hashable.swift new file mode 100644 index 0000000..013a939 --- /dev/null +++ b/Sources/Conformance/Day+Hashable.swift @@ -0,0 +1,7 @@ +import Foundation + +extension Day: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(daysSince1970) + } +} diff --git a/Sources/Day+Strideable.swift b/Sources/Conformance/Day+Stridable.swift similarity index 56% rename from Sources/Day+Strideable.swift rename to Sources/Conformance/Day+Stridable.swift index ecfd657..562939a 100644 --- a/Sources/Day+Strideable.swift +++ b/Sources/Conformance/Day+Stridable.swift @@ -1,18 +1,11 @@ -// -// Day+Stridable.swift -// -// -// Created by Derek Clarkson on 15/1/2024. -// - import Foundation extension Day: Strideable { public func distance(to other: Day) -> Int { - other.daysSince1970 - self.daysSince1970 + other.daysSince1970 - daysSince1970 } - + public func advanced(by n: Int) -> Day { self + n } diff --git a/Sources/Day+Conversions.swift b/Sources/Day+Conversions.swift index 04cd169..c5742f2 100644 --- a/Sources/Day+Conversions.swift +++ b/Sources/Day+Conversions.swift @@ -1,10 +1,3 @@ -// -// Day+Conversions.swift -// -// -// Created by Derek Clarkson on 6/12/2023. -// - import Foundation public extension Day { diff --git a/Sources/Day+Core.swift b/Sources/Day+Core.swift deleted file mode 100644 index 0aba4ef..0000000 --- a/Sources/Day+Core.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// Day+Hashable.swift -// -// -// Created by Derek Clarkson on 10/1/2024. -// - -import Foundation - -extension Day: Hashable { - public func hash(into hasher: inout Hasher) { - hasher.combine(daysSince1970) - } -} - -extension Day: Equatable { - public static func == (lhs: Day, rhs: Day) -> Bool { - lhs.daysSince1970 == rhs.daysSince1970 - } -} - -extension Day: Comparable { - public static func < (lhs: Day, rhs: Day) -> Bool { - lhs.daysSince1970 < rhs.daysSince1970 - } -} diff --git a/Sources/Day+CustomStringConvertable.swift b/Sources/Day+CustomStringConvertable.swift deleted file mode 100644 index e1bc2a2..0000000 --- a/Sources/Day+CustomStringConvertable.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// Day+CustomStringConvertable.swift -// -// -// Created by Derek Clarkson on 15/1/2024. -// - -import Foundation - -extension Day: CustomStringConvertible { - public var description: String { - self.formatted() - } -} diff --git a/Sources/Day+Formatted.swift b/Sources/Day+Formatted.swift index a32fc15..5954430 100644 --- a/Sources/Day+Formatted.swift +++ b/Sources/Day+Formatted.swift @@ -1,10 +1,3 @@ -// -// Day+Formatted.swift -// -// -// Created by Derek Clarkson on 6/12/2023. -// - import Foundation public extension Day { diff --git a/Sources/Day+Functions.swift b/Sources/Day+Functions.swift index 9118c6b..5430f52 100644 --- a/Sources/Day+Functions.swift +++ b/Sources/Day+Functions.swift @@ -1,10 +1,3 @@ -// -// Day+Functions.swift -// -// -// Created by Derek Clarkson on 16/1/2024. -// - import Foundation public extension Day { diff --git a/Sources/Day+Operations.swift b/Sources/Day+Operations.swift index 934b2db..fb0761c 100644 --- a/Sources/Day+Operations.swift +++ b/Sources/Day+Operations.swift @@ -1,10 +1,3 @@ -// -// Day+Operations.swift -// -// -// Created by Derek Clarkson on 11/12/2023. -// - import Foundation public extension Day { diff --git a/Sources/Day.swift b/Sources/Day.swift index 43e5a26..d8bb34c 100755 --- a/Sources/Day.swift +++ b/Sources/Day.swift @@ -1,7 +1,3 @@ -// -// Copyright © Derek Clarkson. All rights reserved. -// - import Foundation /// Simple alias to help code readability. diff --git a/Sources/DayComponents.swift b/Sources/DayComponents.swift index c5320a2..7277ac3 100644 --- a/Sources/DayComponents.swift +++ b/Sources/DayComponents.swift @@ -1,10 +1,3 @@ -// -// DayComponents.swift -// -// -// Created by Derek Clarkson on 5/11/2023. -// - import Foundation /// Similar to Swift's ``DateComponents`` in that it contains the individual components of a ``Day``. diff --git a/Sources/Property wrappers/CustomISO8601.swift b/Sources/Property wrappers/CustomISO8601.swift deleted file mode 100644 index 8df56cb..0000000 --- a/Sources/Property wrappers/CustomISO8601.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// CustomISO8601Day.swift -// -// -// Created by Derek Clarkson on 9/1/2024. -// - -import Foundation - -/// Identifies a ``Day`` property that reads and writes from an ISO8601 formatted string. -@propertyWrapper -public struct CustomISO8601: Codable where T: ISO8601Codable, Configurator: CustomISO8601Configurator { - - public var wrappedValue: T - - public init(wrappedValue: T) { - self.wrappedValue = wrappedValue - } - - public init(from decoder: Decoder) throws { - wrappedValue = try T(iso8601Decoder: decoder, configurator: Configurator.self) - } - - public func encode(to encoder: Encoder) throws { - try wrappedValue.encode(iso8601Encoder: encoder, configurator: Configurator.self) - } -} diff --git a/Sources/Property wrappers/CustomISO8601Configurator.swift b/Sources/Property wrappers/CustomISO8601Configurator.swift deleted file mode 100644 index 8ab85cd..0000000 --- a/Sources/Property wrappers/CustomISO8601Configurator.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// ISO8601CodingStrategy.swift -// -// -// Created by Derek Clarkson on 10/1/2024. -// - -import Foundation - -/// Sets up the ``ISO8601DateFormatter`` used for decoding and encoding date strings. -public protocol CustomISO8601Configurator { - - /// Called when setting up to decode or encode an ISO8601 date string. - static func configure(formatter: ISO8601DateFormatter) -} - -/// Useful common configurations of ISO8601 formatters. -public enum ISO8601Config { - - /// A default implementation that leaves the formatted untouched from it's defaults. - /// in the default property wrappers. - public enum Default: CustomISO8601Configurator { - public static func configure(formatter _: ISO8601DateFormatter) {} - } - - /// Removes the time zone element from the string. - public enum SansTimeZone: CustomISO8601Configurator { - public static func configure(formatter: ISO8601DateFormatter) { - formatter.formatOptions.remove(.withTimeZone) - } - } -} diff --git a/Sources/Property wrappers/DateString.swift b/Sources/Property wrappers/DateString.swift deleted file mode 100644 index 65167ac..0000000 --- a/Sources/Property wrappers/DateString.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// CustomISO8601Day.swift -// -// -// Created by Derek Clarkson on 9/1/2024. -// - -import Foundation - -/// Identifies a ``Day`` property that reads and writes from date strings using a configured ``DateFormatter``. -@propertyWrapper -public struct DateString: Codable where T: DateStringCodable, Configurator: DateStringConfigurator { - - public var wrappedValue: T - - public init(wrappedValue: T) { - self.wrappedValue = wrappedValue - } - - public init(from decoder: Decoder) throws { - wrappedValue = try T(dateDecoder: decoder, configurator: Configurator.self) - } - - public func encode(to encoder: Encoder) throws { - try wrappedValue.encode(dateEncoder: encoder, configurator: Configurator.self) - } -} diff --git a/Sources/Property wrappers/DateStringConfigurator.swift b/Sources/Property wrappers/DateStringConfigurator.swift deleted file mode 100644 index 789f80b..0000000 --- a/Sources/Property wrappers/DateStringConfigurator.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// ISO8601CodingStrategy.swift -// -// -// Created by Derek Clarkson on 10/1/2024. -// - -import Foundation - -/// Sets up the ``DateFormatter`` used for decoding and encoding date strings. -public protocol DateStringConfigurator { - - /// Called when setting up to decode or encode a date string. - static func configure(formatter: DateFormatter) -} - -/// Useful common configurations of date formatters. -public enum DateStringConfig { - - public enum ISO: DateStringConfigurator { - public static func configure(formatter: DateFormatter) { - formatter.dateFormat = "yyyy-MM-dd" - } - } - - public enum DMY: DateStringConfigurator { - public static func configure(formatter: DateFormatter) { - formatter.dateFormat = "dd/MM/yyyy" - } - } - - public enum MDY: DateStringConfigurator { - public static func configure(formatter: DateFormatter) { - formatter.dateFormat = "MM/dd/yyyy" - } - } -} diff --git a/Sources/Property wrappers/Day+DateStringCodable.swift b/Sources/Property wrappers/Day+DateStringCodable.swift deleted file mode 100644 index 02ecdad..0000000 --- a/Sources/Property wrappers/Day+DateStringCodable.swift +++ /dev/null @@ -1,60 +0,0 @@ -// -// DayCodable.swift -// -// -// Created by Derek Clarkson on 29/1/2024. -// - -import Foundation - -/// Protocol that allows us to abstract the differences between ``Day`` and ``Day?``. -/// -/// By using this protocols for property wrappers we can reduce the number of wrappers needed because -/// it erases the optional aspect of the values. -public protocol DateStringCodable { - init(dateDecoder decoder: Decoder, configurator: (some DateStringConfigurator).Type) throws - func encode(dateEncoder encoder: Encoder, configurator: (some DateStringConfigurator).Type) throws -} - -extension Day: DateStringCodable { - - public init(dateDecoder decoder: Decoder, configurator: (some DateStringConfigurator).Type) throws { - - let container = try decoder.singleValueContainer() - let formatter = DateFormatter() - configurator.configure(formatter: formatter) - - guard let dateString = try? container.decode(String.self), - let date = formatter.date(from: dateString) else { - let context = DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Unable to read the date string.") - throw DecodingError.dataCorrupted(context) - } - - self = Day(date: date) - } - - public func encode(dateEncoder encoder: Encoder, configurator: (some DateStringConfigurator).Type) throws { - var container = encoder.singleValueContainer() - let formatter = DateFormatter() - configurator.configure(formatter: formatter) - try container.encode(formatter.string(from: date())) - } -} - -/// `Day?` support which mostly just handles `nil` before calling the main ``Day`` codable code. -extension Day?: DateStringCodable { - - public init(dateDecoder decoder: Decoder, configurator: (some DateStringConfigurator).Type) throws { - let container = try decoder.singleValueContainer() - self = container.decodeNil() ? nil : try Day(dateDecoder: decoder, configurator: configurator) - } - - public func encode(dateEncoder encoder: Encoder, configurator: (some DateStringConfigurator).Type) throws { - if let self { - try self.encode(dateEncoder: encoder, configurator: configurator) - } else { - var container = encoder.singleValueContainer() - try container.encodeNil() - } - } -} diff --git a/Sources/Property wrappers/Day+ISO8601Codable.swift b/Sources/Property wrappers/Day+ISO8601Codable.swift deleted file mode 100644 index af3afd0..0000000 --- a/Sources/Property wrappers/Day+ISO8601Codable.swift +++ /dev/null @@ -1,64 +0,0 @@ -// -// DayCodable.swift -// -// -// Created by Derek Clarkson on 9/1/2024. -// - -import Foundation - -/// Protocol that allows us to abstract the differences between ``Day`` and ``Day?``. -/// -/// By using this protocols for property wrappers we can reduce the number of wrappers needed because -/// it erases the optional aspect of the values. -public protocol ISO8601Codable { - init(iso8601Decoder decoder: Decoder, configurator: (some CustomISO8601Configurator).Type) throws - func encode(iso8601Encoder encoder: Encoder, configurator: (some CustomISO8601Configurator).Type) throws -} - -/// Adds ``DayCodable`` to ``Day``. -extension Day: ISO8601Codable { - - public init(iso8601Decoder decoder: Decoder, configurator: (some CustomISO8601Configurator).Type) throws { - - let container = try decoder.singleValueContainer() - if let iso8601String = try? container.decode(String.self) { - let reader = ISO8601DateFormatter() - configurator.configure(formatter: reader) - if let date = reader.date(from: iso8601String) { - self.init(date: date) - return - } - } - - let context = DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Unable to read a Day value, expected a valid ISO8601 string.") - throw DecodingError.dataCorrupted(context) - } - - public func encode(iso8601Encoder encoder: Encoder, configurator: (some CustomISO8601Configurator).Type) throws { - let writer = ISO8601DateFormatter() - configurator.configure(formatter: writer) - let iso8601String = writer.string(from: date()) - var container = encoder.singleValueContainer() - try container.encode(iso8601String) - } -} - -/// `Day?` support which mostly just handles `nil` before calling the main ``Day`` codable code. -extension Day?: ISO8601Codable { - - public init(iso8601Decoder decoder: Decoder, configurator: (some CustomISO8601Configurator).Type) throws { - let container = try decoder.singleValueContainer() - self = container.decodeNil() ? nil : try Day(iso8601Decoder: decoder, configurator: configurator) - } - - public func encode(iso8601Encoder encoder: Encoder, configurator: (some CustomISO8601Configurator).Type) throws { - if let self { - try self.encode(iso8601Encoder: encoder, configurator: configurator) - } else { - var container = encoder.singleValueContainer() - try container.encodeNil() - } - } -} - diff --git a/Sources/Property wrappers/DayFormatters.swift b/Sources/Property wrappers/DayFormatters.swift new file mode 100644 index 0000000..260918f --- /dev/null +++ b/Sources/Property wrappers/DayFormatters.swift @@ -0,0 +1,30 @@ +import Foundation + +enum DayFormatters { + + public static let dmy = { + let formatter = DateFormatter() + formatter.dateFormat = "dd/MM/yyyy" + return formatter + }() + + public static let mdy = { + let formatter = DateFormatter() + formatter.dateFormat = "MM/dd/yyyy" + return formatter + }() + + public static let ymd = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() + + public static let iso8601 = ISO8601DateFormatter() + + public static let iso8601SansTimezone = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions.remove(.withTimeZone) + return formatter + }() +} diff --git a/Sources/Property wrappers/DayString/DayString.swift b/Sources/Property wrappers/DayString/DayString.swift new file mode 100644 index 0000000..34ed45e --- /dev/null +++ b/Sources/Property wrappers/DayString/DayString.swift @@ -0,0 +1,19 @@ +import DayTypeMacros +import Foundation + +/// Identifies a ``Day`` property that reads and writes from day strings using a formatter. + +@DayStringPropertyWrapper(name: "DMY", formatter: DayFormatters.dmy) +@DayStringPropertyWrapper(name: "MDY", formatter: DayFormatters.mdy) +@DayStringPropertyWrapper(name: "YMD", formatter: DayFormatters.ymd) +public enum DayString where DayType: DayStringCodable {} + +@DecodeMissingString(type: DayString.DMY.self) +@DecodeMissingString(type: DayString.MDY.self) +@DecodeMissingString(type: DayString.YMD.self) +extension KeyedDecodingContainer {} + +@EncodeMissingString(type: DayString.DMY.self) +@EncodeMissingString(type: DayString.MDY.self) +@EncodeMissingString(type: DayString.YMD.self) +extension KeyedEncodingContainer {} diff --git a/Sources/Property wrappers/DayString/DayStringCodable.swift b/Sources/Property wrappers/DayString/DayStringCodable.swift new file mode 100644 index 0000000..6b63622 --- /dev/null +++ b/Sources/Property wrappers/DayString/DayStringCodable.swift @@ -0,0 +1,46 @@ +import DayTypeMacros +import Foundation + +/// Protocol that allows us to abstract the differences between ``Day`` and ``Day?``. +/// +/// By using this protocols for property wrappers we can reduce the number of wrappers needed because +/// it erases the optional aspect of the values. +public protocol DayStringCodable { + static func decode(from decoder: Decoder, formatter: DateFormatter) throws -> Self + func encode(into encoder: Encoder, formatter: DateFormatter, writeNulls: Bool) throws +} + +extension Day: DayStringCodable { + + public static func decode(from decoder: Decoder, formatter: DateFormatter) throws -> Day { + let container = try decoder.singleValueContainer() + guard let dateString = try? container.decode(String.self), + let date = formatter.date(from: dateString) else { + let context = DecodingError.Context(codingPath: container.codingPath, debugDescription: "Unable to read the date string.") + throw DecodingError.dataCorrupted(context) + } + return Day(date: date) + } + + public func encode(into encoder: Encoder, formatter: DateFormatter, writeNulls _: Bool) throws { + var container = encoder.singleValueContainer() + try container.encode(formatter.string(from: date())) + } +} + +/// `Day?` support which mostly just handles `nil`. +extension Day?: DayStringCodable { + public static func decode(from decoder: Decoder, formatter: DateFormatter) throws -> Day? { + let container = try decoder.singleValueContainer() + return container.decodeNil() ? nil : try Day.decode(from: decoder, formatter: formatter) + } + + public func encode(into encoder: Encoder, formatter: DateFormatter, writeNulls: Bool) throws { + if let self { + try self.encode(into: encoder, formatter: formatter, writeNulls: writeNulls) + } else if writeNulls { + var container = encoder.singleValueContainer() + try container.encodeNil() + } + } +} diff --git a/Sources/Property wrappers/Epoch/Epoch.swift b/Sources/Property wrappers/Epoch/Epoch.swift new file mode 100644 index 0000000..d25370d --- /dev/null +++ b/Sources/Property wrappers/Epoch/Epoch.swift @@ -0,0 +1,14 @@ +import Foundation +import DayTypeMacros + +@EpochPropertyWrapper(typeName: "Milliseconds", milliseconds: true) +@EpochPropertyWrapper(typeName: "Seconds", milliseconds: false) +public enum Epoch where DayType: EpochCodable {} + +@DecodeMissingEpoch(type: Epoch.Milliseconds.self) +@DecodeMissingEpoch(type: Epoch.Seconds.self) +extension KeyedDecodingContainer {} + +@EncodeMissingEpoch(type: Epoch.Milliseconds.self) +@EncodeMissingEpoch(type: Epoch.Seconds.self) +extension KeyedEncodingContainer {} diff --git a/Sources/Property wrappers/Day+EpochCodable.swift b/Sources/Property wrappers/Epoch/EpochCodable.swift similarity index 52% rename from Sources/Property wrappers/Day+EpochCodable.swift rename to Sources/Property wrappers/Epoch/EpochCodable.swift index 6e3999c..a4bc505 100644 --- a/Sources/Property wrappers/Day+EpochCodable.swift +++ b/Sources/Property wrappers/Epoch/EpochCodable.swift @@ -1,10 +1,4 @@ -// -// DayCodable.swift -// -// -// Created by Derek Clarkson on 9/1/2024. -// - +import DayTypeMacros import Foundation /// Protocol that allows us to abstract the differences between ``Day`` and ``Day?``. @@ -12,41 +6,41 @@ import Foundation /// By using this protocols for property wrappers we can reduce the number of wrappers needed because /// it erases the optional aspect of the values. public protocol EpochCodable { - init(epochDecoder decoder: Decoder, factor: Double) throws - func encode(epochEncoder encoder: Encoder, factor: Double) throws + static func decode(using decoder: Decoder, milliseconds: Bool) throws -> Self + func encode(using encoder: Encoder, milliseconds: Bool, writeNulls: Bool) throws } extension Day: EpochCodable { - public init(epochDecoder decoder: Decoder, factor: Double) throws { + public static func decode(using decoder: Decoder, milliseconds: Bool) throws -> Day { let container = try decoder.singleValueContainer() guard let epochTime = try? container.decode(TimeInterval.self) else { let context = DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Unable to read a Day value, expected an epoch.") throw DecodingError.dataCorrupted(context) } - self = Day(date: Date(timeIntervalSince1970: epochTime / factor)) + return Day(date: Date(timeIntervalSince1970: epochTime / (milliseconds ? 1000 : 1))) } - public func encode(epochEncoder encoder: Encoder, factor: Double) throws { + public func encode(using encoder: Encoder, milliseconds: Bool, writeNulls _: Bool) throws { var container = encoder.singleValueContainer() - try container.encode(date().timeIntervalSince1970 * factor) + try container.encode(date().timeIntervalSince1970 * (milliseconds ? 1000 : 1)) } } /// `Day?` support which mostly just handles `nil` before calling the main ``Day`` codable code. extension Day?: EpochCodable { - - public init(epochDecoder decoder: Decoder, factor: Double) throws { + public static func decode(using decoder: Decoder, milliseconds: Bool) throws -> Day? { let container = try decoder.singleValueContainer() - self = container.decodeNil() ? nil : try Day(epochDecoder: decoder, factor: factor) + return container.decodeNil() ? nil : try Day.decode(using: decoder, milliseconds: milliseconds) } - public func encode(epochEncoder encoder: Encoder, factor: Double) throws { + public func encode(using encoder: Encoder, milliseconds: Bool, writeNulls: Bool) throws { if let self { - try self.encode(epochEncoder: encoder, factor: factor) - } else { + try self.encode(using: encoder, milliseconds: milliseconds, writeNulls: writeNulls) + } else if writeNulls { var container = encoder.singleValueContainer() try container.encodeNil() } } + } diff --git a/Sources/Property wrappers/EpochMilliseconds.swift b/Sources/Property wrappers/EpochMilliseconds.swift deleted file mode 100644 index 54fd9a6..0000000 --- a/Sources/Property wrappers/EpochMilliseconds.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// EpochDay.swift -// -// -// Created by Derek Clarkson on 9/1/2024. -// - -import Foundation - -/// Identifies a ``Day`` property that reads and writes from an epoch time value expressed in seconds. -@propertyWrapper -public struct EpochMilliseconds: Codable where T: EpochCodable { - - public var wrappedValue: T - - public init(wrappedValue: T) { - self.wrappedValue = wrappedValue - } - - public init(from decoder: Decoder) throws { - wrappedValue = try T(epochDecoder: decoder, factor: 1000.0) - } - - public func encode(to encoder: Encoder) throws { - try wrappedValue.encode(epochEncoder: encoder, factor: 1000.0) - } -} diff --git a/Sources/Property wrappers/EpochSeconds.swift b/Sources/Property wrappers/EpochSeconds.swift deleted file mode 100644 index c22daaa..0000000 --- a/Sources/Property wrappers/EpochSeconds.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// EpochDay.swift -// -// -// Created by Derek Clarkson on 9/1/2024. -// - -import Foundation - -/// Identifies a ``Day`` property that reads and writes from an epoch time value expressed in seconds. -@propertyWrapper -public struct EpochSeconds: Codable where T: EpochCodable { - - public var wrappedValue: T - - public init(wrappedValue: T) { - self.wrappedValue = wrappedValue - } - - public init(from decoder: Decoder) throws { - wrappedValue = try T(epochDecoder: decoder, factor: 1.0) - } - - public func encode(to encoder: Encoder) throws { - try wrappedValue.encode(epochEncoder: encoder, factor: 1.0) - } -} diff --git a/Sources/Property wrappers/ISO8601.swift b/Sources/Property wrappers/ISO8601.swift deleted file mode 100644 index 3cddce6..0000000 --- a/Sources/Property wrappers/ISO8601.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// ISO8601Day.swift -// -// -// Created by Derek Clarkson on 9/1/2024. -// - -import Foundation - -/// Identifies a ``Day`` property that reads and writes from an ISO8601 formatted string. -@propertyWrapper -public struct ISO8601: Codable where T: ISO8601Codable { - - public var wrappedValue: T - - public init(wrappedValue: T) { - self.wrappedValue = wrappedValue - } - - public init(from decoder: Decoder) throws { - wrappedValue = try T(iso8601Decoder: decoder, configurator: ISO8601Config.Default.self) - } - - public func encode(to encoder: Encoder) throws { - try wrappedValue.encode(iso8601Encoder: encoder, configurator: ISO8601Config.Default.self) - } -} diff --git a/Sources/Property wrappers/ISO8601/ISO8601.swift b/Sources/Property wrappers/ISO8601/ISO8601.swift new file mode 100644 index 0000000..a46bd26 --- /dev/null +++ b/Sources/Property wrappers/ISO8601/ISO8601.swift @@ -0,0 +1,14 @@ +import DayTypeMacros +import Foundation + +@ISO8601PropertyWrapper(name: "Default", formatter: DayFormatters.iso8601) +@ISO8601PropertyWrapper(name: "SansTimezone", formatter: DayFormatters.iso8601SansTimezone) +public enum ISO8601 where DayType: ISO8601Codable {} + +@DecodeMissingString(type: ISO8601.Default.self) +@DecodeMissingString(type: ISO8601.SansTimezone.self) +extension KeyedDecodingContainer {} + +@EncodeMissingString(type: ISO8601.Default.self) +@EncodeMissingString(type: ISO8601.SansTimezone.self) +extension KeyedEncodingContainer {} diff --git a/Sources/Property wrappers/ISO8601/ISO8601Codable.swift b/Sources/Property wrappers/ISO8601/ISO8601Codable.swift new file mode 100644 index 0000000..b1d4c06 --- /dev/null +++ b/Sources/Property wrappers/ISO8601/ISO8601Codable.swift @@ -0,0 +1,46 @@ +import DayTypeMacros +import Foundation + +/// Protocol that allows us to abstract the differences between ``Day`` and ``Day?``. +/// +/// By using this protocols for property wrappers we can reduce the number of wrappers needed because +/// it erases the optional aspect of the values. +public protocol ISO8601Codable { + static func decode(from decoder: Decoder, formatter: ISO8601DateFormatter) throws -> Self + func encode(into encoder: Encoder, formatter: ISO8601DateFormatter, writeNulls: Bool) throws +} + +extension Day: ISO8601Codable { + + public static func decode(from decoder: Decoder, formatter: ISO8601DateFormatter) throws -> Day { + let container = try decoder.singleValueContainer() + guard let dateString = try? container.decode(String.self), + let date = formatter.date(from: dateString) else { + let context = DecodingError.Context(codingPath: container.codingPath, debugDescription: "Unable to read the date string.") + throw DecodingError.dataCorrupted(context) + } + return Day(date: date) + } + + public func encode(into encoder: Encoder, formatter: ISO8601DateFormatter, writeNulls _: Bool) throws { + var container = encoder.singleValueContainer() + try container.encode(formatter.string(from: date())) + } +} + +/// `Day?` support which mostly just handles `nil`. +extension Day?: ISO8601Codable { + public static func decode(from decoder: Decoder, formatter: ISO8601DateFormatter) throws -> Day? { + let container = try decoder.singleValueContainer() + return container.decodeNil() ? nil : try Day.decode(from: decoder, formatter: formatter) + } + + public func encode(into encoder: Encoder, formatter: ISO8601DateFormatter, writeNulls: Bool) throws { + if let self { + try self.encode(into: encoder, formatter: formatter, writeNulls: writeNulls) + } else if writeNulls { + var container = encoder.singleValueContainer() + try container.encodeNil() + } + } +} diff --git a/Tests/Conformance/DayCodableTests.swift b/Tests/Conformance/DayCodableTests.swift new file mode 100644 index 0000000..ae49e85 --- /dev/null +++ b/Tests/Conformance/DayCodableTests.swift @@ -0,0 +1,36 @@ +import DayType +import Foundation +import Testing + +extension ProtocolConformanceSuites { + + @Suite("Codable") + struct DayCodableTests { + + private struct DummyType: Codable { + let abc: Day + } + + @Test("Decoding") + func decoding() throws { + + let json = """ + { + "abc": 19455 + } + """ + + let decoder = JSONDecoder() + let day = try decoder.decode(DummyType.self, from: json.data(using: .utf8)!) + #expect(day.abc.daysSince1970 == 19455) + } + + @Test("Encoding") + func encoding() throws { + let obj = DummyType(abc: Day(daysSince1970: 19455)) + let encoder = JSONEncoder() + let encoded = try #require(String(data: encoder.encode(obj), encoding: .utf8)) + #expect(encoded == #"{"abc":19455}"#) + } + } +} diff --git a/Tests/Conformance/DayConparableTests.swift b/Tests/Conformance/DayConparableTests.swift new file mode 100644 index 0000000..e1710da --- /dev/null +++ b/Tests/Conformance/DayConparableTests.swift @@ -0,0 +1,38 @@ +import DayType +import Foundation +import Testing + +extension ProtocolConformanceSuites { + + @Suite("Comparable") + struct DayComparableTests { + + @Test(">") + func greaterThan() { + #expect((Day(2020, 3, 12) > Day(2020, 3, 11)) == true) + #expect((Day(2020, 3, 12) > Day(2020, 3, 12)) == false) + #expect((Day(2020, 3, 12) > Day(2020, 3, 13)) == false) + } + + @Test(">=") + func greaterThanEquals() { + #expect((Day(2020, 3, 12) >= Day(2020, 3, 11)) == true) + #expect((Day(2020, 3, 12) >= Day(2020, 3, 12)) == true) + #expect((Day(2020, 3, 12) >= Day(2020, 3, 13)) == false) + } + + @Test("<") + func lessThan() { + #expect((Day(2020, 3, 12) < Day(2020, 3, 11)) == false) + #expect((Day(2020, 3, 12) < Day(2020, 3, 12)) == false) + #expect((Day(2020, 3, 12) < Day(2020, 3, 13)) == true) + } + + @Test("<=") + func lessThanEquals() { + #expect((Day(2020, 3, 12) <= Day(2020, 3, 11)) == false) + #expect((Day(2020, 3, 12) <= Day(2020, 3, 12)) == true) + #expect((Day(2020, 3, 12) <= Day(2020, 3, 13)) == true) + } + } +} diff --git a/Tests/Conformance/DayCustomStringConvertableTests.swift b/Tests/Conformance/DayCustomStringConvertableTests.swift new file mode 100644 index 0000000..9e6bce5 --- /dev/null +++ b/Tests/Conformance/DayCustomStringConvertableTests.swift @@ -0,0 +1,12 @@ +import DayType +import Foundation +import Testing + +extension ProtocolConformanceSuites { + + @Test("CustomStringConvertable") + func description() { + let date = DateComponents(calendar: .current, year: 2001, month: 2, day: 3).date! + #expect(Day(2001, 2, 3).description == date.formatted(date: .abbreviated, time: .omitted)) + } +} diff --git a/Tests/Conformance/DayEquatableTests.swift b/Tests/Conformance/DayEquatableTests.swift new file mode 100644 index 0000000..2ba569c --- /dev/null +++ b/Tests/Conformance/DayEquatableTests.swift @@ -0,0 +1,12 @@ +import DayType +import Foundation +import Testing + +extension ProtocolConformanceSuites { + + @Test("Equatable") + func equals() { + #expect(Day(2020, 3, 12) == Day(2020, 3, 12)) + #expect(Day(2020, 3, 12) != Day(2001, 1, 5)) + } +} diff --git a/Tests/Conformance/DayHashableTests.swift b/Tests/Conformance/DayHashableTests.swift new file mode 100644 index 0000000..5f4ff60 --- /dev/null +++ b/Tests/Conformance/DayHashableTests.swift @@ -0,0 +1,24 @@ +import DayType +import Foundation +import Testing + +extension ProtocolConformanceSuites { + + @Test("Hashable") + func hashable() { + var days: Set = [Day(2020, 01, 11), Day(2020, 01, 12)] + + #expect(days.contains(Day(2020, 01, 13)) == false) + #expect(days.contains(Day(2020, 01, 12)) == true) + + // Modify and try again. + days.insert(Day(2020, 01, 13)) + #expect(days.count == 3) + #expect(days.contains(Day(2020, 01, 13)) == true) + #expect(days.contains(Day(2020, 01, 12)) == true) + + // Duplicate check. + days.insert(Day(2020, 01, 11)) + #expect(days.count == 3) + } +} diff --git a/Tests/Conformance/DayStrideableTests.swift b/Tests/Conformance/DayStrideableTests.swift new file mode 100644 index 0000000..0c52072 --- /dev/null +++ b/Tests/Conformance/DayStrideableTests.swift @@ -0,0 +1,42 @@ +import DayType +import Foundation +import Testing + +extension ProtocolConformanceSuites { + + @Suite("Stridable") + struct DayStrideableTests { + + @Test("In a for loop with an open range") + func forEachOpenRange() { + var days: [Day] = [] + for day in Day(2000, 1, 1) ... Day(2000, 1, 5) { + days.append(day) + } + #expect(days == [Day(2000, 1, 1), Day(2000, 1, 2), Day(2000, 1, 3), Day(2000, 1, 4), Day(2000, 1, 5)]) + } + + @Test("In a for loop with a half open range") + func forEachHalfOpenRange() { + var days: [Day] = [] + for day in Day(2000, 1, 1) ..< Day(2000, 1, 5) { + days.append(day) + } + #expect(days == [Day(2000, 1, 1), Day(2000, 1, 2), Day(2000, 1, 3), Day(2000, 1, 4)]) + } + + @Test("With the stride function") + func forEachViaStrideFunction() { + var days: [Day] = [] + for day in stride(from: Day(2000, 1, 1), to: Day(2000, 1, 5), by: 2) { + days.append(day) + } + #expect(days == [Day(2000, 1, 1), Day(2000, 1, 3)]) + } + + @Test("Distance function") + func distanceTo() { + #expect(Day(2020, 3, 6).distance(to: Day(2020, 3, 12)) == 6) + } + } +} diff --git a/Tests/Conformance/ProtocolConformanceSuites.swift b/Tests/Conformance/ProtocolConformanceSuites.swift new file mode 100644 index 0000000..77b9de2 --- /dev/null +++ b/Tests/Conformance/ProtocolConformanceSuites.swift @@ -0,0 +1,6 @@ +import Testing + +/// Groups up property test suites. +@Suite("Protocol conformance", .tags(.ProtocolConformance)) +struct ProtocolConformanceSuites {} + diff --git a/Tests/DayCodableTests.swift b/Tests/DayCodableTests.swift deleted file mode 100644 index f29fec7..0000000 --- a/Tests/DayCodableTests.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// DayCodableTests.swift -// -// -// Created by Derek Clarkson on 9/12/2023. -// - -import DayType -import Nimble -import XCTest - -class DayCodableTests: XCTestCase { - - struct DummyType: Codable { - let abc: Day - } - - func testBaseDecoding() throws { - - let json = """ - { - "abc": 19455 - } - """ - - let decoder = JSONDecoder() - let day = try decoder.decode(DummyType.self, from: json.data(using: .utf8)!) - expect(day.abc.daysSince1970) == 19455 - } - - func testEncoding() throws { - let obj = DummyType(abc: Day(daysSince1970: 19455)) - let encoder = JSONEncoder() - let encoded = try String(data: encoder.encode(obj), encoding: .utf8) - expect(encoded).to(contain("\"abc\":19455")) - } -} diff --git a/Tests/DayConversionTests.swift b/Tests/DayConversionTests.swift index bf073e4..e093413 100644 --- a/Tests/DayConversionTests.swift +++ b/Tests/DayConversionTests.swift @@ -1,29 +1,25 @@ -// -// DayConversionTests.swift -// -// -// Created by Derek Clarkson on 6/12/2023. -// - -import XCTest import DayType -import Nimble +import Foundation +import Testing -class DayConversionTests: XCTestCase { +@Suite("Day to Date conversions") +struct DayConversionTests { - func testDateInCalendarCurrent() { - let day = Day(2000,1,1) + @Test("To a Date") + func toDate() { + let day = Day(2000, 1, 1) let date = day.date() let dateComponents = Calendar.current.dateComponents([.year, .month, .day, .hour], from: date) - expect(dateComponents.year) == 2000 - expect(dateComponents.month) == 1 - expect(dateComponents.day) == 1 - expect(dateComponents.hour) == 0 + #expect(dateComponents.year == 2000) + #expect(dateComponents.month == 1) + #expect(dateComponents.day == 1) + #expect(dateComponents.hour == 0) } + @Test("Time zones are correct") func testDateInTimeZone() { - let day = Day(2000,1,1) + let day = Day(2000, 1, 1) // Get the date of the day in the Melbourne time zone. let melbourne = TimeZone(secondsFromGMT: 11 * 3600)! @@ -34,20 +30,20 @@ class DayConversionTests: XCTestCase { let gmtDate = day.date(timeZone: gmt) // We expect that the two points in time are different. - expect(melbourneDate.timeIntervalSince1970) != gmtDate.timeIntervalSince1970 + #expect(melbourneDate.timeIntervalSince1970 != gmtDate.timeIntervalSince1970) // Check that the Melbourne date is midnight in the Melbourne time zone. let melbourneComponentsInMelbourne = Calendar.current.dateComponents(in: melbourne, from: melbourneDate) - expect(melbourneComponentsInMelbourne.year) == 2000 - expect(melbourneComponentsInMelbourne.month) == 1 - expect(melbourneComponentsInMelbourne.day) == 1 - expect(melbourneComponentsInMelbourne.hour) == 0 + #expect(melbourneComponentsInMelbourne.year == 2000) + #expect(melbourneComponentsInMelbourne.month == 1) + #expect(melbourneComponentsInMelbourne.day == 1) + #expect(melbourneComponentsInMelbourne.hour == 0) // Check that the GMT date is midnight in the GMT time zone. let gmtComponentsInGMT = Calendar.current.dateComponents(in: gmt, from: gmtDate) - expect(gmtComponentsInGMT.year) == 2000 - expect(gmtComponentsInGMT.month) == 1 - expect(gmtComponentsInGMT.day) == 1 - expect(gmtComponentsInGMT.hour) == 0 + #expect(gmtComponentsInGMT.year == 2000) + #expect(gmtComponentsInGMT.month == 1) + #expect(gmtComponentsInGMT.day == 1) + #expect(gmtComponentsInGMT.hour == 0) } } diff --git a/Tests/DayCoreTests.swift b/Tests/DayCoreTests.swift deleted file mode 100644 index 0f2703b..0000000 --- a/Tests/DayCoreTests.swift +++ /dev/null @@ -1,68 +0,0 @@ -// -// DayOperationsTests.swift -// -// -// Created by Derek Clarkson on 12/12/2023. -// - -import DayType -import Nimble -import XCTest - -class DayHashableTests: XCTestCase { - - func testHash() { - var days: Set = [Day(2020, 01, 11), Day(2020, 01, 12)] - expect(days.contains(Day(2020, 01, 13))) == false - expect(days.contains(Day(2020, 01, 12))) == true - - // Modify and try again. - days.insert(Day(2020, 01, 13)) - expect(days.contains(Day(2020, 01, 13))) == true - expect(days.contains(Day(2020, 01, 12))) == true - - // Duplicate check. - days.insert(Day(2020, 01, 11)) - expect(days.count) == 3 - } -} - -class DayEquatableTests: XCTestCase { - - func testEquals() { - expect(Day(2020, 3, 12) == Day(2020, 3, 12)) == true - expect(Day(2020, 3, 12) == Day(2001, 1, 5)) == false - } - - func testNotEquals() { - expect(Day(2020, 3, 12) != Day(2001, 1, 5)) == true - expect(Day(2020, 3, 12) != Day(2020, 3, 12)) == false - } -} - -class DayComparableTests: XCTestCase { - - func testGreaterThan() { - expect(Day(2020, 3, 12) > Day(2020, 3, 11)) == true - expect(Day(2020, 3, 12) > Day(2020, 3, 12)) == false - expect(Day(2020, 3, 12) > Day(2020, 3, 13)) == false - } - - func testGreaterThanEquals() { - expect(Day(2020, 3, 12) >= Day(2020, 3, 11)) == true - expect(Day(2020, 3, 12) >= Day(2020, 3, 12)) == true - expect(Day(2020, 3, 12) >= Day(2020, 3, 13)) == false - } - - func testLessThan() { - expect(Day(2020, 3, 12) < Day(2020, 3, 11)) == false - expect(Day(2020, 3, 12) < Day(2020, 3, 12)) == false - expect(Day(2020, 3, 12) < Day(2020, 3, 13)) == true - } - - func testLessThanEquals() { - expect(Day(2020, 3, 12) <= Day(2020, 3, 11)) == false - expect(Day(2020, 3, 12) <= Day(2020, 3, 12)) == true - expect(Day(2020, 3, 12) <= Day(2020, 3, 13)) == true - } -} diff --git a/Tests/DayCustomStringConvertableTests.swift b/Tests/DayCustomStringConvertableTests.swift deleted file mode 100644 index 5d9fcca..0000000 --- a/Tests/DayCustomStringConvertableTests.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// DayCustomStringConvertableTests.swift -// -// -// Created by Derek Clarkson on 15/1/2024. -// - -import DayType -import Nimble -import XCTest - -class DayCustomStringConvertableTests: XCTestCase { - func testyDescription() { - let date = DateComponents(calendar: .current, year: 2001, month: 2, day: 3).date! - expect(Day(2001, 2, 3).description) == date.formatted(date: .abbreviated, time: .omitted) - } -} diff --git a/Tests/DayFormattedTests.swift b/Tests/DayFormattedTests.swift index 09f95be..a330602 100644 --- a/Tests/DayFormattedTests.swift +++ b/Tests/DayFormattedTests.swift @@ -1,25 +1,16 @@ -// -// DayFormattedTests.swift -// -// -// Created by Derek Clarkson on 7/12/2023. -// - import DayType -import Nimble -import XCTest +import Foundation +import Testing -class DayFormattedTests: XCTestCase { +@Test("Day formatted() matches Date formatted strings") +func formatted() { - func testFormatted() { - - let day = Day(2000, 2, 1) - let date = DateComponents(calendar: .current, year: 2000, month: 2, day: 1).date! + let day = Day(2000, 2, 1) + let date = DateComponents(calendar: .current, year: 2000, month: 2, day: 1).date! - expect(day.formatted()) == date.formatted(date: .abbreviated, time: .omitted) - expect(day.formatted(.abbreviated)) == date.formatted(date: .abbreviated, time: .omitted) - expect(day.formatted(.complete)) == date.formatted(date: .complete, time: .omitted) - expect(day.formatted(.long)) == date.formatted(date: .long, time: .omitted) - expect(day.formatted(.numeric)) == date.formatted(date: .numeric, time: .omitted) - } + #expect(day.formatted() == date.formatted(date: .abbreviated, time: .omitted)) + #expect(day.formatted(.abbreviated) == date.formatted(date: .abbreviated, time: .omitted)) + #expect(day.formatted(.complete) == date.formatted(date: .complete, time: .omitted)) + #expect(day.formatted(.long) == date.formatted(date: .long, time: .omitted)) + #expect(day.formatted(.numeric) == date.formatted(date: .numeric, time: .omitted)) } diff --git a/Tests/DayFunctionsTests.swift b/Tests/DayFunctionsTests.swift index c169478..42c0721 100644 --- a/Tests/DayFunctionsTests.swift +++ b/Tests/DayFunctionsTests.swift @@ -1,24 +1,20 @@ -// -// DayFunctionsTests.swift -// -// -// Created by Derek Clarkson on 16/1/2024. -// - import DayType -import Nimble -import XCTest +import Foundation +import Testing -class DayFunctionsTests: XCTestCase { +@Suite("Day functions") +struct DayFunctionsTests { - func testDayByAdding() { - expect(Day(2001, 2, 3).day(byAdding: .day, value: 3)) == Day(2001, 2, 6) - expect(Day(2001, 2, 3).day(byAdding: .month, value: 3)) == Day(2001, 5, 3) - expect(Day(2001, 2, 3).day(byAdding: .year, value: 3)) == Day(2004, 2, 3) + @Test("Adding days") + func addingDays() { + #expect(Day(2001, 2, 3).day(byAdding: .day, value: 3) == Day(2001, 2, 6)) + #expect(Day(2001, 2, 3).day(byAdding: .month, value: 3) == Day(2001, 5, 3)) + #expect(Day(2001, 2, 3).day(byAdding: .year, value: 3) == Day(2004, 2, 3)) } - func testDayByAddingRolling() { - expect(Day(2001, 2, 3).day(byAdding: .day, value: 55)) == Day(2001, 3, 30) - expect(Day(2001, 2, 3).day(byAdding: .month, value: 55)) == Day(2005, 9, 10) + @Test("Adding and rolling days") + func rollingDays() { + #expect(Day(2001, 2, 3).day(byAdding: .day, value: 55) == Day(2001, 3, 30)) + #expect(Day(2001, 2, 3).day(byAdding: .month, value: 55) == Day(2005, 9, 10)) } } diff --git a/Tests/DayOperationsTests.swift b/Tests/DayOperationsTests.swift index 48b4ce7..bb9cef7 100644 --- a/Tests/DayOperationsTests.swift +++ b/Tests/DayOperationsTests.swift @@ -1,37 +1,36 @@ -// -// DayOperationsTests.swift -// -// -// Created by Derek Clarkson on 12/12/2023. -// - import DayType -import Nimble -import XCTest +import Foundation +import Testing -class DayOperationTests: XCTestCase { +@Suite("Day operations") +struct DayOperationTests { - func testPlus() { - expect((Day(daysSince1970: 19445) + 5).daysSince1970) == 19450 + @Test("+") + func plus() { + #expect((Day(daysSince1970: 19445) + 5).daysSince1970 == 19450) } - func testMinus() { - expect((Day(daysSince1970: 19445) - 5).daysSince1970) == 19440 + @Test("-") + func minus() { + #expect((Day(daysSince1970: 19445) - 5).daysSince1970 == 19440) } - func testInplacePlus() { + @Test("+=") + func inplacePlus() { var day = Day(daysSince1970: 19445) day += 5 - expect(day.daysSince1970) == 19450 + #expect(day.daysSince1970 == 19450) } - func testInplaceMinus() { + @Test("-=") + func inplaceMinus() { var day = Day(daysSince1970: 19445) day -= 5 - expect(day.daysSince1970) == 19440 + #expect(day.daysSince1970 == 19440) } - func testDayDiff() { - expect(Day(2020, 3, 12) - Day(2020, 3, 6)) == 6 + @Test("Diff") + func dayDiff() { + #expect(Day(2020, 3, 12) - Day(2020, 3, 6) == 6) } } diff --git a/Tests/DayStrideableTests.swift b/Tests/DayStrideableTests.swift deleted file mode 100644 index e1bc4fe..0000000 --- a/Tests/DayStrideableTests.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// Day+Stridable.swift -// -// -// Created by Derek Clarkson on 15/1/2024. -// - -import Foundation -import DayType -import XCTest -import Nimble - -class DayStrideableTests: XCTestCase { - - func testForEachOpenRange() { - var days: [Day] = [] - for day in Day(2000,1,1)...Day(2000,1,5) { - days.append(day) - } - expect(days) == [Day(2000,1,1), Day(2000,1,2), Day(2000,1,3), Day(2000,1,4), Day(2000,1,5)] - } - - func testForEachHalfOpenRange() { - var days: [Day] = [] - for day in Day(2000,1,1).. 0 + #expect(Day().daysSince1970 > 0) } - func testInitDaysSince1970() { - expect(Day(daysSince1970: 19455).daysSince1970) == 19455 + @Test("Init with days since 1970") + func initDaysSince1970() { + #expect(Day(daysSince1970: 19455).daysSince1970 == 19455) } - func testInitTimeIntervalSince1970() { - expect(Day(timeIntervalSince1970: 1_680_954_742).daysSince1970) == 19455 + @Test("Init with time interval since 1970") + func initTimeIntervalSince1970() { + #expect(Day(timeIntervalSince1970: 1_680_954_742).daysSince1970 == 19455) } - func testInitTimeIntervalSince1970TruncationCheck() { - expect(Day(timeIntervalSince1970: 24 * 60 * 60 - 1).daysSince1970) == 0 - expect(Day(timeIntervalSince1970: 24 * 60 * 60 + 0).daysSince1970) == 1 - expect(Day(timeIntervalSince1970: 24 * 60 * 60 + 1).daysSince1970) == 1 + @Test("Init with time interval since 1970 time truncations") + func initTimeIntervalSince1970TruncationCheck() { + #expect(Day(timeIntervalSince1970: 24 * 60 * 60 - 1).daysSince1970 == 0) + #expect(Day(timeIntervalSince1970: 24 * 60 * 60 + 0).daysSince1970 == 1) + #expect(Day(timeIntervalSince1970: 24 * 60 * 60 + 1).daysSince1970 == 1) } - func testInitComponents() { - expect(Day(components: DayComponents(year: 2023, month: 4, day: 8)).daysSince1970) == 19455 + @Test("Init with components") + func initComponents() { + #expect(Day(components: DayComponents(year: 2023, month: 4, day: 8)).daysSince1970 == 19455) } - func testInitShortForm() { - expect(Day(2023, 4, 8).daysSince1970) == 19455 + @Test("Init with short form components") + func initShortForm() { + #expect(Day(2023, 4, 8).daysSince1970 == 19455) } - func testDateToDayToDayComponents() { + @Test("Check Day vs Date math") + func dateToDayToDayComponents() { let baseDate = Calendar.current.date(from: DateComponents(year: 1900, month: 1, day: 1))! for offset in 0 ..< 1_000_000 { let expectedDate = Calendar.current.date(byAdding: .day, value: offset, to: baseDate)! @@ -48,7 +51,7 @@ class DayTests: XCTestCase { } } - private func matches(day: Day, date: Date, file: StaticString = #file, line: UInt = #line) -> Bool { + private func matches(day: Day, date: Date, sourceLocation: Testing.SourceLocation = #_sourceLocation) -> Bool { let dayComponents = day.dayComponents() let dateComponents = Calendar.current.dateComponents([.year, .month, .day], from: date) @@ -58,7 +61,7 @@ class DayTests: XCTestCase { dayComponents.day == dateComponents.day else { print("Date components: \(dateComponents)") print("Day components : \(dayComponents)") - fail("Day from date and back to date failed", file: file, line: line) + Issue.record("Day from date and back to date failed", sourceLocation: sourceLocation) return false } return true diff --git a/Tests/Property wrappers/CustomISO8601Tests.swift b/Tests/Property wrappers/CustomISO8601Tests.swift deleted file mode 100644 index 75d286b..0000000 --- a/Tests/Property wrappers/CustomISO8601Tests.swift +++ /dev/null @@ -1,111 +0,0 @@ -// -// DayCodableTests.swift -// -// -// Created by Derek Clarkson on 9/12/2023. -// - -import DayType -import Nimble -import XCTest - -// MARK: - ISO8601 Decoding - -private struct ISO8601CustomContainer: Codable where Configurator: CustomISO8601Configurator { - @CustomISO8601 var d1: Day - init(d1: Day) { - self.d1 = d1 - } -} - -private struct ISO8601CustomOptionalContainer: Codable where Configurator: CustomISO8601Configurator { - @CustomISO8601 var d1: Day? - init(d1: Day?) { - self.d1 = d1 - } -} - -class CustomISO8601Tests: XCTestCase { - - func testDecodingSansTimeZone() throws { - let json = #"{"d1": "2012-02-02T13:33:23"}"# - let result = try JSONDecoder().decode(ISO8601CustomContainer.self, from: json.data(using: .utf8)!) - expect(result.d1) == Day(2012, 02, 03) - } - - func testDecodingSansTimeZoneToTimeZone() throws { - enum SansTimeZoneToMelbourneTimeZone: CustomISO8601Configurator { - static func configure(formatter: ISO8601DateFormatter) { - formatter.timeZone = TimeZone(secondsFromGMT: 11 * 60 * 60) - formatter.formatOptions.remove(.withTimeZone) - } - } - let json = #"{"d1": "2012-02-02T13:33:23"}"# - let result = try JSONDecoder().decode(ISO8601CustomContainer.self, from: json.data(using: .utf8)!) - expect(result.d1) == Day(2012, 02, 02) - } - - func testDecodingWithTimeZoneOverridesDateTimeZone() throws { - enum BrazilToMelbourneTimeZone: CustomISO8601Configurator { - static func configure(formatter: ISO8601DateFormatter) { - formatter.timeZone = TimeZone(secondsFromGMT: 11 * 60 * 60) - } - } - let json = #"{"d1": "2012-02-02T13:33:23-03:00"}"# - let result = try JSONDecoder().decode(ISO8601CustomContainer.self, from: json.data(using: .utf8)!) - expect(result.d1) == Day(2012, 02, 03) - } - - func testDecodingMinimalFormat() throws { - enum MinimalFormat: CustomISO8601Configurator { - static func configure(formatter: ISO8601DateFormatter) { - formatter.timeZone = TimeZone(secondsFromGMT: 11 * 60 * 60) - formatter.formatOptions.insert(.withSpaceBetweenDateAndTime) - formatter.formatOptions.subtract([.withTimeZone, .withColonSeparatorInTime, .withDashSeparatorInDate]) - } - } - let json = #"{"d1": "20120202 133323"}"# - let result = try JSONDecoder().decode(ISO8601CustomContainer.self, from: json.data(using: .utf8)!) - expect(result.d1) == Day(2012, 02, 02) - } - -} - -class CustomISO8601OptionalDayDecodingTests: XCTestCase { - - func testDecodingSansTimeZone() throws { - let json = #"{"d1": "2012-02-02T13:33:23"}"# - let result = try JSONDecoder().decode(ISO8601CustomOptionalContainer.self, from: json.data(using: .utf8)!) - expect(result.d1) == Day(2012, 02, 03) - } - - func testDecodingSansTimeZoneWithNil() throws { - let json = #"{"d1":null}"# - let result = try JSONDecoder().decode(ISO8601CustomOptionalContainer.self, from: json.data(using: .utf8)!) - expect(result.d1).to(beNil()) - } -} - -class CustomISO8601EncodingTests: XCTestCase { - - func testEncoding() throws { - let instance = ISO8601CustomContainer(d1: Day(2012, 02, 03)) - let result = try JSONEncoder().encode(instance) - expect(String(data: result, encoding: .utf8)!) == #"{"d1":"2012-02-02T13:00:00"}"# - } -} - -class CustomISO8601OptionalDayEncodingTests: XCTestCase { - - func testEncoding() throws { - let instance = ISO8601CustomOptionalContainer(d1: Day(2012, 02, 03)) - let result = try JSONEncoder().encode(instance) - expect(String(data: result, encoding: .utf8)!) == #"{"d1":"2012-02-02T13:00:00"}"# - } - - func testEncodingNil() throws { - let instance = ISO8601CustomOptionalContainer(d1: nil) - let result = try JSONEncoder().encode(instance) - expect(String(data: result, encoding: .utf8)!) == #"{"d1":null}"# - } -} diff --git a/Tests/Property wrappers/DateStringTests.swift b/Tests/Property wrappers/DateStringTests.swift index f0ffa96..0af42fd 100644 --- a/Tests/Property wrappers/DateStringTests.swift +++ b/Tests/Property wrappers/DateStringTests.swift @@ -1,119 +1,122 @@ -// -// DayCodableTests.swift -// -// -// Created by Derek Clarkson on 9/12/2023. -// - import DayType -import Nimble -import XCTest - -// MARK: - ISO8601 Decoding - -private struct DateStringContainer: Codable where Configurator: DateStringConfigurator { - @DateString var d1: Day - init(d1: Day) { - self.d1 = d1 - } -} +import Foundation +import Testing -private struct DateStringOptionalContainer: Codable where Configurator: DateStringConfigurator { - @DateString var d1: Day? - init(d1: Day?) { - self.d1 = d1 - } +private struct DayContainer: Codable { + @DayString.DMY var dmy: Day + @DayString.MDY var mdy: Day + @DayString.YMD var ymd: Day } -class DateStringTests: XCTestCase { - - func testDecodingISO8601DateString() throws { - let json = #"{"d1": "2012-02-01"}"# - let result = try JSONDecoder().decode(DateStringContainer.self, from: json.data(using: .utf8)!) - expect(result.d1) == Day(2012, 02, 01) - } - - func testDecodingAustralianDateStrings() throws { - let json = #"{"d1": "01/02/2012"}"# - let result = try JSONDecoder().decode(DateStringContainer.self, from: json.data(using: .utf8)!) - expect(result.d1) == Day(2012, 02, 01) - } - - func testDecodingAmericanDateStrings() throws { - let json = #"{"d1": "02/01/2012"}"# - let result = try JSONDecoder().decode(DateStringContainer.self, from: json.data(using: .utf8)!) - expect(result.d1) == Day(2012, 02, 01) - } -} - -class CodableAsDateStroingOptionalTests: XCTestCase { - - func testDecodingISO8601DateString() throws { - let json = #"{"d1": "2012-02-01"}"# - let result = try JSONDecoder().decode(DateStringOptionalContainer.self, from: json.data(using: .utf8)!) - expect(result.d1) == Day(2012, 02, 01) - } - - func testDecodingAustralianDateStrings() throws { - let json = #"{"d1": "01/02/2012"}"# - let result = try JSONDecoder().decode(DateStringOptionalContainer.self, from: json.data(using: .utf8)!) - expect(result.d1) == Day(2012, 02, 01) - } - - func testDecodingAmericanDateStrings() throws { - let json = #"{"d1": "02/01/2012"}"# - let result = try JSONDecoder().decode(DateStringOptionalContainer.self, from: json.data(using: .utf8)!) - expect(result.d1) == Day(2012, 02, 01) - } - - func testDecodingNilAustralianDateStrings() throws { - let json = #"{"d1": null}"# - let result = try JSONDecoder().decode(DateStringOptionalContainer.self, from: json.data(using: .utf8)!) - expect(result.d1) == nil - } - - func testDecodingInvalidAustralianDateStringsThrows() throws { - - let json = #"{"d1": "xxx"}"# - let decoder = JSONDecoder() - expect(try decoder.decode(DateStringOptionalContainer.self, from: json.data(using: .utf8)!)) - .to(throwError { error in - guard case DecodingError.dataCorrupted(let context) = error else { - fail("Incorrect error \(error)") - return - } - expect(context.codingPath.map { $0.stringValue }) == ["d1"] - expect(context.debugDescription) == "Unable to read the date string." - }) - } +private struct DayOptionalContainer: Codable { + @DayString.DMY var dmy: Day? + @DayString.MDY var mdy: Day? + @DayString.YMD var ymd: Day? } -class CodableAsDateStringEncodingTests: XCTestCase { - - func testEncoding() throws { - let instance = DateStringContainer(d1: Day(2012, 02, 01)) - let encoder = JSONEncoder() - encoder.outputFormatting = .withoutEscapingSlashes - let result = try encoder.encode(instance) - expect(String(data: result, encoding: .utf8)!) == #"{"d1":"01/02/2012"}"# - } +private struct DayOptionalNullContainer: Codable { + @DayString.DMY.Nullable var dmy: Day? + @DayString.MDY.Nullable var mdy: Day? + @DayString.YMD.Nullable var ymd: Day? } -class CodableAsDateStringOptionalTests: XCTestCase { - - func testEncoding() throws { - let instance = DateStringOptionalContainer(d1: Day(2012, 02, 01)) - let encoder = JSONEncoder() - encoder.outputFormatting = .withoutEscapingSlashes - let result = try encoder.encode(instance) - expect(String(data: result, encoding: .utf8)!) == #"{"d1":"01/02/2012"}"# - } - - func testEncodingNil() throws { - let instance = DateStringOptionalContainer(d1: nil) - let encoder = JSONEncoder() - encoder.outputFormatting = .withoutEscapingSlashes - let result = try encoder.encode(instance) - expect(String(data: result, encoding: .utf8)!) == #"{"d1":null}"# +extension PropertyWrapperSuites { + + @Suite("@DayString") + struct DateStringTests { + + @Test("Decoding date strings") + func decodingDates() throws { + let json = #"{"dmy": "01/02/2012", "mdy": "02/01/2012", "ymd": "2012-02-01"}"# + let result = try JSONDecoder().decode(DayContainer.self, from: json.data(using: .utf8)!) + let expectedDate = Day(2012, 02, 01) + #expect(result.dmy == expectedDate) + #expect(result.mdy == expectedDate) + #expect(result.ymd == expectedDate) + } + + @Test("Optional date string decoding") + func decodingOptionalDates() throws { + let json = #"{"dmy": "01/02/2012", "mdy": "02/01/2012", "ymd": "2012-02-01"}"# + let result = try JSONDecoder().decode(DayOptionalContainer.self, from: json.data(using: .utf8)!) + let expectedDate = Day(2012, 02, 01) + #expect(result.dmy == expectedDate) + #expect(result.mdy == expectedDate) + #expect(result.ymd == expectedDate) + } + + @Test("Optional nil decoding") + func decodingOptionalNilDates() throws { + let json = #"{"dmy": null, "mdy": null, "ymd": null}"# + let result = try JSONDecoder().decode(DayOptionalContainer.self, from: json.data(using: .utf8)!) + #expect(result.dmy == nil) + #expect(result.mdy == nil) + #expect(result.ymd == nil) + } + + @Test("Optional missing decoding") + func decodingOptionalMissingDates() throws { + let json = #"{}"# + let result = try JSONDecoder().decode(DayOptionalContainer.self, from: json.data(using: .utf8)!) + #expect(result.dmy == nil) + #expect(result.mdy == nil) + #expect(result.ymd == nil) + } + + @Test("Invalid date string decoding throws an error") + func decodingInvalidDateThrows() throws { + do { + let json = #"{"dmy": "xxx"}"# + _ = try JSONDecoder().decode(DayOptionalContainer.self, from: json.data(using: .utf8)!) + Issue.record("Error not thrown") + } catch DecodingError.dataCorrupted(let context) { + #expect(context.codingPath.map(\.stringValue) == ["dmy"]) + #expect(context.debugDescription == "Unable to read the date string.") + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test("Date string encoding") + func encodingDateStrings() throws { + let day = Day(2012, 02, 01) + let instance = DayContainer(dmy: day, mdy: day, ymd: day) + let encoder = JSONEncoder() + encoder.outputFormatting = [.withoutEscapingSlashes, .sortedKeys] + let result = try encoder.encode(instance) + let json = String(data: result, encoding: .utf8)! + #expect(json == #"{"dmy":"01/02/2012","mdy":"02/01/2012","ymd":"2012-02-01"}"#) + } + + @Test("Optional date string encoding") + func encodingOptionalDateStrings() throws { + let day = Day(2012, 02, 01) + let instance = DayOptionalContainer(dmy: day, mdy: day, ymd: day) + let encoder = JSONEncoder() + encoder.outputFormatting = [.withoutEscapingSlashes, .sortedKeys] + let result = try encoder.encode(instance) + let json = String(data: result, encoding: .utf8)! + #expect(json == #"{"dmy":"01/02/2012","mdy":"02/01/2012","ymd":"2012-02-01"}"#) + } + + @Test("Optional date encoding nil") + func encodingOptionalDateStringsWithNil() throws { + let instance = DayOptionalContainer(dmy: nil, mdy: nil, ymd: nil) + let encoder = JSONEncoder() + encoder.outputFormatting = .withoutEscapingSlashes + let result = try encoder.encode(instance) + let json = String(data: result, encoding: .utf8)! + #expect(json == "{}") + } + + @Test("Optional date encoding nil -> null") + func encodingOptionalDateStringsWithNilToNull() throws { + let instance = DayOptionalNullContainer(dmy: nil, mdy: nil, ymd: nil) + let encoder = JSONEncoder() + encoder.outputFormatting = [.withoutEscapingSlashes, .sortedKeys] + let result = try encoder.encode(instance) + let json = String(data: result, encoding: .utf8)! + #expect(json == #"{"dmy":null,"mdy":null,"ymd":null}"#) + } } } diff --git a/Tests/Property wrappers/EpochMillisecondsTests.swift b/Tests/Property wrappers/EpochMillisecondsTests.swift deleted file mode 100644 index 124082e..0000000 --- a/Tests/Property wrappers/EpochMillisecondsTests.swift +++ /dev/null @@ -1,94 +0,0 @@ -// -// DayCodableTests.swift -// -// -// Created by Derek Clarkson on 9/12/2023. -// - -import DayType -import Nimble -import XCTest - -private struct EpochContainer: Codable { - @EpochMilliseconds var d1: Day - init(d1: Day) { - self.d1 = d1 - } -} - -private struct EpochOptionalContainer: Codable { - @EpochMilliseconds var d1: Day? - init(d1: Day?) { - self.d1 = d1 - } -} - -class EpochMillisecondsDecodingTests: XCTestCase { - - func testDecoding() throws { - let json = #"{"d1": 1328251182123}"# - let result = try JSONDecoder().decode(EpochContainer.self, from: json.data(using: .utf8)!) - expect(result.d1) == Day(2012, 02, 03) - } - - func testDecodingWithInvalidValue() throws { - do { - let json = #"{"d1": "2012-02-03T10:33:23.123+11:00"}"# - _ = try JSONDecoder().decode(EpochContainer.self, from: json.data(using: .utf8)!) - fail("Error not thrown") - } catch DecodingError.dataCorrupted(let context) { - expect(context.codingPath.last?.stringValue) == "d1" - expect(context.debugDescription) == "Unable to read a Day value, expected an epoch." - } - } - -} - -class CodableAsEpochMillisecondsDecodiongOptionalTests: XCTestCase { - - func testDecoding() throws { - let json = #"{"d1": 1328251182123}"# - let result = try JSONDecoder().decode(EpochOptionalContainer.self, from: json.data(using: .utf8)!) - expect(result.d1) == Day(2012, 02, 03) - } - - func testDecodingWithNil() throws { - let json = #"{"d1": null}"# - let result = try JSONDecoder().decode(EpochOptionalContainer.self, from: json.data(using: .utf8)!) - expect(result.d1).to(beNil()) - } - - func testDecodingWithMissingValue() throws { - do { - let json = #"{}"# - _ = try JSONDecoder().decode(EpochOptionalContainer.self, from: json.data(using: .utf8)!) - fail("Error not thrown") - } catch DecodingError.keyNotFound(let key, _) { - expect(key.stringValue) == "d1" - } - } -} - -class CodableAsEpochMillisecondsEncodingTests: XCTestCase { - - func testEncoding() throws { - let instance = EpochContainer(d1: Day(2012, 02, 03)) - let result = try JSONEncoder().encode(instance) - expect(String(data: result, encoding: .utf8)!) == #"{"d1":1328187600000}"# - } -} - -class CodableAsEpochMillisecondsEncodingOptionalTests: XCTestCase { - - func testEncoding() throws { - let instance = EpochOptionalContainer(d1: Day(2012, 02, 03)) - let result = try JSONEncoder().encode(instance) - expect(String(data: result, encoding: .utf8)!) == #"{"d1":1328187600000}"# - } - - func testEncodingNil() throws { - let instance = EpochOptionalContainer(d1: nil) - let result = try JSONEncoder().encode(instance) - expect(String(data: result, encoding: .utf8)!) == #"{"d1":null}"# - } -} diff --git a/Tests/Property wrappers/EpochSecondsTests.swift b/Tests/Property wrappers/EpochSecondsTests.swift deleted file mode 100644 index 1cac6a1..0000000 --- a/Tests/Property wrappers/EpochSecondsTests.swift +++ /dev/null @@ -1,94 +0,0 @@ -// -// DayCodableTests.swift -// -// -// Created by Derek Clarkson on 9/12/2023. -// - -import DayType -import Nimble -import XCTest - -private struct EpochContainer: Codable { - @EpochSeconds var d1: Day - init(d1: Day) { - self.d1 = d1 - } -} - -private struct EpochOptionalContainer: Codable { - @EpochSeconds var d1: Day? - init(d1: Day?) { - self.d1 = d1 - } -} - -class EpochSecondsDecodingTests: XCTestCase { - - func testDecoding() throws { - let json = #"{"d1": 1328251182}"# - let result = try JSONDecoder().decode(EpochContainer.self, from: json.data(using: .utf8)!) - expect(result.d1) == Day(2012, 02, 03) - } - - func testDecodingWithInvalidValue() throws { - do { - let json = #"{"d1": "2012-02-03T10:33:23+11:00"}"# - _ = try JSONDecoder().decode(EpochContainer.self, from: json.data(using: .utf8)!) - fail("Error not thrown") - } catch DecodingError.dataCorrupted(let context) { - expect(context.codingPath.last?.stringValue) == "d1" - expect(context.debugDescription) == "Unable to read a Day value, expected an epoch." - } - } - -} - -class CodableAsEpochSecondsDecodiongOptionalTests: XCTestCase { - - func testDecoding() throws { - let json = #"{"d1": 1328251182}"# - let result = try JSONDecoder().decode(EpochOptionalContainer.self, from: json.data(using: .utf8)!) - expect(result.d1) == Day(2012, 02, 03) - } - - func testDecodingWithNil() throws { - let json = #"{"d1": null}"# - let result = try JSONDecoder().decode(EpochOptionalContainer.self, from: json.data(using: .utf8)!) - expect(result.d1).to(beNil()) - } - - func testDecodingWithMissingValue() throws { - do { - let json = #"{}"# - _ = try JSONDecoder().decode(EpochOptionalContainer.self, from: json.data(using: .utf8)!) - fail("Error not thrown") - } catch DecodingError.keyNotFound(let key, _) { - expect(key.stringValue) == "d1" - } - } -} - -class CodableAsEpochSecondsEncodingTests: XCTestCase { - - func testEncoding() throws { - let instance = EpochContainer(d1: Day(2012, 02, 03)) - let result = try JSONEncoder().encode(instance) - expect(String(data: result, encoding: .utf8)!) == #"{"d1":1328187600}"# - } -} - -class CodableAsEpochSecondsEncodingOptionalTests: XCTestCase { - - func testEncoding() throws { - let instance = EpochOptionalContainer(d1: Day(2012, 02, 03)) - let result = try JSONEncoder().encode(instance) - expect(String(data: result, encoding: .utf8)!) == #"{"d1":1328187600}"# - } - - func testEncodingNil() throws { - let instance = EpochOptionalContainer(d1: nil) - let result = try JSONEncoder().encode(instance) - expect(String(data: result, encoding: .utf8)!) == #"{"d1":null}"# - } -} diff --git a/Tests/Property wrappers/EpochTests.swift b/Tests/Property wrappers/EpochTests.swift new file mode 100644 index 0000000..bb93993 --- /dev/null +++ b/Tests/Property wrappers/EpochTests.swift @@ -0,0 +1,115 @@ +import DayType +import Foundation +import Testing + +private struct EpochContainer: Codable { + @Epoch.Milliseconds var milliseconds: Day + @Epoch.Seconds var seconds: Day +} + +private struct EpochOptionalContainer: Codable { + @Epoch.Milliseconds var milliseconds: Day? + @Epoch.Seconds var seconds: Day? +} + +private struct EpochOptionalNullContainer: Codable { + @Epoch.Milliseconds.Nullable var milliseconds: Day? + @Epoch.Seconds.Nullable var seconds: Day? +} + +extension PropertyWrapperSuites { + + @Suite("@Epoch") + struct EpochTests { + + @Test("Decoding epochs") + func decoding() throws { + let json = #"{"milliseconds": 1328251182123, "seconds": 1328251182}"# + let result = try JSONDecoder().decode(EpochContainer.self, from: json.data(using: .utf8)!) + let expectedDay = Day(2012, 02, 03) + #expect(result.milliseconds == expectedDay) + #expect(result.seconds == expectedDay) + } + + @Test("Decoding optional epochs") + func decodingOptional() throws { + let json = #"{"milliseconds": 1328251182123, "seconds": 1328251182}"# + let result = try JSONDecoder().decode(EpochOptionalContainer.self, from: json.data(using: .utf8)!) + let expectedDay = Day(2012, 02, 03) + #expect(result.milliseconds == expectedDay) + #expect(result.seconds == expectedDay) + } + + @Test("Decoding missing epochs") + func decodingMissingOptional() throws { + let json = #"{}"# + let result = try JSONDecoder().decode(EpochOptionalContainer.self, from: json.data(using: .utf8)!) + #expect(result.milliseconds == nil) + #expect(result.seconds == nil) + } + + @Test("Decoding null epochs") + func decodingNullOptional() throws { + let json = #"{"milliseconds": null, "seconds": null}"# + let result = try JSONDecoder().decode(EpochOptionalContainer.self, from: json.data(using: .utf8)!) + #expect(result.milliseconds == nil) + #expect(result.seconds == nil) + } + + @Test("Decoding invalid epochs") + func decodingInvalidEpochs() throws { + do { + let json = #"{"milliseconds": "xxx"}"# + _ = try JSONDecoder().decode(EpochOptionalContainer.self, from: json.data(using: .utf8)!) + Issue.record("Error not thrown") + } catch DecodingError.dataCorrupted(let context) { + #expect(context.codingPath.map(\.stringValue) == ["milliseconds"]) + #expect(context.debugDescription == "Unable to read a Day value, expected an epoch.") + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test("Epoch encoding") + func encodingEpochs() throws { + let day = Day(2012, 02, 03) + let instance = EpochContainer(milliseconds: day, seconds: day) + let encoder = JSONEncoder() + encoder.outputFormatting = [.withoutEscapingSlashes, .sortedKeys] + let result = try encoder.encode(instance) + let json = String(data: result, encoding: .utf8)! + #expect(json == #"{"milliseconds":1328187600000,"seconds":1328187600}"#) + } + + @Test("Optional epoch encoding") + func optionalEncodingEpochs() throws { + let day = Day(2012, 02, 03) + let instance = EpochOptionalContainer(milliseconds: day, seconds: day) + let encoder = JSONEncoder() + encoder.outputFormatting = [.withoutEscapingSlashes, .sortedKeys] + let result = try encoder.encode(instance) + let json = String(data: result, encoding: .utf8)! + #expect(json == #"{"milliseconds":1328187600000,"seconds":1328187600}"#) + } + + @Test("Optional epoch encoding nil") + func optionalEncodingEpochsWithNil() throws { + let instance = EpochOptionalContainer(milliseconds: nil, seconds: nil) + let encoder = JSONEncoder() + encoder.outputFormatting = [.withoutEscapingSlashes, .sortedKeys] + let result = try encoder.encode(instance) + let json = String(data: result, encoding: .utf8)! + #expect(json == #"{}"#) + } + + @Test("Optional epoch encoding nil -> null") + func optionalEncodingEpochsWithNilToNull() throws { + let instance = EpochOptionalNullContainer(milliseconds: nil, seconds: nil) + let encoder = JSONEncoder() + encoder.outputFormatting = [.withoutEscapingSlashes, .sortedKeys] + let result = try encoder.encode(instance) + let json = String(data: result, encoding: .utf8)! + #expect(json == #"{"milliseconds":null,"seconds":null}"#) + } + } +} diff --git a/Tests/Property wrappers/ISO8601Tests.swift b/Tests/Property wrappers/ISO8601Tests.swift index 6eb5fe4..216b559 100644 --- a/Tests/Property wrappers/ISO8601Tests.swift +++ b/Tests/Property wrappers/ISO8601Tests.swift @@ -1,100 +1,130 @@ -// -// DayCodableTests.swift -// -// -// Created by Derek Clarkson on 9/12/2023. -// - import DayType -import Nimble -import XCTest +import Foundation +import Testing private struct ISO8601Container: Codable { - - @ISO8601 var d1: Day - init(d1: Day) { - self.d1 = d1 - } + @ISO8601.Default var iso8601: Day + @ISO8601.SansTimezone var iso8601SansTimezone: Day } private struct ISO8601OptionalContainer: Codable { - @ISO8601 var d1: Day? - init(d1: Day?) { - self.d1 = d1 - } + @ISO8601.Default var iso8601: Day? + @ISO8601.SansTimezone var iso8601SansTimezone: Day? } -class ISO8601Tests: XCTestCase { +private struct ISO8601OptionalNullContainer: Codable { + @ISO8601.Default.Nullable var iso8601: Day? + @ISO8601.SansTimezone.Nullable var iso8601SansTimezone: Day? +} - func testDecoding() throws { - let json = #"{"d1": "2012-02-03T10:33:23+11:00"}"# - let result = try JSONDecoder().decode(ISO8601Container.self, from: json.data(using: .utf8)!) - expect(result.d1) == Day(2012, 02, 03) - } +extension PropertyWrapperSuites { - func testDecodingWithDefaultGMT() throws { - let json = #"{"d1": "2012-02-02T13:33:23Z"}"# - let result = try JSONDecoder().decode(ISO8601Container.self, from: json.data(using: .utf8)!) - expect(result.d1) == Day(2012, 02, 03) - } + @Suite("@ISO8601") + struct ISO8601Tests { - func testDecodingWithInvalidStringDate() throws { - do { - let json = #"{"d1": "xxxx"}"# - _ = try JSONDecoder().decode(ISO8601Container.self, from: json.data(using: .utf8)!) - fail("Error not thrown") - } catch DecodingError.dataCorrupted(let context) { - expect(context.debugDescription) == "Unable to read a Day value, expected a valid ISO8601 string." - expect(context.codingPath.last?.stringValue) == "d1" + @Test("Decoding strings") + func decodingDates() throws { + let json = #"{"iso8601": "2012-02-01T12:00:00Z+12:00", "iso8601SansTimezone": "2012-02-01T12:00:00"}"# + let result = try JSONDecoder().decode(ISO8601Container.self, from: json.data(using: .utf8)!) + let expectedDate = Day(2012, 02, 01) + #expect(result.iso8601 == expectedDate) + #expect(result.iso8601SansTimezone == expectedDate) } - } -} -class ISO8601OptionalTests: XCTestCase { - - func testDecoding() throws { - let json = #"{"d1": "2012-02-03T10:33:23+11:00"}"# - let result = try JSONDecoder().decode(ISO8601OptionalContainer.self, from: json.data(using: .utf8)!) - expect(result.d1) == Day(2012, 02, 03) - } + @Test("Optional string decoding") + func decodingOptionalDates() throws { + let json = #"{"iso8601": "2012-02-01T12:00:00Z+12:00", "iso8601SansTimezone": "2012-02-01T12:00:00"}"# + let result = try JSONDecoder().decode(ISO8601OptionalContainer.self, from: json.data(using: .utf8)!) + let expectedDate = Day(2012, 02, 01) + #expect(result.iso8601 == expectedDate) + #expect(result.iso8601SansTimezone == expectedDate) + } - func testDecodingWithNil() throws { - let json = #"{"d1": null}"# - let result = try JSONDecoder().decode(ISO8601OptionalContainer.self, from: json.data(using: .utf8)!) - expect(result.d1).to(beNil()) - } + @Test("Optional nil decoding") + func decodingOptionalNilDates() throws { + let json = #"{"ios8601": null, "iso8691SansTimezone": null}"# + let result = try JSONDecoder().decode(ISO8601OptionalContainer.self, from: json.data(using: .utf8)!) + #expect(result.iso8601 == nil) + #expect(result.iso8601SansTimezone == nil) + } - func testDecodingWithMissingValue() throws { - do { + @Test("Optional missing decoding") + func decodingOptionalMissingDates() throws { let json = #"{}"# - _ = try JSONDecoder().decode(ISO8601OptionalContainer.self, from: json.data(using: .utf8)!) - fail("Error not thrown") - } catch DecodingError.keyNotFound(let key, _) { - expect(key.stringValue) == "d1" + let result = try JSONDecoder().decode(ISO8601OptionalContainer.self, from: json.data(using: .utf8)!) + #expect(result.iso8601 == nil) + #expect(result.iso8601SansTimezone == nil) } - } -} -class ISO8601DayEncodingTests: XCTestCase { + @Test("Invalid ISO8691 string decoding throws an error") + func decodingInvalidDateThrows() throws { + do { + let json = #"{"iso8601": "xxx4 5 ass3"}"# + _ = try JSONDecoder().decode(ISO8601OptionalContainer.self, from: json.data(using: .utf8)!) + Issue.record("Error not thrown") + } catch DecodingError.dataCorrupted(let context) { + #expect(context.codingPath.map(\.stringValue) == ["iso8601"]) + #expect(context.debugDescription == "Unable to read the date string.") + } catch { + Issue.record("Unexpected error: \(error)") + } + } - func testEncoding() throws { - let instance = ISO8601Container(d1: Day(2012, 02, 03)) - let result = try JSONEncoder().encode(instance) - expect(String(data: result, encoding: .utf8)!) == #"{"d1":"2012-02-02T13:00:00Z"}"# - } -} + @Test("String encoding") + func encodingDateStrings() throws { + let day = Day(2012, 02, 01) + let instance = ISO8601Container(iso8601: day, iso8601SansTimezone: day) + let encoder = JSONEncoder() + encoder.outputFormatting = [.withoutEscapingSlashes, .sortedKeys] + let result = try encoder.encode(instance) + let json = String(data: result, encoding: .utf8)! + let expectedJSON = #"{"iso8601":"\#(expectedISO8601Date())","iso8601SansTimezone":"\#(expectedISO8601SansDate())"}"# + #expect(json == expectedJSON) + } + + @Test("Optional string encoding") + func encodingOptionalDateStrings() throws { + let day = Day(2012, 02, 01) + let instance = ISO8601OptionalContainer(iso8601: day, iso8601SansTimezone: day) + let encoder = JSONEncoder() + encoder.outputFormatting = [.withoutEscapingSlashes, .sortedKeys] + let result = try encoder.encode(instance) + let json = String(data: result, encoding: .utf8)! + let expectedJSON = #"{"iso8601":"\#(expectedISO8601Date())","iso8601SansTimezone":"\#(expectedISO8601SansDate())"}"# + #expect(json == expectedJSON) + } -class ISO8601OptionalDayEncodingTests: XCTestCase { + @Test("Optional encoding nil") + func encodingOptionalDateStringsWithNil() throws { + let instance = ISO8601OptionalContainer(iso8601: nil, iso8601SansTimezone: nil) + let encoder = JSONEncoder() + encoder.outputFormatting = .withoutEscapingSlashes + let result = try encoder.encode(instance) + let json = String(data: result, encoding: .utf8)! + #expect(json == "{}") + } - func testEncoding() throws { - let instance = ISO8601OptionalContainer(d1: Day(2012, 02, 03)) - let result = try JSONEncoder().encode(instance) - expect(String(data: result, encoding: .utf8)!) == #"{"d1":"2012-02-02T13:00:00Z"}"# - } + @Test("Optional encoding nil -> null") + func encodingOptionalDateStringsWithNilToNull() throws { + let instance = ISO8601OptionalNullContainer(iso8601: nil, iso8601SansTimezone: nil) + let encoder = JSONEncoder() + encoder.outputFormatting = [.withoutEscapingSlashes, .sortedKeys] + let result = try encoder.encode(instance) + let json = String(data: result, encoding: .utf8)! + #expect(json == #"{"iso8601":null,"iso8601SansTimezone":null}"#) + } - func testEncodingNil() throws { - let instance = ISO8601OptionalContainer(d1: nil) - let result = try JSONEncoder().encode(instance) - expect(String(data: result, encoding: .utf8)!) == #"{"d1":null}"# + func expectedISO8601Date() -> String { + let today = DateComponents(calendar: .current, timeZone: .current, year: 2012, month: 2, day: 1) + let formatter = ISO8601DateFormatter() + return formatter.string(from: today.date!) + } + + func expectedISO8601SansDate() -> String { + let today = DateComponents(calendar: .current, timeZone: .current, year: 2012, month: 2, day: 1) + let formatter = ISO8601DateFormatter() + formatter.formatOptions.remove(.withTimeZone) + return formatter.string(from: today.date!) + } } } diff --git a/Tests/Property wrappers/PropertyWrapperSuites.swift b/Tests/Property wrappers/PropertyWrapperSuites.swift new file mode 100644 index 0000000..6e676a2 --- /dev/null +++ b/Tests/Property wrappers/PropertyWrapperSuites.swift @@ -0,0 +1,6 @@ +import Testing + +/// Groups up property test suites. +@Suite("Property wrappers", .tags(.PropertyWrapper)) +struct PropertyWrapperSuites {} + diff --git a/Tests/Tags.swift b/Tests/Tags.swift new file mode 100644 index 0000000..a64dba0 --- /dev/null +++ b/Tests/Tags.swift @@ -0,0 +1,7 @@ +import Testing + +extension Tag { + @Tag static var PropertyWrapper: Self + @Tag static var ProtocolConformance: Self +} +