diff --git a/src/Repeat.php b/src/Repeat.php index 11350be..e05a5bb 100644 --- a/src/Repeat.php +++ b/src/Repeat.php @@ -23,10 +23,13 @@ { /** * @param int<1, max> $times Number of times to repeat the test. + * @param int<1, max> $failureThreshold Number of failures that should fail the repeated execution. */ public function __construct( public int $times = 2, + public int $failureThreshold = 1, ) { $times > 0 or throw new \InvalidArgumentException('Times must be greater than 0.'); + $failureThreshold > 0 or throw new \InvalidArgumentException('Failure threshold must be greater than 0.'); } } diff --git a/src/Repeat/Internal/RepeatInterceptor.php b/src/Repeat/Internal/RepeatInterceptor.php index 6275520..4545f8d 100644 --- a/src/Repeat/Internal/RepeatInterceptor.php +++ b/src/Repeat/Internal/RepeatInterceptor.php @@ -9,6 +9,7 @@ use Testo\Pipeline\Attribute\InterceptorOptions; use Testo\Pipeline\Middleware\TestRunInterceptor; use Testo\Pipeline\Policy\ConflictPolicy; +use Testo\Core\Value\Status; use Testo\Repeat; /** @@ -29,10 +30,34 @@ public function __construct( public function runTest(TestInfo $info, callable $next): TestResult { $times = $this->options->times; + $failureThreshold = $this->options->failureThreshold; + $failures = 0; + $result = null; \assert($times > 0); - do { + \assert($failureThreshold > 0); + + while ($times-- > 0) { $result = $next($info); - } while (--$times > 0 && !$result->status->isFailure() && $result->status->isCompleted()); + if (!$result->status->isCompleted()) { + return $result; + } + + if (!$result->status->isFailure()) { + continue; + } + + if (++$failures >= $failureThreshold) { + return $result; + } + } + + \assert($result instanceof TestResult); + + if ($failures > 0) { + return $result + ->with(status: Status::Passed) + ->withFailure(null); + } return $result; } diff --git a/tests/Repeat/Feature/RepeatFeatureTest.php b/tests/Repeat/Feature/RepeatFeatureTest.php index 396b16c..aa557fd 100644 --- a/tests/Repeat/Feature/RepeatFeatureTest.php +++ b/tests/Repeat/Feature/RepeatFeatureTest.php @@ -40,6 +40,14 @@ public function repeatOncePasses(): void Assert::same($result->status, Status::Passed); } + #[Test] + public function repeatWithToleratedFailuresPasses(): void + { + $result = TestRunner::runTest([RepeatPassingStub::class, 'repeatWithToleratedFailures']); + + Assert::same($result->status, Status::Passed); + } + #[Test] public function failsOnSecondIteration(): void { @@ -56,6 +64,14 @@ public function failsImmediately(): void Assert::same($result->status, Status::Failed); } + #[Test] + public function reachesFailureThresholdFails(): void + { + $result = TestRunner::runTest([RepeatFailingStub::class, 'reachesFailureThreshold']); + + Assert::same($result->status, Status::Failed); + } + #[Test] public function classLevelRepeatFirstTest(): void { diff --git a/tests/Repeat/Stub/RepeatFailingStub.php b/tests/Repeat/Stub/RepeatFailingStub.php index e534bf7..4b90dc5 100644 --- a/tests/Repeat/Stub/RepeatFailingStub.php +++ b/tests/Repeat/Stub/RepeatFailingStub.php @@ -13,6 +13,8 @@ */ final class RepeatFailingStub { + private int $reachesFailureThresholdCounter = 0; + /** * Passes first time, fails on second iteration. */ @@ -35,4 +37,18 @@ public function failsImmediately(): void { Assert::same(1, 2); } + + /** + * Fails twice and reaches the failure threshold. + */ + #[Test] + #[Repeat(times: 4, failureThreshold: 2)] + public function reachesFailureThreshold(): void + { + ++$this->reachesFailureThresholdCounter; + + if ($this->reachesFailureThresholdCounter % 2 === 0) { + Assert::fail('Simulated failure for threshold testing.'); + } + } } diff --git a/tests/Repeat/Stub/RepeatPassingStub.php b/tests/Repeat/Stub/RepeatPassingStub.php index d8dbc5c..f6f06be 100644 --- a/tests/Repeat/Stub/RepeatPassingStub.php +++ b/tests/Repeat/Stub/RepeatPassingStub.php @@ -13,6 +13,8 @@ */ final class RepeatPassingStub { + private int $repeatWithToleratedFailuresCounter = 0; + /** * Default repeat (2 times), all pass. */ @@ -42,4 +44,18 @@ public function repeatOnce(): void { Assert::true(true); } + + /** + * Fails occasionally but stays below failure threshold. + */ + #[Test] + #[Repeat(times: 4, failureThreshold: 3)] + public function repeatWithToleratedFailures(): void + { + ++$this->repeatWithToleratedFailuresCounter; + + if ($this->repeatWithToleratedFailuresCounter % 2 === 0) { + Assert::fail('Simulated failure for threshold testing.'); + } + } } diff --git a/tests/Repeat/Unit/RepeatInterceptorTest.php b/tests/Repeat/Unit/RepeatInterceptorTest.php index 88f34e9..03f525c 100644 --- a/tests/Repeat/Unit/RepeatInterceptorTest.php +++ b/tests/Repeat/Unit/RepeatInterceptorTest.php @@ -115,6 +115,59 @@ public function stopsOnFailureMidway(): void Assert::same($result->status, Status::Failed); } + public function repeatsUntilFailureThresholdIsReached(): void + { + // Arrange + $interceptor = new RepeatInterceptor(new Repeat(times: 5, failureThreshold: 3)); + $info = self::createTestInfo(); + $callCount = 0; + $next = static function (TestInfo $info) use (&$callCount): TestResult { + $callCount++; + return match ($callCount) { + 2, 4, 5 => new TestResult( + info: $info, + status: Status::Failed, + failure: new \RuntimeException('Simulated failure for threshold testing.'), + ), + default => new TestResult(info: $info, status: Status::Passed), + }; + }; + + // Act + $result = $interceptor->runTest($info, $next); + + // Assert + Assert::same($callCount, 5); + Assert::same($result->status, Status::Failed); + } + + public function failuresBelowThresholdPassAfterAllIterations(): void + { + // Arrange + $interceptor = new RepeatInterceptor(new Repeat(times: 4, failureThreshold: 3)); + $info = self::createTestInfo(); + $callCount = 0; + $next = static function (TestInfo $info) use (&$callCount): TestResult { + $callCount++; + return match ($callCount) { + 2, 4 => new TestResult( + info: $info, + status: Status::Failed, + failure: new \RuntimeException('Simulated failure for threshold testing.'), + ), + default => new TestResult(info: $info, status: Status::Passed), + }; + }; + + // Act + $result = $interceptor->runTest($info, $next); + + // Assert + Assert::same($callCount, 4); + Assert::same($result->status, Status::Passed); + Assert::null($result->failure); + } + /** * Verifies repeat behavior per status: failure statuses stop the loop, others continue. */ diff --git a/tests/Repeat/Unit/RepeatTest.php b/tests/Repeat/Unit/RepeatTest.php index 39ec7bd..b055cdf 100644 --- a/tests/Repeat/Unit/RepeatTest.php +++ b/tests/Repeat/Unit/RepeatTest.php @@ -19,6 +19,7 @@ public function defaultTimesIsTwo(): void // Assert Assert::same($repeat->times, 2); + Assert::same($repeat->failureThreshold, 1); } public function customTimes(): void @@ -39,6 +40,25 @@ public function timesOneIsValid(): void Assert::same($repeat->times, 1); } + public function customFailureThreshold(): void + { + // Act + $repeat = new Repeat(times: 5, failureThreshold: 3); + + // Assert + Assert::same($repeat->failureThreshold, 3); + } + + public function positionalConstructorArguments(): void + { + // Act + $repeat = new Repeat(10, 3); + + // Assert + Assert::same($repeat->times, 10); + Assert::same($repeat->failureThreshold, 3); + } + #[ExpectException(\InvalidArgumentException::class)] public function zeroTimesThrowsException(): void { @@ -50,4 +70,16 @@ public function negativeTimesThrowsException(): void { new Repeat(times: -1); } + + #[ExpectException(\InvalidArgumentException::class)] + public function zeroFailureThresholdThrowsException(): void + { + new Repeat(failureThreshold: 0); + } + + #[ExpectException(\InvalidArgumentException::class)] + public function negativeFailureThresholdThrowsException(): void + { + new Repeat(failureThreshold: -1); + } }