Skip to content

Commit 09cd267

Browse files
authored
Add Date generators
1 parent b787f5e commit 09cd267

File tree

6 files changed

+503
-0
lines changed

6 files changed

+503
-0
lines changed
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
//
2+
// Date+String.swift
3+
// PropertyBased
4+
//
5+
// Created by Lennard Sprong on 31/05/2025.
6+
//
7+
8+
#if canImport(Foundation)
9+
import Foundation
10+
11+
extension Calendar {
12+
static var neutral: Calendar {
13+
var cal = Calendar(identifier: .iso8601)
14+
cal.timeZone = TimeZone(secondsFromGMT: 0)!
15+
return cal
16+
}
17+
}
18+
19+
let dateFormats = [
20+
"yyyy-MM-dd",
21+
"yyyy-MM-dd'T'HH:mm:ss",
22+
"yyyy-MM-dd'T'HH:mm:ssZZZZZ",
23+
"yyyy-MM-dd'T'HH:mm:ss.SSS",
24+
"yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ",
25+
]
26+
27+
extension Date: @retroactive ExpressibleByStringLiteral {
28+
/// Create a date from a ISO8601-formatted string.
29+
///
30+
/// The date components are required. The time and offset components are optional.
31+
///
32+
/// This initializer exists as a convenience for creating date ranges. It is not recommended for use in a production environment.
33+
///
34+
/// - Parameter value: The string to parse.
35+
public init(stringLiteral value: String) {
36+
let formatter = DateFormatter()
37+
formatter.calendar = .neutral
38+
formatter.locale = Locale(identifier: "en_US_POSIX")
39+
formatter.timeZone = TimeZone(secondsFromGMT: 0)
40+
41+
for format in dateFormats {
42+
formatter.dateFormat = format
43+
if let date = formatter.date(from: value) {
44+
self = date
45+
return
46+
}
47+
}
48+
49+
fatalError("\(value) is not a valid ISO 8601 date")
50+
}
51+
}
52+
53+
#endif

Sources/PropertyBased/Documentation.docc/Gen.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,23 @@ You can generate individual characters, and use ``/Generator/string(of:)`` to fo
4242
- ``/Gen/character(in:)``
4343
- ``/Gen/unicodeScalar(in:)``
4444

45+
### Generating dates
46+
47+
- ``/Gen/date``
48+
- ``/Gen/date(in:)``
49+
- ``/Gen/date(inYear:)``
50+
- ``/Gen/dateTime``
51+
- ``/Gen/dateTime(in:)``
52+
- ``/Gen/dateTime(inYear:)``
53+
- ``/Gen/year``
54+
- ``/Gen/year(in:)``
55+
4556
### Handling immutable collections
4657

4758
- ``/Gen/case``
4859
- ``/Gen/element(of:)``
4960
- ``/Gen/shuffled(_:)``
61+
- ``/Gen/optionSet``
5062

5163
### Generating specific number types
5264

Sources/PropertyBased/Gen+Date.swift

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
//
2+
// Gen+Date.swift
3+
// PropertyBased
4+
//
5+
// Created by Lennard Sprong on 30/05/2025.
6+
//
7+
8+
#if canImport(Foundation)
9+
import Foundation
10+
11+
// This value is a constant, to keep test runs reproducible.
12+
// This value may only be changed between package releases.
13+
let defaultYear = 2025
14+
let yearWindow = 60
15+
16+
@usableFromInline let defaultYearRange = (defaultYear - yearWindow)...(defaultYear + yearWindow)
17+
let preferredDistanceFromNow: TimeInterval = 60 * 20
18+
let secondsPerDay: TimeInterval = 60 * 60 * 24
19+
20+
extension Gen where Value == Int {
21+
/// A generator that creates random integers that represent years.
22+
///
23+
/// This is a convenience function that creates integers, and shrinks them closer to the current year.
24+
/// The default range is between 60 years in the past and 60 years in the future.
25+
///
26+
/// ## See Also
27+
/// - ``year(in:)``
28+
@inlinable public static var year: Generator<Int, Shrink.Integer<Int>> {
29+
year(in: defaultYearRange)
30+
}
31+
32+
/// A generator that creates random integers that represent years.
33+
///
34+
/// This is a convenience function that creates integers, and shrinks them closer to the current year.
35+
/// The default range is between approximately 60 years in the past and 60 years in the future.
36+
/// - Parameter range: The range in which to create a random value. Undefined bounds will be set to 60 years from the other bound.
37+
/// - Returns: A new generator.
38+
public static func year(in range: some RangeExpression<Int>) -> Generator<Int, Shrink.Integer<Int>> {
39+
let actualRange = ClosedRange(years: range)
40+
let currentYear = Calendar.current.component(.year, from: Date())
41+
42+
return .init(
43+
run: { rng in Int.random(in: actualRange, using: &rng) },
44+
shrink: { $0.shrink(towards: currentYear) }
45+
)
46+
}
47+
}
48+
49+
extension Gen where Value == Date {
50+
@_documentation(visibility: internal)
51+
public typealias DateTimeShrink = Shrink.Appended<Shrink.Floating<TimeInterval>, Shrink.TruncateTime>
52+
53+
/// A generator that creates dates with time components.
54+
///
55+
/// The default range is between approximately 60 years in the past and 60 years in the future.
56+
@inlinable public static var dateTime: Generator<Date, DateTimeShrink> {
57+
dateTime(inYear: defaultYearRange)
58+
}
59+
60+
/// A generator that creates dates with time components.
61+
/// - Parameter year: The year which contains all generated dates.
62+
/// - Returns: A new generator.
63+
@inlinable public static func dateTime(inYear year: Int) -> Generator<Date, DateTimeShrink> {
64+
return dateTime(inYear: year ... year)
65+
}
66+
67+
/// A generator that creates dates with time components.
68+
/// - Parameter range: A range of years that contains all generated dates. Undefined bounds will be set to 60 years from the other bound.
69+
/// - Returns: A new generator.
70+
public static func dateTime(inYear range: some RangeExpression<Int>) -> Generator<Date, DateTimeShrink> {
71+
let actualRange = ClosedRange(years: range)
72+
73+
guard let lower = DateComponents(calendar: .neutral, year: actualRange.lowerBound, month: 1, day: 1).date,
74+
let upper = DateComponents(calendar: .neutral, year: actualRange.upperBound + 1, month: 1, day: 1).date else {
75+
fatalError("dateTime(inYear:) got invalid year range: \(range)")
76+
}
77+
78+
return dateTime(in: lower ..< upper)
79+
}
80+
81+
/// A generator that creates dates with time components.
82+
///
83+
/// Dates can be passed in directly, or written as string literals in ISO 8601 format:
84+
///
85+
/// ```swift
86+
/// Gen.dateTime(in: "2024-01-01"...)
87+
/// ```
88+
/// - Parameter range: A range which contains all generated dates. Undefined bounds will be set to 60 years from the other bound.
89+
/// - Returns: A new generator.
90+
public static func dateTime(in range: PartialRangeFrom<Date>) -> Generator<Date, DateTimeShrink> {
91+
let upper = Calendar.neutral.date(byAdding: .year, value: yearWindow, to: range.lowerBound)!
92+
return dateTime(in: range.lowerBound ..< upper)
93+
}
94+
95+
/// A generator that creates dates with time components.
96+
///
97+
/// Dates can be passed in directly, or written as string literals in ISO 8601 format:
98+
///
99+
/// ```swift
100+
/// Gen.dateTime(in: ..<"2025-01-01")
101+
/// ```
102+
/// - Parameter range: A range which contains all generated dates. Undefined bounds will be set to 60 years from the other bound.
103+
/// - Returns: A new generator.
104+
public static func dateTime(in range: PartialRangeUpTo<Date>) -> Generator<Date, DateTimeShrink> {
105+
let lower = Calendar.neutral.date(byAdding: .year, value: -yearWindow, to: range.upperBound)!
106+
return dateTime(in: lower ..< range.upperBound)
107+
}
108+
109+
/// A generator that creates dates with time components.
110+
///
111+
/// Dates can be passed in directly, or written as string literals in ISO 8601 format:
112+
///
113+
/// ```swift
114+
/// Gen.dateTime(in: "2024-01-01" ..< "2025-01-01")
115+
/// ```
116+
/// - Parameter range: A range which contains all generated dates. Undefined bounds will be set to 60 years from the other bound.
117+
/// - Returns: A new generator.
118+
public static func dateTime(in range: Range<Date>) -> Generator<Date, DateTimeShrink> {
119+
let interval = range.lowerBound.timeIntervalSinceReferenceDate ..< range.upperBound.timeIntervalSinceReferenceDate
120+
let end = Date().timeIntervalSinceReferenceDate
121+
122+
return .init(
123+
run: { rng in TimeInterval.random(in: interval, using: &rng) },
124+
shrink: {
125+
let seq = abs($0.distance(to: end)) > preferredDistanceFromNow ? $0.shrink(within: interval, towards: end) : nil
126+
return Shrink.Appended(first: seq, second: Shrink.TruncateTime($0, roundUp: end > $0))
127+
},
128+
finalResult: { Date(timeIntervalSinceReferenceDate: $0) }
129+
)
130+
}
131+
}
132+
133+
extension Gen where Value == Date {
134+
/// A generator that creates dates without time components, in the UTC timezone.
135+
///
136+
/// The default range is between approximately 60 years in the past and 60 years in the future.
137+
@inlinable public static var date: Generator<Date, Shrink.Integer<Int>> {
138+
date(inYear: defaultYearRange)
139+
}
140+
141+
/// A generator that creates dates without time components, in the UTC timezone.
142+
/// - Parameter year: The year which contains all generated dates.
143+
/// - Returns: A new generator.
144+
@inlinable public static func date(inYear year: Int) -> Generator<Date, Shrink.Integer<Int>> {
145+
return date(inYear: year ... year)
146+
}
147+
148+
/// A generator that creates dates without time components, in the UTC timezone.
149+
/// - Parameter range: A range of years that contains all generated dates. Undefined bounds will be set to 60 years from the other bound.
150+
/// - Returns: A new generator.
151+
public static func date(inYear range: some RangeExpression<Int>) -> Generator<Date, Shrink.Integer<Int>> {
152+
let actualRange = ClosedRange(years: range)
153+
154+
guard let lower = DateComponents(calendar: .neutral, year: actualRange.lowerBound, month: 1, day: 1).date,
155+
let upper = DateComponents(calendar: .neutral, year: actualRange.upperBound, month: 12, day: 31).date else {
156+
fatalError("date(inYear:) got invalid year range: \(range)")
157+
}
158+
159+
return date(in: lower ... upper)
160+
}
161+
162+
/// A generator that creates dates without time components, in the UTC timezone.
163+
///
164+
/// Dates can be passed in directly, or written as string literals in ISO 8601 format:
165+
///
166+
/// ```swift
167+
/// Gen.date(in: "2024-01-01"...)
168+
/// ```
169+
/// - Parameter range: A range which contains all generated dates. Undefined bounds will be set to 60 years from the other bound.
170+
/// - Returns: A new generator.
171+
public static func date(in range: PartialRangeFrom<Date>) -> Generator<Date, Shrink.Integer<Int>> {
172+
let upper = Calendar.neutral.date(byAdding: .year, value: yearWindow, to: range.lowerBound)!
173+
return date(in: range.lowerBound ... upper)
174+
}
175+
176+
/// A generator that creates dates without time components, in the UTC timezone.
177+
///
178+
/// Dates can be passed in directly, or written as string literals in ISO 8601 format:
179+
///
180+
/// ```swift
181+
/// Gen.date(in: ..<"2024-01-01")
182+
/// ```
183+
/// - Parameter range: A range which contains all generated dates. Undefined bounds will be set to 60 years from the other bound.
184+
/// - Returns: A new generator.
185+
public static func date(in range: PartialRangeUpTo<Date>) -> Generator<Date, Shrink.Integer<Int>> {
186+
let newUpper = range.upperBound.timeIntervalSinceReferenceDate - secondsPerDay
187+
return date(in: ...Date(timeIntervalSinceReferenceDate: newUpper))
188+
}
189+
190+
/// A generator that creates dates without time components, in the UTC timezone.
191+
///
192+
/// Dates can be passed in directly, or written as string literals in ISO 8601 format:
193+
///
194+
/// ```swift
195+
/// Gen.date(in: ..."2024-01-01")
196+
/// ```
197+
/// - Parameter range: A range which contains all generated dates. Undefined bounds will be set to 60 years from the other bound.
198+
/// - Returns: A new generator.
199+
public static func date(in range: PartialRangeThrough<Date>) -> Generator<Date, Shrink.Integer<Int>> {
200+
let lower = Calendar.neutral.date(byAdding: .year, value: -yearWindow, to: range.upperBound)!
201+
return date(in: lower ... range.upperBound)
202+
}
203+
204+
/// A generator that creates dates without time components, in the UTC timezone.
205+
///
206+
/// Dates can be passed in directly, or written as string literals in ISO 8601 format:
207+
///
208+
/// ```swift
209+
/// Gen.date(in: "2024-01-01" ..< "2025-01-01")
210+
/// ```
211+
/// - Parameter range: A range which contains all generated dates. Undefined bounds will be set to 60 years from the other bound.
212+
/// - Returns: A new generator.
213+
public static func date(in range: Range<Date>) -> Generator<Date, Shrink.Integer<Int>> {
214+
let newUpper = range.upperBound.timeIntervalSinceReferenceDate - secondsPerDay
215+
return date(in: range.lowerBound ... Date(timeIntervalSinceReferenceDate: newUpper))
216+
}
217+
218+
/// A generator that creates dates without time components, in the UTC timezone.
219+
///
220+
/// Dates can be passed in directly, or written as string literals in ISO 8601 format:
221+
///
222+
/// ```swift
223+
/// Gen.date(in: "2024-01-01" ... "2025-12-31")
224+
/// ```
225+
/// - Parameter range: A range which contains all generated dates. Undefined bounds will be set to 60 years from the other bound.
226+
/// - Returns: A new generator.
227+
public static func date(in range: ClosedRange<Date>) -> Generator<Date, Shrink.Integer<Int>> {
228+
let interval = Int(range.lowerBound.timeIntervalSinceReferenceDate / secondsPerDay) ... Int(range.upperBound.timeIntervalSinceReferenceDate / secondsPerDay)
229+
let end = Int(Date().timeIntervalSinceReferenceDate / secondsPerDay)
230+
231+
return .init(
232+
run: { rng in Int.random(in: interval, using: &rng) },
233+
shrink: { $0.shrink(within: interval, towards: end) },
234+
finalResult: { Date(timeIntervalSinceReferenceDate: TimeInterval($0) * secondsPerDay) }
235+
)
236+
}
237+
}
238+
239+
extension ClosedRange where Bound == Int {
240+
@usableFromInline init(years range: some RangeExpression<Bound>) {
241+
let fullRange = ClosedRange(range)
242+
243+
switch(fullRange.lowerBound, fullRange.upperBound) {
244+
case (.min, .max):
245+
self = defaultYearRange
246+
case (.min, _):
247+
self = fullRange.upperBound - yearWindow ... fullRange.upperBound
248+
case (_, .max):
249+
self = fullRange.lowerBound ... fullRange.lowerBound + yearWindow
250+
default:
251+
self = fullRange
252+
}
253+
}
254+
}
255+
256+
#endif
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
//
2+
// Shrink+Date.swift
3+
// PropertyBased
4+
//
5+
// Created by Lennard Sprong on 30/05/2025.
6+
//
7+
8+
#if canImport(Foundation)
9+
import Foundation
10+
11+
extension Shrink {
12+
/// Shrink a TimeInterval by rounding to seconds, then minutes, then 15 minute intervals.
13+
public struct TruncateTime: Sequence, IteratorProtocol, Sendable, BitwiseCopyable {
14+
public init(_ value: TimeInterval, roundUp: Bool) {
15+
self.value = value
16+
self.roundUp = roundUp
17+
}
18+
19+
public var value: TimeInterval
20+
public let roundUp: Bool
21+
22+
public mutating func next() -> TimeInterval? {
23+
let rounding: FloatingPointRoundingRule = roundUp ? .up : .down
24+
25+
let withoutMillis = value.rounded(rounding)
26+
if withoutMillis != value {
27+
value = withoutMillis
28+
return withoutMillis
29+
}
30+
31+
let withoutSeconds = (value / 60).rounded(rounding) * 60
32+
if withoutSeconds != value {
33+
value = withoutSeconds
34+
return withoutSeconds
35+
}
36+
37+
let quarter = (value / (60*15)).rounded(rounding) * (60 * 15)
38+
if quarter != value {
39+
value = quarter
40+
return quarter
41+
}
42+
43+
return nil
44+
}
45+
}
46+
}
47+
48+
#endif

0 commit comments

Comments
 (0)