Skip to content

Commit edaffed

Browse files
authored
Add support for handling Issues with lower severity (#21)
1 parent 547479c commit edaffed

5 files changed

Lines changed: 190 additions & 46 deletions

File tree

Sources/PropertyBased/FixedSeedTrait.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ public struct FixedSeedTrait: TestTrait, TestScoping {
4141
}
4242
}
4343

44-
if foundIssues == 0 {
44+
if foundIssues.errors == 0 {
4545
Issue.record(
4646
"A fixed seed was used, but no expectation failure occured. The property was not tested fully.",
4747
sourceLocation: sourceLocation)

Sources/PropertyBased/IssueCounting.swift

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,34 @@
77

88
import Testing
99

10+
struct CountResult {
11+
var errors: Int = 0
12+
var warnings: Int = 0
13+
}
14+
1015
func countIssues(isolation: isolated (any Actor)? = #isolation, suppress: Bool, perform: () async throws -> Void) async
11-
-> Int
16+
-> CountResult
1217
{
13-
let found = Mutex(0)
18+
let found = Mutex(CountResult())
1419

1520
#if swift(>=6.2)
1621
nonisolated(unsafe) let closure = perform
1722

18-
let handler = IssueHandlingTrait.filterIssues { _ in
19-
found.withLock { $0 += 1 }
20-
return !suppress
23+
let handler = IssueHandlingTrait.compactMapIssues { input in
24+
var issue = input
25+
if FixedSeedTrait.fixedRandom != nil {
26+
issue.isWarning = false
27+
}
28+
29+
found.withLock {
30+
if issue.isWarning {
31+
$0.warnings += 1
32+
} else {
33+
$0.errors += 1
34+
}
35+
}
36+
guard !suppress else { return nil }
37+
return issue
2138
}
2239

2340
try? await handler.provideScope(for: Test.current!, testCase: Test.Case.current) {
@@ -27,14 +44,30 @@ func countIssues(isolation: isolated (any Actor)? = #isolation, suppress: Bool,
2744
try? await withKnownIssue(isIntermittent: true, isolation: isolation) {
2845
try await perform()
2946
} matching: { _ in
30-
found.withLock { $0 += 1 }
47+
found.withLock { $0.errors += 1 }
3148
return suppress
3249
}
3350
#endif
3451

3552
return found.withLock { $0 }
3653
}
3754

55+
extension Issue {
56+
#if swift(>=6.3)
57+
var isWarning: Bool {
58+
get { severity == .warning }
59+
set {
60+
severity = newValue ? .warning : .error
61+
}
62+
}
63+
#else
64+
var isWarning: Bool {
65+
get { false }
66+
set {}
67+
}
68+
#endif
69+
}
70+
3871
@inlinable
3972
func run(_ closure: () async throws -> Void, in isolation: isolated (any Actor)?) async throws {
4073
try await closure()

Sources/PropertyBased/PropertyCheck.swift

Lines changed: 64 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ public func propertyCheck<InputValue, ResultValue>(
115115
guard count > 0 else { return }
116116

117117
let fixedRng = FixedSeedTrait.fixedRandom
118+
var rngWithIssues: (rng: Xoshiro, value: InputValue, isError: Bool)?
118119

119120
let actualCount = fixedRng != nil ? 1 : count
120121

@@ -130,62 +131,88 @@ public func propertyCheck<InputValue, ResultValue>(
130131
try await body(resultValue)
131132
}
132133

133-
if foundIssues > 0 {
134-
let seed = rngCopy.traitHint
134+
if foundIssues.errors > 0 {
135+
rngWithIssues = (rngCopy, inputValue, isError: true)
136+
break
137+
} else if rngWithIssues == nil, foundIssues.warnings > 0 {
138+
rngWithIssues = (rngCopy, inputValue, isError: false)
139+
}
140+
}
135141

136-
var shrunkenInput = inputValue
142+
if let rngWithIssues {
143+
let seed = rngWithIssues.rng.traitHint
137144

138-
var didShrink = EnableShrinkTrait.isEnabled
139-
var shrinkCount = 0
140-
while didShrink {
141-
didShrink = false
142-
let candidates = input._shrinker(shrunkenInput)
145+
var shrunkenInput = rngWithIssues.value
146+
var isErrorLevel = rngWithIssues.isError
143147

144-
for c in candidates {
145-
guard !Task.isCancelled else { return }
148+
var didShrink = EnableShrinkTrait.isEnabled
149+
var shrinkCount = 0
150+
while didShrink {
151+
didShrink = false
152+
let candidates = input._shrinker(shrunkenInput)
146153

147-
guard let mappedShrunk = input._mapFilter(c) else { continue }
154+
for c in candidates {
155+
guard !Task.isCancelled else { return }
148156

149-
let shrunkIssues = await countIssues(isolation: isolation, suppress: true) {
150-
try await body(mappedShrunk)
151-
}
157+
guard let mappedShrunk = input._mapFilter(c) else { continue }
152158

153-
if shrunkIssues > 0 {
154-
didShrink = true
155-
shrinkCount += 1
156-
shrunkenInput = c
157-
break
158-
}
159+
let shrunkIssues = await countIssues(isolation: isolation, suppress: true) {
160+
try await body(mappedShrunk)
159161
}
160-
}
161162

162-
// If previous inputs were suppressed, run the block one more time to fully record all issues.
163-
if EnableShrinkTrait.isEnabled {
164-
_ = await countIssues(isolation: isolation, suppress: false) {
165-
try await body(input._mapFilter(shrunkenInput)!)
163+
if shrunkIssues.errors > 0 || (shrunkIssues.warnings > 0 && !isErrorLevel) {
164+
didShrink = true
165+
shrinkCount += 1
166+
shrunkenInput = c
167+
break
166168
}
167169
}
170+
}
171+
172+
// If previous inputs were suppressed, run the block one more time to fully record all issues.
173+
if EnableShrinkTrait.isEnabled {
174+
let finalCount = await countIssues(isolation: isolation, suppress: false) {
175+
try await body(input._mapFilter(shrunkenInput)!)
176+
}
177+
if finalCount.errors > 0 {
178+
isErrorLevel = true
179+
}
180+
}
168181

169-
let originalParamLabel = String(describingForTest: resultValue)
182+
let resultValue = input._mapFilter(rngWithIssues.value)!
183+
let originalParamLabel = String(describingForTest: resultValue)
170184

171-
let failureMessage: String
185+
let failureMessage: String
172186

173-
if shrinkCount == 0 {
174-
failureMessage = "Failure occured with input \(originalParamLabel)."
175-
} else {
176-
let shrunkParamLabel = String(describingForTest: input._mapFilter(shrunkenInput)!)
177-
failureMessage =
178-
"Failure occured with input \(shrunkParamLabel).\n(shrunk down from \(originalParamLabel) after \(shrinkCount) iteration\(shrinkCount != 1 ? "s" : ""))"
187+
let issueTypeLabel = isErrorLevel ? "Failure" : "Warning"
188+
189+
if shrinkCount == 0 {
190+
failureMessage = "\(issueTypeLabel) occured with input \(originalParamLabel)."
191+
} else {
192+
let shrunkParamLabel = String(describingForTest: input._mapFilter(shrunkenInput)!)
193+
failureMessage =
194+
"\(issueTypeLabel) occured with input \(shrunkParamLabel).\n(shrunk down from \(originalParamLabel) after \(shrinkCount) iteration\(shrinkCount != 1 ? "s" : ""))"
195+
}
196+
197+
if fixedRng == nil {
198+
if isErrorLevel {
199+
Issue.record(
200+
"\(failureMessage)\n\nAdd `.fixedSeed\(seed)` to the Test to reproduce this issue.",
201+
sourceLocation: sourceLocation)
179202
}
180203

181-
if fixedRng == nil {
204+
#if swift(>=6.3)
205+
if !isErrorLevel {
182206
Issue.record(
183207
"\(failureMessage)\n\nAdd `.fixedSeed\(seed)` to the Test to reproduce this issue.",
208+
severity: .warning,
184209
sourceLocation: sourceLocation)
185-
} else {
186-
Issue.record("\(failureMessage)", sourceLocation: sourceLocation)
187210
}
188-
return
211+
#endif
212+
213+
} else {
214+
Issue.record("\(failureMessage)", sourceLocation: sourceLocation)
189215
}
216+
return
190217
}
191218
}

Tests/PropertyBasedTests/ActorTests.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import Testing
2424
#expect(i < 500)
2525
}
2626
}
27-
#expect(issues > 0)
27+
#expect(issues.errors > 0)
2828
}
2929

3030
@OtherActor @Test func testOnOtherActor() async {
@@ -35,6 +35,6 @@ import Testing
3535
#expect(i < 500)
3636
}
3737
}
38-
#expect(issues > 0)
38+
#expect(issues.errors > 0)
3939
}
4040
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
//
2+
// WarningTests.swift
3+
// PropertyBased
4+
//
5+
// Created by Lennard Sprong on 13/04/2026.
6+
//
7+
8+
#if swift(>=6.3)
9+
import Testing
10+
11+
@testable import PropertyBased
12+
13+
@Suite struct WarningTests {
14+
@Test func testWarningIssues() async {
15+
let count = await countIssues(suppress: true) {
16+
await propertyCheck(input: Gen.int(in: 0...100)) { i in
17+
if i > 10 {
18+
Issue.record("Above 10", severity: .warning)
19+
}
20+
}
21+
}
22+
23+
#expect(count.errors == 0)
24+
#expect(count.warnings > 0)
25+
26+
let texts = await gatherIssues {
27+
await propertyCheck(input: Gen.int(in: 0...100)) { i in
28+
if i > 10 {
29+
Issue.record("Above 10", severity: .warning)
30+
}
31+
}
32+
}
33+
34+
#expect(
35+
texts.contains(where: {
36+
$0.contains("Warning")
37+
}))
38+
}
39+
40+
@Test func testErrorSupersedesWarning() async {
41+
// Don't fail the rare case where the very first iteration is zero
42+
let mutex = Mutex(false)
43+
44+
let count = await countIssues(suppress: true) {
45+
await propertyCheck(input: Gen.int(in: 0...1_000_000)) { i in
46+
let wasAboveTen = mutex.withLock {
47+
if i > 10 {
48+
$0 = true
49+
}
50+
return $0
51+
}
52+
53+
if wasAboveTen {
54+
#expect(i > 0)
55+
}
56+
if i > 10 {
57+
Issue.record("Above 10", severity: .warning)
58+
}
59+
}
60+
}
61+
62+
#expect(count.errors > 0)
63+
#expect(count.warnings == 0)
64+
}
65+
66+
@Test func testFixedSeedUpgradesWarnings() async {
67+
let count = await countIssues(suppress: true) {
68+
await FixedSeedTrait.fixedSeed(
69+
16_227_743_274_320_082_944, 3_104_485_330_390_707_246, 16_893_853_035_950_354_083,
70+
9_960_939_931_137_502_728
71+
).provideScope(for: .current!, testCase: .current) {
72+
await propertyCheck(input: Gen.int(in: 0...100)) { i in
73+
if i > 10 {
74+
Issue.record("Above 10", severity: .warning)
75+
}
76+
}
77+
}
78+
}
79+
80+
#expect(count.errors > 0)
81+
#expect(count.warnings == 0)
82+
}
83+
}
84+
#endif

0 commit comments

Comments
 (0)