From f55022a1c5b1bf5b9d65b8a4084e1547e32ab17f Mon Sep 17 00:00:00 2001 From: Govind Yadav Date: Tue, 13 Oct 2026 16:33:01 +0530 Subject: [PATCH] Fix expression caching within polling attempts --- .../Nimble.docc/Guides/PollingExpectations.md | 5 +++ Sources/Nimble/Polling+AsyncAwait.swift | 24 +++++++------- Sources/Nimble/Polling+Require.swift | 24 +++++++------- Sources/Nimble/Polling.swift | 2 +- .../NimbleTests/AsyncAwaitTest+Require.swift | 11 +++++++ Tests/NimbleTests/AsyncAwaitTest.swift | 32 ++++++++++++++++++ Tests/NimbleTests/PollingTest.swift | 33 +++++++++++++++++++ 7 files changed, 106 insertions(+), 25 deletions(-) diff --git a/Sources/Nimble/Nimble.docc/Guides/PollingExpectations.md b/Sources/Nimble/Nimble.docc/Guides/PollingExpectations.md index ee36ad5f8..b5cb6fffc 100644 --- a/Sources/Nimble/Nimble.docc/Guides/PollingExpectations.md +++ b/Sources/Nimble/Nimble.docc/Guides/PollingExpectations.md @@ -4,6 +4,11 @@ In Nimble, it's easy to make expectations on values that are updated asynchronously. These are called Polling Expectations, because they work by continuously polling the Expectation. +The expression is evaluated again for each polling attempt. Within an attempt, +the matcher reuses the same evaluated value, including when it builds a failure +message. This keeps the result and its diagnostic consistent without preventing +later attempts from observing changes. + ## Forms of Polling Expectations There are 4 forms of polling expectations: `toEventually`, diff --git a/Sources/Nimble/Polling+AsyncAwait.swift b/Sources/Nimble/Polling+AsyncAwait.swift index 5123fd0e3..b98cd926d 100644 --- a/Sources/Nimble/Polling+AsyncAwait.swift +++ b/Sources/Nimble/Polling+AsyncAwait.swift @@ -106,7 +106,7 @@ extension SyncExpectation { timeout: timeout, poll: pollInterval, fnName: "toEventually") { @MainActor in - try matcher.satisfies(expression.withoutCaching()) + try matcher.satisfies(expression.withoutCaching().withCaching()) } } return verify(pass, msg) @@ -132,7 +132,7 @@ extension SyncExpectation { timeout: timeout, poll: pollInterval, fnName: "toEventuallyNot") { @MainActor in - try matcher.satisfies(expression.withoutCaching()) + try matcher.satisfies(expression.withoutCaching().withCaching()) } } return verify(pass, msg) @@ -166,7 +166,7 @@ extension SyncExpectation { timeout: until, poll: pollInterval, fnName: "toNever") { @MainActor in - try matcher.satisfies(expression.withoutCaching()) + try matcher.satisfies(expression.withoutCaching().withCaching()) } } return verify(pass, msg) @@ -200,7 +200,7 @@ extension SyncExpectation { timeout: until, poll: pollInterval, fnName: "toAlways") { @MainActor in - try matcher.satisfies(expression.withoutCaching()) + try matcher.satisfies(expression.withoutCaching().withCaching()) } } return verify(pass, msg) @@ -236,7 +236,7 @@ extension SyncExpectation { timeout: timeout, poll: pollInterval, fnName: "toEventually") { @MainActor in - try await matcher.satisfies(expression.withoutCaching().toAsyncExpression()) + try await matcher.satisfies(expression.withoutCaching().withCaching().toAsyncExpression()) } } return verify(pass, msg) @@ -262,7 +262,7 @@ extension SyncExpectation { timeout: timeout, poll: pollInterval, fnName: "toEventuallyNot") { @MainActor in - try await matcher.satisfies(expression.withoutCaching().toAsyncExpression()) + try await matcher.satisfies(expression.withoutCaching().withCaching().toAsyncExpression()) } } return verify(pass, msg) @@ -296,7 +296,7 @@ extension SyncExpectation { timeout: until, poll: pollInterval, fnName: "toNever") { @MainActor in - try await matcher.satisfies(expression.withoutCaching().toAsyncExpression()) + try await matcher.satisfies(expression.withoutCaching().withCaching().toAsyncExpression()) } } return verify(pass, msg) @@ -330,7 +330,7 @@ extension SyncExpectation { timeout: until, poll: pollInterval, fnName: "toAlways") { @MainActor in - try await matcher.satisfies(expression.withoutCaching().toAsyncExpression()) + try await matcher.satisfies(expression.withoutCaching().withCaching().toAsyncExpression()) } } return verify(pass, msg) @@ -490,7 +490,7 @@ extension AsyncExpectation { timeout: timeout, poll: pollInterval, fnName: "toEventually") { - try await matcher.satisfies(expression.withoutCaching()) + try await matcher.satisfies(expression.withoutCaching().withCaching()) } } return verify(pass, msg) @@ -514,7 +514,7 @@ extension AsyncExpectation { timeout: timeout, poll: pollInterval, fnName: "toEventuallyNot") { - try await matcher.satisfies(expression.withoutCaching()) + try await matcher.satisfies(expression.withoutCaching().withCaching()) } } return verify(pass, msg) @@ -547,7 +547,7 @@ extension AsyncExpectation { timeout: until, poll: pollInterval, fnName: "toNever") { - try await matcher.satisfies(expression.withoutCaching()) + try await matcher.satisfies(expression.withoutCaching().withCaching()) } } return verify(pass, msg) @@ -580,7 +580,7 @@ extension AsyncExpectation { timeout: until, poll: pollInterval, fnName: "toAlways") { - try await matcher.satisfies(expression.withoutCaching()) + try await matcher.satisfies(expression.withoutCaching().withCaching()) } } return verify(pass, msg) diff --git a/Sources/Nimble/Polling+Require.swift b/Sources/Nimble/Polling+Require.swift index 673ce70b9..260539c4c 100644 --- a/Sources/Nimble/Polling+Require.swift +++ b/Sources/Nimble/Polling+Require.swift @@ -211,7 +211,7 @@ extension SyncRequirement { timeout: timeout, poll: pollInterval, fnName: "toEventually") { @MainActor in - try matcher.satisfies(expression.withoutCaching()) + try matcher.satisfies(expression.withoutCaching().withCaching()) } } return try verify(pass, msg, try await asyncExpression.evaluate()) @@ -237,7 +237,7 @@ extension SyncRequirement { timeout: timeout, poll: pollInterval, fnName: "toEventuallyNot") { @MainActor in - try matcher.satisfies(expression.withoutCaching()) + try matcher.satisfies(expression.withoutCaching().withCaching()) } } return try verify(pass, msg, try await asyncExpression.evaluate()) @@ -271,7 +271,7 @@ extension SyncRequirement { timeout: until, poll: pollInterval, fnName: "toNever") { @MainActor in - try matcher.satisfies(expression.withoutCaching()) + try matcher.satisfies(expression.withoutCaching().withCaching()) } } return try verify(pass, msg, try await asyncExpression.evaluate()) @@ -305,7 +305,7 @@ extension SyncRequirement { timeout: until, poll: pollInterval, fnName: "toAlways") { @MainActor in - try matcher.satisfies(expression.withoutCaching()) + try matcher.satisfies(expression.withoutCaching().withCaching()) } } return try verify(pass, msg, try await asyncExpression.evaluate()) @@ -341,7 +341,7 @@ extension SyncRequirement { timeout: timeout, poll: pollInterval, fnName: "toEventually") { @MainActor in - try await matcher.satisfies(expression.withoutCaching().toAsyncExpression()) + try await matcher.satisfies(expression.withoutCaching().withCaching().toAsyncExpression()) } } return try verify(pass, msg, try await asyncExpression.evaluate()) @@ -367,7 +367,7 @@ extension SyncRequirement { timeout: timeout, poll: pollInterval, fnName: "toEventuallyNot") { @MainActor in - try await matcher.satisfies(expression.withoutCaching().toAsyncExpression()) + try await matcher.satisfies(expression.withoutCaching().withCaching().toAsyncExpression()) } } return try verify(pass, msg, try await asyncExpression.evaluate()) @@ -401,7 +401,7 @@ extension SyncRequirement { timeout: until, poll: pollInterval, fnName: "toNever") { @MainActor in - try await matcher.satisfies(expression.withoutCaching().toAsyncExpression()) + try await matcher.satisfies(expression.withoutCaching().withCaching().toAsyncExpression()) } } return try verify(pass, msg, try await asyncExpression.evaluate()) @@ -435,7 +435,7 @@ extension SyncRequirement { timeout: until, poll: pollInterval, fnName: "toAlways") { @MainActor in - try await matcher.satisfies(expression.withoutCaching().toAsyncExpression()) + try await matcher.satisfies(expression.withoutCaching().withCaching().toAsyncExpression()) } } return try verify(pass, msg, try await asyncExpression.evaluate()) @@ -595,7 +595,7 @@ extension AsyncRequirement { timeout: timeout, poll: pollInterval, fnName: "toEventually") { - try await matcher.satisfies(expression.withoutCaching()) + try await matcher.satisfies(expression.withoutCaching().withCaching()) } } return try verify(pass, msg, try await expression.evaluate()) @@ -619,7 +619,7 @@ extension AsyncRequirement { timeout: timeout, poll: pollInterval, fnName: "toEventuallyNot") { - try await matcher.satisfies(expression.withoutCaching()) + try await matcher.satisfies(expression.withoutCaching().withCaching()) } } return try verify(pass, msg, try await expression.evaluate()) @@ -652,7 +652,7 @@ extension AsyncRequirement { timeout: until, poll: pollInterval, fnName: "toNever") { - try await matcher.satisfies(expression.withoutCaching()) + try await matcher.satisfies(expression.withoutCaching().withCaching()) } } return try verify(pass, msg, try await expression.evaluate()) @@ -685,7 +685,7 @@ extension AsyncRequirement { timeout: until, poll: pollInterval, fnName: "toAlways") { - try await matcher.satisfies(expression.withoutCaching()) + try await matcher.satisfies(expression.withoutCaching().withCaching()) } } return try verify(pass, msg, try await expression.evaluate()) diff --git a/Sources/Nimble/Polling.swift b/Sources/Nimble/Polling.swift index 19b12ea0c..82ccdfca9 100644 --- a/Sources/Nimble/Polling.swift +++ b/Sources/Nimble/Polling.swift @@ -98,7 +98,7 @@ internal func poll( sourceLocation: actualExpression.location, fnName: fnName, isContinuous: matchStyle.isContinuous) { - lastMatcherResult = try matcher.satisfies(uncachedExpression) + lastMatcherResult = try matcher.satisfies(uncachedExpression.withCaching()) if lastMatcherResult!.toBoolean(expectation: style) { if matchStyle.isContinuous { return .incomplete diff --git a/Tests/NimbleTests/AsyncAwaitTest+Require.swift b/Tests/NimbleTests/AsyncAwaitTest+Require.swift index f6dfd712d..64d0b6e24 100644 --- a/Tests/NimbleTests/AsyncAwaitTest+Require.swift +++ b/Tests/NimbleTests/AsyncAwaitTest+Require.swift @@ -7,6 +7,17 @@ import NimbleSharedTestHelpers #endif final class AsyncAwaitRequireTest: XCTestCase { // swiftlint:disable:this type_body_length + @MainActor + func testRequireReturnsValueFromCachedPollingAttempt() async throws { + var evaluations = 0 + let value = try await require { + evaluations += 1 + return evaluations % 3 != 1 + }.toEventually(beTrue(), timeout: .milliseconds(100), pollInterval: .milliseconds(1)) + XCTAssertTrue(value) + XCTAssertEqual(evaluations, 2) + } + func testToPositiveMatches() async throws { func someAsyncFunction() async throws -> Int { try await Task.sleep(nanoseconds: 1_000_000) // 1 millisecond diff --git a/Tests/NimbleTests/AsyncAwaitTest.swift b/Tests/NimbleTests/AsyncAwaitTest.swift index c3e94d72d..b9500f7a1 100644 --- a/Tests/NimbleTests/AsyncAwaitTest.swift +++ b/Tests/NimbleTests/AsyncAwaitTest.swift @@ -7,6 +7,38 @@ import NimbleSharedTestHelpers #endif final class AsyncAwaitTest: XCTestCase { // swiftlint:disable:this type_body_length + @MainActor + func testSyncExpressionIsCachedWithinAsyncPollingAttempt() async { + var evaluations = 0 + await expect { + evaluations += 1 + return evaluations % 3 != 1 + }.toEventually(beTrue(), timeout: .milliseconds(100), pollInterval: .milliseconds(1)) + XCTAssertEqual(evaluations, 2) + } + + func testAsyncMatcherCachesEachPollingAttempt() async { + actor Counter { + var evaluations = 0 + func next() -> Int { + evaluations += 1 + return evaluations + } + } + let counter = Counter() + let matcher = AsyncMatcher { expression in + let first = try await expression.evaluate() + let second = try await expression.evaluate() + XCTAssertEqual(first, second) + return MatcherResult(bool: first == 2, message: .expectedActualValueTo("equal 2")) + } + await expect { await counter.next() }.toEventually( + matcher, timeout: .milliseconds(100), pollInterval: .milliseconds(1) + ) + let evaluations = await counter.evaluations + XCTAssertEqual(evaluations, 2) + } + func testToPositiveMatches() async { func someAsyncFunction() async throws -> Int { try await Task.sleep(nanoseconds: 1_000_000) // 1 millisecond diff --git a/Tests/NimbleTests/PollingTest.swift b/Tests/NimbleTests/PollingTest.swift index 932363937..82f1dc6f1 100644 --- a/Tests/NimbleTests/PollingTest.swift +++ b/Tests/NimbleTests/PollingTest.swift @@ -29,6 +29,39 @@ final class PollingTest: XCTestCase { expect { value }.toEventuallyNot(equal(1)) } + func testPollingMatcherEvaluatesExpressionOncePerAttempt() { + var evaluations = 0 + expect { + evaluations += 1 + return evaluations % 3 != 1 + }.toEventually( + beTrue(), timeout: .milliseconds(100), pollInterval: .milliseconds(1) + ) + XCTAssertEqual(evaluations, 2) + } + + func testContinuousPollingCachesEachAttempt() { + let matcher = Matcher { expression in + let first = try expression.evaluate() + let second = try expression.evaluate() + XCTAssertEqual(first, second) + return MatcherResult(bool: true, message: .expectedActualValueTo("match")) + } + var evaluations = 0 + expect { + evaluations += 1 + return evaluations + }.toAlways(matcher, until: .milliseconds(20), pollInterval: .milliseconds(1)) + XCTAssertGreaterThan(evaluations, 1) + + evaluations = 0 + expect { + evaluations += 1 + return evaluations + }.toNever(not(matcher), until: .milliseconds(20), pollInterval: .milliseconds(1)) + XCTAssertGreaterThan(evaluations, 1) + } + func testToEventuallyNegativeMatches() { let value = 0 failsWithErrorMessage("expected to eventually not equal <0>, got <0>") {