Skip to content

Commit f5b24d3

Browse files
authored
Add MaximumAttemptsTrait to control Generator behavior (#25)
Fixes #24
1 parent edaffed commit f5b24d3

15 files changed

Lines changed: 269 additions & 106 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ Property-Based Testing can be used as an alternative for (or in addition to) tes
3030

3131
Add the following line to the dependencies array in your `Package.swift` file:
3232
```swift
33-
.package(url: "https://github.com/x-sheep/swift-property-based.git", from: "1.0.0")
33+
.package(url: "https://github.com/x-sheep/swift-property-based.git", from: "2.0.0")
3434
```
3535

3636
Then add the following to your test target:

Sources/PropertyBased/Documentation.docc/Generator.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
### Testing a generator
1111

1212
- ``run(using:)``
13+
- ``run(using:limit:)``
1314

1415
### Grouping generated values
1516

Sources/PropertyBased/Gen+Collection.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,14 +79,14 @@ extension Generator {
7979
@inlinable
8080
public func array(of count: ClosedRange<Int>) -> Generator<[ResultValue], ArrayShrink> {
8181
return .init(
82-
run: { rng in
82+
run: { rng, limit in
8383
let itemCount = Int.random(in: count, using: &rng)
8484

8585
var collection: [InputValue] = []
8686

8787
collection.reserveCapacity(itemCount)
8888
for _ in 0..<itemCount {
89-
collection.append(self.runFull(&rng).input)
89+
collection.append(try self.runFull(&rng, limit).input)
9090
}
9191
return collection
9292
},

Sources/PropertyBased/Gen+Date.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ extension Gen where Value == Date {
123123
let end = Date().timeIntervalSinceReferenceDate
124124

125125
return .init(
126-
run: { rng in TimeInterval.random(in: interval, using: &rng) },
126+
run: { rng, _ in TimeInterval.random(in: interval, using: &rng) },
127127
shrink: {
128128
let seq =
129129
abs($0.distance(to: end)) > preferredDistanceFromNow
@@ -240,7 +240,7 @@ extension Gen where Value == Date {
240240
let end = Int(Date().timeIntervalSinceReferenceDate / secondsPerDay)
241241

242242
return .init(
243-
run: { rng in Int.random(in: interval, using: &rng) },
243+
run: { rng, _ in Int.random(in: interval, using: &rng) },
244244
shrink: { $0.shrink(within: interval, towards: end) },
245245
finalResult: { Date(timeIntervalSinceReferenceDate: TimeInterval($0) * secondsPerDay) }
246246
)

Sources/PropertyBased/Gen+Frequency.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,11 +83,11 @@ extension Gen {
8383
precondition(total > 0, "At least one generator with a weight above 0 must be specified.")
8484

8585
return Generator(
86-
run: { [total] rng in
86+
run: { [total] rng, limit in
8787
let pick = FloatLiteralType.random(in: 0..<total, using: &rng)
8888
let index = options.firstIndex { $0.limit > pick }! as Int
8989

90-
return (index: index, value: options[index].gen.runFull(&rng).input)
90+
return try (index: index, value: options[index].gen.runFull(&rng, limit).input)
9191
},
9292
shrink: { pair in
9393
let opt = options[pair.index]

Sources/PropertyBased/Generator.swift

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ public struct Generator<ResultValue, ShrinkSequence: SendableSequenceType>: Send
2323

2424
/// Generate a single result, before mapping or filtering.
2525
@usableFromInline
26-
internal var _runIntermediate: @Sendable (inout any SeededRandomNumberGenerator) -> sending InputValue
26+
internal var _runIntermediate: @Sendable (inout any SeededRandomNumberGenerator, Int) throws -> sending InputValue
2727

2828
/// Map an intermediate result to its final value, or return `nil` if the value should be filtered.
2929
@usableFromInline
@@ -33,22 +33,27 @@ public struct Generator<ResultValue, ShrinkSequence: SendableSequenceType>: Send
3333
internal var _shrinker: @Sendable (InputValue) -> ShrinkSequence
3434

3535
/// Run the generator until a single unfiltered value is found.
36-
@inlinable
37-
internal func runFull<G: SeededRandomNumberGenerator>(_ rng: inout G)
36+
@usableFromInline
37+
internal func runFull<G: SeededRandomNumberGenerator>(_ rng: inout G, _ limit: Int)
38+
throws
3839
-> sending (
3940
input: InputValue, result: ResultValue
4041
)
4142
{
4243
var arng: any SeededRandomNumberGenerator = rng
4344
defer { rng = arng as! G }
4445

45-
while true {
46-
let run = _runIntermediate(&arng)
46+
var attempts = 0
47+
48+
while attempts <= limit {
49+
let run = try _runIntermediate(&arng, limit)
4750

4851
if let ret = _mapFilter(run) {
4952
return (run, ret)
5053
}
54+
attempts += 1
5155
}
56+
throw GeneratorError.runLimitExceeded(limit)
5257
}
5358
}
5459

@@ -57,7 +62,22 @@ extension Generator {
5762
/// - Parameter rng: The random number generator to use.
5863
/// - Returns: A randomly generated value.
5964
public func run<G: SeededRandomNumberGenerator>(using rng: inout G) -> sending ResultValue {
60-
runFull(&rng).result
65+
try! runFull(&rng, Int.max).result
66+
}
67+
68+
/// Generate a single value within a certain amount of attempts.
69+
/// - Parameter rng: The random number generator to use.
70+
/// - Parameter limit: The maximum amount of attempts before the generator stops.
71+
/// - Returns: A randomly generated value.
72+
/// - Throws: When the limit is reached.
73+
public func run<G: SeededRandomNumberGenerator>(using rng: inout G, limit: Int)
74+
throws(GeneratorError) -> sending ResultValue
75+
{
76+
do {
77+
return try runFull(&rng, limit).result
78+
} catch {
79+
throw error as! GeneratorError
80+
}
6181
}
6282

6383
/// Remove the shrinker for this generator.
@@ -100,7 +120,7 @@ extension Generator where InputValue == ResultValue {
100120
run: @Sendable @escaping (inout any SeededRandomNumberGenerator) -> sending ResultValue,
101121
shrink: @Sendable @escaping (InputValue) -> sending ShrinkSequence,
102122
) {
103-
self._runIntermediate = run
123+
_runIntermediate = { rng, _ in run(&rng) }
104124
self._shrinker = shrink
105125
self._mapFilter = { $0 }
106126
}
@@ -109,7 +129,7 @@ extension Generator where InputValue == ResultValue {
109129
extension Generator {
110130
@inlinable
111131
internal init(
112-
run: @Sendable @escaping (inout any SeededRandomNumberGenerator) -> sending InputValue,
132+
run: @Sendable @escaping (inout any SeededRandomNumberGenerator, Int) throws -> sending InputValue,
113133
shrink: @Sendable @escaping (InputValue) -> ShrinkSequence,
114134
finalResult: @Sendable @escaping (InputValue) -> ResultValue?
115135
) {
@@ -128,7 +148,7 @@ extension Generator where ShrinkSequence == Shrink.None<ResultValue> {
128148
public init(
129149
run: @Sendable @escaping (inout any SeededRandomNumberGenerator) -> sending ResultValue
130150
) {
131-
_runIntermediate = run
151+
_runIntermediate = { rng, _ in run(&rng) }
132152
_shrinker = { _ in .init() }
133153
_mapFilter = { $0 }
134154
}
@@ -220,11 +240,11 @@ extension Generator {
220240
/// - Returns: A generator of optional values.
221241
public func optional(valueRate: Float = 0.75) -> Generator<ResultValue?, Shrink.WithNil<ShrinkSequence>> {
222242
return .init(
223-
run: { rng in
243+
run: { rng, limit in
224244
if Float.random(in: 0..<1, using: &rng) >= valueRate {
225245
return nil as InputValue?
226246
}
227-
return self._runIntermediate(&rng)
247+
return try self._runIntermediate(&rng, limit)
228248
},
229249
shrink: { value in
230250
if let value {
@@ -283,8 +303,8 @@ extension Generator {
283303
/// - Returns: A copy of this generator.
284304
@inlinable public func eraseToAny() -> Generator<ResultValue, AnySequence<Any>> {
285305
return .init(
286-
run: { rng in
287-
self._runIntermediate(&rng) as Any
306+
run: { rng, limit in
307+
try self._runIntermediate(&rng, limit) as Any
288308
},
289309
shrink: {
290310
AnySequence(_shrinker($0 as! InputValue).lazy.map { $0 as Any })
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
//
2+
// GeneratorError.swift
3+
// PropertyBased
4+
//
5+
// Created by Lennard Sprong on 13/08/2026.
6+
//
7+
8+
#if canImport(Foundation)
9+
import Foundation
10+
#endif
11+
12+
/// Errors that may be thrown by a generator.
13+
public enum GeneratorError: Equatable, Error, CustomStringConvertible {
14+
/// A generator failed to generate a valid value within the specified amount of attempts.
15+
case runLimitExceeded(Int)
16+
17+
public var description: String {
18+
switch self {
19+
case .runLimitExceeded(let count):
20+
"Failed to generate a valid input after \(count) attempts. Check if the Generator is filtering too many values."
21+
}
22+
}
23+
}
24+
25+
#if canImport(Foundation)
26+
extension GeneratorError: LocalizedError {
27+
public var localizedDescription: String { description }
28+
}
29+
#endif
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
//
2+
// MaximumAttemptsTrait.swift
3+
// PropertyBased
4+
//
5+
// Created by Lennard Sprong on 10/08/2026.
6+
//
7+
8+
import Testing
9+
10+
/// A trait that changes how often a generator can reject values before stopping.
11+
///
12+
/// Use ``maximumAttempts(_:)`` to construct an instance of this trait.
13+
public struct MaximumAttemptsTrait: TestTrait, SuiteTrait, TestScoping {
14+
@_documentation(visibility: internal)
15+
public var isRecursive: Bool { false }
16+
17+
public func provideScope(
18+
for test: Test, testCase: Test.Case?, performing function: @Sendable () async throws -> Void
19+
) async throws {
20+
try await Self.$_maxAttempts.withValue(value) {
21+
try await function()
22+
}
23+
}
24+
25+
@TaskLocal static var _maxAttempts: Int?
26+
27+
var value: Int
28+
}
29+
30+
extension Trait where Self == MaximumAttemptsTrait {
31+
/// Change how often a generator can run before stopping.
32+
/// - Parameter limit: The new limit.
33+
/// - Returns: An instance of ``MaximumAttemptsTrait``.
34+
/// - Precondition: Limit must have a positive value.
35+
public static func maximumAttempts(_ limit: Int) -> Self {
36+
precondition(limit >= 0, "Limit must have a positive value.")
37+
return Self(value: limit)
38+
}
39+
}

Sources/PropertyBased/PropertyCheck.swift

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,14 +118,32 @@ public func propertyCheck<InputValue, ResultValue>(
118118
var rngWithIssues: (rng: Xoshiro, value: InputValue, isError: Bool)?
119119

120120
let actualCount = fixedRng != nil ? 1 : count
121+
let runLimit = MaximumAttemptsTrait._maxAttempts
121122

122123
for _ in 0..<actualCount {
123124
guard !Task.isCancelled else { return }
124125

125126
var rng = fixedRng?.rng ?? Xoshiro()
126127
let rngCopy = rng
127128

128-
let (inputValue, resultValue) = input.runFull(&rng)
129+
let inputValue: InputValue
130+
let resultValue: ResultValue
131+
do {
132+
(inputValue, resultValue) = try input.runFull(&rng, runLimit ?? 10000)
133+
} catch {
134+
var failureMessage = String(describing: error)
135+
if runLimit == nil, let genError = error as? GeneratorError, case .runLimitExceeded = genError {
136+
failureMessage += "\n\nYou can add `.maximumAttempts()` to the Test or Suite to increase the limit."
137+
}
138+
139+
if fixedRng == nil {
140+
let seed = rngCopy.traitHint
141+
failureMessage += "\n\nAdd `.fixedSeed\(seed)` to the Test to reproduce this issue."
142+
}
143+
144+
Issue.record("\(failureMessage)", sourceLocation: sourceLocation)
145+
return
146+
}
129147

130148
let foundIssues = await countIssues(isolation: isolation, suppress: EnableShrinkTrait.isEnabled) {
131149
try await body(resultValue)

0 commit comments

Comments
 (0)